@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/README.md +360 -0
- package/TECHNICAL-DETAILS.md +480 -0
- package/dist/index-bc4bw3ba.js +844 -0
- package/dist/index-c3taa3cg.js +9 -0
- package/dist/index.d.ts +235 -0
- package/dist/index.js +504 -0
- package/dist/privacy/attributes.d.ts +12 -0
- package/dist/privacy/collect.d.ts +8 -0
- package/dist/privacy/markers.d.ts +7 -0
- package/dist/privacy/mask.d.ts +2 -0
- package/dist/privacy/policy.d.ts +27 -0
- package/dist/privacy/sensitivity.d.ts +1 -0
- package/dist/replay/budget.d.ts +49 -0
- package/dist/replay/buffer.d.ts +155 -0
- package/dist/replay/config.d.ts +190 -0
- package/dist/replay/drops.d.ts +46 -0
- package/dist/replay/index.d.ts +2 -0
- package/dist/replay/index.js +8 -0
- package/dist/replay/interactions.d.ts +48 -0
- package/dist/replay/persist.d.ts +75 -0
- package/dist/replay/privacy-hooks.d.ts +101 -0
- package/dist/replay/recorder.d.ts +20 -0
- package/dist/replay/triggers.d.ts +48 -0
- package/dist/replay/upload.d.ts +194 -0
- package/dist/replay/wire.d.ts +132 -0
- package/dist/stack.d.ts +8 -0
- package/dist/wire-992wvzs1.js +926 -0
- package/package.json +62 -0
|
@@ -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,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;
|