@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,318 @@
|
|
|
1
|
+
# Advanced: custom event recorder
|
|
2
|
+
|
|
3
|
+
Build a production-grade `EventRecorder` that batches, retries, and survives navigation. This recipe assumes you've read [chapter 8: observability](../08-observability.md).
|
|
4
|
+
|
|
5
|
+
## Goals
|
|
6
|
+
|
|
7
|
+
A good recorder:
|
|
8
|
+
|
|
9
|
+
- **Doesn't block.** `record()` returns synchronously; transmission happens in the background.
|
|
10
|
+
- **Batches.** One HTTP request per N events or T seconds, not per event.
|
|
11
|
+
- **Survives navigation.** Pending events flush on page unload via `sendBeacon`.
|
|
12
|
+
- **Drops gracefully.** Network failures don't crash the app; events that fail to send don't pile up forever.
|
|
13
|
+
- **Lifecycle-aware.** `destroy()` flushes everything before disposal.
|
|
14
|
+
|
|
15
|
+
## Skeleton
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
// src/lib/recorders/http-recorder.ts
|
|
19
|
+
import type { EventRecorder, EventRecord } from "@finesoft/front";
|
|
20
|
+
|
|
21
|
+
export interface HttpRecorderOptions {
|
|
22
|
+
endpoint: string;
|
|
23
|
+
batchSize?: number;
|
|
24
|
+
flushIntervalMs?: number;
|
|
25
|
+
maxQueueSize?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class HttpEventRecorder implements EventRecorder {
|
|
29
|
+
private queue: EventRecord[] = [];
|
|
30
|
+
private timer: ReturnType<typeof setInterval> | null = null;
|
|
31
|
+
private flushing = false;
|
|
32
|
+
private readonly opts: Required<HttpRecorderOptions>;
|
|
33
|
+
|
|
34
|
+
constructor(options: HttpRecorderOptions) {
|
|
35
|
+
this.opts = {
|
|
36
|
+
batchSize: 50,
|
|
37
|
+
flushIntervalMs: 5000,
|
|
38
|
+
maxQueueSize: 1000,
|
|
39
|
+
...options,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
if (typeof window !== "undefined") {
|
|
43
|
+
this.timer = setInterval(() => this.flush(), this.opts.flushIntervalMs);
|
|
44
|
+
window.addEventListener("pagehide", this.beaconFlush);
|
|
45
|
+
window.addEventListener("beforeunload", this.beaconFlush);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
record(event: EventRecord): void {
|
|
50
|
+
if (this.queue.length >= this.opts.maxQueueSize) {
|
|
51
|
+
// overflow protection — drop the oldest to bound memory
|
|
52
|
+
this.queue.shift();
|
|
53
|
+
}
|
|
54
|
+
this.queue.push(event);
|
|
55
|
+
if (this.queue.length >= this.opts.batchSize) {
|
|
56
|
+
void this.flush();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
destroy(): void {
|
|
61
|
+
if (this.timer) clearInterval(this.timer);
|
|
62
|
+
if (typeof window !== "undefined") {
|
|
63
|
+
window.removeEventListener("pagehide", this.beaconFlush);
|
|
64
|
+
window.removeEventListener("beforeunload", this.beaconFlush);
|
|
65
|
+
}
|
|
66
|
+
this.beaconFlush();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private async flush(): Promise<void> {
|
|
70
|
+
if (this.flushing || this.queue.length === 0) return;
|
|
71
|
+
this.flushing = true;
|
|
72
|
+
const batch = this.queue.splice(0, this.opts.batchSize);
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const resp = await fetch(this.opts.endpoint, {
|
|
76
|
+
method: "POST",
|
|
77
|
+
headers: { "Content-Type": "application/json" },
|
|
78
|
+
body: JSON.stringify(batch),
|
|
79
|
+
keepalive: true,
|
|
80
|
+
});
|
|
81
|
+
if (!resp.ok) {
|
|
82
|
+
// 4xx — drop. 5xx — re-queue at the front.
|
|
83
|
+
if (resp.status >= 500) this.queue.unshift(...batch);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// network failure — re-queue
|
|
87
|
+
this.queue.unshift(...batch);
|
|
88
|
+
} finally {
|
|
89
|
+
this.flushing = false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private beaconFlush = (): void => {
|
|
94
|
+
if (this.queue.length === 0) return;
|
|
95
|
+
if (typeof navigator === "undefined" || !navigator.sendBeacon) return;
|
|
96
|
+
const batch = this.queue.splice(0, this.queue.length);
|
|
97
|
+
navigator.sendBeacon(this.opts.endpoint, JSON.stringify(batch));
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Why each piece exists
|
|
103
|
+
|
|
104
|
+
### `keepalive: true`
|
|
105
|
+
|
|
106
|
+
Tells the browser the request should complete even if the page is navigating away. Has a max body size (~64 KB) but works across navigation. Pair with `sendBeacon` for unload — beacons are smaller and more reliable for that case.
|
|
107
|
+
|
|
108
|
+
### `pagehide` and `beforeunload`
|
|
109
|
+
|
|
110
|
+
`pagehide` fires when the page enters the bfcache (back/forward navigation). `beforeunload` fires on regular navigation/close. Both should flush pending events. Some browsers fire one and not the other, so listen for both.
|
|
111
|
+
|
|
112
|
+
### `keepalive` vs `sendBeacon`
|
|
113
|
+
|
|
114
|
+
| Method | Body limit | Returns response? | When |
|
|
115
|
+
| ---------------------- | ---------- | ----------------- | ------------------------------- |
|
|
116
|
+
| `fetch(..keepalive)` | ~64KB | Yes | Periodic flush during page life |
|
|
117
|
+
| `navigator.sendBeacon` | ~64KB | No | Final flush on unload |
|
|
118
|
+
|
|
119
|
+
Use both: periodic `fetch` for visibility into successes/failures, `sendBeacon` as the final escape hatch.
|
|
120
|
+
|
|
121
|
+
### Re-queue on 5xx, drop on 4xx
|
|
122
|
+
|
|
123
|
+
A 5xx is the server's fault — retry later. A 4xx is your fault — retrying won't help, and infinite retry would flood the server. Drop the batch and move on.
|
|
124
|
+
|
|
125
|
+
### Overflow protection
|
|
126
|
+
|
|
127
|
+
If the network is down for hours and your app keeps emitting events, the queue would grow without bound. `maxQueueSize` caps it; oldest events drop when new ones arrive. This trades completeness for memory safety — pick the size based on how much you can afford to lose vs how much memory you can spend.
|
|
128
|
+
|
|
129
|
+
## Wiring it up
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
// src/main.ts
|
|
133
|
+
import { startBrowserApp, CompositeEventRecorder, ConsoleEventRecorder } from "@finesoft/front/browser";
|
|
134
|
+
import { bootstrap } from "./bootstrap";
|
|
135
|
+
import { HttpEventRecorder } from "./lib/recorders/http-recorder";
|
|
136
|
+
|
|
137
|
+
startBrowserApp({
|
|
138
|
+
bootstrap,
|
|
139
|
+
frameworkConfig: {
|
|
140
|
+
eventRecorder: new CompositeEventRecorder([
|
|
141
|
+
new ConsoleEventRecorder(),
|
|
142
|
+
new HttpEventRecorder({
|
|
143
|
+
endpoint: "/api/events",
|
|
144
|
+
batchSize: 50,
|
|
145
|
+
flushIntervalMs: 5000,
|
|
146
|
+
}),
|
|
147
|
+
]),
|
|
148
|
+
},
|
|
149
|
+
mount: /* ... */,
|
|
150
|
+
});
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The `CompositeEventRecorder` wraps both so events go to console (for dev visibility) **and** to your backend.
|
|
154
|
+
|
|
155
|
+
## Adding cross-cutting fields
|
|
156
|
+
|
|
157
|
+
Decorate with `WithFieldsRecorder` to attach session-level fields:
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
import { WithFieldsRecorder, type FieldProvider } from "@finesoft/front";
|
|
161
|
+
|
|
162
|
+
const sessionFields: FieldProvider = {
|
|
163
|
+
getFields: () => ({
|
|
164
|
+
sessionId: getSessionId(),
|
|
165
|
+
appVersion: __APP_VERSION__,
|
|
166
|
+
userAgent: navigator.userAgent,
|
|
167
|
+
}),
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const userFields: FieldProvider = {
|
|
171
|
+
getFields: () => {
|
|
172
|
+
const user = getCurrentUser();
|
|
173
|
+
return user ? { userId: user.id, role: user.role } : {};
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
new WithFieldsRecorder(new HttpEventRecorder({ endpoint: "/api/events" }), [
|
|
178
|
+
sessionFields,
|
|
179
|
+
userFields,
|
|
180
|
+
]);
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
`getFields` runs **per event**, so user-state changes between events reflect correctly.
|
|
184
|
+
|
|
185
|
+
## Server-side recording
|
|
186
|
+
|
|
187
|
+
The framework records `PageView` for every SSR request. If you want to capture those server-side:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
// src/ssr.ts
|
|
191
|
+
import { createSSRRender } from "@finesoft/front";
|
|
192
|
+
import { HttpEventRecorder } from "./lib/recorders/http-recorder";
|
|
193
|
+
|
|
194
|
+
// Single recorder shared across all SSR requests
|
|
195
|
+
const serverRecorder = new HttpEventRecorder({
|
|
196
|
+
endpoint: "https://internal-events.example/v1/events",
|
|
197
|
+
batchSize: 100, // server batches more aggressively
|
|
198
|
+
flushIntervalMs: 1000,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
export const render = createSSRRender({
|
|
202
|
+
bootstrap,
|
|
203
|
+
frameworkConfig: {
|
|
204
|
+
eventRecorder: serverRecorder,
|
|
205
|
+
},
|
|
206
|
+
/* ... */
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
process.on("SIGTERM", () => serverRecorder.destroy());
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
Server-side recorders should:
|
|
213
|
+
|
|
214
|
+
- Share a single instance across requests (don't construct per-request)
|
|
215
|
+
- Use a larger batch / longer flush interval (no UI to block)
|
|
216
|
+
- Wire `destroy()` into graceful shutdown so in-flight events flush
|
|
217
|
+
|
|
218
|
+
## Integration with Sentry / Datadog
|
|
219
|
+
|
|
220
|
+
If you use both a `ReportCallback` (for `warn`/`error` logs to Sentry) and an `EventRecorder` (for structured events to your backend), keep them separate:
|
|
221
|
+
|
|
222
|
+
```ts
|
|
223
|
+
Framework.create({
|
|
224
|
+
reportCallback: (level, category, args) => {
|
|
225
|
+
Sentry.captureMessage(`[${category}] ${args.join(" ")}`, level);
|
|
226
|
+
},
|
|
227
|
+
eventRecorder: new CompositeEventRecorder([
|
|
228
|
+
new HttpEventRecorder({ endpoint: "/api/events" }),
|
|
229
|
+
// Optionally: forward to Datadog too
|
|
230
|
+
new DatadogEventRecorder({ apiKey: env.DD_API_KEY }),
|
|
231
|
+
]),
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
Different sinks for different purposes — errors go to Sentry for triage, structured events go to your warehouse for analytics. Don't try to make one recorder do both.
|
|
236
|
+
|
|
237
|
+
## Sampling
|
|
238
|
+
|
|
239
|
+
For high-traffic apps, sample events:
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
class SamplingRecorder implements EventRecorder {
|
|
243
|
+
constructor(
|
|
244
|
+
private inner: EventRecorder,
|
|
245
|
+
private rate: number,
|
|
246
|
+
) {}
|
|
247
|
+
record(event: EventRecord): void {
|
|
248
|
+
if (Math.random() < this.rate) this.inner.record(event);
|
|
249
|
+
}
|
|
250
|
+
destroy(): void {
|
|
251
|
+
this.inner.destroy?.();
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
new SamplingRecorder(new HttpEventRecorder({ endpoint: "/api/events" }), 0.1);
|
|
256
|
+
// Records 10% of events
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
Sample at the recorder level, not the call site — call sites shouldn't know whether they're being sampled.
|
|
260
|
+
|
|
261
|
+
## Testing
|
|
262
|
+
|
|
263
|
+
```ts
|
|
264
|
+
import { afterEach, describe, expect, test, vi } from "vite-plus/test";
|
|
265
|
+
import { HttpEventRecorder } from "./http-recorder";
|
|
266
|
+
|
|
267
|
+
afterEach(() => {
|
|
268
|
+
vi.useRealTimers();
|
|
269
|
+
vi.unstubAllGlobals();
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
describe("HttpEventRecorder", () => {
|
|
273
|
+
test("flushes when batch fills", async () => {
|
|
274
|
+
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
|
|
275
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
276
|
+
|
|
277
|
+
const recorder = new HttpEventRecorder({
|
|
278
|
+
endpoint: "/api/events",
|
|
279
|
+
batchSize: 3,
|
|
280
|
+
flushIntervalMs: 60_000,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
recorder.record({ name: "A", fields: {} });
|
|
284
|
+
recorder.record({ name: "B", fields: {} });
|
|
285
|
+
recorder.record({ name: "C", fields: {} });
|
|
286
|
+
|
|
287
|
+
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
|
288
|
+
expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toHaveLength(3);
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("re-queues batch on 5xx", async () => {
|
|
292
|
+
const fetchMock = vi
|
|
293
|
+
.fn()
|
|
294
|
+
.mockResolvedValueOnce(new Response(null, { status: 503 }))
|
|
295
|
+
.mockResolvedValueOnce(new Response(null, { status: 200 }));
|
|
296
|
+
vi.stubGlobal("fetch", fetchMock);
|
|
297
|
+
|
|
298
|
+
const recorder = new HttpEventRecorder({
|
|
299
|
+
endpoint: "/api/events",
|
|
300
|
+
batchSize: 1,
|
|
301
|
+
flushIntervalMs: 60_000,
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
recorder.record({ name: "A", fields: {} });
|
|
305
|
+
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
|
306
|
+
|
|
307
|
+
// simulate the next flush
|
|
308
|
+
await (recorder as any).flush();
|
|
309
|
+
|
|
310
|
+
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
311
|
+
});
|
|
312
|
+
});
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
## Related
|
|
316
|
+
|
|
317
|
+
- [Chapter 8: Observability](../08-observability.md) — base primitives and built-in events
|
|
318
|
+
- The framework's own composite/with-fields recorders: `packages/core/src/metrics/`
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Advanced: inline proxy codegen
|
|
2
|
+
|
|
3
|
+
For serverless and edge deployments where you want proxy logic inlined into the function bundle — no runtime call to `registerProxyRoutes`, no extra dependencies — the framework exposes `generateProxyCode`.
|
|
4
|
+
|
|
5
|
+
## Use case
|
|
6
|
+
|
|
7
|
+
You're deploying to Cloudflare Workers / Vercel Edge / AWS Lambda@Edge. Each function has:
|
|
8
|
+
|
|
9
|
+
- Tight cold-start budget
|
|
10
|
+
- Cold-bundle-size budget (Workers: 1 MB compressed)
|
|
11
|
+
- No `process.env` in some runtimes
|
|
12
|
+
|
|
13
|
+
Importing the proxy router and its support files (validators, Hono integration) adds bytes. `generateProxyCode` emits **only** the lines you need for the routes you declared. The output is self-contained: a few `app.get(...)` / `app.all(...)` calls plus a single `_sanitizeProxyPath` helper.
|
|
14
|
+
|
|
15
|
+
## Generated output
|
|
16
|
+
|
|
17
|
+
Given this input:
|
|
18
|
+
|
|
19
|
+
```ts
|
|
20
|
+
import { generateProxyCode } from "@finesoft/front";
|
|
21
|
+
|
|
22
|
+
const code = generateProxyCode([
|
|
23
|
+
{
|
|
24
|
+
prefix: "/api",
|
|
25
|
+
target: "https://upstream.example",
|
|
26
|
+
headers: { "X-App": "myapp" },
|
|
27
|
+
auth: { type: "bearer", envKey: "API_TOKEN" },
|
|
28
|
+
cache: "max-age=60",
|
|
29
|
+
},
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
console.log(code);
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
You get something like:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
// ─── 框架声明式代理路由 ───
|
|
39
|
+
function _sanitizeProxyPath(raw) {
|
|
40
|
+
if (raw.length > 2048) return null;
|
|
41
|
+
try {
|
|
42
|
+
if (decodeURIComponent(raw) !== raw) return null;
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (raw.startsWith("//")) return null;
|
|
47
|
+
if (!/^[/\w.\-~%:@!$&'()*+,;=]*$/.test(raw)) return null;
|
|
48
|
+
return raw.startsWith("/") ? raw : "/" + raw;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
app.all("/api/*", async (c) => {
|
|
52
|
+
const _sub = _sanitizeProxyPath(c.req.path.replace("/api", ""));
|
|
53
|
+
if (!_sub) return c.text("Invalid path", 400);
|
|
54
|
+
const _target = new URL(_sub, "https://upstream.example");
|
|
55
|
+
if (_target.origin !== "https://upstream.example") return c.text("Invalid proxy target", 400);
|
|
56
|
+
const _reqUrl = new URL(c.req.url);
|
|
57
|
+
_reqUrl.searchParams.forEach((v, k) => _target.searchParams.set(k, v));
|
|
58
|
+
const _headers = { "X-App": "myapp" };
|
|
59
|
+
const _token =
|
|
60
|
+
(typeof process !== "undefined" && process.env && process.env["API_TOKEN"]) || "";
|
|
61
|
+
if (_token) _headers.Authorization = "Bearer " + _token;
|
|
62
|
+
try {
|
|
63
|
+
const _resp = await fetch(_target.toString(), { headers: _headers, redirect: "manual" });
|
|
64
|
+
const _cl = _resp.headers.get("Content-Length");
|
|
65
|
+
if (_cl && parseInt(_cl, 10) > 10485760) {
|
|
66
|
+
return c.text("Proxy response too large", 502);
|
|
67
|
+
}
|
|
68
|
+
const _body = await _resp.arrayBuffer();
|
|
69
|
+
if (_body.byteLength > 10485760) {
|
|
70
|
+
return c.text("Proxy response too large", 502);
|
|
71
|
+
}
|
|
72
|
+
const _rh = { "Content-Type": _resp.headers.get("Content-Type") || "application/json" };
|
|
73
|
+
if ("max-age=60") _rh["Cache-Control"] = "max-age=60";
|
|
74
|
+
return c.newResponse(_body, _resp.status, _rh);
|
|
75
|
+
} catch (_e) {
|
|
76
|
+
console.error("[Proxy /api]", _e);
|
|
77
|
+
return c.json({ error: "Proxy request failed" }, 502);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Everything is inline. No imports from `@finesoft/front` for the proxy path. Drop this into your function bundle alongside the SSR entry.
|
|
83
|
+
|
|
84
|
+
## When to use codegen vs runtime registration
|
|
85
|
+
|
|
86
|
+
| Concern | Runtime (`registerProxyRoutes`) | Codegen (`generateProxyCode`) |
|
|
87
|
+
| ----------------------------------------- | ------------------------------- | ------------------------------------------- |
|
|
88
|
+
| Long-lived server (Node, Workers) | ✅ preferred | ✅ also fine |
|
|
89
|
+
| Tiny edge functions (Lambda@Edge) | Heavier import | ✅ minimal |
|
|
90
|
+
| Need to update routes without redeploying | ✅ change config, restart | ❌ redeploy required |
|
|
91
|
+
| Config from a remote service | ✅ supported | ❌ codegen runs at build time |
|
|
92
|
+
| Multiple proxies sharing helpers | ✅ shared at runtime | Code duplication unless you dedupe yourself |
|
|
93
|
+
|
|
94
|
+
Use codegen specifically when bundle size matters. For most deployments, the runtime path is fine.
|
|
95
|
+
|
|
96
|
+
## Build-time integration
|
|
97
|
+
|
|
98
|
+
A typical setup:
|
|
99
|
+
|
|
100
|
+
```ts
|
|
101
|
+
// scripts/build-proxy.mjs
|
|
102
|
+
import { generateProxyCode } from "@finesoft/front";
|
|
103
|
+
import { writeFile } from "node:fs/promises";
|
|
104
|
+
|
|
105
|
+
const code = generateProxyCode([
|
|
106
|
+
{ prefix: "/api/users", target: "https://users.internal" },
|
|
107
|
+
{ prefix: "/api/products", target: "https://products.internal", cache: "max-age=30" },
|
|
108
|
+
{
|
|
109
|
+
prefix: "/api/orders",
|
|
110
|
+
target: "https://orders.internal",
|
|
111
|
+
auth: { type: "bearer", envKey: "ORDERS_TOKEN" },
|
|
112
|
+
},
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
const wrapper = `
|
|
116
|
+
import { Hono } from "hono";
|
|
117
|
+
const app = new Hono();
|
|
118
|
+
|
|
119
|
+
${code}
|
|
120
|
+
|
|
121
|
+
export default app;
|
|
122
|
+
`;
|
|
123
|
+
|
|
124
|
+
await writeFile("dist/proxy.js", wrapper, "utf8");
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Then import `./proxy.js` from your serverless function entry:
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
// dist/main.ts (for Cloudflare Worker)
|
|
131
|
+
import proxyApp from "./proxy.js";
|
|
132
|
+
import ssrApp from "./ssr-bundle.js";
|
|
133
|
+
|
|
134
|
+
const app = new Hono();
|
|
135
|
+
app.route("/", proxyApp);
|
|
136
|
+
app.route("/", ssrApp);
|
|
137
|
+
|
|
138
|
+
export default app;
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## What the generated code does for you
|
|
142
|
+
|
|
143
|
+
The generated handler enforces the same guarantees as the runtime path:
|
|
144
|
+
|
|
145
|
+
- **SSRF protection**: path validation rejects encoded chars, `//` prefix, non-allowed characters
|
|
146
|
+
- **Open-redirect protection**: `target.origin` must match the configured target's origin
|
|
147
|
+
- **10 MB response size limit**: `Content-Length` fast-reject + `byteLength` actual-bytes check
|
|
148
|
+
- **Binary integrity**: `arrayBuffer()` forwarding (no UTF-8 decoding)
|
|
149
|
+
- **Auth from env**: reads `process.env[envKey]` at request time
|
|
150
|
+
|
|
151
|
+
The framework's test suite asserts **parity** between runtime and generated code with these checks:
|
|
152
|
+
|
|
153
|
+
```ts
|
|
154
|
+
// packages/server/test/proxy.test.ts
|
|
155
|
+
test("generated proxy code embeds the same response size limit as runtime (parity)", () => {
|
|
156
|
+
const code = generateProxyCode([{ prefix: "/api", target: "https://upstream.example" }]);
|
|
157
|
+
|
|
158
|
+
const MAX = String(10 * 1024 * 1024);
|
|
159
|
+
expect(code).toContain(`parseInt(_cl, 10) > ${MAX}`);
|
|
160
|
+
expect(code).toContain(`_body.byteLength > ${MAX}`);
|
|
161
|
+
});
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
If you patch the runtime path's size limit, the generated code's limit is updated in lockstep.
|
|
165
|
+
|
|
166
|
+
## Caveats
|
|
167
|
+
|
|
168
|
+
### `process.env` may not exist
|
|
169
|
+
|
|
170
|
+
The generated code guards with `typeof process !== "undefined"`. On runtimes without `process` (some edge environments), the auth header is simply not added — the upstream sees no auth.
|
|
171
|
+
|
|
172
|
+
For platforms like Cloudflare Workers that inject env via function args instead of `process.env`, you'll need to either:
|
|
173
|
+
|
|
174
|
+
- Wrap the generated code to inject the auth header from the worker's env arg
|
|
175
|
+
- Replace the auth section after generation with the platform-appropriate access
|
|
176
|
+
|
|
177
|
+
### No retries, no breakers
|
|
178
|
+
|
|
179
|
+
The generated handler does one `fetch` and bubbles failures up as `502 Proxy request failed`. For retry / breaker logic, write your own proxy code — `generateProxyCode` is intentionally minimal.
|
|
180
|
+
|
|
181
|
+
### Multiple proxies share helper code
|
|
182
|
+
|
|
183
|
+
`_sanitizeProxyPath` is emitted once at the top of the generated string. Multiple `app.all` calls share it. If you `generateProxyCode` separately for each route and concatenate, you'll get the helper repeated — call it once with all routes.
|
|
184
|
+
|
|
185
|
+
### Validation at generation time
|
|
186
|
+
|
|
187
|
+
`generateProxyCode` runs the same `validateConfig` as `registerProxyRoutes`. Invalid configs throw at build time:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
generateProxyCode([{ prefix: "/api", target: "file:///etc/passwd" }]);
|
|
191
|
+
// Error: [proxy] target must start with "https://" or "http://": "file:///etc/passwd"
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
This catches config errors before the deploy ships.
|
|
195
|
+
|
|
196
|
+
## Related
|
|
197
|
+
|
|
198
|
+
- [Chapter 9: Server & deployment — proxy routes](../09-server-and-deployment.md#proxy-routes)
|
|
199
|
+
- The implementation: `packages/server/src/proxy.ts`
|
|
200
|
+
- The parity test: `packages/server/test/proxy.test.ts`
|