@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 +27 -96
- package/dist/index.d.cts +5 -11
- package/dist/index.d.mts +5 -11
- package/dist/index.mjs +28 -97
- package/package.json +2 -2
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.
|
|
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
|
|
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
|
-
*
|
|
422
|
-
* `
|
|
423
|
-
*
|
|
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
|
|
32
|
+
* Node.js `Flare` singleton, exported from `@flareapp/node` as `flare`.
|
|
33
33
|
*
|
|
34
|
-
* Subclasses core's `Flare` and wires the Node-only seams
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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
|
|
32
|
+
* Node.js `Flare` singleton, exported from `@flareapp/node` as `flare`.
|
|
33
33
|
*
|
|
34
|
-
* Subclasses core's `Flare` and wires the Node-only seams
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
393
|
-
* `
|
|
394
|
-
*
|
|
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.
|
|
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.
|
|
47
|
+
"@flareapp/core": "2.12.0"
|
|
48
48
|
},
|
|
49
49
|
"devDependencies": {
|
|
50
50
|
"@flareapp/test-helpers": "*",
|