@flareapp/node 0.1.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.
package/.oxlintrc.json ADDED
@@ -0,0 +1,7 @@
1
+ {
2
+ "$schema": "../../node_modules/oxlint/configuration_schema.json",
3
+ "extends": ["../../.oxlintrc.json"],
4
+ "env": {
5
+ "node": true
6
+ }
7
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "git": {
3
+ "tagName": "@flareapp/node@${version}",
4
+ "tagAnnotation": "Release @flareapp/node@${version}",
5
+ "commitMessage": "chore: release @flareapp/node@${version}",
6
+ "requireBranch": "main",
7
+ "requireCleanWorkingDir": true,
8
+ "push": true
9
+ },
10
+ "npm": { "publish": true },
11
+ "github": { "release": false },
12
+ "hooks": { "before:release": "node ../../scripts/check-deps-published.mjs && npm test --if-present" }
13
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,22 @@
1
+ # @flareapp/node changelog
2
+
3
+ ## 0.1.0 — 2026-05-28
4
+
5
+ Initial release. Standalone Node.js SDK.
6
+
7
+ - `flare.runWithContext({ method, path, url, headers, body }, fn)` opens an
8
+ AsyncLocalStorage-backed scope for a single request. Glows, custom context,
9
+ user identity, and entry point are all isolated.
10
+ - `flare.setUser({ id, email, username, ipAddress })` attaches identity to the
11
+ active scope. Mirrors Sentry's split between user identity and request context.
12
+ - Process listeners attached on `flare.light(key)` based on
13
+ `uncaughtExceptionMode` and `unhandledRejectionMode` (each `off`, `report`,
14
+ or `report-and-exit`; default `report-and-exit`). Listener state reconciles
15
+ dynamically when `configureNode(...)` is called later.
16
+ - Default header denylist redacts `authorization`, `cookie`, `set-cookie`,
17
+ `x-api-key`, CSRF tokens, and forwarding headers. Custom allowlist and
18
+ denylist available via `configureNode`.
19
+ - Request body capture is off by default; opt in via
20
+ `configureNode({ captureRequestBody: true })`. JSON and form-urlencoded
21
+ content types accepted; 16 KB cap; circular references and PII keys redacted.
22
+ - Requires Node >=22.
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # @flareapp/node
2
+
3
+ Node.js SDK for [flareapp.io](https://flareapp.io). Capture uncaught
4
+ exceptions, unhandled rejections, and explicit `flare.report(err)` calls in
5
+ your Node servers. Per-request context isolation via AsyncLocalStorage.
6
+
7
+ > **Status: unstable (0.x).** This package is pre-1.0 and its API may change
8
+ > between minor releases. Pin an exact version in production
9
+ > (`"@flareapp/node": "0.1.0"`). The 2.x packages (`@flareapp/js` and the
10
+ > framework integrations) are stable and unaffected.
11
+
12
+ Requires Node 22 or newer.
13
+
14
+ ## Install
15
+
16
+ ```bash
17
+ npm install @flareapp/node
18
+ ```
19
+
20
+ ## Quick start
21
+
22
+ ```ts
23
+ import { flare } from '@flareapp/node';
24
+
25
+ flare.light('your-flare-api-key');
26
+ ```
27
+
28
+ That's it. Crashes and unhandled rejections are reported automatically (the
29
+ default behavior is to report, flush, and exit with code 1).
30
+
31
+ ## Per-request context
32
+
33
+ In an HTTP server, wrap each request handler with `runWithContext` so the
34
+ SDK has request data attached to any error reported during that request:
35
+
36
+ ```ts
37
+ import http from 'node:http';
38
+ import { flare } from '@flareapp/node';
39
+
40
+ flare.light('your-flare-api-key');
41
+
42
+ http.createServer((req, res) => {
43
+ flare.runWithContext({ method: req.method, path: req.url, headers: req.headers }, () => {
44
+ // your handler logic
45
+ });
46
+ }).listen(3000);
47
+ ```
48
+
49
+ When your authentication middleware resolves a user, attach it:
50
+
51
+ ```ts
52
+ flare.setUser({ id: user.id, email: user.email });
53
+ ```
54
+
55
+ You can also patch the request context after it was first set:
56
+
57
+ ```ts
58
+ flare.mergeContext({ url: resolvedAbsoluteUrl });
59
+ ```
60
+
61
+ ## Framework wiring
62
+
63
+ > **Async errors and request context.** A report only carries request context
64
+ > (`http.request.*`, `url.path`, user) when it is created while the request's
65
+ > `runWithContext` scope is active. `AsyncLocalStorage` propagates that scope
66
+ > across `await`, but a report fired from a framework's _global_ error handler
67
+ > only stays in scope if that handler runs inside the request's async chain.
68
+ > When in doubt, report inside `runWithContext`. The patterns below are verified
69
+ > by `e2e/node-frameworks/context.spec.ts` against the versions noted.
70
+
71
+ ### Express
72
+
73
+ ```ts
74
+ import express from 'express';
75
+ import { flare } from '@flareapp/node';
76
+
77
+ flare.light('your-key');
78
+
79
+ const app = express();
80
+ app.use((req, res, next) => {
81
+ flare.runWithContext({ method: req.method, path: req.originalUrl, headers: req.headers }, () => next());
82
+ });
83
+ // ... your routes
84
+ app.use((err, req, res, next) => {
85
+ flare.report(err);
86
+ res.status(500).send('Internal Server Error');
87
+ });
88
+ ```
89
+
90
+ ### Fastify
91
+
92
+ ```ts
93
+ import Fastify from 'fastify';
94
+ import { flare } from '@flareapp/node';
95
+
96
+ flare.light('your-key');
97
+
98
+ const app = Fastify();
99
+ app.addHook('onRequest', (req, _reply, done) => {
100
+ flare.runWithContext({ method: req.method, path: req.url, headers: req.headers }, () => done());
101
+ });
102
+ app.setErrorHandler((err, _req, reply) => {
103
+ flare.report(err);
104
+ reply.status(500).send({ error: 'Internal Server Error' });
105
+ });
106
+ ```
107
+
108
+ ### Hono
109
+
110
+ ```ts
111
+ import { Hono } from 'hono';
112
+ import { flare } from '@flareapp/node';
113
+
114
+ flare.light('your-key');
115
+
116
+ const app = new Hono();
117
+ app.use('*', async (c, next) => {
118
+ await flare.runWithContext(
119
+ { method: c.req.method, path: c.req.path, headers: Object.fromEntries(c.req.raw.headers) },
120
+ () => next(),
121
+ );
122
+ });
123
+ app.onError((err, c) => {
124
+ flare.report(err);
125
+ return c.text('Internal Server Error', 500);
126
+ });
127
+ ```
128
+
129
+ Verified with Express 5, Fastify 5, Hono 4. Express 4 does not catch async
130
+ route errors at all (they surface as `unhandledRejection`); use Express 5 or
131
+ report inside `runWithContext`.
132
+
133
+ ## Configuration
134
+
135
+ ```ts
136
+ flare.configureNode({
137
+ uncaughtExceptionMode: 'report-and-exit', // 'off' | 'report' | 'report-and-exit'
138
+ unhandledRejectionMode: 'report-and-exit',
139
+ shutdownTimeoutMs: 2000,
140
+ captureRequestBody: false,
141
+ bodyMaxBytes: 16_384,
142
+ headerDenylist: /^x-private-/i, // unioned with the default denylist
143
+ headerAllowlist: undefined,
144
+ });
145
+ ```
146
+
147
+ ## Security: header and body capture defaults
148
+
149
+ Headers are emitted as `http.request.header.<lowercase-name>`. The default
150
+ denylist redacts `authorization`, `cookie`, `set-cookie`, `x-api-key`,
151
+ `proxy-authorization`, `x-csrf-token`, `x-xsrf-token`, `x-auth-token`,
152
+ `forwarded`, and `x-forwarded-for|user`. Values for denylisted headers are
153
+ replaced with `[redacted]`.
154
+
155
+ Body capture is off by default. When enabled, only `application/json` and
156
+ `application/x-www-form-urlencoded` content types are captured. PII keys
157
+ (`password`, `token`, `secret`, etc.) are redacted in the captured body. Body
158
+ is truncated to 16 KB by default.
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@flareapp/node",
3
+ "version": "0.1.0",
4
+ "description": "Node.js SDK for flareapp.io",
5
+ "homepage": "https://flareapp.io",
6
+ "bugs": {
7
+ "url": "https://github.com/spatie/flare-client-js/issues"
8
+ },
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/spatie/flare-client-js.git"
12
+ },
13
+ "license": "MIT",
14
+ "author": {
15
+ "name": "Spatie",
16
+ "email": "info@spatie.be"
17
+ },
18
+ "main": "./dist/index.cjs",
19
+ "module": "./dist/index.mjs",
20
+ "types": "./dist/index.d.cts",
21
+ "exports": {
22
+ ".": {
23
+ "import": {
24
+ "types": "./dist/index.d.mts",
25
+ "default": "./dist/index.mjs"
26
+ },
27
+ "require": {
28
+ "types": "./dist/index.d.cts",
29
+ "default": "./dist/index.cjs"
30
+ }
31
+ }
32
+ },
33
+ "engines": {
34
+ "node": ">=22"
35
+ },
36
+ "scripts": {
37
+ "prepublishOnly": "npm run build",
38
+ "build": "tsdown src/index.ts --format cjs,esm --dts --env.FLARE_JS_CLIENT_VERSION=$(node -p \"require('./package.json').version\") --clean",
39
+ "test": "vitest run",
40
+ "typescript": "tsc --noEmit",
41
+ "release": "release-it"
42
+ },
43
+ "dependencies": {
44
+ "@flareapp/core": "2.2.0"
45
+ },
46
+ "devDependencies": {
47
+ "tsdown": "^0.20.3",
48
+ "typescript": "^5.7.0",
49
+ "vitest": "^4.0.18"
50
+ },
51
+ "publishConfig": {
52
+ "access": "public"
53
+ }
54
+ }
package/src/Flare.ts ADDED
@@ -0,0 +1,224 @@
1
+ import { Api, Flare as CoreFlare } from '@flareapp/core';
2
+
3
+ import { DEFAULT_BODY_CONTENT_TYPES, DEFAULT_BODY_KEY_DENYLIST } from './context/body';
4
+ import { makeNodeContextCollector } from './context/collectNode';
5
+ import { DEFAULT_HEADER_DENYLIST, resolveHeaderDenylist } from './context/headers';
6
+ import { buildFatalCallbacks } from './process/fatal';
7
+ import { ProcessHandlerManager } from './process/handlers';
8
+ import { AsyncLocalStorageScopeProvider } from './scope/AsyncLocalStorageScopeProvider';
9
+ import type { NodeScope } from './scope/NodeScope';
10
+ import { DiskFileReader } from './stacktrace/DiskFileReader';
11
+ import type { NodeOptions, RequestContext, ResolvedNodeOptions, User } from './types';
12
+
13
+ const NODE_SDK_NAME = '@flareapp/node';
14
+ const NODE_SDK_VERSION =
15
+ typeof process !== 'undefined' && process.env?.FLARE_JS_CLIENT_VERSION !== undefined
16
+ ? process.env.FLARE_JS_CLIENT_VERSION
17
+ : '?';
18
+
19
+ /**
20
+ * Strip the `g` and `y` flags from a user-supplied regex.
21
+ *
22
+ * `RegExp.prototype.test()` and `.exec()` keep `lastIndex` state when either of
23
+ * these flags is set, which means reusing the same regex across many keys (as
24
+ * the header denylist and body redaction do) silently skips matches after the
25
+ * first hit. Reconstructing the regex without those flags gives stateless
26
+ * matching while preserving everything else (`i`, `m`, `s`, `u`, source).
27
+ */
28
+ function sanitizeRegex(re: RegExp): RegExp {
29
+ const safeFlags = re.flags.replace(/[gy]/g, '');
30
+ return new RegExp(re.source, safeFlags);
31
+ }
32
+
33
+ const DEFAULT_NODE_OPTIONS: ResolvedNodeOptions = {
34
+ uncaughtExceptionMode: 'report-and-exit',
35
+ unhandledRejectionMode: 'report-and-exit',
36
+ shutdownTimeoutMs: 2000,
37
+ headerDenylist: DEFAULT_HEADER_DENYLIST,
38
+ headerAllowlist: null,
39
+ replaceDefaultHeaderDenylist: false,
40
+ captureRequestBody: false,
41
+ bodyMaxBytes: 16_384,
42
+ bodyAllowedContentTypes: DEFAULT_BODY_CONTENT_TYPES,
43
+ bodyKeyDenylist: DEFAULT_BODY_KEY_DENYLIST,
44
+ };
45
+
46
+ /**
47
+ * Node.js-specific `Flare` singleton, exposed from `@flareapp/node` as `flare`.
48
+ *
49
+ * Subclasses core's `Flare` and wires the Node-only seams in its constructor:
50
+ *
51
+ * - `AsyncLocalStorageScopeProvider` so each `runWithContext(...)` callback
52
+ * gets its own `NodeScope` (glows, attributes, user, entry-point, request),
53
+ * isolated from concurrent requests.
54
+ * - `makeNodeContextCollector(...)` to project the current `NodeScope` and
55
+ * process info into report attributes (http.request.*, url.path, etc).
56
+ * - `DiskFileReader` to read source files for stack-trace snippets via
57
+ * `node:fs/promises` instead of the browser's `fetch`.
58
+ * - `ProcessHandlerManager` to attach/detach `uncaughtException` and
59
+ * `unhandledRejection` listeners based on the current `NodeOptions`.
60
+ *
61
+ * Also adds Node-only API surface on top of core: `configureNode(...)`,
62
+ * `runWithContext(...)`, `mergeContext(...)`, `setUser(...)`, `getContext()`,
63
+ * `removeProcessListeners()`. Inherited core methods (`light`, `configure`,
64
+ * `addContext`, `glow`, etc.) return `this`, so chaining keeps the
65
+ * `NodeFlare` type and `configureNode(...)` stays callable mid-chain.
66
+ */
67
+ export class NodeFlare extends CoreFlare {
68
+ private nodeOptions: ResolvedNodeOptions = { ...DEFAULT_NODE_OPTIONS };
69
+ private isLit = false;
70
+ private nodeScopeProvider: AsyncLocalStorageScopeProvider;
71
+ private handlerManager: ProcessHandlerManager;
72
+
73
+ constructor() {
74
+ const scopeProvider = new AsyncLocalStorageScopeProvider();
75
+ // The collector closes over `() => this.nodeOptions` (a getter, not a
76
+ // value) so subsequent `configureNode(...)` calls take effect on
77
+ // future reports without reinjecting the collector.
78
+ const collector = makeNodeContextCollector(scopeProvider, () => this.nodeOptions);
79
+ super(new Api(), collector, new DiskFileReader(), scopeProvider);
80
+ this.nodeScopeProvider = scopeProvider;
81
+ this.setSdkInfo({ name: NODE_SDK_NAME, version: NODE_SDK_VERSION });
82
+
83
+ const cbs = buildFatalCallbacks(this, () => this.nodeOptions);
84
+ this.handlerManager = new ProcessHandlerManager(cbs);
85
+ }
86
+
87
+ /**
88
+ * Set the API key (and optional debug flag), then reconcile process
89
+ * listeners with the current `nodeOptions`. Reconcile runs on EVERY call,
90
+ * not just the first, so `light()` is the right escape hatch to re-attach
91
+ * after `removeProcessListeners()`.
92
+ */
93
+ light(key?: string, debug?: boolean) {
94
+ super.light(key, debug);
95
+ this.isLit = true;
96
+ this.handlerManager.reconcile(this.nodeOptions);
97
+ return this;
98
+ }
99
+
100
+ /**
101
+ * Merge Node-only options (fatal-handler modes, header/body redaction
102
+ * config, shutdown timeout) into the active configuration. Safe to call
103
+ * before or after `light()`:
104
+ *
105
+ * - Before `light()`: options are stored; listeners are attached when
106
+ * `light()` runs.
107
+ * - After `light()`: options are stored AND listeners are reconciled
108
+ * immediately, so flipping a mode to `'off'` detaches the handler and
109
+ * flipping it back to `'report'`/`'report-and-exit'` re-attaches.
110
+ *
111
+ * Regex options (`headerAllowlist`, `bodyAllowedContentTypes`,
112
+ * `bodyKeyDenylist`) are passed through `sanitizeRegex` to strip stateful
113
+ * `g`/`y` flags; without that, `RegExp.prototype.test` would skip matches
114
+ * across keys.
115
+ */
116
+ configureNode(partial: Partial<NodeOptions>): NodeFlare {
117
+ if (partial.headerDenylist !== undefined || partial.replaceDefaultHeaderDenylist !== undefined) {
118
+ this.nodeOptions.headerDenylist = resolveHeaderDenylist(
119
+ partial.headerDenylist ?? undefined,
120
+ partial.replaceDefaultHeaderDenylist ?? this.nodeOptions.replaceDefaultHeaderDenylist,
121
+ );
122
+ this.nodeOptions.replaceDefaultHeaderDenylist =
123
+ partial.replaceDefaultHeaderDenylist ?? this.nodeOptions.replaceDefaultHeaderDenylist;
124
+ }
125
+
126
+ if (partial.headerAllowlist !== undefined) {
127
+ this.nodeOptions.headerAllowlist =
128
+ partial.headerAllowlist === null ? null : sanitizeRegex(partial.headerAllowlist);
129
+ }
130
+
131
+ if (partial.uncaughtExceptionMode !== undefined) {
132
+ this.nodeOptions.uncaughtExceptionMode = partial.uncaughtExceptionMode;
133
+ }
134
+
135
+ if (partial.unhandledRejectionMode !== undefined) {
136
+ this.nodeOptions.unhandledRejectionMode = partial.unhandledRejectionMode;
137
+ }
138
+
139
+ if (partial.shutdownTimeoutMs !== undefined) {
140
+ this.nodeOptions.shutdownTimeoutMs = partial.shutdownTimeoutMs;
141
+ }
142
+
143
+ if (partial.captureRequestBody !== undefined) {
144
+ this.nodeOptions.captureRequestBody = partial.captureRequestBody;
145
+ }
146
+
147
+ if (partial.bodyMaxBytes !== undefined) {
148
+ this.nodeOptions.bodyMaxBytes = partial.bodyMaxBytes;
149
+ }
150
+
151
+ if (partial.bodyAllowedContentTypes !== undefined) {
152
+ this.nodeOptions.bodyAllowedContentTypes = sanitizeRegex(partial.bodyAllowedContentTypes);
153
+ }
154
+
155
+ if (partial.bodyKeyDenylist !== undefined) {
156
+ this.nodeOptions.bodyKeyDenylist = sanitizeRegex(partial.bodyKeyDenylist);
157
+ }
158
+
159
+ if (this.isLit) {
160
+ this.handlerManager.reconcile(this.nodeOptions);
161
+ }
162
+
163
+ return this;
164
+ }
165
+
166
+ /**
167
+ * Run `fn` inside a fresh `NodeScope` carrying the supplied request
168
+ * metadata. Inside `fn` (and any async work it awaits), `flare.glow(...)`,
169
+ * `flare.addContext(...)`, `flare.setUser(...)`, and `flare.report(...)`
170
+ * see a scope that is isolated from other concurrent requests.
171
+ *
172
+ * Mirrors a typical web-framework middleware: call once per request,
173
+ * wrapping the request handler, and the SDK will attribute any error
174
+ * reported inside the chain to the right request.
175
+ */
176
+ runWithContext<T>(request: RequestContext, fn: () => T): T {
177
+ return this.nodeScopeProvider.runWithContext(request, fn);
178
+ }
179
+
180
+ /**
181
+ * Patch the request metadata on the active scope after `runWithContext(...)`
182
+ * has already started. Useful when fields become known partway through a
183
+ * request (e.g., the resolved absolute URL after proxy headers are parsed).
184
+ *
185
+ * Outside any `runWithContext(...)` callback, this writes to the fallback
186
+ * scope; the patch is visible to subsequent reports issued from outside a
187
+ * request scope but is NOT inherited by future `runWithContext(...)` calls.
188
+ */
189
+ mergeContext(partial: Partial<RequestContext>): void {
190
+ this.nodeScopeProvider.mergeContext(partial);
191
+ }
192
+
193
+ /**
194
+ * Attach an authenticated user to the active scope. Inside a request scope
195
+ * this is per-request; outside it lands on the fallback scope. The fields
196
+ * are projected to OTel-style keys (`enduser.id`, `enduser.email`,
197
+ * `enduser.username`, `client.address`) by the Node context collector.
198
+ */
199
+ setUser(user: User | null): void {
200
+ this.nodeScopeProvider.setUser(user);
201
+ }
202
+
203
+ /**
204
+ * Returns the request scope when called inside `runWithContext(...)`, or
205
+ * `null` outside. Intentionally returns `null` (not the fallback scope)
206
+ * when no request is active, so callers can distinguish "we are inside a
207
+ * request" from "we are not". Primarily useful for debugging.
208
+ */
209
+ getContext(): NodeScope | null {
210
+ return this.nodeScopeProvider.getContext();
211
+ }
212
+
213
+ /**
214
+ * Detach the `uncaughtException` and `unhandledRejection` listeners
215
+ * without changing `nodeOptions`. Intended for tests and for graceful
216
+ * shutdown paths where you want to take ownership of process exit
217
+ * yourself.
218
+ *
219
+ * Calling `light()` afterwards re-attaches based on the current options.
220
+ */
221
+ removeProcessListeners(): void {
222
+ this.handlerManager.detach();
223
+ }
224
+ }
@@ -0,0 +1,185 @@
1
+ import { DEFAULT_URL_DENYLIST } from '@flareapp/core';
2
+
3
+ /**
4
+ * Content types accepted by default for body capture. JSON and
5
+ * URL-encoded forms cover the vast majority of API payloads while keeping
6
+ * the parser surface tiny. `\b` after the second alternative prevents
7
+ * accidental matches like `application/x-www-form-urlencoded-foo` (artificial
8
+ * but cheap to defend against). The leading `^` plus `\b` lets us match
9
+ * either bare types or types with `; charset=utf-8` style suffixes.
10
+ */
11
+ export const DEFAULT_BODY_CONTENT_TYPES = /^application\/(json|x-www-form-urlencoded)\b/i;
12
+
13
+ /**
14
+ * Keys whose values get replaced with `[redacted]` during body redaction.
15
+ * Reuses core's URL denylist so credentials, tokens, etc are caught with the
16
+ * same regex everywhere (less surface for users to keep in sync).
17
+ */
18
+ export const DEFAULT_BODY_KEY_DENYLIST = DEFAULT_URL_DENYLIST;
19
+
20
+ type BodyOptions = {
21
+ bodyAllowedContentTypes: RegExp;
22
+ bodyKeyDenylist: RegExp;
23
+ bodyMaxBytes: number;
24
+ };
25
+
26
+ /**
27
+ * Normalize, redact, serialize, and size-cap a request body for inclusion in
28
+ * a Flare report.
29
+ *
30
+ * Accepts four runtime shapes (whatever the user hands us via
31
+ * `runWithContext({ body, ... })`):
32
+ *
33
+ * - `string` — assumed to match the declared `contentType`. Must be JSON or
34
+ * form-encoded text per `bodyAllowedContentTypes`; otherwise dropped.
35
+ * - `Buffer` — decoded as UTF-8 then treated like a string.
36
+ * - `URLSearchParams` — flattened to a plain `Record<string, string>`. No
37
+ * content-type gate (the type is unambiguous from the shape).
38
+ * - Other `object` (POJO, array) — used as-is, no content-type gate. This is
39
+ * the common middleware path (Express's `req.body`, Fastify's, etc).
40
+ *
41
+ * Anything else (`number`, `boolean`, class instance, stream) returns `null`
42
+ * and the body is not reported.
43
+ *
44
+ * After parsing:
45
+ *
46
+ * 1. **Redact.** Walk the value, replacing any property whose key matches
47
+ * `bodyKeyDenylist` with `'[redacted]'`. Handles arrays, nested objects,
48
+ * and circular references (`WeakSet`-tracked, emits `'[Circular]'` on
49
+ * repeat sight).
50
+ * 2. **Stringify.** `JSON.stringify`; if it throws (BigInt, Symbol, etc),
51
+ * drop the body entirely.
52
+ * 3. **Truncate.** Cap at `bodyMaxBytes` UTF-8 bytes (the option's named
53
+ * semantic) INCLUDING the suffix. Truncation respects codepoint boundaries
54
+ * so the result decodes cleanly with no replacement characters.
55
+ *
56
+ * Returns the final JSON string, or `null` when the body should not be
57
+ * reported (unknown shape, content-type miss, serialization failure).
58
+ */
59
+ export function captureBody(body: unknown, contentType: string | undefined, opts: BodyOptions): string | null {
60
+ if (body === undefined || body === null) return null;
61
+
62
+ let parsed: unknown;
63
+ if (typeof body === 'string') {
64
+ if (!matchesContentType(contentType, opts.bodyAllowedContentTypes)) return null;
65
+ parsed = parseString(body, contentType);
66
+ if (parsed === undefined) return null;
67
+ } else if (Buffer.isBuffer(body)) {
68
+ if (!matchesContentType(contentType, opts.bodyAllowedContentTypes)) return null;
69
+ parsed = parseString(body.toString('utf8'), contentType);
70
+ if (parsed === undefined) return null;
71
+ } else if (body instanceof URLSearchParams) {
72
+ parsed = Object.fromEntries(body.entries());
73
+ } else if (Array.isArray(body) || isPlainObject(body)) {
74
+ parsed = body;
75
+ } else {
76
+ return null;
77
+ }
78
+
79
+ const redacted = redact(parsed, opts.bodyKeyDenylist);
80
+ let serialized: string;
81
+ try {
82
+ serialized = JSON.stringify(redacted);
83
+ } catch {
84
+ return null;
85
+ }
86
+ return truncateToByteLimit(serialized, opts.bodyMaxBytes);
87
+ }
88
+
89
+ const TRUNCATION_SUFFIX = '…[truncated]';
90
+ const TRUNCATION_SUFFIX_BYTES = Buffer.byteLength(TRUNCATION_SUFFIX, 'utf8');
91
+
92
+ /**
93
+ * Truncate a serialized string so the resulting UTF-8 byte length never
94
+ * exceeds `maxBytes`, including the appended truncation suffix.
95
+ *
96
+ * Walks backwards from the budget index while the byte at that position is a
97
+ * UTF-8 continuation byte (`10xxxxxx`), stopping at the first byte that
98
+ * starts a new codepoint. Slicing at that index leaves a buffer that decodes
99
+ * cleanly with no replacement characters.
100
+ */
101
+ function truncateToByteLimit(serialized: string, maxBytes: number): string {
102
+ const buf = Buffer.from(serialized, 'utf8');
103
+ if (buf.length <= maxBytes) return serialized;
104
+ if (maxBytes <= TRUNCATION_SUFFIX_BYTES) {
105
+ // Budget too small to fit the suffix plus any payload. Emit the suffix
106
+ // truncated to the byte budget, respecting codepoint boundaries.
107
+ const suffixBuf = Buffer.from(TRUNCATION_SUFFIX, 'utf8');
108
+ let cut = maxBytes;
109
+ while (cut > 0 && (suffixBuf[cut] & 0xc0) === 0x80) cut--;
110
+ return suffixBuf.subarray(0, cut).toString('utf8');
111
+ }
112
+ let cut = maxBytes - TRUNCATION_SUFFIX_BYTES;
113
+ while (cut > 0 && (buf[cut] & 0xc0) === 0x80) cut--;
114
+ return buf.subarray(0, cut).toString('utf8') + TRUNCATION_SUFFIX;
115
+ }
116
+
117
+ /**
118
+ * Check whether a `content-type` header is on the allowlist. Normalizes to the
119
+ * bare media type first: strips any parameters (`; charset=utf-8`), trims, and
120
+ * lowercases, so the regex is tested against `application/json` rather than the
121
+ * full header. This lets a strict custom regex like `/^application\/json$/`
122
+ * still match `application/json; charset=utf-8`. Empty/missing is a hard miss.
123
+ */
124
+ function matchesContentType(ct: string | undefined, allowed: RegExp): boolean {
125
+ if (!ct) return false;
126
+ const mediaType = ct.split(';')[0].trim().toLowerCase();
127
+ if (!mediaType) return false;
128
+ return allowed.test(mediaType);
129
+ }
130
+
131
+ /**
132
+ * Parse a serialized body string into a JS value, branching on the declared
133
+ * content type.
134
+ *
135
+ * - URL-encoded forms become a flat object so the same `redact` walker works.
136
+ * - Otherwise treat as JSON. Returns `undefined` (NOT `null`, which is a
137
+ * legitimate JSON value) when parsing fails, so the caller can distinguish
138
+ * "couldn't parse" from "parsed to literal null".
139
+ */
140
+ function parseString(text: string, contentType?: string): unknown {
141
+ if (contentType && /x-www-form-urlencoded/i.test(contentType)) {
142
+ return Object.fromEntries(new URLSearchParams(text).entries());
143
+ }
144
+ try {
145
+ return JSON.parse(text);
146
+ } catch {
147
+ return undefined;
148
+ }
149
+ }
150
+
151
+ /**
152
+ * True only for `Object.create(null)` or `{}`-shaped values. Excludes class
153
+ * instances (their prototype chain points somewhere other than Object.prototype
154
+ * or null), streams, FormData, ArrayBuffer views, Buffer, URLSearchParams,
155
+ * and other built-ins that happen to be `typeof === 'object'`.
156
+ */
157
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
158
+ if (value === null || typeof value !== 'object') return false;
159
+ const proto = Object.getPrototypeOf(value);
160
+ return proto === null || proto === Object.prototype;
161
+ }
162
+
163
+ /**
164
+ * Recursively walk `value`, replacing values for denylisted keys with
165
+ * `'[redacted]'` and substituting `'[Circular]'` for any object visited more
166
+ * than once.
167
+ *
168
+ * `seen` is a `WeakSet` of already-visited objects. Carried as a parameter
169
+ * (rather than a closure variable) so the same recursive call can pass it
170
+ * down without per-call allocation.
171
+ *
172
+ * Primitives and `null` pass through unchanged. Arrays preserve order;
173
+ * objects preserve keys.
174
+ */
175
+ function redact(value: unknown, denylist: RegExp, seen: WeakSet<object> = new WeakSet()): unknown {
176
+ if (value === null || typeof value !== 'object') return value;
177
+ if (seen.has(value as object)) return '[Circular]';
178
+ seen.add(value as object);
179
+ if (Array.isArray(value)) return value.map((v) => redact(v, denylist, seen));
180
+ const out: Record<string, unknown> = {};
181
+ for (const [k, v] of Object.entries(value)) {
182
+ out[k] = denylist.test(k) ? '[redacted]' : redact(v, denylist, seen);
183
+ }
184
+ return out;
185
+ }