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