@finesoft/front 0.1.76 → 0.1.78

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 (51) hide show
  1. package/docs/01-getting-started.md +230 -0
  2. package/docs/02-routing-and-controllers.md +203 -0
  3. package/docs/03-middleware.md +220 -0
  4. package/docs/04-rendering-and-hydration.md +271 -0
  5. package/docs/05-i18n.md +243 -0
  6. package/docs/06-http-client.md +286 -0
  7. package/docs/07-di-container.md +264 -0
  8. package/docs/08-observability.md +290 -0
  9. package/docs/09-server-and-deployment.md +242 -0
  10. package/docs/10-features-platform-pwa.md +238 -0
  11. package/docs/README.md +72 -0
  12. package/docs/advanced/custom-action-handler.md +248 -0
  13. package/docs/advanced/custom-adapter.md +264 -0
  14. package/docs/advanced/custom-event-recorder.md +318 -0
  15. package/docs/advanced/inline-proxy-codegen.md +200 -0
  16. package/docs/advanced/multi-tenant-scopes.md +330 -0
  17. package/docs/engineering/ci-release-flow.md +244 -0
  18. package/docs/engineering/project-structure.md +296 -0
  19. package/docs/engineering/testing.md +317 -0
  20. package/docs/pitfalls/container-scope-leak.md +215 -0
  21. package/docs/pitfalls/i18n-bundle-size.md +182 -0
  22. package/docs/pitfalls/proxy-binary-payloads.md +133 -0
  23. package/docs/pitfalls/redirect-vs-rewrite.md +147 -0
  24. package/docs/pitfalls/ssr-hydration-mismatch.md +163 -0
  25. package/docs/pitfalls/ssr-vs-csr-globals.md +176 -0
  26. package/docs/zh/01-getting-started.md +230 -0
  27. package/docs/zh/02-routing-and-controllers.md +203 -0
  28. package/docs/zh/03-middleware.md +220 -0
  29. package/docs/zh/04-rendering-and-hydration.md +271 -0
  30. package/docs/zh/05-i18n.md +243 -0
  31. package/docs/zh/06-http-client.md +286 -0
  32. package/docs/zh/07-di-container.md +264 -0
  33. package/docs/zh/08-observability.md +287 -0
  34. package/docs/zh/09-server-and-deployment.md +242 -0
  35. package/docs/zh/10-features-platform-pwa.md +238 -0
  36. package/docs/zh/README.md +72 -0
  37. package/docs/zh/advanced/custom-action-handler.md +248 -0
  38. package/docs/zh/advanced/custom-adapter.md +264 -0
  39. package/docs/zh/advanced/custom-event-recorder.md +318 -0
  40. package/docs/zh/advanced/inline-proxy-codegen.md +200 -0
  41. package/docs/zh/advanced/multi-tenant-scopes.md +330 -0
  42. package/docs/zh/engineering/ci-release-flow.md +244 -0
  43. package/docs/zh/engineering/project-structure.md +296 -0
  44. package/docs/zh/engineering/testing.md +317 -0
  45. package/docs/zh/pitfalls/container-scope-leak.md +215 -0
  46. package/docs/zh/pitfalls/i18n-bundle-size.md +182 -0
  47. package/docs/zh/pitfalls/proxy-binary-payloads.md +133 -0
  48. package/docs/zh/pitfalls/redirect-vs-rewrite.md +147 -0
  49. package/docs/zh/pitfalls/ssr-hydration-mismatch.md +163 -0
  50. package/docs/zh/pitfalls/ssr-vs-csr-globals.md +176 -0
  51. package/package.json +2 -1
@@ -0,0 +1,264 @@
1
+ # Advanced: custom adapter
2
+
3
+ Target a platform the framework doesn't ship with. The bundled adapters are Node, Vercel, Cloudflare, Netlify, and Static. Anything else — Deno Deploy, Bun, AWS Lambda, custom on-prem — is a custom adapter.
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:
179
+
180
+ ```ts
181
+ function detectAdapter(env: Record<string, string>): string {
182
+ if (env.VERCEL === "1") return "vercel";
183
+ if (env.CF_PAGES === "1") return "cloudflare";
184
+ if (env.NETLIFY === "true") return "netlify";
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
+ });
200
+ ```
201
+
202
+ ## Testing the adapter
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
@@ -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/`