@loxel.dev/pharos-browser 0.6.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.js ADDED
@@ -0,0 +1,504 @@
1
+ import {
2
+ ACTIVATION_EVENT_TAG,
3
+ EXCLUDE_CLASS,
4
+ LOCAL_TTL_MS,
5
+ MASK_CLASS,
6
+ MASK_TOKEN,
7
+ MAX_BUFFER_BYTES,
8
+ MAX_WINDOW_BYTES,
9
+ RECORDER_DEFAULTS,
10
+ beginsWithSnapshot,
11
+ clearPersisted,
12
+ createDropLedger,
13
+ createUploadSink,
14
+ decide,
15
+ drainPersisted,
16
+ encodeWindow,
17
+ envelopeMetaFor,
18
+ isSensitiveField,
19
+ maskText,
20
+ masksValue,
21
+ persistWindow,
22
+ postEnvelope,
23
+ quotaDroppedWindows,
24
+ resetQuotaDroppedWindows,
25
+ resolveConfig,
26
+ scrubAttribute,
27
+ scrubUrl,
28
+ shouldExclude,
29
+ shouldMaskByMarker
30
+ } from "./index-bc4bw3ba.js";
31
+ import {
32
+ __require
33
+ } from "./index-c3taa3cg.js";
34
+
35
+ // src/stack.ts
36
+ var MAX_FRAMES = 50;
37
+ var V8_WITH_FN = /^at\s+(.+?)\s+\((.+):(\d+):(\d+)\)$/;
38
+ var V8_NO_FN = /^at\s+(.+):(\d+):(\d+)$/;
39
+ var FIREFOX = /^([^@]*)@(.+):(\d+):(\d+)$/;
40
+ function parseLine(line) {
41
+ let m = V8_WITH_FN.exec(line);
42
+ if (m) {
43
+ return { fn: m[1], file: m[2], line: Number(m[3]), col: Number(m[4]) };
44
+ }
45
+ m = V8_NO_FN.exec(line);
46
+ if (m) {
47
+ return { file: m[1], line: Number(m[2]), col: Number(m[3]) };
48
+ }
49
+ m = FIREFOX.exec(line);
50
+ if (m) {
51
+ const frame = { file: m[2], line: Number(m[3]), col: Number(m[4]) };
52
+ if (m[1])
53
+ frame.fn = m[1];
54
+ return frame;
55
+ }
56
+ return null;
57
+ }
58
+ function parseStack(stack) {
59
+ const frames = [];
60
+ for (const rawLine of stack.split(`
61
+ `)) {
62
+ if (frames.length >= MAX_FRAMES)
63
+ break;
64
+ const line = rawLine.trim();
65
+ if (!line)
66
+ continue;
67
+ const frame = parseLine(line);
68
+ if (frame)
69
+ frames.push(frame);
70
+ }
71
+ return frames;
72
+ }
73
+ // src/privacy/collect.ts
74
+ var STRUCTURAL_ATTRS = new Set(["class", "type", "contenteditable"]);
75
+ function pathOf(el, root) {
76
+ const parts = [];
77
+ let node = el;
78
+ while (node && node !== root) {
79
+ const parent = node.parentElement;
80
+ if (!parent)
81
+ break;
82
+ const index = Array.prototype.indexOf.call(parent.children, node) + 1;
83
+ parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`);
84
+ node = parent;
85
+ }
86
+ return parts.join(" > ");
87
+ }
88
+ function collectRecordedStrings(root) {
89
+ const out = [];
90
+ const visit = (el) => {
91
+ const decision = decide(el);
92
+ if (decision === "exclude")
93
+ return;
94
+ const path = pathOf(el, root);
95
+ if (decision === "record" || decision === "mask") {
96
+ const valueIsMasked = masksValue(el);
97
+ for (const attr of Array.from(el.attributes)) {
98
+ const name = attr.name.toLowerCase();
99
+ if (STRUCTURAL_ATTRS.has(name))
100
+ continue;
101
+ if (name === "value" && valueIsMasked)
102
+ continue;
103
+ const scrubbed = scrubAttribute(el, attr.name, attr.value);
104
+ if (scrubbed === null || scrubbed === MASK_TOKEN)
105
+ continue;
106
+ if (scrubbed === "")
107
+ continue;
108
+ out.push({ value: scrubbed, path, origin: "attribute", attribute: attr.name });
109
+ }
110
+ if (decision === "record" && !valueIsMasked) {
111
+ for (const child of Array.from(el.childNodes)) {
112
+ if (child.nodeType !== 3)
113
+ continue;
114
+ const text = (child.textContent ?? "").trim();
115
+ if (text)
116
+ out.push({ value: text, path, origin: "text" });
117
+ }
118
+ }
119
+ }
120
+ for (const child of Array.from(el.children))
121
+ visit(child);
122
+ };
123
+ visit(root);
124
+ return out;
125
+ }
126
+
127
+ // src/index.ts
128
+ var MAX_MESSAGE_LEN = 8192;
129
+ var MAX_KIND_SITE_LEN = 256;
130
+ var MAX_FRAME_FIELD_LEN = 512;
131
+ var MAX_FLAG_KEYS = 64;
132
+ var MAX_ATTR_KEYS = 64;
133
+ var MAX_ATTR_KEY_LEN = 128;
134
+ function isScalar(v) {
135
+ return typeof v === "string" || typeof v === "number" || typeof v === "boolean";
136
+ }
137
+ function truncate(s, max) {
138
+ return s.length > max ? s.slice(0, max) : s;
139
+ }
140
+ function sanitizeMessage(err) {
141
+ let raw;
142
+ if (err && typeof err === "object" && typeof err.message === "string") {
143
+ raw = err.message;
144
+ } else {
145
+ raw = String(err);
146
+ }
147
+ return truncate(raw.length > 0 ? raw : "(no message)", MAX_MESSAGE_LEN);
148
+ }
149
+ function sanitizeKind(err) {
150
+ if (err && typeof err === "object") {
151
+ const name = err.name;
152
+ if (typeof name === "string" && name.length > 0)
153
+ return truncate(name, MAX_KIND_SITE_LEN);
154
+ }
155
+ return;
156
+ }
157
+ function triggerSourceFor(kind, err) {
158
+ const name = sanitizeKind(err);
159
+ return name !== undefined ? { kind, name } : { kind };
160
+ }
161
+ function sanitizeStack(err) {
162
+ if (!err || typeof err !== "object")
163
+ return [];
164
+ const stack = err.stack;
165
+ if (typeof stack !== "string")
166
+ return [];
167
+ return parseStack(stack).map((f) => {
168
+ const out = {};
169
+ if (f.fn !== undefined)
170
+ out.fn = truncate(f.fn, MAX_FRAME_FIELD_LEN);
171
+ if (f.file !== undefined)
172
+ out.file = truncate(f.file, MAX_FRAME_FIELD_LEN);
173
+ if (typeof f.line === "number")
174
+ out.line = f.line;
175
+ if (typeof f.col === "number")
176
+ out.col = f.col;
177
+ return out;
178
+ });
179
+ }
180
+ function sanitizeFlags(flags) {
181
+ const out = {};
182
+ for (const k of Object.keys(flags).slice(0, MAX_FLAG_KEYS)) {
183
+ const v = flags[k];
184
+ if (isScalar(v))
185
+ out[k] = v;
186
+ }
187
+ return out;
188
+ }
189
+ function sanitizeAttrs(attrs) {
190
+ const out = {};
191
+ for (const k of Object.keys(attrs).slice(0, MAX_ATTR_KEYS)) {
192
+ if (k.length > MAX_ATTR_KEY_LEN)
193
+ continue;
194
+ const v = attrs[k];
195
+ if (isScalar(v)) {
196
+ out[k] = v;
197
+ } else if (Array.isArray(v)) {
198
+ const scalars = v.filter(isScalar);
199
+ if (scalars.length > 0)
200
+ out[k] = scalars;
201
+ }
202
+ }
203
+ return out;
204
+ }
205
+ var RECONNECT_BACKOFF_MS = 1000;
206
+ function buildBootstrapBody(context) {
207
+ const { contextKey, application, release, sessionId, attributes } = context;
208
+ const pharos = { contextKey };
209
+ if (application !== undefined)
210
+ pharos.application = application;
211
+ if (release !== undefined)
212
+ pharos.release = release;
213
+ if (sessionId !== undefined)
214
+ pharos.sessionId = sessionId;
215
+ return { pharos, attributes: attributes ?? {} };
216
+ }
217
+
218
+ class PharosBrowserClient {
219
+ baseUrl;
220
+ clientKey;
221
+ context;
222
+ eventSourceFactory;
223
+ flags = {};
224
+ version = -Infinity;
225
+ streamToken = null;
226
+ eventSource = null;
227
+ listeners = {
228
+ change: new Set,
229
+ error: new Set
230
+ };
231
+ closed = false;
232
+ reconnectTimer = null;
233
+ errorsEnabled;
234
+ errorTarget;
235
+ replayHandle = null;
236
+ replayTriggerKind = "exception";
237
+ replayErrorListeners = null;
238
+ onReplayError = (event) => {
239
+ const e = event;
240
+ this.replayHandle?.trigger(triggerSourceFor("exception", e?.error ?? e?.message));
241
+ };
242
+ onReplayRejection = (event) => {
243
+ const e = event;
244
+ this.replayHandle?.trigger(triggerSourceFor("unhandledrejection", e?.reason));
245
+ };
246
+ constructor(config) {
247
+ this.baseUrl = config.baseUrl.replace(/\/+$/, "");
248
+ this.clientKey = config.clientKey;
249
+ this.context = config.context;
250
+ this.eventSourceFactory = config.eventSourceFactory ?? ((url) => new EventSource(url));
251
+ this.errorsEnabled = config.errors ?? true;
252
+ this.errorTarget = config.errorTarget ?? globalThis;
253
+ }
254
+ static async init(config) {
255
+ const client = new PharosBrowserClient(config);
256
+ await client.bootstrap();
257
+ client.connectStream();
258
+ if (client.errorsEnabled)
259
+ client.registerErrorListeners();
260
+ return client;
261
+ }
262
+ async bootstrap() {
263
+ const body = buildBootstrapBody(this.context);
264
+ const res = await fetch(`${this.baseUrl}/api/v1/client/bootstrap`, {
265
+ method: "POST",
266
+ headers: {
267
+ "Content-Type": "application/json",
268
+ Authorization: `Bearer ${this.clientKey}`
269
+ },
270
+ body: JSON.stringify(body)
271
+ });
272
+ if (!res.ok) {
273
+ throw new Error(`pharos bootstrap failed: HTTP ${res.status}`);
274
+ }
275
+ const data = await res.json();
276
+ this.flags = data.flags ?? {};
277
+ this.version = data.version ?? 0;
278
+ this.streamToken = data.streamToken;
279
+ }
280
+ connectStream() {
281
+ if (!this.streamToken)
282
+ return;
283
+ const url = `${this.baseUrl}/api/v1/client/stream?token=${encodeURIComponent(this.streamToken)}`;
284
+ const es = this.eventSourceFactory(url);
285
+ es.addEventListener("flags", (event) => {
286
+ try {
287
+ const payload = JSON.parse(event.data);
288
+ if (typeof payload.version === "number" && payload.version < this.version) {
289
+ return;
290
+ }
291
+ this.flags = payload.flags ?? {};
292
+ this.version = payload.version;
293
+ this.emit("change", payload);
294
+ } catch (err) {
295
+ this.emit("error", err);
296
+ }
297
+ });
298
+ es.onerror = () => {
299
+ this.emit("error", { type: "stream-error" });
300
+ this.maybeReconnect(es);
301
+ };
302
+ this.eventSource = es;
303
+ }
304
+ maybeReconnect(es) {
305
+ if (this.closed || this.reconnectTimer)
306
+ return;
307
+ if (es.readyState !== 2)
308
+ return;
309
+ this.reconnectTimer = setTimeout(() => {
310
+ this.reconnectTimer = null;
311
+ if (this.closed)
312
+ return;
313
+ this.identify(this.context).catch((err) => this.emit("error", err));
314
+ }, RECONNECT_BACKOFF_MS);
315
+ }
316
+ onWindowError = (event) => {
317
+ const e = event;
318
+ this.captureException(e?.error ?? e?.message);
319
+ };
320
+ onUnhandledRejection = (event) => {
321
+ const e = event;
322
+ this.replayTriggerKind = "unhandledrejection";
323
+ try {
324
+ this.captureException(e?.reason);
325
+ } finally {
326
+ this.replayTriggerKind = "exception";
327
+ }
328
+ };
329
+ registerErrorListeners() {
330
+ this.errorTarget.addEventListener("error", this.onWindowError);
331
+ this.errorTarget.addEventListener("unhandledrejection", this.onUnhandledRejection);
332
+ }
333
+ unregisterErrorListeners() {
334
+ this.errorTarget.removeEventListener("error", this.onWindowError);
335
+ this.errorTarget.removeEventListener("unhandledrejection", this.onUnhandledRejection);
336
+ }
337
+ captureException(err, opts) {
338
+ if (this.closed)
339
+ return;
340
+ const entry = { message: sanitizeMessage(err) };
341
+ const kind = sanitizeKind(err);
342
+ if (kind !== undefined)
343
+ entry.kind = kind;
344
+ this.replayHandle?.trigger(triggerSourceFor(this.replayTriggerKind, err));
345
+ if (opts?.site)
346
+ entry.site = truncate(opts.site, MAX_KIND_SITE_LEN);
347
+ const stack = sanitizeStack(err);
348
+ if (stack.length > 0)
349
+ entry.stack = stack;
350
+ const flags = sanitizeFlags(this.flags);
351
+ if (Object.keys(flags).length > 0)
352
+ entry.flags = flags;
353
+ if (opts?.attributes) {
354
+ const attrs = sanitizeAttrs(opts.attributes);
355
+ if (Object.keys(attrs).length > 0)
356
+ entry.attrs = attrs;
357
+ }
358
+ const pharos = { contextKey: this.context.contextKey };
359
+ if (this.context.application !== undefined)
360
+ pharos.application = this.context.application;
361
+ if (this.context.release !== undefined)
362
+ pharos.release = this.context.release;
363
+ if (this.context.sessionId !== undefined)
364
+ pharos.sessionId = this.context.sessionId;
365
+ fetch(`${this.baseUrl}/api/v1/errors`, {
366
+ method: "POST",
367
+ headers: {
368
+ "Content-Type": "application/json",
369
+ Authorization: `Bearer ${this.clientKey}`
370
+ },
371
+ body: JSON.stringify({ pharos, errors: [entry] })
372
+ }).catch((err2) => this.emit("error", err2));
373
+ }
374
+ attachReplay(handle) {
375
+ if (this.closed)
376
+ return;
377
+ if (this.replayHandle !== null && this.replayHandle !== handle) {
378
+ throw new Error("pharos: a session recorder is already attached — call detachReplay() " + "before attaching another. rrweb records into one process-wide " + "session, so a second startRecording() silently takes over the first.");
379
+ }
380
+ this.warnIfSessionIdsDisagree(handle);
381
+ this.replayHandle = handle;
382
+ if (this.errorsEnabled || this.replayErrorListeners)
383
+ return;
384
+ this.errorTarget.addEventListener("error", this.onReplayError);
385
+ this.errorTarget.addEventListener("unhandledrejection", this.onReplayRejection);
386
+ this.replayErrorListeners = () => {
387
+ this.errorTarget.removeEventListener("error", this.onReplayError);
388
+ this.errorTarget.removeEventListener("unhandledrejection", this.onReplayRejection);
389
+ };
390
+ }
391
+ warnIfSessionIdsDisagree(handle) {
392
+ const ctxId = this.context.sessionId;
393
+ if (ctxId === undefined)
394
+ return;
395
+ const replayId = handle.sessionId;
396
+ if (typeof replayId !== "string" || replayId === ctxId)
397
+ return;
398
+ console.warn("pharos: this recorder uploads under sessionId " + `"${replayId}" while errors are reported under "${ctxId}". ` + "Replay windows cannot be joined to errors. Pass " + "`sessionId: <PharosContext.sessionId>` to startReplayUpload(), or " + "use client.startReplay() which does it for you.");
399
+ }
400
+ async startReplay(options = {}) {
401
+ if (this.closed)
402
+ return null;
403
+ const { startReplayUpload } = await import("./wire-992wvzs1.js");
404
+ if (this.closed)
405
+ return null;
406
+ const handle = startReplayUpload({
407
+ ...options,
408
+ endpoint: `${this.baseUrl}/api/v1/client/replay`,
409
+ appKey: this.clientKey,
410
+ ...this.context.sessionId !== undefined ? { sessionId: this.context.sessionId } : {}
411
+ });
412
+ try {
413
+ this.attachReplay(handle);
414
+ } catch (e) {
415
+ handle.stop();
416
+ throw e;
417
+ }
418
+ return handle;
419
+ }
420
+ detachReplay() {
421
+ this.replayHandle = null;
422
+ this.replayErrorListeners?.();
423
+ this.replayErrorListeners = null;
424
+ }
425
+ flag(key, defaultValue) {
426
+ if (Object.prototype.hasOwnProperty.call(this.flags, key)) {
427
+ return this.flags[key];
428
+ }
429
+ return defaultValue;
430
+ }
431
+ on(event, cb) {
432
+ this.listeners[event].add(cb);
433
+ return () => {
434
+ this.listeners[event].delete(cb);
435
+ };
436
+ }
437
+ emit(event, payload) {
438
+ for (const cb of this.listeners[event]) {
439
+ cb(payload);
440
+ }
441
+ }
442
+ async identify(context) {
443
+ if (this.closed)
444
+ return;
445
+ this.context = context;
446
+ if (this.eventSource) {
447
+ this.eventSource.close();
448
+ this.eventSource = null;
449
+ }
450
+ await this.bootstrap();
451
+ if (!this.closed) {
452
+ this.connectStream();
453
+ }
454
+ }
455
+ close() {
456
+ this.closed = true;
457
+ if (this.reconnectTimer) {
458
+ clearTimeout(this.reconnectTimer);
459
+ this.reconnectTimer = null;
460
+ }
461
+ if (this.eventSource) {
462
+ this.eventSource.close();
463
+ this.eventSource = null;
464
+ }
465
+ if (this.errorsEnabled) {
466
+ this.unregisterErrorListeners();
467
+ }
468
+ this.replayHandle = null;
469
+ this.replayErrorListeners?.();
470
+ this.replayErrorListeners = null;
471
+ }
472
+ }
473
+ export {
474
+ ACTIVATION_EVENT_TAG,
475
+ EXCLUDE_CLASS,
476
+ LOCAL_TTL_MS,
477
+ MASK_CLASS,
478
+ MASK_TOKEN,
479
+ MAX_BUFFER_BYTES,
480
+ MAX_WINDOW_BYTES,
481
+ PharosBrowserClient,
482
+ RECORDER_DEFAULTS,
483
+ beginsWithSnapshot,
484
+ clearPersisted,
485
+ collectRecordedStrings,
486
+ createDropLedger,
487
+ createUploadSink,
488
+ decide,
489
+ drainPersisted,
490
+ encodeWindow,
491
+ envelopeMetaFor,
492
+ isSensitiveField,
493
+ maskText,
494
+ masksValue,
495
+ persistWindow,
496
+ postEnvelope,
497
+ quotaDroppedWindows,
498
+ resetQuotaDroppedWindows,
499
+ resolveConfig,
500
+ scrubAttribute,
501
+ scrubUrl,
502
+ shouldExclude,
503
+ shouldMaskByMarker
504
+ };
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Keep scheme, host and path; drop `?query` and `#fragment`, which is where
3
+ * typed search text and record ids overwhelmingly live — the shape of the
4
+ * #792 leak. Non-http(s) schemes are masked entirely: a `mailto:` path IS an
5
+ * email address and a `data:` URL can embed the content itself. URL userinfo
6
+ * (`user:pw@host`) is dropped as a side effect of reconstructing the output
7
+ * from `protocol`/`host`/`pathname` rather than copying `url.href` verbatim —
8
+ * do not "simplify" this back to `url.href`, or credentials come back.
9
+ */
10
+ export declare function scrubUrl(value: string): string;
11
+ /** Returns the value to record, or `null` to omit the attribute entirely. */
12
+ export declare function scrubAttribute(el: Element, name: string, value: string): string | null;
@@ -0,0 +1,8 @@
1
+ export interface RecordedString {
2
+ value: string;
3
+ path: string;
4
+ origin: 'text' | 'value' | 'attribute';
5
+ attribute?: string;
6
+ }
7
+ export declare const STRUCTURAL_ATTRS: Set<string>;
8
+ export declare function collectRecordedStrings(root: Element): RecordedString[];
@@ -0,0 +1,7 @@
1
+ export declare const EXCLUDE_CLASS = "pharos-exclude";
2
+ export declare const MASK_CLASS = "pharos-mask";
3
+ export declare function hasMarkerAncestor(el: Element, cls: string): boolean;
4
+ /** The subtree is recorded as an empty same-tag element: contents never leave. */
5
+ export declare function shouldExclude(el: Element): boolean;
6
+ /** Structure and attributes are recorded; text and values are masked. */
7
+ export declare function shouldMaskByMarker(el: Element): boolean;
@@ -0,0 +1,2 @@
1
+ export declare const MASK_TOKEN = "\u2022\u2022\u2022\u2022";
2
+ export declare function maskText(_text: string): string;
@@ -0,0 +1,27 @@
1
+ export type Decision = 'exclude' | 'mask' | 'record';
2
+ export declare function decide(el: Element): Decision;
3
+ /**
4
+ * Whether this element's VALUE must be masked — always true for anything the
5
+ * user types into, regardless of markers or detection (spec §3.3).
6
+ *
7
+ * CONTENTEDITABLE IS INHERITED, AND THAT IS THE WHOLE POINT OF THE ANCESTOR
8
+ * CHECK. A browser stores multi-line contenteditable content in CHILD
9
+ * elements — a `<div>`/`<p>` per line on Enter, a `<span>` per styled run —
10
+ * and those children carry no `contenteditable` attribute of their own.
11
+ * Reading only `el.getAttribute('contenteditable')` (as this did until
12
+ * 2026-09-02) therefore answered `false` for essentially every real
13
+ * contenteditable region, and the typed text in those children was recorded
14
+ * verbatim: the #792 leak shape, through the one input surface that has
15
+ * element children. Do NOT "simplify" this back to a self-only attribute
16
+ * read.
17
+ *
18
+ * CALLING CONTRACT: this does NOT consult `pharos-exclude`/`pharos-mask` or
19
+ * run field-sensitivity detection, by design — that is `decide()`'s job.
20
+ * Call this only AFTER `decide(el)` has returned `'record'` or `'mask'`, never
21
+ * on an element `decide()` said to exclude: an excluded element's subtree is
22
+ * replaced wholesale (see `markers.ts`), so asking this function about it is
23
+ * a category error, not a stricter check. A recorder that calls this
24
+ * standalone, without routing the element through `decide()` first, will
25
+ * record values `decide()` would have excluded outright.
26
+ */
27
+ export declare function masksValue(el: Element): boolean;
@@ -0,0 +1 @@
1
+ export declare function isSensitiveField(el: Element): boolean;
@@ -0,0 +1,49 @@
1
+ import type { DegradationRecord, ResolvedRecorderConfig } from './config';
2
+ /** The browser's own long-task threshold. A `PerformanceObserver` longtask entry is ≥ this by definition. */
3
+ export declare const LONG_TASK_MS = 50;
4
+ /** Minimum gap between escalations, so one janky second does not collapse straight to the floor. */
5
+ export declare const ESCALATION_COOLDOWN_MS = 5000;
6
+ /** Quiet period before stepping back up one level. */
7
+ export declare const RECOVERY_AFTER_MS = 30000;
8
+ export declare const MAX_DEGRADATION_LEVEL = 3;
9
+ /** Rate limit on memory-ceiling records: a heavy page evicts constantly and must not emit thousands. */
10
+ export declare const CEILING_RECORD_COOLDOWN_MS = 10000;
11
+ export interface Budget {
12
+ level(): number;
13
+ checkpointIntervalMs(): number;
14
+ mousemoveWaitMs(): number;
15
+ /** Returns true when the level actually changed, so the caller can re-apply rates. */
16
+ noteLongTask(durationMs: number): boolean;
17
+ /**
18
+ * One segment dropped for the byte ceiling. Call this once per eviction —
19
+ * i.e. wire it straight to `ReplayBuffer`'s `onCeilingEviction`, which fires
20
+ * once per dropped segment and in lockstep with `evictedForBytes`. Bursts
21
+ * are collapsed HERE, with the count preserved; see the emission comment
22
+ * below for why the caller must not pre-aggregate.
23
+ */
24
+ noteCeilingEviction(): void;
25
+ /** Returns true when the level actually changed. */
26
+ maybeRecover(): boolean;
27
+ /** A snapshot. Callers may retain it; later coalescing never rewrites it. */
28
+ records(): DegradationRecord[];
29
+ }
30
+ export declare function createBudget(opts: {
31
+ config: ResolvedRecorderConfig;
32
+ now: () => number;
33
+ }): Budget;
34
+ /**
35
+ * Wires `PerformanceObserver` long-task entries into a budget. Returns a
36
+ * detach function.
37
+ *
38
+ * Long-task observation is optional: Safari ships the constructor but not the
39
+ * `longtask` entry type, and an engine asked for an entry type it does not
40
+ * support either throws or quietly observes nothing. BOTH ARE HANDLED — the
41
+ * throw by the `catch` below, the silence by simply never degrading — which is
42
+ * why this swallows the failure rather than reporting it.
43
+ *
44
+ * happy-dom DOES define `PerformanceObserver` and accepts the `observe` call,
45
+ * but never emits a `longtask` entry — so tests reach this function's success
46
+ * path and the escalation path below is unreachable without a stub. Do not
47
+ * assume the test environment lacks the constructor; it does not.
48
+ */
49
+ export declare function observeLongTasks(budget: Budget, onEscalate: () => void): () => void;