@fixback/node 0.2.0 → 0.3.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 (46) hide show
  1. package/README.md +4 -1
  2. package/dist/client.js +1 -1
  3. package/dist/client.js.map +1 -1
  4. package/dist/config.d.ts +25 -19
  5. package/dist/config.js +11 -6
  6. package/dist/config.js.map +1 -1
  7. package/dist/host-identity.d.ts +60 -0
  8. package/dist/host-identity.js +72 -0
  9. package/dist/host-identity.js.map +1 -0
  10. package/dist/http.d.ts +2 -2
  11. package/dist/http.js +2 -2
  12. package/dist/index.d.ts +2 -0
  13. package/dist/index.js +5 -1
  14. package/dist/index.js.map +1 -1
  15. package/dist/stack.d.ts +8 -18
  16. package/dist/stack.js +8 -77
  17. package/dist/stack.js.map +1 -1
  18. package/dist/transport.d.ts +1 -17
  19. package/dist/transport.js +4 -20
  20. package/dist/transport.js.map +1 -1
  21. package/dist/version.d.ts +1 -1
  22. package/dist/version.js +1 -1
  23. package/dist/wire.d.ts +10 -16
  24. package/package.json +8 -6
  25. package/src/client.test.ts +0 -272
  26. package/src/client.ts +0 -262
  27. package/src/config.test.ts +0 -79
  28. package/src/config.ts +0 -131
  29. package/src/context.test.ts +0 -99
  30. package/src/context.ts +0 -124
  31. package/src/express.test.ts +0 -112
  32. package/src/express.ts +0 -85
  33. package/src/fingerprint-parity.test.ts +0 -86
  34. package/src/http.ts +0 -83
  35. package/src/index.ts +0 -51
  36. package/src/nestjs.test.ts +0 -146
  37. package/src/nestjs.ts +0 -123
  38. package/src/process.test.ts +0 -154
  39. package/src/process.ts +0 -153
  40. package/src/stack.test.ts +0 -89
  41. package/src/stack.ts +0 -153
  42. package/src/transport.test.ts +0 -187
  43. package/src/transport.ts +0 -227
  44. package/src/version.test.ts +0 -10
  45. package/src/version.ts +0 -13
  46. package/src/wire.ts +0 -116
@@ -1,79 +0,0 @@
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 DELETED
@@ -1,131 +0,0 @@
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
- }
@@ -1,99 +0,0 @@
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
- });
package/src/context.ts DELETED
@@ -1,124 +0,0 @@
1
- /**
2
- * Per-request correlation via Node **AsyncLocalStorage** (spec §D9, ADR-0025) — so a
3
- * captured error carries the in-flight request's context without the developer
4
- * threading a context object through every call.
5
- *
6
- * The request-context middleware opens a store holding the request/response refs and
7
- * a correlation id; the transport reads {@link currentServerContext} at capture time,
8
- * so the route pattern and status are read *late* (once the router has matched and the
9
- * status is set), not at request start. `setRequestUser` lets the app attach its own
10
- * user ref — the SDK never scrapes identity from the request itself.
11
- */
12
-
13
- import { AsyncLocalStorage } from "node:async_hooks";
14
- import { randomUUID } from "node:crypto";
15
-
16
- import { type HttpRequestLike, type HttpResponseLike, type NextFunction, routePatternOf } from "./http";
17
- import type { ServerContext } from "./wire";
18
-
19
- /** What the ALS store carries for one in-flight request. */
20
- export interface RequestStore {
21
- readonly req: HttpRequestLike;
22
- readonly res?: HttpResponseLike;
23
- /** The correlation id (inbound header when present, else generated). */
24
- readonly requestId?: string;
25
- /** The app-supplied user ref, written by {@link setRequestUser}. Never scraped. */
26
- user?: string;
27
- }
28
-
29
- const storage = new AsyncLocalStorage<RequestStore>();
30
-
31
- /** The active request store, or `undefined` outside a request scope. */
32
- export function getRequestStore(): RequestStore | undefined {
33
- return storage.getStore();
34
- }
35
-
36
- /** Run `fn` (and everything it awaits) with `store` as the active request store. */
37
- export function runWithRequestStore<T>(store: RequestStore, fn: () => T): T {
38
- return storage.run(store, fn);
39
- }
40
-
41
- /**
42
- * Attach an **app-supplied** user reference to the current request, so any error
43
- * captured during it carries the user. A no-op outside a request scope (never
44
- * throws). The SDK never derives identity from the request — only what you pass here.
45
- */
46
- export function setRequestUser(user: string): void {
47
- const store = storage.getStore();
48
- if (store && typeof user === "string" && user.length > 0) store.user = user;
49
- }
50
-
51
- /**
52
- * Derive the private-by-default {@link ServerContext} from a request store: the HTTP
53
- * method, the route **pattern**, the resolved status, the correlation id, and the
54
- * app-supplied user ref. Returns `undefined` when there is no store or nothing
55
- * resolvable — never the concrete path, a body, a header, or a query value.
56
- */
57
- export function deriveServerContext(store: RequestStore | undefined): ServerContext | undefined {
58
- if (!store) return undefined;
59
- const ctx: {
60
- method?: string;
61
- route?: string;
62
- statusCode?: number;
63
- requestId?: string;
64
- user?: string;
65
- } = {};
66
-
67
- const method = store.req.method;
68
- if (typeof method === "string" && method.length > 0) ctx.method = method;
69
-
70
- const route = routePatternOf(store.req);
71
- if (route) ctx.route = route;
72
-
73
- const status = store.res?.statusCode;
74
- if (typeof status === "number" && status > 0) ctx.statusCode = status;
75
-
76
- if (store.requestId) ctx.requestId = store.requestId;
77
- if (store.user) ctx.user = store.user;
78
-
79
- return Object.keys(ctx).length > 0 ? ctx : undefined;
80
- }
81
-
82
- /** The {@link ServerContext} for the active request, or `undefined` outside one. */
83
- export function currentServerContext(): ServerContext | undefined {
84
- return deriveServerContext(storage.getStore());
85
- }
86
-
87
- /** Read a correlation id from the configured header, taking the first if repeated. */
88
- function readRequestId(req: HttpRequestLike, header: string): string | undefined {
89
- const raw = req.headers?.[header];
90
- const value = Array.isArray(raw) ? raw[0] : raw;
91
- if (typeof value !== "string") return undefined;
92
- const trimmed = value.trim();
93
- return trimmed.length > 0 ? trimmed : undefined;
94
- }
95
-
96
- /** Options for {@link createRequestContextMiddleware}. */
97
- export interface RequestContextOptions {
98
- /** The header a correlation id is read from. Defaults to `x-request-id`. */
99
- readonly requestIdHeader?: string;
100
- }
101
-
102
- /**
103
- * Build the Express-style request-context middleware: it opens an ALS store for the
104
- * request (adopting an inbound correlation id, or minting one) and runs the rest of
105
- * the request within it. Shared by the Express adapter and the NestJS module so both
106
- * frameworks get per-request correlation the same way.
107
- */
108
- export function createRequestContextMiddleware(
109
- options: RequestContextOptions = {},
110
- ): (req: HttpRequestLike, res: HttpResponseLike, next: NextFunction) => void {
111
- const header = (options.requestIdHeader ?? "x-request-id").toLowerCase();
112
- return function fixbackRequestContext(
113
- req: HttpRequestLike,
114
- res: HttpResponseLike,
115
- next: NextFunction,
116
- ): void {
117
- const store: RequestStore = {
118
- req,
119
- res,
120
- requestId: readRequestId(req, header) ?? randomUUID(),
121
- };
122
- runWithRequestStore(store, () => next());
123
- };
124
- }
@@ -1,112 +0,0 @@
1
- import { afterEach, describe, expect, it } from "vitest";
2
-
3
- import { close as moduleClose, FixbackClient, init } from "./client";
4
- import { resolveConfig } from "./config";
5
- import { fixbackErrorHandler, fixbackRequestContext } from "./express";
6
- import type { CaptureTransport } from "./transport";
7
- import type { ServerErrorPayload } from "./wire";
8
-
9
- function recorder(): { enqueued: ServerErrorPayload[]; transport: CaptureTransport } {
10
- const enqueued: ServerErrorPayload[] = [];
11
- return {
12
- enqueued,
13
- transport: {
14
- enqueue: (payload) => enqueued.push(payload),
15
- flush: async () => {},
16
- close: async () => {},
17
- },
18
- };
19
- }
20
-
21
- const makeClient = (transport: CaptureTransport) =>
22
- new FixbackClient(resolveConfig({ secretKey: "sk" }), { transport });
23
-
24
- describe("fixbackErrorHandler + fixbackRequestContext", () => {
25
- it("captures a request error with server context (handled:true) and forwards it to next", () => {
26
- const { enqueued, transport } = recorder();
27
- const client = makeClient(transport);
28
- const contextMiddleware = fixbackRequestContext();
29
- const errorHandler = fixbackErrorHandler({ client });
30
-
31
- const req = { method: "GET", route: { path: "/boom/:id" }, path: "/boom/1" };
32
- const res = { statusCode: 200 };
33
- const error = new Error("kaboom");
34
- let forwarded: unknown;
35
-
36
- contextMiddleware(req, res, () => {
37
- // Simulate a route throwing and Express routing it to the error handler.
38
- errorHandler(error, req, res, (e) => {
39
- forwarded = e;
40
- });
41
- });
42
-
43
- expect(forwarded).toBe(error); // never swallowed — the app's own handling still runs
44
- expect(enqueued).toHaveLength(1);
45
- expect(enqueued[0]!.handled).toBe(true);
46
- expect(enqueued[0]!.server?.method).toBe("GET");
47
- expect(enqueued[0]!.server?.route).toBe("/boom/:id");
48
- // No err.status and res still 200 → a sensible 500 for an unhandled request error.
49
- expect(enqueued[0]!.server?.statusCode).toBe(500);
50
- });
51
-
52
- it("carries the correlation id opened by the request-context middleware", () => {
53
- const { enqueued, transport } = recorder();
54
- const client = makeClient(transport);
55
- const contextMiddleware = fixbackRequestContext();
56
- const errorHandler = fixbackErrorHandler({ client });
57
-
58
- const req = { method: "POST", route: { path: "/x" }, headers: { "x-request-id": "corr-77" } };
59
- const res = { statusCode: 200 };
60
- contextMiddleware(req, res, () => {
61
- errorHandler(new Error("boom"), req, res, () => {});
62
- });
63
-
64
- expect(enqueued[0]!.server?.requestId).toBe("corr-77");
65
- });
66
-
67
- it("derives the status from an http-errors-style status on the error", () => {
68
- const { enqueued, transport } = recorder();
69
- const client = makeClient(transport);
70
- const errorHandler = fixbackErrorHandler({ client });
71
- const error = Object.assign(new Error("forbidden"), { statusCode: 403 });
72
-
73
- let forwarded: unknown;
74
- errorHandler(error, { method: "GET", route: { path: "/y" } }, { statusCode: 200 }, (e) => {
75
- forwarded = e;
76
- });
77
-
78
- expect(forwarded).toBe(error);
79
- expect(enqueued[0]!.server).toEqual({ method: "GET", route: "/y", statusCode: 403 });
80
- });
81
-
82
- it("still forwards the error when capture itself throws (never breaks the app)", () => {
83
- const throwingClient = {
84
- captureException() {
85
- throw new Error("capture blew up");
86
- },
87
- };
88
- const errorHandler = fixbackErrorHandler({ client: throwingClient });
89
- const original = new Error("x");
90
- let forwarded: unknown;
91
- errorHandler(original, {}, {}, (e) => {
92
- forwarded = e;
93
- });
94
- expect(forwarded).toBe(original);
95
- });
96
-
97
- it("uses the active module client when no explicit client is given", async () => {
98
- const { enqueued, transport } = recorder();
99
- init(
100
- { secretKey: "sk", captureUncaughtException: false, captureUnhandledRejection: false },
101
- { transport },
102
- );
103
- const errorHandler = fixbackErrorHandler();
104
- errorHandler(new Error("via module"), { method: "GET", route: { path: "/z" } }, { statusCode: 500 }, () => {});
105
- expect(enqueued).toHaveLength(1);
106
- await moduleClose();
107
- });
108
-
109
- afterEach(async () => {
110
- await moduleClose();
111
- });
112
- });
package/src/express.ts DELETED
@@ -1,85 +0,0 @@
1
- /**
2
- * The **Express** adapter (`@fixback/node/express`, story 3) — two middlewares:
3
- *
4
- * - {@link fixbackRequestContext}: mount **before** your routes. It opens the
5
- * per-request AsyncLocalStorage scope (correlation id + request/response refs) so a
6
- * captured error carries the request without manual threading.
7
- * - {@link fixbackErrorHandler}: mount **after** your routes (Express recognises a
8
- * 4-arg middleware as an error handler). It captures an error that reaches
9
- * `next(err)` with the request's server context (`handled: true`), then **calls
10
- * `next(err)`** so your own error handling still runs — Fixback never swallows the
11
- * error and never breaks the request.
12
- *
13
- * Uses only structural request/response types, so the SDK needs no `express`
14
- * dependency; the real Express objects satisfy them.
15
- */
16
-
17
- import {
18
- type CaptureClient,
19
- captureException as moduleCaptureException,
20
- getClient,
21
- } from "./client";
22
- import { createRequestContextMiddleware } from "./context";
23
- import {
24
- errorStatus,
25
- type HttpRequestLike,
26
- type HttpResponseLike,
27
- type NextFunction,
28
- serverContextFromRequest,
29
- } from "./http";
30
-
31
- /** An Express request-processing middleware (`(req, res, next)`). */
32
- export type ExpressMiddleware = (
33
- req: HttpRequestLike,
34
- res: HttpResponseLike,
35
- next: NextFunction,
36
- ) => void;
37
-
38
- /** An Express error-handling middleware (`(err, req, res, next)`). */
39
- export type ExpressErrorMiddleware = (
40
- error: unknown,
41
- req: HttpRequestLike,
42
- res: HttpResponseLike,
43
- next: NextFunction,
44
- ) => void;
45
-
46
- /** Options for {@link fixbackRequestContext}. */
47
- export interface RequestContextOptions {
48
- /** The header a correlation id is read from. Defaults to the client's config, then `x-request-id`. */
49
- readonly requestIdHeader?: string;
50
- }
51
-
52
- /** Options for {@link fixbackErrorHandler}. */
53
- export interface ErrorHandlerOptions {
54
- /** The client to file through. Defaults to the active module client (`init`). */
55
- readonly client?: CaptureClient;
56
- }
57
-
58
- /**
59
- * Build the request-context middleware. Mount it before your routes:
60
- * `app.use(fixbackRequestContext())`.
61
- */
62
- export function fixbackRequestContext(options: RequestContextOptions = {}): ExpressMiddleware {
63
- const header = options.requestIdHeader ?? getClient()?.requestIdHeader;
64
- return createRequestContextMiddleware(header ? { requestIdHeader: header } : {});
65
- }
66
-
67
- /**
68
- * Build the error-handling middleware. Mount it after your routes:
69
- * `app.use(fixbackErrorHandler())`. Captures the error (with the request's server
70
- * context, `handled: true`) and always forwards it via `next(err)`.
71
- */
72
- export function fixbackErrorHandler(options: ErrorHandlerOptions = {}): ExpressErrorMiddleware {
73
- const client: CaptureClient = options.client ?? {
74
- captureException: (error, context) => moduleCaptureException(error, context),
75
- };
76
- return function fixbackErrorHandlerMiddleware(error, req, res, next): void {
77
- try {
78
- const server = { ...serverContextFromRequest(req), statusCode: errorStatus(error, res) };
79
- client.captureException(error, { handled: true, server });
80
- } catch {
81
- // Capture must never break the request pipeline — always forward the error.
82
- }
83
- next(error);
84
- };
85
- }