@finesoft/front 0.5.1 → 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.
- package/README.md +4 -4
- package/dist/Outlet.svelte +39 -0
- package/dist/Outlet.svelte.d.ts +7 -0
- package/dist/browser-DIU6Sxl3.mjs +1237 -0
- package/dist/browser-kFMjlLGT.d.mts +262 -0
- package/dist/browser.d.mts +7 -2
- package/dist/browser.mjs +9 -1
- package/dist/controller-types-CgmJ6-le.d.mts +16 -0
- package/dist/cookies-Bpf9VayB.d.mts +779 -0
- package/dist/fetch-policy-BHT8RtrL.mjs +82 -0
- package/dist/host-guard-DDWxLpFL.mjs +222 -0
- package/dist/http-B6CJqDyf.d.mts +46 -0
- package/dist/http-CaxrMD1A.d.mts +1 -0
- package/dist/http-D70PL72H.mjs +257 -0
- package/dist/http.d.mts +3 -0
- package/dist/http.mjs +2 -0
- package/dist/index-node.d.mts +15 -0
- package/dist/index-node.mjs +15 -0
- package/dist/index.d.mts +48 -697
- package/dist/index.mjs +44 -261
- package/dist/load-node.d.mts +5 -0
- package/dist/load-node.mjs +10 -0
- package/dist/load-portable.d.mts +5 -0
- package/dist/load-portable.mjs +8 -0
- package/dist/lru-map-BKoUAySU.mjs +50 -0
- package/dist/messages-CAt2QdGr.mjs +140 -0
- package/dist/native-contract-DuR25hYB.d.mts +14 -0
- package/dist/native-contract.d.mts +2 -0
- package/dist/native-contract.mjs +1 -0
- package/dist/node-D9hB4dsz.d.mts +35 -0
- package/dist/node.d.mts +2 -0
- package/dist/node.mjs +59 -0
- package/dist/path-CGFl2w7D.mjs +113 -0
- package/dist/path-CXT6xGPO.d.mts +261 -0
- package/dist/portable-CaxrMD1A.d.mts +1 -0
- package/dist/portable.d.mts +11 -0
- package/dist/portable.mjs +12 -0
- package/dist/proxy-1SphZ7x7.mjs +436 -0
- package/dist/proxy-2dSWO-Xw.d.mts +53 -0
- package/dist/public-types-BcJM-AYc.mjs +835 -0
- package/dist/react-DhwBRw01.d.mts +16 -0
- package/dist/react.d.mts +3 -0
- package/dist/react.mjs +29 -0
- package/dist/rolldown-runtime-B4iAMlE-.mjs +35 -0
- package/dist/secure-fetch-Xlht2jd7.d.mts +30 -0
- package/dist/server-controller-proxy-BkVuVWVD.d.mts +38 -0
- package/dist/session-DnB4ZC3x.d.mts +1279 -0
- package/dist/src-Ftl_0rhu.mjs +28 -0
- package/dist/src-qwx7Vw8g.mjs +3807 -0
- package/dist/ssr-BEUNDvbj.d.mts +210 -0
- package/dist/ssr-C8xnYXoY.mjs +357 -0
- package/dist/ssr.d.mts +3 -0
- package/dist/ssr.mjs +3 -0
- package/dist/svelte-Dr5to3SE.d.mts +16 -0
- package/dist/svelte.d.mts +3 -0
- package/dist/svelte.mjs +13 -0
- package/dist/typegen-C-WeJCtf.d.mts +12 -0
- package/dist/typegen-cli.d.mts +1 -0
- package/dist/typegen-cli.mjs +11 -0
- package/dist/typegen.d.mts +3 -0
- package/dist/typegen.mjs +2 -0
- package/dist/types-BuaZHRG7.mjs +402 -0
- package/dist/undici-CPfL25Hr.mjs +22262 -0
- package/dist/vite-Cj4SPA8D.d.mts +277 -0
- package/dist/vite.d.mts +4 -0
- package/dist/vite.mjs +2354 -0
- package/dist/vue-DGmzuKho.d.mts +32 -0
- package/dist/vue.d.mts +3 -0
- package/dist/vue.mjs +56 -0
- package/dist/web.d.mts +6 -0
- package/dist/web.mjs +8 -0
- package/dist/worker.d.mts +2 -0
- package/dist/worker.mjs +2 -0
- package/docs/01-getting-started.md +67 -199
- package/docs/02-routing-and-controllers.md +163 -241
- package/docs/03-middleware.md +10 -212
- package/docs/04-rendering-and-hydration.md +6 -333
- package/docs/05-i18n.md +7 -237
- package/docs/06-http-client.md +20 -263
- package/docs/07-di-container.md +23 -257
- package/docs/08-observability.md +6 -286
- package/docs/09-server-and-deployment.md +59 -219
- package/docs/10-features-platform-pwa.md +7 -231
- package/docs/11-navigation.md +143 -288
- package/docs/12-session-restoration.md +6 -214
- package/docs/README.md +7 -7
- package/docs/advanced/custom-action-handler.md +29 -229
- package/docs/advanced/custom-adapter.md +7 -259
- package/docs/advanced/custom-event-recorder.md +7 -312
- package/docs/advanced/inline-proxy-codegen.md +6 -185
- package/docs/advanced/multi-tenant-scopes.md +10 -323
- package/docs/engineering/ci-release-flow.md +35 -222
- package/docs/engineering/project-structure.md +34 -277
- package/docs/engineering/testing.md +8 -310
- package/docs/pitfalls/container-scope-leak.md +2 -214
- package/docs/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/pitfalls/ssr-hydration-mismatch.md +4 -160
- package/docs/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/docs/zh/01-getting-started.md +67 -199
- package/docs/zh/02-routing-and-controllers.md +166 -244
- package/docs/zh/03-middleware.md +10 -212
- package/docs/zh/04-rendering-and-hydration.md +6 -333
- package/docs/zh/05-i18n.md +7 -237
- package/docs/zh/06-http-client.md +20 -263
- package/docs/zh/07-di-container.md +23 -257
- package/docs/zh/08-observability.md +6 -283
- package/docs/zh/09-server-and-deployment.md +59 -219
- package/docs/zh/10-features-platform-pwa.md +7 -231
- package/docs/zh/11-navigation.md +130 -290
- package/docs/zh/12-session-restoration.md +6 -214
- package/docs/zh/README.md +4 -4
- package/docs/zh/advanced/custom-action-handler.md +29 -229
- package/docs/zh/advanced/custom-adapter.md +7 -259
- package/docs/zh/advanced/custom-event-recorder.md +7 -312
- package/docs/zh/advanced/inline-proxy-codegen.md +6 -185
- package/docs/zh/advanced/multi-tenant-scopes.md +10 -323
- package/docs/zh/engineering/ci-release-flow.md +35 -222
- package/docs/zh/engineering/project-structure.md +34 -277
- package/docs/zh/engineering/testing.md +8 -310
- package/docs/zh/pitfalls/container-scope-leak.md +2 -214
- package/docs/zh/pitfalls/i18n-bundle-size.md +6 -176
- package/docs/zh/pitfalls/proxy-binary-payloads.md +8 -12
- package/docs/zh/pitfalls/ssr-hydration-mismatch.md +4 -160
- package/docs/zh/pitfalls/ssr-vs-csr-globals.md +9 -170
- package/package.json +118 -20
- package/dist/browser-BHhVWXik.mjs +0 -2
- package/dist/browser-BV2BBXm7.d.mts +0 -2811
|
@@ -1,264 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Custom deployment adapters
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
This recipe walks through writing one end-to-end. The pattern: emit a platform-specific entry file at build time, then point that entry at the framework's SSR + proxy pipeline.
|
|
6
|
-
|
|
7
|
-
## What an adapter does
|
|
8
|
-
|
|
9
|
-
At build time:
|
|
10
|
-
|
|
11
|
-
1. Bundle the SSR entry (`src/ssr.ts`) into a single JS file with all dependencies inlined.
|
|
12
|
-
2. Bundle the client entry into the platform's expected shape (`dist/client/` for most).
|
|
13
|
-
3. Emit a **platform-specific entry** that:
|
|
14
|
-
- Imports the SSR bundle
|
|
15
|
-
- Receives requests in the platform's native shape (Request, Lambda event, etc.)
|
|
16
|
-
- Calls `createServer({ ssrEntry, proxies })` and serves the response
|
|
17
|
-
|
|
18
|
-
The framework provides `buildBundle`, `generateSSREntry`, `copyStaticAssets`, and `prerenderRoutes` helpers in `packages/server/src/adapters/shared.ts`. Use them — they handle the heavy lifting consistently across all adapters.
|
|
19
|
-
|
|
20
|
-
## Example: Deno Deploy adapter
|
|
21
|
-
|
|
22
|
-
Deno Deploy runs ES modules with web-standard Request/Response. Workflow is similar to Cloudflare Workers but with native Deno APIs available.
|
|
23
|
-
|
|
24
|
-
### Adapter interface
|
|
25
|
-
|
|
26
|
-
```ts
|
|
27
|
-
// src/lib/adapters/deno-deploy.ts
|
|
28
|
-
import type { AdapterDefinition, AdapterContext } from "@finesoft/front";
|
|
29
|
-
import { buildBundle, copyStaticAssets, generateSSREntry, prerenderRoutes } from "@finesoft/front";
|
|
30
|
-
|
|
31
|
-
export const denoDeployAdapter: AdapterDefinition = {
|
|
32
|
-
name: "deno-deploy",
|
|
33
|
-
|
|
34
|
-
async build(ctx: AdapterContext): Promise<void> {
|
|
35
|
-
// 1. Bundle SSR
|
|
36
|
-
const ssrEntry = generateSSREntry(ctx, {
|
|
37
|
-
// Deno supports native fetch / URL / Response, so no shims needed
|
|
38
|
-
external: [],
|
|
39
|
-
});
|
|
40
|
-
await buildBundle(ctx, {
|
|
41
|
-
entry: ssrEntry,
|
|
42
|
-
outFile: "dist/server.js",
|
|
43
|
-
format: "esm",
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
// 2. Copy static assets
|
|
47
|
-
copyStaticAssets(ctx, "dist/client", "dist/static");
|
|
48
|
-
|
|
49
|
-
// 3. Prerender any prerender routes
|
|
50
|
-
await prerenderRoutes(ctx);
|
|
51
|
-
|
|
52
|
-
// 4. Emit the Deno entry
|
|
53
|
-
writeEntryFile(
|
|
54
|
-
ctx,
|
|
55
|
-
"dist/main.ts",
|
|
56
|
-
`
|
|
57
|
-
import { createServer } from "./server.js";
|
|
58
|
-
const app = createServer({
|
|
59
|
-
ssrEntry: "./server.js",
|
|
60
|
-
staticDir: "./static",
|
|
61
|
-
});
|
|
62
|
-
Deno.serve(app.fetch);
|
|
63
|
-
`,
|
|
64
|
-
);
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
```
|
|
68
|
-
|
|
69
|
-
### Registering
|
|
70
|
-
|
|
71
|
-
```ts
|
|
72
|
-
// vite.config.ts
|
|
73
|
-
import { finesoftFrontViteConfig } from "@finesoft/front";
|
|
74
|
-
import { denoDeployAdapter } from "./src/lib/adapters/deno-deploy";
|
|
75
|
-
|
|
76
|
-
export default {
|
|
77
|
-
plugins: [
|
|
78
|
-
finesoftFrontViteConfig({
|
|
79
|
-
ssr: { entry: "src/ssr.ts" },
|
|
80
|
-
adapter: denoDeployAdapter,
|
|
81
|
-
}),
|
|
82
|
-
],
|
|
83
|
-
};
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
The `adapter` option accepts either a string (built-in) or an `AdapterDefinition` (custom).
|
|
87
|
-
|
|
88
|
-
## Adapter context
|
|
89
|
-
|
|
90
|
-
The `AdapterContext` passed to `build()` exposes:
|
|
91
|
-
|
|
92
|
-
```ts
|
|
93
|
-
interface AdapterContext {
|
|
94
|
-
root: string; // absolute path to project root
|
|
95
|
-
outDir: string; // absolute path to dist directory
|
|
96
|
-
ssrEntryPath: string; // resolved path to src/ssr.ts
|
|
97
|
-
routes: RouteDefinition[]; // routes from bootstrap (for prerendering)
|
|
98
|
-
proxies: ProxyRouteConfig[]; // proxy config from finesoftFrontViteConfig
|
|
99
|
-
isr: IsrConfig | null; // ISR config if enabled
|
|
100
|
-
env: Record<string, string>; // build-time env vars
|
|
101
|
-
}
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
You don't typically read all of these — `buildBundle` and `generateSSREntry` take what they need.
|
|
105
|
-
|
|
106
|
-
## Common patterns
|
|
107
|
-
|
|
108
|
-
### Edge runtime (Workers / Deno / Bun)
|
|
109
|
-
|
|
110
|
-
Standard Web APIs (Request, Response, fetch). Bundle as ESM, target `webworker`. Most edge runtimes accept a default-exported handler:
|
|
111
|
-
|
|
112
|
-
```ts
|
|
113
|
-
export default {
|
|
114
|
-
async fetch(request, env) {
|
|
115
|
-
return app.fetch(request, env);
|
|
116
|
-
},
|
|
117
|
-
};
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
The Cloudflare adapter in `packages/server/src/adapters/cloudflare.ts` is the canonical reference.
|
|
121
|
-
|
|
122
|
-
### Lambda-style (AWS Lambda, GCF, Azure Functions)
|
|
123
|
-
|
|
124
|
-
Platform-specific event shapes. Convert to/from `Request`:
|
|
125
|
-
|
|
126
|
-
```ts
|
|
127
|
-
import { app } from "./server.js";
|
|
128
|
-
|
|
129
|
-
export const handler = async (event: APIGatewayProxyEventV2) => {
|
|
130
|
-
const request = lambdaEventToRequest(event);
|
|
131
|
-
const response = await app.fetch(request);
|
|
132
|
-
return responseToLambdaResult(response);
|
|
133
|
-
};
|
|
134
|
-
```
|
|
135
|
-
|
|
136
|
-
Each cloud's SDK ships type definitions and helpers for the event-to-request conversion. Lift-and-shift them; don't reinvent.
|
|
137
|
-
|
|
138
|
-
### Multi-process server (Bun cluster, PM2)
|
|
139
|
-
|
|
140
|
-
Bun and modern Node support `cluster`-style multi-process serving for CPU parallelism:
|
|
141
|
-
|
|
142
|
-
```ts
|
|
143
|
-
import { app } from "./server.js";
|
|
144
|
-
import { serve } from "@hono/node-server";
|
|
145
|
-
|
|
146
|
-
const port = parseInt(process.env.PORT ?? "3000", 10);
|
|
147
|
-
serve({ fetch: app.fetch, port });
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
Each process is independent. The ISR cache is per-process — for a true shared cache, put a CDN in front.
|
|
151
|
-
|
|
152
|
-
## Static (no server)
|
|
153
|
-
|
|
154
|
-
`adapter: "static"` is the simplest target — everything is prerendered, nothing runs at request time.
|
|
155
|
-
|
|
156
|
-
```ts
|
|
157
|
-
export const staticAdapter: AdapterDefinition = {
|
|
158
|
-
name: "static",
|
|
159
|
-
async build(ctx) {
|
|
160
|
-
// Skip SSR bundle entirely
|
|
161
|
-
await prerenderRoutes(ctx); // every route must have renderMode: "prerender"
|
|
162
|
-
copyStaticAssets(ctx, "dist/client", "dist/static");
|
|
163
|
-
// No server entry — just the static files
|
|
164
|
-
},
|
|
165
|
-
};
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
Verify every route is prerenderable:
|
|
169
|
-
|
|
170
|
-
```ts
|
|
171
|
-
if (!ctx.routes.every((r) => r.renderMode === "prerender")) {
|
|
172
|
-
throw new Error("Static adapter requires every route to be renderMode: 'prerender'");
|
|
173
|
-
}
|
|
174
|
-
```
|
|
175
|
-
|
|
176
|
-
## Auto-detection extension
|
|
177
|
-
|
|
178
|
-
The built-in `"auto"` adapter checks env vars in order:
|
|
3
|
+
A build adapter implements Adapter from the Vite entry. Its job is to emit deployment files and bind platform Request/Response and cleanup capabilities. Runtime SSR uses createSSRHandler from the SSR entry. Keep one shared response assembler; custom generators must preserve request context, status, headers, cookies, locale and redirects. The internal buildBundle/generateSSREntry helpers are private implementation, not exported public APIs. The repository’s Node/Cloudflare/Netlify/Vercel adapters are reference implementations; validate emitted code in the target runtime before claiming support.
|
|
179
4
|
|
|
180
5
|
```ts
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
return "node";
|
|
186
|
-
}
|
|
187
|
-
```
|
|
188
|
-
|
|
189
|
-
If your custom adapter has a known env signature, you can wrap auto-detection yourself in your project's `vite.config.ts`:
|
|
190
|
-
|
|
191
|
-
```ts
|
|
192
|
-
function pickAdapter() {
|
|
193
|
-
if (process.env.DENO_DEPLOYMENT_ID) return denoDeployAdapter;
|
|
194
|
-
return "node";
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
finesoftFrontViteConfig({
|
|
198
|
-
adapter: pickAdapter(),
|
|
199
|
-
});
|
|
6
|
+
import type { Adapter, AdapterContext } from "@finesoft/front";
|
|
7
|
+
// Supply { name, async build(context: AdapterContext) { ... } } as the Vite adapter.
|
|
8
|
+
// Runtime module:
|
|
9
|
+
import { createSSRHandler } from "@finesoft/front";
|
|
200
10
|
```
|
|
201
11
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
Integration test: run the build, then exercise the emitted entry:
|
|
205
|
-
|
|
206
|
-
```ts
|
|
207
|
-
import { describe, test, expect } from "vite-plus/test";
|
|
208
|
-
import { build } from "vite";
|
|
209
|
-
import { denoDeployAdapter } from "./deno-deploy";
|
|
210
|
-
|
|
211
|
-
describe("denoDeployAdapter", () => {
|
|
212
|
-
test("emits a Deno-compatible entry", async () => {
|
|
213
|
-
await build({
|
|
214
|
-
root: "test/fixtures/basic",
|
|
215
|
-
plugins: [
|
|
216
|
-
finesoftFrontViteConfig({
|
|
217
|
-
ssr: { entry: "src/ssr.ts" },
|
|
218
|
-
adapter: denoDeployAdapter,
|
|
219
|
-
}),
|
|
220
|
-
],
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
const entry = await readFile("test/fixtures/basic/dist/main.ts", "utf-8");
|
|
224
|
-
expect(entry).toContain("Deno.serve");
|
|
225
|
-
expect(entry).toContain("./server.js");
|
|
226
|
-
});
|
|
227
|
-
});
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
Smoke test the runtime: spin up the actual platform locally and hit `/`. This catches platform-specific quirks (CORS, header normalization, body decoding) that unit tests don't.
|
|
231
|
-
|
|
232
|
-
## Gotchas
|
|
233
|
-
|
|
234
|
-
### Don't bundle Node built-ins on edge runtimes
|
|
235
|
-
|
|
236
|
-
`fs`, `path`, `http`, etc. don't exist on Workers / Deno. `generateSSREntry` accepts an `external` list — set it to the platform-incompatible modules so the bundler errors out at build time rather than the deploy crashing at request time.
|
|
237
|
-
|
|
238
|
-
### `process.env` works differently per platform
|
|
239
|
-
|
|
240
|
-
- Node, Vercel: `process.env.FOO`
|
|
241
|
-
- Cloudflare Workers: secrets via `env` arg to `fetch()`
|
|
242
|
-
- Deno: `Deno.env.get("FOO")`
|
|
243
|
-
|
|
244
|
-
The framework handles `process.env` for declared proxy auth keys, but for your own runtime env reads, wrap them in a platform-aware helper.
|
|
245
|
-
|
|
246
|
-
### File system access for assets
|
|
247
|
-
|
|
248
|
-
If you rely on reading files at request time (rare; most serve through `staticDir`), only Node-like adapters have native fs access. For edge runtimes, embed assets into the bundle or proxy through KV stores.
|
|
249
|
-
|
|
250
|
-
## Submitting upstream
|
|
251
|
-
|
|
252
|
-
If your adapter targets a popular platform that doesn't ship with the framework, consider opening a PR. Adapters live in `packages/server/src/adapters/` and follow a consistent structure — `cloudflare.ts` is the cleanest reference.
|
|
253
|
-
|
|
254
|
-
The framework's adapter API is intentionally small. Keep your contribution minimal:
|
|
255
|
-
|
|
256
|
-
- One file in `adapters/`
|
|
257
|
-
- One entry in `auto.ts` for auto-detection (if applicable)
|
|
258
|
-
- One section in this doc
|
|
259
|
-
|
|
260
|
-
## Related
|
|
261
|
-
|
|
262
|
-
- The bundled adapters: `packages/server/src/adapters/`
|
|
263
|
-
- The shared helpers you'll use: `packages/server/src/adapters/shared.ts`
|
|
264
|
-
- [Chapter 9: Server & deployment](../09-server-and-deployment.md) — what the adapters wrap
|
|
12
|
+
`createSSRHandler` returns the execution owner with `fetch(request, bindings)` and `dispose()`, replacing `createSSRHost(...).handle(...)`. Standard hosts set `ownRenderers: true`: shutdown drains response assembly, then disposes each owned renderer once. Without this option, renderers remain caller-owned. `createSSRRender` owns native rendering and Runtime shutdown itself.
|
|
@@ -1,318 +1,13 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Event recording
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Portable operations emit structured events through their execution context; the runtime accepts a recorder.
|
|
4
4
|
|
|
5
|
-
##
|
|
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
|
|
5
|
+
## Recorder / 记录器
|
|
16
6
|
|
|
17
7
|
```ts
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
}
|
|
8
|
+
import { ConsoleEventRecorder, createRuntime } from "@finesoft/front";
|
|
9
|
+
const runtime = createRuntime({ app, recorder: new ConsoleEventRecorder() });
|
|
10
|
+
// In a handler: context.record("cart.updated", { itemCount: 3 });
|
|
100
11
|
```
|
|
101
12
|
|
|
102
|
-
|
|
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/`
|
|
13
|
+
Record business outcomes without copying credentials, raw request bodies or private page data. Recorder failures are isolated from execution. Browser impressions require the explicit browser observer; portable core only defines the event contract.
|