PageSourceSearch

https://philippdormann.de/techstack/

html philippdormann.de collected 2026-09-25 22:07:17 UTC 71,525 bytes, 881 lines download raw bytes

1<!doctype html>
2<html lang="de">
3	<head>
4		<meta charset="utf-8" />
5		<meta name="viewport" content="width=device-width, initial-scale=1" />
6		<meta name="theme-color" content="#0a0a0a" />
7		<meta name="color-scheme" content="light dark" />
8		<link rel="icon" href="/favicon.ico" />
9		<link rel="icon" href="/pd.svg" type="image/svg+xml" />
10		<link rel="apple-touch-icon" href="/pd.png" />
11		
11<script
12			data-domains="philippdormann.de"
13			async
14			defer
15			src="https://a.philippdormann.de/u.min.js?v=UMGQsk"
16			data-website-id="71b100ad-9e0d-487a-bead-9021594dbb01"
17		></script>
17
18		<link href="../_app/immutable/entry/start.CMFHRKFM.js" rel="modulepreload">
19		<link href="../_app/immutable/entry/payload.DSmR2FwN.js" rel="modulepreload">
20		<link href="../_app/immutable/chunks/Bq9QSwnx.js" rel="modulepreload">
21		<link href="../_app/immutable/chunks/DAlw6Pn-.js" rel="modulepreload">
22		<link href="../_app/immutable/chunks/BMKLcj0c.js" rel="modulepreload">
23		<link href="../_app/immutable/chunks/BBWjyu60.js" rel="modulepreload">
24		<link href="../_app/immutable/entry/app.D9422SKp.js" rel="modulepreload">
25		<link href="../_app/immutable/nodes/0.B5w1Ja0U.js" rel="modulepreload">
26		<link href="../_app/immutable/chunks/CbD_2rOR.js" rel="modulepreload">
27		<link href="../_app/immutable/nodes/5.Cw7XR4xF.js" rel="modulepreload">
28		<link href="../_app/immutable/chunks/DP5r4u-1.js" rel="modulepreload">
29		<!--12qhfyh--><meta name="description" content="Philipps Tech Stack Guide – Bun + SvelteKit + oRPC Monorepo Blueprint mit End-to-End Type Safety und Docker/Nginx Deployment."/> <meta name="author" content="Philipp Dormann"/> <meta name="robots" content="noindex, nofollow"/> <link rel="canonical" href="https://philippdormann.de/techstack/"/> <meta property="og:type" content="website"/> <meta property="og:locale" content="de_DE"/> <meta property="og:site_name" content="Philipp Dormann"/> <meta property="og:url" content="https://philippdormann.de/techstack/"/> <meta property="og:title" content="Tech Stack – Bun, SvelteKit &amp; oRPC Guide | Philipp Dormann"/> <meta property="og:description" content="Philipps Tech Stack Guide – Bun + SvelteKit + oRPC Monorepo Blueprint mit End-to-End Type Safety und Docker/Nginx Deployment."/> <meta property="og:image" content="https://philippdormann.de/general/og-bm9l6wo7yvccx0y468uz0kfp.png"/> <meta property="og:image:alt" content="Philipp Dormann – Fullstack Developer &amp; Gründer"/> <meta property="og:image:width" content="1200"/> <meta property="og:image:height" content="630"/> <meta name="twitter:card" content="summary_large_image"/> <meta name="twitter:site" content="@philipp_dormann"/> <meta name="twitter:creator" content="@philipp_dormann"/> <meta name="twitter:title" content="Tech Stack – Bun, SvelteKit &amp; oRPC Guide | Philipp Dormann"/> <meta name="twitter:description" content="Philipps Tech Stack Guide – Bun + SvelteKit + oRPC Monorepo Blueprint mit End-to-End Type Safety und Docker/Nginx Deployment."/> <meta name="twitter:image" content="https://philippdormann.de/general/og-bm9l6wo7yvccx0y468uz0kfp.png"/> <meta name="twitter:image:alt" content="Philipp Dormann – Fullstack Developer &amp; Gründer"/> <!---->
29<script type="application/ld+json">{"@context":"https://schema.org","@graph":[{"@type":"Person","@id":"https://philippdormann.de/#person","name":"Philipp Dormann","url":"https://philippdormann.de/","image":"https://philippdormann.de/pd.png","jobTitle":"Fullstack Developer & Gründer","sameAs":["https://github.com/philippdormann","https://linkedin.com/in/philippdormann","https://philippdormann.de/"],"worksFor":{"@id":"https://philippdormann.de/#organization"}},{"@type":"ProfessionalService","@id":"https://philippdormann.de/#organization","name":"Philipp Dormann IT Dienstleistungen","url":"https://philippdormann.de/","logo":"https://philippdormann.de/pd.png","image":"https://philippdormann.de/pd.png","address":{"@type":"PostalAddress","streetAddress":"Sandäcker 5","addressLocality":"Herzogenaurach","postalCode":"91074","addressCountry":"DE"},"founder":{"@id":"https://philippdormann.de/#person"},"sameAs":["https://github.com/philippdormann","https://linkedin.com/in/philippdormann"]}]}</script>
29<!----><!----><title>Tech Stack – Bun, SvelteKit &amp; oRPC Guide | Philipp Dormann</title>
30		<link href="../_app/immutable/assets/0.CdoKOQDn.css" rel="stylesheet">
31		<link href="../_app/immutable/assets/5.CWWKpEts.css" rel="stylesheet">
32	</head>
33
34	<body>
35		<div id="svelte"><!--[--><!--[--><!--[0--><!--[--><div class="bg-blobs" aria-hidden="true"><span class="blob blob--one"></span> <span class="blob blob--two"></span> <span class="blob blob--three"></span></div> <header class="nav"><div class="nav-inner"><button type="button" class="nav-toggle" aria-expanded="false" aria-controls="primary-navigation" aria-label="Menü öffnen"><span aria-hidden="true"></span> <span aria-hidden="true"></span> <span aria-hidden="true"></span></button> <nav id="primary-navigation" class="nav-links" aria-label="Hauptnavigation"><!--[--><a class="nav-link" href="/">Home</a><a class="nav-link" href="/uses/">Uses</a><a class="nav-link nav-link--active" href="/techstack/" aria-current="page">Techstack</a><!--]--></nav> <div class="nav-social"><a aria-label="Termin vereinbaren" rel="noopener noreferrer" target="_blank" href="https://cal.com/philippdormann/meeting/"><svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M16 2v4"></path><path d="M21 11.75V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h7.25"></path><path d="m22 22-1.875-1.875"></path><path d="M3 10h18"></path><path d="M8 2v4"></path><circle cx="18" cy="18" r="3"></circle></svg></a> <a aria-label="Philipp Dormann auf GitHub" rel="noopener noreferrer" target="_blank" href="https://github.com/philippdormann/"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M12 2C6.475 2 2 6.475 2 12a9.994 9.994 0 0 0 6.838 9.488c.5.087.687-.213.687-.476 0-.237-.013-1.024-.013-1.862-2.512.463-3.162-.612-3.362-1.175-.113-.288-.6-1.175-1.025-1.413-.35-.187-.85-.65-.013-.662.788-.013 1.35.725 1.538 1.025.9 1.512 2.338 1.087 2.912.825.088-.65.35-1.087.638-1.337-2.225-.25-4.55-1.113-4.55-4.938 0-1.088.387-1.987 1.025-2.688-.1-.25-.45-1.275.1-2.65 0 0 .837-.262 2.75 1.026a9.28 9.28 0 0 1 2.5-.338c.85 0 1.7.112 2.5.337 1.912-1.3 2.75-1.024 2.75-1.024.55 1.375.2 2.4.1 2.65.637.7 1.025 1.587 1.025 2.687 0 3.838-2.337 4.688-4.562 4.938.362.312.675.912.675 1.85 0 1.337-.013 2.412-.013 2.75 0 .262.188.574.688.474A10.016 10.016 0 0 0 22 12c0-5.525-4.475-10-10-10z"></path></svg></a> <a aria-label="E-Mail" rel="noopener noreferrer" target="_blank" href="mailto:[email protected]"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="20" height="20" fill="currentColor"><path d="M502.3 190.8c3.9-3.1 9.7-.2 9.7 4.7V400c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V195.6c0-5 5.7-7.8 9.7-4.7 22.4 17.4 52.1 39.5 154.1 113.6 21.1 15.4 56.7 47.8 92.2 47.6 35.7.3 72-32.8 92.3-47.6 102-74.1 131.6-96.3 154-113.7zM256 320c23.2.4 56.6-29.2 73.4-41.4 132.7-96.3 142.8-104.7 173.4-128.7 5.8-4.5 9.2-11.5 9.2-18.9v-19c0-26.5-21.5-48-48-48H48C21.5 64 0 85.5 0 112v19c0 7.4 3.4 14.3 9.2 18.9 30.6 23.9 40.7 32.4 173.4 128.7 16.8 12.2 50.2 41.8 73.4 41.4z"></path></svg></a> <a aria-label="LinkedIn" rel="noopener noreferrer" target="_blank" href="https://linkedin.com/in/philippdormann/"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="20" height="20" fill="currentColor"><path d="M18.335 18.339H15.67v-4.177c0-.996-.02-2.278-1.39-2.278-1.389 0-1.601 1.084-1.601 2.205v4.25h-2.666V9.75h2.56v1.17h.035c.358-.674 1.228-1.387 2.528-1.387 2.7 0 3.2 1.778 3.2 4.091v4.715zM7.003 8.575a1.546 1.546 0 0 1-1.548-1.549 1.548 1.548 0 1 1 1.547 1.549zm1.336 9.764H5.666V9.75H8.34v8.589zM19.67 3H4.329C3.593 3 3 3.58 3 4.297v15.406C3 20.42 3.594 21 4.328 21h15.338C20.4 21 21 20.42 21 19.703V4.297C21 3.58 20.4 3 19.666 3h.003z"></path></svg></a></div></div></header> <main class="main"><!--[--><!--[-1--><!--[--><section class="content-page techstack-page svelte-wp987f"><div class="techstack-intro svelte-wp987f"><p class="svelte-wp987f">Building web applications since 2017 — started with <strong class="svelte-wp987f">PHP, HTML &amp; CSS</strong>, via <strong class="svelte-wp987f">JavaScript</strong>, now almost exclusively <strong class="svelte-wp987f">
35TypeScript, Docker, type safety &amp;
36				Bun</strong>. Focus: end-to-end types (oRPC + Valibot), lean <code class="svelte-wp987f">alpine</code>/<code class="svelte-wp987f">scratch</code> images and fast Bun builds. The guide below is my 09/2026 blueprint — copy-paste ready, no
37			product logic.</p></div> <div class="techstack-actions svelte-wp987f"><button type="button" class="btn btn--primary techstack-download-btn svelte-wp987f" aria-label="Download Tech Stack Guide as Markdown"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path><polyline points="7 10 12 15 17 10"></polyline><line x1="12" y1="15" x2="12" y2="3"></line></svg> Download guide as Markdown</button> <span class="techstack-actions__hint svelte-wp987f">.md · 45.2 KB</span></div> <div class="techstack-markdown svelte-wp987f"><!----><h1>Philipps 09/2026 Tech Stack Guide</h1>
38<blockquote>
39<p>Copy-paste blueprint for a Bun + SvelteKit + oRPC monorepo with end-to-end type safety, independent per-app releases, and Docker/Nginx deployment. No product-specific logic is documented here — only the structural pattern.</p>
40</blockquote>
41<hr>
42<h2>1. Overview</h2>
43<table>
44<thead>
45<tr>
46<th>Layer</th>
47<th>Choice</th>
48<th>Why</th>
49</tr>
50</thead>
51<tbody><tr>
52<td>Runtime</td>
53<td><strong>Bun</strong> (<code>ghcr.io/philippdormann/bun:1.4.2</code>)</td>
54<td>Fast install/start, native <code>Bun.serve</code>, <code>Bun.file</code>/<code>Bun.write</code> — minimal + zero CVE</td>
55</tr>
56<tr>
57<td>Package manager</td>
58<td><strong>Bun workspaces</strong> (<code>bun.lock</code> at root)</td>
59<td>Single lockfile, <code>workspace:*</code> links, always version pinned deps</td>
60</tr>
61<tr>
62<td>Language</td>
63<td><strong>TypeScript (strict)</strong></td>
64<td><code>strict: true</code>, <code>noUncheckedIndexedAccess</code>, <code>bundler</code> resolution</td>
65</tr>
66<tr>
67<td>Backend</td>
68<td><strong>Bun.serve + oRPC v2</strong></td>
69<td><code>oRPC</code> gives RPC + OpenAPI from one router</td>
70</tr>
71<tr>
72<td>Validation</td>
73<td><strong>Valibot</strong> (<code>@orpc/valibot</code>)</td>
74<td>Lightweight, converts to JSON Schema for OpenAPI</td>
75</tr>
76<tr>
77<td>Frontend</td>
78<td><strong>Svelte 5 + SvelteKit 3 + Vite 8 + Tailwind 4 + shadcn-svelte + <code>cn</code></strong></td>
79<td>Runes, <code>adapter-static</code> (SPA) or <code>adapter-node</code> (SSR) — UI via <a href="https://www.shadcn-svelte.com/llms.txt">shadcn-svelte</a> (Tailwind + Bits UI) where available, no custom primitives; class merging via <a href="https://npmx.dev/package/cn"><code>cn</code></a> (not <code>clsx</code>/<code>tailwind-merge</code>/<code>cnfast</code>)</td>
80</tr>
81<tr>
82<td>Fonts</td>
83<td><strong>Fontsource</strong> (<code>@fontsource/*</code> self-hosted)</td>
84<td>Privacy — no Google Fonts CDN (<code>fonts.googleapis.com</code>), self-hosted subsets/weights, CSP-friendly (<a href="https://fontsource.org">fontsource.org</a>)</td>
85</tr>
86<tr>
87<td>DB ORM</td>
88<td><strong>Drizzle ORM</strong> (<code>drizzle-orm</code> + <code>drizzle-kit</code>) on <strong>Postgres</strong> via <strong>Bun&#39;s built-in <code>Bun.SQL</code></strong> (<code>drizzle-orm/bun-sql</code>, no extra driver)</td>
89<td>Typed SQL via Bun native driver — no <code>pg</code> / <code>postgres</code> / <code>mysql2</code> / <code>better-sqlite3</code> needed (<a href="https://bun.sh/docs/runtime/sql">Bun SQL docs</a>) — <strong>Postgres is the preferred DB</strong>; MySQL/MariaDB is legacy/avoid for new code</td>
90</tr>
91<tr>
92<td>Cache / Queues</td>
93<td><strong>Valkey + BullMQ + croner</strong></td>
94<td>Sessions, rate-limits, scheduled jobs</td>
95</tr>
96<tr>
97<td>Object Storage</td>
98<td><strong>Bun&#39;s built-in <code>S3Client</code></strong> (<code>bun</code> — <code>S3Client</code> / <code>s3</code> global, no extra SDK)</td>
99<td>S3-compatible (AWS / R2 / MinIO / Spaces) — no <code>@aws-sdk/*</code> needed (<a href="https://bun.sh/docs/runtime/s3">Bun S3 docs</a>) — central <code>new S3Client()</code> per bucket</td>
100</tr>
101<tr>
102<td>CDN / Assets</td>
103<td><strong>Hono + Sharp</strong> (optional)</td>
104<td>Lightweight edge caching, image transforms</td>
105</tr>
106<tr>
107<td>Observability</td>
108<td><strong>Sentry</strong> (<code>@sentry/bun</code> / <code>@sentry/browser</code> + <code>ORPCInstrumentation</code>)</td>
109<td>Traces, error capture</td>
110</tr>
111<tr>
112<td>CI/CD</td>
113<td><strong>GitLab CI + Docker Buildx + Caddy</strong></td>
114<td><code>audit</code> stage 0 (<code>bun audit</code> gate) → tag-triggered per-app arm64 images</td>
115</tr>
116<tr>
117<td>Security</td>
118<td><strong><code>bun audit</code> (stage <code>audit</code>, step 0)</strong></td>
119<td>Blocking CI gate on every tagged build; fix before <code>build</code> starts</td>
120</tr>
121</tbody></table>
122<hr>
123<h2>2. Directory Layout</h2>
124<pre><code>.
125├── package.json              # root workspaces definition
126├── tsconfig.json             # shared base TS config
127├── bun.lock
128├── patches/                  # patchedDependencies (e.g. drizzle-kit)
129├── Caddyfile                 # reverse proxy (prod)
130├── docker-compose.yml        # local dev (valkey, mailpit, apps)
131├── apps/*/Dockerfile         # file lives here: apps/backend/Dockerfile, apps/mobile/Dockerfile … (all minimal + zero-CVE based). Always build with repo-root context: `docker build -f apps/&lt;app&gt;/Dockerfile .`
132├── .gitlab-ci.yml            # tag-triggered builds
133├── scripts/                  # one-off codegen / maintenance scripts
134│   └── update-*.ts
135├── packages/
136│   └── shared/               # @scope/shared — framework-agnostic utils + Svelte components
137│       ├── package.json      # &quot;exports&quot; map, no bundling, direct .ts/.svelte imports
138│       ├── *.ts
139│       └── *.svelte
140└── apps/
141    ├── backend/              # Bun HTTP server, oRPC router, DB, queues
142    │   ├── src/
143    │   │   ├── server.ts     # Bun.serve entry (fetch + websocket) — sole listen point
144    │   │   ├── index.ts      # type-only re-export of RouterType, no runtime
145    │   │   ├── orpc/         # router, context, middleware, procedures/
146    │   │   ├── db/           # drizzle clients + schema (generated)
147    │   │   ├── middleware/   # cors, requestSize
148    │   │   └── cron/         # scheduled jobs
149    │   └── package.json      # @scope/backend, exports: { &quot;.&quot;: { &quot;types&quot;: &quot;./src/index.ts&quot; } } (types-only)
150    ├── &lt;frontend-a&gt;/         # SvelteKit + adapter-static → nginx
151    ├── &lt;frontend-b&gt;/         # SvelteKit + adapter-static → nginx
152    ├── &lt;frontend-c&gt;/         # SvelteKit + adapter-static → nginx
153    ├── website/              # SvelteKit + adapter-node  → node (SSR)
154    └── cdn/                  # Hono server (Bun), optional
155</code></pre>
156<p><strong>Rule:</strong> Anything imported by ≥2 apps lives in <code>packages/shared</code>. Anything app-specific stays in that app.</p>
157<hr>
158<h2>3. Package Manager &amp; Workspaces</h2>
159<p>Root <code>package.json:19-22</code>:</p>
160<pre><code class="language-json">{
161  &quot;private&quot;: true,
162  &quot;type&quot;: &quot;module&quot;,
163  &quot;workspaces&quot;: [&quot;packages/*&quot;, &quot;apps/*&quot;]
164}
165</code></pre>
166<ul>
167<li><p>Use <code>bun add -E &lt;pkg&gt;</code> / <code>bunx</code> (never <code>npx</code>, never hand-edit <code>dependencies</code>).</p>
168</li>
169<li><p>Inter-package deps are <code>workspace:*</code>:</p>
170<pre><code class="language-json">{ &quot;dependencies&quot;: { &quot;@scope/shared&quot;: &quot;workspace:*&quot; } }
171</code></pre>
172</li>
173<li><p>Frontends depend on backend <strong>as <code>devDependency</code> only</strong> — they need its types, not its runtime:</p>
174<pre><code class="language-json">{ &quot;devDependencies&quot;: { &quot;@scope/backend&quot;: &quot;workspace:*&quot; } }
175</code></pre>
176</li>
177<li><p>Optional: <code>patchedDependencies</code> for upstream fixes without forking.</p>
178</li>
179</ul>
180<p>Root scripts delegate per-app:</p>
181<pre><code class="language-json">{
182  &quot;scripts&quot;: {
183    &quot;check&quot;: &quot;bun --filter @scope/backend typecheck &amp;&amp; bun --filter @scope/app-a check &amp;&amp; ...&quot;,
184    &quot;lint:all&quot;: &quot;bun --filter @scope/backend lint &amp;&amp; ...&quot;
185  }
186}
187</code></pre>
188<p><code>bun --filter &lt;pkg&gt;</code> runs the script in that workspace only.</p>
189<hr>
190<h2>4. TypeScript Baseline</h2>
191<p>Root <code>tsconfig.json</code> (shared):</p>
192<pre><code class="language-json">{
193  &quot;compilerOptions&quot;: {
194    &quot;lib&quot;: [&quot;ESNext&quot;], &quot;target&quot;: &quot;ESNext&quot;,
195    &quot;module&quot;: &quot;Preserve&quot;, &quot;moduleResolution&quot;: &quot;bundler&quot;,
196    &quot;allowImportingTsExtensions&quot;: true, &quot;verbatimModuleSyntax&quot;: true,
197    &quot;noEmit&quot;: true, &quot;strict&quot;: true,
198    &quot;skipLibCheck&quot;: true,
199    &quot;noFallthroughCasesInSwitch&quot;: true,
200    &quot;noUncheckedIndexedAccess&quot;: true,
201    &quot;noImplicitOverride&quot;: true
202  }
203}
204</code></pre>
205<p>Each app extends it. Backend enables <code>noUnusedLocals/Parameters: true</code>; frontends rely on <code>svelte-check</code>. Use <code>rewriteRelativeImportExtensions: true</code> in SvelteKit apps.</p>
206<hr>
207<h2>5. Backend — <code>apps/backend</code></h2>
208<h3>5.1 Server Entry (<code>src/server.ts</code>)</h3>
209<ul>
210<li><p><code>Bun.serve({ fetch, websocket })</code> — single process.</p>
211</li>
212<li><p>Health checks + migration gate before listening.</p>
213</li>
214<li><p>Two oRPC handlers from the <strong>same router</strong>:</p>
215<pre><code class="language-ts">import { RPCHandler } from &quot;@orpc/server/fetch&quot;;
216import { OpenAPIHandler } from &quot;@orpc/openapi/fetch&quot;;
217import { CORSPlugin } from &quot;@orpc/server/plugins&quot;;
218import { CompressionPlugin } from &quot;@orpc/server/fetch&quot;;
219import { OpenAPIReferencePlugin } from &quot;@orpc/openapi/plugins&quot;;
220import { experimental_ValibotToJsonSchemaConverter } from &quot;@orpc/valibot&quot;;
221
222const cors = new CORSPlugin({ origin: o =&gt; isAllowed(o) ? o : null });
223const rpcHandler = new RPCHandler(router, {
224  plugins: [cors, new CompressionPlugin()],
225});
226const openApiHandler = new OpenAPIHandler(router, {
227  plugins: [cors, new OpenAPIReferencePlugin({
228    schemaConverters: [new experimental_ValibotToJsonSchemaConverter()],
229    specGenerateOptions: { info, servers },
230    docsProvider: &quot;scalar&quot;, docsPath: &quot;/docs&quot;, specPath: &quot;/openapi.json&quot;,
231  })],
232});
233
234Bun.serve({
235  async fetch(req, server) {
236    if (server.upgrade(req)) return undefined;
237    const ctx = { request: req.clone(), db, valkey };
238    if (new URL(req.url).pathname.startsWith(&quot;/rpc&quot;)) {
239      const res = await rpcHandler.handle(req, { prefix: &quot;/rpc&quot;, context: ctx });
240      return res.matched ? res.response : new Response(&quot;Not Found&quot;, { status: 404 });
241    }
242    const res = await openApiHandler.handle(req, { context: ctx });
243    return res.matched ? res.response : new Response(&quot;Not Found&quot;, { status: 404 });
244  },
245  websocket: { open, message(ws, raw) { handleWsMessage(ws, raw); }, close }
246});
247</code></pre>
248</li>
249<li><p>Sentry + <code>ORPCInstrumentation</code> via <code>onError</code> interceptor.</p>
250</li>
251<li><p>Graceful shutdown via <code>registerCleanup()</code>.</p>
252</li>
253</ul>
254<h3>5.2 oRPC Router &amp; Procedures</h3>
255<pre><code>src/orpc/
256├── router.ts        # assembles os.router({ ... })
257├── context.ts       # ORPCContext = { request, db, valkey, user? }
258├── middleware.ts    # authMiddleware, requirePermission(), caching
259├── errors.ts        # throwBadRequest / throwNotFound / ORPCError helpers + responseCodes
260├── procedures/      # one file per domain, each exports os.route(...).input(v.object).handler(...)
261└── s3.ts / ws.ts    # infra helpers (storage via Bun S3Client — see §11 — / websocket)
262</code></pre>
263<p><strong>Procedure pattern</strong> (Valibot validation + typed handler):</p>
264<pre><code class="language-ts">import { os } from &quot;@orpc/server&quot;;
265import * as v from &quot;valibot&quot;;
266
267export const myProcedure = os
268  .route({
269    method: &quot;GET&quot;,            // also drives OpenAPI method
270    path: &quot;/v1/my-resource&quot;,  // also drives OpenAPI path
271    tags: [&quot;MyDomain&quot;],
272    summary: &quot;...&quot;,
273    description: &quot;...&quot;,
274  })
275  .input(v.object({ id: v.string(), includeArchived: v.optional(v.string()) }))
276  // .use(authMiddleware).use(requirePermission(&quot;my:read&quot;))
277  .handler(async ({ input, context }) =&gt; {
278    // context: ORPCContext — typed DB + valkey + request
279    return { status: &quot;ok&quot;, code: &quot;OK&quot;, data: { ... } };
280  });
281</code></pre>
282<p>Then aggregate in <code>router.ts</code>:</p>
283<pre><code class="language-ts">import { os } from &quot;@orpc/server&quot;;
284import * as domain from &quot;./procedures/domain&quot;;
285export const orpcRouter = os.router({ healthz, livez, ...domain });
286export type RouterType = typeof orpcRouter;
287</code></pre>
288<h3>5.3 Dual Transport</h3>
289<table>
290<thead>
291<tr>
292<th>Transport</th>
293<th>Handler</th>
294<th>URL</th>
295<th>Consumer</th>
296</tr>
297</thead>
298<tbody><tr>
299<td><strong>RPC</strong> (JSON over POST)</td>
300<td><code>RPCHandler</code></td>
301<td><code>/rpc/*</code></td>
302<td>First-party frontends via <code>@orpc/client</code></td>
303</tr>
304<tr>
305<td><strong>REST / OpenAPI</strong></td>
306<td><code>OpenAPIHandler</code></td>
307<td><code>path</code> as defined in <code>.route()</code> (e.g. <code>/v1/...</code>)</td>
308<td>Third parties, Scalar docs at <code>/docs</code>, raw spec at <code>/openapi.json</code></td>
309</tr>
310</tbody></table>
311<p>Both handlers share the <strong>same router instance</strong> — no duplication.</p>
312<h3>5.4 End-to-End Type Safety</h3>
313<p>The entire chain is type-checked without codegen:</p>
314<pre><code>backend/src/orpc/router.ts  →  export type RouterType
315        │
316        │  re-exported via
317        ▼
318backend/src/index.ts        →  export type RouterType = RouterClient&lt;ORPCAppRouterType&gt;
319        │
320        │  imported as devDependency  &quot;@scope/backend&quot;: &quot;workspace:*&quot;
321        ▼
322frontend/src/lib/orpc.ts    →  createORPCClient&lt;RouterType&gt;(new RPCLink({ url: &quot;/rpc&quot;, headers: ... }))
323        │
324        ▼
325frontend code               →  orpc.myProcedure({ id: &quot;...&quot; })  // fully typed input/output
326</code></pre>
327<p><strong>Backend exports types only</strong> (<code>package.json</code> <code>exports: { &quot;.&quot;: { &quot;types&quot;: &quot;./src/index.ts&quot; } }</code>). Frontends install <code>@scope/backend</code> as <code>devDependencies</code> so the import is erased at build.</p>
328<p><strong>Client setup</strong> (per frontend, <code>src/lib/orpc.ts</code> — must stay SSR-safe for <code>adapter-node</code>):</p>
329<pre><code class="language-ts">import { browser } from &quot;$app/environment&quot;;
330import { PUBLIC_API_BASE_URL } from &quot;$env/static/public&quot;;
331import { createORPCClient } from &quot;@orpc/client&quot;;
332import { RPCLink } from &quot;@orpc/client/fetch&quot;;
333import { ClientRetryPlugin, DedupeRequestsPlugin } from &quot;@orpc/client/plugins&quot;;
334import type { RouterType } from &quot;@scope/backend&quot;;
335
336// `.env`: PUBLIC_API_BASE_URL=https://api.example.com
337// Never touch `window` / `localStorage` at module top-level — this module also runs on the server.
338const baseUrl = browser &amp;&amp; window.location.hostname === &quot;localhost&quot;
339  ? &quot;http://localhost:3000&quot;
340  : PUBLIC_API_BASE_URL;
341
342const link = new RPCLink({
343  url: `${baseUrl}/rpc`,
344  headers: () =&gt; {
345    if (!browser) return {};
346    const t = localStorage.getItem(&quot;auth&quot;);
347    return t ? { Authorization: `Bearer ${t}` } : {};
348  },
349  fetch: (req, init) =&gt; {
350    const timeout = AbortSignal.timeout(30_000);
351    const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout;
352    return globalThis.fetch(req, { ...init, signal });
353  },
354  plugins: [
355    new ClientRetryPlugin({ default: { retry: 2, retryDelay: 1_000 } }),
356    new DedupeRequestsPlugin({ filter: ({ request }) =&gt; request.method === &quot;GET&quot;, groups: [{ condition: () =&gt; true, context: {} }] }),
357  ],
358});
359
360export const orpc = createORPCClient&lt;RouterType&gt;(link);
361</code></pre>
362<p>Only needed when procedures return an envelope (<code>{ status, code, data }</code> as in §5.2). Plain procedure results need no unwrapping:</p>
363<pre><code class="language-ts">// packages/shared/orpc-unwrap.ts
364export function unwrapOrpcResponse&lt;T&gt;(raw: { data: T } | T): T {
365  if (typeof raw === &quot;object&quot; &amp;&amp; raw !== null &amp;&amp; &quot;data&quot; in raw) {
366    return raw.data;
367  }
368  return raw;
369}
370</code></pre>
371<h3>5.5 End-to-End Type-Safe WebSocket Messages</h3>
372<p>oRPC covers request/response. For real-time push (chat, live updates) use the same single-source-of-truth principle via a shared event catalog and Valibot schemas.
372</p>
373<p><strong>Shared catalog</strong> (<code>packages/shared/ws.ts</code> — constants + Valibot schemas only, no server code):</p>
374<pre><code class="language-ts">export const ChatClientType = {
375  NEW_MESSAGE: &quot;newMessage&quot;,
376  TYPING: &quot;typing&quot;,
377  // ...
378} as const;
379export const WsServerType = {
380  MESSAGE: &quot;message&quot;,
381  TYPING: &quot;typing&quot;,
382  UNREAD_UPDATED: &quot;unreadUpdated&quot;,
383  ERROR: &quot;error&quot;,
384  // ...
385} as const;
386</code></pre>
387<p>Both backend (<code>apps/backend/src/ws-hub.ts</code>, <code>apps/backend/src/ws/handler.ts</code>) and frontends import event names from <code>@scope/shared/ws</code> — event names can never drift.</p>
388<p><strong>Backend: typed handler map</strong> (<code>apps/backend/src/ws/handler.ts</code> + <code>apps/backend/src/ws-hub.ts</code>):</p>
389<pre><code class="language-ts">import * as v from &quot;valibot&quot;;
390import { ChatClientType, WsServerType } from &quot;@scope/shared/ws&quot;;
391
392const EnvelopeSchema = v.object({ type: v.str
392ing(), payload: v.optional(v.unknown()) });
393type Envelope = v.InferOutput&lt;typeof EnvelopeSchema&gt;;
394type AuthedClient = { userId: string };
395
396const clients = new Map&lt;ServerWebSocket, AuthedClient&gt;();
397
398const MessagePostSchema = v.object({
399  target: v.string(),
400  content: v.pipe(v.string(), v.maxLength(15_000)),
401  media: v.optional(v.string()),
402});
403
404type WsHandler = (ws: ServerWebSocket, msg: Envelope, client: AuthedClient) =&gt; void;
405
406function sendError(ws: ServerWebSocket, code: string): void {
407  ws.send(JSON.stringify({ type: WsServerType.ERROR, data: code }));
408}
409
410export const authedHandlers: Partial&lt;Record&lt;string, WsHandler&gt;&gt; = {
411  [ChatClientType.NEW_MESSAGE]: (ws, msg, client) =&gt; {
412    const parsed = v.safeParse(MessagePostSchema, msg.payload);
413    if (!parsed.success) { sendError(ws, &quot;INVALID_PAYLOAD&quot;); return; }
414    // ... use parsed.output with full type safety
415  },
416  [ChatClientType.TYPING]: (ws, msg, client) =&gt; { /* ... */ },
417};
418
419// Single entry point wired to `Bun.serve({ websocket: { message } })` in `src/server.ts`
420export function handleWsMessage(ws: ServerWebSocket, raw: string): void {
421  let decoded: unknown;
422  try {
423    decoded = JSON.parse(raw);
424  } catch {
425    sendError(ws, &quot;INVALID_PAYLOAD&quot;);
426    return;
427  }
428  const envelope = v.safeParse(EnvelopeSchema, decoded);
429  if (!envelope.success) { sendError(ws, &quot;INVALID_PAYLOAD&quot;); return; }
430  const client = clients.get(ws);
431  if (!client) { sendError(ws, &quot;NOT_AUTHED&quot;); return; }
432  authedHandlers[envelope.output.type]?.(ws, envelope.output, client);
433}
434</code></pre>
435<p>Valibot gives the same DX as oRPC procedures: parse once, then <code>parsed.output</code> is fully typed — no type assertions needed.</p>
436<p><strong>Frontend: typed wrapper</strong> (<code>apps/&lt;frontend&gt;/src/lib/websocket.ts</code>):</p>
437<pre><code class="language-ts">import { browser } from &quot;$app/environment&quot;;
438import { PUBLIC_API_BASE_URL } from &quot;$env/static/public&quot;;
439import type { ChatSocketPayload } from &quot;@scope/shared/ws&quot;;
440
441// Native WebSocket served by `Bun.serve` (`src/server.ts` + `ws-hub` fan-out).
442// Auth goes via `?auth=` query param. Reconnect + lifecycle (e.g. Capacitor
443// `resume`/`background`) is handled by the caller.
444let socket: WebSocket | null = null;
445
446export function createWebsocket(auth: string): WebSocket {
447  const wsUrl = new URL(`${PUBLIC_API_BASE_URL.replace(/^http/, &quot;ws&quot;)}/v1/ws`);
448  wsUrl.searchParams.set(&quot;auth&quot;, auth);
449  socket = new WebSocket(wsUrl.toString());
450  return socket;
451}
452
453export function sendWebsocket(msg: ChatSocketPayload): void {
454  if (browser) socket?.send(JSON.stringify(msg));
455}
456</code></pre>
457<blockquote>
458<p><strong>Note:</strong> oRPC covers request/response (RPC + OpenAPI streaming where needed). Push messages (chat, live updates) use a separate native WebSocket served by the same <code>Bun.serve({ fetch, websocket: { open, message, close } })</code> in <code>src/server.ts</code> with fan-out in <code>ws-hub.ts</code>.</p>
459</blockquote>
460<p><strong>Why this works:</strong></p>
461<ul>
462<li>Event names are a shared <code>as const</code> union — rename in one place, TS errors everywhere.</li>
463<li>Payloads are Valibot schemas shared or mirrored backend/frontend — no <code>any</code> / <code>as unknown</code> / <code>as</code> assertions needed.</li>
464<li>The <code>authedHandlers</code> map is the websocket equivalent of <code>os.router()</code>: a single registry guarantees both sides agree on the contract.</li>
465<li>Keep <code>packages/shared</code> free of server-only code; only the event constants (and optionally shared Valibot schemas) live there.</li>
466</ul>
467<hr>
468<h2>6. Frontend Apps — SvelteKit + Capacitor Native</h2>
469<h3>6.0 Capacitor Native Integration (<code>apps/mobile</code>)</h3>
470<p>The mobile app is a <strong>SvelteKit SPA + Capacitor</strong> hybrid — same <code>build/</code> output runs on web and as a native iOS/Android shell.</p>
471<ul>
472<li><strong>Config:</strong> <code>apps/mobile/capacitor.config.ts:1-31</code> — <code>appId: &quot;mobile.my.app&quot;</code>, <code>webDir: &quot;build&quot;</code>, platform <code>ios</code>/<code>android</code> flags (<code>allowsLinkPreview: false</code>, <code>zoomEnabled: false</code>), <code>Keyboard</code> (<code>resize: Body</code>, <code>style: Light</code>) and push (<code>FirebaseMessaging</code> / <code>PushNotifications</code> with <code>badge</code>/<code>sound</code>/<code>alert</code>). Native shells live in <code>apps/mobile/android/</code> and <code>apps/mobile/ios/</code> (generated, platform-specific <code>.gitignore</code>).</li>
473<li><strong>Build pipeline:</strong> <code>apps/mobile/package.json:6-12</code> exposes <code>build:native</code>, <code>build:native:android</code>, <code>build:native:ios</code>:<pre><code class="language-bash">bun run build                  # vite build → build/
474bunx cap sync                  # copy web assets into android/ios
475bun run set:versions           # syncs version → Info.plist / project.pbxproj / build.gradle
476bunx cap open android|ios      # opens Xcode / Android Studio
477</code></pre>
478<code>set-native-version.ts:1-74</code> derives <code>versionCode</code> (<code>major*10000 + minor*100 + patch</code>) and <code>MARKETING_VERSION</code> from <code>package.json</code>/<code>APP_VERSION</code> and patches <code>ios/App/App/Info.plist</code>, <code>ios/App/App.xcodeproj/project.pbxproj</code>, <code>android/app/build.gradle</code>.</li>
479<li><strong>Plugins in use</strong> (<code>apps/mobile/package.json:68-99</code>): <code>@capacitor/core|cli|android|ios</code>, <code>@capacitor/app|app-launcher|camera|clipboard|device|dialog|filesystem|haptics|inappbrowser|keyboard|network|preferences|share</code>, <code>@capacitor-firebase/messaging</code>, <code>@capawesome/capacitor-badge</code>, <code>@ebarooni/capacitor-calendar</code>, <code>@capacitor-community/*</code> (<code>in-app-review</code>, <code>media</code>), <code>capacitor-native-settings</code>. All access is via <code>@capacitor/*</code> ESM imports — no native code in JS besides <code>Capacitor.isNativePlatform()</code> guards.</li>
480<li><strong>WebSocket + Capacitor:</strong> Real-time uses the same <code>@scope/shared/ws</code> catalog. On native, <code>
480Capacitor</code> network/keyboard lifecycle is respected (reconnect on <code>resume</code>, pause on <code>background</code>), but the transport stays standard WebSocket (<code>Bun.serve</code> websocket) — no extra native socket plugin required.</li>
481<li><strong>Rule:</strong> Never commit <code>android/build/</code>, <code>ios/App/public/</code>, or <code>capacitor.config.json</code> copies inside <code>ios/App/App/</code>; they are generated by <code>cap sync</code>. Version bumps go through <code>set-native-version.ts</code>, not hand-edits.</li>
482</ul>
483<h3>6.1 Per-App Package</h3>
484<p>Each frontend has its own <code>package.json</code> with independent <code>version</code> and <code>release-it</code> config (see §13).</p>
485<h3>6.2 Vite + SvelteKit Config</h3>
486<ul>
487<li><p><strong>SPA frontends</strong> → <code>adapter-static</code> with SPA fallback + <code>nginx</code>:</p>
488<pre><code class="language-ts">// vite.config.ts
489import adapterStatic from &quot;@sveltejs/adapter-static&quot;;
490import { sveltekit } from &quot;@sveltejs/kit/vite&quot;;
491import tailwindcss from &quot;@tailwindcss/vite&quot;;
492export default defineConfig({
493  plugins: [tailwindcss(), sveltekit({
494    adapter: adapterStatic({ pages: &quot;build&quot;, assets: &quot;build&quot;, fallback: &quot;app.html&quot;, precompress: true }),
495  })],
496  ssr: { noExternal: [&quot;@scope/shared&quot;] },
497});
498</code></pre>
499<p>Dockerfile is two-stage: <code>ghcr.io/philippdormann/bun:1.4.2</code> → <code>bun run build</code> → <code>nginx:1.29-alpine3.23-slim</code> (alpine) serving <code>build/</code> with <code>nginx.conf</code> that does <code>try_files $uri /app.html</code> and long-cache on <code>/_app</code>.</p>
500</li>
501<li><p><strong>SSR website</strong> → <code>adapter-node</code> running <code>build/index.js</code> on <code>PORT=80</code>. Build + runtime both use <code>ghcr.io/philippdormann/bun:1.4.2</code> (<code>apps/website/Dockerfile:1,34</code>) — minimal + zero CVE, prefer <code>ghcr.io/philippdormann/bun</code> everywhere (see §12 Docker policy).</p>
502</li>
503</ul>
504<h3>6.3 Nginx Pattern (<code>nginx.conf</code>)</h3>
505<pre><code class="language-nginx">server {
506  error_page 404 /app.html;          # or /index.html — must match adapter fallback
507  location /_app { expires 1y; }     # hashed SvelteKit assets — immutable
508  location / { try_files $uri $uri/ /app.html; }
509  gzip on; gzip_types text/css application/javascript ...;
510}
511</code></pre>
512<h3>6.4 Conventions Inside Frontends</h3>
513<ul>
514<li><code>#lib</code> import alias via <code>package.json</code> <code>imports: { &quot;#lib&quot;: &quot;./src/lib/index.js&quot; }</code>.</li>
515<li><code>src/lib/orpc.ts</code> owns the typed client (above).</li>
516<li>Validation on the client with <code>valibot</code> mirrors backend schemas but is not coupled — backend is source of truth.</li>
517<li>Sentry <code>@sentry/browser</code> per frontend.</li>
518</ul>
519<h3>6.5 UI Components — Prefer shadcn-svelte</h3>
520<blockquote>
521<p><strong>Policy:</strong> For frontend design, <strong>prefer components from <a href="https://www.shadcn-svelte.com/llms.txt">shadcn-svelte</a> where available</strong> — do not hand-roll primitives that already exist there. shadcn-svelte is Tailwind 4 + Bits UI based, accessible, and matches the stack&#39;s styling (see §8). Use the CLI to vendor components into each app; customize via <code>components.json</code> / CSS variables, not by forking.</p>
522</blockquote>
523<ul>
524<li>Install per frontend: <code>bunx shadcn-svelte@latest init</code> then <code>bunx shadcn-svelte@latest add button card dialog ...</code> (see <a href="https://shadcn-svelte.com/docs/installation/sveltekit">SvelteKit install</a> + <a href="https://shadcn-svelte.com/docs/cli">CLI</a> + <a href="https://shadcn-svelte.com/docs/theming">theming</a>). Components vendor to <code>src/lib/components/ui/</code> — committed, not from <code>node_modules</code>.</li>
525<li><strong>Class merging:</strong> Frontends use <a href="https://npmx.dev/package/cn"><code>cn</code></a> — <code>import { cn } from &quot;cn&quot;</code> — as drop-in replacement for <code>clsx</code> + <code>tailwind-merge</code> (and <code>cnfast</code>). Do <strong>not</strong> add <code>clsx</code>, <code>tailwind-merge</code>, or <code>cnfast</code> in frontend projects. If <code>cn</code> is needed: <code>
525bun add -E cn</code>, then <code>cn(&quot;px-2 py-1&quot;, isActive &amp;&amp; &quot;bg-blue-500&quot;, { &quot;text-white&quot;: isActive }, className)</code>. Legacy <code>lib/utils.ts</code> wrapper (<code>twMerge(clsx(...))</code>) is replaced by <code>export { cn } from &quot;cn&quot;</code> — see <a href="https://npmx.dev/package/cn">cn docs</a> + <code>npx shadcn@latest migrate cn</code> for existing projects.</li>
526<li>Available primitives (check <a href="https://www.shadcn-svelte.com/llms.txt"><code>llms.txt</code></a> for full catalog): <code>button</code>, <code>input</code>, <code>select</code>, <code>checkbox</code>, <code>dialog</code>, <code>drawer</code>, <code>dropdown-menu</code>, <code>tabs</code>, <code>card</code>, <code>table</code>, <code>data-table</code> (TanStack), <code>chart</code> (LayerChart), <code>sonner</code> (toast), <code>avatar</code>, <code>skeleton</code>, <code>carousel</code>, etc. — use these before building custom.</li>
527<li>Keep app-specific composites on top of shadcn primitives (e.g. <code>UserCard.svelte</code> composes <code>Card</code> + <code>Avatar</code> + <code>Badge</code> from <code>ui/</code>). Truly shared UI (e.g. <code>Logo.svelte</code>, <code>format-date</code>) stays in <code>packages/shared</code>; shadcn primitives stay per-app (they vendor per project by design).</li>
528<li>Theming: CSS variables + Tailwind 4 — configure in <code>components.json</code> and <code>app.css</code> per <a href="https://shadcn-svelte.com/docs/theming">theming</a> and <a href="https://shadcn-svelte.com/docs/migration/tailwind-v4">Tailwind v4 migration</a>.</li>
529</ul>
530<h3>6.6 Default UX Decisions</h3>
531<blockquote>
532<p><strong>Policy:</strong> Apply these UX defaults to every frontend unless a feature explicitly opts out.</p>
533</blockquote>
534<ul>
535<li><p><strong>Confirm modals for critical interactions:</strong> Destructive or irreversible actions (delete, bulk delete, archive, publish, invite revoke, payment, permission change) <strong>must</strong> use a confirmation modal — never <code>window.confirm</code>/<code>alert</code> (see §15). Prefer <code>AlertDialog</code> from shadcn-svelte (see <a href="https://www.shadcn-svelte.com/docs/components/alert-dialog">Alert Dialog</a>) for destructive confirms and <code>Dialog</code> for non-destructive. Modal must state the impact in plain language, show the count/names of affected 
535items, require explicit confirm (e.g. <code>Delete 3 items</code> button, disabled until acknowledged), and offer undo where feasible. No critical action on single-click without confirm.</p>
536</li>
537<li><p><strong>Default to non-technical user-facing texts:</strong> All copy shown to end users defaults to plain, non-technical language (DE/EN). Avoid jargon, error codes, stack traces, table/column names, or IDs in UI. Map backend errors to friendly messages (<code>&quot;Could not save — please try again.&quot;</code> not <code>&quot;500: pg error 23505&quot;</code>). Keep technical details in logs/Sentry, not in toasts/alerts. Write copy for easy i18n later — no concatenated strings, use complete sentences with placeholders.</p>
538</li>
539<li><p><strong>Bulk actions where useful:</strong> Any list/table with ≥3 items that supports a per-item destructive or state-changing action must also offer bulk selection + bulk action. Use <code>Checkbox</code> + <code>Data Table</code> row selection (shadcn-svelte <a href="https://www.shadcn-svelte.com/docs/components/data-table">Data Table</a> / <a href="https://www.shadcn-svelte.com/docs/components/checkbox">Checkbox</a>) with a sticky bulk bar (e.g. <code>3 selected — Delete | Archive | Export</code>). Bulk actions reuse the same confirm modal (with count) and show progress / partial-failure handling. Design empty- and single-selection states explicitly.</p>
540</li>
541</ul>
542<hr>
543<h2>7. Shared Package — <code>packages/shared</code></h2>
544<pre><code>packages/shared/package.json
545{
546  &quot;name&quot;: &quot;@scope/shared&quot;,
547  &quot;private&quot;: true,
548  &quot;type&quot;: &quot;module&quot;,
549  &quot;exports&quot;: {
550    &quot;./format-date&quot;: { &quot;types&quot;: &quot;./format-date.ts&quot;, &quot;default&quot;: &quot;./format-date.ts&quot; },
551    &quot;./Logo.svelte&quot;: { &quot;types&quot;: &quot;./Logo.svelte&quot;, &quot;default&quot;: &quot;./Logo.svelte&quot; },
552    ...
553  }
554}
555</code></pre>
556<ul>
557<li>No build step — consumers import <code>.ts</code>/<code>.svelte</code> directly. Vite/SvelteKit + Bun handle it (<code>ssr.noExternal</code>).</li>
558<li>Holds only framework-agnostic helpers and presentational Svelte components (date formatting, debounce, storage, avatar placeholders, etc.). <strong>Do not duplicate shadcn-svelte primitives in <code>shared</code></strong> — shadcn components vendor per-app via the CLI (see §6.5); only truly cross-app composites belong in <code>shared</code>.</li>
559<li>Keep server-only code out of <code>shared</code>.</li>
560</ul>
561<hr>
562<h2>8. Styling / UI / Linting / Formatting</h2>
563<blockquote>
564<p><strong>UI Policy:</strong> Frontend styling is <strong>Tailwind 4</strong> + <strong>shadcn-svelte</strong> primitives (see §6.5) + <a href="https://npmx.dev/package/cn"><code>cn</code></a> for class merging. Prefer <code>https://www.shadcn-svelte.com/llms.txt</code> components (<code>button</code>, <code>card</code>, <code>dialog</code>, <code>table</code>, <code>sonner</code>, etc.) over custom CSS/primitives — install via <code>shadcn-svelte</code> CLI and theme via CSS variables (<code>components.json</code>). Use <code>cn</code> from <code>&quot;cn&quot;</code> (not <code>clsx</code>/<code>tailwind-merge</code>/<code>cnfast</code>) — <code>cn(&quot;px-2 py-1&quot;, condition &amp;&amp; &quot;bg-blue-500&quot;, className)</code>.</p>
565</blockquote>
566<blockquote>
567<p><strong>Fonts Policy:</strong> Use <strong>Fontsource</strong> (<code>@fontsource/*</code>) self-hosted via <code>bun add -E @fontsource/&lt;font&gt;</code> — <strong>do not</strong> load Google Fonts CDN (<code>fonts.googleapis.com</code> / <code>fonts.gstatic.com</code>). Improves privacy (no third-party request), offline, CSP-friendly. Import in <code>src/app.css</code> or <code>+layout.svelte</code>: <code>import &quot;@fontsource/inter/latin-400.css&quot;</code> / <code>import &quot;@fontsource/inter/latin-700.css&quot;</code> — pick subsets/weights explicitly. See <a href="https://fontsource.org">fontsource.org</a>.</p>
568</blockquote>
569<table>
570<thead>
571<tr>
572<th>App</th>
573<th>Formatter</th>
574<th>Linter</th>
575<th>Typecheck</th>
576</tr>
577</thead>
578<tbody><tr>
579<td><code>backend</code>, <code>cdn</code></td>
580<td><code>oxfmt</code> (<code>oxfmt --check</code>)</td>
581<td><code>oxlint</code></td>
582<td><code>tsc --noEmit</code></td>
583</tr>
584<tr>
585<td>Frontends</td>
586<td><code>prettier</code> + <code>prettier-plugin-svelte</code> + <code>prettier-plugin-tailwindcss</code></td>
587<td><code>eslint</code> + <code>eslint-plugin-svelte</code> + <code>typescript-eslint</code></td>
588<td><code>svelte-check --tsconfig ./tsconfig.json</code></td>
589</tr>
590</tbody></table>
591<p>Root <code>lint:all</code> / <code>check</code> scripts fan out with <code>bun --filter</code>.</p>
592<blockquote>
593<p><strong>Migration plan:</strong> Backend/CDN already run on <code>oxfmt</code> + <code>oxlint</code>. Frontends stay on <code>prettier</code> + <code>eslint-plugin-svelte</code> until oxc ships full Svelte/SvelteKit support — per <a href="https://oxc.rs/compatibility.html">oxc compatibility</a> <code>oxlint</code> has no Svelte template linting yet (<a href="https://github.com/oxc-project/oxc/issues/15761">oxc#15761</a>) and <code>oxfmt</code> for Svelte/SvelteKit still requires installing <code>svelte/compiler</code> separately. Once full support lands, migrate frontends to <code>oxfmt</code> + <code>oxlint</code> and drop <code>prettier</code>/<code>eslint</code>.</p>
594</blockquote>
595<hr>
596<h2>9. Database &amp; ORM — Postgres Preferred</h2>
597<blockquote>
598<p><strong>Policy:</strong> Postgres is the <strong>preferred</strong> database for all new code in this template. Drizzle is wired via <code>drizzle-orm/bun-sql</code> (<code>apps/backend/src/db.ts:2</code>, <code>drizzle.config.ts:6</code> <code>dialect: &quot;postgresql&quot;</code>). MySQL/MariaDB is <strong>legacy / avoid</strong> — do not introduce <code>mysql2</code>/<code>drizzle-orm/mysql-core</code> for new features or new services.</p>
599</blockquote>
600<blockquote>
601<p><strong>No extra DB driver needed:</strong> Postgres access uses <strong>Bun&#39;s built-in <code>Bun.SQL</code></strong> (<code>import { SQL } from &quot;bun&quot;</code> / <code>import { sql } from &quot;bun&quot;</code>). Do <strong>not</strong> install <code>pg</code>, <code>postgres</code> (porsager/postgres), <code>mysql2</code>, or <code>better-sqlite3</code> — Bun ships a native SQL client with pooling, prepared statements, and a unified API for Postgres/MySQL/SQLite. See <a href="https://bun.sh/docs/runtime/sql">Bun SQL docs</a>. Drizzle plugs into it via <code>drizzle-orm/bun-sql</code>.</p>
602</blockquote>
603<ul>
604<li><p>Single Drizzle client in <code>apps/backend/src/db.ts:1-25</code> — thin wrapper over the native client:</p>
605<pre><code class="language-ts">import { SQL } from &quot;bun&quot;; // Bun built-in — no npm driver to install
606import { drizzle } from &quot;drizzle-orm/bun-sql&quot;; // or &quot;drizzle-orm/bun-sql/postgres&quot;
607const client = new SQL(process.env.DATABASE_URL!, { max: 10, idleTimeout: 30 });
608export const db = drizzle({ client });
609export async function checkDbConnection() { await db.execute(sql`SELECT 1`); }
610// Raw Bun SQL also works without Drizzle: await client`SELECT 1`
611</code></pre>
612</li>
613<li><p>Schema under <code>src/db/schema/</code> — all imports from <code>drizzle-orm/pg-core</code> (<code>apps/backend/src/db/schema.ts:12</code>, <code>src/index.ts:12</code> <code>drizzle-orm/pg-core/migrator</code>). Snapshots are <code>dialect: &quot;postgres&quot;</code> (<code>apps/backend/drizzle/*/snapshot.json:5</code>), service image is <code>postgres:18.0-alpine3.22</code> (<code>apps/backend/docker-compose.yml:14</code>) and <code>DATABASE_URL=postgresql://...</code> (<code>apps/backend/docker-compose.yml:10</code>, <code>
613.env.sample:7</code>).</p>
614</li>
615<li><p>Generated via <code>drizzle-kit pull</code> (introspects existing DB) + <code>drizzle-kit generate/migrate</code>.</p>
616</li>
617<li><p><code>src/db/migrate.ts</code> runs on startup when <code>RUN_DB_MIGRATIONS_ON_STARTUP=true</code> (see <code>apps/backend/src/index.ts:48-55</code>).</p>
618</li>
619<li><p>Keep migrations out of version control noise: PRs should not contain generated SQL — generate after merge (enforced via PR template).</p>
620</li>
621</ul>
622<hr>
623<h2>10. Caching / Queues / Jobs</h2>
624<ul>
625<li><strong>Valkey</strong> (single instance, Redis-compatible) for auth cache, rate limits, de-duplication.</li>
626<li><strong>BullMQ</strong> for mail queues, push queues.</li>
627<li><strong>croner</strong> for cron jobs (<code>src/cron/</code> + <code>src/scheduled.ts</code>), guarded by a Valkey distributed lock so only one replica runs.</li>
628<li>Image/proxy helpers (<code>imageproxy.ts</code>, <code>orpc/s3.ts</code>) build presigned URLs in the backend and cache them where possible — frontends never construct storage URLs (see §11 for S3).</li>
629</ul>
630<hr>
631<h2>11. Object Storage — S3 via Bun-native <code>S3Client</code></h2>
632<blockquote>
633<p><strong>No extra S3 SDK needed:</strong> S3 access uses <strong>Bun&#39;s built-in <code>S3Client</code></strong> (<code>import { S3Client } from &quot;bun&quot;</code> / <code>import { s3 } from &quot;bun&quot;</code>). Do <strong>not</strong> install <code>@aws-sdk/client-s3</code>, <code>@aws-sdk/s3-presigner</code>, <code>aws-sdk</code>, or <code>minio</code> — Bun ships a native S3 client with <code>S3File</code> (<code>Blob</code>-compatible), presigning, streaming/multipart, and <code>s3://</code> in <code>fetch</code>/<code>Bun.file</code>. See <a href="https://bun.sh/docs/runtime/s3">Bun S3 docs</a>.</p>
634</blockquote>
635<blockquote>
636<p><strong>Policy:</strong> Prefer a <strong>single central <code>S3Client</code> per bucket</strong> (e.g. <code>apps/backend/src/lib/s3.ts</code>). Pass it around; do not create ad-hoc <code>new S3Client()</code> in every procedure/handler.</p>
637</blockquote>
638<ul>
639<li><p>Central client in <code>apps/backend/src/lib/s3.ts:1-25</code> — sole place that reads env/credentials:</p>
640<pre><code class="language-ts">import { S3Client } from &quot;bun&quot;; // Bun built-in — no npm S3 SDK to install
641
642// Single central client per bucket — reuse everywhere
643export const s3 = new S3Client({
644  bucket: process.env.S3_BUCKET!,
645  // credentials auto-read from S3_ACCESS_KEY_ID / S3_SECRET_ACCESS_KEY
646  // (falls back to AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY) or set explicitly:
647  // accessKeyId: process.env.S3_ACCESS_KEY_ID!,
648  // secretAccessKey: process.env.S3_SECRET_ACCESS_KEY!,
649  // endpoint: process.env.S3_ENDPOINT, // R2 / MinIO / Spaces / Supabase — omit for AWS
650  // region: process.env.S3_REGION, // e.g. &quot;us-east-1&quot; / &quot;auto&quot; for R2
651});
652
653// Optional: also export a helpers bucket for presigned URL / exists checks
654export function s3File(key: string) { return s3.file(key); } // lazy S3File ref
655</code></pre>
656</li>
657<li><p>Usage from procedures / helpers (<code>src/orpc/s3.ts:1-40</code>, <code>src/orpc/procedures/*</code>):</p>
658<pre><code class="language-ts">import { s3, s3File } from &quot;../../lib/s3.ts&quot;;
659
660// Read (Blob-compatible — same API as Bun.file)
661const text = await s3File(&quot;user/123.json&quot;).text();
662const json = await s3File(&quot;user/123.json&quot;).json();
663const stream = s3File(&quot;large.bin&quot;).stream();
664
665// Write / upload (auto multipart for large streams)
666await s3.write(&quot;uploads/avatar.png&quot;, imageBuffer, { type: &quot;image/png&quot; });
667// or
668await s3File(&quot;uploads/avatar.png&quot;).write(imageBuffer, { type: &quot;image/png&quot; });
669await Bun.write(s3File(&quot;uploads/report.pdf&quot;), pdfBytes);
670
671// Presign (sync — no network request) — backend builds, frontend uses
672const downloadUrl = s3File(&quot;private/doc.pdf&quot;).presign({ expiresIn: 3600 });
673const uploadUrl = s3File(&quot;uploads/incoming.jpg&quot;).presign({ method: &quot;PUT&quot;, expiresIn: 600, type: &quot;image/jpeg&quot; });
674
675// Exists / delete / stat
676const exists = await s3.exists(&quot;uploads/avatar.png&quot;);
677await s3.delete(&quot;tmp/old.json&quot;);
678const { size, etag } = await s3File(&quot;uploads/avatar.png&quot;).stat();
679</code></pre>
680</li>
681<li><p>Credentials: <code>S3Client</code> auto-reads <code>S3_ACCESS_KEY_ID</code>, <code>S3_SECRET_ACCESS_KEY</code>, <code>S3_BUCKET</code>, <code>S3_ENDPOINT</code>, <code>S3_REGION</code> (with <code>AWS_*</code> fallbacks) from env/<code>.env</code> — no <code>process.env</code>
681 plumbing needed. Override per-client via constructor options — see <a href="https://bun.sh/docs/runtime/s3#credentials">Bun S3 credentials</a>. Works with AWS S3, Cloudflare R2 (<code>endpoint: &quot;https://&lt;account&gt;.r2.cloudflarestorage.com&quot;</code>), DigitalOcean Spaces, MinIO (<code>endpoint: &quot;http://localhost:9000&quot;</code>), Supabase — same <code>S3Client</code>.</p>
682</li>
683<li><p>Keep <code>packages/shared</code> free of S3 code; only the presigned <strong>URL string</strong> leaves the backend. Frontends never construct <code>s3://</code> paths or call <code>S3Client</code> — they receive <code>https://...</code> presigned URLs via oRPC (see §5.2 <code>orpc/s3.ts</code>).</p>
684</li>
685</ul>
686<hr>
687<h2>12. Deployment</h2>
688<h3>Docker — Minimal + Zero CVE Only</h3>
689<blockquote>
690<p><strong>Policy:</strong> <strong>Use <code>ghcr.io/philippdormann/bun:1.4.2</code> (minimal + zero CVE) for all Bun build/runtime stages.</strong> All Dockerfiles in this repo follow this — do not introduce <code>oven/bun</code>, <code>debian</code>/<code>slim-bullseye</code>/<code>ubuntu</code> bases for new services without justification.</p>
691</blockquote>
692<ul>
693<li>One <code>*.Dockerfile</code> per app at repo root (so <code>docker build -f apps/&lt;app&gt;/Dockerfile .</code> gets the whole monorepo context — actual files at <code>apps/backend/Dockerfile:1</code>, <code>apps/mobile/Dockerfile:1</code>, <code>apps/website/Dockerfile:1</code>).</li>
694<li>Backend/CDN: single-stage <code>ghcr.io/philippdormann/bun:1.4.2</code> (<code>apps/backend/Dockerfile:1</code>), <code>bun install --filter @scope/&lt;app&gt; --production</code>, <code>CMD [&quot;bun&quot;,&quot;run&quot;,&quot;src/index.ts&quot;]</code>.</li>
695<li>Frontends (static): multi-stage <code>ghcr.io/philippdormann/bun:1.4.2</code> build → <code>nginx:1.29-alpine3.23-slim</code> serve (<code>apps/mobile/Dockerfile:1</code>, <code>apps/mobile/Dockerfile:28</code>). The <code>-alpine-slim</code> nginx variant is still alpine-based.</li>
696<li>Website (SSR): multi-stage <code>ghcr.io/philippdormann/bun:1.4.2</code> build → <code>ghcr.io/philippdormann/bun:1.4.2</code> runtime (<code>apps/website/Dockerfile:1</code>, <code>apps/website/Dockerfile:34</code> <code>production</code> stage, <code>bun run build/index.js</code>). No <code>node:*-alpine</code> mixing unless strictly needed.</li>
697<li>Verification: CI builds all images via <code>docker buildx --platform linux/arm64</code> (<code>.gitlab-ci.yml:61</code>); local parity via <code>docker-compose.yml</code> per app (<code>apps/*/docker-compose.yml</code>). If you must use a non-minimal base, document why in the Dockerfile header comment.</li>
698</ul>
699<h3>Caddy (<code>Caddyfile</code>)</h3>
700<p>Caddy terminates TLS and reverse-proxies per subdomain:</p>
701<pre><code>api.example.com      → backend:3000
702cdn.example.com      → cdn:3000
703app.example.com      → frontend-a:80
704admin.example.com    → frontend-b:80
705example.com          → website:80
706</code></pre>
707<p>Add <code>redir</code> blocks for legacy URL compatibility (keep old links working).</p>
708<h3>Local Dev (<code>docker-compose.yml</code>)</h3>
709<p><code>valkey</code>, <code>mailpit</code>, <code>backend</code>, and each frontend (<code>:4000</code>, <code>:4001</code>, …) for parity. Backend waits for <code>valkey: healthy</code>.</p>
710<h3>Security — <code>bun audit</code> (CI Step 0)</h3>
711<blockquote>
712<p><strong>Requirement:</strong> Every tagged build runs <code>bun audit</code> as <strong>stage 0</strong> before any <code>build</code> job. Fix vulnerabilities before the build is allowed to start.</p>
713</blockquote>
714<p>In this repo (<code>.gitlab-ci.yml:1-14</code>):</p>
715<pre><code class="language-yaml">stages: [audit, build]           # audit is step 0 — always first
716audit:
717  stage: audit
718  image: ghcr.io/philippdormann/bun:1.4.2    # minimal + zero CVE (see Docker policy)
719  rules:
720    - if: $CI_COMMIT_TAG =~ /^backend-.*$/
721    - if: $CI_COMMIT_TAG =~ /^website-.*$/
722    - if: $CI_COMMIT_TAG =~ /^mobile-.*$/
723  script: [bun audit]             # fails the pipeline on audit findings
724</code></pre>
725<ul>
726<li><code>docker-build-*</code> jobs all have <code>needs: [audit]</code> (and <code>docker-build-backend</code> additionally <code>needs: [audit, typecheck-backend]</code> — <code>.gitlab-ci.yml:69-89</code>), so the <code>audit</code> gate is <strong>blocking</strong> — builds never start if auditing fails.</li>
727<li>Locally, run <code>bun audit</code> before pushing a release tag. For a quick pre-release check: <code>bun audit --help</code> and <code>bun pm pack</code> for advisory details.</li>
728<li>This gate exists to catch CVEs in the single <code>bun.lock</code> workspace before images are built/pushed.</li>
729</ul>
730<hr>
731<h2>13. Release Flow — Independent Per-App Versioning</h2>
732<p>Each app has its own <code>version</code> in <code>apps/&lt;app&gt;/package.json</code> and a <code>release-it</code> block:</p>
733<pre><code class="language-json">{
734  &quot;version&quot;: &quot;1.2.3&quot;,
735  &quot;scripts&quot;: { &quot;release&quot;: &quot;release-it&quot; },
736  &quot;release-it&quot;: {
737    &quot;git&quot;: {
738      &quot;commit&quot;: true, &quot;push&quot;: true, &quot;tag&quot;: true,
739      &quot;requireBranch&quot;: &quot;main&quot;, &quot;requireCleanWorkingDir&quot;: true,
740      &quot;commitMessage&quot;: &quot;chore(release): &lt;app&gt;-${version}&quot;,
741      &quot;tagName&quot;: &quot;&lt;app&gt;-${version}&quot;, &quot;tagAnnotation&quot;: &quot;&lt;app&gt;-${version}&quot;
742    },
743    &quot;npm&quot;: { &quot;publish&quot;: false },
744    &quot;hooks&quot;: { &quot;after:bump&quot;: &quot;bun run build-versioninfo.ts &amp;&amp; git add versioninfo.ts&quot; }
745  }
746}
747</code></pre>
748<p><strong>Flow:</strong></p>
749<ol>
750<li><p><code>bun --filter @scope/&lt;app&gt; release</code> (or <code>release-it</code> directly) bumps <code>package.json</code>, creates commit <code>chore(release): &lt;app&gt;-x.y.z</code>, tags <code>&lt;app&gt;-x.y.z</code>, pushes.</p>
751</li>
752<li><p><code>.gitlab-ci.yml</code> has one job per app, triggered only by matching tag. <strong>All jobs are gated by <code>audit</code> (step 0)</strong> — builds only run after <code>bun audit</code> passes (<code>.gitlab-ci.yml:69-89</code> <code>needs: [audit]</code>):</p>
753<pre><code class="language-yaml">audit:                          # stage: audit — step 0, blocking gate
754  stage: audit
755  image: ghcr.io/philippdormann/bun:1.4.2
756  script: [bun audit]
757
758backend:                        # stage: build
759  needs: [audit, typecheck-backend]
760  rules: [{ if: &#39;$CI_COMMIT_TAG =~ /^backend-\d+\.\d+\.\d+$/&#39; }]
761  script:
762    - docker buildx build --platform linux/arm64 -f &quot;apps/$APP_NAME/Dockerfile&quot;
763        -t &quot;$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG&quot; --push .
764</code></pre>
765<p>This produces floating major tags like <code>backend-4-arm64</code>, <code>frontend-a-2-arm64</code> — deploy pulls the major tag, no redeploy config change for patch/minor.</p>
766</li>
767<li><p>The same pattern applies to every frontend and <code>cdn</code>/<code>website</code>. Every <code>
767Dockerfile</code> must use <strong><code>ghcr.io/philippdormann/bun:1.4.2</code> (minimal + zero CVE)</strong> for Bun stages (see §12 Docker policy); non-minimal bases require justification.</p>
768</li>
769</ol>
770<p>Optional <code>scripts/update-*.ts</code> codegen runs in <code>prebuild</code> of frontends (e.g. generate <code>appversions.ts</code> from the live API) so builds stay in sync.</p>
771<hr>
772<h2>14. How to Replicate This Pattern From Scratch</h2>
773<ol>
774<li><strong>Init monorepo:</strong><pre><code class="language-bash">bun init
775# root package.json: { &quot;private&quot;: true, &quot;workspaces&quot;: [&quot;packages/*&quot;, &quot;apps/*&quot;] }
776</code></pre>
777</li>
778<li><strong>Add shared:</strong><pre><code class="language-bash">mkdir -p packages/shared
779# packages/shared/package.json with &quot;exports&quot; map, no build
780</code></pre>
781</li>
782<li><strong>Add backend:</strong><pre><code class="language-bash">mkdir -p apps/backend/src/{orpc/procedures,db,middleware}
783bun add -E @orpc/server @orpc/openapi @orpc/valibot valibot drizzle-orm
784# DB: no extra driver — use Bun&#39;s built-in SQL (https://bun.sh/docs/runtime/sql)
785# via `import { SQL } from &quot;bun&quot;` + `drizzle-orm/bun-sql`. Do NOT add pg/postgres/mysql2.
786# S3: no extra SDK — use Bun&#39;s built-in S3Client (https://bun.sh/docs/runtime/s3)
787# via `import { S3Client } from &quot;bun&quot;`. Do NOT add @aws-sdk/* / aws-sdk / minio.
788# src/server.ts: Bun.serve entry (sole listen point)
789# src/index.ts: type-only `export type RouterType = RouterClient&lt;typeof router&gt;`
790# package.json: { &quot;name&quot;: &quot;@scope/backend&quot;, &quot;exports&quot;: { &quot;.&quot;: { &quot;types&quot;: &quot;./src/index.ts&quot; } } }
791</code></pre>
792</li>
793<li><strong>Add a frontend:</strong><pre><code class="language-bash">bunx sv create apps/app-a   # choose SvelteKit + TS
794# add to apps/app-a/package.json:
795#   &quot;devDependencies&quot;: { &quot;@scope/backend&quot;: &quot;workspace:*&quot; }
796#   &quot;dependencies&quot;: { &quot;@scope/shared&quot;: &quot;workspace:*&quot;, &quot;@orpc/client&quot;: &quot;...&quot; }
797# create src/lib/orpc.ts as in §5.4 (SSR-safe: PUBLIC_API_BASE_URL + browser guard)
798# vite.config.ts: ssr.noExternal = [&quot;@scope/shared&quot;]
799# UI: prefer shadcn-svelte — https://www.shadcn-svelte.com/llms.txt (see §6.5)
800#   bunx shadcn-svelte@latest init &amp;&amp; bunx shadcn-svelte@latest add button card dialog input select tabs
801# class merging: use `cn` from &quot;cn&quot; (https://npmx.dev/package/cn) — not clsx/tailwind-merge/cnfast
802#   bun add -E cn &amp;&amp; export { cn } from &quot;cn&quot; in lib/utils.ts
803</code></pre>
804</li>
805<li><strong>Wire e2e types:</strong> Import <code>RouterType</code> from <code>@scope/backend</code> in <code>src/lib/orpc.ts</code> and create the client. No codegen step needed — TS resolves via <code>workspace:*</code>.</li>
806<li><strong>Add Dockerfiles</strong> per app at <code>apps/&lt;app&gt;/Dockerfile</code> (file lives here, always build with repo-root context <code>docker build -f apps/&lt;app&gt;/Dockerfile .</code>; all Bun stages minimal + zero CVE — <code>ghcr.io/philippdormann/bun:*</code>, <code>nginx:*-alpine*-slim</code>; see §12) + <code>Caddyfile</code> + <code>docker-compose.yml</code>.</li>
807<li><strong>Add release-it</strong> per app (<code>tagName: &quot;&lt;app&gt;-${version}&quot;</code>) and a CI job per app filtered on <code>^&lt;app&gt;-\d+\.\d+\.\d+$</code>, <strong>gated by an <code>audit</code> stage 0</strong> running <code>bun audit</code> with <code>needs: [audit]</code> on every build job (see §12 Security).</li>
808<li><strong>Add Postgres as default DB</strong> — use <strong>Bun&#39;s built-in <code>Bun.SQL</code></strong> (<code>SQL</code> from <code>&quot;bun&quot;</code>), no external driver (<code>pg</code>/<code>postgres</code>/<code>mysql2</code> not needed) — see <a href="https://bun.sh/docs/runtime/sql">Bun SQL docs</a>. Wire Drizzle via <code>drizzle-orm/bun-sql</code> (<code>postgres:*-alpine</code>, <code>dialect: &quot;postgresql&quot;</code> — see §9) and avoid MySQL/MariaDB for new code.</li>
809<li><strong>Add Valkey for cache/queues</strong> (<code>valkey/valkey:*-alpine</code>, Redis-compatible — see §10): single <code>valkey</code> service in <code>docker-compose.yml</code>, <code>ioredis</code> client + BullMQ queues in backend, Valkey distributed lock for <code>croner</code> jobs.</li>
810<li><strong>Add S3 object storage</strong> — use <strong>Bun&#39;s built-in <code>S3Client</code></strong> (<code>new S3Client()</code> from <code>&quot;bun&quot;</code>), no <code>@aws-sdk/*</code> / <code>minio</code> needed — see <a href="https://bun.sh/docs/runtime/s3">Bun S3 docs</a> and §11. Create central <code>apps/backend/src/lib/s3.ts</code> with <code>export const s3 = new S3Client({ bucket: process.env.S3_BUCKET! })</code> and reuse via <code>s3.file()</code> / <code>s3.write()</code> / <code>.presign()</code> (see §11).</li>
811<li><strong>Add Capacitor integration</strong> for native shells if needed: <code>capacitor.config.ts</code> (<code>webDir: &quot;build&quot;</code>), <code>bunx cap sync</code>, version sync scripts, plugins via <code>@capacitor/*</code> (see §6.0).</li>
812<li><strong>Add root scripts:</strong> <code>check</code>, <code>lint:all</code> via <code>bun --filter</code>.</li>
813</ol>
814<hr>
815<h2>15. Conventions &amp; Gotchas</h2>
816<ul>
817<li><strong>Timestamps:</strong>
817 Store as UNIX seconds in DB, return UNIX seconds from backend, format in frontend (<code>DD.MM.YYYY HH:mm</code> default; <code>DD.MM.YYYY</code> without time; <code>Europe/Berlin</code> for mails). Avoid <code>Date</code> strings in the DB.</li>
818<li><strong>Asset URLs:</strong> Always built in the backend (presigned + cached via Bun <code>S3Client</code> — see §11). Frontends never interpolate storage paths or construct <code>s3://</code> URLs.</li>
819<li><strong>UI components:</strong> Prefer <a href="https://www.shadcn-svelte.com/llms.txt">shadcn-svelte</a> where available (see §6.5) — do not re-implement <code>button</code>/<code>card</code>/<code>dialog</code>/<code>table</code>/<code>sonner</code> etc. when the registry provides them. Vendor via CLI, theme via <code>components.json</code>. For class merging use <a href="https://npmx.dev/package/cn"><code>cn</code></a> (<code>import { cn } from &quot;cn&quot;</code>) — not <code>clsx</code>/<code>tailwind-merge</code>/<code>cnfast</code>.</li>
820<li><strong>Fonts:</strong> Use <strong>Fontsource</strong> self-hosted (<code>@fontsource/*</code> via <code>bun add -E</code>) — do not use Google Fonts CDN. Privacy-first, no external request, explicit subsets/weights (see §8 Fonts Policy).</li>
821<li><strong>Notifications (e.g. Telegram):</strong> Never include PII; link to profile/ID instead.</li>
822<li><strong>CORS:</strong> Central <code>isOriginAllowed()</code> used by both <code>CORSPlugin</code> instances (RPC + OpenAPI) and fallback 404 handler.</li>
823<li><strong>Auth:</strong> Validate JWT in middleware, cache resolved user in Valkey (<code>auth:user:&lt;id&gt;</code> or <code>auth:user:&lt;id&gt;:tenant:&lt;tenantId&gt;</code> for multi-tenant setups), invalidate on permission change. Guard against cross-user-type token reuse by checking <code>usertype</code>/<code>role</code> claims.</li>
824<li><strong>OpenAPI docs:</strong> <code>OpenAPIReferencePlugin</code> with <code>scalar</code> + <code>experimental_ValibotToJsonSchemaConverter</code> auto-derives the spec from the same <code>os.route()</code> definitions — no manual spec.</li>
825<li><strong>No <code>as</code> assertions (except <code>as const</code>):</strong> No <code>: any</code>, <code>as any</code>, <code>as unknown</code>, <code>as unknown as</code>, <code>as Record</code>, <code>as string</code>, or other type assertions that kill type safety. Prefer discriminated unions, <code>in</code> / <code>typeof</code> narrowing, and Valibot inference (<code>v.safeParse</code> + <code>parsed.output</code>, <code>v.InferOutput</code>). Keep <code>strict</code> on.</li>
826<li><strong>Commits:</strong> Conventional commits with optional scope (<code>fix(backend): ...</code>), small commits, no generated migrations in PRs.</li>
827<li><strong>Confirm modals for critical interactions:</strong> Never use <code>window.alert</code>/<code>confirm</code>/<code>prompt</code> — use shadcn-svelte <code>AlertDialog</code>/<code>Dialog</code> (see §6.6). Every destructive/irreversible action requires explicit confirm modal with plain-language impact + count.</li>
828<li><strong>Non-technical user-facing texts by default:</strong> Default all UI copy to plain language (DE/EN), no jargon/IDs/codes in toasts or errors; map technical errors to friendly messages (see §6.6).</li>
829<li><strong>Bulk actions where useful:</strong> Lists/tables with per-item mutations must also expose multi-select + bulk bar + bulk confirm (see §6.6).</li>
830</ul>
831<hr>
832<h2>16. Further Reading</h2>
833<ul>
834<li>oRPC docs: <code>https://orpc.dev/llms.txt</code> and subpages (canonical reference for <code>os</code>, <code>RPCHandler</code>, <code>OpenAPIHandler</code>, <code>RPCLink</code>).</li>
835<li>Svelte 5 runes: <code>https://svelte.dev/docs/svelte/v5-migration-guide</code></li>
836<li>shadcn-svelte (prefer where available — UI components via CLI, Tailwind 4 + Bits UI): <code>https://www.shadcn-svelte.com/llms.txt</code> (catalog: <code>button</code>, <code>card</code>, <code>dialog</code>, <code>table</code>, <code>sonner</code>, <code>chart</code>, <code>data-table</code>, etc.)</li>
837<li><code>cn</code> (class merging for Tailwind — use instead of <code>clsx</code>/<code>tailwind-merge</code>/<code>cnfast</code> in frontends): <code>https://npmx.dev/package/cn</code> (<code>import { cn } from &quot;cn&quot;</code>)</li>
838<li>Fontsource (self-hosted fonts — use instead of Google Fonts CDN, privacy-first): <code>https://fontsource.org</code></li>
839<li>Drizzle ORM: <code>
839https://orm.drizzle.team</code></li>
840<li>Bun SQL (native driver, no <code>pg</code>/<code>postgres</code>/<code>mysql2</code> needed): <code>https://bun.sh/docs/runtime/sql</code></li>
841<li>Bun S3 (native <code>S3Client</code>, no <code>@aws-sdk/*</code> needed — central <code>new S3Client()</code> per bucket): <code>https://bun.sh/docs/runtime/s3</code></li>
842<li>Release-it: <code>https://github.com/release-it/release-it</code></li>
843</ul>
844<!----></div></section><!--]--><!--]--><!--]--><!----></main> <footer class="footer"><div class="footer-inner"><p class="footer-copy">© 2026 Philipp Dormann</p> <nav class="footer-links" aria-label="Fußzeile Navigation"><a href="/impressum/">Impressum</a> <a href="/datenschutz/">Datenschutz</a></nav></div></footer><!--]--><!--]--><!--]--><!----> <!--[-1--><!--]--><!--]-->
845			
846			
846<script>
847				{
848					__sveltekit_xnp7w0 = {
849						base: new URL("..", location).pathname.slice(0, -1),
850						version: "1790288855734"
851					};
852
853					const element = document.currentScript.parentElement;
854
855					import("../_app/immutable/entry/start.CMFHRKFM.js").then(async (kit) => {
856						kit.init(__sveltekit_xnp7w0);
857						const app = await import("../_app/immutable/entry/app.D9422SKp.js");
858						kit.start(app, element, {
859							node_ids: [0, 5],
860							data: [null,null],
861							form: null,
862							error: null
863						});
864					});
865
866					if ('serviceWorker' in navigator) {
867						const script_url = '../service-worker.js';
868						const policy = globalThis?.window?.trustedTypes?.createPolicy(
869							'sveltekit-trusted-url',
870							{ createScriptURL(url) { return url; } }
871						);
872						const sanitised = policy?.createScriptURL(script_url) ?? script_url;
873						addEventListener('load', function () {
874							navigator.serviceWorker.register(sanitised, { type: 'module' });
875						});
876					}
877				}
878			</script>
878
879		</div>
880	</body>
881</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.