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