@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.
@@ -0,0 +1,155 @@
1
+ /** Mirrors rrweb's `EventType.FullSnapshot`. Pinned against the real enum in privacy-hooks.test.ts. */
2
+ export declare const EVENT_TYPE_FULL_SNAPSHOT = 2;
3
+ /** Mirrors rrweb's `EventType.Meta`. Pinned against the real enum in privacy-hooks.test.ts. */
4
+ export declare const EVENT_TYPE_META = 4;
5
+ export interface BufferedEvent {
6
+ type: number;
7
+ timestamp: number;
8
+ [key: string]: unknown;
9
+ }
10
+ export interface BufferStats {
11
+ bytes: number;
12
+ segments: number;
13
+ events: number;
14
+ evictedForAge: number;
15
+ evictedForBytes: number;
16
+ droppedBeforeFirstSnapshot: number;
17
+ }
18
+ export interface ReplayBufferOptions {
19
+ /** Hard ceiling in bytes. It WINS over `preRollMs`: a configured window is a request, not a guarantee. */
20
+ maxBytes: number;
21
+ preRollMs: number;
22
+ /** Byte accounting. Defaults to `JSON.stringify(event).length`; injectable so tests are exact. */
23
+ sizeOf?: (event: BufferedEvent) => number;
24
+ /** Called when the ceiling — not age — forced a segment out, so the recorder can record a degradation. */
25
+ onCeilingEviction?: () => void;
26
+ /**
27
+ * ALWAYS-ON SESSION REPLAY (issue #793): called exactly once, synchronously,
28
+ * the moment a checkpoint SUPERSEDES the segment before it — i.e. right
29
+ * after `append()` opens a new segment via a `FullSnapshot`, for whatever
30
+ * segment was open immediately before that arrived. NOT called for the
31
+ * very first segment of a session (there is nothing before it to close),
32
+ * and NOT called by eviction: a segment dropped for age or ceiling never
33
+ * closed, it was simply discarded, and `onCeilingEviction` already reports
34
+ * that separately.
35
+ *
36
+ * THIS IS WHAT MAKES A SESSION WITHOUT ANY ERROR STILL RECORDED. Before
37
+ * this hook the buffer was purely a ring the trigger coordinator read from
38
+ * on demand (`extract`); nothing ever consumed a segment on its own. A
39
+ * closed segment already begins with its own full snapshot — the
40
+ * checkpoint invariant this whole file exists to guarantee — so it needs
41
+ * no pre-roll or post-roll to be replayable: it already IS a window.
42
+ *
43
+ * The array handed back is a FRESH COPY of the segment's events, not the
44
+ * live internal array — unlike `extract()`'s return, whose ARRAY is fresh
45
+ * but whose EVENT OBJECTS are shared by reference on purpose. Here there is
46
+ * no reason to share even the array: the closed segment may still be
47
+ * retained in `this.segments` for pre-roll purposes (a trigger's
48
+ * `extract()` can still reach back into it), so handing out the buffer's
49
+ * OWN array would let a careless caller `push`/`shift` it and corrupt
50
+ * retained history exactly the way `extract()`'s own tests pin against.
51
+ */
52
+ onSegmentClosed?: (events: BufferedEvent[], startedAt: number) => void;
53
+ }
54
+ /**
55
+ * Whether an event array is replayable at all: it must open with a full
56
+ * snapshot, optionally preceded by the Meta event rrweb emits alongside it.
57
+ * Exported because it is BOTH the buffer's own fail-closed self-check and the
58
+ * assertion every window test makes.
59
+ */
60
+ export declare function beginsWithSnapshot(events: readonly BufferedEvent[]): boolean;
61
+ /**
62
+ * How far a held `Meta` may be from the `FullSnapshot` that claims it. rrweb
63
+ * emits the pair back-to-back inside one cycle, so a real gap is sub-millisecond
64
+ * and this bound has three orders of magnitude of headroom.
65
+ *
66
+ * A LARGE gap means the Meta's own snapshot never arrived — dropped by a
67
+ * privacy scrubber upstream, say, which is a real path in this very slice.
68
+ * Pinning a 19-second-old viewport onto a fresh snapshot tells the replayer a
69
+ * layout that may since have rotated or resized. A segment with no Meta is
70
+ * still replayable (`beginsWithSnapshot` accepts a bare snapshot) and the
71
+ * player falls back to a default viewport: a known-unknown beats
72
+ * confidently-wrong data.
73
+ */
74
+ export declare const MAX_PENDING_META_AGE_MS = 1000;
75
+ /**
76
+ * Default byte accounting: the event's UTF-8 SERIALIZED size, deliberately not
77
+ * `JSON.stringify(event).length`.
78
+ *
79
+ * `.length` counts UTF-16 code units, which UNDER-COUNTS every non-ASCII
80
+ * character — roughly 2.5x on a CJK page, 2x on emoji. Under-counting is the
81
+ * HARMFUL direction: the ceiling then admits more than it was told to, and the
82
+ * ceiling is the only thing standing between this recorder and eating a user's
83
+ * tab. So the default counts real bytes.
84
+ *
85
+ * NOTE: this measures SERIALIZED size, not JS heap footprint. The retained
86
+ * event objects cost several times this in the heap; `maxBytes` is a
87
+ * transport-size ceiling used as a proxy for memory.
88
+ *
89
+ * Exported because every buffer test injects `sizeOf`, which would otherwise
90
+ * leave the function that actually runs in production with no coverage at all.
91
+ */
92
+ export declare function defaultSizeOf(event: BufferedEvent): number;
93
+ export declare class ReplayBuffer {
94
+ private readonly maxBytes;
95
+ private readonly preRollMs;
96
+ private readonly sizeOf;
97
+ private readonly onCeilingEviction?;
98
+ private readonly onSegmentClosed?;
99
+ private segments;
100
+ /**
101
+ * rrweb emits Meta immediately before each FullSnapshot. Meta carries the
102
+ * viewport the replayer needs, so it belongs to the segment the snapshot
103
+ * OPENS, not the one it closes — hence a one-slot hold rather than an append.
104
+ */
105
+ private pendingMeta;
106
+ private newestTimestamp;
107
+ private totalBytes;
108
+ private evictedForAge;
109
+ private evictedForBytes;
110
+ private droppedBeforeFirstSnapshot;
111
+ constructor(opts: ReplayBufferOptions);
112
+ append(event: BufferedEvent): void;
113
+ private dropOldestSegment;
114
+ private evict;
115
+ /**
116
+ * The window `[triggeredAt - preRollMs, until]`, snapped BACK to the
117
+ * checkpoint at or before the pre-roll boundary. Returns `null` when no
118
+ * replayable window exists — the caller MUST NOT ship a window in that case.
119
+ */
120
+ extract(triggeredAt: number, until: number): BufferedEvent[] | null;
121
+ /**
122
+ * ALWAYS-ON SESSION REPLAY (issue #793 follow-up): the currently OPEN
123
+ * segment's events, exactly as they stand right now — a snapshot of a
124
+ * still-growing thing, not a closing of it. `null` when nothing has been
125
+ * appended yet (no segment exists at all).
126
+ *
127
+ * THIS IS WHAT MAKES A SHORT SESSION STILL RECORD SOMETHING. A segment
128
+ * otherwise only ships via `onSegmentClosed`, which fires exclusively when
129
+ * the NEXT checkpoint supersedes it — so a session shorter than one
130
+ * `checkpointIntervalMs` (15s at the defaults) never closes its first
131
+ * segment at all, and produced ZERO windows under `onSegmentClosed` alone.
132
+ * The recorder's unload path calls this instead, to ship whatever the open
133
+ * segment holds — the tail of every session, however short — WITHOUT
134
+ * closing it, restarting it, or forcing a new snapshot: the open segment
135
+ * already begins with its own full snapshot (this buffer's own invariant),
136
+ * so there is nothing to force.
137
+ *
138
+ * MAY BE CALLED ANY NUMBER OF TIMES, unlike `onSegmentClosed`'s one-shot
139
+ * notification — each call reflects the segment's CURRENT content, so a
140
+ * caller can tell whether it has grown since a previous call (the
141
+ * recorder does exactly this, to avoid re-shipping an unchanged segment on
142
+ * a second unload signal with nothing new in it).
143
+ *
144
+ * Returns a COPY, for the same reason `onSegmentClosed` does: the segment
145
+ * is still open and this buffer will keep appending to its OWN internal
146
+ * array, which a caller must not alias.
147
+ */
148
+ currentSegment(): {
149
+ events: BufferedEvent[];
150
+ startedAt: number;
151
+ } | null;
152
+ /** Discards everything. `stop()` calls this so a consent withdrawal leaves nothing to upload. */
153
+ clear(): void;
154
+ stats(): BufferStats;
155
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * How an activation was produced. THIS IS THE ONLY THING THE RECORDER EVER
3
+ * LEARNS ABOUT THE KEYBOARD. No key value is read, stored or transmitted —
4
+ * a `keydown` listener sees passwords, and a recorder that stored key values
5
+ * would reintroduce the #792 leak through a channel no privacy marker covers.
6
+ */
7
+ export type ActivationMode = 'pointer' | 'keyboard' | 'programmatic';
8
+ /**
9
+ * `'checkpoint'` is issue #793's always-on design: a window shipped because a
10
+ * checkpoint segment closed, not because anything went wrong. It carries no
11
+ * `name`, always has `occurrences: 1` (there is no burst to collapse — see
12
+ * `triggers.ts`'s `checkpoint()`), and is produced independently of every
13
+ * other kind here, which all remain exactly what B1 shipped: something a
14
+ * caller told the recorder went wrong.
15
+ */
16
+ export type TriggerKind = 'exception' | 'unhandledrejection' | 'manual' | 'checkpoint';
17
+ /** What a caller hands the recorder when something went wrong. */
18
+ export interface TriggerSource {
19
+ kind: TriggerKind;
20
+ /**
21
+ * `err.name` — a constructor name such as `"TypeError"`, never a message.
22
+ * Messages can carry user content and the recorder is not the place to
23
+ * decide that; the errors pillar already owns message sanitization.
24
+ */
25
+ name?: string;
26
+ }
27
+ /** What actually shipped, after de-duplication collapsed a burst into one window. */
28
+ export interface TriggerInfo {
29
+ kind: TriggerKind;
30
+ name?: string;
31
+ /** Epoch ms of the FIRST trigger collapsed into this window. */
32
+ at: number;
33
+ /** How many trigger notifications this window absorbed. */
34
+ occurrences: number;
35
+ /** Epoch ms the window was extracted and handed to the sink. */
36
+ flushedAt: number;
37
+ /** True when the post-roll was cut short by an unload flush. */
38
+ unloaded: boolean;
39
+ }
40
+ export type DegradationReason = 'long-task' | 'recovery' | 'memory-ceiling';
41
+ /**
42
+ * One recorded transition of the recorder's own health. Every transition is
43
+ * recorded WITH ITS REASON so a sparse replay is explained rather than
44
+ * looking like a bug (spec §7).
45
+ */
46
+ export interface DegradationRecord {
47
+ /** Epoch ms of the FIRST event collapsed into this record. */
48
+ at: number;
49
+ level: number;
50
+ reason: DegradationReason;
51
+ checkpointIntervalMs: number;
52
+ mousemoveWaitMs: number;
53
+ /**
54
+ * How many events this record collapses — always 1 for a level transition,
55
+ * and for `memory-ceiling` the number of evicted segments, because the
56
+ * buffer evicts one segment at a time and a burst is rate-limited into one
57
+ * record (`budget.ts`). Without it, rate limiting would report 500 lost
58
+ * segments as one, and the counter that could correct it
59
+ * (`ReplayBuffer.stats().evictedForBytes`) never reaches a reader: the sink
60
+ * only ever sees a `ReplayWindow`. Same idea, and the same name, as
61
+ * `TriggerInfo.occurrences`.
62
+ */
63
+ occurrences: number;
64
+ }
65
+ export interface ResolvedRecorderConfig {
66
+ preRollMs: number;
67
+ postRollMs: number;
68
+ maxBytes: number;
69
+ pointerHz: number;
70
+ mousemoveWaitMs: number;
71
+ checkpointIntervalMs: number;
72
+ }
73
+ /**
74
+ * What the sink receives. `events` is an rrweb event array whose FIRST entry
75
+ * is a Meta or FullSnapshot event — see `buffer.ts` for the invariant that
76
+ * guarantees it.
77
+ */
78
+ export interface ReplayWindow {
79
+ events: unknown[];
80
+ trigger: TriggerInfo;
81
+ degraded: DegradationRecord[];
82
+ config: ResolvedRecorderConfig;
83
+ }
84
+ /**
85
+ * B1 ships NO NETWORK CODE. It hands windows to this pluggable sink; slice B2
86
+ * replaces the sink with a real uploader without touching the recorder.
87
+ */
88
+ export type Sink = (window: ReplayWindow) => void;
89
+ /**
90
+ * Why the trigger coordinator dropped a window instead of shipping it. Both
91
+ * are B1 fail-closed returns in `triggers.ts` and both were entirely SILENT
92
+ * until slice B2 wired `onWindowDropped` (below) — no stat, no
93
+ * `DegradationRecord`, and the absorbed `occurrences` count discarded with
94
+ * them.
95
+ */
96
+ export type WindowDropReason =
97
+ /** The buffer held no extractable snapshot, so nothing replayable existed. */
98
+ 'no-snapshot'
99
+ /** Every event postdated the error, so the window was not that error's context. */
100
+ | 'fabricated-window';
101
+ export interface RecorderOptions {
102
+ sink: Sink;
103
+ /**
104
+ * Notified when the coordinator drops a window instead of calling `sink`
105
+ * (see `WindowDropReason`).
106
+ *
107
+ * ADDED IN SLICE B2 (Task 9, requirement 3) AND ADDITIVE: absent, nothing
108
+ * changes and the drops stay silent exactly as B1 shipped them. B1's own
109
+ * comment on `RecorderHandle.trigger` invited this — "Slice B2, where an
110
+ * uploader gives it somewhere to go, should carry the reason and the
111
+ * absorbed count rather than a number." The uploader's `droppedWindows`
112
+ * (drops.ts) is that somewhere: without this hook the server's cumulative
113
+ * count omits every window B1 lost, and an operator reading
114
+ * `droppedWindows: 0` on a memory-pressured session is told nothing was
115
+ * lost when windows were.
116
+ *
117
+ * Called inside the coordinator's `contained()`, like the sink, so a
118
+ * throwing implementation cannot break the page.
119
+ */
120
+ onWindowDropped?: (reason: WindowDropReason) => void;
121
+ preRollMs?: number;
122
+ postRollMs?: number;
123
+ maxBytes?: number;
124
+ pointerHz?: number;
125
+ /** Injectable clock. Defaults to `Date.now`. */
126
+ now?: () => number;
127
+ /** Injectable idle scheduler. Defaults to `requestIdleCallback`, or `setTimeout(cb, 0)` where it does not exist. */
128
+ scheduleIdle?: (cb: () => void) => void;
129
+ /** Injectable timer pair, so tests drive the post-roll and checkpoint clocks deterministically. */
130
+ setTimer?: (cb: () => void, ms: number) => unknown;
131
+ clearTimer?: (handle: unknown) => void;
132
+ /** Injectable listener target for the activation-modality listeners. Defaults to `document`. */
133
+ target?: EventTarget;
134
+ /**
135
+ * Injectable listener target for the page-lifecycle flush (`pagehide` and
136
+ * `visibilitychange`). Defaults to `globalThis` — the Window, which is the
137
+ * end of the propagation path for a Document-dispatched `visibilitychange`
138
+ * as well as the target of `pagehide`, so one target covers both.
139
+ */
140
+ lifecycleTarget?: EventTarget;
141
+ /** Injectable document, used to build surrogate elements for attribute scrubbing. Defaults to the global `document`. */
142
+ doc?: Document;
143
+ }
144
+ export interface RecorderStats {
145
+ snapshots: number;
146
+ bufferBytes: number;
147
+ segments: number;
148
+ /**
149
+ * Activation-modality listener invocations that performed real work — spec
150
+ * §7.1's fourth proxy, and `ModalityState.work` verbatim.
151
+ *
152
+ * RENAMED FROM `pointerWork` 2026-09-02, because that name was false:
153
+ * `interactions.ts` increments the counter on `keydown` and `pointerdown`
154
+ * as well as on a sampled `pointermove`, so a stat called "pointer work"
155
+ * reported keyboard work too. Nothing measured changed — the field is still
156
+ * the tracker's single counter — and the alternative (excluding `keydown`
157
+ * from it) was rejected because the proxy is meant to bound the recorder's
158
+ * TOTAL listener cost, and a recorder that omits its keyboard listener from
159
+ * its own cost report understates itself.
160
+ *
161
+ * A DELTA OVER THIS IS ONLY "PER POINTER EVENT" IF THE MEASURED WINDOW
162
+ * DISPATCHES NOTHING ELSE. The gate tests in budget-gate.test.ts dispatch
163
+ * `pointermove` alone and say so; a future test mixing event types must
164
+ * account for that or it will read a contaminated number.
165
+ */
166
+ interactionWork: number;
167
+ degradationLevel: number;
168
+ }
169
+ export interface RecorderHandle {
170
+ readonly config: ResolvedRecorderConfig;
171
+ /** Halts capture AND DISCARDS THE BUFFER. Any in-flight post-roll is abandoned. */
172
+ stop(): void;
173
+ trigger(source: TriggerSource): void;
174
+ /** Flushes the in-flight window immediately (unload). No-op when nothing is pending. */
175
+ flushNow(): void;
176
+ stats(): RecorderStats;
177
+ }
178
+ /**
179
+ * The hard memory ceiling, and IT WINS OVER CONFIGURED WINDOWS (spec §5): a
180
+ * configured pre-roll is a request, not a guarantee. When a page is heavy
181
+ * enough that the pre-roll does not fit, the buffer holds less and records a
182
+ * `memory-ceiling` degradation.
183
+ */
184
+ export declare const MAX_BUFFER_BYTES: number;
185
+ export declare const RECORDER_DEFAULTS: {
186
+ readonly preRollMs: 30000;
187
+ readonly postRollMs: 10000;
188
+ readonly pointerHz: 20;
189
+ };
190
+ export declare function resolveConfig(opts: Pick<RecorderOptions, 'preRollMs' | 'postRollMs' | 'maxBytes' | 'pointerHz'>): ResolvedRecorderConfig;
@@ -0,0 +1,46 @@
1
+ /** Every way slice B2 can lose a whole window. */
2
+ export type DropReason =
3
+ /** Encoded larger than `MAX_WINDOW_BYTES`; the server would 413 it. */
4
+ 'oversized'
5
+ /** A POST that failed, rejected, or answered non-2xx. */
6
+ | 'delivery'
7
+ /**
8
+ * IndexedDB refused the write FOR SPACE (`QuotaExceededError`) — either the
9
+ * oldest persisted window was evicted to make room, or the incoming one
10
+ * could not be stored even after that eviction.
11
+ */
12
+ | 'quota'
13
+ /**
14
+ * The local store could not take the window for a reason that is NOT
15
+ * space: no `IndexedDB` in this host at all, `open()` throwing (a
16
+ * sandboxed iframe, a blocked storage partition), or a transaction dying.
17
+ *
18
+ * SPLIT OUT FROM `quota` IN FIX ROUND 1, because it was being labelled
19
+ * `quota` — including by requirement 5's own fixture, whose factory throws
20
+ * from `open()` and has nothing to do with space — and one branch was
21
+ * labelling a persistence failure `delivery`. `total()` was right either
22
+ * way, and nothing on the wire carries the breakdown, but this file
23
+ * documents the buckets as what a test and a future error channel assert
24
+ * on, so a label that does not mean what it says is a defect in the thing
25
+ * this module exists to be.
26
+ */
27
+ | 'storage'
28
+ /** B1: the buffer held no extractable snapshot, so nothing replayable existed. */
29
+ | 'no-snapshot'
30
+ /** B1: every event in the window postdated the error, so the window was not that error's context. */
31
+ | 'fabricated-window';
32
+ export interface DropLedger {
33
+ note(reason: DropReason): void;
34
+ /** The cumulative number that goes on the wire as `droppedWindows`. */
35
+ total(): number;
36
+ /** Per-reason breakdown. Nothing on the wire carries it; tests and future error channels do. */
37
+ byReason(): Record<DropReason, number>;
38
+ /**
39
+ * Back to zero. Called on `stop()` (spec §3 decision 6): a count accrued
40
+ * BEFORE a consent withdrawal must not ride along on an upload made after
41
+ * one. Without this, `stop()` then `startRecording()` would attach the old
42
+ * session's losses to the new session's first envelope.
43
+ */
44
+ reset(): void;
45
+ }
46
+ export declare function createDropLedger(): DropLedger;
@@ -0,0 +1,2 @@
1
+ export { startRecording } from './recorder';
2
+ export { SESSION_STORAGE_KEY, SESSION_DROPS_KEY, resolveSessionId, startReplayUpload, type ReplayUploadHandle, type ReplayUploadOptions, } from './wire';
@@ -0,0 +1,8 @@
1
+ import"../index-c3taa3cg.js";
2
+ export {
3
+ SESSION_DROPS_KEY,
4
+ SESSION_STORAGE_KEY,
5
+ resolveSessionId,
6
+ startRecording,
7
+ startReplayUpload
8
+ };
@@ -0,0 +1,48 @@
1
+ import type { ActivationMode } from './config';
2
+ export type InputModality = 'pointer' | 'keyboard';
3
+ /**
4
+ * How long a recorded modality stays authoritative. Beyond this, an
5
+ * activation with no click-count is treated as programmatic — otherwise an
6
+ * `el.click()` minutes after a real mouse click would be reported as a
7
+ * pointer activation forever.
8
+ */
9
+ export declare const ACTIVATION_MODALITY_TTL_MS = 500;
10
+ /** The rrweb custom-event tag. Stable: B2 and B3 both match on it. */
11
+ export declare const ACTIVATION_EVENT_TAG = "pharos-activation";
12
+ export interface ActivationPayload {
13
+ mode: ActivationMode;
14
+ }
15
+ /**
16
+ * A SINGLE mutable record for the tracker's whole lifetime. Its identity never
17
+ * changes, which is the property the "zero allocation per pointer event"
18
+ * budget test asserts.
19
+ */
20
+ export interface ModalityState {
21
+ modality: InputModality | null;
22
+ modalityAt: number;
23
+ pointerAt: number;
24
+ /** Listener invocations that performed real work — spec §7.1's fourth proxy. */
25
+ work: number;
26
+ }
27
+ export interface InteractionTracker {
28
+ readonly state: ModalityState;
29
+ setPointerIntervalMs(ms: number): void;
30
+ detach(): void;
31
+ }
32
+ /**
33
+ * Two independent signals, so neither is a single point of failure (spec §4.2):
34
+ * the tracked modality, and `event.detail` — which a keyboard-produced click
35
+ * reports as 0 while a pointer click reports a click-count.
36
+ */
37
+ export declare function resolveActivationMode(input: {
38
+ modality: InputModality | null;
39
+ modalityAt: number;
40
+ at: number;
41
+ detail: number;
42
+ }): ActivationMode;
43
+ export declare function attachInteractions(opts: {
44
+ target: EventTarget;
45
+ now: () => number;
46
+ pointerIntervalMs: number;
47
+ emit: (payload: ActivationPayload) => void;
48
+ }): InteractionTracker;
@@ -0,0 +1,75 @@
1
+ import { type DropLedger } from './drops';
2
+ /** Design §3 decision 6: undrained windows expire locally after 24h. */
3
+ export declare const LOCAL_TTL_MS: number;
4
+ /**
5
+ * Number of windows evicted by the quota-exceeded path (spec §7, "Error
6
+ * handling") ON THE DEFAULT LEDGER ONLY.
7
+ *
8
+ * TEST/DIAGNOSTIC SEAM. A caller that injected its own ledger — which the
9
+ * wired path always does — will see 0 here no matter how many evictions
10
+ * happened, because its evictions went to ITS ledger. Read
11
+ * `DropLedger.byReason().quota` for that. Kept under this name because
12
+ * Task 8's suite pins it and because "what did the default ledger evict" is
13
+ * still the right question for a caller that never injected one.
14
+ *
15
+ * Not reset by `clearPersisted`, which clears STORAGE, not counters.
16
+ */
17
+ export declare function quotaDroppedWindows(): number;
18
+ /**
19
+ * Resets the default ledger (Task 9, requirement 6).
20
+ *
21
+ * Task 8 deliberately left the counter un-reset by `clearPersisted`, which is
22
+ * right — clearing storage is not the same event as withdrawing consent — but
23
+ * it left an eviction count from BEFORE a consent withdrawal able to ride out
24
+ * on an upload made AFTER one. `stop()` is the event that must zero it, and
25
+ * `startReplayUpload` calls this from `stop()` alongside resetting the
26
+ * session ledger, so the rule holds whichever ledger a caller used.
27
+ */
28
+ export declare function resetQuotaDroppedWindows(): void;
29
+ /**
30
+ * Whether IndexedDB refused a write FOR SPACE, as opposed to any other storage
31
+ * failure. Matched on `name`, not `instanceof DOMException`: the error crosses
32
+ * a promise boundary out of this module and the constructor identity is not
33
+ * guaranteed across realms (an iframe, a test's fake), while the name is what
34
+ * the specification defines.
35
+ *
36
+ * EXPORTED, AND THAT IS THE POINT (final review, minor). `wire.ts` held a
37
+ * byte-identical copy under a different name — two predicates deciding the
38
+ * same question, which is this slice's own recurring finding. One
39
+ * implementation, two call sites; a change to what counts as "out of space"
40
+ * cannot now move on one path and not the other.
41
+ */
42
+ export declare function isQuotaExceeded(err: unknown): boolean;
43
+ /**
44
+ * Persists one window, keyed by an autoIncrement id (see the module doc
45
+ * comment for why not `insertedAt` itself). Expired records are purged first
46
+ * (`purgeExpired`). On `QuotaExceededError`, evicts the single OLDEST stored
47
+ * record (spec §7: "the oldest persisted window is dropped and counted in
48
+ * `droppedWindows`") and retries exactly once — a second failure propagates
49
+ * rather than looping, since a store that still won't accept a write after
50
+ * freeing space is not a retry problem.
51
+ */
52
+ export declare function persistWindow(body: Uint8Array, meta: object, now?: () => number, indexedDB?: IDBFactory, drops?: DropLedger): Promise<void>;
53
+ /**
54
+ * Drains every persisted window, oldest first. Each record is handled
55
+ * independently:
56
+ *
57
+ * - Older than `LOCAL_TTL_MS`: dropped without being sent (consent/TTL rule).
58
+ * - `send` resolves `true`: deleted, counted in the returned total.
59
+ * - `send` resolves `false` or rejects: left in place for the next attempt.
60
+ *
61
+ * Reads all records up front in one transaction, then performs deletes in
62
+ * their own transactions AFTER each `await send(...)` — an IndexedDB
63
+ * transaction auto-commits once its task queue empties, so holding one open
64
+ * across an awaited network call (a macrotask) would throw
65
+ * `TransactionInactiveError` on the delete that follows it.
66
+ */
67
+ export declare function drainPersisted(send: (body: Uint8Array, meta: object) => Promise<boolean>, now?: () => number, indexedDB?: IDBFactory): Promise<number>;
68
+ /**
69
+ * The consent primitive (spec §3 decision 6): removes every persisted
70
+ * window, unconditionally. Wired by the caller into `stop()` so withdrawing
71
+ * consent leaves nothing of the user's anywhere — this module does not call
72
+ * `stop()` itself (it has no recorder handle), so that wiring is the caller's
73
+ * job, not this function's.
74
+ */
75
+ export declare function clearPersisted(indexedDB?: IDBFactory): Promise<void>;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Attributes rrweb invents during serialization. They are not page content
3
+ * and must not be routed through `scrubAttribute`, which would mangle them.
4
+ *
5
+ * `_cssText` is rrweb's inlined `<style>` CONTENTS — a stylesheet, not a
6
+ * `style` attribute. Slice A's style scrubber parses `;`-separated
7
+ * declarations, not rules, so treating a stylesheet as one would mask quoted
8
+ * strings inside author CSS and corrupt the replay's rendering.
9
+ *
10
+ * DEFENCE IN DEPTH, NOT A LOAD-BEARING RULE, for the scrub pass below:
11
+ * `scrubAttribute` today applies the style scrubber only to an attribute
12
+ * literally named `style`, and none of the names in this set is a URL,
13
+ * value-bearing or `data-*` attribute, so routing them through it would in
14
+ * fact be a no-op. Deleting this skip therefore changes nothing observable
15
+ * today — it exists so that a future `scrubAttribute` that learns about
16
+ * `_cssText` or `rr_dataURL` cannot silently start mangling them. In
17
+ * `surrogateElement` the skip IS load-bearing: an invented attribute must not
18
+ * be allowed to influence slice A's sensitivity detection.
19
+ *
20
+ * Author CSS is not user content; a `content: "…"` templated from user data
21
+ * would leave, and that limitation is stated in apps/pharos/README.md's
22
+ * "What this does NOT catch".
23
+ *
24
+ * RECORDER CONSTRAINT — `rr_dataURL`. rrweb writes RENDERED CANVAS PIXELS
25
+ * into it, and under `inlineImages` the bytes of every image
26
+ * (rrweb-snapshot.js:1036-1072). That is arbitrarily sensitive content — a
27
+ * signature pad, an uploaded photo — and skipping it here means nothing
28
+ * scrubs it. It is unreachable today only because `recordCanvas` and
29
+ * `inlineImages` both default to false, so: THE RECORDER MUST NOT ENABLE
30
+ * `recordCanvas` OR `inlineImages` WHILE `rr_dataURL` IS IN THIS SET.
31
+ * Enabling either needs a decision first, because there is no partial scrub
32
+ * of a bitmap: the honest options are to keep the feature off, or to map
33
+ * `rr_dataURL` into `RENAMED_CONTENT_ATTRS` as a `src`, which sends it
34
+ * through `scrubUrl` and — `data:` being a non-http scheme — masks it
35
+ * wholesale, i.e. replays the canvas blank. Note that merely REMOVING it
36
+ * from this set does NOT create that backstop: `scrubAttribute` has no rule
37
+ * for the name `rr_dataURL` and returns it verbatim.
38
+ */
39
+ export declare const RRWEB_INTERNAL_ATTRS: Set<string>;
40
+ /** rrweb-snapshot's serialized node shape, narrowed to the parts we scrub. */
41
+ export interface SerializedNode {
42
+ type: number;
43
+ tagName?: string;
44
+ attributes?: Record<string, unknown>;
45
+ childNodes?: SerializedNode[];
46
+ id?: number;
47
+ [key: string]: unknown;
48
+ }
49
+ export interface ScrubContext {
50
+ doc: Document;
51
+ /** rrweb's mirror lookup, so an attribute mutation can be judged against the live element. */
52
+ resolveNode: (id: number) => Node | null;
53
+ }
54
+ /**
55
+ * Whether a text node under `el` must be masked.
56
+ *
57
+ * The decision itself is `shouldMaskValueOf`'s — text and values resolve
58
+ * identically, and sharing one function is what stops them diverging.
59
+ *
60
+ * FAILS CLOSED on a null parent: rrweb hands `null` when a text node has no
61
+ * parent element, and an unattributable string is exactly the one we cannot
62
+ * reason about.
63
+ */
64
+ export declare function shouldMaskTextOf(el: Element | null): boolean;
65
+ /**
66
+ * rrweb's `maskTextFn`. Because the recorder sets `maskTextSelector: '*'`,
67
+ * rrweb calls this for EVERY text node, which makes this function the single
68
+ * decision point — exactly what spec §6 requires. rrweb only ever invokes
69
+ * `maskTextFn` for nodes it has already decided to mask, so without the `'*'`
70
+ * selector this hook would never see a sensitivity-detected element and the
71
+ * policy would silently diverge.
72
+ */
73
+ export declare function maskTextHook(text: string, el: HTMLElement | null): string;
74
+ /**
75
+ * rrweb's `maskInputFn`. Unconditional: a value is something the user typed,
76
+ * and slice A masks it regardless of markers or detection (spec §3.3). The
77
+ * element argument is accepted and ignored on purpose — there is no branch to
78
+ * add here, and adding one would be a re-implementation.
79
+ */
80
+ export declare function maskInputHook(text: string, _el: HTMLElement): string;
81
+ /**
82
+ * A detached element carrying the serialized node's own tag and attributes.
83
+ *
84
+ * `scrubAttribute` needs an `Element` only to run `isSensitiveField`, which
85
+ * reads `tagName`, `type`, `autocomplete`, `name`, `id` and `aria-label` — all
86
+ * present in the serialized attributes. So a surrogate built from the event
87
+ * itself is exact, and needs no lookup into rrweb's mirror (which would fail
88
+ * for nodes already removed from the document).
89
+ */
90
+ export declare function surrogateElement(tagName: string, attributes: Record<string, unknown>, doc: Document): Element | null;
91
+ /** Scrubs one serialized element's attributes in place, then recurses. */
92
+ export declare function scrubSerializedNode(node: SerializedNode, doc: Document): void;
93
+ /**
94
+ * Scrubs an emitted rrweb event's attributes IN PLACE and returns it.
95
+ *
96
+ * rrweb has no attribute hook and does not scrub attributes at all — verified
97
+ * against 2.1.1, where `<a href="/s?q=pierogi">` ships as
98
+ * `http://localhost/s?q=pierogi`. This pass closes that by CALLING slice A's
99
+ * `scrubAttribute`; it implements no rule of its own.
100
+ */
101
+ export declare function scrubEventAttributes<T>(event: T, ctx: ScrubContext): T;
@@ -0,0 +1,20 @@
1
+ import { type RecorderHandle, type RecorderOptions } from './config';
2
+ /**
3
+ * ONE RECORDER AT A TIME, AND THE SECOND ONE IS INERT. rrweb's `record` is a
4
+ * module singleton (see `active` above), so a second concurrent
5
+ * `startRecording()` silently takes the session over from the first, whose
6
+ * buffer then stops filling while its handle still reports healthy stats —
7
+ * the exact silent-degradation shape this slice exists to eliminate.
8
+ *
9
+ * REFUSED BY RETURNING AN INERT HANDLE, NOT BY THROWING. This is a call the
10
+ * recorder can be made on any stack, including a framework's, and the
11
+ * precedent is already set two ways in this file: a failed `record()` yields
12
+ * an inert handle, and `triggers.ts` contains the sink. `attachReplay`
13
+ * (index.ts) DOES throw, and deliberately: that is an explicit
14
+ * once-per-application wiring call on the app author's own stack, where the
15
+ * throw is the only signal a developer will see. Here the first recorder is
16
+ * already running and the honest answer is to leave it alone.
17
+ *
18
+ * `stop()` on the owning recorder releases the slot.
19
+ */
20
+ export declare function startRecording(opts: RecorderOptions): RecorderHandle;