@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/context.ts ADDED
@@ -0,0 +1,124 @@
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
+ }
@@ -0,0 +1,112 @@
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 ADDED
@@ -0,0 +1,85 @@
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
+ }
@@ -0,0 +1,86 @@
1
+ import { computeFingerprint } from "@fixback/sdk-core";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { FixbackClient } from "./client";
5
+ import { resolveConfig } from "./config";
6
+ import type { CaptureTransport } from "./transport";
7
+ import type { ServerErrorPayload } from "./wire";
8
+
9
+ /**
10
+ * Cross-surface fingerprint parity (ADR-0027/0028, spec #224 Seam 3): the browser and
11
+ * node SDKs must compute the **same** fingerprint for the same logical error, or a
12
+ * crash surfaced from both the frontend and the backend would not group together.
13
+ *
14
+ * Both SDKs key an error with the **shared core** `computeFingerprint(type, value,
15
+ * stack)`. `browserFingerprint` reproduces the browser SDK's distillation exactly
16
+ * (`packages/sdk/src/error-capture.ts`: `type = error.name || "Error"`,
17
+ * `value = error.message`, `stack = error.stack`, then `computeFingerprint(...)`), so
18
+ * it is the key the browser SDK would produce. `nodeFingerprint` drives the real
19
+ * `@fixback/node` capture pipeline and reads the fingerprint off the outgoing payload.
20
+ */
21
+
22
+ /** The key the browser SDK computes for `error` (its distillation + the shared core). */
23
+ function browserFingerprint(error: Error): string {
24
+ const type = error.name || "Error";
25
+ return computeFingerprint(type, error.message, error.stack);
26
+ }
27
+
28
+ /** The key the node SDK puts on the wire for `error` (the real capture pipeline). */
29
+ function nodeFingerprint(error: unknown): string {
30
+ const enqueued: ServerErrorPayload[] = [];
31
+ const transport: CaptureTransport = {
32
+ enqueue: (payload) => enqueued.push(payload),
33
+ flush: async () => {},
34
+ close: async () => {},
35
+ };
36
+ const client = new FixbackClient(resolveConfig({ secretKey: "sk" }), { transport });
37
+ client.captureException(error);
38
+ return enqueued[0]!.errorSignature;
39
+ }
40
+
41
+ describe("cross-surface fingerprint parity (ADR-0028)", () => {
42
+ it("matches the browser's fingerprint for a typed error with a stack", () => {
43
+ const error = new TypeError("Cannot read properties of undefined (reading 'id')");
44
+ error.stack = [
45
+ "TypeError: Cannot read properties of undefined (reading 'id')",
46
+ " at renderTotal (/srv/app/dist/checkout.js:88:12)",
47
+ " at handle (/srv/app/dist/server.js:42:5)",
48
+ ].join("\n");
49
+ expect(nodeFingerprint(error)).toBe(browserFingerprint(error));
50
+ });
51
+
52
+ it("matches for a plain, stackless message-only error", () => {
53
+ const error = new Error("database connection refused");
54
+ error.stack = undefined;
55
+ expect(nodeFingerprint(error)).toBe(browserFingerprint(error));
56
+ });
57
+
58
+ it("matches for a custom error subclass (its name is the type on both surfaces)", () => {
59
+ class PaymentError extends Error {
60
+ constructor(message: string) {
61
+ super(message);
62
+ this.name = "PaymentError";
63
+ }
64
+ }
65
+ const error = new PaymentError("card declined");
66
+ expect(nodeFingerprint(error)).toBe(browserFingerprint(error));
67
+ });
68
+
69
+ it("is stable across instances — the same error from different install paths groups", () => {
70
+ // Two fleet instances throw the identical error; only the absolute path differs.
71
+ const onA = new Error("boom");
72
+ onA.stack = "Error: boom\n at handler (/srv/app-a/dist/checkout.js:88:12)";
73
+ const onB = new Error("boom");
74
+ onB.stack = "Error: boom\n at handler (/home/deploy/app-b/dist/checkout.js:88:12)";
75
+
76
+ // The node SDK folds them to one fingerprint, and it is the browser's fingerprint too.
77
+ expect(nodeFingerprint(onA)).toBe(nodeFingerprint(onB));
78
+ expect(nodeFingerprint(onA)).toBe(browserFingerprint(onA));
79
+ });
80
+
81
+ it("keeps genuinely distinct errors distinct", () => {
82
+ const a = new Error("first failure");
83
+ const b = new Error("second failure");
84
+ expect(nodeFingerprint(a)).not.toBe(nodeFingerprint(b));
85
+ });
86
+ });
package/src/http.ts ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Minimal **structural** types for the HTTP surface the adapters touch, plus
3
+ * {@link routePatternOf}.
4
+ *
5
+ * Described structurally (the same posture as `packages/expo/src/http.ts`) so the
6
+ * SDK needs no `@types/express` dependency: Express and NestJS (on Express) both pass
7
+ * requests/responses that satisfy these shapes, and tests pass plain objects. Only
8
+ * the private-by-default fields are read here — never bodies, headers beyond the
9
+ * correlation id, query values, or env.
10
+ */
11
+
12
+ /** The request slice the adapters read — never its body, headers (bar the id), or query. */
13
+ export interface HttpRequestLike {
14
+ readonly method?: string;
15
+ readonly baseUrl?: string;
16
+ readonly path?: string;
17
+ /** Populated by the router once a route matches — its `path` is the **pattern**. */
18
+ readonly route?: { readonly path?: string | RegExp } | undefined;
19
+ readonly headers?: Readonly<Record<string, string | string[] | undefined>>;
20
+ }
21
+
22
+ /** The response slice the adapters read — only the resolved status. */
23
+ export interface HttpResponseLike {
24
+ readonly statusCode?: number;
25
+ }
26
+
27
+ /** The Express/Nest `next` callback — `next(err)` forwards to the error pipeline. */
28
+ export type NextFunction = (err?: unknown) => void;
29
+
30
+ /**
31
+ * The route **pattern** for a request (`/api/users/:id`), joining a mounted router's
32
+ * `baseUrl` with the matched `route.path`. Returns `undefined` when no route has
33
+ * matched — deliberately **never** falling back to the concrete `path`, which would
34
+ * leak the in-URL values the PII boundary forbids (spec §D12).
35
+ */
36
+ export function routePatternOf(req: HttpRequestLike): string | undefined {
37
+ const routePath = req.route?.path;
38
+ if (routePath === undefined || routePath === null) return undefined;
39
+ const pattern =
40
+ typeof routePath === "string" ? routePath : (routePath.source ?? String(routePath));
41
+ const base = typeof req.baseUrl === "string" ? req.baseUrl : "";
42
+ if (pattern.length === 0) return base.length > 0 ? base : undefined;
43
+ if (base.length === 0) return pattern;
44
+ return `${base}${pattern.startsWith("/") ? pattern : `/${pattern}`}`;
45
+ }
46
+
47
+ /**
48
+ * The private-by-default server-context fields derivable from a request alone — the
49
+ * HTTP method and the route **pattern**. The single place that decides what an adapter
50
+ * reads off a request (never a body, header, query value, or the concrete path), so the
51
+ * Express and NestJS adapters can never drift on the privacy posture. The status is
52
+ * added by the caller (it differs: an Express error's own status vs a Nest
53
+ * `HttpException`'s).
54
+ */
55
+ export function serverContextFromRequest(req: HttpRequestLike): { method?: string; route?: string } {
56
+ const out: { method?: string; route?: string } = {};
57
+ if (typeof req.method === "string" && req.method.length > 0) out.method = req.method;
58
+ const route = routePatternOf(req);
59
+ if (route) out.route = route;
60
+ return out;
61
+ }
62
+
63
+ /** Read an http-errors-style numeric status off a thrown value (`err.status`/`.statusCode`). */
64
+ export function numericStatus(error: unknown): number | undefined {
65
+ if (error && typeof error === "object") {
66
+ const e = error as { status?: unknown; statusCode?: unknown };
67
+ const raw = typeof e.status === "number" ? e.status : e.statusCode;
68
+ if (typeof raw === "number" && Number.isFinite(raw)) return raw;
69
+ }
70
+ return undefined;
71
+ }
72
+
73
+ /**
74
+ * A sensible status for a captured request error: the error's own status when set,
75
+ * else a 4xx/5xx already on the response, else `500`.
76
+ */
77
+ export function errorStatus(error: unknown, res: HttpResponseLike): number {
78
+ const fromError = numericStatus(error);
79
+ if (fromError && fromError >= 400) return fromError;
80
+ const fromResponse = res.statusCode;
81
+ if (typeof fromResponse === "number" && fromResponse >= 400) return fromResponse;
82
+ return 500;
83
+ }
package/src/index.ts ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `@fixback/node` — the Fixback backend error SDK for Node servers.
3
+ *
4
+ * `init({ secretKey })` wires a batched secret-key transport, the polite
5
+ * process-crash handlers, and the manual capture API below. Framework adapters are
6
+ * separate entry points so a plain install never pulls a framework in:
7
+ *
8
+ * - `@fixback/node/express` — {@link https://expressjs.com Express} middlewares.
9
+ * - `@fixback/node/nestjs` — a NestJS module + exception filter.
10
+ *
11
+ * The root entry is framework-free: import from here for `init`, manual capture,
12
+ * `setUser`, and the shared types.
13
+ */
14
+
15
+ // Capture surface + lifecycle.
16
+ export {
17
+ captureException,
18
+ captureMessage,
19
+ close,
20
+ FixbackClient,
21
+ flush,
22
+ getClient,
23
+ init,
24
+ } from "./client";
25
+ export type { CaptureClient, CaptureContext, ClientDeps } from "./client";
26
+
27
+ // Per-request context (AsyncLocalStorage) — attach a user, or read the active context.
28
+ export { currentServerContext, getRequestStore, setRequestUser, setRequestUser as setUser } from "./context";
29
+ export type { RequestStore } from "./context";
30
+
31
+ // Configuration.
32
+ export { DEFAULT_API_URL } from "./config";
33
+ export type { InitOptions } from "./config";
34
+
35
+ // Transport contract (advanced — a custom transport).
36
+ export type { CaptureTransport } from "./transport";
37
+
38
+ // Wire / event value shapes.
39
+ export type {
40
+ BeforeSend,
41
+ FixbackErrorEvent,
42
+ ServerContext,
43
+ ServerErrorPayload,
44
+ Severity,
45
+ } from "./wire";
46
+
47
+ // Re-exported from the shared core so callers can type stack frames in `beforeSend`.
48
+ export type { CapturedFrame } from "@fixback/sdk-core";
49
+
50
+ // The SDK's own version, reported as the capture `sdkVersion`.
51
+ export { NODE_SDK_VERSION } from "./version";
@@ -0,0 +1,146 @@
1
+ import "reflect-metadata";
2
+
3
+ import type { ArgumentsHost } from "@nestjs/common";
4
+ import { HttpException } from "@nestjs/common";
5
+ import { APP_FILTER, BaseExceptionFilter } from "@nestjs/core";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+
8
+ import { close as moduleClose, init } from "./client";
9
+ import type { CaptureClient, CaptureContext } from "./client";
10
+ import { captureNestException, FixbackExceptionFilter, FixbackModule } from "./nestjs";
11
+ import type { CaptureTransport } from "./transport";
12
+ import type { ServerErrorPayload } from "./wire";
13
+
14
+ interface Captured {
15
+ error: unknown;
16
+ context?: CaptureContext;
17
+ }
18
+
19
+ function recordingClient(): { captures: Captured[]; client: CaptureClient } {
20
+ const captures: Captured[] = [];
21
+ return {
22
+ captures,
23
+ client: { captureException: (error, context) => captures.push({ error, context }) },
24
+ };
25
+ }
26
+
27
+ function recorder(): { enqueued: ServerErrorPayload[]; transport: CaptureTransport } {
28
+ const enqueued: ServerErrorPayload[] = [];
29
+ return {
30
+ enqueued,
31
+ transport: {
32
+ enqueue: (payload) => enqueued.push(payload),
33
+ flush: async () => {},
34
+ close: async () => {},
35
+ },
36
+ };
37
+ }
38
+
39
+ function fakeHost(req: unknown, res: unknown, type = "http"): ArgumentsHost {
40
+ const http = {
41
+ getRequest: <T>() => req as T,
42
+ getResponse: <T>() => res as T,
43
+ getNext: <T>() => undefined as T,
44
+ };
45
+ return {
46
+ switchToHttp: () => http,
47
+ getType: () => type,
48
+ getArgs: () => [],
49
+ getArgByIndex: () => undefined,
50
+ switchToRpc: () => ({}),
51
+ switchToWs: () => ({}),
52
+ } as unknown as ArgumentsHost;
53
+ }
54
+
55
+ afterEach(async () => {
56
+ await moduleClose();
57
+ });
58
+
59
+ describe("captureNestException", () => {
60
+ it("captures a thrown request error with server context (handled:true)", () => {
61
+ const { captures, client } = recordingClient();
62
+ captureNestException(
63
+ new Error("boom"),
64
+ fakeHost({ method: "GET", route: { path: "/orders/:id" } }, { statusCode: 200 }),
65
+ client,
66
+ );
67
+ expect(captures[0]!.context?.handled).toBe(true);
68
+ expect(captures[0]!.context?.server?.method).toBe("GET");
69
+ expect(captures[0]!.context?.server?.route).toBe("/orders/:id");
70
+ expect(captures[0]!.context?.server?.statusCode).toBe(500);
71
+ });
72
+
73
+ it("uses an HttpException's status code", () => {
74
+ const { captures, client } = recordingClient();
75
+ captureNestException(
76
+ new HttpException("forbidden", 403),
77
+ fakeHost({ method: "DELETE", route: { path: "/x" } }, { statusCode: 200 }),
78
+ client,
79
+ );
80
+ expect(captures[0]!.context?.server?.statusCode).toBe(403);
81
+ });
82
+
83
+ it("never throws even if capture fails", () => {
84
+ const client: CaptureClient = {
85
+ captureException() {
86
+ throw new Error("capture blew up");
87
+ },
88
+ };
89
+ expect(() =>
90
+ captureNestException(new Error("y"), fakeHost({ method: "GET" }, {}), client),
91
+ ).not.toThrow();
92
+ });
93
+ });
94
+
95
+ describe("FixbackExceptionFilter", () => {
96
+ it("captures the exception, then delegates to Nest's base filter (behaviour unchanged)", () => {
97
+ const { enqueued, transport } = recorder();
98
+ init(
99
+ { secretKey: "sk", captureUncaughtException: false, captureUnhandledRejection: false },
100
+ { transport },
101
+ );
102
+ const superCatch = vi
103
+ .spyOn(BaseExceptionFilter.prototype, "catch")
104
+ .mockImplementation(() => {});
105
+
106
+ const filter = new FixbackExceptionFilter();
107
+ const host = fakeHost({ method: "GET", route: { path: "/x" } }, { statusCode: 200 });
108
+ filter.catch(new Error("boom"), host);
109
+
110
+ expect(enqueued).toHaveLength(1);
111
+ expect(enqueued[0]!.handled).toBe(true);
112
+ expect(enqueued[0]!.server?.route).toBe("/x");
113
+ expect(superCatch).toHaveBeenCalledTimes(1);
114
+ superCatch.mockRestore();
115
+ });
116
+ });
117
+
118
+ describe("FixbackModule", () => {
119
+ it("configure() applies a request-context middleware to all routes", () => {
120
+ const applied: { middleware: unknown[]; routes: unknown[] } = { middleware: [], routes: [] };
121
+ const consumer = {
122
+ apply: (...mws: unknown[]) => {
123
+ applied.middleware = mws;
124
+ return {
125
+ forRoutes: (...routes: unknown[]) => {
126
+ applied.routes = routes;
127
+ },
128
+ };
129
+ },
130
+ };
131
+ new FixbackModule().configure(consumer as never);
132
+ expect(applied.middleware).toHaveLength(1);
133
+ expect(typeof applied.middleware[0]).toBe("function");
134
+ expect(applied.routes).toEqual(["*"]);
135
+ });
136
+
137
+ it("forRoot() registers the exception filter as a global APP_FILTER", () => {
138
+ const dynamic = FixbackModule.forRoot();
139
+ expect(dynamic.module).toBe(FixbackModule);
140
+ const providers = dynamic.providers ?? [];
141
+ const filterProvider = providers.find(
142
+ (p) => (p as { provide?: unknown }).provide === APP_FILTER,
143
+ ) as { useClass?: unknown } | undefined;
144
+ expect(filterProvider?.useClass).toBe(FixbackExceptionFilter);
145
+ });
146
+ });