@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.cjs ADDED
@@ -0,0 +1,834 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
2
+ //#region \0rolldown/runtime.js
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __copyProps = (to, from, except, desc) => {
10
+ if (from && typeof from === "object" || typeof from === "function") {
11
+ for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
12
+ key = keys[i];
13
+ if (!__hasOwnProp.call(to, key) && key !== except) {
14
+ __defProp(to, key, {
15
+ get: ((k) => from[k]).bind(null, key),
16
+ enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
17
+ });
18
+ }
19
+ }
20
+ }
21
+ return to;
22
+ };
23
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
24
+ value: mod,
25
+ enumerable: true
26
+ }) : target, mod));
27
+
28
+ //#endregion
29
+ let _flareapp_core = require("@flareapp/core");
30
+ let node_os = require("node:os");
31
+ node_os = __toESM(node_os);
32
+ let node_async_hooks = require("node:async_hooks");
33
+ let node_fs_promises = require("node:fs/promises");
34
+ let node_url = require("node:url");
35
+
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
+ */
45
+ 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
+ */
51
+ const DEFAULT_BODY_KEY_DENYLIST = _flareapp_core.DEFAULT_URL_DENYLIST;
52
+ /**
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).
84
+ */
85
+ function captureBody(body, contentType, opts) {
86
+ if (body === void 0 || body === null) return null;
87
+ let parsed;
88
+ if (typeof body === "string") {
89
+ if (!matchesContentType(contentType, opts.bodyAllowedContentTypes)) return null;
90
+ parsed = parseString(body, contentType);
91
+ if (parsed === void 0) return null;
92
+ } else if (Buffer.isBuffer(body)) {
93
+ if (!matchesContentType(contentType, opts.bodyAllowedContentTypes)) return null;
94
+ parsed = parseString(body.toString("utf8"), contentType);
95
+ if (parsed === void 0) return null;
96
+ } else if (body instanceof URLSearchParams) parsed = Object.fromEntries(body.entries());
97
+ else if (Array.isArray(body) || isPlainObject(body)) parsed = body;
98
+ else return null;
99
+ const redacted = redact(parsed, opts.bodyKeyDenylist);
100
+ let serialized;
101
+ try {
102
+ serialized = JSON.stringify(redacted);
103
+ } catch {
104
+ return null;
105
+ }
106
+ return truncateToByteLimit(serialized, opts.bodyMaxBytes);
107
+ }
108
+ const TRUNCATION_SUFFIX = "…[truncated]";
109
+ 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
+ */
119
+ function truncateToByteLimit(serialized, maxBytes) {
120
+ const buf = Buffer.from(serialized, "utf8");
121
+ if (buf.length <= maxBytes) return serialized;
122
+ if (maxBytes <= TRUNCATION_SUFFIX_BYTES) {
123
+ const suffixBuf = Buffer.from(TRUNCATION_SUFFIX, "utf8");
124
+ let cut = maxBytes;
125
+ while (cut > 0 && (suffixBuf[cut] & 192) === 128) cut--;
126
+ return suffixBuf.subarray(0, cut).toString("utf8");
127
+ }
128
+ let cut = maxBytes - TRUNCATION_SUFFIX_BYTES;
129
+ while (cut > 0 && (buf[cut] & 192) === 128) cut--;
130
+ return buf.subarray(0, cut).toString("utf8") + TRUNCATION_SUFFIX;
131
+ }
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
+ */
139
+ function matchesContentType(ct, allowed) {
140
+ if (!ct) return false;
141
+ const mediaType = ct.split(";")[0].trim().toLowerCase();
142
+ if (!mediaType) return false;
143
+ return allowed.test(mediaType);
144
+ }
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
+ */
154
+ function parseString(text, contentType) {
155
+ if (contentType && /x-www-form-urlencoded/i.test(contentType)) return Object.fromEntries(new URLSearchParams(text).entries());
156
+ try {
157
+ return JSON.parse(text);
158
+ } catch {
159
+ return;
160
+ }
161
+ }
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
+ */
168
+ function isPlainObject(value) {
169
+ if (value === null || typeof value !== "object") return false;
170
+ const proto = Object.getPrototypeOf(value);
171
+ return proto === null || proto === Object.prototype;
172
+ }
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
+ */
185
+ function redact(value, denylist, seen = /* @__PURE__ */ new WeakSet()) {
186
+ if (value === null || typeof value !== "object") return value;
187
+ if (seen.has(value)) return "[Circular]";
188
+ seen.add(value);
189
+ if (Array.isArray(value)) return value.map((v) => redact(v, denylist, seen));
190
+ const out = {};
191
+ for (const [k, v] of Object.entries(value)) out[k] = denylist.test(k) ? "[redacted]" : redact(v, denylist, seen);
192
+ return out;
193
+ }
194
+
195
+ //#endregion
196
+ //#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
+ */
203
+ function findHeader(headers, name) {
204
+ if (!headers) return void 0;
205
+ const target = name.toLowerCase();
206
+ for (const [key, value] of Object.entries(headers)) {
207
+ if (key.toLowerCase() !== target) continue;
208
+ if (value === void 0) continue;
209
+ return Array.isArray(value) ? value[0] : value;
210
+ }
211
+ }
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
+ */
224
+ 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
+ */
235
+ function resolveHeaderDenylist(custom, replaceDefault = false) {
236
+ if (!custom) return DEFAULT_HEADER_DENYLIST;
237
+ if (replaceDefault) return new RegExp(custom.source, custom.flags.replace(/[gy]/g, ""));
238
+ return new RegExp(`(?:${DEFAULT_HEADER_DENYLIST.source})|(?:${custom.source})`, "i");
239
+ }
240
+ /**
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.
261
+ */
262
+ function projectHeaders(headers, options) {
263
+ const out = {};
264
+ if (!headers) return out;
265
+ for (const [rawName, rawValue] of Object.entries(headers)) {
266
+ if (rawValue === void 0) continue;
267
+ const name = rawName.toLowerCase();
268
+ if (options.headerAllowlist && !options.headerAllowlist.test(name)) continue;
269
+ const value = Array.isArray(rawValue) ? rawValue.join(", ") : rawValue;
270
+ out[`http.request.header.${name}`] = options.headerDenylist.test(name) ? "[redacted]" : value;
271
+ }
272
+ return out;
273
+ }
274
+
275
+ //#endregion
276
+ //#region src/context/process.ts
277
+ /**
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.
285
+ */
286
+ function collectProcessAttributes() {
287
+ return {
288
+ "process.runtime.name": "nodejs",
289
+ "process.runtime.version": process.version,
290
+ "process.pid": process.pid,
291
+ "process.uptime": process.uptime(),
292
+ "host.name": node_os.default.hostname(),
293
+ "host.arch": process.arch,
294
+ "os.type": node_os.default.type(),
295
+ "os.version": node_os.default.release()
296
+ };
297
+ }
298
+
299
+ //#endregion
300
+ //#region src/context/collectNode.ts
301
+ /**
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), optional body, and
309
+ * authenticated user. Present when `runWithContext(...)` is active;
310
+ * falls back to the shared scope otherwise (no request attrs emitted then).
311
+ *
312
+ * Both `provider` and `getOptions` are passed in (not captured by reference to
313
+ * concrete instances) so the closure stays decoupled from `NodeFlare`'s
314
+ * internals. `getOptions` is a getter (not a value) so that `configureNode(...)`
315
+ * changes are visible on subsequent reports without rebuilding the collector.
316
+ *
317
+ * The function returned matches `ContextCollector = (config) => Attributes`,
318
+ * which is core's interface for `Flare`'s third constructor parameter.
319
+ */
320
+ function makeNodeContextCollector(provider, getOptions) {
321
+ return (config) => {
322
+ const attrs = {
323
+ "flare.entry_point.type": "server",
324
+ ...collectProcessAttributes()
325
+ };
326
+ const scope = provider.active();
327
+ const { request } = scope;
328
+ if (request.method) attrs["http.request.method"] = request.method;
329
+ if (request.path) {
330
+ const queryStart = request.path.indexOf("?");
331
+ if (queryStart === -1) attrs["url.path"] = request.path;
332
+ else {
333
+ attrs["url.path"] = request.path.slice(0, queryStart);
334
+ const redactedQuery = (0, _flareapp_core.redactUrlQuery)(request.path, config.urlDenylist);
335
+ const redactedQueryStart = redactedQuery.indexOf("?");
336
+ attrs["url.query"] = redactedQuery.slice(redactedQueryStart + 1);
337
+ }
338
+ }
339
+ if (request.url) attrs["url.full"] = (0, _flareapp_core.redactUrlQuery)(request.url, config.urlDenylist);
340
+ const opts = getOptions();
341
+ Object.assign(attrs, projectHeaders(request.headers, opts));
342
+ if (opts.captureRequestBody) {
343
+ const contentType = findHeader(request.headers, "content-type");
344
+ const body = captureBody(request.body, contentType, opts);
345
+ if (body !== null) attrs["http.request.body"] = body;
346
+ }
347
+ if (scope.user) {
348
+ if (scope.user.id !== void 0) attrs["enduser.id"] = String(scope.user.id);
349
+ if (scope.user.email !== void 0) attrs["enduser.email"] = scope.user.email;
350
+ if (scope.user.username !== void 0) attrs["enduser.username"] = scope.user.username;
351
+ if (scope.user.ipAddress !== void 0) attrs["client.address"] = scope.user.ipAddress;
352
+ }
353
+ return attrs;
354
+ };
355
+ }
356
+
357
+ //#endregion
358
+ //#region src/process/fatal.ts
359
+ function buildFatalCallbacks(flare, getOpts, exit = process.exit.bind(process)) {
360
+ return {
361
+ async onUncaught(err, origin) {
362
+ const opts = getOpts();
363
+ if (opts.uncaughtExceptionMode === "report-and-exit") process.exitCode = 1;
364
+ const error = err instanceof Error ? err : new Error(String(err));
365
+ try {
366
+ await flare.report(error, { "process.uncaught_exception.origin": origin });
367
+ } catch {}
368
+ if (opts.uncaughtExceptionMode === "report-and-exit") {
369
+ await flare.flush(opts.shutdownTimeoutMs);
370
+ exit(1);
371
+ }
372
+ },
373
+ async onRejection(reason) {
374
+ const opts = getOpts();
375
+ if (opts.unhandledRejectionMode === "report-and-exit") process.exitCode = 1;
376
+ const error = reason instanceof Error ? reason : new Error(String(reason));
377
+ try {
378
+ await flare.report(error);
379
+ } catch {}
380
+ if (opts.unhandledRejectionMode === "report-and-exit") {
381
+ await flare.flush(opts.shutdownTimeoutMs);
382
+ exit(1);
383
+ }
384
+ }
385
+ };
386
+ }
387
+
388
+ //#endregion
389
+ //#region src/process/handlers.ts
390
+ /**
391
+ * Owns the lifecycle of the two process-level error listeners that capture
392
+ * fatal failures and feed them to Flare:
393
+ *
394
+ * - `process.on('uncaughtException', ...)`
395
+ * - `process.on('unhandledRejection', ...)`
396
+ *
397
+ * The manager has two responsibilities:
398
+ *
399
+ * 1. **Reconcile listener state with intent.** Given the current `FatalMode`
400
+ * for each event (`'off' | 'report' | 'report-and-exit'`), make the actual
401
+ * listener attachment match: attach when it should be attached but isn't,
402
+ * detach when it shouldn't be attached but is, no-op when already in the
403
+ * desired state. This is idempotent — calling `reconcile(...)` repeatedly
404
+ * with the same options is safe.
405
+ * 2. **Tear down on demand.** `detach()` removes both listeners regardless of
406
+ * intent, for tests and graceful shutdown.
407
+ *
408
+ * Why keep this separate from `NodeFlare`: the attach/detach logic is purely
409
+ * about Node `process` events and contains no Flare semantics. Isolating it
410
+ * makes it trivial to test (the test suite drives `reconcile()` directly with
411
+ * stub callbacks and asserts on `process.listeners(...)`) and keeps
412
+ * `NodeFlare` focused on report assembly + user-facing API.
413
+ */
414
+ var ProcessHandlerManager = class {
415
+ /** The currently-attached listener for `uncaughtException`, or `null`. */
416
+ uncaughtHandler = null;
417
+ /** The currently-attached listener for `unhandledRejection`, or `null`. */
418
+ rejectionHandler = null;
419
+ constructor(cbs) {
420
+ this.cbs = cbs;
421
+ }
422
+ /**
423
+ * Bring the attached listeners into agreement with the supplied modes.
424
+ * Idempotent: when current state already matches intent, this is a no-op.
425
+ */
426
+ reconcile(opts) {
427
+ this.reconcileOne("uncaughtException", opts.uncaughtExceptionMode, () => this.uncaughtHandler, (h) => {
428
+ this.uncaughtHandler = h;
429
+ }, (err, origin) => this.cbs.onUncaught(err, origin));
430
+ this.reconcileOne("unhandledRejection", opts.unhandledRejectionMode, () => this.rejectionHandler, (h) => {
431
+ this.rejectionHandler = h;
432
+ }, (reason) => this.cbs.onRejection(reason));
433
+ }
434
+ /**
435
+ * Remove both listeners regardless of current intent. Used by tests and by
436
+ * `NodeFlare.removeProcessListeners()`. Safe to call when nothing is
437
+ * attached.
438
+ */
439
+ detach() {
440
+ if (this.uncaughtHandler) {
441
+ process.off("uncaughtException", this.uncaughtHandler);
442
+ this.uncaughtHandler = null;
443
+ }
444
+ if (this.rejectionHandler) {
445
+ process.off("unhandledRejection", this.rejectionHandler);
446
+ this.rejectionHandler = null;
447
+ }
448
+ }
449
+ /**
450
+ * Generic attach/detach for one event. The `get`/`set` closures let us
451
+ * share this body between the two events while still mutating distinct
452
+ * fields (`uncaughtHandler` vs `rejectionHandler`).
453
+ *
454
+ * Truth table:
455
+ * - intent off, currently attached -> detach
456
+ * - intent off, not attached -> no-op
457
+ * - intent on, currently attached -> no-op (already correct)
458
+ * - intent on, not attached -> attach
459
+ */
460
+ reconcileOne(event, mode, get, set, impl) {
461
+ const current = get();
462
+ const wants = mode !== "off";
463
+ if (wants && !current) {
464
+ set(impl);
465
+ process.on(event, impl);
466
+ } else if (!wants && current) {
467
+ process.off(event, current);
468
+ set(null);
469
+ }
470
+ }
471
+ };
472
+
473
+ //#endregion
474
+ //#region src/scope/NodeScope.ts
475
+ var NodeScope = class extends _flareapp_core.Scope {
476
+ request = {};
477
+ user = null;
478
+ };
479
+
480
+ //#endregion
481
+ //#region src/scope/AsyncLocalStorageScopeProvider.ts
482
+ /**
483
+ * `ScopeProvider` implementation that gives every in-flight request its own
484
+ * `NodeScope`, isolated from concurrent requests.
485
+ *
486
+ * Built on Node's `node:async_hooks#AsyncLocalStorage`: when code runs inside
487
+ * `als.run(scope, fn)`, every `als.getStore()` call from within `fn` (and any
488
+ * async work `fn` awaits, including timers, promises, `process.nextTick`, etc)
489
+ * returns that `scope`. Outside any `als.run` call, `getStore()` returns
490
+ * `undefined`. This is the same primitive that lets observability libraries
491
+ * propagate trace context across async boundaries without manual plumbing.
492
+ *
493
+ * Two "kinds of read" surfaced separately:
494
+ *
495
+ * - `active()` — never returns null. The internal read used by `Flare` for
496
+ * every glow, attribute set, and report. When called inside `runWithContext`,
497
+ * returns the per-request `NodeScope`. Outside, returns a shared `fallback`
498
+ * scope so glows/attributes/reports issued outside any request still have
499
+ * somewhere to land (process-level reports, startup errors, scheduled jobs).
500
+ * - `getContext()` — public debug helper. Returns `null` outside any
501
+ * `runWithContext`, so consumers can distinguish "I am inside a request" from
502
+ * "I am not". The fallback is intentionally NOT exposed here.
503
+ *
504
+ * The fallback is also a per-instance `NodeScope` so that writes from outside
505
+ * a request scope persist for subsequent outside-scope reports.
506
+ */
507
+ var AsyncLocalStorageScopeProvider = class {
508
+ als = new node_async_hooks.AsyncLocalStorage();
509
+ fallback = new NodeScope();
510
+ /**
511
+ * Internal: returns the per-request scope when inside `runWithContext`,
512
+ * or the shared fallback otherwise. Always returns a real `NodeScope`.
513
+ */
514
+ active() {
515
+ return this.als.getStore() ?? this.fallback;
516
+ }
517
+ /**
518
+ * Public: returns the per-request scope when inside `runWithContext`, or
519
+ * `null` otherwise. Useful for assertions like "am I in a request?".
520
+ */
521
+ getContext() {
522
+ return this.als.getStore() ?? null;
523
+ }
524
+ /**
525
+ * Open a fresh request scope around `fn` and run it. Every async hop
526
+ * inside `fn` (awaits, timers, promise chains) sees the same scope via
527
+ * `active()`/`getContext()`; concurrent calls each get their own.
528
+ *
529
+ * `request` is shallow-cloned so later edits to the caller's object do not
530
+ * leak into the stored scope.
531
+ */
532
+ runWithContext(request, fn) {
533
+ const scope = new NodeScope();
534
+ scope.request = { ...request };
535
+ return this.als.run(scope, fn);
536
+ }
537
+ /**
538
+ * Patch the current scope's `request` shape. When called inside
539
+ * `runWithContext`, the patch is visible to all subsequent reads from
540
+ * within the same request chain. When called outside, the patch lands on
541
+ * the fallback scope.
542
+ */
543
+ mergeContext(partial) {
544
+ const scope = this.als.getStore() ?? this.fallback;
545
+ scope.request = {
546
+ ...scope.request,
547
+ ...partial
548
+ };
549
+ }
550
+ /**
551
+ * Set the authenticated user on the current scope. Same in-scope vs
552
+ * fallback semantics as `mergeContext`.
553
+ */
554
+ setUser(user) {
555
+ const scope = this.als.getStore() ?? this.fallback;
556
+ scope.user = user;
557
+ }
558
+ };
559
+
560
+ //#endregion
561
+ //#region src/stacktrace/DiskFileReader.ts
562
+ /**
563
+ * Node `FileReader` implementation that reads source files from disk.
564
+ *
565
+ * Wired into `@flareapp/node`'s singleton so the stack-trace builder can pull
566
+ * source for each frame and render a snippet. On the server the frame's "URL"
567
+ * is usually a local path (e.g. `/app/dist/server.js`) or a `file://` URL
568
+ * (from `import.meta.url`), so we resolve straight off disk instead of going
569
+ * over the network.
570
+ *
571
+ * Safety gates:
572
+ *
573
+ * 1. **Local-path allowlist.** Only `file://` URLs and absolute filesystem
574
+ * paths (POSIX `/foo`, Windows `C:\foo` or `\\server\share\foo`) are
575
+ * accepted. HTTP URLs and relative paths return `null` immediately. We
576
+ * refuse to read anything that does not unambiguously identify a local
577
+ * file — no surprise traversal, no following http stack frames in a
578
+ * server build, no relative-path ambiguity around the current working
579
+ * directory.
580
+ * 2. **Catch-all.** Missing files, permission errors, and any other failure
581
+ * return `null`. The `read()` contract returns `null` on every failure
582
+ * path and never throws.
583
+ *
584
+ * `fileURLToPath` is used when the input is a `file://` URL so we hand
585
+ * `readFile` a real OS path. Otherwise the URL IS already a path and is
586
+ * passed through unchanged.
587
+ */
588
+ var DiskFileReader = class {
589
+ async read(url) {
590
+ if (!isLocalFileUrl(url)) return null;
591
+ try {
592
+ return await (0, node_fs_promises.readFile)(/^file:\/\//i.test(url) ? (0, node_url.fileURLToPath)(url) : url, "utf-8");
593
+ } catch {
594
+ return null;
595
+ }
596
+ }
597
+ };
598
+ /**
599
+ * Return true when `url` is something we are willing to treat as a local
600
+ * file. Matches four shapes:
601
+ *
602
+ * - `file://...` URLs (any casing of the scheme)
603
+ * - POSIX absolute paths starting with `/`
604
+ * - Windows drive-letter paths like `C:\foo` or `c:/foo`
605
+ * - Windows UNC paths starting with `\\`
606
+ *
607
+ * Anything else (relative paths, http, data, blob, etc) is rejected.
608
+ */
609
+ function isLocalFileUrl(url) {
610
+ return /^file:\/\//i.test(url) || url.startsWith("/") || /^[a-z]:[\\/]/i.test(url) || url.startsWith("\\\\");
611
+ }
612
+
613
+ //#endregion
614
+ //#region src/Flare.ts
615
+ const NODE_SDK_NAME = "@flareapp/node";
616
+ const NODE_SDK_VERSION = typeof process !== "undefined" && true ? "0.1.1" : "?";
617
+ /**
618
+ * Strip the `g` and `y` flags from a user-supplied regex.
619
+ *
620
+ * `RegExp.prototype.test()` and `.exec()` keep `lastIndex` state when either of
621
+ * these flags is set, which means reusing the same regex across many keys (as
622
+ * the header denylist and body redaction do) silently skips matches after the
623
+ * first hit. Reconstructing the regex without those flags gives stateless
624
+ * matching while preserving everything else (`i`, `m`, `s`, `u`, source).
625
+ */
626
+ function sanitizeRegex(re) {
627
+ const safeFlags = re.flags.replace(/[gy]/g, "");
628
+ return new RegExp(re.source, safeFlags);
629
+ }
630
+ const DEFAULT_NODE_OPTIONS = {
631
+ uncaughtExceptionMode: "report-and-exit",
632
+ unhandledRejectionMode: "report-and-exit",
633
+ shutdownTimeoutMs: 2e3,
634
+ headerDenylist: DEFAULT_HEADER_DENYLIST,
635
+ headerAllowlist: null,
636
+ replaceDefaultHeaderDenylist: false,
637
+ captureRequestBody: false,
638
+ bodyMaxBytes: 16384,
639
+ bodyAllowedContentTypes: DEFAULT_BODY_CONTENT_TYPES,
640
+ bodyKeyDenylist: DEFAULT_BODY_KEY_DENYLIST
641
+ };
642
+ /**
643
+ * Node.js-specific `Flare` singleton, exposed from `@flareapp/node` as `flare`.
644
+ *
645
+ * Subclasses core's `Flare` and wires the Node-only seams in its constructor:
646
+ *
647
+ * - `AsyncLocalStorageScopeProvider` so each `runWithContext(...)` callback
648
+ * gets its own `NodeScope` (glows, attributes, user, entry-point, request),
649
+ * isolated from concurrent requests.
650
+ * - `makeNodeContextCollector(...)` to project the current `NodeScope` and
651
+ * process info into report attributes (http.request.*, url.path, etc).
652
+ * - `DiskFileReader` to read source files for stack-trace snippets via
653
+ * `node:fs/promises` instead of the browser's `fetch`.
654
+ * - `ProcessHandlerManager` to attach/detach `uncaughtException` and
655
+ * `unhandledRejection` listeners based on the current `NodeOptions`.
656
+ *
657
+ * Also adds Node-only API surface on top of core: `configureNode(...)`,
658
+ * `runWithContext(...)`, `mergeContext(...)`, `setUser(...)`, `getContext()`,
659
+ * `removeProcessListeners()`. Inherited core methods (`light`, `configure`,
660
+ * `addContext`, `glow`, etc.) return `this`, so chaining keeps the
661
+ * `NodeFlare` type and `configureNode(...)` stays callable mid-chain.
662
+ */
663
+ var NodeFlare = class extends _flareapp_core.Flare {
664
+ nodeOptions = { ...DEFAULT_NODE_OPTIONS };
665
+ isLit = false;
666
+ nodeScopeProvider;
667
+ handlerManager;
668
+ constructor() {
669
+ const scopeProvider = new AsyncLocalStorageScopeProvider();
670
+ const collector = makeNodeContextCollector(scopeProvider, () => this.nodeOptions);
671
+ super(new _flareapp_core.Api(), collector, new DiskFileReader(), scopeProvider);
672
+ this.nodeScopeProvider = scopeProvider;
673
+ this.setSdkInfo({
674
+ name: NODE_SDK_NAME,
675
+ version: NODE_SDK_VERSION
676
+ });
677
+ this.handlerManager = new ProcessHandlerManager(buildFatalCallbacks(this, () => this.nodeOptions));
678
+ }
679
+ /**
680
+ * Set the API key (and optional debug flag), then reconcile process
681
+ * listeners with the current `nodeOptions`. Reconcile runs on EVERY call,
682
+ * not just the first, so `light()` is the right escape hatch to re-attach
683
+ * after `removeProcessListeners()`.
684
+ */
685
+ light(key, debug) {
686
+ super.light(key, debug);
687
+ this.isLit = true;
688
+ this.handlerManager.reconcile(this.nodeOptions);
689
+ return this;
690
+ }
691
+ /**
692
+ * Merge Node-only options (fatal-handler modes, header/body redaction
693
+ * config, shutdown timeout) into the active configuration. Safe to call
694
+ * before or after `light()`:
695
+ *
696
+ * - Before `light()`: options are stored; listeners are attached when
697
+ * `light()` runs.
698
+ * - After `light()`: options are stored AND listeners are reconciled
699
+ * immediately, so flipping a mode to `'off'` detaches the handler and
700
+ * flipping it back to `'report'`/`'report-and-exit'` re-attaches.
701
+ *
702
+ * Regex options (`headerAllowlist`, `bodyAllowedContentTypes`,
703
+ * `bodyKeyDenylist`) are passed through `sanitizeRegex` to strip stateful
704
+ * `g`/`y` flags; without that, `RegExp.prototype.test` would skip matches
705
+ * across keys.
706
+ */
707
+ configureNode(partial) {
708
+ if (partial.headerDenylist !== void 0 || partial.replaceDefaultHeaderDenylist !== void 0) {
709
+ this.nodeOptions.headerDenylist = resolveHeaderDenylist(partial.headerDenylist ?? void 0, partial.replaceDefaultHeaderDenylist ?? this.nodeOptions.replaceDefaultHeaderDenylist);
710
+ this.nodeOptions.replaceDefaultHeaderDenylist = partial.replaceDefaultHeaderDenylist ?? this.nodeOptions.replaceDefaultHeaderDenylist;
711
+ }
712
+ if (partial.headerAllowlist !== void 0) this.nodeOptions.headerAllowlist = partial.headerAllowlist === null ? null : sanitizeRegex(partial.headerAllowlist);
713
+ if (partial.uncaughtExceptionMode !== void 0) this.nodeOptions.uncaughtExceptionMode = partial.uncaughtExceptionMode;
714
+ if (partial.unhandledRejectionMode !== void 0) this.nodeOptions.unhandledRejectionMode = partial.unhandledRejectionMode;
715
+ if (partial.shutdownTimeoutMs !== void 0) this.nodeOptions.shutdownTimeoutMs = partial.shutdownTimeoutMs;
716
+ if (partial.captureRequestBody !== void 0) this.nodeOptions.captureRequestBody = partial.captureRequestBody;
717
+ if (partial.bodyMaxBytes !== void 0) this.nodeOptions.bodyMaxBytes = partial.bodyMaxBytes;
718
+ if (partial.bodyAllowedContentTypes !== void 0) this.nodeOptions.bodyAllowedContentTypes = sanitizeRegex(partial.bodyAllowedContentTypes);
719
+ if (partial.bodyKeyDenylist !== void 0) this.nodeOptions.bodyKeyDenylist = sanitizeRegex(partial.bodyKeyDenylist);
720
+ if (this.isLit) this.handlerManager.reconcile(this.nodeOptions);
721
+ return this;
722
+ }
723
+ /**
724
+ * Run `fn` inside a fresh `NodeScope` carrying the supplied request
725
+ * metadata. Inside `fn` (and any async work it awaits), `flare.glow(...)`,
726
+ * `flare.addContext(...)`, `flare.setUser(...)`, and `flare.report(...)`
727
+ * see a scope that is isolated from other concurrent requests.
728
+ *
729
+ * Mirrors a typical web-framework middleware: call once per request,
730
+ * wrapping the request handler, and the SDK will attribute any error
731
+ * reported inside the chain to the right request.
732
+ */
733
+ runWithContext(request, fn) {
734
+ return this.nodeScopeProvider.runWithContext(request, fn);
735
+ }
736
+ /**
737
+ * Patch the request metadata on the active scope after `runWithContext(...)`
738
+ * has already started. Useful when fields become known partway through a
739
+ * request (e.g., the resolved absolute URL after proxy headers are parsed).
740
+ *
741
+ * Outside any `runWithContext(...)` callback, this writes to the fallback
742
+ * scope; the patch is visible to subsequent reports issued from outside a
743
+ * request scope but is NOT inherited by future `runWithContext(...)` calls.
744
+ */
745
+ mergeContext(partial) {
746
+ this.nodeScopeProvider.mergeContext(partial);
747
+ }
748
+ /**
749
+ * Attach an authenticated user to the active scope. Inside a request scope
750
+ * this is per-request; outside it lands on the fallback scope. The fields
751
+ * are projected to OTel-style keys (`enduser.id`, `enduser.email`,
752
+ * `enduser.username`, `client.address`) by the Node context collector.
753
+ */
754
+ setUser(user) {
755
+ this.nodeScopeProvider.setUser(user);
756
+ }
757
+ /**
758
+ * Returns the request scope when called inside `runWithContext(...)`, or
759
+ * `null` outside. Intentionally returns `null` (not the fallback scope)
760
+ * when no request is active, so callers can distinguish "we are inside a
761
+ * request" from "we are not". Primarily useful for debugging.
762
+ */
763
+ getContext() {
764
+ return this.nodeScopeProvider.getContext();
765
+ }
766
+ /**
767
+ * Detach the `uncaughtException` and `unhandledRejection` listeners
768
+ * without changing `nodeOptions`. Intended for tests and for graceful
769
+ * shutdown paths where you want to take ownership of process exit
770
+ * yourself.
771
+ *
772
+ * Calling `light()` afterwards re-attaches based on the current options.
773
+ */
774
+ removeProcessListeners() {
775
+ this.handlerManager.detach();
776
+ }
777
+ };
778
+
779
+ //#endregion
780
+ //#region src/index.ts
781
+ const flare = new NodeFlare();
782
+
783
+ //#endregion
784
+ Object.defineProperty(exports, 'DEFAULT_URL_DENYLIST', {
785
+ enumerable: true,
786
+ get: function () {
787
+ return _flareapp_core.DEFAULT_URL_DENYLIST;
788
+ }
789
+ });
790
+ Object.defineProperty(exports, 'Flare', {
791
+ enumerable: true,
792
+ get: function () {
793
+ return _flareapp_core.Flare;
794
+ }
795
+ });
796
+ Object.defineProperty(exports, 'GlobalScopeProvider', {
797
+ enumerable: true,
798
+ get: function () {
799
+ return _flareapp_core.GlobalScopeProvider;
800
+ }
801
+ });
802
+ exports.NodeFlare = NodeFlare;
803
+ exports.NodeScope = NodeScope;
804
+ Object.defineProperty(exports, 'NullFileReader', {
805
+ enumerable: true,
806
+ get: function () {
807
+ return _flareapp_core.NullFileReader;
808
+ }
809
+ });
810
+ Object.defineProperty(exports, 'Scope', {
811
+ enumerable: true,
812
+ get: function () {
813
+ return _flareapp_core.Scope;
814
+ }
815
+ });
816
+ Object.defineProperty(exports, 'convertToError', {
817
+ enumerable: true,
818
+ get: function () {
819
+ return _flareapp_core.convertToError;
820
+ }
821
+ });
822
+ exports.flare = flare;
823
+ Object.defineProperty(exports, 'redactUrlQuery', {
824
+ enumerable: true,
825
+ get: function () {
826
+ return _flareapp_core.redactUrlQuery;
827
+ }
828
+ });
829
+ Object.defineProperty(exports, 'resolveDenylist', {
830
+ enumerable: true,
831
+ get: function () {
832
+ return _flareapp_core.resolveDenylist;
833
+ }
834
+ });