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 & 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 & 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 & 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 & 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 & 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 & CSS</strong>, via <strong class="svelte-wp987f">JavaScript</strong>, now almost exclusively <strong class="svelte-wp987f">
35TypeScript, Docker, type safety & 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'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'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/<app>/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 # "exports" 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: { ".": { "types": "./src/index.ts" } } (types-only) 150 âââ <frontend-a>/ # SvelteKit + adapter-static â nginx 151 âââ <frontend-b>/ # SvelteKit + adapter-static â nginx 152 âââ <frontend-c>/ # 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 & Workspaces</h2> 159<p>Root <code>package.json:19-22</code>:</p> 160<pre><code class="language-json">{ 161 "private": true, 162 "type": "module", 163 "workspaces": ["packages/*", "apps/*"] 164} 165</code></pre> 166<ul> 167<li><p>Use <code>bun add -E <pkg></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">{ "dependencies": { "@scope/shared": "workspace:*" } } 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">{ "devDependencies": { "@scope/backend": "workspace:*" } } 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 "scripts": { 183 "check": "bun --filter @scope/backend typecheck && bun --filter @scope/app-a check && ...", 184 "lint:all": "bun --filter @scope/backend lint && ..." 185 } 186} 187</code></pre> 188<p><code>bun --filter <pkg></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 "compilerOptions": { 194 "lib": ["ESNext"], "target": "ESNext", 195 "module": "Preserve", "moduleResolution": "bundler", 196 "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, 197 "noEmit": true, "strict": true, 198 "skipLibCheck": true, 199 "noFallthroughCasesInSwitch": true, 200 "noUncheckedIndexedAccess": true, 201 "noImplicitOverride": 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 "@orpc/server/fetch"; 216import { OpenAPIHandler } from "@orpc/openapi/fetch"; 217import { CORSPlugin } from "@orpc/server/plugins"; 218import { CompressionPlugin } from "@orpc/server/fetch"; 219import { OpenAPIReferencePlugin } from "@orpc/openapi/plugins"; 220import { experimental_ValibotToJsonSchemaConverter } from "@orpc/valibot"; 221
222const cors = new CORSPlugin({ origin: o => 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: "scalar", docsPath: "/docs", specPath: "/openapi.json", 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("/rpc")) { 239 const res = await rpcHandler.handle(req, { prefix: "/rpc", context: ctx }); 240 return res.matched ? res.response : new Response("Not Found", { status: 404 }); 241 } 242 const res = await openApiHandler.handle(req, { context: ctx }); 243 return res.matched ? res.response : new Response("Not Found", { 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 & 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 "@orpc/server"; 265import * as v from "valibot"; 266 267export const myProcedure = os 268 .route({ 269 method: "GET", // also drives OpenAPI method 270 path: "/v1/my-resource", // also drives OpenAPI path 271 tags: ["MyDomain"], 272 summary: "...", 273 description: "...", 274 }) 275 .input(v.object({ id: v.string(), includeArchived: v.optional(v.string()) })) 276 // .use(authMiddleware).use(requirePermission("my:read")) 277 .handler(async ({ input, context }) => { 278 // context: ORPCContext â typed DB + valkey + request 279 return { status: "ok", code: "OK", data: { ... } }; 280 }); 281</code></pre> 282<p>Then aggregate in <code>router.ts</code>:</p> 283<pre><code class="language-ts">import { os } from "@orpc/server"; 284import * as domain from "./procedures/domain"; 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<ORPCAppRouterType> 319 â 320 â imported as devDependency "@scope/backend": "workspace:*" 321 â¼ 322frontend/src/lib/orpc.ts â createORPCClient<RouterType>(new RPCLink({ url: "/rpc", headers: ... })) 323 â 324 â¼ 325frontend code â orpc.myProcedure({ id: "..." }) // fully typed input/output 326</code></pre> 327<p><strong>Backend exports types only</strong> (<code>package.json</code> <code>exports: { ".": { "types": "./src/index.ts" } }</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 "$app/environment"; 330import { PUBLIC_API_BASE_URL } from "$env/static/public"; 331import { createORPCClient } from "@orpc/client"; 332import { RPCLink } from "@orpc/client/fetch"; 333import { ClientRetryPlugin, DedupeRequestsPlugin } from "@orpc/client/plugins"; 334import type { RouterType } from "@scope/backend"; 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 && window.location.hostname === "localhost" 339 ? "http://localhost:3000" 340 : PUBLIC_API_BASE_URL; 341 342const link = new RPCLink({ 343 url: `${baseUrl}/rpc`, 344 headers: () => { 345 if (!browser) return {}; 346 const t = localStorage.getItem("auth"); 347 return t ? { Authorization: `Bearer ${t}` } : {}; 348 }, 349 fetch: (req, init) => { 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 }) => request.method === "GET", groups: [{ condition: () => true, context: {} }] }), 357 ], 358}); 359 360export const orpc = createORPCClient<RouterType>(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<T>(raw: { data: T } | T): T { 365 if (typeof raw === "object" && raw !== null && "data" 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: "newMessage", 376 TYPING: "typing", 377 // ... 378} as const; 379export const WsServerType = { 380 MESSAGE: "message", 381 TYPING: "typing", 382 UNREAD_UPDATED: "unreadUpdated", 383 ERROR: "error", 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 "valibot"; 390import { ChatClientType, WsServerType } from "@scope/shared/ws"; 391 392const EnvelopeSchema = v.object({ type: v.str
392ing(), payload: v.optional(v.unknown()) }); 393type Envelope = v.InferOutput<typeof EnvelopeSchema>; 394type AuthedClient = { userId: string }; 395 396const clients = new Map<ServerWebSocket, AuthedClient>(); 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) => 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<Record<string, WsHandler>> = { 411 [ChatClientType.NEW_MESSAGE]: (ws, msg, client) => { 412 const parsed = v.safeParse(MessagePostSchema, msg.payload); 413 if (!parsed.success) { sendError(ws, "INVALID_PAYLOAD"); return; } 414 // ... use parsed.output with full type safety 415 }, 416 [ChatClientType.TYPING]: (ws, msg, client) => { /* ... */ }, 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, "INVALID_PAYLOAD"); 426 return; 427 } 428 const envelope = v.safeParse(EnvelopeSchema, decoded); 429 if (!envelope.success) { sendError(ws, "INVALID_PAYLOAD"); return; } 430 const client = clients.get(ws); 431 if (!client) { sendError(ws, "NOT_AUTHED"); 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/<frontend>/src/lib/websocket.ts</code>):</p> 437<pre><code class="language-ts">import { browser } from "$app/environment"; 438import { PUBLIC_API_BASE_URL } from "$env/static/public"; 439import type { ChatSocketPayload } from "@scope/shared/ws"; 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/, "ws")}/v1/ws`); 448 wsUrl.searchParams.set("auth", 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: "mobile.my.app"</code>, <code>webDir: "build"</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 "@sveltejs/adapter-static"; 490import { sveltekit } from "@sveltejs/kit/vite"; 491import tailwindcss from "@tailwindcss/vite"; 492export default defineConfig({ 493 plugins: [tailwindcss(), sveltekit({ 494 adapter: adapterStatic({ pages: "build", assets: "build", fallback: "app.html", precompress: true }), 495 })], 496 ssr: { noExternal: ["@scope/shared"] }, 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: { "#lib": "./src/lib/index.js" }</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'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 "cn"</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("px-2 py-1", isActive && "bg-blue-500", { "text-white": isActive }, className)</code>. Legacy <code>lib/utils.ts</code> wrapper (<code>twMerge(clsx(...))</code>) is replaced by <code>export { cn } from "cn"</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>"Could not save â please try again."</code> not <code>"500: pg error 23505"</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 "name": "@scope/shared", 547 "private": true, 548 "type": "module", 549 "exports": { 550 "./format-date": { "types": "./format-date.ts", "default": "./format-date.ts" }, 551 "./Logo.svelte": { "types": "./Logo.svelte", "default": "./Logo.svelte" }, 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>"cn"</code> (not <code>clsx</code>/<code>tailwind-merge</code>/<code>cnfast</code>) â <code>cn("px-2 py-1", condition && "bg-blue-500", 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/<font></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 "@fontsource/inter/latin-400.css"</code> / <code>import "@fontsource/inter/latin-700.css"</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 & 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: "postgresql"</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's built-in <code>Bun.SQL</code></strong> (<code>import { SQL } from "bun"</code> / <code>import { sql } from "bun"</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 "bun"; // Bun built-in â no npm driver to install 606import { drizzle } from "drizzle-orm/bun-sql"; // or "drizzle-orm/bun-sql/postgres" 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: "postgres"</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's built-in <code>S3Client</code></strong> (<code>import { S3Client } from "bun"</code> / <code>import { s3 } from "bun"</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 "bun"; // 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. "us-east-1" / "auto" 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 "../../lib/s3.ts"; 659 660// Read (Blob-compatible â same API as Bun.file) 661const text = await s3File("user/123.json").text(); 662const json = await s3File("user/123.json").json(); 663const stream = s3File("large.bin").stream(); 664 665// Write / upload (auto multipart for large streams) 666await s3.write("uploads/avatar.png", imageBuffer, { type: "image/png" }); 667// or 668await s3File("uploads/avatar.png").write(imageBuffer, { type: "image/png" }); 669await Bun.write(s3File("uploads/report.pdf"), pdfBytes); 670 671// Presign (sync â no network request) â backend builds, frontend uses 672const downloadUrl = s3File("private/doc.pdf").presign({ expiresIn: 3600 }); 673const uploadUrl = s3File("uploads/incoming.jpg").presign({ method: "PUT", expiresIn: 600, type: "image/jpeg" }); 674 675// Exists / delete / stat 676const exists = await s3.exists("uploads/avatar.png"); 677await s3.delete("tmp/old.json"); 678const { size, etag } = await s3File("uploads/avatar.png").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: "https://<account>.r2.cloudflarestorage.com"</code>), DigitalOcean Spaces, MinIO (<code>endpoint: "http://localhost:9000"</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/<app>/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/<app> --production</code>, <code>CMD ["bun","run","src/index.ts"]</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/<app>/package.json</code> and a <code>release-it</code> block:</p> 733<pre><code class="language-json">{ 734 "version": "1.2.3", 735 "scripts": { "release": "release-it" }, 736 "release-it": { 737 "git": { 738 "commit": true, "push": true, "tag": true, 739 "requireBranch": "main", "requireCleanWorkingDir": true, 740 "commitMessage": "chore(release): <app>-${version}", 741 "tagName": "<app>-${version}", "tagAnnotation": "<app>-${version}" 742 }, 743 "npm": { "publish": false }, 744 "hooks": { "after:bump": "bun run build-versioninfo.ts && git add versioninfo.ts" } 745 } 746} 747</code></pre> 748<p><strong>Flow:</strong></p> 749<ol> 750<li><p><code>bun --filter @scope/<app> release</code> (or <code>release-it</code> directly) bumps <code>package.json</code>, creates commit <code>chore(release): <app>-x.y.z</code>, tags <code><app>-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: '$CI_COMMIT_TAG =~ /^backend-\d+\.\d+\.\d+$/' }] 761 script: 762 - docker buildx build --platform linux/arm64 -f "apps/$APP_NAME/Dockerfile" 763 -t "$CI_REGISTRY_IMAGE:$CI_COMMIT_TAG" --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: { "private": true, "workspaces": ["packages/*", "apps/*"] } 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 "exports" 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's built-in SQL (https://bun.sh/docs/runtime/sql) 785# via `import { SQL } from "bun"` + `drizzle-orm/bun-sql`. Do NOT add pg/postgres/mysql2. 786# S3: no extra SDK â use Bun's built-in S3Client (https://bun.sh/docs/runtime/s3) 787# via `import { S3Client } from "bun"`. 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<typeof router>` 790# package.json: { "name": "@scope/backend", "exports": { ".": { "types": "./src/index.ts" } } } 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# "devDependencies": { "@scope/backend": "workspace:*" } 796# "dependencies": { "@scope/shared": "workspace:*", "@orpc/client": "..." } 797# create src/lib/orpc.ts as in §5.4 (SSR-safe: PUBLIC_API_BASE_URL + browser guard) 798# vite.config.ts: ssr.noExternal = ["@scope/shared"] 799# UI: prefer shadcn-svelte â https://www.shadcn-svelte.com/llms.txt (see §6.5) 800# bunx shadcn-svelte@latest init && bunx shadcn-svelte@latest add button card dialog input select tabs 801# class merging: use `cn` from "cn" (https://npmx.dev/package/cn) â not clsx/tailwind-merge/cnfast 802# bun add -E cn && export { cn } from "cn" 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/<app>/Dockerfile</code> (file lives here, always build with repo-root context <code>docker build -f apps/<app>/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: "<app>-${version}"</code>) and a CI job per app filtered on <code>^<app>-\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's built-in <code>Bun.SQL</code></strong> (<code>SQL</code> from <code>"bun"</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: "postgresql"</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's built-in <code>S3Client</code></strong> (<code>new S3Client()</code> from <code>"bun"</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: "build"</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 & 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 "cn"</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:<id></code> or <code>auth:user:<id>:tenant:<tenantId></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 "cn"</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.