@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.
- package/docs/01-getting-started.md +230 -0
- package/docs/02-routing-and-controllers.md +197 -0
- package/docs/03-middleware.md +214 -0
- package/docs/04-rendering-and-hydration.md +271 -0
- package/docs/05-i18n.md +243 -0
- package/docs/06-http-client.md +286 -0
- package/docs/07-di-container.md +264 -0
- package/docs/08-observability.md +290 -0
- package/docs/09-server-and-deployment.md +242 -0
- package/docs/10-features-platform-pwa.md +238 -0
- package/docs/README.md +72 -0
- package/docs/advanced/custom-action-handler.md +248 -0
- package/docs/advanced/custom-adapter.md +264 -0
- package/docs/advanced/custom-event-recorder.md +318 -0
- package/docs/advanced/inline-proxy-codegen.md +200 -0
- package/docs/advanced/multi-tenant-scopes.md +330 -0
- package/docs/engineering/ci-release-flow.md +244 -0
- package/docs/engineering/project-structure.md +296 -0
- package/docs/engineering/testing.md +317 -0
- package/docs/pitfalls/container-scope-leak.md +215 -0
- package/docs/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
- package/docs/zh/01-getting-started.md +230 -0
- package/docs/zh/02-routing-and-controllers.md +197 -0
- package/docs/zh/03-middleware.md +214 -0
- package/docs/zh/04-rendering-and-hydration.md +271 -0
- package/docs/zh/05-i18n.md +243 -0
- package/docs/zh/06-http-client.md +286 -0
- package/docs/zh/07-di-container.md +264 -0
- package/docs/zh/08-observability.md +287 -0
- package/docs/zh/09-server-and-deployment.md +242 -0
- package/docs/zh/10-features-platform-pwa.md +238 -0
- package/docs/zh/README.md +72 -0
- package/docs/zh/advanced/custom-action-handler.md +248 -0
- package/docs/zh/advanced/custom-adapter.md +264 -0
- package/docs/zh/advanced/custom-event-recorder.md +318 -0
- package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
- package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
- package/docs/zh/engineering/ci-release-flow.md +244 -0
- package/docs/zh/engineering/project-structure.md +296 -0
- package/docs/zh/engineering/testing.md +317 -0
- package/docs/zh/pitfalls/container-scope-leak.md +215 -0
- package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
- package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
- package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
- 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
|