@reliantlabs/forge-web-runtime 0.10.0 → 0.10.2

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/node.js ADDED
@@ -0,0 +1,576 @@
1
+ // Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
2
+ //
3
+ // Node / Electron-main telemetry: logs, errors and spans over OTLP/HTTP to the
4
+ // same ingest the browser entry (`/telemetry`) ships to — usually the KEYED
5
+ // ingest, `https://<ingest host>/k/<key>`, since a desktop app's main process
6
+ // has no same-origin `/_otel`.
7
+ //
8
+ // ── What it records ──────────────────────────────────────────────────────
9
+ //
10
+ // - uncaught exceptions and unhandled rejections, as ERROR log records in
11
+ // the shape forge/pkg/observe gives a server error (node-exception.ts), so
12
+ // `forge obs issues` groups them with sfp1;
13
+ // - what the app logs through `telemetry.logger`, and errors it hands to
14
+ // `telemetry.reportError` (and to the barrel's `reportException`);
15
+ // - spans: the global @opentelemetry/api tracer is this one (node-trace.ts).
16
+ //
17
+ // Records carry no `rum.sessionId`. That is what makes the issues pipeline
18
+ // treat them as server-side errors (obsquery.ServerErrorFilterSQL), which is
19
+ // what a main process is.
20
+ //
21
+ // ── What it changes about the process ────────────────────────────────────
22
+ //
23
+ // Nothing an app can observe, by design. Exceptions are watched through
24
+ // `uncaughtExceptionMonitor`, which leaves the process's fate to whoever owns
25
+ // it (the app's own handler, Electron's dialog, Node's exit). Rejections have
26
+ // no monitor event, and any `unhandledRejection` listener — this one included
27
+ // — switches off Node's default of raising the rejection as an uncaught
28
+ // exception. So when this listener is the only one, it puts the default back:
29
+ // it flushes the record, then raises the rejection as Node would have
30
+ // (`--unhandled-rejections` is honoured). In plain Node with no
31
+ // `uncaughtException` handler of the app's own, the process would die before
32
+ // an async export could finish, so the runtime flushes first and then exits
33
+ // as Node does (stderr, code 1). Under Electron the main process outlives an
34
+ // uncaught exception by default, and nothing is installed.
35
+ //
36
+ // ── Batching and quitting ────────────────────────────────────────────────
37
+ //
38
+ // Records are queued and exported in batches (delay and size below), with
39
+ // unref'd timers, so telemetry never keeps a process alive. A fatal record
40
+ // starts its export at once. `flush(timeoutMs)` exports everything queued and
41
+ // resolves by the timeout whatever the network does; `flushOnQuit(app)` runs
42
+ // it from Electron's `before-quit`. An idle Node process flushes on
43
+ // `beforeExit`.
44
+ //
45
+ // ── Privacy ──────────────────────────────────────────────────────────────
46
+ //
47
+ // Nothing about the user beyond what the browser entry sends: an opaque user
48
+ // id when the app sets one, the app client, and the record itself. Messages,
49
+ // bodies and string attributes have credential shapes redacted with the same
50
+ // patterns `track()` uses; an exception keeps only its message's first line;
51
+ // stack paths lose the home directory and the install location; object-valued
52
+ // attributes are dropped, never serialized. No host name, no IP, no argv, no
53
+ // environment.
54
+ import { createRequire } from "node:module";
55
+ import { homedir } from "node:os";
56
+ import { inspect } from "node:util";
57
+ import { context as contextApi, propagation, trace, } from "@opentelemetry/api";
58
+ import { setExceptionReporter } from "./error-reporter.js";
59
+ import { bound, boundMessage, exceptionAttributes, } from "./node-exception.js";
60
+ import { BatchQueue, encodeAttributes, exportSignal, logsPayload, MAX_ATTRIBUTE_CHARS, nowMs, SEVERITY, tracesPayload, unixNanos, } from "./node-otlp.js";
61
+ import { AsyncLocalContextManager, NodeTracerProvider, TraceparentPropagator, } from "./node-trace.js";
62
+ import { redactSecrets } from "./track.js";
63
+ export { frontendServiceName } from "./service-name.js";
64
+ export { CODE_FILE, CODE_FUNCTION, CODE_LINE, ERROR_GROUP, EXCEPTION_MESSAGE, EXCEPTION_STACKTRACE, EXCEPTION_TYPE, exceptionAttributes, } from "./node-exception.js";
65
+ /** The instrumentation scope of every log record this entry writes. */
66
+ export const NODE_SCOPE_NAME = "@reliantlabs/forge-web-runtime/node";
67
+ /** Body of the record for an uncaught exception. */
68
+ export const UNCAUGHT_EXCEPTION_BODY = "uncaught exception";
69
+ /** Body of the record for an unhandled rejection. */
70
+ export const UNHANDLED_REJECTION_BODY = "unhandled rejection";
71
+ /** Body of the record for an error passed to reportError / reportException. */
72
+ export const REPORTED_EXCEPTION_BODY = "caught exception";
73
+ /** Longest log body. */
74
+ export const MAX_BODY_CHARS = 4096;
75
+ /** Longest user id, as the browser entry bounds it. */
76
+ export const MAX_USER_ID_CHARS = 128;
77
+ export const DEFAULT_FLUSH_TIMEOUT_MS = 2_000;
78
+ export const DEFAULT_BATCH_DELAY_MS = 5_000;
79
+ export const DEFAULT_MAX_BATCH = 256;
80
+ export const DEFAULT_MAX_QUEUE = 2_048;
81
+ export const DEFAULT_EXPORT_TIMEOUT_MS = 10_000;
82
+ /**
83
+ * Most records of ONE fatal error (same type, message and frame) per minute.
84
+ * A handler that throws while handling — an EPIPE logger writing to the pipe
85
+ * that just broke — raises the same exception in a tight loop, and each one
86
+ * would otherwise be a record and a request.
87
+ */
88
+ export const MAX_REPEATS_PER_MINUTE = 5;
89
+ let current = null;
90
+ /** The running instance, or null before init / after shutdown. */
91
+ export function getNodeTelemetry() {
92
+ return current;
93
+ }
94
+ /**
95
+ * The absolute OTLP base for `endpoint`, or null when telemetry is off: unset,
96
+ * empty, `off`, or not an absolute http(s) URL. Trailing slashes are dropped.
97
+ */
98
+ export function resolveNodeIngestUrl(endpoint) {
99
+ const value = (endpoint ?? "").trim();
100
+ if (value === "" || value.toLowerCase() === "off") {
101
+ return null;
102
+ }
103
+ try {
104
+ const url = new URL(value);
105
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
106
+ return null;
107
+ }
108
+ url.search = "";
109
+ url.hash = "";
110
+ return url.toString().replace(/\/+$/, "");
111
+ }
112
+ catch {
113
+ return null;
114
+ }
115
+ }
116
+ function sdkVersion() {
117
+ try {
118
+ const req = createRequire(import.meta.url);
119
+ const v = req("../package.json").version;
120
+ return typeof v === "string" ? v : "";
121
+ }
122
+ catch {
123
+ return "";
124
+ }
125
+ }
126
+ const OS_TYPE = { win32: "windows", sunos: "solaris" };
127
+ const HOST_ARCH = {
128
+ x64: "amd64",
129
+ ia32: "x86",
130
+ ppc64: "ppc64",
131
+ };
132
+ /**
133
+ * The resource every record and span carries. Pure given its inputs;
134
+ * exported for tests and for an app that wants to see exactly what is sent.
135
+ */
136
+ export function buildNodeResource(config, runtime, version = sdkVersion()) {
137
+ const electron = runtime.versions.electron;
138
+ const out = {
139
+ "telemetry.sdk.name": "@reliantlabs/forge-web-runtime",
140
+ "telemetry.sdk.language": "nodejs",
141
+ "process.runtime.name": electron ? "electron" : "nodejs",
142
+ "process.runtime.version": (electron ??
143
+ runtime.versions.node ??
144
+ ""),
145
+ "os.type": OS_TYPE[runtime.platform] ?? runtime.platform,
146
+ "host.arch": HOST_ARCH[runtime.arch] ?? runtime.arch,
147
+ };
148
+ if (version)
149
+ out["telemetry.sdk.version"] = version;
150
+ if (config.serviceVersion)
151
+ out["service.version"] = config.serviceVersion;
152
+ if (config.serviceNamespace)
153
+ out["service.namespace"] = config.serviceNamespace;
154
+ if (config.frontend)
155
+ out["forge.frontend"] = config.frontend;
156
+ if (config.environment) {
157
+ out["deployment.environment.name"] = config.environment;
158
+ out["deployment.environment"] = config.environment;
159
+ }
160
+ Object.assign(out, config.resourceAttributes ?? {});
161
+ out["service.name"] = config.serviceName;
162
+ return out;
163
+ }
164
+ function scrubString(value, max) {
165
+ return bound(redactSecrets(value.slice(0, max * 2)), max);
166
+ }
167
+ /**
168
+ * Reduce caller attributes to what a record may carry: strings redacted and
169
+ * bounded, finite numbers, booleans, arrays of those; an Error becomes its
170
+ * bounded message (its stack goes in exception.*). Objects are dropped.
171
+ */
172
+ function sanitizeAttributes(attrs) {
173
+ const out = {};
174
+ let error = undefined;
175
+ if (!attrs || typeof attrs !== "object") {
176
+ return { attributes: out, error };
177
+ }
178
+ for (const [key, v] of Object.entries(attrs)) {
179
+ if (v instanceof Error) {
180
+ if (error === undefined)
181
+ error = v;
182
+ out[key] = boundMessage(v.message ?? "");
183
+ }
184
+ else if (typeof v === "string") {
185
+ out[key] = scrubString(v, MAX_ATTRIBUTE_CHARS);
186
+ }
187
+ else if ((typeof v === "number" && Number.isFinite(v)) ||
188
+ typeof v === "boolean") {
189
+ out[key] = v;
190
+ }
191
+ else if (Array.isArray(v)) {
192
+ const kept = v
193
+ .filter((x) => typeof x === "string" ||
194
+ typeof x === "boolean" ||
195
+ (typeof x === "number" && Number.isFinite(x)))
196
+ .map((x) => typeof x === "string" ? scrubString(x, MAX_ATTRIBUTE_CHARS) : x);
197
+ out[key] = kept;
198
+ }
199
+ }
200
+ return { attributes: out, error };
201
+ }
202
+ function withTimeout(p, ms) {
203
+ return new Promise((resolve) => {
204
+ const t = setTimeout(resolve, Math.max(0, ms));
205
+ t.unref?.();
206
+ void p.then(() => {
207
+ clearTimeout(t);
208
+ resolve();
209
+ }, () => {
210
+ clearTimeout(t);
211
+ resolve();
212
+ });
213
+ });
214
+ }
215
+ /** What `--unhandled-rejections` says Node does with a rejection nobody handles. */
216
+ export function unhandledRejectionMode(execArgv, nodeOptions) {
217
+ const args = [...execArgv, ...(nodeOptions ?? "").split(/\s+/)];
218
+ let mode = "throw";
219
+ for (let i = 0; i < args.length; i++) {
220
+ const a = args[i] ?? "";
221
+ if (a.startsWith("--unhandled-rejections=")) {
222
+ mode = a.slice("--unhandled-rejections=".length);
223
+ }
224
+ else if (a === "--unhandled-rejections" && args[i + 1]) {
225
+ mode = args[i + 1];
226
+ }
227
+ }
228
+ return mode;
229
+ }
230
+ /** The error Node raises for a rejection whose reason is not an Error. */
231
+ function rejectionError(reason) {
232
+ const err = new Error("This error originated either by throwing inside of an async function without a catch block, " +
233
+ "or by rejecting a promise which was not handled with .catch(). The promise rejected with the reason " +
234
+ `"${inspect(reason)}".`);
235
+ err.name = "UnhandledPromiseRejection";
236
+ err.code = "ERR_UNHANDLED_REJECTION";
237
+ return err;
238
+ }
239
+ /**
240
+ * initNodeTelemetry starts telemetry for this process and returns it, or null
241
+ * when it is off (no usable endpoint, no fetch, not Node). Idempotent: a
242
+ * second call returns the running instance and ignores its config. Never
243
+ * throws.
244
+ */
245
+ export function initNodeTelemetry(config) {
246
+ if (current) {
247
+ return current;
248
+ }
249
+ try {
250
+ const instance = start(config);
251
+ current = instance;
252
+ return instance;
253
+ }
254
+ catch {
255
+ // Telemetry must never break the app.
256
+ return null;
257
+ }
258
+ }
259
+ function start(config) {
260
+ if (typeof process === "undefined" || !process.versions?.node) {
261
+ return null;
262
+ }
263
+ const endpoint = resolveNodeIngestUrl(config.endpoint);
264
+ const fetchFn = config.fetch ??
265
+ (typeof globalThis.fetch === "function"
266
+ ? globalThis.fetch
267
+ : undefined);
268
+ if (!endpoint || !fetchFn || !config.serviceName) {
269
+ return null;
270
+ }
271
+ const isElectron = Boolean(process.versions.electron);
272
+ const scrub = {
273
+ appRoot: config.appRoot ?? process.cwd(),
274
+ homeDir: safeHomedir(),
275
+ caseInsensitive: process.platform === "win32",
276
+ };
277
+ const resource = encodeAttributes(buildNodeResource(config, {
278
+ versions: process.versions,
279
+ platform: process.platform,
280
+ arch: process.arch,
281
+ }));
282
+ const headers = { ...(config.headers ?? {}) };
283
+ const origin = config.origin?.trim().replace(/\/+$/, "");
284
+ if (origin) {
285
+ headers["origin"] = origin;
286
+ }
287
+ const exporter = {
288
+ endpoint,
289
+ headers,
290
+ fetch: fetchFn,
291
+ timeoutMs: config.exportTimeoutMs ?? DEFAULT_EXPORT_TIMEOUT_MS,
292
+ retries: 2,
293
+ onError: config.onExportError,
294
+ };
295
+ const batch = {
296
+ maxQueue: config.maxQueueSize ?? DEFAULT_MAX_QUEUE,
297
+ maxBatch: config.maxBatchSize ?? DEFAULT_MAX_BATCH,
298
+ delayMs: config.batchDelayMs ?? DEFAULT_BATCH_DELAY_MS,
299
+ };
300
+ const logScope = {
301
+ name: NODE_SCOPE_NAME,
302
+ version: sdkVersion() || undefined,
303
+ };
304
+ const logs = new BatchQueue({
305
+ ...batch,
306
+ send: (records) => exportSignal(exporter, "logs", logsPayload(resource, logScope, records)),
307
+ });
308
+ const spans = new BatchQueue({
309
+ ...batch,
310
+ send: (records) => exportSignal(exporter, "traces", tracesPayload(resource, records)),
311
+ });
312
+ let stopped = false;
313
+ let userId = null;
314
+ const globals = () => {
315
+ const g = {};
316
+ if (config.appClient)
317
+ g["app.client"] = config.appClient;
318
+ if (userId)
319
+ g["userId"] = userId;
320
+ return g;
321
+ };
322
+ // Context: ours, registered globally when allowed. If another manager got
323
+ // there first, spans follow ITS context so the app's context.active() and
324
+ // ours agree.
325
+ const manager = new AsyncLocalContextManager();
326
+ const registerGlobal = config.registerGlobal ?? true;
327
+ const ownsContext = registerGlobal && contextApi.setGlobalContextManager(manager);
328
+ const ctxOps = registerGlobal
329
+ ? {
330
+ active: () => contextApi.active(),
331
+ with: (c, fn) => contextApi.with(c, fn),
332
+ }
333
+ : { active: () => manager.active(), with: (c, fn) => manager.with(c, fn) };
334
+ const provider = new NodeTracerProvider((span) => {
335
+ if (!stopped)
336
+ spans.push(span);
337
+ }, globals, ctxOps);
338
+ const ownsTrace = registerGlobal && trace.setGlobalTracerProvider(provider);
339
+ const ownsPropagation = registerGlobal &&
340
+ propagation.setGlobalPropagator(new TraceparentPropagator());
341
+ const tracer = provider.getTracer(NODE_SCOPE_NAME, sdkVersion() || undefined);
342
+ const minSeverity = SEVERITY[config.minSeverity ?? "INFO"] ?? SEVERITY.INFO;
343
+ function emit(severity, body, attributes) {
344
+ if (stopped || SEVERITY[severity] < minSeverity) {
345
+ return;
346
+ }
347
+ const now = unixNanos(nowMs());
348
+ const record = {
349
+ timeUnixNano: now,
350
+ observedTimeUnixNano: now,
351
+ severityNumber: SEVERITY[severity],
352
+ severityText: severity,
353
+ body: { stringValue: scrubString(body, MAX_BODY_CHARS) },
354
+ attributes: encodeAttributes({ ...globals(), ...attributes }),
355
+ };
356
+ const sc = trace.getSpanContext(ctxOps.active());
357
+ if (sc && trace.isSpanContextValid(sc)) {
358
+ record.traceId = sc.traceId;
359
+ record.spanId = sc.spanId;
360
+ record.flags = sc.traceFlags;
361
+ }
362
+ logs.push(record);
363
+ }
364
+ function log(severity, body, attrs) {
365
+ try {
366
+ const { attributes, error } = sanitizeAttributes(attrs);
367
+ // As observe: only an ERROR record gets exception inputs, and an
368
+ // attribute the caller set wins over the derived one.
369
+ const derived = severity === "ERROR" && error !== undefined
370
+ ? exceptionAttributes(error, scrub)
371
+ : {};
372
+ emit(severity, body, { ...derived, ...attributes });
373
+ }
374
+ catch {
375
+ // Telemetry must never break the app.
376
+ }
377
+ }
378
+ const logger = {
379
+ debug: (b, a) => log("DEBUG", b, a),
380
+ info: (b, a) => log("INFO", b, a),
381
+ warn: (b, a) => log("WARN", b, a),
382
+ error: (b, a) => log("ERROR", b, a),
383
+ };
384
+ function reportError(error, attrs) {
385
+ try {
386
+ const { attributes } = sanitizeAttributes(attrs);
387
+ emit("ERROR", REPORTED_EXCEPTION_BODY, {
388
+ ...exceptionAttributes(error, scrub),
389
+ ...attributes,
390
+ });
391
+ }
392
+ catch {
393
+ // Telemetry must never break the app.
394
+ }
395
+ }
396
+ // ── Process errors ──────────────────────────────────────────────────────
397
+ const recorded = new WeakSet();
398
+ const repeats = new Map();
399
+ let drainScheduled = false;
400
+ function recordFatal(value, body) {
401
+ if (typeof value === "object" && value !== null) {
402
+ if (recorded.has(value))
403
+ return;
404
+ recorded.add(value);
405
+ }
406
+ try {
407
+ const attributes = exceptionAttributes(value, scrub);
408
+ const key = [
409
+ body,
410
+ attributes["exception.type"],
411
+ attributes["code.function.name"],
412
+ attributes["exception.message"],
413
+ ].join("\u001f");
414
+ const now = Date.now();
415
+ let seen = repeats.get(key);
416
+ if (!seen || now - seen.since >= 60_000) {
417
+ if (repeats.size >= 256)
418
+ repeats.clear();
419
+ seen = { since: now, count: 0 };
420
+ repeats.set(key, seen);
421
+ }
422
+ if (++seen.count > MAX_REPEATS_PER_MINUTE) {
423
+ return;
424
+ }
425
+ emit("ERROR", body, attributes);
426
+ // A fatal record does not wait for its batch; a burst in one turn of the
427
+ // event loop goes out as one request.
428
+ if (!drainScheduled) {
429
+ drainScheduled = true;
430
+ setImmediate(() => {
431
+ drainScheduled = false;
432
+ logs.drain();
433
+ }).unref();
434
+ }
435
+ }
436
+ catch {
437
+ // Telemetry must never break the app.
438
+ }
439
+ }
440
+ const fatalFlushMs = config.fatalFlushTimeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS;
441
+ const onMonitor = (err, origin) => {
442
+ recordFatal(err, origin === "unhandledRejection"
443
+ ? UNHANDLED_REJECTION_BODY
444
+ : UNCAUGHT_EXCEPTION_BODY);
445
+ };
446
+ const onRejection = (reason) => {
447
+ recordFatal(reason, UNHANDLED_REJECTION_BODY);
448
+ if (process.listenerCount("unhandledRejection") > 1) {
449
+ return; // The app handles rejections; it decides what happens next.
450
+ }
451
+ const mode = unhandledRejectionMode(process.execArgv, process.env.NODE_OPTIONS);
452
+ if (mode === "warn" || mode === "none") {
453
+ return;
454
+ }
455
+ if (mode === "warn-with-error-code") {
456
+ process.exitCode = 1;
457
+ return;
458
+ }
459
+ // throw / strict: what Node does with no listener at all — raise it as an
460
+ // uncaught exception — once the record is out.
461
+ const err = reason instanceof Error ? reason : rejectionError(reason);
462
+ recorded.add(err);
463
+ void withTimeout(flushAll(), fatalFlushMs).then(() => process.nextTick(() => {
464
+ throw err;
465
+ }));
466
+ };
467
+ let exiting = false;
468
+ const onUncaught = (err) => {
469
+ if (process.listenerCount("uncaughtException") > 1 || exiting) {
470
+ return; // The app's own handler owns the process's fate.
471
+ }
472
+ // Node's default (print, exit 1), after the record is out.
473
+ exiting = true;
474
+ void withTimeout(flushAll(), fatalFlushMs).then(() => {
475
+ try {
476
+ process.stderr.write(`${err instanceof Error && err.stack ? err.stack : inspect(err)}\n`);
477
+ }
478
+ catch {
479
+ // stderr may be gone; the exit code still says what happened.
480
+ }
481
+ process.exit(1);
482
+ });
483
+ };
484
+ const onBeforeExit = () => {
485
+ if (logs.size > 0 || spans.size > 0) {
486
+ void flushAll();
487
+ }
488
+ };
489
+ const captureErrors = config.captureProcessErrors ?? true;
490
+ if (captureErrors) {
491
+ process.on("uncaughtExceptionMonitor", onMonitor);
492
+ process.on("unhandledRejection", onRejection);
493
+ if (!isElectron) {
494
+ process.on("uncaughtException", onUncaught);
495
+ }
496
+ }
497
+ process.on("beforeExit", onBeforeExit);
498
+ setExceptionReporter((error, attributes) => reportError(error, attributes));
499
+ function flushAll() {
500
+ return Promise.all([logs.flush(), spans.flush()]).then(() => undefined);
501
+ }
502
+ const instance = {
503
+ logger,
504
+ tracer,
505
+ reportError,
506
+ setUser(id) {
507
+ userId =
508
+ typeof id === "string" && id !== ""
509
+ ? id.slice(0, MAX_USER_ID_CHARS)
510
+ : null;
511
+ },
512
+ flush(timeoutMs = DEFAULT_FLUSH_TIMEOUT_MS) {
513
+ return withTimeout(flushAll(), timeoutMs);
514
+ },
515
+ async shutdown(timeoutMs = DEFAULT_FLUSH_TIMEOUT_MS) {
516
+ if (stopped)
517
+ return;
518
+ await withTimeout(flushAll(), timeoutMs);
519
+ stopped = true;
520
+ process.removeListener("uncaughtExceptionMonitor", onMonitor);
521
+ process.removeListener("unhandledRejection", onRejection);
522
+ process.removeListener("uncaughtException", onUncaught);
523
+ process.removeListener("beforeExit", onBeforeExit);
524
+ setExceptionReporter(null);
525
+ if (ownsTrace)
526
+ trace.disable();
527
+ if (ownsPropagation)
528
+ propagation.disable();
529
+ if (ownsContext)
530
+ contextApi.disable();
531
+ if (current === instance)
532
+ current = null;
533
+ },
534
+ get dropped() {
535
+ return logs.dropped + spans.dropped;
536
+ },
537
+ };
538
+ return instance;
539
+ }
540
+ function safeHomedir() {
541
+ try {
542
+ return homedir() || undefined;
543
+ }
544
+ catch {
545
+ return undefined;
546
+ }
547
+ }
548
+ /**
549
+ * Hold Electron's quit until the running telemetry has flushed (bounded by
550
+ * `timeoutMs`, default 2s), then quit. Returns a function that removes the
551
+ * hook.
552
+ *
553
+ * It defers the FIRST quit and calls app.quit() again when the flush is done.
554
+ * An app that already defers quit for its own shutdown (stops a backend, then
555
+ * exits) should instead `await telemetry.flush()` as the last step of that
556
+ * sequence: two listeners that each defer and re-quit cut each other short.
557
+ */
558
+ export function flushOnQuit(app, options = {}) {
559
+ let flushed = false;
560
+ const listener = (event) => {
561
+ const t = current;
562
+ if (flushed || !t) {
563
+ return;
564
+ }
565
+ flushed = true;
566
+ event.preventDefault();
567
+ void t
568
+ .flush(options.timeoutMs ?? DEFAULT_FLUSH_TIMEOUT_MS)
569
+ .finally(() => app.quit());
570
+ };
571
+ app.on("before-quit", listener);
572
+ return () => {
573
+ app.removeListener?.("before-quit", listener);
574
+ };
575
+ }
576
+ //# sourceMappingURL=node.js.map