@flareapp/node 0.10.0 → 0.12.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,24 +34,8 @@ 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
- /** `^` plus `\b` accepts a `; charset=utf-8` suffix while still rejecting `...-urlencoded-foo`. */
38
37
  const DEFAULT_BODY_CONTENT_TYPES = /^application\/(json|x-www-form-urlencoded)\b/i;
39
- /** Reuses core's URL denylist, so credentials are caught by the same regex everywhere. */
40
38
  const DEFAULT_BODY_KEY_DENYLIST = _flareapp_core.DEFAULT_URL_DENYLIST;
41
- /**
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.
54
- */
55
39
  function captureBody(body, contentType, opts) {
56
40
  if (body === void 0 || body === null) return null;
57
41
  let parsed;
@@ -77,7 +61,6 @@ function captureBody(body, contentType, opts) {
77
61
  }
78
62
  const TRUNCATION_SUFFIX = "…[truncated]";
79
63
  const TRUNCATION_SUFFIX_BYTES = Buffer.byteLength(TRUNCATION_SUFFIX, "utf8");
80
- /** Walks back over continuation bytes (`10xxxxxx`) to a codepoint boundary, so the result still decodes. */
81
64
  function truncateToByteLimit(serialized, maxBytes) {
82
65
  const buf = Buffer.from(serialized, "utf8");
83
66
  if (buf.length <= maxBytes) return serialized;
@@ -91,15 +74,12 @@ function truncateToByteLimit(serialized, maxBytes) {
91
74
  while (cut > 0 && (buf[cut] & 192) === 128) cut--;
92
75
  return buf.subarray(0, cut).toString("utf8") + TRUNCATION_SUFFIX;
93
76
  }
94
- /** Normalizes to the bare media type first, so a strict custom regex like `/^application\/json$/` still
95
- * matches `application/json; charset=utf-8`. */
96
77
  function matchesContentType(ct, allowed) {
97
78
  if (!ct) return false;
98
79
  const mediaType = ct.split(";")[0].trim().toLowerCase();
99
80
  if (!mediaType) return false;
100
81
  return allowed.test(mediaType);
101
82
  }
102
- /** Returns `undefined` rather than `null` on failure, since `null` is itself a valid JSON value. */
103
83
  function parseString(text, contentType) {
104
84
  if (contentType && /x-www-form-urlencoded/i.test(contentType)) return Object.fromEntries(new URLSearchParams(text).entries());
105
85
  try {
@@ -108,13 +88,11 @@ function parseString(text, contentType) {
108
88
  return;
109
89
  }
110
90
  }
111
- /** Excludes class instances, streams, FormData, ArrayBuffer views, Buffer and URLSearchParams. */
112
91
  function isPlainObject(value) {
113
92
  if (value === null || typeof value !== "object") return false;
114
93
  const proto = Object.getPrototypeOf(value);
115
94
  return proto === null || proto === Object.prototype;
116
95
  }
117
- /** `seen` is a parameter rather than a closure to avoid allocating a WeakSet per recursion. */
118
96
  function redact(value, denylist, seen = /* @__PURE__ */ new WeakSet()) {
119
97
  if (value === null || typeof value !== "object") return value;
120
98
  if (seen.has(value)) return "[Circular]";
@@ -125,9 +103,26 @@ function redact(value, denylist, seen = /* @__PURE__ */ new WeakSet()) {
125
103
  return out;
126
104
  }
127
105
 
106
+ //#endregion
107
+ //#region src/context/deviceInfo.ts
108
+ var NodeDeviceInfoProvider = class {
109
+ collect() {
110
+ return {
111
+ os: {
112
+ name: node_os.default.type(),
113
+ version: node_os.default.release()
114
+ },
115
+ runtime: {
116
+ name: "nodejs",
117
+ version: process.version
118
+ }
119
+ };
120
+ }
121
+ };
122
+ const nodeDeviceInfoProvider = new NodeDeviceInfoProvider();
123
+
128
124
  //#endregion
129
125
  //#region src/context/headers.ts
130
- /** Case-insensitive. An array value collapses to its first element; callers here want a single value. */
131
126
  function findHeader(headers, name) {
132
127
  if (!headers) return;
133
128
  const target = name.toLowerCase();
@@ -137,20 +132,12 @@ function findHeader(headers, name) {
137
132
  return Array.isArray(value) ? value[0] : value;
138
133
  }
139
134
  }
140
- /** The `^` and `$` matter: without them, `cookie` would also match a header like `X-Some-Cookie-Hint`. */
141
135
  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;
142
- /** `g`/`y` are stripped from a custom pattern: those carry lastIndex, which makes `.test()` stateful. */
143
136
  function resolveHeaderDenylist(custom, replaceDefault = false) {
144
137
  if (!custom) return DEFAULT_HEADER_DENYLIST;
145
138
  if (replaceDefault) return new RegExp(custom.source, custom.flags.replace(/[gy]/g, ""));
146
139
  return new RegExp(`(?:${DEFAULT_HEADER_DENYLIST.source})|(?:${custom.source})`, "i");
147
140
  }
148
- /**
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.
153
- */
154
141
  function projectHeaders(headers, options) {
155
142
  const out = {};
156
143
  if (!headers) return out;
@@ -166,38 +153,23 @@ function projectHeaders(headers, options) {
166
153
 
167
154
  //#endregion
168
155
  //#region src/context/process.ts
169
- /**
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).
173
- */
174
156
  function collectProcessAttributes() {
175
157
  return {
176
- "process.runtime.name": "nodejs",
177
- "process.runtime.version": process.version,
178
158
  "process.pid": process.pid,
179
159
  "process.uptime": process.uptime(),
180
160
  "host.name": node_os.default.hostname(),
181
- "host.arch": process.arch,
182
- "os.type": node_os.default.type(),
183
- "os.version": node_os.default.release()
161
+ "host.arch": process.arch
184
162
  };
185
163
  }
186
164
 
187
165
  //#endregion
188
166
  //#region src/context/collectNode.ts
189
- /**
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`.
193
- *
194
- * `getOptions` is a getter so `configureNode(...)` shows up on later reports without rebuilding this.
195
- */
196
167
  function makeNodeContextCollector(provider, getOptions) {
197
168
  return (config) => {
198
169
  const attrs = {
199
170
  "flare.entry_point.type": "web",
200
- ...collectProcessAttributes()
171
+ ...collectProcessAttributes(),
172
+ ...(0, _flareapp_core.deviceInfoToAttributes)(nodeDeviceInfoProvider.collect())
201
173
  };
202
174
  const { request } = provider.active();
203
175
  if (request.method) attrs["http.request.method"] = request.method;
@@ -268,23 +240,12 @@ function buildFatalCallbacks(flare, getOpts, exit = process.exit.bind(process))
268
240
 
269
241
  //#endregion
270
242
  //#region src/process/handlers.ts
271
- /**
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.
280
- */
281
243
  var ProcessHandlerManager = class {
282
244
  uncaughtHandler = null;
283
245
  rejectionHandler = null;
284
246
  constructor(cbs) {
285
247
  this.cbs = cbs;
286
248
  }
287
- /** Idempotent: a no-op when the attached listeners already match the supplied modes. */
288
249
  reconcile(opts) {
289
250
  this.reconcileOne("uncaughtException", opts.uncaughtExceptionMode, () => this.uncaughtHandler, (h) => {
290
251
  this.uncaughtHandler = h;
@@ -293,7 +254,6 @@ var ProcessHandlerManager = class {
293
254
  this.rejectionHandler = h;
294
255
  }, (reason) => this.cbs.onRejection(reason));
295
256
  }
296
- /** Remove both listeners regardless of intent. Safe when nothing is attached. */
297
257
  detach() {
298
258
  if (this.uncaughtHandler) {
299
259
  process.off("uncaughtException", this.uncaughtHandler);
@@ -304,11 +264,6 @@ var ProcessHandlerManager = class {
304
264
  this.rejectionHandler = null;
305
265
  }
306
266
  }
307
- /**
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.
311
- */
312
267
  reconcileOne(event, mode, get, set, impl) {
313
268
  const current = get();
314
269
  const wants = mode !== "off";
@@ -331,25 +286,15 @@ var NodeScope = class extends _flareapp_core.Scope {
331
286
 
332
287
  //#endregion
333
288
  //#region src/scope/AsyncLocalStorageScopeProvider.ts
334
- /**
335
- * Gives every in-flight request its own `NodeScope`, isolated from concurrent requests.
336
- *
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.
340
- */
341
289
  var AsyncLocalStorageScopeProvider = class {
342
290
  als = new node_async_hooks.AsyncLocalStorage();
343
291
  fallback = new NodeScope();
344
- /** Never null: falls back to the shared scope outside `runWithContext`. */
345
292
  active() {
346
293
  return this.als.getStore() ?? this.fallback;
347
294
  }
348
- /** Null outside `runWithContext`, so callers can tell "inside a request" from "not". */
349
295
  getContext() {
350
296
  return this.als.getStore() ?? null;
351
297
  }
352
- /** `request` is shallow-cloned so later edits to the caller's object do not leak into the scope. */
353
298
  runWithContext(request, fn) {
354
299
  const scope = new NodeScope();
355
300
  scope.request = { ...request };
@@ -366,11 +311,6 @@ var AsyncLocalStorageScopeProvider = class {
366
311
 
367
312
  //#endregion
368
313
  //#region src/stacktrace/DiskFileReader.ts
369
- /**
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.
373
- */
374
314
  var DiskFileReader = class {
375
315
  async read(url) {
376
316
  if (!isLocalFileUrl(url)) return null;
@@ -381,7 +321,6 @@ var DiskFileReader = class {
381
321
  }
382
322
  }
383
323
  };
384
- /** `file://` (any casing), POSIX absolute, Windows drive-letter (`C:\foo`), Windows UNC (`\\`). */
385
324
  function isLocalFileUrl(url) {
386
325
  return /^file:\/\//i.test(url) || url.startsWith("/") || /^[a-z]:[\\/]/i.test(url) || url.startsWith("\\\\");
387
326
  }
@@ -389,9 +328,7 @@ function isLocalFileUrl(url) {
389
328
  //#endregion
390
329
  //#region src/Flare.ts
391
330
  const NODE_SDK_NAME = "@flareapp/node";
392
- const NODE_SDK_VERSION = typeof process !== "undefined" && true ? "0.10.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. */
331
+ const NODE_SDK_VERSION = typeof process !== "undefined" && true ? "0.12.0" : "?";
395
332
  function sanitizeRegex(re) {
396
333
  const safeFlags = re.flags.replace(/[gy]/g, "");
397
334
  return new RegExp(re.source, safeFlags);
@@ -409,18 +346,12 @@ const DEFAULT_NODE_OPTIONS = {
409
346
  bodyKeyDenylist: DEFAULT_BODY_KEY_DENYLIST
410
347
  };
411
348
  /**
412
- * Node.js-specific `Flare` singleton, exposed from `@flareapp/node` as `flare`.
413
- *
414
- * Subclasses core's `Flare` and wires the Node-only seams in its constructor:
415
- * - `AsyncLocalStorageScopeProvider` so each `runWithContext(...)` callback gets its own `NodeScope`,
416
- * isolated from concurrent requests.
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`.
349
+ * Node.js `Flare` singleton, exported from `@flareapp/node` as `flare`.
420
350
  *
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.
351
+ * Subclasses core's `Flare` and wires the Node-only seams: per-request scope via
352
+ * `AsyncLocalStorageScopeProvider`, a Node context collector, `DiskFileReader` for stack snippets,
353
+ * and `ProcessHandlerManager` for the fatal listeners. Adds `configureNode`, `runWithContext`,
354
+ * `mergeContext`, `getContext`, and `removeProcessListeners` on top of the core API.
424
355
  */
425
356
  var NodeFlare = class extends _flareapp_core.Flare {
426
357
  nodeOptions = { ...DEFAULT_NODE_OPTIONS };
package/dist/index.d.cts CHANGED
@@ -29,18 +29,12 @@ declare class NodeScope extends Scope$1 {
29
29
  //#endregion
30
30
  //#region src/Flare.d.ts
31
31
  /**
32
- * Node.js-specific `Flare` singleton, exposed from `@flareapp/node` as `flare`.
32
+ * Node.js `Flare` singleton, exported from `@flareapp/node` as `flare`.
33
33
  *
34
- * Subclasses core's `Flare` and wires the Node-only seams in its constructor:
35
- * - `AsyncLocalStorageScopeProvider` so each `runWithContext(...)` callback gets its own `NodeScope`,
36
- * isolated from concurrent requests.
37
- * - `makeNodeContextCollector(...)` turns the current `NodeScope` + process info into report attributes.
38
- * - `DiskFileReader` reads source for stack-trace snippets via `node:fs/promises`, not `fetch`.
39
- * - `ProcessHandlerManager` attaches/detaches the fatal process listeners per `NodeOptions`.
40
- *
41
- * Adds Node-only API on top of core: `configureNode`, `runWithContext`, `mergeContext`, `getContext`,
42
- * `removeProcessListeners`. Inherited core methods return `this`, so chaining keeps the `NodeFlare`
43
- * type and `configureNode(...)` stays callable mid-chain.
34
+ * Subclasses core's `Flare` and wires the Node-only seams: per-request scope via
35
+ * `AsyncLocalStorageScopeProvider`, a Node context collector, `DiskFileReader` for stack snippets,
36
+ * and `ProcessHandlerManager` for the fatal listeners. Adds `configureNode`, `runWithContext`,
37
+ * `mergeContext`, `getContext`, and `removeProcessListeners` on top of the core API.
44
38
  */
45
39
  declare class NodeFlare extends Flare$1 {
46
40
  private nodeOptions;
package/dist/index.d.mts CHANGED
@@ -29,18 +29,12 @@ declare class NodeScope extends Scope$1 {
29
29
  //#endregion
30
30
  //#region src/Flare.d.ts
31
31
  /**
32
- * Node.js-specific `Flare` singleton, exposed from `@flareapp/node` as `flare`.
32
+ * Node.js `Flare` singleton, exported from `@flareapp/node` as `flare`.
33
33
  *
34
- * Subclasses core's `Flare` and wires the Node-only seams in its constructor:
35
- * - `AsyncLocalStorageScopeProvider` so each `runWithContext(...)` callback gets its own `NodeScope`,
36
- * isolated from concurrent requests.
37
- * - `makeNodeContextCollector(...)` turns the current `NodeScope` + process info into report attributes.
38
- * - `DiskFileReader` reads source for stack-trace snippets via `node:fs/promises`, not `fetch`.
39
- * - `ProcessHandlerManager` attaches/detaches the fatal process listeners per `NodeOptions`.
40
- *
41
- * Adds Node-only API on top of core: `configureNode`, `runWithContext`, `mergeContext`, `getContext`,
42
- * `removeProcessListeners`. Inherited core methods return `this`, so chaining keeps the `NodeFlare`
43
- * type and `configureNode(...)` stays callable mid-chain.
34
+ * Subclasses core's `Flare` and wires the Node-only seams: per-request scope via
35
+ * `AsyncLocalStorageScopeProvider`, a Node context collector, `DiskFileReader` for stack snippets,
36
+ * and `ProcessHandlerManager` for the fatal listeners. Adds `configureNode`, `runWithContext`,
37
+ * `mergeContext`, `getContext`, and `removeProcessListeners` on top of the core API.
44
38
  */
45
39
  declare class NodeFlare extends Flare$1 {
46
40
  private nodeOptions;
package/dist/index.mjs CHANGED
@@ -1,28 +1,12 @@
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";
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, deviceInfoToAttributes, 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
- /** `^` plus `\b` accepts a `; charset=utf-8` suffix while still rejecting `...-urlencoded-foo`. */
9
8
  const DEFAULT_BODY_CONTENT_TYPES = /^application\/(json|x-www-form-urlencoded)\b/i;
10
- /** Reuses core's URL denylist, so credentials are caught by the same regex everywhere. */
11
9
  const DEFAULT_BODY_KEY_DENYLIST = DEFAULT_URL_DENYLIST$1;
12
- /**
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.
25
- */
26
10
  function captureBody(body, contentType, opts) {
27
11
  if (body === void 0 || body === null) return null;
28
12
  let parsed;
@@ -48,7 +32,6 @@ function captureBody(body, contentType, opts) {
48
32
  }
49
33
  const TRUNCATION_SUFFIX = "…[truncated]";
50
34
  const TRUNCATION_SUFFIX_BYTES = Buffer.byteLength(TRUNCATION_SUFFIX, "utf8");
51
- /** Walks back over continuation bytes (`10xxxxxx`) to a codepoint boundary, so the result still decodes. */
52
35
  function truncateToByteLimit(serialized, maxBytes) {
53
36
  const buf = Buffer.from(serialized, "utf8");
54
37
  if (buf.length <= maxBytes) return serialized;
@@ -62,15 +45,12 @@ function truncateToByteLimit(serialized, maxBytes) {
62
45
  while (cut > 0 && (buf[cut] & 192) === 128) cut--;
63
46
  return buf.subarray(0, cut).toString("utf8") + TRUNCATION_SUFFIX;
64
47
  }
65
- /** Normalizes to the bare media type first, so a strict custom regex like `/^application\/json$/` still
66
- * matches `application/json; charset=utf-8`. */
67
48
  function matchesContentType(ct, allowed) {
68
49
  if (!ct) return false;
69
50
  const mediaType = ct.split(";")[0].trim().toLowerCase();
70
51
  if (!mediaType) return false;
71
52
  return allowed.test(mediaType);
72
53
  }
73
- /** Returns `undefined` rather than `null` on failure, since `null` is itself a valid JSON value. */
74
54
  function parseString(text, contentType) {
75
55
  if (contentType && /x-www-form-urlencoded/i.test(contentType)) return Object.fromEntries(new URLSearchParams(text).entries());
76
56
  try {
@@ -79,13 +59,11 @@ function parseString(text, contentType) {
79
59
  return;
80
60
  }
81
61
  }
82
- /** Excludes class instances, streams, FormData, ArrayBuffer views, Buffer and URLSearchParams. */
83
62
  function isPlainObject(value) {
84
63
  if (value === null || typeof value !== "object") return false;
85
64
  const proto = Object.getPrototypeOf(value);
86
65
  return proto === null || proto === Object.prototype;
87
66
  }
88
- /** `seen` is a parameter rather than a closure to avoid allocating a WeakSet per recursion. */
89
67
  function redact(value, denylist, seen = /* @__PURE__ */ new WeakSet()) {
90
68
  if (value === null || typeof value !== "object") return value;
91
69
  if (seen.has(value)) return "[Circular]";
@@ -96,9 +74,26 @@ function redact(value, denylist, seen = /* @__PURE__ */ new WeakSet()) {
96
74
  return out;
97
75
  }
98
76
 
77
+ //#endregion
78
+ //#region src/context/deviceInfo.ts
79
+ var NodeDeviceInfoProvider = class {
80
+ collect() {
81
+ return {
82
+ os: {
83
+ name: os.type(),
84
+ version: os.release()
85
+ },
86
+ runtime: {
87
+ name: "nodejs",
88
+ version: process.version
89
+ }
90
+ };
91
+ }
92
+ };
93
+ const nodeDeviceInfoProvider = new NodeDeviceInfoProvider();
94
+
99
95
  //#endregion
100
96
  //#region src/context/headers.ts
101
- /** Case-insensitive. An array value collapses to its first element; callers here want a single value. */
102
97
  function findHeader(headers, name) {
103
98
  if (!headers) return;
104
99
  const target = name.toLowerCase();
@@ -108,20 +103,12 @@ function findHeader(headers, name) {
108
103
  return Array.isArray(value) ? value[0] : value;
109
104
  }
110
105
  }
111
- /** The `^` and `$` matter: without them, `cookie` would also match a header like `X-Some-Cookie-Hint`. */
112
106
  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;
113
- /** `g`/`y` are stripped from a custom pattern: those carry lastIndex, which makes `.test()` stateful. */
114
107
  function resolveHeaderDenylist(custom, replaceDefault = false) {
115
108
  if (!custom) return DEFAULT_HEADER_DENYLIST;
116
109
  if (replaceDefault) return new RegExp(custom.source, custom.flags.replace(/[gy]/g, ""));
117
110
  return new RegExp(`(?:${DEFAULT_HEADER_DENYLIST.source})|(?:${custom.source})`, "i");
118
111
  }
119
- /**
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.
124
- */
125
112
  function projectHeaders(headers, options) {
126
113
  const out = {};
127
114
  if (!headers) return out;
@@ -137,38 +124,23 @@ function projectHeaders(headers, options) {
137
124
 
138
125
  //#endregion
139
126
  //#region src/context/process.ts
140
- /**
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).
144
- */
145
127
  function collectProcessAttributes() {
146
128
  return {
147
- "process.runtime.name": "nodejs",
148
- "process.runtime.version": process.version,
149
129
  "process.pid": process.pid,
150
130
  "process.uptime": process.uptime(),
151
131
  "host.name": os.hostname(),
152
- "host.arch": process.arch,
153
- "os.type": os.type(),
154
- "os.version": os.release()
132
+ "host.arch": process.arch
155
133
  };
156
134
  }
157
135
 
158
136
  //#endregion
159
137
  //#region src/context/collectNode.ts
160
- /**
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`.
164
- *
165
- * `getOptions` is a getter so `configureNode(...)` shows up on later reports without rebuilding this.
166
- */
167
138
  function makeNodeContextCollector(provider, getOptions) {
168
139
  return (config) => {
169
140
  const attrs = {
170
141
  "flare.entry_point.type": "web",
171
- ...collectProcessAttributes()
142
+ ...collectProcessAttributes(),
143
+ ...deviceInfoToAttributes(nodeDeviceInfoProvider.collect())
172
144
  };
173
145
  const { request } = provider.active();
174
146
  if (request.method) attrs["http.request.method"] = request.method;
@@ -239,23 +211,12 @@ function buildFatalCallbacks(flare, getOpts, exit = process.exit.bind(process))
239
211
 
240
212
  //#endregion
241
213
  //#region src/process/handlers.ts
242
- /**
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.
251
- */
252
214
  var ProcessHandlerManager = class {
253
215
  uncaughtHandler = null;
254
216
  rejectionHandler = null;
255
217
  constructor(cbs) {
256
218
  this.cbs = cbs;
257
219
  }
258
- /** Idempotent: a no-op when the attached listeners already match the supplied modes. */
259
220
  reconcile(opts) {
260
221
  this.reconcileOne("uncaughtException", opts.uncaughtExceptionMode, () => this.uncaughtHandler, (h) => {
261
222
  this.uncaughtHandler = h;
@@ -264,7 +225,6 @@ var ProcessHandlerManager = class {
264
225
  this.rejectionHandler = h;
265
226
  }, (reason) => this.cbs.onRejection(reason));
266
227
  }
267
- /** Remove both listeners regardless of intent. Safe when nothing is attached. */
268
228
  detach() {
269
229
  if (this.uncaughtHandler) {
270
230
  process.off("uncaughtException", this.uncaughtHandler);
@@ -275,11 +235,6 @@ var ProcessHandlerManager = class {
275
235
  this.rejectionHandler = null;
276
236
  }
277
237
  }
278
- /**
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.
282
- */
283
238
  reconcileOne(event, mode, get, set, impl) {
284
239
  const current = get();
285
240
  const wants = mode !== "off";
@@ -302,25 +257,15 @@ var NodeScope = class extends Scope$1 {
302
257
 
303
258
  //#endregion
304
259
  //#region src/scope/AsyncLocalStorageScopeProvider.ts
305
- /**
306
- * Gives every in-flight request its own `NodeScope`, isolated from concurrent requests.
307
- *
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.
311
- */
312
260
  var AsyncLocalStorageScopeProvider = class {
313
261
  als = new AsyncLocalStorage();
314
262
  fallback = new NodeScope();
315
- /** Never null: falls back to the shared scope outside `runWithContext`. */
316
263
  active() {
317
264
  return this.als.getStore() ?? this.fallback;
318
265
  }
319
- /** Null outside `runWithContext`, so callers can tell "inside a request" from "not". */
320
266
  getContext() {
321
267
  return this.als.getStore() ?? null;
322
268
  }
323
- /** `request` is shallow-cloned so later edits to the caller's object do not leak into the scope. */
324
269
  runWithContext(request, fn) {
325
270
  const scope = new NodeScope();
326
271
  scope.request = { ...request };
@@ -337,11 +282,6 @@ var AsyncLocalStorageScopeProvider = class {
337
282
 
338
283
  //#endregion
339
284
  //#region src/stacktrace/DiskFileReader.ts
340
- /**
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.
344
- */
345
285
  var DiskFileReader = class {
346
286
  async read(url) {
347
287
  if (!isLocalFileUrl(url)) return null;
@@ -352,7 +292,6 @@ var DiskFileReader = class {
352
292
  }
353
293
  }
354
294
  };
355
- /** `file://` (any casing), POSIX absolute, Windows drive-letter (`C:\foo`), Windows UNC (`\\`). */
356
295
  function isLocalFileUrl(url) {
357
296
  return /^file:\/\//i.test(url) || url.startsWith("/") || /^[a-z]:[\\/]/i.test(url) || url.startsWith("\\\\");
358
297
  }
@@ -360,9 +299,7 @@ function isLocalFileUrl(url) {
360
299
  //#endregion
361
300
  //#region src/Flare.ts
362
301
  const NODE_SDK_NAME = "@flareapp/node";
363
- const NODE_SDK_VERSION = typeof process !== "undefined" && true ? "0.10.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. */
302
+ const NODE_SDK_VERSION = typeof process !== "undefined" && true ? "0.12.0" : "?";
366
303
  function sanitizeRegex(re) {
367
304
  const safeFlags = re.flags.replace(/[gy]/g, "");
368
305
  return new RegExp(re.source, safeFlags);
@@ -380,18 +317,12 @@ const DEFAULT_NODE_OPTIONS = {
380
317
  bodyKeyDenylist: DEFAULT_BODY_KEY_DENYLIST
381
318
  };
382
319
  /**
383
- * Node.js-specific `Flare` singleton, exposed from `@flareapp/node` as `flare`.
384
- *
385
- * Subclasses core's `Flare` and wires the Node-only seams in its constructor:
386
- * - `AsyncLocalStorageScopeProvider` so each `runWithContext(...)` callback gets its own `NodeScope`,
387
- * isolated from concurrent requests.
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`.
320
+ * Node.js `Flare` singleton, exported from `@flareapp/node` as `flare`.
391
321
  *
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.
322
+ * Subclasses core's `Flare` and wires the Node-only seams: per-request scope via
323
+ * `AsyncLocalStorageScopeProvider`, a Node context collector, `DiskFileReader` for stack snippets,
324
+ * and `ProcessHandlerManager` for the fatal listeners. Adds `configureNode`, `runWithContext`,
325
+ * `mergeContext`, `getContext`, and `removeProcessListeners` on top of the core API.
395
326
  */
396
327
  var NodeFlare = class extends Flare$1 {
397
328
  nodeOptions = { ...DEFAULT_NODE_OPTIONS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flareapp/node",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Node.js SDK for flareapp.io",
5
5
  "homepage": "https://flareapp.io",
6
6
  "bugs": {
@@ -44,7 +44,7 @@
44
44
  "release": "release-it"
45
45
  },
46
46
  "dependencies": {
47
- "@flareapp/core": "2.10.0"
47
+ "@flareapp/core": "2.12.0"
48
48
  },
49
49
  "devDependencies": {
50
50
  "@flareapp/test-helpers": "*",