@finesoft/front 0.1.75 → 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/README.md +2 -411
- package/dist/browser.d.mts +2 -0
- package/dist/browser.mjs +1 -0
- package/dist/index.d.mts +2 -1248
- package/dist/index.mjs +54 -3557
- package/dist/server-data-DGbiKzMS.d.mts +1249 -0
- package/dist/start-app-BdXBCcor.mjs +2 -0
- 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 +12 -3
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# Pitfall: container scope leak
|
|
2
|
+
|
|
3
|
+
## Symptom
|
|
4
|
+
|
|
5
|
+
Memory usage on the server climbs over hours of uptime and never recovers. Eventually:
|
|
6
|
+
|
|
7
|
+
- Garbage collection pauses get longer and longer
|
|
8
|
+
- Heap snapshots show retained `Container`, `HttpClient`, `Logger`, `EventRecorder` instances that should have died with their requests
|
|
9
|
+
- The server eventually OOMs or gets killed by your orchestrator
|
|
10
|
+
|
|
11
|
+
This is a leak that doesn't show up in tests — they finish too fast — but compounds in production.
|
|
12
|
+
|
|
13
|
+
## Root cause
|
|
14
|
+
|
|
15
|
+
A scoped `Container` (typically a request scope) was created but **never disposed**. The framework caches every resolved factory result inside the scope. Anything resolved during the request stays referenced until the scope is collected.
|
|
16
|
+
|
|
17
|
+
Worse: if the scope has child scopes, **they** also stay referenced. A request that creates 3 child scopes for sub-operations leaks all 4.
|
|
18
|
+
|
|
19
|
+
The fix (already in the framework) tracks children explicitly and recursively disposes:
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
// packages/core/src/dependencies/container.ts
|
|
23
|
+
dispose(): void {
|
|
24
|
+
// Snapshot children first — child.dispose() removes itself from this.children
|
|
25
|
+
const childSnapshot = Array.from(this.children);
|
|
26
|
+
for (const child of childSnapshot) {
|
|
27
|
+
child.dispose();
|
|
28
|
+
}
|
|
29
|
+
this.children.clear();
|
|
30
|
+
// ...dispose own resources...
|
|
31
|
+
if (this.parent) {
|
|
32
|
+
this.parent.children.delete(this);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
But this only helps if **someone calls `dispose()` on the root scope.**
|
|
38
|
+
|
|
39
|
+
## When the framework disposes for you
|
|
40
|
+
|
|
41
|
+
- Request scopes created by `createSSRRender` are disposed after the response is sent (success or failure)
|
|
42
|
+
- The browser-side framework's main container lives for the lifetime of the page, then is GC'd when the page navigates away
|
|
43
|
+
|
|
44
|
+
So if you're only using the standard request lifecycle, you don't leak.
|
|
45
|
+
|
|
46
|
+
## When you leak
|
|
47
|
+
|
|
48
|
+
### Long-lived background work
|
|
49
|
+
|
|
50
|
+
```ts
|
|
51
|
+
// BAD
|
|
52
|
+
async execute(params, container) {
|
|
53
|
+
setTimeout(async () => {
|
|
54
|
+
const api = container.resolve("api");
|
|
55
|
+
await api.cleanup();
|
|
56
|
+
}, 60_000);
|
|
57
|
+
return { kind: "done" };
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
The `container` reference inside the closure keeps the request scope alive for 60 seconds **after the response was already sent**. The framework disposed the scope, but your closure resurrected the reference. Anything else resolved through `container.resolve()` is now reached through this dangling closure.
|
|
62
|
+
|
|
63
|
+
Fix: capture the resolved value before the response, not the container:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
// GOOD
|
|
67
|
+
async execute(params, container) {
|
|
68
|
+
const api = container.resolve("api");
|
|
69
|
+
setTimeout(async () => {
|
|
70
|
+
await api.cleanup(); // closure captures the resolved value, not the scope
|
|
71
|
+
}, 60_000);
|
|
72
|
+
return { kind: "done" };
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Even better: don't fire-and-forget from inside a request. Queue the work somewhere persistent.
|
|
77
|
+
|
|
78
|
+
### Manually created scopes you forgot to dispose
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
// BAD
|
|
82
|
+
async function bulkOperation() {
|
|
83
|
+
const scope = framework.container.createScope();
|
|
84
|
+
scope.register("tenantId", () => "tenant-42");
|
|
85
|
+
|
|
86
|
+
for (const item of items) {
|
|
87
|
+
await processItem(scope, item);
|
|
88
|
+
}
|
|
89
|
+
// forgot scope.dispose()
|
|
90
|
+
}
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
The scope outlives the function. Every `processItem` call resolved logger, API client, recorder — all retained. If `bulkOperation` runs once per request, that's a leak per request.
|
|
94
|
+
|
|
95
|
+
Fix: dispose in `finally`:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
// GOOD
|
|
99
|
+
async function bulkOperation() {
|
|
100
|
+
const scope = framework.container.createScope();
|
|
101
|
+
try {
|
|
102
|
+
scope.register("tenantId", () => "tenant-42");
|
|
103
|
+
for (const item of items) {
|
|
104
|
+
await processItem(scope, item);
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
scope.dispose();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Storing references at module scope
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
// BAD
|
|
116
|
+
let cachedScope: Container | null = null;
|
|
117
|
+
|
|
118
|
+
async function withTenantContext(tenantId: string, fn: () => Promise<void>) {
|
|
119
|
+
if (!cachedScope) {
|
|
120
|
+
cachedScope = framework.container.createScope();
|
|
121
|
+
cachedScope.register("tenantId", () => tenantId);
|
|
122
|
+
}
|
|
123
|
+
return fn();
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
The scope grows monotonically — `cachedScope` survives forever, and every dependency resolved through it is pinned in memory.
|
|
128
|
+
|
|
129
|
+
Fix: either (a) make the scope properly request-scoped, or (b) make it deliberately app-scoped on the parent container instead of a scope.
|
|
130
|
+
|
|
131
|
+
## Diagnosing
|
|
132
|
+
|
|
133
|
+
### Symptom-level check
|
|
134
|
+
|
|
135
|
+
Watch RSS over time with a steady workload:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# In production
|
|
139
|
+
ps -o pid,rss,command -p $(pidof node)
|
|
140
|
+
# RSS climbing without bound = likely leak
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
A healthy server has fluctuating but bounded RSS. A leaking server's RSS grows monotonically.
|
|
144
|
+
|
|
145
|
+
### Heap snapshots
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
# Add to your Node startup
|
|
149
|
+
node --inspect=0.0.0.0:9229 server.js
|
|
150
|
+
|
|
151
|
+
# In Chrome DevTools → Memory → Take heap snapshot
|
|
152
|
+
# Run load, take another snapshot, look at "Comparison"
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
Look for:
|
|
156
|
+
|
|
157
|
+
- `Container` instances increasing
|
|
158
|
+
- `HttpClient` instances increasing
|
|
159
|
+
- `EventRecorder` instances increasing
|
|
160
|
+
- Any of your own registered service classes increasing
|
|
161
|
+
|
|
162
|
+
The retainer chain in DevTools tells you what holds the reference. Usually a closure or a setTimeout / setInterval.
|
|
163
|
+
|
|
164
|
+
### Targeted test
|
|
165
|
+
|
|
166
|
+
For unit testing, instrument `dispose()`:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
test("scope is disposed after request", async () => {
|
|
170
|
+
const disposeSpy = vi.fn();
|
|
171
|
+
const scope = framework.container.createScope();
|
|
172
|
+
const original = scope.dispose.bind(scope);
|
|
173
|
+
scope.dispose = vi.fn(() => {
|
|
174
|
+
disposeSpy();
|
|
175
|
+
original();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
await processRequest(scope);
|
|
179
|
+
|
|
180
|
+
expect(disposeSpy).toHaveBeenCalled();
|
|
181
|
+
});
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## Idempotent disposal
|
|
185
|
+
|
|
186
|
+
The framework's `dispose()` is **idempotent** — calling it twice is safe:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
scope.dispose();
|
|
190
|
+
scope.dispose(); // no-op, no error
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
So if you're unsure whether something already disposed, just call dispose anyway in your cleanup. Defensive coding here costs nothing.
|
|
194
|
+
|
|
195
|
+
## What `destroy()` does
|
|
196
|
+
|
|
197
|
+
If your registered factory returns something with a `destroy()` method (loggers, recorders, custom services), `dispose()` calls it:
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
class MyService {
|
|
201
|
+
destroy() {
|
|
202
|
+
// close DB connections, flush queues, etc.
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
container.register("myService", () => new MyService());
|
|
207
|
+
// When the scope is disposed, MyService.destroy() runs.
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Failure inside `destroy()` is swallowed and logged — one failing service can't prevent the rest from being cleaned up.
|
|
211
|
+
|
|
212
|
+
## Related
|
|
213
|
+
|
|
214
|
+
- [Chapter 7: DI container](../07-di-container.md) — the full lifecycle model
|
|
215
|
+
- The fix that introduced recursive child disposal: `packages/core/src/dependencies/container.ts` (see the `children: Set<Container>` field)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
# Pitfall: i18n bundle size
|
|
2
|
+
|
|
3
|
+
## Symptom
|
|
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:
|
|
37
|
+
|
|
38
|
+
```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
|
|
52
|
+
```
|
|
53
|
+
|
|
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`)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# Pitfall: proxy binary payloads
|
|
2
|
+
|
|
3
|
+
## Symptom
|
|
4
|
+
|
|
5
|
+
You proxy an upstream that returns an image / PDF / protobuf, and:
|
|
6
|
+
|
|
7
|
+
- Images come through corrupted (broken thumbnails, gray boxes)
|
|
8
|
+
- PDFs fail to open ("invalid PDF structure")
|
|
9
|
+
- Protobuf clients throw "unexpected wire type" / decoding errors
|
|
10
|
+
- File sizes are slightly different between source and proxied response
|
|
11
|
+
|
|
12
|
+
The headers look fine. The status is 200. The body is what's broken.
|
|
13
|
+
|
|
14
|
+
## Root cause
|
|
15
|
+
|
|
16
|
+
Earlier versions of the proxy forwarded responses via `response.text()`. `text()` decodes bytes as **UTF-8** — which works for JSON and HTML but **destroys** any non-UTF-8 byte sequence:
|
|
17
|
+
|
|
18
|
+
- Bytes that aren't valid UTF-8 are replaced with `U+FFFD` (the replacement character, `0xEF 0xBF 0xBD`)
|
|
19
|
+
- The decoded string is then re-encoded as UTF-8 when it goes back into the response, producing a **different byte sequence than the original**
|
|
20
|
+
|
|
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
|
+
|
|
23
|
+
The current implementation uses `response.arrayBuffer()` and forwards bytes verbatim:
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
// packages/server/src/proxy.ts
|
|
27
|
+
const body = await resp.arrayBuffer();
|
|
28
|
+
return c.newResponse(body, resp.status, respHeaders);
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
This preserves the response byte-for-byte. Images render correctly, PDFs open, protobuf decodes.
|
|
32
|
+
|
|
33
|
+
## Verifying
|
|
34
|
+
|
|
35
|
+
The framework's own test (`packages/server/test/proxy.test.ts`) checks this with a PNG signature:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const binary = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0xff, 0xfe]);
|
|
39
|
+
const fetchMock = vi.fn(
|
|
40
|
+
async () =>
|
|
41
|
+
new Response(binary, {
|
|
42
|
+
status: 200,
|
|
43
|
+
headers: { "Content-Type": "image/png" },
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// ... after the handler runs ...
|
|
48
|
+
|
|
49
|
+
expect(new Uint8Array(capturedBuffer)).toEqual(binary);
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
If you suspect a proxy is corrupting binaries:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
# Compare hashes of direct vs proxied
|
|
56
|
+
curl -s https://upstream.example/image.png | sha256sum
|
|
57
|
+
curl -s http://localhost:3000/api/image.png | sha256sum
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Different hash = corruption. Same hash = the proxy is fine, look elsewhere.
|
|
61
|
+
|
|
62
|
+
## When you'd hit this
|
|
63
|
+
|
|
64
|
+
If you're on the current version (which uses `arrayBuffer`), you won't. This pitfall exists primarily as historical context for:
|
|
65
|
+
|
|
66
|
+
- **Upgrading from an older version** — verify your binary endpoints after upgrade
|
|
67
|
+
- **Building your own custom proxy logic** — if you copy from older examples, you'll reintroduce the bug
|
|
68
|
+
- **Diagnosing if a third-party proxy in front of yours has the same issue** — apply the same `arrayBuffer` test against it
|
|
69
|
+
|
|
70
|
+
## Custom proxies — get this right
|
|
71
|
+
|
|
72
|
+
If you write your own proxy code (outside the framework's `registerProxyRoutes`), use `arrayBuffer`:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
// GOOD
|
|
76
|
+
app.all("/api/*", async (c) => {
|
|
77
|
+
const resp = await fetch(targetUrl);
|
|
78
|
+
const body = await resp.arrayBuffer();
|
|
79
|
+
return c.newResponse(body, resp.status, {
|
|
80
|
+
"Content-Type": resp.headers.get("Content-Type") ?? "application/octet-stream",
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```ts
|
|
86
|
+
// BAD — corrupts non-UTF-8 bytes
|
|
87
|
+
app.all("/api/*", async (c) => {
|
|
88
|
+
const resp = await fetch(targetUrl);
|
|
89
|
+
return c.text(await resp.text(), resp.status);
|
|
90
|
+
});
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Streaming for large bodies
|
|
94
|
+
|
|
95
|
+
For responses >10 MB, the framework's bundled proxy rejects with HTTP 502 to avoid loading them entirely into memory. If you need to support larger responses, write a streaming proxy:
|
|
96
|
+
|
|
97
|
+
```ts
|
|
98
|
+
app.all("/api/*", async (c) => {
|
|
99
|
+
const resp = await fetch(targetUrl);
|
|
100
|
+
return new Response(resp.body, {
|
|
101
|
+
status: resp.status,
|
|
102
|
+
headers: {
|
|
103
|
+
"Content-Type": resp.headers.get("Content-Type") ?? "application/octet-stream",
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
`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
|
+
## The size limit lives in two places
|
|
112
|
+
|
|
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.
|
|
119
|
+
|
|
120
|
+
## Why `Content-Length` and `byteLength` both
|
|
121
|
+
|
|
122
|
+
`Content-Length` is what the upstream **claims**. `byteLength` is what actually arrived. Some upstreams send `Content-Length: 1000` but stream 10MB. Some omit `Content-Length` entirely.
|
|
123
|
+
|
|
124
|
+
The double check covers both:
|
|
125
|
+
|
|
126
|
+
- 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
|
|
128
|
+
|
|
129
|
+
## Related
|
|
130
|
+
|
|
131
|
+
- [Chapter 9: Server & deployment — proxy routes](../09-server-and-deployment.md#proxy-routes)
|
|
132
|
+
- The actual implementation: `packages/server/src/proxy.ts`
|
|
133
|
+
- The regression test: `packages/server/test/proxy.test.ts`
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Pitfall: redirect vs rewrite
|
|
2
|
+
|
|
3
|
+
## Symptom A — wrong URL in the address bar
|
|
4
|
+
|
|
5
|
+
A guard runs `rewrite("/canonical")`, but the user sees `/canonical` in the address bar. You wanted the original URL preserved.
|
|
6
|
+
|
|
7
|
+
## Symptom B — extra round-trip
|
|
8
|
+
|
|
9
|
+
A guard runs `redirect("/login")`, the browser shows a flicker / network panel shows a 302 → 200 round-trip. You wanted in-process re-routing.
|
|
10
|
+
|
|
11
|
+
## Symptom C — `afterLoad` rewrite seemed to issue a 301
|
|
12
|
+
|
|
13
|
+
A guard in `afterLoad` returns `rewrite("/clean-url")`. The browser hits the rewrite URL, gets the canonical content, and your server logs show two requests. You expected one.
|
|
14
|
+
|
|
15
|
+
## Root cause
|
|
16
|
+
|
|
17
|
+
`redirect` and `rewrite` look similar but mean fundamentally different things, and `rewrite` itself behaves differently in `beforeLoad` vs `afterLoad`.
|
|
18
|
+
|
|
19
|
+
| Result | What happens | Visible to user as | Use for |
|
|
20
|
+
| --------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------- | ------------------------------------------------- |
|
|
21
|
+
| `redirect("/foo", 302)` | HTTP 302 with `Location: /foo` (server) or `pushState("/foo")` (browser) | Address bar changes to `/foo` | Auth gates, locale redirects, deprecated paths |
|
|
22
|
+
| `redirect("/foo", 301)` | Same but cacheable as permanent | Address bar changes; cached | Permanent canonicalization |
|
|
23
|
+
| `rewrite("/foo")` in `beforeLoad` | Router resolves `/foo` instead; new match's guards + controller run | Address bar stays original | A/B tests, feature-flag routing, internal aliases |
|
|
24
|
+
| `rewrite("/foo")` in `afterLoad` | `Content-Location: /foo` header; controller already ran | Address bar stays original; no extra request | Canonical-URL signal to crawlers; analytics dedup |
|
|
25
|
+
|
|
26
|
+
## The semantic difference
|
|
27
|
+
|
|
28
|
+
**Redirect** = "the user should be at a different URL." The address bar is the source of truth, and the framework tells the browser to update it.
|
|
29
|
+
|
|
30
|
+
**Rewrite in `beforeLoad`** = "this URL maps to another internally." The user's URL stays; the framework picks a different controller to satisfy the request. Like Nginx's `rewrite ... last;`.
|
|
31
|
+
|
|
32
|
+
**Rewrite in `afterLoad`** = "this content is also available at a canonical URL." The page already rendered (the controller already ran); the response just includes a hint via `Content-Location`. The browser does **not** follow it as a redirect — it's metadata.
|
|
33
|
+
|
|
34
|
+
## Fix Symptom A — you used `redirect` when you wanted `rewrite`
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// BAD — user sees /landing-v2 in address bar
|
|
38
|
+
function abTestGuard(ctx: NavigationContext) {
|
|
39
|
+
if (ctx.url.pathname !== "/landing") return next();
|
|
40
|
+
return bucket(ctx) === "B" ? redirect("/landing-v2") : next();
|
|
41
|
+
}
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```ts
|
|
45
|
+
// GOOD — user keeps /landing, server renders /landing-v2 internally
|
|
46
|
+
function abTestGuard(ctx: NavigationContext) {
|
|
47
|
+
if (ctx.url.pathname !== "/landing") return next();
|
|
48
|
+
return bucket(ctx) === "B" ? rewrite("/landing-v2") : next();
|
|
49
|
+
}
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Same applies to mobile routing, feature flags, locale-based content swapping — anything where the user shouldn't notice the underlying URL changed.
|
|
53
|
+
|
|
54
|
+
## Fix Symptom B — you used `rewrite` when you wanted `redirect`
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
// BAD — user remains on the protected URL; the wrong controller runs
|
|
58
|
+
function authGuard(ctx: NavigationContext) {
|
|
59
|
+
if (!ctx.getCookie("token")) return rewrite("/login");
|
|
60
|
+
return next();
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
If `rewrite` is used here:
|
|
65
|
+
|
|
66
|
+
- The address bar stays at `/admin` (confusing — the user thinks they're already at admin)
|
|
67
|
+
- A reload re-runs the login page logic but doesn't change the URL
|
|
68
|
+
- Bookmarking `/admin` from this state bookmarks a broken URL
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// GOOD — actually navigate to /login
|
|
72
|
+
function authGuard(ctx: NavigationContext) {
|
|
73
|
+
if (!ctx.getCookie("token"))
|
|
74
|
+
return redirect("/login?next=" + encodeURIComponent(ctx.url.pathname));
|
|
75
|
+
return next();
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Fix Symptom C — `afterLoad` rewrite is canonicalization, not 301
|
|
80
|
+
|
|
81
|
+
If you actually want a 301 from `afterLoad`, use `redirect`:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
afterLoad: [
|
|
85
|
+
(ctx) => {
|
|
86
|
+
if (ctx.url.search.includes("utm_")) {
|
|
87
|
+
const clean = ctx.url.pathname;
|
|
88
|
+
return redirect(clean, 301);
|
|
89
|
+
}
|
|
90
|
+
return next();
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
But note: by the time `afterLoad` runs, **the controller already executed**. If `execute()` had side effects (writes, expensive computation), they happened. Use `beforeLoad` for redirects you want to fire before the work runs.
|
|
96
|
+
|
|
97
|
+
If you want to ship the rendered page **and** signal "by the way, the canonical URL is /clean":
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
afterLoad: [
|
|
101
|
+
(ctx) => {
|
|
102
|
+
if (ctx.url.search.includes("utm_")) {
|
|
103
|
+
return rewrite(ctx.url.pathname); // no extra request
|
|
104
|
+
}
|
|
105
|
+
return next();
|
|
106
|
+
},
|
|
107
|
+
],
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
The response includes `Content-Location: /clean`. Crawlers (Google, Bing) use this for canonical resolution; analytics tools can deduplicate the variants.
|
|
111
|
+
|
|
112
|
+
## The `rewrite` recursion depth limit
|
|
113
|
+
|
|
114
|
+
`beforeLoad` rewrites recurse — the new URL's `beforeLoad` chain runs in full, including any rewrites it triggers. The framework caps this at **5 levels** (`MAX_SSR_REWRITE_DEPTH`) to prevent runaway loops.
|
|
115
|
+
|
|
116
|
+
If you hit:
|
|
117
|
+
|
|
118
|
+
```
|
|
119
|
+
Error: Too many SSR rewrites (max 5): /a → /b → /c → /d → /e → /f
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
You have a guard loop. Common cause: a guard rewrites to a URL whose own guard rewrites back.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
// BAD — /landing rewrites to /v2 which rewrites to /landing
|
|
126
|
+
const landingGuard = (ctx) => (ctx.url.pathname === "/landing" ? rewrite("/v2") : next());
|
|
127
|
+
const v2Guard = (ctx) =>
|
|
128
|
+
ctx.url.pathname === "/v2" && !ctx.getCookie("v2") ? rewrite("/landing") : next();
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Fix the loop, not the depth limit.
|
|
132
|
+
|
|
133
|
+
## Decision tree
|
|
134
|
+
|
|
135
|
+
```
|
|
136
|
+
Need to change what URL the user sees?
|
|
137
|
+
├── Yes → redirect (302 for temporary, 301 for permanent)
|
|
138
|
+
└── No, URL stays the same
|
|
139
|
+
├── Need to swap which controller runs? → rewrite in beforeLoad
|
|
140
|
+
├── Already rendered; want canonical hint? → rewrite in afterLoad
|
|
141
|
+
└── Need to abort with an error? → deny(status, message)
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Related
|
|
145
|
+
|
|
146
|
+
- [Chapter 3: Middleware](../03-middleware.md) — the four results explained
|
|
147
|
+
- The behavior change was deliberately introduced — see `packages/ssr/src/render.ts` `ssrRenderInternal` and the `rewriteUrl` field
|