@fixback/node 0.2.0

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 (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/dist/client.d.ts +79 -0
  4. package/dist/client.js +220 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/config.d.ts +71 -0
  7. package/dist/config.js +68 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/context.d.ts +53 -0
  10. package/dist/context.js +97 -0
  11. package/dist/context.js.map +1 -0
  12. package/dist/express.d.ts +42 -0
  13. package/dist/express.js +51 -0
  14. package/dist/express.js.map +1 -0
  15. package/dist/http.d.ts +53 -0
  16. package/dist/http.js +75 -0
  17. package/dist/http.js.map +1 -0
  18. package/dist/index.d.ts +23 -0
  19. package/dist/index.js +38 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/nestjs.d.ts +44 -0
  22. package/dist/nestjs.js +114 -0
  23. package/dist/nestjs.js.map +1 -0
  24. package/dist/package.json +3 -0
  25. package/dist/process.d.ts +52 -0
  26. package/dist/process.js +124 -0
  27. package/dist/process.js.map +1 -0
  28. package/dist/stack.d.ts +33 -0
  29. package/dist/stack.js +142 -0
  30. package/dist/stack.js.map +1 -0
  31. package/dist/transport.d.ts +90 -0
  32. package/dist/transport.js +172 -0
  33. package/dist/transport.js.map +1 -0
  34. package/dist/version.d.ts +13 -0
  35. package/dist/version.js +17 -0
  36. package/dist/version.js.map +1 -0
  37. package/dist/wire.d.ts +108 -0
  38. package/dist/wire.js +15 -0
  39. package/dist/wire.js.map +1 -0
  40. package/package.json +91 -0
  41. package/src/client.test.ts +272 -0
  42. package/src/client.ts +262 -0
  43. package/src/config.test.ts +79 -0
  44. package/src/config.ts +131 -0
  45. package/src/context.test.ts +99 -0
  46. package/src/context.ts +124 -0
  47. package/src/express.test.ts +112 -0
  48. package/src/express.ts +85 -0
  49. package/src/fingerprint-parity.test.ts +86 -0
  50. package/src/http.ts +83 -0
  51. package/src/index.ts +51 -0
  52. package/src/nestjs.test.ts +146 -0
  53. package/src/nestjs.ts +123 -0
  54. package/src/process.test.ts +154 -0
  55. package/src/process.ts +153 -0
  56. package/src/stack.test.ts +89 -0
  57. package/src/stack.ts +153 -0
  58. package/src/transport.test.ts +187 -0
  59. package/src/transport.ts +227 -0
  60. package/src/version.test.ts +10 -0
  61. package/src/version.ts +13 -0
  62. package/src/wire.ts +116 -0
package/src/client.ts ADDED
@@ -0,0 +1,262 @@
1
+ /**
2
+ * The `FixbackClient` and the module-level singleton API (`init`, `captureException`,
3
+ * `captureMessage`, `flush`, `close`) — the SDK's capture surface.
4
+ *
5
+ * A capture distils the error, fingerprints it with the **shared core**
6
+ * (`@fixback/sdk-core`, so the key matches the browser SDK — ADR-0028), attaches the
7
+ * in-flight request's {@link ServerContext} from AsyncLocalStorage, runs the scrub
8
+ * choke point and the project's `beforeSend`, then hands a {@link ServerErrorPayload}
9
+ * to the batched transport. Every step is wrapped so a capture **never throws into
10
+ * the host** (story 34): a misbehaving hook, an unserialisable error, or a transport
11
+ * hiccup is swallowed, and the server keeps running.
12
+ */
13
+
14
+ import { computeFingerprint, scrubUrl } from "@fixback/sdk-core";
15
+
16
+ import { type InitOptions, resolveConfig, type ResolvedConfig } from "./config";
17
+ import { currentServerContext } from "./context";
18
+ import { installProcessHandlers, type ProcessLike } from "./process";
19
+ import { extractError, extractStructuredFrames } from "./stack";
20
+ import { type CaptureTransport, Transport } from "./transport";
21
+ import type { FixbackErrorEvent, ServerContext, ServerErrorPayload, Severity } from "./wire";
22
+
23
+ export type { BeforeSend, FixbackErrorEvent, ServerContext, Severity } from "./wire";
24
+ export type { InitOptions } from "./config";
25
+ export type { CaptureTransport } from "./transport";
26
+
27
+ /** Optional per-capture context — override `handled` or add/override server context. */
28
+ export interface CaptureContext {
29
+ /** Override the handled flag (defaults to `true` for a manual/caught capture). */
30
+ readonly handled?: boolean;
31
+ /** Server context to merge over what the request scope provides (e.g. a filter's status). */
32
+ readonly server?: ServerContext;
33
+ }
34
+
35
+ /** The minimal capture surface the framework adapters file through (satisfied by {@link FixbackClient}). */
36
+ export interface CaptureClient {
37
+ captureException(error: unknown, context?: CaptureContext): void;
38
+ }
39
+
40
+ /** Advanced/testing seam: supply a custom {@link CaptureTransport} or process. */
41
+ export interface ClientDeps {
42
+ readonly transport?: CaptureTransport;
43
+ /** The process to attach handlers to (defaults to the real `process`). */
44
+ readonly process?: ProcessLike;
45
+ /** Override Node's default fatal behaviour after a sole-listener uncaught exception. */
46
+ readonly onFatalError?: (error: unknown) => void;
47
+ }
48
+
49
+ type MutablePayload = { -readonly [K in keyof ServerErrorPayload]: ServerErrorPayload[K] };
50
+
51
+ /** Merge two server contexts, the override winning; `undefined` when both are empty. */
52
+ function mergeServerContext(
53
+ base: ServerContext | undefined,
54
+ override: ServerContext | undefined,
55
+ ): ServerContext | undefined {
56
+ if (!base && !override) return undefined;
57
+ const merged: Record<string, unknown> = { ...(base ?? {}), ...(override ?? {}) };
58
+ for (const key of Object.keys(merged)) {
59
+ if (merged[key] === undefined) delete merged[key];
60
+ }
61
+ return Object.keys(merged).length > 0 ? (merged as ServerContext) : undefined;
62
+ }
63
+
64
+ export class FixbackClient {
65
+ private readonly config: ResolvedConfig;
66
+ private readonly transport: CaptureTransport | null;
67
+
68
+ constructor(config: ResolvedConfig, deps: ClientDeps = {}) {
69
+ this.config = config;
70
+ this.transport =
71
+ deps.transport ??
72
+ (config.enabled
73
+ ? new Transport({
74
+ endpoint: config.errorsEndpoint,
75
+ secretKey: config.secretKey,
76
+ maxBatchSize: config.maxBatchSize,
77
+ maxQueueSize: config.maxQueueSize,
78
+ flushIntervalMs: config.flushIntervalMs,
79
+ timeoutMs: config.timeoutMs,
80
+ })
81
+ : null);
82
+ }
83
+
84
+ /** Whether this client will capture (its secret key resolved and the gate is on). */
85
+ get enabled(): boolean {
86
+ return this.config.enabled;
87
+ }
88
+
89
+ /** The header the request-context middleware reads a correlation id from. */
90
+ get requestIdHeader(): string {
91
+ return this.config.requestIdHeader;
92
+ }
93
+
94
+ /**
95
+ * Report a caught or manually-observed error. `handled` defaults to `true`; the
96
+ * process handlers pass `handled: false` for an uncaught crash.
97
+ */
98
+ captureException(error: unknown, context: CaptureContext = {}): void {
99
+ const { type, value, stack } = extractError(error);
100
+ const frames = extractStructuredFrames(stack);
101
+ this.dispatch(
102
+ {
103
+ errorSignature: computeFingerprint(type, value, stack),
104
+ type,
105
+ value,
106
+ handled: context.handled ?? true,
107
+ errorFrames: frames.length > 0 ? frames : undefined,
108
+ },
109
+ context,
110
+ );
111
+ }
112
+
113
+ /** Report a noteworthy non-error condition, grouped by its message text. */
114
+ captureMessage(message: string, level: Severity = "info", context: CaptureContext = {}): void {
115
+ const value = typeof message === "string" ? message : String(message);
116
+ this.dispatch(
117
+ {
118
+ errorSignature: computeFingerprint("Message", value),
119
+ type: "Message",
120
+ value,
121
+ level,
122
+ handled: context.handled ?? true,
123
+ },
124
+ context,
125
+ );
126
+ }
127
+
128
+ /** Flush the transport's buffer now. Resolves even when the SDK is disabled. */
129
+ flush(): Promise<void> {
130
+ return this.transport?.flush() ?? Promise.resolve();
131
+ }
132
+
133
+ /** Stop the transport (its timer) and drain what remains. */
134
+ close(): Promise<void> {
135
+ return this.transport?.close() ?? Promise.resolve();
136
+ }
137
+
138
+ /** Assemble, scrub, run `beforeSend`, and enqueue — swallowing any failure. */
139
+ private dispatch(
140
+ partial: Pick<FixbackErrorEvent, "errorSignature" | "type" | "value" | "handled"> &
141
+ Partial<Pick<FixbackErrorEvent, "level" | "errorFrames">>,
142
+ context: CaptureContext,
143
+ ): void {
144
+ if (!this.config.enabled || !this.transport) return;
145
+ try {
146
+ const event: FixbackErrorEvent = {
147
+ errorSignature: partial.errorSignature,
148
+ type: partial.type,
149
+ value: partial.value,
150
+ handled: partial.handled,
151
+ level: partial.level,
152
+ environment: this.config.environment,
153
+ release: this.config.release,
154
+ errorFrames: partial.errorFrames,
155
+ server: mergeServerContext(currentServerContext(), context.server),
156
+ };
157
+ if (this.config.scrub) applyDefaultScrub(event);
158
+ const finalised = this.runBeforeSend(event);
159
+ if (!finalised) return; // dropped at the choke point — no transport
160
+ this.transport.enqueue(toPayload(finalised));
161
+ } catch {
162
+ // Capture must never throw into the host (story 34).
163
+ }
164
+ }
165
+
166
+ /** Run the project hook after the default scrubbers; a throwing hook is a no-op. */
167
+ private runBeforeSend(event: FixbackErrorEvent): FixbackErrorEvent | null {
168
+ const hook = this.config.beforeSend;
169
+ if (!hook) return event;
170
+ try {
171
+ return hook(event) ?? null;
172
+ } catch {
173
+ return event; // a buggy hook never breaks the report path nor leaks unscrubbed data
174
+ }
175
+ }
176
+ }
177
+
178
+ /** The built-in default scrub: re-sweep frame URLs through the shared-core scrubber. */
179
+ function applyDefaultScrub(event: FixbackErrorEvent): void {
180
+ if (event.errorFrames && event.errorFrames.length > 0) {
181
+ event.errorFrames = event.errorFrames.map((frame) => ({
182
+ ...frame,
183
+ file: scrubUrl(frame.file),
184
+ }));
185
+ }
186
+ }
187
+
188
+ /** Project the event onto the wire payload, dropping the event-only fields. */
189
+ function toPayload(event: FixbackErrorEvent): ServerErrorPayload {
190
+ const payload: MutablePayload = { errorSignature: event.errorSignature };
191
+ if (event.handled !== undefined) payload.handled = event.handled;
192
+ if (event.environment !== undefined) payload.environment = event.environment;
193
+ if (event.release !== undefined) payload.release = event.release;
194
+ if (event.occurrences !== undefined) payload.occurrences = event.occurrences;
195
+ if (event.errorFrames && event.errorFrames.length > 0) payload.errorFrames = event.errorFrames;
196
+ if (event.server) payload.server = event.server;
197
+ return payload;
198
+ }
199
+
200
+ // --- Module-level singleton API ----------------------------------------------
201
+
202
+ let activeClient: FixbackClient | null = null;
203
+ let activeUninstall: (() => void) | null = null;
204
+
205
+ /**
206
+ * Initialise the SDK: resolve the config, replace any previous client (closing it and
207
+ * removing its process handlers), install the polite process handlers, and return the
208
+ * active {@link FixbackClient}. Fail-quiet — a missing secret key yields an inert
209
+ * client (no handlers, no transport) rather than throwing.
210
+ */
211
+ export function init(options: InitOptions, deps?: ClientDeps): FixbackClient {
212
+ const previousClient = activeClient;
213
+ const previousUninstall = activeUninstall;
214
+ if (previousUninstall) previousUninstall();
215
+ if (previousClient) void previousClient.close();
216
+
217
+ const config = resolveConfig(options);
218
+ const client = new FixbackClient(config, deps);
219
+ activeClient = client;
220
+ activeUninstall =
221
+ config.enabled && (config.captureUncaughtException || config.captureUnhandledRejection)
222
+ ? installProcessHandlers(
223
+ client,
224
+ {
225
+ captureUncaughtException: config.captureUncaughtException,
226
+ captureUnhandledRejection: config.captureUnhandledRejection,
227
+ },
228
+ { process: deps?.process, onFatalError: deps?.onFatalError },
229
+ )
230
+ : null;
231
+ return client;
232
+ }
233
+
234
+ /** The active client set by {@link init}, or `null` before the first `init`. */
235
+ export function getClient(): FixbackClient | null {
236
+ return activeClient;
237
+ }
238
+
239
+ /** Report a caught/manual error through the active client (no-op before `init`). */
240
+ export function captureException(error: unknown, context?: CaptureContext): void {
241
+ activeClient?.captureException(error, context);
242
+ }
243
+
244
+ /** Report a message through the active client (no-op before `init`). */
245
+ export function captureMessage(message: string, level?: Severity, context?: CaptureContext): void {
246
+ activeClient?.captureMessage(message, level, context);
247
+ }
248
+
249
+ /** Flush the active client's buffer (no-op before `init`). */
250
+ export function flush(): Promise<void> {
251
+ return activeClient?.flush() ?? Promise.resolve();
252
+ }
253
+
254
+ /** Close and clear the active client, removing its process handlers (no-op before `init`). */
255
+ export function close(): Promise<void> {
256
+ const current = activeClient;
257
+ const uninstall = activeUninstall;
258
+ activeClient = null;
259
+ activeUninstall = null;
260
+ if (uninstall) uninstall();
261
+ return current?.close() ?? Promise.resolve();
262
+ }
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { DEFAULT_API_URL, resolveConfig } from "./config";
4
+
5
+ describe("resolveConfig", () => {
6
+ it("enables capture and derives the ingest endpoint from a secret key", () => {
7
+ const config = resolveConfig({ secretKey: "sk_live_abc" });
8
+ expect(config.enabled).toBe(true);
9
+ expect(config.secretKey).toBe("sk_live_abc");
10
+ expect(config.apiUrl).toBe(DEFAULT_API_URL);
11
+ expect(config.errorsEndpoint).toBe(`${DEFAULT_API_URL}/api/errors`);
12
+ });
13
+
14
+ it("stays disabled (fail-quiet) when no secret key is given", () => {
15
+ // Misconfiguration must never throw — the host server keeps running (story 34).
16
+ expect(() => resolveConfig({ secretKey: "" })).not.toThrow();
17
+ expect(resolveConfig({ secretKey: "" }).enabled).toBe(false);
18
+ expect(resolveConfig({ secretKey: " " }).enabled).toBe(false);
19
+ // @ts-expect-error — a caller may omit the key entirely at runtime.
20
+ expect(resolveConfig({}).enabled).toBe(false);
21
+ });
22
+
23
+ it("honours an explicit enabled:false gate even with a valid key (story 35)", () => {
24
+ const config = resolveConfig({ secretKey: "sk_live_abc", enabled: false });
25
+ expect(config.enabled).toBe(false);
26
+ });
27
+
28
+ it("normalises the api url (trailing slashes stripped) before building the endpoint", () => {
29
+ const config = resolveConfig({
30
+ secretKey: "sk_live_abc",
31
+ apiUrl: "https://fixback.acme.dev/",
32
+ });
33
+ expect(config.apiUrl).toBe("https://fixback.acme.dev");
34
+ expect(config.errorsEndpoint).toBe("https://fixback.acme.dev/api/errors");
35
+ });
36
+
37
+ it("carries environment and release through, dropping blank strings", () => {
38
+ const config = resolveConfig({
39
+ secretKey: "sk_live_abc",
40
+ environment: "production",
41
+ release: " 1.4.2 ",
42
+ });
43
+ expect(config.environment).toBe("production");
44
+ expect(config.release).toBe("1.4.2");
45
+ expect(resolveConfig({ secretKey: "sk", environment: " " }).environment).toBeUndefined();
46
+ });
47
+
48
+ it("defaults the batching knobs and clamps the batch to the server maximum", () => {
49
+ const config = resolveConfig({ secretKey: "sk_live_abc" });
50
+ expect(config.maxBatchSize).toBe(100);
51
+ expect(config.flushIntervalMs).toBeGreaterThan(0);
52
+ expect(config.maxQueueSize).toBeGreaterThan(0);
53
+
54
+ // The server rejects batches over 500; a larger request is clamped, not sent oversized.
55
+ expect(resolveConfig({ secretKey: "sk", maxBatchSize: 5000 }).maxBatchSize).toBe(500);
56
+ expect(resolveConfig({ secretKey: "sk", maxBatchSize: 0 }).maxBatchSize).toBe(1);
57
+ });
58
+
59
+ it("defaults the process-handler gates on and honours explicit opt-outs", () => {
60
+ const on = resolveConfig({ secretKey: "sk" });
61
+ expect(on.captureUncaughtException).toBe(true);
62
+ expect(on.captureUnhandledRejection).toBe(true);
63
+
64
+ const off = resolveConfig({
65
+ secretKey: "sk",
66
+ captureUncaughtException: false,
67
+ captureUnhandledRejection: false,
68
+ });
69
+ expect(off.captureUncaughtException).toBe(false);
70
+ expect(off.captureUnhandledRejection).toBe(false);
71
+ });
72
+
73
+ it("lower-cases the correlation-id header name", () => {
74
+ expect(resolveConfig({ secretKey: "sk", requestIdHeader: "X-Trace-Id" }).requestIdHeader).toBe(
75
+ "x-trace-id",
76
+ );
77
+ expect(resolveConfig({ secretKey: "sk" }).requestIdHeader).toBe("x-request-id");
78
+ });
79
+ });
package/src/config.ts ADDED
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The `init` options and their resolution into a validated {@link ResolvedConfig}.
3
+ *
4
+ * Resolution is **fail-quiet** (spec #224, story 34): it never throws. A missing
5
+ * secret key or an explicit `enabled: false` leaves the SDK inert (`enabled: false`)
6
+ * rather than raising — a Fixback misconfiguration must never break the host server.
7
+ * Numeric knobs are clamped to safe bounds (the batch to the server's 500-item cap)
8
+ * instead of rejected.
9
+ */
10
+
11
+ import type { BeforeSend } from "./wire";
12
+
13
+ /** The default Fixback ingest origin, shared with the browser and Expo SDKs. */
14
+ export const DEFAULT_API_URL = "https://api.fixback.dev";
15
+
16
+ /** The largest batch the server-error endpoint accepts (`MAX_SERVER_ERROR_BATCH`). */
17
+ const SERVER_MAX_BATCH = 500;
18
+
19
+ const DEFAULT_MAX_BATCH_SIZE = 100;
20
+ const DEFAULT_FLUSH_INTERVAL_MS = 5_000;
21
+ const DEFAULT_MAX_QUEUE_SIZE = 1_024;
22
+ const DEFAULT_TIMEOUT_MS = 30_000;
23
+ const DEFAULT_REQUEST_ID_HEADER = "x-request-id";
24
+
25
+ /** Options for {@link init} — the one call that wires backend capture. */
26
+ export interface InitOptions {
27
+ /**
28
+ * The Project **secret key** (`sk_…`) the backend authenticates with — the same
29
+ * key `npx fixback sourcemaps upload` uses (never the publishable key). Without it
30
+ * the SDK stays inert.
31
+ */
32
+ readonly secretKey: string;
33
+ /** The Fixback ingest origin. Defaults to {@link DEFAULT_API_URL}. */
34
+ readonly apiUrl?: string;
35
+ /** The deploy environment (`production` / `staging` / …) stamped on every error. */
36
+ readonly environment?: string;
37
+ /** The build **Release** the captured stacks symbolicate against. */
38
+ readonly release?: string;
39
+ /** A hook to redact or drop an event before it leaves the process (return `null` to drop). */
40
+ readonly beforeSend?: BeforeSend;
41
+ /** Run the built-in default scrubbers before `beforeSend`. Defaults to `true`. */
42
+ readonly scrub?: boolean;
43
+ /** Master gate — set `false` to disable all capture (e.g. in local dev). Defaults to `true`. */
44
+ readonly enabled?: boolean;
45
+ /** Install the polite `uncaughtException` handler. Defaults to `true`. */
46
+ readonly captureUncaughtException?: boolean;
47
+ /** Install the polite `unhandledRejection` handler. Defaults to `true`. */
48
+ readonly captureUnhandledRejection?: boolean;
49
+ /** Flush the buffer once it reaches this many errors. Defaults to `100`; capped at `500`. */
50
+ readonly maxBatchSize?: number;
51
+ /** Flush a non-empty buffer at least this often, in ms. Defaults to `5000`. */
52
+ readonly flushIntervalMs?: number;
53
+ /** Cap the in-memory buffer so a flood can't grow it without bound. Defaults to `1024`. */
54
+ readonly maxQueueSize?: number;
55
+ /** Overall per-request transport timeout in ms; `0` disables. Defaults to `30000`. */
56
+ readonly timeoutMs?: number;
57
+ /** The header a correlation id is read from. Defaults to `x-request-id`. */
58
+ readonly requestIdHeader?: string;
59
+ }
60
+
61
+ /** The validated, defaulted configuration a {@link FixbackClient} runs on. */
62
+ export interface ResolvedConfig {
63
+ readonly enabled: boolean;
64
+ readonly secretKey: string;
65
+ readonly apiUrl: string;
66
+ readonly errorsEndpoint: string;
67
+ readonly environment?: string;
68
+ readonly release?: string;
69
+ readonly beforeSend?: BeforeSend;
70
+ readonly scrub: boolean;
71
+ readonly captureUncaughtException: boolean;
72
+ readonly captureUnhandledRejection: boolean;
73
+ readonly maxBatchSize: number;
74
+ readonly flushIntervalMs: number;
75
+ readonly maxQueueSize: number;
76
+ readonly timeoutMs: number;
77
+ readonly requestIdHeader: string;
78
+ }
79
+
80
+ /** Trim a string option, returning `undefined` for a blank or non-string value. */
81
+ function trimmed(value: unknown): string | undefined {
82
+ if (typeof value !== "string") return undefined;
83
+ const out = value.trim();
84
+ return out.length > 0 ? out : undefined;
85
+ }
86
+
87
+ /** Normalise an api base URL: trim and strip trailing slashes. */
88
+ function normalizeApiUrl(value: string | undefined): string {
89
+ const base = trimmed(value) ?? DEFAULT_API_URL;
90
+ return base.replace(/\/+$/, "") || DEFAULT_API_URL;
91
+ }
92
+
93
+ /** Clamp a positive-integer option to `[min, max]`, falling back to `fallback`. */
94
+ function clampInt(value: unknown, fallback: number, min: number, max: number): number {
95
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
96
+ return Math.min(max, Math.max(min, Math.floor(value)));
97
+ }
98
+
99
+ /**
100
+ * Resolve raw {@link InitOptions} into a validated {@link ResolvedConfig}. Never
101
+ * throws: without a usable secret key (or with `enabled: false`) the result is
102
+ * inert (`enabled: false`), so an inert client can no-op every capture.
103
+ */
104
+ export function resolveConfig(options: InitOptions): ResolvedConfig {
105
+ const secretKey = trimmed(options.secretKey) ?? "";
106
+ const apiUrl = normalizeApiUrl(options.apiUrl);
107
+ const enabled = options.enabled !== false && secretKey.length > 0;
108
+
109
+ return {
110
+ enabled,
111
+ secretKey,
112
+ apiUrl,
113
+ errorsEndpoint: `${apiUrl}/api/errors`,
114
+ environment: trimmed(options.environment),
115
+ release: trimmed(options.release),
116
+ beforeSend: typeof options.beforeSend === "function" ? options.beforeSend : undefined,
117
+ scrub: options.scrub !== false,
118
+ captureUncaughtException: options.captureUncaughtException !== false,
119
+ captureUnhandledRejection: options.captureUnhandledRejection !== false,
120
+ maxBatchSize: clampInt(options.maxBatchSize, DEFAULT_MAX_BATCH_SIZE, 1, SERVER_MAX_BATCH),
121
+ flushIntervalMs: clampInt(
122
+ options.flushIntervalMs,
123
+ DEFAULT_FLUSH_INTERVAL_MS,
124
+ 0,
125
+ Number.MAX_SAFE_INTEGER,
126
+ ),
127
+ maxQueueSize: clampInt(options.maxQueueSize, DEFAULT_MAX_QUEUE_SIZE, 1, Number.MAX_SAFE_INTEGER),
128
+ timeoutMs: clampInt(options.timeoutMs, DEFAULT_TIMEOUT_MS, 0, Number.MAX_SAFE_INTEGER),
129
+ requestIdHeader: (trimmed(options.requestIdHeader) ?? DEFAULT_REQUEST_ID_HEADER).toLowerCase(),
130
+ };
131
+ }
@@ -0,0 +1,99 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import {
4
+ createRequestContextMiddleware,
5
+ currentServerContext,
6
+ deriveServerContext,
7
+ getRequestStore,
8
+ runWithRequestStore,
9
+ setRequestUser,
10
+ } from "./context";
11
+ import { routePatternOf } from "./http";
12
+
13
+ describe("request store (AsyncLocalStorage)", () => {
14
+ it("exposes the active store inside the scope and nothing outside it", () => {
15
+ expect(getRequestStore()).toBeUndefined();
16
+ runWithRequestStore({ req: { method: "GET" } }, () => {
17
+ expect(getRequestStore()?.req.method).toBe("GET");
18
+ });
19
+ expect(getRequestStore()).toBeUndefined();
20
+ });
21
+
22
+ it("setRequestUser writes onto the active store and is a no-op outside a request", () => {
23
+ expect(() => setRequestUser("u-1")).not.toThrow();
24
+ runWithRequestStore({ req: {} }, () => {
25
+ setRequestUser("user-42");
26
+ expect(getRequestStore()?.user).toBe("user-42");
27
+ });
28
+ });
29
+ });
30
+
31
+ describe("deriveServerContext", () => {
32
+ it("derives method, route pattern, status, requestId and user — never the concrete path", () => {
33
+ const ctx = deriveServerContext({
34
+ req: { method: "POST", route: { path: "/users/:id" }, path: "/users/42" },
35
+ res: { statusCode: 500 },
36
+ requestId: "corr-9",
37
+ user: "user-42",
38
+ });
39
+ expect(ctx).toEqual({
40
+ method: "POST",
41
+ route: "/users/:id",
42
+ statusCode: 500,
43
+ requestId: "corr-9",
44
+ user: "user-42",
45
+ });
46
+ // The concrete path (with the value 42) is never carried.
47
+ expect(JSON.stringify(ctx)).not.toContain("/users/42");
48
+ });
49
+
50
+ it("returns undefined for no store and omits fields it can't resolve", () => {
51
+ expect(deriveServerContext(undefined)).toBeUndefined();
52
+ // No route matched yet, no status, no id → an empty context collapses to undefined.
53
+ expect(deriveServerContext({ req: {} })).toBeUndefined();
54
+ expect(deriveServerContext({ req: { method: "GET" } })).toEqual({ method: "GET" });
55
+ });
56
+ });
57
+
58
+ describe("createRequestContextMiddleware", () => {
59
+ it("opens a store carrying a generated correlation id and propagates across awaits", async () => {
60
+ const middleware = createRequestContextMiddleware();
61
+ const req = { method: "GET", route: { path: "/health" } };
62
+ const res = { statusCode: 200 };
63
+
64
+ const store = await new Promise<ReturnType<typeof getRequestStore>>((resolve) => {
65
+ middleware(req, res, async () => {
66
+ await Promise.resolve();
67
+ resolve(getRequestStore());
68
+ });
69
+ });
70
+
71
+ expect(store?.req).toBe(req);
72
+ expect(store?.res).toBe(res);
73
+ expect(typeof store?.requestId).toBe("string");
74
+ expect(store?.requestId?.length).toBeGreaterThan(0);
75
+ });
76
+
77
+ it("adopts an inbound correlation-id header when present", () => {
78
+ const middleware = createRequestContextMiddleware({ requestIdHeader: "x-request-id" });
79
+ const req = { method: "GET", headers: { "x-request-id": "trace-abc" }, route: { path: "/x" } };
80
+ let seen: string | undefined;
81
+ middleware(req, { statusCode: 200 }, () => {
82
+ seen = currentServerContext()?.requestId;
83
+ });
84
+ expect(seen).toBe("trace-abc");
85
+ });
86
+ });
87
+
88
+ describe("routePatternOf", () => {
89
+ it("joins a mounted router's base with the route pattern", () => {
90
+ expect(routePatternOf({ baseUrl: "/api", route: { path: "/users/:id" } })).toBe(
91
+ "/api/users/:id",
92
+ );
93
+ expect(routePatternOf({ route: { path: "/health" } })).toBe("/health");
94
+ });
95
+
96
+ it("omits the route entirely when none matched (never leaks the concrete path)", () => {
97
+ expect(routePatternOf({ path: "/users/42" })).toBeUndefined();
98
+ });
99
+ });