@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/nestjs.ts ADDED
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The **NestJS** adapter (`@fixback/node/nestjs`, story 2) — a module + exception
3
+ * filter that capture thrown request errors with server context, **without changing
4
+ * the app's behaviour**.
5
+ *
6
+ * - {@link FixbackExceptionFilter} extends Nest's `BaseExceptionFilter`: it captures
7
+ * the exception (`handled: true`, with the request's route pattern / method /
8
+ * status) and then delegates to `super.catch`, so Nest still produces the exact
9
+ * HTTP response it would have. Registered globally via `APP_FILTER`.
10
+ * - {@link FixbackModule} wires that filter and applies the request-context
11
+ * middleware (AsyncLocalStorage correlation) to every route. Register it with
12
+ * `FixbackModule.forRoot()`.
13
+ *
14
+ * `@nestjs/common` and `@nestjs/core` are **optional peer dependencies** — this module
15
+ * is only loaded when you import `@fixback/node/nestjs`, so an Express-only or
16
+ * manual-capture install never pulls Nest in.
17
+ */
18
+
19
+ import "reflect-metadata";
20
+
21
+ import {
22
+ type ArgumentsHost,
23
+ Catch,
24
+ type DynamicModule,
25
+ HttpException,
26
+ type MiddlewareConsumer,
27
+ Module,
28
+ type NestModule,
29
+ } from "@nestjs/common";
30
+ import { APP_FILTER, BaseExceptionFilter } from "@nestjs/core";
31
+
32
+ import {
33
+ type CaptureClient,
34
+ captureException as moduleCaptureException,
35
+ getClient,
36
+ } from "./client";
37
+ import { createRequestContextMiddleware } from "./context";
38
+ import {
39
+ errorStatus,
40
+ type HttpRequestLike,
41
+ type HttpResponseLike,
42
+ serverContextFromRequest,
43
+ } from "./http";
44
+
45
+ /** Files through the active module client by default. */
46
+ const defaultClient: CaptureClient = {
47
+ captureException: (error, context) => moduleCaptureException(error, context),
48
+ };
49
+
50
+ /** Resolve the status: an `HttpException`'s own status, else the shared heuristic. */
51
+ function nestStatus(exception: unknown, res: HttpResponseLike): number {
52
+ if (exception instanceof HttpException) {
53
+ const status = exception.getStatus();
54
+ if (typeof status === "number") return status;
55
+ }
56
+ return errorStatus(exception, res);
57
+ }
58
+
59
+ /**
60
+ * Capture a Nest exception with the request's server context (`handled: true`).
61
+ * Exported so it can be unit-tested against a fake `ArgumentsHost`; the filter uses
62
+ * it. Never throws — a capture failure must not break the request pipeline.
63
+ */
64
+ export function captureNestException(
65
+ exception: unknown,
66
+ host: ArgumentsHost,
67
+ client: CaptureClient = defaultClient,
68
+ ): void {
69
+ try {
70
+ const isHttp = typeof host.getType === "function" ? host.getType() === "http" : true;
71
+ if (!isHttp) {
72
+ client.captureException(exception, { handled: true });
73
+ return;
74
+ }
75
+ const http = host.switchToHttp();
76
+ const req = http.getRequest<HttpRequestLike>();
77
+ const res = (http.getResponse<HttpResponseLike>() ?? {}) as HttpResponseLike;
78
+ const base = req ? serverContextFromRequest(req) : {};
79
+ const server = { ...base, statusCode: nestStatus(exception, res) };
80
+ client.captureException(exception, { handled: true, server });
81
+ } catch {
82
+ try {
83
+ client.captureException(exception, { handled: true });
84
+ } catch {
85
+ /* capture must never break the pipeline */
86
+ }
87
+ }
88
+ }
89
+
90
+ /**
91
+ * A global exception filter that captures every thrown request error and then hands
92
+ * off to Nest's `BaseExceptionFilter`, so the HTTP response is exactly what Nest
93
+ * would have produced (no behaviour change).
94
+ */
95
+ @Catch()
96
+ export class FixbackExceptionFilter extends BaseExceptionFilter {
97
+ catch(exception: unknown, host: ArgumentsHost): void {
98
+ captureNestException(exception, host);
99
+ super.catch(exception, host);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * The Fixback NestJS module. Register with `imports: [FixbackModule.forRoot()]`; it
105
+ * installs the global {@link FixbackExceptionFilter} and applies the request-context
106
+ * middleware to every route.
107
+ */
108
+ @Module({})
109
+ export class FixbackModule implements NestModule {
110
+ static forRoot(): DynamicModule {
111
+ return {
112
+ module: FixbackModule,
113
+ providers: [{ provide: APP_FILTER, useClass: FixbackExceptionFilter }],
114
+ };
115
+ }
116
+
117
+ configure(consumer: MiddlewareConsumer): void {
118
+ const requestIdHeader = getClient()?.requestIdHeader;
119
+ consumer
120
+ .apply(createRequestContextMiddleware(requestIdHeader ? { requestIdHeader } : {}))
121
+ .forRoutes("*");
122
+ }
123
+ }
@@ -0,0 +1,154 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { installProcessHandlers, type ProcessLike } from "./process";
4
+
5
+ type Listener = (...args: unknown[]) => void;
6
+
7
+ /** A tiny EventEmitter-shaped stand-in for `process` (never the real one, in tests). */
8
+ class FakeProcess implements ProcessLike {
9
+ private readonly map = new Map<string, Listener[]>();
10
+ on(event: string, listener: Listener): this {
11
+ this.map.set(event, [...(this.map.get(event) ?? []), listener]);
12
+ return this;
13
+ }
14
+ removeListener(event: string, listener: Listener): this {
15
+ this.map.set(event, (this.map.get(event) ?? []).filter((l) => l !== listener));
16
+ return this;
17
+ }
18
+ listeners(event: string): Listener[] {
19
+ return [...(this.map.get(event) ?? [])];
20
+ }
21
+ emit(event: string, ...args: unknown[]): void {
22
+ for (const l of [...(this.map.get(event) ?? [])]) l(...args);
23
+ }
24
+ }
25
+
26
+ function fakeSink() {
27
+ const captures: Array<{ error: unknown; handled?: boolean }> = [];
28
+ let flushes = 0;
29
+ return {
30
+ captures,
31
+ get flushes() {
32
+ return flushes;
33
+ },
34
+ sink: {
35
+ captureException(error: unknown, context?: { handled?: boolean }) {
36
+ captures.push({ error, handled: context?.handled });
37
+ },
38
+ flush: async () => {
39
+ flushes += 1;
40
+ },
41
+ },
42
+ };
43
+ }
44
+
45
+ const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
46
+
47
+ describe("installProcessHandlers", () => {
48
+ it("adds an uncaughtException and unhandledRejection listener, and uninstall removes them", () => {
49
+ const proc = new FakeProcess();
50
+ const { sink } = fakeSink();
51
+ const uninstall = installProcessHandlers(
52
+ sink,
53
+ { captureUncaughtException: true, captureUnhandledRejection: true },
54
+ { process: proc, onFatalError: () => {} },
55
+ );
56
+ expect(proc.listeners("uncaughtException")).toHaveLength(1);
57
+ expect(proc.listeners("unhandledRejection")).toHaveLength(1);
58
+ uninstall();
59
+ expect(proc.listeners("uncaughtException")).toHaveLength(0);
60
+ expect(proc.listeners("unhandledRejection")).toHaveLength(0);
61
+ });
62
+
63
+ it("installs only the handlers the gates enable", () => {
64
+ const proc = new FakeProcess();
65
+ const { sink } = fakeSink();
66
+ installProcessHandlers(
67
+ sink,
68
+ { captureUncaughtException: false, captureUnhandledRejection: true },
69
+ { process: proc, onFatalError: () => {} },
70
+ );
71
+ expect(proc.listeners("uncaughtException")).toHaveLength(0);
72
+ expect(proc.listeners("unhandledRejection")).toHaveLength(1);
73
+ });
74
+
75
+ it("captures an uncaught exception as handled:false", () => {
76
+ const proc = new FakeProcess();
77
+ const { sink, captures } = fakeSink();
78
+ installProcessHandlers(
79
+ sink,
80
+ { captureUncaughtException: true, captureUnhandledRejection: false },
81
+ { process: proc, onFatalError: () => {} },
82
+ );
83
+ proc.emit("uncaughtException", new Error("crash"), "uncaughtException");
84
+ expect(captures).toHaveLength(1);
85
+ expect((captures[0]!.error as Error).message).toBe("crash");
86
+ expect(captures[0]!.handled).toBe(false);
87
+ });
88
+
89
+ it("preserves the default crash — flushes then calls onFatalError — when it is the only listener", async () => {
90
+ const proc = new FakeProcess();
91
+ const { sink } = fakeSink();
92
+ const onFatalError = vi.fn();
93
+ installProcessHandlers(
94
+ sink,
95
+ { captureUncaughtException: true, captureUnhandledRejection: false },
96
+ { process: proc, onFatalError },
97
+ );
98
+ const boom = new Error("boom");
99
+ proc.emit("uncaughtException", boom);
100
+ await tick();
101
+ expect(onFatalError).toHaveBeenCalledTimes(1);
102
+ expect(onFatalError).toHaveBeenCalledWith(boom);
103
+ });
104
+
105
+ it("does NOT change exit behaviour when the app has its own uncaughtException listener", async () => {
106
+ const proc = new FakeProcess();
107
+ const { sink, captures } = fakeSink();
108
+ const onFatalError = vi.fn();
109
+ installProcessHandlers(
110
+ sink,
111
+ { captureUncaughtException: true, captureUnhandledRejection: false },
112
+ { process: proc, onFatalError },
113
+ );
114
+ // The host installed its own handler — it owns the exit decision.
115
+ proc.on("uncaughtException", () => {});
116
+ proc.emit("uncaughtException", new Error("x"));
117
+ await tick();
118
+ expect(captures).toHaveLength(1); // still captured
119
+ expect(onFatalError).not.toHaveBeenCalled(); // but we never force an exit
120
+ });
121
+
122
+ it("captures an unhandled rejection as handled:false and never forces an exit (story 5)", async () => {
123
+ const proc = new FakeProcess();
124
+ const { sink, captures } = fakeSink();
125
+ const onFatalError = vi.fn();
126
+ installProcessHandlers(
127
+ sink,
128
+ { captureUncaughtException: false, captureUnhandledRejection: true },
129
+ { process: proc, onFatalError },
130
+ );
131
+ proc.emit("unhandledRejection", new Error("rejected"), Promise.resolve());
132
+ await tick();
133
+ expect(captures).toHaveLength(1);
134
+ expect((captures[0]!.error as Error).message).toBe("rejected");
135
+ expect(captures[0]!.handled).toBe(false);
136
+ expect(onFatalError).not.toHaveBeenCalled();
137
+ });
138
+
139
+ it("never throws out of the handler when capture itself fails", () => {
140
+ const proc = new FakeProcess();
141
+ const throwingSink = {
142
+ captureException() {
143
+ throw new Error("capture blew up");
144
+ },
145
+ flush: async () => {},
146
+ };
147
+ installProcessHandlers(
148
+ throwingSink,
149
+ { captureUncaughtException: true, captureUnhandledRejection: false },
150
+ { process: proc, onFatalError: () => {} },
151
+ );
152
+ expect(() => proc.emit("uncaughtException", new Error("x"))).not.toThrow();
153
+ });
154
+ });
package/src/process.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Process-level capture (spec §D9, stories 4 & 5) — the *polite* `uncaughtException`
3
+ * and `unhandledRejection` handlers that **never change the app's exit behaviour**.
4
+ *
5
+ * The subtlety: merely *adding* an `uncaughtException` listener suppresses Node's
6
+ * default crash. So to stay polite:
7
+ *
8
+ * - **uncaughtException:** capture (`handled: false`), then — only when we are the
9
+ * *sole* listener (the app installed none of its own) — best-effort flush and hand
10
+ * off to `onFatalError`, which preserves Node's default (log + non-zero exit). When
11
+ * the app has its own handler, we do nothing further: the app owns the exit.
12
+ * - **unhandledRejection:** capture (`handled: false`) and stop. We never escalate a
13
+ * rejection to a process exit — exactly the "never turn a logged rejection into an
14
+ * exit" guarantee (story 5).
15
+ *
16
+ * Everything is wrapped so a capture failure can never break the handler.
17
+ */
18
+
19
+ /** The `process` slice these handlers touch — injectable so tests never touch the real one. */
20
+ export interface ProcessLike {
21
+ on(event: string, listener: (...args: unknown[]) => void): unknown;
22
+ removeListener(event: string, listener: (...args: unknown[]) => void): unknown;
23
+ listeners(event: string): Array<(...args: unknown[]) => void>;
24
+ }
25
+
26
+ /** What a captured process error is filed through — the {@link FixbackClient} satisfies it. */
27
+ export interface CaptureSink {
28
+ captureException(error: unknown, context?: { handled?: boolean }): void;
29
+ flush(): Promise<void>;
30
+ }
31
+
32
+ /** Which process handlers to install. */
33
+ export interface ProcessHandlerOptions {
34
+ readonly captureUncaughtException: boolean;
35
+ readonly captureUnhandledRejection: boolean;
36
+ /** How long a fatal-path flush may take before the process exits anyway. */
37
+ readonly flushTimeoutMs?: number;
38
+ }
39
+
40
+ /** Injectable collaborators for {@link installProcessHandlers}. */
41
+ export interface ProcessHandlerDeps {
42
+ readonly process?: ProcessLike;
43
+ /**
44
+ * Preserve Node's default fatal behaviour after a sole-listener uncaught exception.
45
+ * Defaults to logging the error and exiting non-zero (exactly what Node would do).
46
+ */
47
+ readonly onFatalError?: (error: unknown) => void;
48
+ }
49
+
50
+ const DEFAULT_FLUSH_TIMEOUT_MS = 2_000;
51
+
52
+ /** The real `process`, when running on Node. */
53
+ function resolveProcess(): ProcessLike | undefined {
54
+ const g = globalThis as unknown as { process?: ProcessLike };
55
+ const proc = g.process;
56
+ return proc && typeof proc.on === "function" ? proc : undefined;
57
+ }
58
+
59
+ /** Node's default fatal behaviour: print the error and exit non-zero. */
60
+ function defaultOnFatalError(proc: ProcessLike): (error: unknown) => void {
61
+ return (error: unknown) => {
62
+ try {
63
+ console.error(error);
64
+ } catch {
65
+ /* a hostile console must not stop the exit */
66
+ }
67
+ const exit = (proc as { exit?: (code?: number) => never }).exit;
68
+ if (typeof exit === "function") {
69
+ try {
70
+ exit(1);
71
+ } catch {
72
+ /* exit is terminal; nothing to do if it throws */
73
+ }
74
+ }
75
+ };
76
+ }
77
+
78
+ /** Race a flush against a timeout so a wedged transport can't hang a crashing process. */
79
+ function flushWithTimeout(flush: () => Promise<void>, ms: number): Promise<void> {
80
+ let result: Promise<void>;
81
+ try {
82
+ result = Promise.resolve(flush());
83
+ } catch {
84
+ return Promise.resolve();
85
+ }
86
+ if (ms <= 0) return result.catch(() => {});
87
+ let timer: ReturnType<typeof setTimeout> | undefined;
88
+ const timeout = new Promise<void>((resolve) => {
89
+ timer = setTimeout(resolve, ms);
90
+ });
91
+ return Promise.race([result.catch(() => {}), timeout]).finally(() => {
92
+ if (timer !== undefined) clearTimeout(timer);
93
+ });
94
+ }
95
+
96
+ /**
97
+ * Install the polite process handlers for `sink`, returning an uninstall function.
98
+ * A no-op (and a no-op uninstall) when no `process` is available or when both gates
99
+ * are off.
100
+ */
101
+ export function installProcessHandlers(
102
+ sink: CaptureSink,
103
+ options: ProcessHandlerOptions,
104
+ deps: ProcessHandlerDeps = {},
105
+ ): () => void {
106
+ const proc = deps.process ?? resolveProcess();
107
+ if (!proc) return () => {};
108
+
109
+ const onFatalError = deps.onFatalError ?? defaultOnFatalError(proc);
110
+ const flushTimeoutMs = options.flushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS;
111
+
112
+ const onUncaughtException = (...args: unknown[]): void => {
113
+ const error = args[0];
114
+ try {
115
+ sink.captureException(error, { handled: false });
116
+ } catch {
117
+ /* capture must never break the handler */
118
+ }
119
+ // If the app installed its own uncaughtException handler, it owns the exit — we
120
+ // must not force one (that would *change* its behaviour). Only when we are the
121
+ // sole listener do we preserve Node's default crash.
122
+ const others = proc.listeners("uncaughtException").filter((l) => l !== onUncaughtException);
123
+ if (others.length > 0) return;
124
+ void flushWithTimeout(() => sink.flush(), flushTimeoutMs).finally(() => {
125
+ try {
126
+ onFatalError(error);
127
+ } catch {
128
+ /* the fatal handler is terminal */
129
+ }
130
+ });
131
+ };
132
+
133
+ const onUnhandledRejection = (...args: unknown[]): void => {
134
+ // Capture only — never escalate a rejection to a process exit (story 5).
135
+ try {
136
+ sink.captureException(args[0], { handled: false });
137
+ } catch {
138
+ /* capture must never break the handler */
139
+ }
140
+ };
141
+
142
+ if (options.captureUncaughtException) proc.on("uncaughtException", onUncaughtException);
143
+ if (options.captureUnhandledRejection) proc.on("unhandledRejection", onUnhandledRejection);
144
+
145
+ return () => {
146
+ if (options.captureUncaughtException) {
147
+ proc.removeListener("uncaughtException", onUncaughtException);
148
+ }
149
+ if (options.captureUnhandledRejection) {
150
+ proc.removeListener("unhandledRejection", onUnhandledRejection);
151
+ }
152
+ };
153
+ }
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { extractError, extractStructuredFrames } from "./stack";
4
+
5
+ const V8_STACK = [
6
+ "TypeError: Cannot read properties of undefined (reading 'id')",
7
+ " at renderTotal (/srv/app/dist/checkout.js:88:12)",
8
+ " at Object.<anonymous> (/srv/app/dist/server.js:42:5)",
9
+ " at processTicksAndRejections (node:internal/process/task_queues:95:5)",
10
+ ].join("\n");
11
+
12
+ describe("extractStructuredFrames", () => {
13
+ it("parses V8 frames into file/line/column/function, top of stack first", () => {
14
+ const frames = extractStructuredFrames(V8_STACK);
15
+ expect(frames[0]).toEqual({
16
+ file: "/srv/app/dist/checkout.js",
17
+ line: 88,
18
+ column: 12,
19
+ function: "renderTotal",
20
+ });
21
+ expect(frames[1]).toEqual({
22
+ file: "/srv/app/dist/server.js",
23
+ line: 42,
24
+ column: 5,
25
+ function: "Object.<anonymous>",
26
+ });
27
+ });
28
+
29
+ it("scrubs credentials and query strings from a frame's file url (shared-core scrub)", () => {
30
+ const stack = [
31
+ "Error: boom",
32
+ " at handler (https://user:pass@cdn.example.com/app.js?token=secret123:5:1)",
33
+ ].join("\n");
34
+ const [frame] = extractStructuredFrames(stack);
35
+ expect(frame?.file).not.toContain("token=secret123");
36
+ expect(frame?.file).not.toContain("user:pass@");
37
+ expect(frame?.file).toContain("cdn.example.com/app.js");
38
+ });
39
+
40
+ it("caps the number of frames it keeps", () => {
41
+ const many = ["Error: boom"]
42
+ .concat(Array.from({ length: 100 }, (_, i) => ` at fn${i} (/srv/app/f${i}.js:${i + 1}:1)`))
43
+ .join("\n");
44
+ expect(extractStructuredFrames(many, 5)).toHaveLength(5);
45
+ });
46
+
47
+ it("returns no frames for a missing or stackless input", () => {
48
+ expect(extractStructuredFrames(undefined)).toEqual([]);
49
+ expect(extractStructuredFrames("")).toEqual([]);
50
+ expect(extractStructuredFrames("Error: no frames here")).toEqual([]);
51
+ });
52
+ });
53
+
54
+ describe("extractError", () => {
55
+ it("distils an Error into type / value / stack", () => {
56
+ const err = new TypeError("nope");
57
+ const extracted = extractError(err);
58
+ expect(extracted.type).toBe("TypeError");
59
+ expect(extracted.value).toBe("nope");
60
+ expect(extracted.stack).toBe(err.stack);
61
+ });
62
+
63
+ it("falls back to Error for an anonymous error and keeps an empty message", () => {
64
+ const bare = new Error();
65
+ expect(extractError(bare).type).toBe("Error");
66
+ expect(extractError(bare).value).toBe("");
67
+ });
68
+
69
+ it("distils a thrown string", () => {
70
+ expect(extractError("kaboom")).toEqual({ type: "Error", value: "kaboom", stack: undefined });
71
+ });
72
+
73
+ it("distils a thrown non-error object without throwing", () => {
74
+ const extracted = extractError({ weird: true });
75
+ expect(extracted.type).toBe("Error");
76
+ expect(typeof extracted.value).toBe("string");
77
+ expect(extracted.stack).toBeUndefined();
78
+ });
79
+
80
+ it("uses a custom error subclass name as the type", () => {
81
+ class PaymentError extends Error {
82
+ constructor(message: string) {
83
+ super(message);
84
+ this.name = "PaymentError";
85
+ }
86
+ }
87
+ expect(extractError(new PaymentError("declined")).type).toBe("PaymentError");
88
+ });
89
+ });
package/src/stack.ts ADDED
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Turning a thrown value into the pieces a capture needs: the distilled
3
+ * `type | value | stack` a fingerprint is built from ({@link extractError}), and the
4
+ * **structured stack frames** for the wire ({@link extractStructuredFrames}).
5
+ *
6
+ * The distillation mirrors the browser SDK's exactly (`packages/sdk/src/error-capture.ts`)
7
+ * — `type = error.name`, `value = error.message`, `stack = error.stack` — so the same
8
+ * logical error yields the same shared-core fingerprint on either surface (the
9
+ * ADR-0028 parity guarantee). Frame parsing keeps the full script path (server-side
10
+ * symbolication matches it against uploaded sourcemaps) but scrubs each URL through
11
+ * the shared-core scrubber before it can leave the process.
12
+ */
13
+
14
+ import { type CapturedFrame, scrubUrl } from "@fixback/sdk-core";
15
+
16
+ /** The most structured frames shipped per error (mirrors the server's cap). */
17
+ const STRUCTURED_FRAME_LIMIT = 30;
18
+
19
+ /** The distilled shape a fingerprint and a report are built from. */
20
+ export interface ExtractedError {
21
+ readonly type: string;
22
+ readonly value: string;
23
+ readonly stack?: string;
24
+ }
25
+
26
+ interface ErrorLike {
27
+ name?: unknown;
28
+ message?: unknown;
29
+ stack?: unknown;
30
+ }
31
+
32
+ /** Coerce any value to a string without throwing (a hostile `toString` can throw). */
33
+ function asString(value: unknown): string {
34
+ if (typeof value === "string") return value;
35
+ if (value == null) return "";
36
+ try {
37
+ return String(value);
38
+ } catch {
39
+ return "";
40
+ }
41
+ }
42
+
43
+ /** Best-effort JSON for a non-Error thrown object, falling back to `String`. */
44
+ function safeStringify(value: unknown): string {
45
+ try {
46
+ return JSON.stringify(value) ?? asString(value);
47
+ } catch {
48
+ return asString(value);
49
+ }
50
+ }
51
+
52
+ /** True for a duck-typed error (a cross-realm Error, or an error-shaped throwable). */
53
+ function isErrorLike(value: object): value is ErrorLike {
54
+ const err = value as ErrorLike;
55
+ return (
56
+ typeof err.message === "string" ||
57
+ typeof err.stack === "string" ||
58
+ typeof err.name === "string"
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Distil any thrown value into `{ type, value, stack }`. An `Error` (or error-shaped
64
+ * object) contributes its `name` / `message` / `stack`; a thrown string becomes the
65
+ * value; any other value is stringified. Never throws.
66
+ */
67
+ export function extractError(input: unknown): ExtractedError {
68
+ if (input instanceof Error) {
69
+ return {
70
+ type: asString(input.name) || "Error",
71
+ value: asString(input.message),
72
+ stack: typeof input.stack === "string" ? input.stack : undefined,
73
+ };
74
+ }
75
+ if (typeof input === "string") {
76
+ return { type: "Error", value: input, stack: undefined };
77
+ }
78
+ if (input && typeof input === "object") {
79
+ if (isErrorLike(input)) {
80
+ const err = input as ErrorLike;
81
+ return {
82
+ type: asString(err.name) || "Error",
83
+ value: asString(err.message),
84
+ stack: typeof err.stack === "string" ? err.stack : undefined,
85
+ };
86
+ }
87
+ return { type: "Error", value: safeStringify(input), stack: undefined };
88
+ }
89
+ return { type: "Error", value: asString(input), stack: undefined };
90
+ }
91
+
92
+ /** Split a `file:line:col` location into parts; `null` when it has no line. */
93
+ function parseLocation(
94
+ location: string,
95
+ ): { file: string; line: number; column: number | null } | null {
96
+ // The file may itself contain colons (https://…, node:internal/…), so split from
97
+ // the right: the trailing `:line(:col)?` are the numeric groups.
98
+ const match = location.match(/^(.*?):(\d+)(?::(\d+))?$/);
99
+ if (!match) return null;
100
+ const file = match[1] ?? "";
101
+ if (file.length === 0 || file === "native" || file.includes("<anonymous>")) {
102
+ return null;
103
+ }
104
+ const line = Number(match[2]);
105
+ if (!Number.isFinite(line)) return null;
106
+ const column = match[3] !== undefined ? Number(match[3]) : null;
107
+ return { file, line, column };
108
+ }
109
+
110
+ /**
111
+ * Extract structured frames from a stack for the wire (#117, ADR-0024), top of stack
112
+ * first. Keeps the full (scrubbed) script path so server-side symbolication can match
113
+ * it against uploaded sourcemaps; skips unlocatable frames (`native`, `<anonymous>`,
114
+ * eval); caps the count. V8 is Node's format; the Firefox/Safari form is handled too
115
+ * so the parser matches the browser SDK's exactly.
116
+ */
117
+ export function extractStructuredFrames(
118
+ stack: string | undefined,
119
+ limit = STRUCTURED_FRAME_LIMIT,
120
+ ): CapturedFrame[] {
121
+ if (typeof stack !== "string" || stack.length === 0) return [];
122
+ const frames: CapturedFrame[] = [];
123
+ for (const raw of stack.split("\n")) {
124
+ if (frames.length >= limit) break;
125
+ const line = raw.trim();
126
+ let fn: string | null = null;
127
+ let location: string | null = null;
128
+ // V8: "at fn (loc)" | "at loc"
129
+ const v8Named = line.match(/^at\s+(.+?)\s+\((.+)\)$/);
130
+ const v8Bare = v8Named ? null : line.match(/^at\s+(.+)$/);
131
+ const geckoAt = v8Named || v8Bare ? -1 : line.indexOf("@");
132
+ if (v8Named) {
133
+ fn = v8Named[1] ?? null;
134
+ location = v8Named[2] ?? null;
135
+ } else if (v8Bare) {
136
+ location = v8Bare[1] ?? null;
137
+ } else if (geckoAt >= 0) {
138
+ // Firefox / Safari: "fn@loc" | "@loc"
139
+ fn = geckoAt > 0 ? line.slice(0, geckoAt) : null;
140
+ location = line.slice(geckoAt + 1);
141
+ }
142
+ if (!location) continue;
143
+ const parsed = parseLocation(location);
144
+ if (!parsed) continue;
145
+ frames.push({
146
+ file: scrubUrl(parsed.file),
147
+ line: parsed.line,
148
+ column: parsed.column,
149
+ function: fn && fn.length > 0 ? fn : null,
150
+ });
151
+ }
152
+ return frames;
153
+ }