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