@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.
package/dist/index.mjs ADDED
@@ -0,0 +1,755 @@
1
+ import { Api, DEFAULT_URL_DENYLIST, DEFAULT_URL_DENYLIST as DEFAULT_URL_DENYLIST$1, Flare, Flare as Flare$1, GlobalScopeProvider, NullFileReader, Scope, Scope as Scope$1, convertToError, redactUrlQuery, redactUrlQuery as redactUrlQuery$1, resolveDenylist } from "@flareapp/core";
2
+ import os from "node:os";
3
+ import { AsyncLocalStorage } from "node:async_hooks";
4
+ import { readFile } from "node:fs/promises";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ //#region src/context/body.ts
8
+ /**
9
+ * Content types accepted by default for body capture. JSON and
10
+ * URL-encoded forms cover the vast majority of API payloads while keeping
11
+ * the parser surface tiny. `\b` after the second alternative prevents
12
+ * accidental matches like `application/x-www-form-urlencoded-foo` (artificial
13
+ * but cheap to defend against). The leading `^` plus `\b` lets us match
14
+ * either bare types or types with `; charset=utf-8` style suffixes.
15
+ */
16
+ const DEFAULT_BODY_CONTENT_TYPES = /^application\/(json|x-www-form-urlencoded)\b/i;
17
+ /**
18
+ * Keys whose values get replaced with `[redacted]` during body redaction.
19
+ * Reuses core's URL denylist so credentials, tokens, etc are caught with the
20
+ * same regex everywhere (less surface for users to keep in sync).
21
+ */
22
+ const DEFAULT_BODY_KEY_DENYLIST = DEFAULT_URL_DENYLIST$1;
23
+ /**
24
+ * Normalize, redact, serialize, and size-cap a request body for inclusion in
25
+ * a Flare report.
26
+ *
27
+ * Accepts four runtime shapes (whatever the user hands us via
28
+ * `runWithContext({ body, ... })`):
29
+ *
30
+ * - `string` — assumed to match the declared `contentType`. Must be JSON or
31
+ * form-encoded text per `bodyAllowedContentTypes`; otherwise dropped.
32
+ * - `Buffer` — decoded as UTF-8 then treated like a string.
33
+ * - `URLSearchParams` — flattened to a plain `Record<string, string>`. No
34
+ * content-type gate (the type is unambiguous from the shape).
35
+ * - Other `object` (POJO, array) — used as-is, no content-type gate. This is
36
+ * the common middleware path (Express's `req.body`, Fastify's, etc).
37
+ *
38
+ * Anything else (`number`, `boolean`, class instance, stream) returns `null`
39
+ * and the body is not reported.
40
+ *
41
+ * After parsing:
42
+ *
43
+ * 1. **Redact.** Walk the value, replacing any property whose key matches
44
+ * `bodyKeyDenylist` with `'[redacted]'`. Handles arrays, nested objects,
45
+ * and circular references (`WeakSet`-tracked, emits `'[Circular]'` on
46
+ * repeat sight).
47
+ * 2. **Stringify.** `JSON.stringify`; if it throws (BigInt, Symbol, etc),
48
+ * drop the body entirely.
49
+ * 3. **Truncate.** Cap at `bodyMaxBytes` UTF-8 bytes (the option's named
50
+ * semantic) INCLUDING the suffix. Truncation respects codepoint boundaries
51
+ * so the result decodes cleanly with no replacement characters.
52
+ *
53
+ * Returns the final JSON string, or `null` when the body should not be
54
+ * reported (unknown shape, content-type miss, serialization failure).
55
+ */
56
+ function captureBody(body, contentType, opts) {
57
+ if (body === void 0 || body === null) return null;
58
+ let parsed;
59
+ if (typeof body === "string") {
60
+ if (!matchesContentType(contentType, opts.bodyAllowedContentTypes)) return null;
61
+ parsed = parseString(body, contentType);
62
+ if (parsed === void 0) return null;
63
+ } else if (Buffer.isBuffer(body)) {
64
+ if (!matchesContentType(contentType, opts.bodyAllowedContentTypes)) return null;
65
+ parsed = parseString(body.toString("utf8"), contentType);
66
+ if (parsed === void 0) return null;
67
+ } else if (body instanceof URLSearchParams) parsed = Object.fromEntries(body.entries());
68
+ else if (Array.isArray(body) || isPlainObject(body)) parsed = body;
69
+ else return null;
70
+ const redacted = redact(parsed, opts.bodyKeyDenylist);
71
+ let serialized;
72
+ try {
73
+ serialized = JSON.stringify(redacted);
74
+ } catch {
75
+ return null;
76
+ }
77
+ return truncateToByteLimit(serialized, opts.bodyMaxBytes);
78
+ }
79
+ const TRUNCATION_SUFFIX = "…[truncated]";
80
+ const TRUNCATION_SUFFIX_BYTES = Buffer.byteLength(TRUNCATION_SUFFIX, "utf8");
81
+ /**
82
+ * Truncate a serialized string so the resulting UTF-8 byte length never
83
+ * exceeds `maxBytes`, including the appended truncation suffix.
84
+ *
85
+ * Walks backwards from the budget index while the byte at that position is a
86
+ * UTF-8 continuation byte (`10xxxxxx`), stopping at the first byte that
87
+ * starts a new codepoint. Slicing at that index leaves a buffer that decodes
88
+ * cleanly with no replacement characters.
89
+ */
90
+ function truncateToByteLimit(serialized, maxBytes) {
91
+ const buf = Buffer.from(serialized, "utf8");
92
+ if (buf.length <= maxBytes) return serialized;
93
+ if (maxBytes <= TRUNCATION_SUFFIX_BYTES) {
94
+ const suffixBuf = Buffer.from(TRUNCATION_SUFFIX, "utf8");
95
+ let cut = maxBytes;
96
+ while (cut > 0 && (suffixBuf[cut] & 192) === 128) cut--;
97
+ return suffixBuf.subarray(0, cut).toString("utf8");
98
+ }
99
+ let cut = maxBytes - TRUNCATION_SUFFIX_BYTES;
100
+ while (cut > 0 && (buf[cut] & 192) === 128) cut--;
101
+ return buf.subarray(0, cut).toString("utf8") + TRUNCATION_SUFFIX;
102
+ }
103
+ /**
104
+ * Check whether a `content-type` header is on the allowlist. Normalizes to the
105
+ * bare media type first: strips any parameters (`; charset=utf-8`), trims, and
106
+ * lowercases, so the regex is tested against `application/json` rather than the
107
+ * full header. This lets a strict custom regex like `/^application\/json$/`
108
+ * still match `application/json; charset=utf-8`. Empty/missing is a hard miss.
109
+ */
110
+ function matchesContentType(ct, allowed) {
111
+ if (!ct) return false;
112
+ const mediaType = ct.split(";")[0].trim().toLowerCase();
113
+ if (!mediaType) return false;
114
+ return allowed.test(mediaType);
115
+ }
116
+ /**
117
+ * Parse a serialized body string into a JS value, branching on the declared
118
+ * content type.
119
+ *
120
+ * - URL-encoded forms become a flat object so the same `redact` walker works.
121
+ * - Otherwise treat as JSON. Returns `undefined` (NOT `null`, which is a
122
+ * legitimate JSON value) when parsing fails, so the caller can distinguish
123
+ * "couldn't parse" from "parsed to literal null".
124
+ */
125
+ function parseString(text, contentType) {
126
+ if (contentType && /x-www-form-urlencoded/i.test(contentType)) return Object.fromEntries(new URLSearchParams(text).entries());
127
+ try {
128
+ return JSON.parse(text);
129
+ } catch {
130
+ return;
131
+ }
132
+ }
133
+ /**
134
+ * True only for `Object.create(null)` or `{}`-shaped values. Excludes class
135
+ * instances (their prototype chain points somewhere other than Object.prototype
136
+ * or null), streams, FormData, ArrayBuffer views, Buffer, URLSearchParams,
137
+ * and other built-ins that happen to be `typeof === 'object'`.
138
+ */
139
+ function isPlainObject(value) {
140
+ if (value === null || typeof value !== "object") return false;
141
+ const proto = Object.getPrototypeOf(value);
142
+ return proto === null || proto === Object.prototype;
143
+ }
144
+ /**
145
+ * Recursively walk `value`, replacing values for denylisted keys with
146
+ * `'[redacted]'` and substituting `'[Circular]'` for any object visited more
147
+ * than once.
148
+ *
149
+ * `seen` is a `WeakSet` of already-visited objects. Carried as a parameter
150
+ * (rather than a closure variable) so the same recursive call can pass it
151
+ * down without per-call allocation.
152
+ *
153
+ * Primitives and `null` pass through unchanged. Arrays preserve order;
154
+ * objects preserve keys.
155
+ */
156
+ function redact(value, denylist, seen = /* @__PURE__ */ new WeakSet()) {
157
+ if (value === null || typeof value !== "object") return value;
158
+ if (seen.has(value)) return "[Circular]";
159
+ seen.add(value);
160
+ if (Array.isArray(value)) return value.map((v) => redact(v, denylist, seen));
161
+ const out = {};
162
+ for (const [k, v] of Object.entries(value)) out[k] = denylist.test(k) ? "[redacted]" : redact(v, denylist, seen);
163
+ return out;
164
+ }
165
+
166
+ //#endregion
167
+ //#region src/context/headers.ts
168
+ /**
169
+ * Case-insensitively look up a header value. Returns the first defined value
170
+ * for the lowercased name, or undefined. Array values (rare but valid for
171
+ * some headers) are coalesced to the first element since the consumers in
172
+ * this package treat the value as scalar.
173
+ */
174
+ function findHeader(headers, name) {
175
+ if (!headers) return void 0;
176
+ const target = name.toLowerCase();
177
+ for (const [key, value] of Object.entries(headers)) {
178
+ if (key.toLowerCase() !== target) continue;
179
+ if (value === void 0) continue;
180
+ return Array.isArray(value) ? value[0] : value;
181
+ }
182
+ }
183
+ /**
184
+ * Default-redacted header names. The pattern is anchored to the FULL header
185
+ * name (`^...$`) and case-insensitive so it catches `Authorization`,
186
+ * `AUTHORIZATION`, `authorization`, etc. Anchoring matters: an unanchored
187
+ * `cookie` would match `X-Some-Cookie-Hint` too, which we do NOT want — only
188
+ * the exact header by name should be redacted by default.
189
+ *
190
+ * Covers the usual credential carriers (`authorization`, `cookie`, etc) plus
191
+ * common proxy-set headers that often expose client IPs (`forwarded`,
192
+ * `x-forwarded-for`, `x-forwarded-user`). Users add domain-specific entries
193
+ * via `configureNode({ headerDenylist: ... })`.
194
+ */
195
+ const DEFAULT_HEADER_DENYLIST = /^(authorization|proxy-authorization|cookie|set-cookie|x-api-key|x-csrf-token|x-xsrf-token|x-auth-token|forwarded|x-forwarded-(?:for|user))$/i;
196
+ /**
197
+ * Combine the built-in denylist with an optional user-supplied one.
198
+ *
199
+ * - No custom regex -> use the default as-is.
200
+ * - Custom + replace = true -> use only the custom pattern (with `g`/`y`
201
+ * flags stripped so `.test()` stays stateless).
202
+ * - Custom + replace = false -> union: `(?:default)|(?:custom)`, forcing case
203
+ * insensitivity since header names are
204
+ * case-insensitive over the wire.
205
+ */
206
+ function resolveHeaderDenylist(custom, replaceDefault = false) {
207
+ if (!custom) return DEFAULT_HEADER_DENYLIST;
208
+ if (replaceDefault) return new RegExp(custom.source, custom.flags.replace(/[gy]/g, ""));
209
+ return new RegExp(`(?:${DEFAULT_HEADER_DENYLIST.source})|(?:${custom.source})`, "i");
210
+ }
211
+ /**
212
+ * Project an HTTP request `headers` object into report attributes.
213
+ *
214
+ * Behavior per header:
215
+ *
216
+ * - **Unset values** (entry exists but the value is `undefined`) are dropped
217
+ * entirely — `node:http` represents "header was not sent" this way.
218
+ * - **Names are lowercased.** OTel's attribute convention uses lowercase
219
+ * header keys, and HTTP header names are case-insensitive anyway.
220
+ * - **Allowlist gate.** If `headerAllowlist` is set, only headers whose
221
+ * lowercased name matches are emitted; everything else is silently dropped
222
+ * (NOT redacted, dropped). This is the strongest filter — useful for
223
+ * compliance scenarios where you must opt into headers explicitly.
224
+ * - **Array values** (`set-cookie` can be `string[]`) are joined with `, ` so
225
+ * the emitted value is a flat string, matching the on-the-wire shape that
226
+ * most HTTP clients render.
227
+ * - **Denylist redaction.** If the name matches `headerDenylist`, the value
228
+ * is replaced with `'[redacted]'` (the key still appears so consumers can
229
+ * tell the header was present).
230
+ *
231
+ * Output keys are `http.request.header.<lowercased-name>`, per OTel.
232
+ */
233
+ function projectHeaders(headers, options) {
234
+ const out = {};
235
+ if (!headers) return out;
236
+ for (const [rawName, rawValue] of Object.entries(headers)) {
237
+ if (rawValue === void 0) continue;
238
+ const name = rawName.toLowerCase();
239
+ if (options.headerAllowlist && !options.headerAllowlist.test(name)) continue;
240
+ const value = Array.isArray(rawValue) ? rawValue.join(", ") : rawValue;
241
+ out[`http.request.header.${name}`] = options.headerDenylist.test(name) ? "[redacted]" : value;
242
+ }
243
+ return out;
244
+ }
245
+
246
+ //#endregion
247
+ //#region src/context/process.ts
248
+ /**
249
+ * Snapshot the Node runtime + host environment at report time and project
250
+ * into OTel-style attribute keys. Cheap (just property reads + a couple of
251
+ * syscalls via `os`), so called per-report rather than cached; this keeps
252
+ * `process.uptime` honest and follows the value of `os.hostname()` if it
253
+ * changes mid-run (unlikely but free correctness).
254
+ *
255
+ * Keys are stable OTel resource attributes; the Flare backend recognizes them.
256
+ */
257
+ function collectProcessAttributes() {
258
+ return {
259
+ "process.runtime.name": "nodejs",
260
+ "process.runtime.version": process.version,
261
+ "process.pid": process.pid,
262
+ "process.uptime": process.uptime(),
263
+ "host.name": os.hostname(),
264
+ "host.arch": process.arch,
265
+ "os.type": os.type(),
266
+ "os.version": os.release()
267
+ };
268
+ }
269
+
270
+ //#endregion
271
+ //#region src/context/collectNode.ts
272
+ /**
273
+ * Build the Node-side `ContextCollector` that core's `Flare` calls on every
274
+ * report. The returned function projects two sources into OTel-style report
275
+ * attributes:
276
+ *
277
+ * 1. **Process info** — runtime version, pid, hostname, etc. Always present.
278
+ * 2. **Active request scope** — method, path/url (with query-string keys
279
+ * redacted), headers (with the denylist applied), optional body, and
280
+ * authenticated user. Present when `runWithContext(...)` is active;
281
+ * falls back to the shared scope otherwise (no request attrs emitted then).
282
+ *
283
+ * Both `provider` and `getOptions` are passed in (not captured by reference to
284
+ * concrete instances) so the closure stays decoupled from `NodeFlare`'s
285
+ * internals. `getOptions` is a getter (not a value) so that `configureNode(...)`
286
+ * changes are visible on subsequent reports without rebuilding the collector.
287
+ *
288
+ * The function returned matches `ContextCollector = (config) => Attributes`,
289
+ * which is core's interface for `Flare`'s third constructor parameter.
290
+ */
291
+ function makeNodeContextCollector(provider, getOptions) {
292
+ return (config) => {
293
+ const attrs = {
294
+ "flare.entry_point.type": "server",
295
+ ...collectProcessAttributes()
296
+ };
297
+ const scope = provider.active();
298
+ const { request } = scope;
299
+ if (request.method) attrs["http.request.method"] = request.method;
300
+ if (request.path) {
301
+ const queryStart = request.path.indexOf("?");
302
+ if (queryStart === -1) attrs["url.path"] = request.path;
303
+ else {
304
+ attrs["url.path"] = request.path.slice(0, queryStart);
305
+ const redactedQuery = redactUrlQuery$1(request.path, config.urlDenylist);
306
+ const redactedQueryStart = redactedQuery.indexOf("?");
307
+ attrs["url.query"] = redactedQuery.slice(redactedQueryStart + 1);
308
+ }
309
+ }
310
+ if (request.url) attrs["url.full"] = redactUrlQuery$1(request.url, config.urlDenylist);
311
+ const opts = getOptions();
312
+ Object.assign(attrs, projectHeaders(request.headers, opts));
313
+ if (opts.captureRequestBody) {
314
+ const contentType = findHeader(request.headers, "content-type");
315
+ const body = captureBody(request.body, contentType, opts);
316
+ if (body !== null) attrs["http.request.body"] = body;
317
+ }
318
+ if (scope.user) {
319
+ if (scope.user.id !== void 0) attrs["enduser.id"] = String(scope.user.id);
320
+ if (scope.user.email !== void 0) attrs["enduser.email"] = scope.user.email;
321
+ if (scope.user.username !== void 0) attrs["enduser.username"] = scope.user.username;
322
+ if (scope.user.ipAddress !== void 0) attrs["client.address"] = scope.user.ipAddress;
323
+ }
324
+ return attrs;
325
+ };
326
+ }
327
+
328
+ //#endregion
329
+ //#region src/process/fatal.ts
330
+ function buildFatalCallbacks(flare, getOpts, exit = process.exit.bind(process)) {
331
+ return {
332
+ async onUncaught(err, origin) {
333
+ const opts = getOpts();
334
+ if (opts.uncaughtExceptionMode === "report-and-exit") process.exitCode = 1;
335
+ const error = err instanceof Error ? err : new Error(String(err));
336
+ try {
337
+ await flare.report(error, { "process.uncaught_exception.origin": origin });
338
+ } catch {}
339
+ if (opts.uncaughtExceptionMode === "report-and-exit") {
340
+ await flare.flush(opts.shutdownTimeoutMs);
341
+ exit(1);
342
+ }
343
+ },
344
+ async onRejection(reason) {
345
+ const opts = getOpts();
346
+ if (opts.unhandledRejectionMode === "report-and-exit") process.exitCode = 1;
347
+ const error = reason instanceof Error ? reason : new Error(String(reason));
348
+ try {
349
+ await flare.report(error);
350
+ } catch {}
351
+ if (opts.unhandledRejectionMode === "report-and-exit") {
352
+ await flare.flush(opts.shutdownTimeoutMs);
353
+ exit(1);
354
+ }
355
+ }
356
+ };
357
+ }
358
+
359
+ //#endregion
360
+ //#region src/process/handlers.ts
361
+ /**
362
+ * Owns the lifecycle of the two process-level error listeners that capture
363
+ * fatal failures and feed them to Flare:
364
+ *
365
+ * - `process.on('uncaughtException', ...)`
366
+ * - `process.on('unhandledRejection', ...)`
367
+ *
368
+ * The manager has two responsibilities:
369
+ *
370
+ * 1. **Reconcile listener state with intent.** Given the current `FatalMode`
371
+ * for each event (`'off' | 'report' | 'report-and-exit'`), make the actual
372
+ * listener attachment match: attach when it should be attached but isn't,
373
+ * detach when it shouldn't be attached but is, no-op when already in the
374
+ * desired state. This is idempotent — calling `reconcile(...)` repeatedly
375
+ * with the same options is safe.
376
+ * 2. **Tear down on demand.** `detach()` removes both listeners regardless of
377
+ * intent, for tests and graceful shutdown.
378
+ *
379
+ * Why keep this separate from `NodeFlare`: the attach/detach logic is purely
380
+ * about Node `process` events and contains no Flare semantics. Isolating it
381
+ * makes it trivial to test (the test suite drives `reconcile()` directly with
382
+ * stub callbacks and asserts on `process.listeners(...)`) and keeps
383
+ * `NodeFlare` focused on report assembly + user-facing API.
384
+ */
385
+ var ProcessHandlerManager = class {
386
+ /** The currently-attached listener for `uncaughtException`, or `null`. */
387
+ uncaughtHandler = null;
388
+ /** The currently-attached listener for `unhandledRejection`, or `null`. */
389
+ rejectionHandler = null;
390
+ constructor(cbs) {
391
+ this.cbs = cbs;
392
+ }
393
+ /**
394
+ * Bring the attached listeners into agreement with the supplied modes.
395
+ * Idempotent: when current state already matches intent, this is a no-op.
396
+ */
397
+ reconcile(opts) {
398
+ this.reconcileOne("uncaughtException", opts.uncaughtExceptionMode, () => this.uncaughtHandler, (h) => {
399
+ this.uncaughtHandler = h;
400
+ }, (err, origin) => this.cbs.onUncaught(err, origin));
401
+ this.reconcileOne("unhandledRejection", opts.unhandledRejectionMode, () => this.rejectionHandler, (h) => {
402
+ this.rejectionHandler = h;
403
+ }, (reason) => this.cbs.onRejection(reason));
404
+ }
405
+ /**
406
+ * Remove both listeners regardless of current intent. Used by tests and by
407
+ * `NodeFlare.removeProcessListeners()`. Safe to call when nothing is
408
+ * attached.
409
+ */
410
+ detach() {
411
+ if (this.uncaughtHandler) {
412
+ process.off("uncaughtException", this.uncaughtHandler);
413
+ this.uncaughtHandler = null;
414
+ }
415
+ if (this.rejectionHandler) {
416
+ process.off("unhandledRejection", this.rejectionHandler);
417
+ this.rejectionHandler = null;
418
+ }
419
+ }
420
+ /**
421
+ * Generic attach/detach for one event. The `get`/`set` closures let us
422
+ * share this body between the two events while still mutating distinct
423
+ * fields (`uncaughtHandler` vs `rejectionHandler`).
424
+ *
425
+ * Truth table:
426
+ * - intent off, currently attached -> detach
427
+ * - intent off, not attached -> no-op
428
+ * - intent on, currently attached -> no-op (already correct)
429
+ * - intent on, not attached -> attach
430
+ */
431
+ reconcileOne(event, mode, get, set, impl) {
432
+ const current = get();
433
+ const wants = mode !== "off";
434
+ if (wants && !current) {
435
+ set(impl);
436
+ process.on(event, impl);
437
+ } else if (!wants && current) {
438
+ process.off(event, current);
439
+ set(null);
440
+ }
441
+ }
442
+ };
443
+
444
+ //#endregion
445
+ //#region src/scope/NodeScope.ts
446
+ var NodeScope = class extends Scope$1 {
447
+ request = {};
448
+ user = null;
449
+ };
450
+
451
+ //#endregion
452
+ //#region src/scope/AsyncLocalStorageScopeProvider.ts
453
+ /**
454
+ * `ScopeProvider` implementation that gives every in-flight request its own
455
+ * `NodeScope`, isolated from concurrent requests.
456
+ *
457
+ * Built on Node's `node:async_hooks#AsyncLocalStorage`: when code runs inside
458
+ * `als.run(scope, fn)`, every `als.getStore()` call from within `fn` (and any
459
+ * async work `fn` awaits, including timers, promises, `process.nextTick`, etc)
460
+ * returns that `scope`. Outside any `als.run` call, `getStore()` returns
461
+ * `undefined`. This is the same primitive that lets observability libraries
462
+ * propagate trace context across async boundaries without manual plumbing.
463
+ *
464
+ * Two "kinds of read" surfaced separately:
465
+ *
466
+ * - `active()` — never returns null. The internal read used by `Flare` for
467
+ * every glow, attribute set, and report. When called inside `runWithContext`,
468
+ * returns the per-request `NodeScope`. Outside, returns a shared `fallback`
469
+ * scope so glows/attributes/reports issued outside any request still have
470
+ * somewhere to land (process-level reports, startup errors, scheduled jobs).
471
+ * - `getContext()` — public debug helper. Returns `null` outside any
472
+ * `runWithContext`, so consumers can distinguish "I am inside a request" from
473
+ * "I am not". The fallback is intentionally NOT exposed here.
474
+ *
475
+ * The fallback is also a per-instance `NodeScope` so that writes from outside
476
+ * a request scope persist for subsequent outside-scope reports.
477
+ */
478
+ var AsyncLocalStorageScopeProvider = class {
479
+ als = new AsyncLocalStorage();
480
+ fallback = new NodeScope();
481
+ /**
482
+ * Internal: returns the per-request scope when inside `runWithContext`,
483
+ * or the shared fallback otherwise. Always returns a real `NodeScope`.
484
+ */
485
+ active() {
486
+ return this.als.getStore() ?? this.fallback;
487
+ }
488
+ /**
489
+ * Public: returns the per-request scope when inside `runWithContext`, or
490
+ * `null` otherwise. Useful for assertions like "am I in a request?".
491
+ */
492
+ getContext() {
493
+ return this.als.getStore() ?? null;
494
+ }
495
+ /**
496
+ * Open a fresh request scope around `fn` and run it. Every async hop
497
+ * inside `fn` (awaits, timers, promise chains) sees the same scope via
498
+ * `active()`/`getContext()`; concurrent calls each get their own.
499
+ *
500
+ * `request` is shallow-cloned so later edits to the caller's object do not
501
+ * leak into the stored scope.
502
+ */
503
+ runWithContext(request, fn) {
504
+ const scope = new NodeScope();
505
+ scope.request = { ...request };
506
+ return this.als.run(scope, fn);
507
+ }
508
+ /**
509
+ * Patch the current scope's `request` shape. When called inside
510
+ * `runWithContext`, the patch is visible to all subsequent reads from
511
+ * within the same request chain. When called outside, the patch lands on
512
+ * the fallback scope.
513
+ */
514
+ mergeContext(partial) {
515
+ const scope = this.als.getStore() ?? this.fallback;
516
+ scope.request = {
517
+ ...scope.request,
518
+ ...partial
519
+ };
520
+ }
521
+ /**
522
+ * Set the authenticated user on the current scope. Same in-scope vs
523
+ * fallback semantics as `mergeContext`.
524
+ */
525
+ setUser(user) {
526
+ const scope = this.als.getStore() ?? this.fallback;
527
+ scope.user = user;
528
+ }
529
+ };
530
+
531
+ //#endregion
532
+ //#region src/stacktrace/DiskFileReader.ts
533
+ /**
534
+ * Node `FileReader` implementation that reads source files from disk.
535
+ *
536
+ * Wired into `@flareapp/node`'s singleton so the stack-trace builder can pull
537
+ * source for each frame and render a snippet. On the server the frame's "URL"
538
+ * is usually a local path (e.g. `/app/dist/server.js`) or a `file://` URL
539
+ * (from `import.meta.url`), so we resolve straight off disk instead of going
540
+ * over the network.
541
+ *
542
+ * Safety gates:
543
+ *
544
+ * 1. **Local-path allowlist.** Only `file://` URLs and absolute filesystem
545
+ * paths (POSIX `/foo`, Windows `C:\foo` or `\\server\share\foo`) are
546
+ * accepted. HTTP URLs and relative paths return `null` immediately. We
547
+ * refuse to read anything that does not unambiguously identify a local
548
+ * file — no surprise traversal, no following http stack frames in a
549
+ * server build, no relative-path ambiguity around the current working
550
+ * directory.
551
+ * 2. **Catch-all.** Missing files, permission errors, and any other failure
552
+ * return `null`. The `read()` contract returns `null` on every failure
553
+ * path and never throws.
554
+ *
555
+ * `fileURLToPath` is used when the input is a `file://` URL so we hand
556
+ * `readFile` a real OS path. Otherwise the URL IS already a path and is
557
+ * passed through unchanged.
558
+ */
559
+ var DiskFileReader = class {
560
+ async read(url) {
561
+ if (!isLocalFileUrl(url)) return null;
562
+ try {
563
+ return await readFile(/^file:\/\//i.test(url) ? fileURLToPath(url) : url, "utf-8");
564
+ } catch {
565
+ return null;
566
+ }
567
+ }
568
+ };
569
+ /**
570
+ * Return true when `url` is something we are willing to treat as a local
571
+ * file. Matches four shapes:
572
+ *
573
+ * - `file://...` URLs (any casing of the scheme)
574
+ * - POSIX absolute paths starting with `/`
575
+ * - Windows drive-letter paths like `C:\foo` or `c:/foo`
576
+ * - Windows UNC paths starting with `\\`
577
+ *
578
+ * Anything else (relative paths, http, data, blob, etc) is rejected.
579
+ */
580
+ function isLocalFileUrl(url) {
581
+ return /^file:\/\//i.test(url) || url.startsWith("/") || /^[a-z]:[\\/]/i.test(url) || url.startsWith("\\\\");
582
+ }
583
+
584
+ //#endregion
585
+ //#region src/Flare.ts
586
+ const NODE_SDK_NAME = "@flareapp/node";
587
+ const NODE_SDK_VERSION = typeof process !== "undefined" && true ? "0.1.1" : "?";
588
+ /**
589
+ * Strip the `g` and `y` flags from a user-supplied regex.
590
+ *
591
+ * `RegExp.prototype.test()` and `.exec()` keep `lastIndex` state when either of
592
+ * these flags is set, which means reusing the same regex across many keys (as
593
+ * the header denylist and body redaction do) silently skips matches after the
594
+ * first hit. Reconstructing the regex without those flags gives stateless
595
+ * matching while preserving everything else (`i`, `m`, `s`, `u`, source).
596
+ */
597
+ function sanitizeRegex(re) {
598
+ const safeFlags = re.flags.replace(/[gy]/g, "");
599
+ return new RegExp(re.source, safeFlags);
600
+ }
601
+ const DEFAULT_NODE_OPTIONS = {
602
+ uncaughtExceptionMode: "report-and-exit",
603
+ unhandledRejectionMode: "report-and-exit",
604
+ shutdownTimeoutMs: 2e3,
605
+ headerDenylist: DEFAULT_HEADER_DENYLIST,
606
+ headerAllowlist: null,
607
+ replaceDefaultHeaderDenylist: false,
608
+ captureRequestBody: false,
609
+ bodyMaxBytes: 16384,
610
+ bodyAllowedContentTypes: DEFAULT_BODY_CONTENT_TYPES,
611
+ bodyKeyDenylist: DEFAULT_BODY_KEY_DENYLIST
612
+ };
613
+ /**
614
+ * Node.js-specific `Flare` singleton, exposed from `@flareapp/node` as `flare`.
615
+ *
616
+ * Subclasses core's `Flare` and wires the Node-only seams in its constructor:
617
+ *
618
+ * - `AsyncLocalStorageScopeProvider` so each `runWithContext(...)` callback
619
+ * gets its own `NodeScope` (glows, attributes, user, entry-point, request),
620
+ * isolated from concurrent requests.
621
+ * - `makeNodeContextCollector(...)` to project the current `NodeScope` and
622
+ * process info into report attributes (http.request.*, url.path, etc).
623
+ * - `DiskFileReader` to read source files for stack-trace snippets via
624
+ * `node:fs/promises` instead of the browser's `fetch`.
625
+ * - `ProcessHandlerManager` to attach/detach `uncaughtException` and
626
+ * `unhandledRejection` listeners based on the current `NodeOptions`.
627
+ *
628
+ * Also adds Node-only API surface on top of core: `configureNode(...)`,
629
+ * `runWithContext(...)`, `mergeContext(...)`, `setUser(...)`, `getContext()`,
630
+ * `removeProcessListeners()`. Inherited core methods (`light`, `configure`,
631
+ * `addContext`, `glow`, etc.) return `this`, so chaining keeps the
632
+ * `NodeFlare` type and `configureNode(...)` stays callable mid-chain.
633
+ */
634
+ var NodeFlare = class extends Flare$1 {
635
+ nodeOptions = { ...DEFAULT_NODE_OPTIONS };
636
+ isLit = false;
637
+ nodeScopeProvider;
638
+ handlerManager;
639
+ constructor() {
640
+ const scopeProvider = new AsyncLocalStorageScopeProvider();
641
+ const collector = makeNodeContextCollector(scopeProvider, () => this.nodeOptions);
642
+ super(new Api(), collector, new DiskFileReader(), scopeProvider);
643
+ this.nodeScopeProvider = scopeProvider;
644
+ this.setSdkInfo({
645
+ name: NODE_SDK_NAME,
646
+ version: NODE_SDK_VERSION
647
+ });
648
+ this.handlerManager = new ProcessHandlerManager(buildFatalCallbacks(this, () => this.nodeOptions));
649
+ }
650
+ /**
651
+ * Set the API key (and optional debug flag), then reconcile process
652
+ * listeners with the current `nodeOptions`. Reconcile runs on EVERY call,
653
+ * not just the first, so `light()` is the right escape hatch to re-attach
654
+ * after `removeProcessListeners()`.
655
+ */
656
+ light(key, debug) {
657
+ super.light(key, debug);
658
+ this.isLit = true;
659
+ this.handlerManager.reconcile(this.nodeOptions);
660
+ return this;
661
+ }
662
+ /**
663
+ * Merge Node-only options (fatal-handler modes, header/body redaction
664
+ * config, shutdown timeout) into the active configuration. Safe to call
665
+ * before or after `light()`:
666
+ *
667
+ * - Before `light()`: options are stored; listeners are attached when
668
+ * `light()` runs.
669
+ * - After `light()`: options are stored AND listeners are reconciled
670
+ * immediately, so flipping a mode to `'off'` detaches the handler and
671
+ * flipping it back to `'report'`/`'report-and-exit'` re-attaches.
672
+ *
673
+ * Regex options (`headerAllowlist`, `bodyAllowedContentTypes`,
674
+ * `bodyKeyDenylist`) are passed through `sanitizeRegex` to strip stateful
675
+ * `g`/`y` flags; without that, `RegExp.prototype.test` would skip matches
676
+ * across keys.
677
+ */
678
+ configureNode(partial) {
679
+ if (partial.headerDenylist !== void 0 || partial.replaceDefaultHeaderDenylist !== void 0) {
680
+ this.nodeOptions.headerDenylist = resolveHeaderDenylist(partial.headerDenylist ?? void 0, partial.replaceDefaultHeaderDenylist ?? this.nodeOptions.replaceDefaultHeaderDenylist);
681
+ this.nodeOptions.replaceDefaultHeaderDenylist = partial.replaceDefaultHeaderDenylist ?? this.nodeOptions.replaceDefaultHeaderDenylist;
682
+ }
683
+ if (partial.headerAllowlist !== void 0) this.nodeOptions.headerAllowlist = partial.headerAllowlist === null ? null : sanitizeRegex(partial.headerAllowlist);
684
+ if (partial.uncaughtExceptionMode !== void 0) this.nodeOptions.uncaughtExceptionMode = partial.uncaughtExceptionMode;
685
+ if (partial.unhandledRejectionMode !== void 0) this.nodeOptions.unhandledRejectionMode = partial.unhandledRejectionMode;
686
+ if (partial.shutdownTimeoutMs !== void 0) this.nodeOptions.shutdownTimeoutMs = partial.shutdownTimeoutMs;
687
+ if (partial.captureRequestBody !== void 0) this.nodeOptions.captureRequestBody = partial.captureRequestBody;
688
+ if (partial.bodyMaxBytes !== void 0) this.nodeOptions.bodyMaxBytes = partial.bodyMaxBytes;
689
+ if (partial.bodyAllowedContentTypes !== void 0) this.nodeOptions.bodyAllowedContentTypes = sanitizeRegex(partial.bodyAllowedContentTypes);
690
+ if (partial.bodyKeyDenylist !== void 0) this.nodeOptions.bodyKeyDenylist = sanitizeRegex(partial.bodyKeyDenylist);
691
+ if (this.isLit) this.handlerManager.reconcile(this.nodeOptions);
692
+ return this;
693
+ }
694
+ /**
695
+ * Run `fn` inside a fresh `NodeScope` carrying the supplied request
696
+ * metadata. Inside `fn` (and any async work it awaits), `flare.glow(...)`,
697
+ * `flare.addContext(...)`, `flare.setUser(...)`, and `flare.report(...)`
698
+ * see a scope that is isolated from other concurrent requests.
699
+ *
700
+ * Mirrors a typical web-framework middleware: call once per request,
701
+ * wrapping the request handler, and the SDK will attribute any error
702
+ * reported inside the chain to the right request.
703
+ */
704
+ runWithContext(request, fn) {
705
+ return this.nodeScopeProvider.runWithContext(request, fn);
706
+ }
707
+ /**
708
+ * Patch the request metadata on the active scope after `runWithContext(...)`
709
+ * has already started. Useful when fields become known partway through a
710
+ * request (e.g., the resolved absolute URL after proxy headers are parsed).
711
+ *
712
+ * Outside any `runWithContext(...)` callback, this writes to the fallback
713
+ * scope; the patch is visible to subsequent reports issued from outside a
714
+ * request scope but is NOT inherited by future `runWithContext(...)` calls.
715
+ */
716
+ mergeContext(partial) {
717
+ this.nodeScopeProvider.mergeContext(partial);
718
+ }
719
+ /**
720
+ * Attach an authenticated user to the active scope. Inside a request scope
721
+ * this is per-request; outside it lands on the fallback scope. The fields
722
+ * are projected to OTel-style keys (`enduser.id`, `enduser.email`,
723
+ * `enduser.username`, `client.address`) by the Node context collector.
724
+ */
725
+ setUser(user) {
726
+ this.nodeScopeProvider.setUser(user);
727
+ }
728
+ /**
729
+ * Returns the request scope when called inside `runWithContext(...)`, or
730
+ * `null` outside. Intentionally returns `null` (not the fallback scope)
731
+ * when no request is active, so callers can distinguish "we are inside a
732
+ * request" from "we are not". Primarily useful for debugging.
733
+ */
734
+ getContext() {
735
+ return this.nodeScopeProvider.getContext();
736
+ }
737
+ /**
738
+ * Detach the `uncaughtException` and `unhandledRejection` listeners
739
+ * without changing `nodeOptions`. Intended for tests and for graceful
740
+ * shutdown paths where you want to take ownership of process exit
741
+ * yourself.
742
+ *
743
+ * Calling `light()` afterwards re-attaches based on the current options.
744
+ */
745
+ removeProcessListeners() {
746
+ this.handlerManager.detach();
747
+ }
748
+ };
749
+
750
+ //#endregion
751
+ //#region src/index.ts
752
+ const flare = new NodeFlare();
753
+
754
+ //#endregion
755
+ export { DEFAULT_URL_DENYLIST, Flare, GlobalScopeProvider, NodeFlare, NodeScope, NullFileReader, Scope, convertToError, flare, redactUrlQuery, resolveDenylist };