@flareapp/node 0.1.0 → 0.1.1

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.
@@ -1,90 +0,0 @@
1
- import type { Attributes } from '@flareapp/core';
2
-
3
- /**
4
- * Case-insensitively look up a header value. Returns the first defined value
5
- * for the lowercased name, or undefined. Array values (rare but valid for
6
- * some headers) are coalesced to the first element since the consumers in
7
- * this package treat the value as scalar.
8
- */
9
- export function findHeader(
10
- headers: Record<string, string | string[] | undefined> | undefined,
11
- name: string,
12
- ): string | undefined {
13
- if (!headers) return undefined;
14
- const target = name.toLowerCase();
15
- for (const [key, value] of Object.entries(headers)) {
16
- if (key.toLowerCase() !== target) continue;
17
- if (value === undefined) continue;
18
- return Array.isArray(value) ? value[0] : value;
19
- }
20
- return undefined;
21
- }
22
-
23
- /**
24
- * Default-redacted header names. The pattern is anchored to the FULL header
25
- * name (`^...$`) and case-insensitive so it catches `Authorization`,
26
- * `AUTHORIZATION`, `authorization`, etc. Anchoring matters: an unanchored
27
- * `cookie` would match `X-Some-Cookie-Hint` too, which we do NOT want — only
28
- * the exact header by name should be redacted by default.
29
- *
30
- * Covers the usual credential carriers (`authorization`, `cookie`, etc) plus
31
- * common proxy-set headers that often expose client IPs (`forwarded`,
32
- * `x-forwarded-for`, `x-forwarded-user`). Users add domain-specific entries
33
- * via `configureNode({ headerDenylist: ... })`.
34
- */
35
- export const DEFAULT_HEADER_DENYLIST =
36
- /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|x-csrf-token|x-xsrf-token|x-auth-token|forwarded|x-forwarded-(?:for|user))$/i;
37
-
38
- /**
39
- * Combine the built-in denylist with an optional user-supplied one.
40
- *
41
- * - No custom regex -> use the default as-is.
42
- * - Custom + replace = true -> use only the custom pattern (with `g`/`y`
43
- * flags stripped so `.test()` stays stateless).
44
- * - Custom + replace = false -> union: `(?:default)|(?:custom)`, forcing case
45
- * insensitivity since header names are
46
- * case-insensitive over the wire.
47
- */
48
- export function resolveHeaderDenylist(custom?: RegExp, replaceDefault = false): RegExp {
49
- if (!custom) return DEFAULT_HEADER_DENYLIST;
50
- if (replaceDefault) return new RegExp(custom.source, custom.flags.replace(/[gy]/g, ''));
51
- return new RegExp(`(?:${DEFAULT_HEADER_DENYLIST.source})|(?:${custom.source})`, 'i');
52
- }
53
-
54
- /**
55
- * Project an HTTP request `headers` object into report attributes.
56
- *
57
- * Behavior per header:
58
- *
59
- * - **Unset values** (entry exists but the value is `undefined`) are dropped
60
- * entirely — `node:http` represents "header was not sent" this way.
61
- * - **Names are lowercased.** OTel's attribute convention uses lowercase
62
- * header keys, and HTTP header names are case-insensitive anyway.
63
- * - **Allowlist gate.** If `headerAllowlist` is set, only headers whose
64
- * lowercased name matches are emitted; everything else is silently dropped
65
- * (NOT redacted, dropped). This is the strongest filter — useful for
66
- * compliance scenarios where you must opt into headers explicitly.
67
- * - **Array values** (`set-cookie` can be `string[]`) are joined with `, ` so
68
- * the emitted value is a flat string, matching the on-the-wire shape that
69
- * most HTTP clients render.
70
- * - **Denylist redaction.** If the name matches `headerDenylist`, the value
71
- * is replaced with `'[redacted]'` (the key still appears so consumers can
72
- * tell the header was present).
73
- *
74
- * Output keys are `http.request.header.<lowercased-name>`, per OTel.
75
- */
76
- export function projectHeaders(
77
- headers: Record<string, string | string[] | undefined> | undefined,
78
- options: { headerDenylist: RegExp; headerAllowlist: RegExp | null },
79
- ): Attributes {
80
- const out: Attributes = {};
81
- if (!headers) return out;
82
- for (const [rawName, rawValue] of Object.entries(headers)) {
83
- if (rawValue === undefined) continue;
84
- const name = rawName.toLowerCase();
85
- if (options.headerAllowlist && !options.headerAllowlist.test(name)) continue;
86
- const value = Array.isArray(rawValue) ? rawValue.join(', ') : rawValue;
87
- out[`http.request.header.${name}`] = options.headerDenylist.test(name) ? '[redacted]' : value;
88
- }
89
- return out;
90
- }
@@ -1,25 +0,0 @@
1
- import os from 'node:os';
2
-
3
- import type { Attributes } from '@flareapp/core';
4
-
5
- /**
6
- * Snapshot the Node runtime + host environment at report time and project
7
- * into OTel-style attribute keys. Cheap (just property reads + a couple of
8
- * syscalls via `os`), so called per-report rather than cached; this keeps
9
- * `process.uptime` honest and follows the value of `os.hostname()` if it
10
- * changes mid-run (unlikely but free correctness).
11
- *
12
- * Keys are stable OTel resource attributes; the Flare backend recognizes them.
13
- */
14
- export function collectProcessAttributes(): Attributes {
15
- return {
16
- 'process.runtime.name': 'nodejs',
17
- 'process.runtime.version': process.version,
18
- 'process.pid': process.pid,
19
- 'process.uptime': process.uptime(),
20
- 'host.name': os.hostname(),
21
- 'host.arch': process.arch,
22
- 'os.type': os.type(),
23
- 'os.version': os.release(),
24
- };
25
- }
package/src/index.ts DELETED
@@ -1,27 +0,0 @@
1
- import { NodeFlare } from './Flare';
2
-
3
- export const flare = new NodeFlare();
4
-
5
- export { NodeFlare } from './Flare';
6
- export type { RequestContext, User, FatalMode, NodeOptions } from './types';
7
- export { Flare, Scope, GlobalScopeProvider, NullFileReader } from '@flareapp/core';
8
- export type {
9
- AttributeValue,
10
- Attributes,
11
- Config,
12
- ContextCollector,
13
- EntryPointHandler,
14
- FileReader,
15
- Framework,
16
- Glow,
17
- MessageLevel,
18
- OverriddenGrouping,
19
- Report,
20
- ScopeProvider,
21
- SdkInfo,
22
- SpanEvent,
23
- StackFrame,
24
- } from '@flareapp/core';
25
- export { convertToError, DEFAULT_URL_DENYLIST, redactUrlQuery, resolveDenylist } from '@flareapp/core';
26
-
27
- export { NodeScope } from './scope/NodeScope';
@@ -1,54 +0,0 @@
1
- import type { Flare } from '@flareapp/core';
2
-
3
- import type { FatalMode } from '../types';
4
-
5
- type FatalOptions = {
6
- uncaughtExceptionMode: FatalMode;
7
- unhandledRejectionMode: FatalMode;
8
- shutdownTimeoutMs: number;
9
- };
10
-
11
- export function buildFatalCallbacks(
12
- flare: Flare,
13
- getOpts: () => FatalOptions,
14
- exit: (code: number) => void = process.exit.bind(process),
15
- ) {
16
- return {
17
- async onUncaught(err: unknown, origin: string): Promise<void> {
18
- const opts = getOpts();
19
- if (opts.uncaughtExceptionMode === 'report-and-exit') {
20
- process.exitCode = 1;
21
- }
22
- const error = err instanceof Error ? err : new Error(String(err));
23
- try {
24
- await flare.report(error, { 'process.uncaught_exception.origin': origin });
25
- } catch {
26
- // swallow
27
- }
28
- // Only drain other in-flight reports when we're about to exit. In
29
- // 'report' mode the process keeps running, so those reports settle
30
- // on their own and flushing here would just waste time. Mirrors
31
- // onRejection.
32
- if (opts.uncaughtExceptionMode === 'report-and-exit') {
33
- await flare.flush(opts.shutdownTimeoutMs);
34
- exit(1);
35
- }
36
- },
37
- async onRejection(reason: unknown): Promise<void> {
38
- const opts = getOpts();
39
- if (opts.unhandledRejectionMode === 'report-and-exit') {
40
- process.exitCode = 1;
41
- }
42
- const error = reason instanceof Error ? reason : new Error(String(reason));
43
- try {
44
- await flare.report(error);
45
- } catch {
46
- // swallow
47
- }
48
- if (opts.unhandledRejectionMode === 'report-and-exit') {
49
- await flare.flush(opts.shutdownTimeoutMs);
50
- exit(1);
51
- }
52
- },
53
- };
54
- }
@@ -1,109 +0,0 @@
1
- import type { FatalMode } from '../types';
2
-
3
- type Callbacks = {
4
- onUncaught: (err: unknown, origin: string) => void;
5
- onRejection: (reason: unknown) => void;
6
- };
7
-
8
- /**
9
- * Owns the lifecycle of the two process-level error listeners that capture
10
- * fatal failures and feed them to Flare:
11
- *
12
- * - `process.on('uncaughtException', ...)`
13
- * - `process.on('unhandledRejection', ...)`
14
- *
15
- * The manager has two responsibilities:
16
- *
17
- * 1. **Reconcile listener state with intent.** Given the current `FatalMode`
18
- * for each event (`'off' | 'report' | 'report-and-exit'`), make the actual
19
- * listener attachment match: attach when it should be attached but isn't,
20
- * detach when it shouldn't be attached but is, no-op when already in the
21
- * desired state. This is idempotent — calling `reconcile(...)` repeatedly
22
- * with the same options is safe.
23
- * 2. **Tear down on demand.** `detach()` removes both listeners regardless of
24
- * intent, for tests and graceful shutdown.
25
- *
26
- * Why keep this separate from `NodeFlare`: the attach/detach logic is purely
27
- * about Node `process` events and contains no Flare semantics. Isolating it
28
- * makes it trivial to test (the test suite drives `reconcile()` directly with
29
- * stub callbacks and asserts on `process.listeners(...)`) and keeps
30
- * `NodeFlare` focused on report assembly + user-facing API.
31
- */
32
- export class ProcessHandlerManager {
33
- /** The currently-attached listener for `uncaughtException`, or `null`. */
34
- private uncaughtHandler: ((err: unknown, origin: string) => void) | null = null;
35
- /** The currently-attached listener for `unhandledRejection`, or `null`. */
36
- private rejectionHandler: ((reason: unknown) => void) | null = null;
37
-
38
- constructor(private cbs: Callbacks) {}
39
-
40
- /**
41
- * Bring the attached listeners into agreement with the supplied modes.
42
- * Idempotent: when current state already matches intent, this is a no-op.
43
- */
44
- reconcile(opts: { uncaughtExceptionMode: FatalMode; unhandledRejectionMode: FatalMode }): void {
45
- this.reconcileOne(
46
- 'uncaughtException',
47
- opts.uncaughtExceptionMode,
48
- () => this.uncaughtHandler,
49
- (h) => {
50
- this.uncaughtHandler = h;
51
- },
52
- (err, origin) => this.cbs.onUncaught(err, origin as string),
53
- );
54
- this.reconcileOne(
55
- 'unhandledRejection',
56
- opts.unhandledRejectionMode,
57
- () => this.rejectionHandler,
58
- (h) => {
59
- this.rejectionHandler = h;
60
- },
61
- (reason) => this.cbs.onRejection(reason),
62
- );
63
- }
64
-
65
- /**
66
- * Remove both listeners regardless of current intent. Used by tests and by
67
- * `NodeFlare.removeProcessListeners()`. Safe to call when nothing is
68
- * attached.
69
- */
70
- detach(): void {
71
- if (this.uncaughtHandler) {
72
- process.off('uncaughtException', this.uncaughtHandler as any);
73
- this.uncaughtHandler = null;
74
- }
75
- if (this.rejectionHandler) {
76
- process.off('unhandledRejection', this.rejectionHandler as any);
77
- this.rejectionHandler = null;
78
- }
79
- }
80
-
81
- /**
82
- * Generic attach/detach for one event. The `get`/`set` closures let us
83
- * share this body between the two events while still mutating distinct
84
- * fields (`uncaughtHandler` vs `rejectionHandler`).
85
- *
86
- * Truth table:
87
- * - intent off, currently attached -> detach
88
- * - intent off, not attached -> no-op
89
- * - intent on, currently attached -> no-op (already correct)
90
- * - intent on, not attached -> attach
91
- */
92
- private reconcileOne(
93
- event: 'uncaughtException' | 'unhandledRejection',
94
- mode: FatalMode,
95
- get: () => ((...args: any[]) => void) | null,
96
- set: (h: ((...args: any[]) => void) | null) => void,
97
- impl: (...args: any[]) => void,
98
- ): void {
99
- const current = get();
100
- const wants = mode !== 'off';
101
- if (wants && !current) {
102
- set(impl);
103
- process.on(event, impl as any);
104
- } else if (!wants && current) {
105
- process.off(event, current as any);
106
- set(null);
107
- }
108
- }
109
- }
@@ -1,86 +0,0 @@
1
- import { AsyncLocalStorage } from 'node:async_hooks';
2
-
3
- import type { ScopeProvider } from '@flareapp/core';
4
-
5
- import type { RequestContext, User } from '../types';
6
- import { NodeScope } from './NodeScope';
7
-
8
- /**
9
- * `ScopeProvider` implementation that gives every in-flight request its own
10
- * `NodeScope`, isolated from concurrent requests.
11
- *
12
- * Built on Node's `node:async_hooks#AsyncLocalStorage`: when code runs inside
13
- * `als.run(scope, fn)`, every `als.getStore()` call from within `fn` (and any
14
- * async work `fn` awaits, including timers, promises, `process.nextTick`, etc)
15
- * returns that `scope`. Outside any `als.run` call, `getStore()` returns
16
- * `undefined`. This is the same primitive that lets observability libraries
17
- * propagate trace context across async boundaries without manual plumbing.
18
- *
19
- * Two "kinds of read" surfaced separately:
20
- *
21
- * - `active()` — never returns null. The internal read used by `Flare` for
22
- * every glow, attribute set, and report. When called inside `runWithContext`,
23
- * returns the per-request `NodeScope`. Outside, returns a shared `fallback`
24
- * scope so glows/attributes/reports issued outside any request still have
25
- * somewhere to land (process-level reports, startup errors, scheduled jobs).
26
- * - `getContext()` — public debug helper. Returns `null` outside any
27
- * `runWithContext`, so consumers can distinguish "I am inside a request" from
28
- * "I am not". The fallback is intentionally NOT exposed here.
29
- *
30
- * The fallback is also a per-instance `NodeScope` so that writes from outside
31
- * a request scope persist for subsequent outside-scope reports.
32
- */
33
- export class AsyncLocalStorageScopeProvider implements ScopeProvider {
34
- private als = new AsyncLocalStorage<NodeScope>();
35
- private fallback = new NodeScope();
36
-
37
- /**
38
- * Internal: returns the per-request scope when inside `runWithContext`,
39
- * or the shared fallback otherwise. Always returns a real `NodeScope`.
40
- */
41
- active(): NodeScope {
42
- return this.als.getStore() ?? this.fallback;
43
- }
44
-
45
- /**
46
- * Public: returns the per-request scope when inside `runWithContext`, or
47
- * `null` otherwise. Useful for assertions like "am I in a request?".
48
- */
49
- getContext(): NodeScope | null {
50
- return this.als.getStore() ?? null;
51
- }
52
-
53
- /**
54
- * Open a fresh request scope around `fn` and run it. Every async hop
55
- * inside `fn` (awaits, timers, promise chains) sees the same scope via
56
- * `active()`/`getContext()`; concurrent calls each get their own.
57
- *
58
- * `request` is shallow-cloned so later edits to the caller's object do not
59
- * leak into the stored scope.
60
- */
61
- runWithContext<T>(request: RequestContext, fn: () => T): T {
62
- const scope = new NodeScope();
63
- scope.request = { ...request };
64
- return this.als.run(scope, fn);
65
- }
66
-
67
- /**
68
- * Patch the current scope's `request` shape. When called inside
69
- * `runWithContext`, the patch is visible to all subsequent reads from
70
- * within the same request chain. When called outside, the patch lands on
71
- * the fallback scope.
72
- */
73
- mergeContext(partial: Partial<RequestContext>): void {
74
- const scope = this.als.getStore() ?? this.fallback;
75
- scope.request = { ...scope.request, ...partial };
76
- }
77
-
78
- /**
79
- * Set the authenticated user on the current scope. Same in-scope vs
80
- * fallback semantics as `mergeContext`.
81
- */
82
- setUser(user: User | null): void {
83
- const scope = this.als.getStore() ?? this.fallback;
84
- scope.user = user;
85
- }
86
- }
@@ -1,8 +0,0 @@
1
- import { Scope } from '@flareapp/core';
2
-
3
- import type { RequestContext, User } from '../types';
4
-
5
- export class NodeScope extends Scope {
6
- request: RequestContext = {};
7
- user: User | null = null;
8
- }
@@ -1,57 +0,0 @@
1
- import { readFile } from 'node:fs/promises';
2
- import { fileURLToPath } from 'node:url';
3
-
4
- import type { FileReader } from '@flareapp/core';
5
-
6
- /**
7
- * Node `FileReader` implementation that reads source files from disk.
8
- *
9
- * Wired into `@flareapp/node`'s singleton so the stack-trace builder can pull
10
- * source for each frame and render a snippet. On the server the frame's "URL"
11
- * is usually a local path (e.g. `/app/dist/server.js`) or a `file://` URL
12
- * (from `import.meta.url`), so we resolve straight off disk instead of going
13
- * over the network.
14
- *
15
- * Safety gates:
16
- *
17
- * 1. **Local-path allowlist.** Only `file://` URLs and absolute filesystem
18
- * paths (POSIX `/foo`, Windows `C:\foo` or `\\server\share\foo`) are
19
- * accepted. HTTP URLs and relative paths return `null` immediately. We
20
- * refuse to read anything that does not unambiguously identify a local
21
- * file — no surprise traversal, no following http stack frames in a
22
- * server build, no relative-path ambiguity around the current working
23
- * directory.
24
- * 2. **Catch-all.** Missing files, permission errors, and any other failure
25
- * return `null`. The `read()` contract returns `null` on every failure
26
- * path and never throws.
27
- *
28
- * `fileURLToPath` is used when the input is a `file://` URL so we hand
29
- * `readFile` a real OS path. Otherwise the URL IS already a path and is
30
- * passed through unchanged.
31
- */
32
- export class DiskFileReader implements FileReader {
33
- async read(url: string): Promise<string | null> {
34
- if (!isLocalFileUrl(url)) return null;
35
- try {
36
- const path = /^file:\/\//i.test(url) ? fileURLToPath(url) : url;
37
- return await readFile(path, 'utf-8');
38
- } catch {
39
- return null;
40
- }
41
- }
42
- }
43
-
44
- /**
45
- * Return true when `url` is something we are willing to treat as a local
46
- * file. Matches four shapes:
47
- *
48
- * - `file://...` URLs (any casing of the scheme)
49
- * - POSIX absolute paths starting with `/`
50
- * - Windows drive-letter paths like `C:\foo` or `c:/foo`
51
- * - Windows UNC paths starting with `\\`
52
- *
53
- * Anything else (relative paths, http, data, blob, etc) is rejected.
54
- */
55
- function isLocalFileUrl(url: string): boolean {
56
- return /^file:\/\//i.test(url) || url.startsWith('/') || /^[a-z]:[\\/]/i.test(url) || url.startsWith('\\\\');
57
- }
package/src/types.ts DELETED
@@ -1,37 +0,0 @@
1
- export type RequestContext = {
2
- method?: string;
3
- path?: string;
4
- url?: string;
5
- headers?: Record<string, string | string[] | undefined>;
6
- body?: unknown;
7
- };
8
-
9
- export type User = {
10
- id?: string | number;
11
- email?: string;
12
- username?: string;
13
- ipAddress?: string;
14
- };
15
-
16
- export type FatalMode = 'off' | 'report' | 'report-and-exit';
17
-
18
- export type NodeOptions = {
19
- uncaughtExceptionMode?: FatalMode;
20
- unhandledRejectionMode?: FatalMode;
21
- shutdownTimeoutMs?: number;
22
- headerDenylist?: RegExp;
23
- headerAllowlist?: RegExp | null;
24
- replaceDefaultHeaderDenylist?: boolean;
25
- captureRequestBody?: boolean;
26
- bodyMaxBytes?: number;
27
- bodyAllowedContentTypes?: RegExp;
28
- bodyKeyDenylist?: RegExp;
29
- };
30
-
31
- export type ResolvedNodeOptions = Required<
32
- Omit<NodeOptions, 'headerDenylist' | 'headerAllowlist' | 'bodyKeyDenylist'>
33
- > & {
34
- headerDenylist: RegExp;
35
- headerAllowlist: RegExp | null;
36
- bodyKeyDenylist: RegExp;
37
- };
@@ -1,43 +0,0 @@
1
- import { describe, expect, it } from 'vitest';
2
-
3
- import { AsyncLocalStorageScopeProvider } from '../src/scope/AsyncLocalStorageScopeProvider';
4
- import { NodeScope } from '../src/scope/NodeScope';
5
-
6
- describe('AsyncLocalStorageScopeProvider', () => {
7
- it('falls back to a shared NodeScope outside runWithContext', () => {
8
- const provider = new AsyncLocalStorageScopeProvider();
9
- const a = provider.active();
10
- const b = provider.active();
11
- expect(a).toBeInstanceOf(NodeScope);
12
- expect(a).toBe(b);
13
- });
14
-
15
- it('returns null from getContext outside runWithContext', () => {
16
- const provider = new AsyncLocalStorageScopeProvider();
17
- expect(provider.getContext()).toBeNull();
18
- });
19
-
20
- it('isolates scopes across runWithContext calls', async () => {
21
- const provider = new AsyncLocalStorageScopeProvider();
22
- const results: Array<string | undefined> = [];
23
-
24
- async function workload(label: string) {
25
- return provider.runWithContext({ path: `/${label}` }, async () => {
26
- await new Promise((r) => setTimeout(r, Math.random() * 20));
27
- results.push(provider.active().request.path);
28
- });
29
- }
30
-
31
- await Promise.all([workload('a'), workload('b'), workload('c')]);
32
- results.sort();
33
- expect(results).toEqual(['/a', '/b', '/c']);
34
- });
35
-
36
- it('mergeContext patches the active scope', () => {
37
- const provider = new AsyncLocalStorageScopeProvider();
38
- provider.runWithContext({ method: 'GET' }, () => {
39
- provider.mergeContext({ path: '/foo' });
40
- expect(provider.active().request).toEqual({ method: 'GET', path: '/foo' });
41
- });
42
- });
43
- });
@@ -1,129 +0,0 @@
1
- import { Readable } from 'node:stream';
2
-
3
- import { describe, expect, it } from 'vitest';
4
-
5
- import { captureBody, DEFAULT_BODY_CONTENT_TYPES, DEFAULT_BODY_KEY_DENYLIST } from '../src/context/body';
6
-
7
- const opts = {
8
- bodyAllowedContentTypes: DEFAULT_BODY_CONTENT_TYPES,
9
- bodyKeyDenylist: DEFAULT_BODY_KEY_DENYLIST,
10
- bodyMaxBytes: 16_384,
11
- };
12
-
13
- describe('captureBody', () => {
14
- it('returns null for empty body', () => {
15
- expect(captureBody(undefined, 'application/json', opts)).toBeNull();
16
- });
17
-
18
- it('parses JSON body and redacts password key', () => {
19
- const out = captureBody('{"user":"x","password":"secret"}', 'application/json', opts);
20
- const parsed = JSON.parse(out!);
21
- expect(parsed).toEqual({ user: 'x', password: '[redacted]' });
22
- });
23
-
24
- it('accepts content-type with parameters', () => {
25
- const out = captureBody('{"a":1}', 'application/json; charset=utf-8', opts);
26
- expect(out).toBe('{"a":1}');
27
- });
28
-
29
- it('normalizes the media type so a strict custom regex matches parameterized headers', () => {
30
- const strict = { ...opts, bodyAllowedContentTypes: /^application\/json$/ };
31
- // Anchored regex would reject the raw header `application/json; charset=utf-8`;
32
- // matchesContentType strips params and lowercases first, so it matches.
33
- expect(captureBody('{"a":1}', 'application/json; charset=utf-8', strict)).toBe('{"a":1}');
34
- expect(captureBody('{"a":1}', 'APPLICATION/JSON', strict)).toBe('{"a":1}');
35
- });
36
-
37
- it('rejects content-types not in allowlist', () => {
38
- expect(captureBody('hello', 'text/plain', opts)).toBeNull();
39
- });
40
-
41
- it('decodes Buffer input as UTF-8', () => {
42
- const out = captureBody(Buffer.from('{"k":1}'), 'application/json', opts);
43
- expect(out).toBe('{"k":1}');
44
- });
45
-
46
- it('skips content-type check when body is already an object', () => {
47
- const out = captureBody({ a: 1, token: 'x' }, undefined, opts);
48
- expect(JSON.parse(out!)).toEqual({ a: 1, token: '[redacted]' });
49
- });
50
-
51
- it('handles URLSearchParams', () => {
52
- const out = captureBody(new URLSearchParams({ a: '1', secret: 'x' }), undefined, opts);
53
- expect(JSON.parse(out!)).toEqual({ a: '1', secret: '[redacted]' });
54
- });
55
-
56
- it('truncates over bodyMaxBytes', () => {
57
- const big = { v: 'x'.repeat(20_000) };
58
- const out = captureBody(big, undefined, { ...opts, bodyMaxBytes: 100 });
59
- expect(Buffer.byteLength(out!, 'utf8')).toBeLessThanOrEqual(100);
60
- expect(out!.endsWith('…[truncated]')).toBe(true);
61
- });
62
-
63
- it('truncates by UTF-8 byte length, not character length, for multi-byte payloads', () => {
64
- // Three-byte char (CJK) repeated. 200 chars = 600 UTF-8 bytes.
65
- const big = { v: '漢'.repeat(200) };
66
- const out = captureBody(big, undefined, { ...opts, bodyMaxBytes: 100 });
67
- expect(out).not.toBeNull();
68
- expect(Buffer.byteLength(out!, 'utf8')).toBeLessThanOrEqual(100);
69
- expect(out!.endsWith('…[truncated]')).toBe(true);
70
- });
71
-
72
- it('never leaves a partial multi-byte sequence at the cut', () => {
73
- const big = { v: '漢'.repeat(200) };
74
- const out = captureBody(big, undefined, { ...opts, bodyMaxBytes: 50 });
75
- // Decoded buffer must round-trip cleanly (no Unicode replacement char).
76
- expect(out!.includes('�')).toBe(false);
77
- });
78
-
79
- it('emits only suffix when budget is too small', () => {
80
- const big = { v: 'x'.repeat(100) };
81
- const out = captureBody(big, undefined, { ...opts, bodyMaxBytes: 5 });
82
- // 5 bytes can't fit 14-byte suffix + any payload. Result should be the
83
- // suffix truncated to 5 bytes, staying within the byte budget.
84
- expect(Buffer.byteLength(out!, 'utf8')).toBeLessThanOrEqual(5);
85
- });
86
-
87
- it('ASCII-only path still truncates at byte budget including suffix', () => {
88
- const big = { v: 'x'.repeat(200) };
89
- const out = captureBody(big, undefined, { ...opts, bodyMaxBytes: 50 });
90
- expect(Buffer.byteLength(out!, 'utf8')).toBeLessThanOrEqual(50);
91
- expect(out!.endsWith('…[truncated]')).toBe(true);
92
- });
93
-
94
- it('handles circular references', () => {
95
- const obj: any = { a: 1 };
96
- obj.self = obj;
97
- const out = captureBody(obj, undefined, opts);
98
- expect(out).toContain('"[Circular]"');
99
- });
100
-
101
- it('skips Node streams', () => {
102
- const stream = Readable.from(['x']);
103
- expect(captureBody(stream, undefined, opts)).toBeNull();
104
- });
105
-
106
- it('skips ArrayBuffer and typed arrays', () => {
107
- expect(captureBody(new ArrayBuffer(8), undefined, opts)).toBeNull();
108
- expect(captureBody(new Uint8Array([1, 2, 3]), undefined, opts)).toBeNull();
109
- });
110
-
111
- it('skips FormData', () => {
112
- const fd = new FormData();
113
- fd.append('a', '1');
114
- expect(captureBody(fd, undefined, opts)).toBeNull();
115
- });
116
-
117
- it('skips class instances with non-Object prototypes', () => {
118
- class User {
119
- constructor(public id: string) {}
120
- }
121
- expect(captureBody(new User('u1'), undefined, opts)).toBeNull();
122
- });
123
-
124
- it('still accepts plain objects and arrays', () => {
125
- expect(captureBody({ a: 1 }, undefined, opts)).toBe('{"a":1}');
126
- expect(captureBody([1, 2, 3], undefined, opts)).toBe('[1,2,3]');
127
- expect(captureBody(Object.create(null), undefined, opts)).toBe('{}');
128
- });
129
- });