@finesoft/front 0.1.76 → 0.1.77

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/docs/01-getting-started.md +230 -0
  2. package/docs/02-routing-and-controllers.md +197 -0
  3. package/docs/03-middleware.md +214 -0
  4. package/docs/04-rendering-and-hydration.md +271 -0
  5. package/docs/05-i18n.md +243 -0
  6. package/docs/06-http-client.md +286 -0
  7. package/docs/07-di-container.md +264 -0
  8. package/docs/08-observability.md +290 -0
  9. package/docs/09-server-and-deployment.md +242 -0
  10. package/docs/10-features-platform-pwa.md +238 -0
  11. package/docs/README.md +72 -0
  12. package/docs/advanced/custom-action-handler.md +248 -0
  13. package/docs/advanced/custom-adapter.md +264 -0
  14. package/docs/advanced/custom-event-recorder.md +318 -0
  15. package/docs/advanced/inline-proxy-codegen.md +200 -0
  16. package/docs/advanced/multi-tenant-scopes.md +330 -0
  17. package/docs/engineering/ci-release-flow.md +244 -0
  18. package/docs/engineering/project-structure.md +296 -0
  19. package/docs/engineering/testing.md +317 -0
  20. package/docs/pitfalls/container-scope-leak.md +215 -0
  21. package/docs/pitfalls/i18n-bundle-size.md +182 -0
  22. package/docs/pitfalls/proxy-binary-payloads.md +133 -0
  23. package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
  24. package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
  25. package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
  26. package/docs/zh/01-getting-started.md +230 -0
  27. package/docs/zh/02-routing-and-controllers.md +197 -0
  28. package/docs/zh/03-middleware.md +214 -0
  29. package/docs/zh/04-rendering-and-hydration.md +271 -0
  30. package/docs/zh/05-i18n.md +243 -0
  31. package/docs/zh/06-http-client.md +286 -0
  32. package/docs/zh/07-di-container.md +264 -0
  33. package/docs/zh/08-observability.md +287 -0
  34. package/docs/zh/09-server-and-deployment.md +242 -0
  35. package/docs/zh/10-features-platform-pwa.md +238 -0
  36. package/docs/zh/README.md +72 -0
  37. package/docs/zh/advanced/custom-action-handler.md +248 -0
  38. package/docs/zh/advanced/custom-adapter.md +264 -0
  39. package/docs/zh/advanced/custom-event-recorder.md +318 -0
  40. package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
  41. package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
  42. package/docs/zh/engineering/ci-release-flow.md +244 -0
  43. package/docs/zh/engineering/project-structure.md +296 -0
  44. package/docs/zh/engineering/testing.md +317 -0
  45. package/docs/zh/pitfalls/container-scope-leak.md +215 -0
  46. package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
  47. package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
  48. package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
  49. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
  50. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
  51. package/package.json +2 -1
@@ -0,0 +1,244 @@
1
+ # Engineering: CI & release flow
2
+
3
+ How the framework itself is released, and how to set up the same workflow for an app that depends on it.
4
+
5
+ ## What ships
6
+
7
+ Only `@finesoft/front` is published to npm. The internal `core` / `browser` / `ssr` / `server` packages are bundled into `front` via `tsdown`'s `noExternal: [@finesoft/*]`.
8
+
9
+ This means:
10
+
11
+ - One npm package for users to install
12
+ - Internal refactors don't bump multiple versions
13
+ - Single CHANGELOG to read
14
+
15
+ `create-finesoft-app` is its own published package (a CLI), separate from the framework runtime.
16
+
17
+ ## The release workflow
18
+
19
+ The repo ships a single `.github/workflows/release.yml` that handles everything inline. Trigger: push to `main`.
20
+
21
+ ```
22
+ push to main
23
+
24
+
25
+ Checkout main (with PAT, not GITHUB_TOKEN)
26
+
27
+
28
+ Reconcile npm registry with main
29
+ ├── npm == main? → continue
30
+ ├── main > npm? → catch-up publish current main version
31
+ └── npm > main? → error, manual investigation
32
+
33
+
34
+ Generate auto-changeset (one patch per push)
35
+
36
+
37
+ Apply version bump
38
+ ├── changes? → continue
39
+ └── no changes? → done, nothing to publish
40
+
41
+
42
+ Commit "chore(release): version packages"
43
+
44
+
45
+ Build all packages, publish @finesoft/front to npm
46
+
47
+
48
+ Push commit + tag back to main (with rebase retry)
49
+ ```
50
+
51
+ ### Why one inline workflow instead of changesets/action's PR mode
52
+
53
+ The standard changesets workflow opens a PR ("Version Packages") that, when merged, triggers a second workflow run that publishes. **But `GITHUB_TOKEN`-merged commits don't trigger subsequent workflows** (GitHub's anti-recursion safety) — the publish never fires. The inline workflow does everything in one run, no PR hop.
54
+
55
+ ### Why a PAT instead of `GITHUB_TOKEN`
56
+
57
+ The repo has a ruleset enforcing signed commits, linear history, and required PRs on `main`. Bypass actors include `RepositoryRole=5 (admin)` but **not** `github-actions[bot]`. GitHub's UI does not allow adding the bot to the bypass list. Push from a PAT owned by an admin user matches the existing bypass entry.
58
+
59
+ The PAT is `Contents: Read & Write` only — the minimum needed for `git push`.
60
+
61
+ ## Concurrency
62
+
63
+ ```yaml
64
+ concurrency: release-${{ github.ref }}
65
+ ```
66
+
67
+ Multiple pushes to `main` queue rather than cancel. This matters because:
68
+
69
+ - Cancellation mid-publish leaves npm in an inconsistent state
70
+ - Each push must wait for the previous to finish to avoid version-number races
71
+ - The next run's reconcile step picks up whatever the previous one published
72
+
73
+ ## Idempotency
74
+
75
+ `changeset publish` skips versions already on npm. So if push to `main` fails after publish:
76
+
77
+ - npm: has version 0.1.75
78
+ - main: stuck at 0.1.74
79
+
80
+ The next release run's reconcile detects `main < npm`, refuses to "catch up backwards," and errors out. Manual remediation: open a PR that bumps `packages/front/package.json` to match npm and merges. Subsequent pushes resume normally.
81
+
82
+ ## Setting up the same for an app
83
+
84
+ Most apps don't need a publish step — they have deploys instead. But the changeset + auto-bump shape still works:
85
+
86
+ ```yaml
87
+ name: Release
88
+
89
+ on:
90
+ push:
91
+ branches:
92
+ - main
93
+
94
+ concurrency: release-${{ github.ref }}
95
+
96
+ jobs:
97
+ release:
98
+ runs-on: ubuntu-latest
99
+ if: "!startsWith(github.event.head_commit.message, 'chore(release):')"
100
+ permissions:
101
+ contents: write
102
+ steps:
103
+ - uses: actions/checkout@v5
104
+ with:
105
+ ref: main
106
+ fetch-depth: 0
107
+ token: ${{ secrets.RELEASE_PUSH_TOKEN }}
108
+
109
+ - uses: voidzero-dev/setup-vp@v1
110
+ with:
111
+ node-version: 24
112
+ cache: true
113
+
114
+ - run: vp install --frozen-lockfile
115
+
116
+ - name: Configure git
117
+ run: |
118
+ git config user.name "github-actions[bot]"
119
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
120
+
121
+ - name: Generate auto changeset
122
+ run: vp run release:auto:changeset
123
+
124
+ - name: Apply version bump
125
+ id: bump
126
+ run: |
127
+ vp run version
128
+ if git diff --quiet; then
129
+ echo "should_publish=false" >> "$GITHUB_OUTPUT"
130
+ else
131
+ NEW=$(node -p "require('./package.json').version")
132
+ echo "version=$NEW" >> "$GITHUB_OUTPUT"
133
+ echo "should_publish=true" >> "$GITHUB_OUTPUT"
134
+ fi
135
+
136
+ - name: Commit version
137
+ if: steps.bump.outputs.should_publish == 'true'
138
+ run: |
139
+ git add -A
140
+ git commit -m "chore(release): version packages"
141
+
142
+ - name: Build
143
+ if: steps.bump.outputs.should_publish == 'true'
144
+ run: vp run build
145
+
146
+ - name: Deploy
147
+ if: steps.bump.outputs.should_publish == 'true'
148
+ run: vp run deploy # your deploy command
149
+
150
+ - name: Push tag and commit
151
+ if: steps.bump.outputs.should_publish == 'true'
152
+ run: git push --follow-tags origin HEAD:main
153
+ ```
154
+
155
+ Replace `vp run deploy` with your platform's deploy command (Vercel, Cloudflare, your own infra).
156
+
157
+ ## Conventional commits + auto-changeset
158
+
159
+ The `release:auto:changeset` script (in this repo, generates one patch changeset per push) is intentionally simple — every merged PR becomes one patch bump. For semver-driven versioning, replace it with a script that:
160
+
161
+ - Reads `git log` since the last tag
162
+ - Maps commit prefixes (`feat:`, `fix:`, `BREAKING:`) to changeset types
163
+ - Writes the right `.changeset/*.md`
164
+
165
+ The framework's repo uses patch-only because:
166
+
167
+ - Every push is a small change; large changes go through review and become small commits anyway
168
+ - Real breaking changes are rare and warrant manual changesets
169
+ - It avoids a class of "the prefix lies" bugs
170
+
171
+ Pick the policy that matches how your team commits.
172
+
173
+ ## Per-PR validation (`quality.yml`)
174
+
175
+ The repo also has a `quality.yml` workflow on PRs:
176
+
177
+ ```yaml
178
+ on:
179
+ pull_request:
180
+ push:
181
+ branches:
182
+ - main
183
+
184
+ jobs:
185
+ coverage:
186
+ runs-on: ubuntu-latest
187
+ steps:
188
+ - uses: actions/checkout@v5
189
+ - uses: voidzero-dev/setup-vp@v1
190
+ with: { node-version: 24, cache: true }
191
+ - run: vp install --frozen-lockfile
192
+ - run: vp test --coverage
193
+ - uses: actions/upload-artifact@v7
194
+ with:
195
+ name: coverage-report
196
+ path: reports/coverage
197
+ if-no-files-found: error
198
+ ```
199
+
200
+ The `check` job (format + lint + types) is gated off by `if: false` in this repo because Vite+ runs them all locally via pre-commit. Re-enable it if your team doesn't run pre-commit hooks consistently.
201
+
202
+ ## CodeQL
203
+
204
+ The repo enables CodeQL on a schedule and PRs. Scan scope is restricted to `packages/{core,browser,ssr,server,front}/src/**` — tests, templates, scripts, and the scaffolder are excluded.
205
+
206
+ For application repos, enable the default CodeQL config — its noise is low and it catches real issues (open redirects, SQL injection, secret exposure).
207
+
208
+ ## Required status checks
209
+
210
+ The repo ruleset requires:
211
+
212
+ - `Coverage` (from `quality.yml`)
213
+ - `CodeQL`
214
+
215
+ PRs cannot merge until both pass. The release workflow bypasses these via the admin PAT — release runs after merge, on `main`, so the checks already passed on the PR.
216
+
217
+ ## Migration: from changesets PR mode to inline
218
+
219
+ If you're moving an existing repo from `changesets/action` (PR mode):
220
+
221
+ 1. Delete the old release workflow
222
+ 2. Create the inline workflow above
223
+ 3. Generate a fine-grained PAT, store as `RELEASE_PUSH_TOKEN`
224
+ 4. The first push to main after this change will:
225
+ - Detect main == npm (no catch-up needed)
226
+ - Generate one patch changeset
227
+ - Bump + publish + push back to main
228
+
229
+ If there's a pending "Version Packages" PR from the old workflow, close it without merging. The auto-changeset will pick up everything from there.
230
+
231
+ ## What can go wrong
232
+
233
+ | Symptom | Cause | Fix |
234
+ | ----------------------------------------------- | --------------------------------------------------------------------------------- | -------------------------------------------------------------- |
235
+ | `[remote rejected] HEAD -> main` | PAT actor not in ruleset bypass; PAT lacks `Contents: Write` | Verify PAT scope; confirm push actor is an admin user |
236
+ | `npm ... is ahead of main` | Previous run published but failed to push | Open a PR syncing `packages/front/package.json` to npm version |
237
+ | Workflow doesn't trigger after a release commit | `if: "!startsWith(github.event.head_commit.message, 'chore(release):'"` filtering | Working as intended — recursion prevention |
238
+ | Pre-commit hook (`vp check`) fails CI | Local formatter not run | `vp check --fix` locally; commit; rerun |
239
+
240
+ ## See also
241
+
242
+ - The actual release workflow: `.github/workflows/release.yml`
243
+ - The actual quality workflow: `.github/workflows/quality.yml`
244
+ - [Changesets docs](https://github.com/changesets/changesets) — for understanding `vp run version` and `vp run release`
@@ -0,0 +1,296 @@
1
+ # Engineering: project structure
2
+
3
+ Recommended layout for apps past the scaffolded starter. The shape that works for ~20 routes and ~10 engineers without major reorganization.
4
+
5
+ ## Layout
6
+
7
+ ```
8
+ my-app/
9
+ ├── src/
10
+ │ ├── bootstrap.ts # routes + DI setup (shared SSR + CSR)
11
+ │ ├── main.ts # browser entry
12
+ │ ├── ssr.ts # SSR entry
13
+ │ ├── App.vue # root component
14
+ │ │
15
+ │ ├── routes/ # route definitions, grouped by domain
16
+ │ │ ├── home.ts
17
+ │ │ ├── product.ts
18
+ │ │ ├── checkout.ts
19
+ │ │ └── admin.ts
20
+ │ │
21
+ │ ├── controllers/ # controllers, one file per intent
22
+ │ │ ├── home.ts
23
+ │ │ ├── product.ts
24
+ │ │ └── checkout.ts
25
+ │ │
26
+ │ ├── views/ # view components, mirror controllers
27
+ │ │ ├── Home.vue
28
+ │ │ ├── Product.vue
29
+ │ │ └── Checkout.vue
30
+ │ │
31
+ │ ├── lib/
32
+ │ │ ├── api/ # HttpClient subclasses
33
+ │ │ │ ├── user.ts
34
+ │ │ │ └── product.ts
35
+ │ │ ├── guards/ # reusable middleware
36
+ │ │ │ ├── auth.ts
37
+ │ │ │ ├── locale.ts
38
+ │ │ │ └── analytics.ts
39
+ │ │ ├── di/
40
+ │ │ │ ├── keys.ts # APP_KEYS const map
41
+ │ │ │ └── register.ts # central registration
42
+ │ │ ├── i18n/
43
+ │ │ │ └── translator.ts # Translator factory
44
+ │ │ └── pages/
45
+ │ │ └── types.ts # Page union type
46
+ │ │
47
+ │ ├── locales/
48
+ │ │ ├── en-US.json
49
+ │ │ ├── zh-Hans.json
50
+ │ │ └── ja-JP.json
51
+ │ │
52
+ │ └── env.ts # env var parsing + validation
53
+
54
+ ├── public/ # static assets, served at /
55
+ ├── index.html
56
+ ├── vite.config.ts
57
+ ├── package.json
58
+ └── tsconfig.json
59
+ ```
60
+
61
+ ## Why this shape
62
+
63
+ ### `routes/` separate from `controllers/`
64
+
65
+ A route is **where** an intent is exposed (URL pattern, guards, render mode). A controller is **what** an intent does. Splitting them lets you:
66
+
67
+ - Reuse a controller across multiple URLs without route definitions cluttering its file
68
+ - Find "what URLs does my app serve" by reading one folder
69
+ - Find "what does intent X compute" by reading one folder
70
+
71
+ ### `controllers/` mirrors `views/`
72
+
73
+ One file per intent on both sides. The intent id, controller file, and view file share the same name. Finding the rendering code for `/products/:id` becomes mechanical.
74
+
75
+ ### `lib/` for everything cross-cutting
76
+
77
+ Anything that isn't a route, controller, or view. The framework's `bootstrap()` reaches into `lib/di/register.ts` for the heavy registration; controllers reach into `lib/api/*` for clients; guards live in `lib/guards/`.
78
+
79
+ ### `env.ts` at the top of `src/`
80
+
81
+ Parse and validate environment variables once, in one file. Use [zod](https://zod.dev/) or hand-rolled checks. Re-export typed constants everywhere else.
82
+
83
+ ```ts
84
+ // src/env.ts
85
+ function requireEnv(name: string): string {
86
+ const v = process.env[name];
87
+ if (!v) throw new Error(`Missing env: ${name}`);
88
+ return v;
89
+ }
90
+
91
+ export const env = {
92
+ UPSTREAM_URL: requireEnv("UPSTREAM_URL"),
93
+ SESSION_SECRET: requireEnv("SESSION_SECRET"),
94
+ NODE_ENV: process.env.NODE_ENV ?? "development",
95
+ } as const;
96
+ ```
97
+
98
+ This makes "what env vars does this app need" trivially discoverable, and the build fails fast on missing values instead of crashing at request time.
99
+
100
+ ## `bootstrap.ts` shape
101
+
102
+ Keep `bootstrap.ts` thin — it should orchestrate, not contain logic:
103
+
104
+ ```ts
105
+ // src/bootstrap.ts
106
+ import { type Framework } from "@finesoft/front";
107
+ import { registerDependencies } from "./lib/di/register";
108
+ import { homeRoutes } from "./routes/home";
109
+ import { productRoutes } from "./routes/product";
110
+ import { checkoutRoutes } from "./routes/checkout";
111
+ import { adminRoutes } from "./routes/admin";
112
+
113
+ export function bootstrap(framework: Framework): void {
114
+ registerDependencies(framework.container);
115
+
116
+ homeRoutes(framework);
117
+ productRoutes(framework);
118
+ checkoutRoutes(framework);
119
+ adminRoutes(framework);
120
+ }
121
+ ```
122
+
123
+ Each `*Routes` function calls `defineRoutes(framework, [...])` with its own routes. Adding a new route group is one import + one call.
124
+
125
+ ## Per-domain route file
126
+
127
+ ```ts
128
+ // src/routes/product.ts
129
+ import { defineRoutes, type Framework } from "@finesoft/front";
130
+ import { ProductController } from "../controllers/product";
131
+ import { ProductListController } from "../controllers/product-list";
132
+ import { authGuard } from "../lib/guards/auth";
133
+
134
+ export function productRoutes(framework: Framework): void {
135
+ defineRoutes(framework, [
136
+ { path: "/products", intentId: "product-list", controller: new ProductListController() },
137
+ { path: "/products/:id", intentId: "product", controller: new ProductController() },
138
+ {
139
+ path: "/products/:id/edit",
140
+ intentId: "product-edit",
141
+ controller: new ProductEditController(),
142
+ beforeLoad: [authGuard],
143
+ renderMode: "csr",
144
+ },
145
+ ]);
146
+ }
147
+ ```
148
+
149
+ ## Central DI registration
150
+
151
+ ```ts
152
+ // src/lib/di/register.ts
153
+ import { type Container, DEP_KEYS } from "@finesoft/front";
154
+ import { APP_KEYS } from "./keys";
155
+ import { UserApi } from "../api/user";
156
+ import { ProductApi } from "../api/product";
157
+ import { ConsoleLogger } from "@finesoft/front";
158
+ import { env } from "../../env";
159
+
160
+ export function registerDependencies(container: Container): void {
161
+ container.register(DEP_KEYS.LOGGER, () => new ConsoleLogger("app"));
162
+
163
+ container.register(
164
+ APP_KEYS.USER_API,
165
+ () =>
166
+ new UserApi({
167
+ baseUrl: env.UPSTREAM_URL,
168
+ }),
169
+ );
170
+ container.register(
171
+ APP_KEYS.PRODUCT_API,
172
+ () =>
173
+ new ProductApi({
174
+ baseUrl: env.UPSTREAM_URL,
175
+ }),
176
+ );
177
+ }
178
+ ```
179
+
180
+ Centralizing keeps the wiring inspectable. Adding a service is a single edit, not a search across the project.
181
+
182
+ ## Typed DI keys
183
+
184
+ ```ts
185
+ // src/lib/di/keys.ts
186
+ export const APP_KEYS = {
187
+ USER_API: "userApi",
188
+ PRODUCT_API: "productApi",
189
+ SESSION: "session",
190
+ FEATURE_BUCKETING: "featureBucketing",
191
+ } as const;
192
+
193
+ export type AppKey = (typeof APP_KEYS)[keyof typeof APP_KEYS];
194
+ ```
195
+
196
+ Then in any controller:
197
+
198
+ ```ts
199
+ import { APP_KEYS } from "../lib/di/keys";
200
+
201
+ async execute(params, container) {
202
+ const api = container.resolve<UserApi>(APP_KEYS.USER_API);
203
+ // ...
204
+ }
205
+ ```
206
+
207
+ A typo in `APP_KEYS.USER_PAI` is a compile error. A typo in `"userPai"` is a runtime error.
208
+
209
+ ## Page type union
210
+
211
+ ```ts
212
+ // src/lib/pages/types.ts
213
+ import type { HomePage } from "../../controllers/home";
214
+ import type { ProductPage } from "../../controllers/product";
215
+ import type { CheckoutPage } from "../../controllers/checkout";
216
+ import type { ErrorPage } from "./error";
217
+
218
+ export type Page = HomePage | ProductPage | CheckoutPage | ErrorPage;
219
+ ```
220
+
221
+ Use a discriminated union with a `kind` field. The view layer's root component switches on `page.kind`:
222
+
223
+ ```vue
224
+ <script setup lang="ts">
225
+ import type { Page } from "@/lib/pages/types";
226
+ const props = defineProps<{ page: Page }>();
227
+ </script>
228
+
229
+ <template>
230
+ <Home v-if="page.kind === 'home'" :page="page" />
231
+ <Product v-else-if="page.kind === 'product'" :page="page" />
232
+ <Checkout v-else-if="page.kind === 'checkout'" :page="page" />
233
+ <Error v-else-if="page.kind === 'error'" :page="page" />
234
+ </template>
235
+ ```
236
+
237
+ The discriminant narrows the type inside each branch — view components get a fully typed `page` prop without casts.
238
+
239
+ ## Splitting the SSR and browser entries
240
+
241
+ Keep `ssr.ts` and `main.ts` thin. They differ only in:
242
+
243
+ - `ssr.ts` calls `createSSRRender` and exports `render` + `serializeServerData`
244
+ - `main.ts` calls `startBrowserApp` and mounts the view layer
245
+
246
+ Everything else — routes, controllers, DI, i18n — is shared via `bootstrap.ts`.
247
+
248
+ ```ts
249
+ // src/ssr.ts
250
+ import { createSSRRender, serializeServerData } from "@finesoft/front";
251
+ import { renderToString } from "vue/server-renderer";
252
+ import { createSSRApp } from "vue";
253
+ import App from "./App.vue";
254
+ import { bootstrap } from "./bootstrap";
255
+
256
+ export const render = createSSRRender({
257
+ bootstrap,
258
+ getErrorPage: () => ({ kind: "error", title: "Server error" }),
259
+ async renderApp(page) {
260
+ const html = await renderToString(createSSRApp(App, { page }));
261
+ return { html, head: `<title>${page.title}</title>`, css: "" };
262
+ },
263
+ });
264
+
265
+ export { serializeServerData };
266
+ ```
267
+
268
+ ```ts
269
+ // src/main.ts
270
+ import { startBrowserApp } from "@finesoft/front/browser";
271
+ import { createSSRApp } from "vue";
272
+ import App from "./App.vue";
273
+ import { bootstrap } from "./bootstrap";
274
+
275
+ startBrowserApp({
276
+ bootstrap,
277
+ mount(target, { framework }) {
278
+ createSSRApp(App, { framework }).mount(target);
279
+ },
280
+ });
281
+ ```
282
+
283
+ ## When to break this layout
284
+
285
+ The shape above works through ~50 routes. Past that, consider:
286
+
287
+ - **Per-feature folders** (`src/features/checkout/{routes,controllers,views,api}.ts`) for very large apps. Each feature is independently understandable.
288
+ - **Lazy-loaded route bundles** with `import()` inside the route definition. The Vite plugin splits them automatically.
289
+ - **Workspace packages** if multiple apps share the same controllers / API clients. Move shared code to `packages/shared` and import from there.
290
+
291
+ Don't pre-emptively reorganize. The flat `routes/` + `controllers/` shape is fine well into the hundreds of files.
292
+
293
+ ## See also
294
+
295
+ - [Engineering: testing](./testing.md) — how to test against this structure
296
+ - [DI container](../07-di-container.md) — registration patterns