@flareapp/node 0.7.0 → 0.9.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.mjs CHANGED
@@ -1,57 +1,27 @@
1
- import { Api, DEFAULT_URL_DENYLIST, DEFAULT_URL_DENYLIST as DEFAULT_URL_DENYLIST$1, Flare, Flare as Flare$1, GlobalScopeProvider, Logger, NullFileReader, Scope, Scope as Scope$1, convertToError, redactUrlQuery, redactUrlQuery as redactUrlQuery$1, resolveDenylist } from "@flareapp/core";
1
+ import { Api, DEFAULT_URL_DENYLIST, DEFAULT_URL_DENYLIST as DEFAULT_URL_DENYLIST$1, Flare, Flare as Flare$1, FrameworkName, GlobalScopeProvider, Logger, NullFileReader, Scope, Scope as Scope$1, convertToError, redactUrlQuery, redactUrlQuery as redactUrlQuery$1, resolveDenylist, urlAttributes } from "@flareapp/core";
2
2
  import os from "node:os";
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { fileURLToPath } from "node:url";
6
6
 
7
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
- */
8
+ /** `^` plus `\b` accepts a `; charset=utf-8` suffix while still rejecting `...-urlencoded-foo`. */
16
9
  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
- */
10
+ /** Reuses core's URL denylist, so credentials are caught by the same regex everywhere. */
22
11
  const DEFAULT_BODY_KEY_DENYLIST = DEFAULT_URL_DENYLIST$1;
23
12
  /**
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).
13
+ * Normalize, redact, serialize, and size-cap a request body for a Flare report. Returns the JSON string,
14
+ * or `null` when the body should not be reported (unknown shape, content-type miss, serialization fail).
15
+ *
16
+ * Accepts four runtime shapes:
17
+ * - `string`: must match `contentType` per `bodyAllowedContentTypes`, else dropped.
18
+ * - `Buffer`: decoded UTF-8 then treated as a string.
19
+ * - `URLSearchParams`: flattened to a plain record. No content-type gate (shape is unambiguous).
20
+ * - Other `object`/array: used as-is, no gate. The common middleware path (Express/Fastify `req.body`).
21
+ * Anything else returns `null`.
22
+ *
23
+ * Then: redact (denylisted keys become `'[redacted]'`, cycles become `'[Circular]'`), `JSON.stringify`
24
+ * (drop body if it throws on BigInt/Symbol/etc), and truncate to `bodyMaxBytes` including the suffix.
55
25
  */
56
26
  function captureBody(body, contentType, opts) {
57
27
  if (body === void 0 || body === null) return null;
@@ -78,15 +48,7 @@ function captureBody(body, contentType, opts) {
78
48
  }
79
49
  const TRUNCATION_SUFFIX = "…[truncated]";
80
50
  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
- */
51
+ /** Walks back over continuation bytes (`10xxxxxx`) to a codepoint boundary, so the result still decodes. */
90
52
  function truncateToByteLimit(serialized, maxBytes) {
91
53
  const buf = Buffer.from(serialized, "utf8");
92
54
  if (buf.length <= maxBytes) return serialized;
@@ -100,28 +62,15 @@ function truncateToByteLimit(serialized, maxBytes) {
100
62
  while (cut > 0 && (buf[cut] & 192) === 128) cut--;
101
63
  return buf.subarray(0, cut).toString("utf8") + TRUNCATION_SUFFIX;
102
64
  }
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
- */
65
+ /** Normalizes to the bare media type first, so a strict custom regex like `/^application\/json$/` still
66
+ * matches `application/json; charset=utf-8`. */
110
67
  function matchesContentType(ct, allowed) {
111
68
  if (!ct) return false;
112
69
  const mediaType = ct.split(";")[0].trim().toLowerCase();
113
70
  if (!mediaType) return false;
114
71
  return allowed.test(mediaType);
115
72
  }
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
- */
73
+ /** Returns `undefined` rather than `null` on failure, since `null` is itself a valid JSON value. */
125
74
  function parseString(text, contentType) {
126
75
  if (contentType && /x-www-form-urlencoded/i.test(contentType)) return Object.fromEntries(new URLSearchParams(text).entries());
127
76
  try {
@@ -130,29 +79,13 @@ function parseString(text, contentType) {
130
79
  return;
131
80
  }
132
81
  }
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
- */
82
+ /** Excludes class instances, streams, FormData, ArrayBuffer views, Buffer and URLSearchParams. */
139
83
  function isPlainObject(value) {
140
84
  if (value === null || typeof value !== "object") return false;
141
85
  const proto = Object.getPrototypeOf(value);
142
86
  return proto === null || proto === Object.prototype;
143
87
  }
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
- */
88
+ /** `seen` is a parameter rather than a closure to avoid allocating a WeakSet per recursion. */
156
89
  function redact(value, denylist, seen = /* @__PURE__ */ new WeakSet()) {
157
90
  if (value === null || typeof value !== "object") return value;
158
91
  if (seen.has(value)) return "[Circular]";
@@ -165,14 +98,9 @@ function redact(value, denylist, seen = /* @__PURE__ */ new WeakSet()) {
165
98
 
166
99
  //#endregion
167
100
  //#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
- */
101
+ /** Case-insensitive. An array value collapses to its first element; callers here want a single value. */
174
102
  function findHeader(headers, name) {
175
- if (!headers) return void 0;
103
+ if (!headers) return;
176
104
  const target = name.toLowerCase();
177
105
  for (const [key, value] of Object.entries(headers)) {
178
106
  if (key.toLowerCase() !== target) continue;
@@ -180,55 +108,19 @@ function findHeader(headers, name) {
180
108
  return Array.isArray(value) ? value[0] : value;
181
109
  }
182
110
  }
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
- */
111
+ /** The `^` and `$` matter: without them, `cookie` would also match a header like `X-Some-Cookie-Hint`. */
195
112
  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
- */
113
+ /** `g`/`y` are stripped from a custom pattern: those carry lastIndex, which makes `.test()` stateful. */
206
114
  function resolveHeaderDenylist(custom, replaceDefault = false) {
207
115
  if (!custom) return DEFAULT_HEADER_DENYLIST;
208
116
  if (replaceDefault) return new RegExp(custom.source, custom.flags.replace(/[gy]/g, ""));
209
117
  return new RegExp(`(?:${DEFAULT_HEADER_DENYLIST.source})|(?:${custom.source})`, "i");
210
118
  }
211
119
  /**
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.
120
+ * Turns headers into `http.request.header.<name>` attributes. The two lists differ on purpose: an
121
+ * allowlist drops a header entirely, for apps that may only send named headers, while the denylist
122
+ * keeps the name and replaces the value, so you can still see the header was there. `undefined` is
123
+ * how `node:http` says "not sent", so those are dropped.
232
124
  */
233
125
  function projectHeaders(headers, options) {
234
126
  const out = {};
@@ -246,13 +138,9 @@ function projectHeaders(headers, options) {
246
138
  //#endregion
247
139
  //#region src/context/process.ts
248
140
  /**
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.
141
+ * Snapshot the Node runtime + host environment at report time as OTel resource attributes. Called
142
+ * per-report rather than cached so `process.uptime()` stays accurate and `os.hostname()` tracks mid-run
143
+ * changes; cheap enough (property reads plus a couple `os` syscalls).
256
144
  */
257
145
  function collectProcessAttributes() {
258
146
  return {
@@ -270,29 +158,16 @@ function collectProcessAttributes() {
270
158
  //#endregion
271
159
  //#region src/context/collectNode.ts
272
160
  /**
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), and optional body. Present
280
- * when `runWithContext(...)` is active; falls back to the shared scope
281
- * otherwise (no request attrs emitted then). User identity is no longer
282
- * projected here: `Flare.setUser` writes it straight to `pendingAttributes`.
283
- *
284
- * Both `provider` and `getOptions` are passed in (not captured by reference to
285
- * concrete instances) so the closure stays decoupled from `NodeFlare`'s
286
- * internals. `getOptions` is a getter (not a value) so that `configureNode(...)`
287
- * changes are visible on subsequent reports without rebuilding the collector.
161
+ * Turns process info (always) and the active request scope (only inside `runWithContext`) into
162
+ * OTel-style attributes. User identity is not handled here: `Flare.setUser` writes straight to
163
+ * `pendingAttributes`.
288
164
  *
289
- * The function returned matches `ContextCollector = (config) => Attributes`,
290
- * which is core's interface for `Flare`'s third constructor parameter.
165
+ * `getOptions` is a getter so `configureNode(...)` shows up on later reports without rebuilding this.
291
166
  */
292
167
  function makeNodeContextCollector(provider, getOptions) {
293
168
  return (config) => {
294
169
  const attrs = {
295
- "flare.entry_point.type": "server",
170
+ "flare.entry_point.type": "web",
296
171
  ...collectProcessAttributes()
297
172
  };
298
173
  const { request } = provider.active();
@@ -307,7 +182,11 @@ function makeNodeContextCollector(provider, getOptions) {
307
182
  attrs["url.query"] = redactedQuery.slice(redactedQueryStart + 1);
308
183
  }
309
184
  }
310
- if (request.url) attrs["url.full"] = redactUrlQuery$1(request.url, config.urlDenylist);
185
+ if (request.url) {
186
+ const fromUrl = urlAttributes(request.url, config.urlDenylist);
187
+ attrs["url.full"] = fromUrl["url.full"];
188
+ if (fromUrl["url.scheme"] !== void 0) attrs["url.scheme"] = fromUrl["url.scheme"];
189
+ }
311
190
  const opts = getOptions();
312
191
  Object.assign(attrs, projectHeaders(request.headers, opts));
313
192
  if (opts.captureRequestBody) {
@@ -361,41 +240,22 @@ function buildFatalCallbacks(flare, getOpts, exit = process.exit.bind(process))
361
240
  //#endregion
362
241
  //#region src/process/handlers.ts
363
242
  /**
364
- * Owns the lifecycle of the two process-level error listeners that capture
365
- * fatal failures and feed them to Flare:
366
- *
367
- * - `process.on('uncaughtException', ...)`
368
- * - `process.on('unhandledRejection', ...)`
369
- *
370
- * The manager has two responsibilities:
371
- *
372
- * 1. **Reconcile listener state with intent.** Given the current `FatalMode`
373
- * for each event (`'off' | 'report' | 'report-and-exit'`), make the actual
374
- * listener attachment match: attach when it should be attached but isn't,
375
- * detach when it shouldn't be attached but is, no-op when already in the
376
- * desired state. This is idempotent — calling `reconcile(...)` repeatedly
377
- * with the same options is safe.
378
- * 2. **Tear down on demand.** `detach()` removes both listeners regardless of
379
- * intent, for tests and graceful shutdown.
380
- *
381
- * Why keep this separate from `NodeFlare`: the attach/detach logic is purely
382
- * about Node `process` events and contains no Flare semantics. Isolating it
383
- * makes it trivial to test (the test suite drives `reconcile()` directly with
384
- * stub callbacks and asserts on `process.listeners(...)`) and keeps
385
- * `NodeFlare` focused on report assembly + user-facing API.
243
+ * Owns the `uncaughtException` and `unhandledRejection` listeners that feed fatal failures to Flare.
244
+ * Separate from `NodeFlare` because it is pure `process`-event plumbing with no Flare semantics, which
245
+ * keeps it trivially testable.
246
+ *
247
+ * Deliberately kept separate from electron's ProcessHandlerManager, not a shared module. @flareapp/electron
248
+ * does not depend on @flareapp/node, and the only package both import is @flareapp/core, which ships in
249
+ * every browser bundle and touches `process` only behind a typeof guard. A shared manager belongs in a new
250
+ * package, not in core, and one method does not pay for one.
386
251
  */
387
252
  var ProcessHandlerManager = class {
388
- /** The currently-attached listener for `uncaughtException`, or `null`. */
389
253
  uncaughtHandler = null;
390
- /** The currently-attached listener for `unhandledRejection`, or `null`. */
391
254
  rejectionHandler = null;
392
255
  constructor(cbs) {
393
256
  this.cbs = cbs;
394
257
  }
395
- /**
396
- * Bring the attached listeners into agreement with the supplied modes.
397
- * Idempotent: when current state already matches intent, this is a no-op.
398
- */
258
+ /** Idempotent: a no-op when the attached listeners already match the supplied modes. */
399
259
  reconcile(opts) {
400
260
  this.reconcileOne("uncaughtException", opts.uncaughtExceptionMode, () => this.uncaughtHandler, (h) => {
401
261
  this.uncaughtHandler = h;
@@ -404,11 +264,7 @@ var ProcessHandlerManager = class {
404
264
  this.rejectionHandler = h;
405
265
  }, (reason) => this.cbs.onRejection(reason));
406
266
  }
407
- /**
408
- * Remove both listeners regardless of current intent. Used by tests and by
409
- * `NodeFlare.removeProcessListeners()`. Safe to call when nothing is
410
- * attached.
411
- */
267
+ /** Remove both listeners regardless of intent. Safe when nothing is attached. */
412
268
  detach() {
413
269
  if (this.uncaughtHandler) {
414
270
  process.off("uncaughtException", this.uncaughtHandler);
@@ -420,26 +276,21 @@ var ProcessHandlerManager = class {
420
276
  }
421
277
  }
422
278
  /**
423
- * Generic attach/detach for one event. The `get`/`set` closures let us
424
- * share this body between the two events while still mutating distinct
425
- * fields (`uncaughtHandler` vs `rejectionHandler`).
426
- *
427
- * Truth table:
428
- * - intent off, currently attached -> detach
429
- * - intent off, not attached -> no-op
430
- * - intent on, currently attached -> no-op (already correct)
431
- * - intent on, not attached -> attach
279
+ * Generic attach/detach for one event. The `get`/`set` closures share this body across both events
280
+ * while mutating distinct fields (`uncaughtHandler` vs `rejectionHandler`). Attaches when wanted and
281
+ * absent, detaches when unwanted and present, else no-op.
432
282
  */
433
283
  reconcileOne(event, mode, get, set, impl) {
434
284
  const current = get();
435
285
  const wants = mode !== "off";
436
- if (wants && !current) {
437
- set(impl);
438
- process.on(event, impl);
439
- } else if (!wants && current) {
286
+ if (wants === (current !== null)) return;
287
+ if (!wants) {
440
288
  process.off(event, current);
441
289
  set(null);
290
+ return;
442
291
  }
292
+ set(impl);
293
+ process.on(event, impl);
443
294
  }
444
295
  };
445
296
 
@@ -452,66 +303,29 @@ var NodeScope = class extends Scope$1 {
452
303
  //#endregion
453
304
  //#region src/scope/AsyncLocalStorageScopeProvider.ts
454
305
  /**
455
- * `ScopeProvider` implementation that gives every in-flight request its own
456
- * `NodeScope`, isolated from concurrent requests.
457
- *
458
- * Built on Node's `node:async_hooks#AsyncLocalStorage`: when code runs inside
459
- * `als.run(scope, fn)`, every `als.getStore()` call from within `fn` (and any
460
- * async work `fn` awaits, including timers, promises, `process.nextTick`, etc)
461
- * returns that `scope`. Outside any `als.run` call, `getStore()` returns
462
- * `undefined`. This is the same primitive that lets observability libraries
463
- * propagate trace context across async boundaries without manual plumbing.
306
+ * Gives every in-flight request its own `NodeScope`, isolated from concurrent requests.
464
307
  *
465
- * Two "kinds of read" surfaced separately:
466
- *
467
- * - `active()` — never returns null. The internal read used by `Flare` for
468
- * every glow, attribute set, and report. When called inside `runWithContext`,
469
- * returns the per-request `NodeScope`. Outside, returns a shared `fallback`
470
- * scope so glows/attributes/reports issued outside any request still have
471
- * somewhere to land (process-level reports, startup errors, scheduled jobs).
472
- * - `getContext()` — public debug helper. Returns `null` outside any
473
- * `runWithContext`, so consumers can distinguish "I am inside a request" from
474
- * "I am not". The fallback is intentionally NOT exposed here.
475
- *
476
- * The fallback is also a per-instance `NodeScope` so that writes from outside
477
- * a request scope persist for subsequent outside-scope reports.
308
+ * The `fallback` scope catches work outside any request (process-level reports, startup errors,
309
+ * scheduled jobs). It is per-instance rather than fresh per read, so outside-scope writes persist for a
310
+ * later outside report.
478
311
  */
479
312
  var AsyncLocalStorageScopeProvider = class {
480
313
  als = new AsyncLocalStorage();
481
314
  fallback = new NodeScope();
482
- /**
483
- * Internal: returns the per-request scope when inside `runWithContext`,
484
- * or the shared fallback otherwise. Always returns a real `NodeScope`.
485
- */
315
+ /** Never null: falls back to the shared scope outside `runWithContext`. */
486
316
  active() {
487
317
  return this.als.getStore() ?? this.fallback;
488
318
  }
489
- /**
490
- * Public: returns the per-request scope when inside `runWithContext`, or
491
- * `null` otherwise. Useful for assertions like "am I in a request?".
492
- */
319
+ /** Null outside `runWithContext`, so callers can tell "inside a request" from "not". */
493
320
  getContext() {
494
321
  return this.als.getStore() ?? null;
495
322
  }
496
- /**
497
- * Open a fresh request scope around `fn` and run it. Every async hop
498
- * inside `fn` (awaits, timers, promise chains) sees the same scope via
499
- * `active()`/`getContext()`; concurrent calls each get their own.
500
- *
501
- * `request` is shallow-cloned so later edits to the caller's object do not
502
- * leak into the stored scope.
503
- */
323
+ /** `request` is shallow-cloned so later edits to the caller's object do not leak into the scope. */
504
324
  runWithContext(request, fn) {
505
325
  const scope = new NodeScope();
506
326
  scope.request = { ...request };
507
327
  return this.als.run(scope, fn);
508
328
  }
509
- /**
510
- * Patch the current scope's `request` shape. When called inside
511
- * `runWithContext`, the patch is visible to all subsequent reads from
512
- * within the same request chain. When called outside, the patch lands on
513
- * the fallback scope.
514
- */
515
329
  mergeContext(partial) {
516
330
  const scope = this.als.getStore() ?? this.fallback;
517
331
  scope.request = {
@@ -524,30 +338,9 @@ var AsyncLocalStorageScopeProvider = class {
524
338
  //#endregion
525
339
  //#region src/stacktrace/DiskFileReader.ts
526
340
  /**
527
- * Node `FileReader` implementation that reads source files from disk.
528
- *
529
- * Wired into `@flareapp/node`'s singleton so the stack-trace builder can pull
530
- * source for each frame and render a snippet. On the server the frame's "URL"
531
- * is usually a local path (e.g. `/app/dist/server.js`) or a `file://` URL
532
- * (from `import.meta.url`), so we resolve straight off disk instead of going
533
- * over the network.
534
- *
535
- * Safety gates:
536
- *
537
- * 1. **Local-path allowlist.** Only `file://` URLs and absolute filesystem
538
- * paths (POSIX `/foo`, Windows `C:\foo` or `\\server\share\foo`) are
539
- * accepted. HTTP URLs and relative paths return `null` immediately. We
540
- * refuse to read anything that does not unambiguously identify a local
541
- * file — no surprise traversal, no following http stack frames in a
542
- * server build, no relative-path ambiguity around the current working
543
- * directory.
544
- * 2. **Catch-all.** Missing files, permission errors, and any other failure
545
- * return `null`. The `read()` contract returns `null` on every failure
546
- * path and never throws.
547
- *
548
- * `fileURLToPath` is used when the input is a `file://` URL so we hand
549
- * `readFile` a real OS path. Otherwise the URL IS already a path and is
550
- * passed through unchanged.
341
+ * Reads snippet sources off disk: on the server a frame's "URL" is usually a local path or a `file://`
342
+ * URL from `import.meta.url`. Only unambiguously local paths are read, which rules out traversal,
343
+ * following an http frame in a server build, and cwd-relative ambiguity. Never throws.
551
344
  */
552
345
  var DiskFileReader = class {
553
346
  async read(url) {
@@ -559,17 +352,7 @@ var DiskFileReader = class {
559
352
  }
560
353
  }
561
354
  };
562
- /**
563
- * Return true when `url` is something we are willing to treat as a local
564
- * file. Matches four shapes:
565
- *
566
- * - `file://...` URLs (any casing of the scheme)
567
- * - POSIX absolute paths starting with `/`
568
- * - Windows drive-letter paths like `C:\foo` or `c:/foo`
569
- * - Windows UNC paths starting with `\\`
570
- *
571
- * Anything else (relative paths, http, data, blob, etc) is rejected.
572
- */
355
+ /** `file://` (any casing), POSIX absolute, Windows drive-letter (`C:\foo`), Windows UNC (`\\`). */
573
356
  function isLocalFileUrl(url) {
574
357
  return /^file:\/\//i.test(url) || url.startsWith("/") || /^[a-z]:[\\/]/i.test(url) || url.startsWith("\\\\");
575
358
  }
@@ -577,16 +360,9 @@ function isLocalFileUrl(url) {
577
360
  //#endregion
578
361
  //#region src/Flare.ts
579
362
  const NODE_SDK_NAME = "@flareapp/node";
580
- const NODE_SDK_VERSION = typeof process !== "undefined" && true ? "0.7.0" : "?";
581
- /**
582
- * Strip the `g` and `y` flags from a user-supplied regex.
583
- *
584
- * `RegExp.prototype.test()` and `.exec()` keep `lastIndex` state when either of
585
- * these flags is set, which means reusing the same regex across many keys (as
586
- * the header denylist and body redaction do) silently skips matches after the
587
- * first hit. Reconstructing the regex without those flags gives stateless
588
- * matching while preserving everything else (`i`, `m`, `s`, `u`, source).
589
- */
363
+ const NODE_SDK_VERSION = typeof process !== "undefined" && true ? "0.9.0" : "?";
364
+ /** `g`/`y` make `.test()` keep `lastIndex` state, so reusing the regex across keys skips every other
365
+ * match. All other flags are preserved. */
590
366
  function sanitizeRegex(re) {
591
367
  const safeFlags = re.flags.replace(/[gy]/g, "");
592
368
  return new RegExp(re.source, safeFlags);
@@ -607,22 +383,15 @@ const DEFAULT_NODE_OPTIONS = {
607
383
  * Node.js-specific `Flare` singleton, exposed from `@flareapp/node` as `flare`.
608
384
  *
609
385
  * Subclasses core's `Flare` and wires the Node-only seams in its constructor:
610
- *
611
- * - `AsyncLocalStorageScopeProvider` so each `runWithContext(...)` callback
612
- * gets its own `NodeScope` (glows, attributes, entry-point, request),
386
+ * - `AsyncLocalStorageScopeProvider` so each `runWithContext(...)` callback gets its own `NodeScope`,
613
387
  * isolated from concurrent requests.
614
- * - `makeNodeContextCollector(...)` to project the current `NodeScope` and
615
- * process info into report attributes (http.request.*, url.path, etc).
616
- * - `DiskFileReader` to read source files for stack-trace snippets via
617
- * `node:fs/promises` instead of the browser's `fetch`.
618
- * - `ProcessHandlerManager` to attach/detach `uncaughtException` and
619
- * `unhandledRejection` listeners based on the current `NodeOptions`.
388
+ * - `makeNodeContextCollector(...)` turns the current `NodeScope` + process info into report attributes.
389
+ * - `DiskFileReader` reads source for stack-trace snippets via `node:fs/promises`, not `fetch`.
390
+ * - `ProcessHandlerManager` attaches/detaches the fatal process listeners per `NodeOptions`.
620
391
  *
621
- * Also adds Node-only API surface on top of core: `configureNode(...)`,
622
- * `runWithContext(...)`, `mergeContext(...)`, `getContext()`,
623
- * `removeProcessListeners()`. Inherited core methods (`light`, `configure`,
624
- * `addContext`, `glow`, etc.) return `this`, so chaining keeps the
625
- * `NodeFlare` type and `configureNode(...)` stays callable mid-chain.
392
+ * Adds Node-only API on top of core: `configureNode`, `runWithContext`, `mergeContext`, `getContext`,
393
+ * `removeProcessListeners`. Inherited core methods return `this`, so chaining keeps the `NodeFlare`
394
+ * type and `configureNode(...)` stays callable mid-chain.
626
395
  */
627
396
  var NodeFlare = class extends Flare$1 {
628
397
  nodeOptions = { ...DEFAULT_NODE_OPTIONS };
@@ -638,14 +407,10 @@ var NodeFlare = class extends Flare$1 {
638
407
  name: NODE_SDK_NAME,
639
408
  version: NODE_SDK_VERSION
640
409
  });
410
+ this.setFramework({ name: FrameworkName.Node });
641
411
  this.handlerManager = new ProcessHandlerManager(buildFatalCallbacks(this, () => this.nodeOptions));
642
412
  }
643
- /**
644
- * Set the API key (and optional debug flag), then reconcile process
645
- * listeners with the current `nodeOptions`. Reconcile runs on EVERY call,
646
- * not just the first, so `light()` is the right escape hatch to re-attach
647
- * after `removeProcessListeners()`.
648
- */
413
+ /** Reconcile runs on every call, so `light()` re-attaches after `removeProcessListeners()`. */
649
414
  light(key, debug) {
650
415
  super.light(key, debug);
651
416
  this.isLit = true;
@@ -653,20 +418,8 @@ var NodeFlare = class extends Flare$1 {
653
418
  return this;
654
419
  }
655
420
  /**
656
- * Merge Node-only options (fatal-handler modes, header/body redaction
657
- * config, shutdown timeout) into the active configuration. Safe to call
658
- * before or after `light()`:
659
- *
660
- * - Before `light()`: options are stored; listeners are attached when
661
- * `light()` runs.
662
- * - After `light()`: options are stored AND listeners are reconciled
663
- * immediately, so flipping a mode to `'off'` detaches the handler and
664
- * flipping it back to `'report'`/`'report-and-exit'` re-attaches.
665
- *
666
- * Regex options (`headerAllowlist`, `bodyAllowedContentTypes`,
667
- * `bodyKeyDenylist`) are passed through `sanitizeRegex` to strip stateful
668
- * `g`/`y` flags; without that, `RegExp.prototype.test` would skip matches
669
- * across keys.
421
+ * Safe before or after `light()`. Before, the listeners attach on `light()`; after, they reconcile
422
+ * immediately, so flipping a mode to `'off'` detaches and flipping it back re-attaches.
670
423
  */
671
424
  configureNode(partial) {
672
425
  if (partial.headerDenylist !== void 0 || partial.replaceDefaultHeaderDenylist !== void 0) {
@@ -685,47 +438,25 @@ var NodeFlare = class extends Flare$1 {
685
438
  return this;
686
439
  }
687
440
  /**
688
- * Run `fn` inside a fresh `NodeScope` carrying the supplied request
689
- * metadata. Inside `fn` (and any async work it awaits), `flare.glow(...)`,
690
- * `flare.addContext(...)`, `flare.setUser(...)`, and `flare.report(...)`
691
- * see a scope that is isolated from other concurrent requests.
692
- *
693
- * Mirrors a typical web-framework middleware: call once per request,
694
- * wrapping the request handler, and the SDK will attribute any error
695
- * reported inside the chain to the right request.
441
+ * Use as web-framework middleware, once per request around the handler. Inside `fn` and any async
442
+ * work it awaits, reports are attributed to that request rather than to a concurrent one.
696
443
  */
697
444
  runWithContext(request, fn) {
698
445
  return this.nodeScopeProvider.runWithContext(request, fn);
699
446
  }
700
447
  /**
701
- * Patch the request metadata on the active scope after `runWithContext(...)`
702
- * has already started. Useful when fields become known partway through a
703
- * request (e.g., the resolved absolute URL after proxy headers are parsed).
704
- *
705
- * Outside any `runWithContext(...)` callback, this writes to the fallback
706
- * scope; the patch is visible to subsequent reports issued from outside a
707
- * request scope but is NOT inherited by future `runWithContext(...)` calls.
448
+ * For fields that only become known partway through a request, such as the absolute URL once proxy
449
+ * headers are parsed. Outside `runWithContext(...)` this writes to the fallback scope, which future
450
+ * `runWithContext(...)` calls do not inherit.
708
451
  */
709
452
  mergeContext(partial) {
710
453
  this.nodeScopeProvider.mergeContext(partial);
711
454
  }
712
- /**
713
- * Returns the request scope when called inside `runWithContext(...)`, or
714
- * `null` outside. Intentionally returns `null` (not the fallback scope)
715
- * when no request is active, so callers can distinguish "we are inside a
716
- * request" from "we are not". Primarily useful for debugging.
717
- */
455
+ /** Null (not the fallback scope) outside a request, so callers can tell the two apart. Debugging aid. */
718
456
  getContext() {
719
457
  return this.nodeScopeProvider.getContext();
720
458
  }
721
- /**
722
- * Detach the `uncaughtException` and `unhandledRejection` listeners
723
- * without changing `nodeOptions`. Intended for tests and for graceful
724
- * shutdown paths where you want to take ownership of process exit
725
- * yourself.
726
- *
727
- * Calling `light()` afterwards re-attaches based on the current options.
728
- */
459
+ /** Leaves `nodeOptions` alone, so a later `light()` re-attaches. For tests and graceful shutdown. */
729
460
  removeProcessListeners() {
730
461
  this.handlerManager.detach();
731
462
  }