@finesoft/front 0.5.0 → 0.5.2

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 (128) hide show
  1. package/README.md +4 -4
  2. package/dist/Outlet.svelte +39 -0
  3. package/dist/Outlet.svelte.d.ts +7 -0
  4. package/dist/browser-DIU6Sxl3.mjs +1237 -0
  5. package/dist/browser-kFMjlLGT.d.mts +262 -0
  6. package/dist/browser.d.mts +7 -2
  7. package/dist/browser.mjs +9 -1
  8. package/dist/controller-types-CgmJ6-le.d.mts +16 -0
  9. package/dist/cookies-Bpf9VayB.d.mts +779 -0
  10. package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
  11. package/dist/host-guard-DDWxLpFL.mjs +222 -0
  12. package/dist/http-B6CJqDyf.d.mts +46 -0
  13. package/dist/http-CaxrMD1A.d.mts +1 -0
  14. package/dist/http-D70PL72H.mjs +257 -0
  15. package/dist/http.d.mts +3 -0
  16. package/dist/http.mjs +2 -0
  17. package/dist/index-node.d.mts +15 -0
  18. package/dist/index-node.mjs +15 -0
  19. package/dist/index.d.mts +48 -698
  20. package/dist/index.mjs +44 -261
  21. package/dist/load-node.d.mts +5 -0
  22. package/dist/load-node.mjs +10 -0
  23. package/dist/load-portable.d.mts +5 -0
  24. package/dist/load-portable.mjs +8 -0
  25. package/dist/lru-map-BKoUAySU.mjs +50 -0
  26. package/dist/messages-CAt2QdGr.mjs +140 -0
  27. package/dist/native-contract-DuR25hYB.d.mts +14 -0
  28. package/dist/native-contract.d.mts +2 -0
  29. package/dist/native-contract.mjs +1 -0
  30. package/dist/node-D9hB4dsz.d.mts +35 -0
  31. package/dist/node.d.mts +2 -0
  32. package/dist/node.mjs +59 -0
  33. package/dist/path-CGFl2w7D.mjs +113 -0
  34. package/dist/path-CXT6xGPO.d.mts +261 -0
  35. package/dist/portable-CaxrMD1A.d.mts +1 -0
  36. package/dist/portable.d.mts +11 -0
  37. package/dist/portable.mjs +12 -0
  38. package/dist/proxy-1SphZ7x7.mjs +436 -0
  39. package/dist/proxy-2dSWO-Xw.d.mts +53 -0
  40. package/dist/public-types-BcJM-AYc.mjs +835 -0
  41. package/dist/react-DhwBRw01.d.mts +16 -0
  42. package/dist/react.d.mts +3 -0
  43. package/dist/react.mjs +29 -0
  44. package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
  45. package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
  46. package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
  47. package/dist/session-DnB4ZC3x.d.mts +1279 -0
  48. package/dist/src-Ftl_0rhu.mjs +28 -0
  49. package/dist/src-qwx7Vw8g.mjs +3807 -0
  50. package/dist/ssr-BEUNDvbj.d.mts +210 -0
  51. package/dist/ssr-C8xnYXoY.mjs +357 -0
  52. package/dist/ssr.d.mts +3 -0
  53. package/dist/ssr.mjs +3 -0
  54. package/dist/svelte-Dr5to3SE.d.mts +16 -0
  55. package/dist/svelte.d.mts +3 -0
  56. package/dist/svelte.mjs +13 -0
  57. package/dist/typegen-C-WeJCtf.d.mts +12 -0
  58. package/dist/typegen-cli.d.mts +1 -0
  59. package/dist/typegen-cli.mjs +11 -0
  60. package/dist/typegen.d.mts +3 -0
  61. package/dist/typegen.mjs +2 -0
  62. package/dist/types-BuaZHRG7.mjs +402 -0
  63. package/dist/undici-CPfL25Hr.mjs +22262 -0
  64. package/dist/vite-Cj4SPA8D.d.mts +277 -0
  65. package/dist/vite.d.mts +4 -0
  66. package/dist/vite.mjs +2354 -0
  67. package/dist/vue-DGmzuKho.d.mts +32 -0
  68. package/dist/vue.d.mts +3 -0
  69. package/dist/vue.mjs +56 -0
  70. package/dist/web.d.mts +6 -0
  71. package/dist/web.mjs +8 -0
  72. package/dist/worker.d.mts +2 -0
  73. package/dist/worker.mjs +2 -0
  74. package/docs/01-getting-started.md +67 -199
  75. package/docs/02-routing-and-controllers.md +163 -241
  76. package/docs/03-middleware.md +10 -212
  77. package/docs/04-rendering-and-hydration.md +6 -333
  78. package/docs/05-i18n.md +7 -237
  79. package/docs/06-http-client.md +20 -263
  80. package/docs/07-di-container.md +23 -257
  81. package/docs/08-observability.md +6 -286
  82. package/docs/09-server-and-deployment.md +59 -219
  83. package/docs/10-features-platform-pwa.md +7 -231
  84. package/docs/11-navigation.md +143 -288
  85. package/docs/12-session-restoration.md +6 -214
  86. package/docs/README.md +7 -7
  87. package/docs/advanced/custom-action-handler.md +29 -229
  88. package/docs/advanced/custom-adapter.md +7 -259
  89. package/docs/advanced/custom-event-recorder.md +7 -312
  90. package/docs/advanced/inline-proxy-codegen.md +6 -185
  91. package/docs/advanced/multi-tenant-scopes.md +10 -323
  92. package/docs/engineering/ci-release-flow.md +35 -222
  93. package/docs/engineering/project-structure.md +34 -277
  94. package/docs/engineering/testing.md +8 -310
  95. package/docs/pitfalls/container-scope-leak.md +2 -214
  96. package/docs/pitfalls/i18n-bundle-size.md +6 -176
  97. package/docs/pitfalls/proxy-binary-payloads.md +8 -12
  98. package/docs/pitfalls/ssr-hydration-mismatch.md +4 -160
  99. package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
  100. package/docs/zh/01-getting-started.md +67 -199
  101. package/docs/zh/02-routing-and-controllers.md +166 -244
  102. package/docs/zh/03-middleware.md +10 -212
  103. package/docs/zh/04-rendering-and-hydration.md +6 -333
  104. package/docs/zh/05-i18n.md +7 -237
  105. package/docs/zh/06-http-client.md +20 -263
  106. package/docs/zh/07-di-container.md +23 -257
  107. package/docs/zh/08-observability.md +6 -283
  108. package/docs/zh/09-server-and-deployment.md +59 -219
  109. package/docs/zh/10-features-platform-pwa.md +7 -231
  110. package/docs/zh/11-navigation.md +130 -290
  111. package/docs/zh/12-session-restoration.md +6 -214
  112. package/docs/zh/README.md +4 -4
  113. package/docs/zh/advanced/custom-action-handler.md +29 -229
  114. package/docs/zh/advanced/custom-adapter.md +7 -259
  115. package/docs/zh/advanced/custom-event-recorder.md +7 -312
  116. package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
  117. package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
  118. package/docs/zh/engineering/ci-release-flow.md +35 -222
  119. package/docs/zh/engineering/project-structure.md +34 -277
  120. package/docs/zh/engineering/testing.md +8 -310
  121. package/docs/zh/pitfalls/container-scope-leak.md +2 -214
  122. package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
  123. package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
  124. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +4 -160
  125. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
  126. package/package.json +118 -20
  127. package/dist/browser-BYZq9Jp7.mjs +0 -2
  128. package/dist/browser-JTs2jqVY.d.mts +0 -2811
@@ -1,182 +1,12 @@
1
- # Pitfall: i18n bundle size
1
+ # Locale bundle size
2
2
 
3
- ## Symptom
3
+ A static import of every locale adds those messages to the browser bundle. Use the generated locale loader at the application definition boundary.
4
4
 
5
- Lighthouse complains about a large initial JS payload. Network panel shows a huge chunk on first request. Your `dist/client/assets/index-*.js` is bigger than it should be, and `vp build --analyze` shows the messages folder dominating the bundle.
6
-
7
- ## Root cause
8
-
9
- Translations got bundled into the main client chunk instead of being split per locale. Either:
10
-
11
- - You imported `src/locales/*.json` directly at module top:
12
-
13
- ```ts
14
- import zh from "../locales/zh-Hans.json";
15
- import en from "../locales/en-US.json";
16
- import ja from "../locales/ja-JP.json";
17
- ```
18
-
19
- All three locales now live in every user's bundle, even though each user only sees one.
20
-
21
- - You built a `Translator` with all messages inlined at module top:
22
-
23
- ```ts
24
- const t = new SimpleTranslator({
25
- locale: "en-US",
26
- messages: { ...zhMessages, ...enMessages, ...jaMessages },
27
- });
28
- ```
29
-
30
- - You serialized translations into HTML via `serializeServerData`, so every SSR page response includes the full dictionary.
31
-
32
- ## Fix
33
-
34
- ### Use `messagesDir` instead of static imports
35
-
36
- Configure the Vite plugin:
5
+ ## Loader / 加载器
37
6
 
38
7
  ```ts
39
- finesoftFrontViteConfig({
40
- i18n: { messagesDir: "src/locales" },
41
- });
42
- ```
43
-
44
- The plugin generates a per-locale loader. On the server it reads from disk; on the browser it dynamic-imports the appropriate chunk. Vite splits each locale's JSON into its own chunk, and only the chunk matching the resolved locale is fetched.
45
-
46
- ```
47
- dist/client/assets/
48
- ├── index-abc123.js ← main bundle (no translations)
49
- ├── locale-en-US-def456.js ← only loaded for en-US visitors
50
- ├── locale-zh-Hans-789.js ← only loaded for zh-Hans visitors
51
- └── locale-ja-JP-xyz.js ← only loaded for ja-JP visitors
8
+ import { loadMessages } from "virtual:finesoft-front/i18n-loader";
9
+ // defineWebApp({ ..., loadMessages })
52
10
  ```
53
11
 
54
- ### Don't serialize translations into HTML
55
-
56
- The framework deliberately does **not** include the dictionary in `PrefetchedIntents`. The browser fetches its locale chunk in parallel with the initial render.
57
-
58
- If you've been manually injecting translations into the page via your own mechanism, stop:
59
-
60
- ```html
61
- <!-- BAD — every SSR response carries the dictionary -->
62
- <script>
63
- window.__TRANSLATIONS__ = { hello: "你好" /* hundreds of keys */ };
64
- </script>
65
- ```
66
-
67
- ```ts
68
- // GOOD — the framework loads it as a separate chunk
69
- // (handled automatically when you use messagesDir)
70
- ```
71
-
72
- ### Check what's actually shipping
73
-
74
- ```bash
75
- vp build
76
- ls -lah dist/client/assets/locale-*
77
- ls -lah dist/client/assets/index-*
78
- ```
79
-
80
- The index chunk should not change when you add a new locale's JSON. If it does, something's wrong.
81
-
82
- For a visual breakdown:
83
-
84
- ```bash
85
- vp build --analyze
86
- ```
87
-
88
- This opens an interactive treemap of bundle contents. Locale chunks should be small (KB), separate, and named.
89
-
90
- ## How big is "too big"?
91
-
92
- Rough thresholds for first-paint critical JS (the index chunk):
93
-
94
- - Static marketing site: <50 KB gzipped
95
- - Standard SPA: <150 KB gzipped
96
- - Heavy dashboard: <300 KB gzipped
97
-
98
- If translations push your index chunk past these, separate them. Per-locale chunks of 10-50 KB are normal and shouldn't worry you.
99
-
100
- ## Server-side: the dictionary is cached, not bundled
101
-
102
- On the server, the framework reads the locale JSON from disk on first request and caches it for subsequent requests:
103
-
104
- ```
105
- Request 1 (zh-Hans): disk read of zh-Hans.json, cached
106
- Request 2 (zh-Hans): served from cache
107
- Request 3 (en-US): disk read of en-US.json, cached
108
- ```
109
-
110
- You don't ship a huge SSR bundle either — `tsdown` packages your server entry, but the locale JSONs are read from disk at runtime, not embedded.
111
-
112
- This means:
113
-
114
- - ✅ Cold-start cost: one disk read per locale, once per worker
115
- - ✅ Steady-state: zero overhead — locales sit in a `Map`
116
- - ❌ Mutability: edit a JSON, server keeps the cached old version until restart
117
-
118
- The mutability issue isn't usually a problem because you commit translations to source control and a redeploy reloads them. For runtime-updated translations, use the custom `loadMessages` callback to fetch from a service.
119
-
120
- ## What if my dictionary is genuinely huge?
121
-
122
- If a single locale's dictionary is multiple MB (rare — most apps fit in <100 KB):
123
-
124
- ### Split by namespace
125
-
126
- ```
127
- src/locales/
128
- ├── en-US/
129
- │ ├── common.json
130
- │ ├── checkout.json
131
- │ ├── admin.json
132
- │ └── help-center.json
133
- └── zh-Hans/
134
- └── ...
135
- ```
136
-
137
- Build a custom `loadMessages` that loads only the namespaces a given page needs:
138
-
139
- ```ts
140
- createSSRRender({
141
- bootstrap,
142
- async loadMessages(locale) {
143
- // load only "common" eagerly; lazy-load others on demand
144
- return import(`./locales/${locale}/common.json`);
145
- },
146
- async renderApp(page) {
147
- /* ... */
148
- },
149
- });
150
- ```
151
-
152
- The view layer can then call `await translator.loadNamespace("admin")` before rendering admin-specific strings.
153
-
154
- ### Lazy-load on view mount
155
-
156
- For very large optional dictionaries (help content, error code messages), fetch them on demand from the view layer rather than at framework startup. The framework doesn't need to know about them — they're just data.
157
-
158
- ## Network-side optimization
159
-
160
- Even with proper splitting, you can speed up the locale fetch:
161
-
162
- - Set long `Cache-Control` on locale chunks (Vite's content-hash filenames make this safe)
163
- - Preload the user's locale chunk:
164
- ```html
165
- <link rel="preload" href="/assets/locale-en-US-def456.js" as="script" crossorigin />
166
- ```
167
- - For high-traffic apps, push the locale chunk over the same HTTP/2 connection as the main JS
168
-
169
- ## Why not just put translations in the HTML?
170
-
171
- Because:
172
-
173
- - Every page response ships the entire dictionary — including content for pages the user never visits
174
- - HTML can't be cached at the CDN level when it varies by locale and contains the dictionary
175
- - SSR latency increases linearly with dictionary size
176
-
177
- The per-locale chunk is the right tradeoff: shipped once, cached forever, only for the locale the user actually has.
178
-
179
- ## Related
180
-
181
- - [Chapter 5: i18n](../05-i18n.md) — the full picture of locale handling
182
- - The Vite plugin source: `packages/server/src/vite-plugin.ts` (search for `messagesDir`)
12
+ Inspect production chunks and gzip totals using identical routes and build settings. A smaller entry file alone may only move bytes into another chunk. SSR and client locale must agree before hydration.
@@ -20,11 +20,12 @@ Earlier versions of the proxy forwarded responses via `response.text()`. `text()
20
20
 
21
21
  A PNG file starts with `0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A` — the leading `0x89` is not valid UTF-8, so it becomes `0xEF 0xBF 0xBD`. The browser's image decoder sees garbage starting at byte 0 and bails.
22
22
 
23
- The current implementation uses `response.arrayBuffer()` and forwards bytes verbatim:
23
+ The current implementation counts streamed bytes against a 10 MiB limit, then combines the bounded chunks into an `ArrayBuffer`:
24
24
 
25
25
  ```ts
26
26
  // packages/server/src/proxy.ts
27
- const body = await resp.arrayBuffer();
27
+ const body = await readProxyBody(resp);
28
+ if (!body) return c.text("Proxy response too large", 502);
28
29
  return c.newResponse(body, resp.status, respHeaders);
29
30
  ```
30
31
 
@@ -61,7 +62,7 @@ Different hash = corruption. Same hash = the proxy is fine, look elsewhere.
61
62
 
62
63
  ## When you'd hit this
63
64
 
64
- If you're on the current version (which uses `arrayBuffer`), you won't. This pitfall exists primarily as historical context for:
65
+ If you're on the current version (which preserves raw bytes), you won't. This pitfall exists primarily as historical context for:
65
66
 
66
67
  - **Upgrading from an older version** — verify your binary endpoints after upgrade
67
68
  - **Building your own custom proxy logic** — if you copy from older examples, you'll reintroduce the bug
@@ -69,7 +70,7 @@ If you're on the current version (which uses `arrayBuffer`), you won't. This pit
69
70
 
70
71
  ## Custom proxies — get this right
71
72
 
72
- If you write your own proxy code (outside the framework's `registerProxyRoutes`), use `arrayBuffer`:
73
+ Custom proxies must also preserve bytes. The `arrayBuffer` example below requires an independent response-size limit; prefer the framework's bounded reader for untrusted upstreams:
73
74
 
74
75
  ```ts
75
76
  // GOOD
@@ -108,14 +109,9 @@ app.all("/api/*", async (c) => {
108
109
 
109
110
  `resp.body` is a `ReadableStream`. Returning it directly streams bytes without buffering. But you lose the size-limit guard — only do this if you trust the upstream.
110
111
 
111
- ## The size limit lives in two places
112
+ ## One implementation enforces both size checks
112
113
 
113
- The framework enforces `MAX_RESPONSE_SIZE = 10 * 1024 * 1024` (10 MB) in two paths:
114
-
115
- 1. **Runtime** (`registerProxyRoutes`): checks `Content-Length` header first, then `body.byteLength` after fetch
116
- 2. **Generated code** (`generateProxyCode`): the inlined version for serverless emits the same two checks
117
-
118
- If you change the limit in one place, change both. The test `generated proxy code embeds the same response size limit as runtime (parity)` enforces this.
114
+ The framework enforces `MAX_RESPONSE_SIZE = 10 * 1024 * 1024` (10 MB) in `registerProxyRoutes`: it checks the `Content-Length` header first, then counts each received chunk and cancels immediately when the limit is exceeded. `generateProxyCode` emits a registration call to this same implementation, so development and deployment cannot drift into different limits. Tests execute generated registration and a built deployment entry, including binary payloads and oversize rejection.
119
115
 
120
116
  ## Why `Content-Length` and `byteLength` both
121
117
 
@@ -124,7 +120,7 @@ If you change the limit in one place, change both. The test `generated proxy cod
124
120
  The double check covers both:
125
121
 
126
122
  - Fast-reject on declared `Content-Length` to avoid downloading 100MB just to reject it
127
- - Final reject on actual bytes received in case `Content-Length` was missing or lying
123
+ - Immediate rejection when actual received bytes exceed the budget in case `Content-Length` was missing or lying
128
124
 
129
125
  ## Related
130
126
 
@@ -1,163 +1,7 @@
1
- # Pitfall: SSR hydration mismatch
1
+ # Hydration mismatch
2
2
 
3
- ## Symptom
3
+ Share the same page definition, App and Outlet view registry between browser and SSR. Use `createSSRRender({ definition, render: app => nativeRender(app) })` on the server. In the browser, select native hydrate or mount using `app.shouldHydrate`, mount first, then await `app.ready`.
4
4
 
5
- After SSR, the browser console logs a hydration warning:
5
+ Hydrate the server snapshot before restoring persisted state. Avoid random values, time or browser globals during the first render. Do not await ready before mounting or manually mutate Outlet's child tree.
6
6
 
7
- ```
8
- [Vue warn]: Hydration node mismatch — server rendered "<div>Loading...</div>" but client expected "<div>Welcome, Alice</div>"
9
- ```
10
-
11
- The page flickers between the SSR-rendered content and the client-rendered content. State that should be already loaded triggers a refetch.
12
-
13
- ## Root cause (most common)
14
-
15
- The server and the browser produced **different `Page` objects** for the same URL because something they read disagreed between sides:
16
-
17
- - Random / time-based values (`Math.random()`, `Date.now()`)
18
- - Reading `window` / `localStorage` / `document.cookie` on the server (these are `undefined`)
19
- - Reading `process.env` on the browser (these are `undefined` after bundling)
20
- - User-Agent-dependent rendering when SSR didn't see the real UA
21
- - Async race: the controller's `execute()` returned different data on each call
22
-
23
- The hydration cache (`PrefetchedIntents`) lookup missed, so the browser re-ran the controller — and got a different result.
24
-
25
- ## Root cause (less common)
26
-
27
- The `PrefetchedIntents` key (intentId + stable-stringified params) doesn't match between server and browser:
28
-
29
- - Params object has values that don't stringify deterministically (Maps, Sets, class instances, Symbols)
30
- - Controller mutates `params` in place — the dispatch key was computed from the original, but the controller saw the mutated version
31
-
32
- ## Diagnosis
33
-
34
- ```ts
35
- // In your view, log the page on both sides:
36
- console.log("[hydration]", typeof window === "undefined" ? "SSR" : "CSR", page);
37
- ```
38
-
39
- Compare the two logs. The first different field is the root cause.
40
-
41
- For `PrefetchedIntents` debugging, log the cache state in the browser:
42
-
43
- ```ts
44
- startBrowserApp({
45
- bootstrap,
46
- onBeforeStart(framework) {
47
- console.log("[prefetched]", framework.prefetchedIntents.dump());
48
- },
49
- mount: /* ... */,
50
- });
51
- ```
52
-
53
- If the dump shows the intent **with different params** than what the browser's first navigation tries to dispatch, you've got a key mismatch.
54
-
55
- ## Fix
56
-
57
- ### Stop reading platform-only globals at module level
58
-
59
- ```ts
60
- // BAD
61
- const userId = localStorage.getItem("uid"); // throws on SSR
62
- const isDarkMode = matchMedia("(prefers-color-scheme: dark)").matches; // throws on SSR
63
- const csrfToken = document.querySelector("meta[name=csrf]")?.content; // null on SSR
64
-
65
- export class HomeController extends BaseController {
66
- /* uses userId */
67
- }
68
- ```
69
-
70
- ```ts
71
- // GOOD
72
- export class HomeController extends BaseController {
73
- async execute(_params, container) {
74
- // resolve from DI; the request scope has the right value on each side
75
- const session = container.resolve<Session>("session");
76
- return { kind: "home", userId: session.userId };
77
- }
78
- }
79
- ```
80
-
81
- Cookies are accessible on both sides via `container.resolve("session")` (after you register it). `localStorage` is browser-only — if the SSR side needs the same value, surface it via a cookie or query param.
82
-
83
- ### Don't use randomness / time-based logic in `execute()`
84
-
85
- ```ts
86
- // BAD — server and browser compute different values
87
- async execute() {
88
- return { kind: "home", randomGreeting: pick(greetings) };
89
- }
90
- ```
91
-
92
- If you need randomness, compute it once on the server and let the client reuse it via `PrefetchedIntents` (it does, automatically). Don't try to "re-randomize on the client" — that's exactly what causes mismatch.
93
-
94
- For time-based logic, decide on the server and ship the result:
95
-
96
- ```ts
97
- async execute() {
98
- const isOfficeHours = new Date().getHours() >= 9 && new Date().getHours() < 17;
99
- return { kind: "home", isOfficeHours };
100
- }
101
- ```
102
-
103
- Both sides will see `isOfficeHours: true` because the browser reads from cache, not re-evaluates.
104
-
105
- ### Make `params` JSON-clean
106
-
107
- ```ts
108
- // BAD — dispatchAction with non-serializable params
109
- framework.dispatch({
110
- intentId: "search",
111
- params: {
112
- query: "widget",
113
- filters: new Set(["red", "small"]), // Sets don't JSON.stringify well
114
- startDate: new Date(), // becomes ISO string, OK, but...
115
- validator: new Validator(), // class instance — won't survive
116
- },
117
- });
118
- ```
119
-
120
- ```ts
121
- // GOOD — primitives + plain objects only
122
- framework.dispatch({
123
- intentId: "search",
124
- params: {
125
- query: "widget",
126
- filters: ["red", "small"],
127
- startDate: "2026-05-14",
128
- },
129
- });
130
- ```
131
-
132
- The `PrefetchedIntents` cache uses **stable stringification** — same keys in different order produce the same key, and circular references are detected. But non-JSON values are coerced to strings or dropped silently.
133
-
134
- ### Don't mutate `params`
135
-
136
- ```ts
137
- // BAD
138
- async execute(params, container) {
139
- params.userId = container.resolve("session").userId; // mutation
140
- return loadFor(params);
141
- }
142
- ```
143
-
144
- ```ts
145
- // GOOD
146
- async execute(params, container) {
147
- const effective = { ...params, userId: container.resolve("session").userId };
148
- return loadFor(effective);
149
- }
150
- ```
151
-
152
- The dispatcher computed the cache key from the original `params`. If you mutate it, the next dispatch with the original shape misses the cache.
153
-
154
- ## Why `stableStringify` matters
155
-
156
- The framework's `stableStringify` (in `packages/core/src/prefetched-intents/stable-stringify.ts`) handles object key ordering. It uses a `seen` Set with `try/finally` cleanup to support DAGs (same object referenced multiple times) — without the cleanup, a DAG would be reported as a false circular reference and the key would silently differ between server and browser.
157
-
158
- If you see "Circular reference detected" warnings during SSR but the data is genuinely a DAG, file a bug — the cleanup is supposed to handle this.
159
-
160
- ## Related
161
-
162
- - [Pitfall: SSR vs CSR globals](./ssr-vs-csr-globals.md) — where the platform-only globals live
163
- - [Chapter 4: Rendering & hydration](../04-rendering-and-hydration.md) — how `PrefetchedIntents` works
7
+ Declare explicit public projections; nested data needs a nested declaration or codec. Wire/buildId mismatches trigger fresh loading. Inspect browser warnings, DOM, requests and entry identity when diagnosing a mismatch.
@@ -1,176 +1,15 @@
1
- # Pitfall: SSR vs CSR globals
1
+ # Environment boundaries
2
2
 
3
- ## Symptom
3
+ All APIs import from `@finesoft/front`; each API still requires its appropriate runtime. The package and compiler select internal implementations without loading unused UI or platform peers.
4
4
 
5
- The build succeeds. The dev server starts. The first request to any SSR route crashes with:
6
-
7
- ```
8
- ReferenceError: window is not defined
9
- at /src/lib/foo.ts:3:13
10
- ```
11
-
12
- Or, more subtly:
13
-
14
- ```
15
- TypeError: Cannot read properties of undefined (reading 'getItem')
16
- at /src/lib/storage.ts:5:34
17
- ```
18
-
19
- The browser-only global (`window`, `document`, `localStorage`, `navigator`, `matchMedia`, `IntersectionObserver`, ...) doesn't exist on Node — Node has none of them.
20
-
21
- ## Root cause
22
-
23
- You're reading a browser-only global at **module evaluation time** in a file imported by your SSR entry. The module graph dragged it in even though you only use it on the client.
24
-
25
- Common entry points:
26
-
27
- - A `controllers/foo.ts` that imports a `lib/analytics.ts` with `window.gtag` at module top
28
- - A `lib/storage.ts` factory that calls `localStorage.getItem` at import time
29
- - An animation library auto-running `requestAnimationFrame` on import
30
-
31
- The same problem in reverse hits the browser:
32
-
33
- - Server-only code (`process.env.X`, Node `fs`, `path`) imported by something the browser bundle pulled in
34
- - Vite tree-shakes most, but not all, and dynamic imports can defeat tree-shaking
35
-
36
- ## Diagnosis
37
-
38
- When the SSR entry crashes, the error message includes the file. Read top-to-bottom — the first `import` chain that touches a browser global is the offender.
39
-
40
- To find browser-only code preemptively, grep:
41
-
42
- ```bash
43
- rg -n '\b(window|document|localStorage|sessionStorage|navigator|matchMedia|location)\b' src/
44
- ```
45
-
46
- Cross-reference with what's imported transitively from `src/ssr.ts`. Anything reachable from `ssr.ts` must be SSR-safe.
47
-
48
- ## Fix
49
-
50
- ### Guard with an environment check
51
-
52
- ```ts
53
- // GOOD — safe on both sides
54
- function getStoredTheme(): "light" | "dark" {
55
- if (typeof window === "undefined") return "light";
56
- return (localStorage.getItem("theme") as "light" | "dark") ?? "light";
57
- }
58
- ```
59
-
60
- `typeof window === "undefined"` is the canonical SSR check. It's safer than `typeof process !== "undefined"` because some bundlers polyfill `process` on the client.
61
-
62
- ### Move to lifecycle hooks
63
-
64
- ```ts
65
- // BAD — runs at import time
66
- const analytics = createAnalytics(window.location.host);
67
- export function track(event: string) {
68
- analytics.send(event);
69
- }
70
- ```
71
-
72
- ```ts
73
- // GOOD — runs after framework start in the browser
74
- let analytics: Analytics | null = null;
75
-
76
- export function track(event: string) {
77
- if (!analytics) {
78
- if (typeof window === "undefined") return;
79
- analytics = createAnalytics(window.location.host);
80
- }
81
- analytics.send(event);
82
- }
83
- ```
84
-
85
- Or use `startBrowserApp`'s `onBeforeStart`:
86
-
87
- ```ts
88
- startBrowserApp({
89
- bootstrap,
90
- onBeforeStart(framework) {
91
- const analytics = createAnalytics(window.location.host);
92
- framework.container.register("analytics", () => analytics);
93
- },
94
- mount: /* ... */,
95
- });
96
- ```
97
-
98
- Then resolve from DI in controllers/views — never touch `window` directly in shared code.
99
-
100
- ### Conditional import
101
-
102
- For libraries that crash on import in Node (animation libs, audio libs), import dynamically only on the browser:
103
-
104
- ```ts
105
- let confetti: ((options?: any) => void) | null = null;
106
-
107
- if (typeof window !== "undefined") {
108
- import("canvas-confetti").then((m) => {
109
- confetti = m.default;
110
- });
111
- }
112
-
113
- export function celebrate() {
114
- confetti?.();
115
- }
116
- ```
117
-
118
- Or register the import in `onBeforeStart`:
119
-
120
- ```ts
121
- onBeforeStart: async (framework) => {
122
- const { default: confetti } = await import("canvas-confetti");
123
- framework.container.register("confetti", () => confetti);
124
- },
125
- ```
126
-
127
- ### Use the framework's abstractions
128
-
129
- The framework provides DI keys that work on both sides:
130
-
131
- - `DEP_KEYS.PLATFORM` — the parsed user-agent on the server, navigator-derived on the client
132
- - `DEP_KEYS.STORAGE` — `localStorage` on the client, in-memory map on the server
133
- - `DEP_KEYS.LOCALE` — resolved locale on both sides
134
-
135
- Use these instead of reading globals directly. They're cross-platform by design.
136
-
137
- ## Symptom: works locally, fails in production build
138
-
139
- Sometimes the dev server tolerates a global access (via Vite's lazy evaluation) but the production build crashes. The cause is usually a module that's tree-shaken in dev but not in prod, or vice versa.
140
-
141
- Test the production build before deploying:
142
-
143
- ```bash
144
- pnpm build
145
- pnpm preview
146
- # hit the SSR routes
147
- ```
148
-
149
- The `vp preview` server runs the same code path as production — if it doesn't crash, the deploy won't either (at least not from this class of bug).
150
-
151
- ## Symptom: works in production but blank page in dev
152
-
153
- Inverse problem — server-only code leaked into the client bundle, and the browser crashed during hydration before the view layer mounted.
154
-
155
- Open browser devtools, check the console for `process is not defined` / `require is not defined`. The fix is the same: guard with `typeof window === "undefined"` (inverted: guard with `typeof window !== "undefined"`) or move to a lifecycle hook.
156
-
157
- ## Why imports matter, not "code that runs"
158
-
159
- You may be tempted to "just not call the function" instead of guarding the import:
5
+ ## Boundaries / 边界
160
6
 
161
7
  ```ts
162
- // import-time check
163
- if (typeof window !== "undefined") {
164
- // never actually called on SSR
165
- setupAnalytics();
166
- }
8
+ import { defineOperation } from "@finesoft/front";
9
+ // createBrowserApp: browser
10
+ // startNodeHandler: Node
11
+ // createHttpHandler: Request/Response host (Node or Worker)
12
+ // finesoftFrontViteConfig: build configuration
167
13
  ```
168
14
 
169
- But the `import` itself runs the module's top-level code. If `lib/analytics.ts` calls `window.gtag` at module top-level (e.g., as part of `const analytics = window.gtag.bind(window)`), the crash happens **at import**, before your `if` check.
170
-
171
- Fix the imported module to be import-safe, not just call-safe.
172
-
173
- ## Related
174
-
175
- - [Pitfall: SSR hydration mismatch](./ssr-hydration-mismatch.md) — when SSR runs but produces different output than CSR
176
- - [DI container](../07-di-container.md) — registering cross-platform services
15
+ Do not read window/document/storage at module evaluation in shared application declarations. UI bindings select only the chosen framework. Node DNS and filesystem code must remain out of browser/Worker graphs. Test the built installed package in its actual runtime; source alias imports can hide missing artifacts.