@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,48 @@
|
|
|
1
|
+
import { type BufferedEvent, type ReplayBuffer } from './buffer';
|
|
2
|
+
import type { DegradationRecord, ResolvedRecorderConfig, Sink, TriggerSource, WindowDropReason } from './config';
|
|
3
|
+
/**
|
|
4
|
+
* The ceiling on post-roll extension, measured from the FIRST trigger. Without
|
|
5
|
+
* it a page erroring continuously every few hundred milliseconds would defer
|
|
6
|
+
* its flush forever and ship nothing at all.
|
|
7
|
+
*/
|
|
8
|
+
export declare const TRIGGER_MAX_WINDOW_EXTENSION_MS = 30000;
|
|
9
|
+
export interface TriggerCoordinatorOptions {
|
|
10
|
+
buffer: ReplayBuffer;
|
|
11
|
+
sink: Sink;
|
|
12
|
+
config: ResolvedRecorderConfig;
|
|
13
|
+
/** Read at flush time, so a degradation recorded during the post-roll still rides along. */
|
|
14
|
+
degraded: () => DegradationRecord[];
|
|
15
|
+
now: () => number;
|
|
16
|
+
setTimer: (cb: () => void, ms: number) => unknown;
|
|
17
|
+
clearTimer: (handle: unknown) => void;
|
|
18
|
+
/**
|
|
19
|
+
* Notified instead of `sink` when a window fails one of the two closed
|
|
20
|
+
* gates below. Optional and additive (slice B2, Task 9): absent, both drops
|
|
21
|
+
* remain as silent as B1 shipped them.
|
|
22
|
+
*/
|
|
23
|
+
onDrop?: (reason: WindowDropReason) => void;
|
|
24
|
+
}
|
|
25
|
+
export interface TriggerCoordinator {
|
|
26
|
+
trigger(source: TriggerSource): void;
|
|
27
|
+
/** Flushes the in-flight window immediately. `unloaded` marks a post-roll cut short by navigation. */
|
|
28
|
+
flushNow(unloaded: boolean): void;
|
|
29
|
+
/** Abandons any in-flight post-roll without shipping. `stop()` uses this. */
|
|
30
|
+
cancel(): void;
|
|
31
|
+
pending(): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* ALWAYS-ON SESSION REPLAY (issue #793): ships a checkpoint-closed segment
|
|
34
|
+
* as its OWN window, independent of `pending` and of every trigger above.
|
|
35
|
+
* Every session is recorded this way, not just the ones with an error — an
|
|
36
|
+
* error still gets its own trigger window (pre-roll/post-roll, unchanged),
|
|
37
|
+
* this is what makes a session WITHOUT one still produce something.
|
|
38
|
+
*
|
|
39
|
+
* `events` already begins with its own full snapshot — that is the
|
|
40
|
+
* checkpoint invariant `buffer.ts` guarantees for every closed segment — so
|
|
41
|
+
* there is no pre-roll to wait for and no post-roll to extend: the closed
|
|
42
|
+
* segment already IS the window, `occurrences` is always 1 (there is no
|
|
43
|
+
* burst to de-duplicate), and this ships SYNCHRONOUSLY rather than through
|
|
44
|
+
* `pending`'s timer machinery.
|
|
45
|
+
*/
|
|
46
|
+
checkpoint(events: readonly BufferedEvent[], startedAt: number, unloaded: boolean): void;
|
|
47
|
+
}
|
|
48
|
+
export declare function createTriggerCoordinator(opts: TriggerCoordinatorOptions): TriggerCoordinator;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import type { ReplayWindow, Sink } from './config';
|
|
2
|
+
import { type DropLedger } from './drops';
|
|
3
|
+
/**
|
|
4
|
+
* The largest DECODED (pre-base64) window body this sink will attempt to
|
|
5
|
+
* upload. COMPUTED, not copied, from the same two constants
|
|
6
|
+
* `replay_ingest.lox`'s `MAX_WINDOW_BYTES` derives from — `(transport cap -
|
|
7
|
+
* header allowance) * 3/4`, because base64 inflates by 4/3 and this measures
|
|
8
|
+
* the bytes BEFORE that inflation, exactly like the server's own check
|
|
9
|
+
* (`Bytes.length(blob)` there, `body.byteLength` here — the same
|
|
10
|
+
* measurement on each end of the wire). A drift in EITHER source constant
|
|
11
|
+
* changes this the same way it changes the server's, rather than requiring
|
|
12
|
+
* this file to be re-tuned by hand against a result it did not derive.
|
|
13
|
+
*
|
|
14
|
+
* THIS IS NOT A SECOND CAP THAT CAN GO OUT OF SYNC WITH ITS OWN REASONING:
|
|
15
|
+
* it uses the identical formula, but it genuinely cannot IMPORT the server's
|
|
16
|
+
* Loxel constants (no shared build-time source between the two languages),
|
|
17
|
+
* so a change to `read_request.lox`'s `DEFAULT_MAX_BODY_BYTES` or
|
|
18
|
+
* `replay_ingest.lox`'s `TRANSPORT_HEADER_ALLOWANCE` still has to be
|
|
19
|
+
* mirrored here by hand. That residual coupling is real and is called out
|
|
20
|
+
* in the task report rather than hidden.
|
|
21
|
+
*/
|
|
22
|
+
export declare const MAX_WINDOW_BYTES: number;
|
|
23
|
+
/**
|
|
24
|
+
* Serializes and gzips a window. Falls back to an uncompressed body with
|
|
25
|
+
* `compressed: false` when `CompressionStream` does not exist in the host —
|
|
26
|
+
* measured in happy-dom/Bun (both have it; see the task report) rather than
|
|
27
|
+
* assumed, but older browsers (pre-Chrome 80/Safari 16.4) genuinely lack it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function encodeWindow(w: ReplayWindow): Promise<{
|
|
30
|
+
body: Uint8Array;
|
|
31
|
+
compressed: boolean;
|
|
32
|
+
}>;
|
|
33
|
+
/**
|
|
34
|
+
* Not cryptographically important — this only needs to be unique enough to
|
|
35
|
+
* group one sink's windows under one session document
|
|
36
|
+
* (`replay_store.lox`'s `_session_key`), never used as a secret or an
|
|
37
|
+
* identity check. `crypto.randomUUID` where it exists; a low-collision
|
|
38
|
+
* fallback (timestamp + random) for hosts that predate it.
|
|
39
|
+
*
|
|
40
|
+
* EXPORTED, AND THAT IS THE POINT (final review, minor). `wire.ts` carried a
|
|
41
|
+
* verbatim duplicate of this function; two implementations of "mint a session
|
|
42
|
+
* id" is the same two-paths-that-can-diverge shape this slice keeps finding,
|
|
43
|
+
* and the two are supposed to produce interchangeable ids. It lives HERE, the
|
|
44
|
+
* lower layer, because `wire.ts` already imports from this file and the
|
|
45
|
+
* reverse edge would be a cycle.
|
|
46
|
+
*/
|
|
47
|
+
export declare function randomSessionId(): string;
|
|
48
|
+
/**
|
|
49
|
+
* The envelope MINUS its blob — every field `_validate_upload`
|
|
50
|
+
* (`replay_ingest.lox`) reads out of the JSON body except `blob` itself.
|
|
51
|
+
*
|
|
52
|
+
* IT IS ALSO EXACTLY THE `meta` PERSISTED TO INDEXEDDB. `persist.ts` stores a
|
|
53
|
+
* raw `(Uint8Array, object)` pair and deliberately never looks inside `meta`,
|
|
54
|
+
* so making the stored meta BE the envelope-minus-blob is what lets
|
|
55
|
+
* `drainPersisted`'s `send(body, meta)` callback be adapted straight onto
|
|
56
|
+
* `postEnvelope` below — the drain path and the live path build the wire
|
|
57
|
+
* format in ONE place, this file, which is the only one verified
|
|
58
|
+
* field-for-field against the server. Re-deriving it on the drain path would
|
|
59
|
+
* be the "two code paths, one masks the other" shape this slice has hit in
|
|
60
|
+
* eight consecutive tasks.
|
|
61
|
+
*/
|
|
62
|
+
export interface UploadEnvelopeMeta {
|
|
63
|
+
sessionId: string;
|
|
64
|
+
trigger: ReplayWindow['trigger'];
|
|
65
|
+
degraded: ReplayWindow['degraded'];
|
|
66
|
+
config: ReplayWindow['config'];
|
|
67
|
+
compressed: boolean;
|
|
68
|
+
droppedWindows: number;
|
|
69
|
+
}
|
|
70
|
+
/** Where an envelope goes, and what it authenticates with. */
|
|
71
|
+
export interface PostTarget {
|
|
72
|
+
/** The ingest route — `replay_ingest.lox`'s `REPLAY_PATH`, `/api/v1/client/replay`, on the Pharos base URL. */
|
|
73
|
+
endpoint: string;
|
|
74
|
+
/** Sent as `Authorization: Bearer <appKey>` — the client key `auth_env(req, "client")` resolves, same convention `index.ts`'s errors POST uses. */
|
|
75
|
+
appKey: string;
|
|
76
|
+
/** Injectable so tests never touch the network; defaults to the global `fetch`. */
|
|
77
|
+
fetchImpl?: typeof fetch;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* THE ONE PLACE THE WIRE FORMAT IS ASSEMBLED AND SENT.
|
|
81
|
+
*
|
|
82
|
+
* Resolves `true` iff the server accepted the window (`res.ok`), `false`
|
|
83
|
+
* otherwise; REJECTS on a transport failure, because a caller that wants to
|
|
84
|
+
* treat "the network died" differently from "the server said no" must be able
|
|
85
|
+
* to, and both callers here in fact treat them identically (one drop each).
|
|
86
|
+
*
|
|
87
|
+
* `meta` is spread FIRST and `blob` added last, so the blob can never be
|
|
88
|
+
* shadowed by a stored meta that somehow carried one.
|
|
89
|
+
*/
|
|
90
|
+
export declare function postEnvelope(body: Uint8Array, meta: UploadEnvelopeMeta, target: PostTarget): Promise<boolean>;
|
|
91
|
+
/** Projects a `ReplayWindow` onto the envelope's non-blob fields. */
|
|
92
|
+
export declare function envelopeMetaFor(window: ReplayWindow, sessionId: string, compressed: boolean, droppedWindows: number): UploadEnvelopeMeta;
|
|
93
|
+
/**
|
|
94
|
+
* Serializes a window for the wire, honouring an explicit `compress: false`.
|
|
95
|
+
* Shared by the live-POST path and wire.ts's persist path so a window that
|
|
96
|
+
* rests in IndexedDB is byte-for-byte the one that would have been POSTed.
|
|
97
|
+
*/
|
|
98
|
+
export declare function encodeForUpload(window: ReplayWindow, compress: boolean | undefined): Promise<{
|
|
99
|
+
body: Uint8Array;
|
|
100
|
+
compressed: boolean;
|
|
101
|
+
}>;
|
|
102
|
+
export interface UploadOptions extends PostTarget {
|
|
103
|
+
/**
|
|
104
|
+
* Force the uncompressed wire form even where `CompressionStream` exists.
|
|
105
|
+
* Defaults to compressing. This is a DIFFERENT code path from
|
|
106
|
+
* `encodeWindow`'s own fallback — see the module doc comment above — and
|
|
107
|
+
* bypasses `encodeWindow` entirely rather than compressing and then
|
|
108
|
+
* discarding the result, so setting it `false` costs nothing extra.
|
|
109
|
+
*/
|
|
110
|
+
compress?: boolean;
|
|
111
|
+
/**
|
|
112
|
+
* The session id the server groups this sink's windows under
|
|
113
|
+
* (`replay_ingest.lox`'s `sessionId`, `replay_store.lox`'s
|
|
114
|
+
* `_session_key(app_id, session_id)`). OPTIONAL. Left unset, one is
|
|
115
|
+
* generated ONCE when `createUploadSink` is called and reused for every
|
|
116
|
+
* window the returned `Sink` uploads.
|
|
117
|
+
*
|
|
118
|
+
* THAT FALLBACK IS A STOPGAP AND MEANS EVERY PAGE LOAD IS AN UNRELATED
|
|
119
|
+
* SESSION, which degrades exactly the grouping slice B3's console is built
|
|
120
|
+
* to read. `startReplayUpload` (wire.ts) is what resolves a REAL one —
|
|
121
|
+
* caller-supplied, else a durable per-tab id in `sessionStorage` — and
|
|
122
|
+
* passes it here. Prefer that entry point over calling this function
|
|
123
|
+
* directly.
|
|
124
|
+
*/
|
|
125
|
+
sessionId?: string;
|
|
126
|
+
/**
|
|
127
|
+
* The cumulative `droppedWindows` ledger (drops.ts). Left unset, this sink
|
|
128
|
+
* makes its own, which counts ONLY the windows THIS sink failed to deliver
|
|
129
|
+
* — not `persist.ts`'s quota evictions and not B1's silent coordinator
|
|
130
|
+
* drops. Inject the session's ledger (as `startReplayUpload` does) to get
|
|
131
|
+
* the complete number spec §7 describes.
|
|
132
|
+
*/
|
|
133
|
+
drops?: DropLedger;
|
|
134
|
+
/**
|
|
135
|
+
* Observability seam. `Sink` is `(window) => void`, so an upload's promise
|
|
136
|
+
* has nowhere to go through the public contract; a caller that needs to
|
|
137
|
+
* await in-flight work (teardown, or a test that must not poll) passes this
|
|
138
|
+
* and receives the promise for each upload. The promise NEVER rejects —
|
|
139
|
+
* `uploadOne` catches everything — so an implementation may store it
|
|
140
|
+
* without adding a rejection handler.
|
|
141
|
+
*/
|
|
142
|
+
track?: (inflight: Promise<void>) => void;
|
|
143
|
+
/**
|
|
144
|
+
* RELIABILITY OVER A DISRUPTED CONNECTION (issue #793 "always-on"
|
|
145
|
+
* follow-up). Called INSTEAD OF `drops.note('delivery')` when the live POST
|
|
146
|
+
* fails — a non-2xx response, a rejected `fetch`, or an encoding failure —
|
|
147
|
+
* so the window can be persisted and delivered on a later drain rather than
|
|
148
|
+
* being lost outright. Under the dashcam model a lost window cost one
|
|
149
|
+
* error's context; under always-on a flaky connection during a long session
|
|
150
|
+
* would otherwise lose most of its segments, and the session then replays
|
|
151
|
+
* WITH HOLES — harder to notice than a session missing entirely, which is
|
|
152
|
+
* why this is worth a dedicated seam rather than leaving the original
|
|
153
|
+
* `drops.note('delivery')` as the only outcome.
|
|
154
|
+
*
|
|
155
|
+
* OPTIONAL AND ADDITIVE: a caller that does not supply it keeps B2's
|
|
156
|
+
* original behaviour exactly — `drops.note('delivery')`, and the window is
|
|
157
|
+
* gone. `startReplayUpload` (wire.ts) is the wiring that supplies it,
|
|
158
|
+
* reusing `persistForLater` — the SAME function the unload path already
|
|
159
|
+
* uses to write to IndexedDB — rather than inventing a second persistence
|
|
160
|
+
* path. Reusing it also means the failure is attributed correctly: a
|
|
161
|
+
* successful persist is NOT a drop (the window is merely delayed), and only
|
|
162
|
+
* a persistence failure itself counts, under `storage` or `quota` exactly
|
|
163
|
+
* as `persist.ts` already distinguishes them.
|
|
164
|
+
*
|
|
165
|
+
* MAY REJECT OR THROW. Awaited; a failure here falls back to
|
|
166
|
+
* `drops.note('delivery')` so the window's loss is still counted rather
|
|
167
|
+
* than silently absorbed — the fallback existing does not make the window
|
|
168
|
+
* unconditionally safe.
|
|
169
|
+
*/
|
|
170
|
+
onDeliveryFailure?: (window: ReplayWindow) => Promise<void> | void;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Builds the `Sink` B1's trigger coordinator calls (`triggers.ts`'s
|
|
174
|
+
* `opts.sink(replayWindow)`, itself inside `contained()`). Every upload is
|
|
175
|
+
* fire-and-forget: `Sink` is `(window: ReplayWindow) => void`, so nothing
|
|
176
|
+
* here may throw synchronously OR let a rejection escape asynchronously —
|
|
177
|
+
* `contained()` can only catch the former, since a `throw` inside an
|
|
178
|
+
* `async function` becomes a REJECTED PROMISE that surfaces after this
|
|
179
|
+
* function has already returned `undefined`, on a stack `contained`'s
|
|
180
|
+
* `try/catch` is no longer on. The `try/catch` inside `uploadOne` below is
|
|
181
|
+
* therefore not a second sink wrapper duplicating `contained` — it covers
|
|
182
|
+
* exactly the async failure mode `contained` structurally cannot reach, and
|
|
183
|
+
* B1's own comment in `triggers.ts` anticipated this ("If slice B2's
|
|
184
|
+
* uploader grows an internal error channel, THIS is the function that feeds
|
|
185
|
+
* it" — `droppedWindows`, folded into the next envelope, is that channel).
|
|
186
|
+
*
|
|
187
|
+
* THIS SINK ALWAYS POSTS. The unload case — where a POST cannot carry the
|
|
188
|
+
* window at all — is routed away from it BEFORE it is called, by
|
|
189
|
+
* `startReplayUpload` (wire.ts), not by a branch in here: a sink that
|
|
190
|
+
* sometimes uploads and sometimes writes to IndexedDB would be two behaviours
|
|
191
|
+
* behind one name, and the test that proved "it POSTed" could not tell you
|
|
192
|
+
* which one ran.
|
|
193
|
+
*/
|
|
194
|
+
export declare function createUploadSink(opts: UploadOptions): Sink;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { type PostTarget } from './upload';
|
|
2
|
+
import { type DropLedger } from './drops';
|
|
3
|
+
import type { RecorderHandle, RecorderOptions } from './config';
|
|
4
|
+
/**
|
|
5
|
+
* Where the durable session id lives. `sessionStorage`, not `localStorage`,
|
|
6
|
+
* deliberately: a replay session is a browsing session — one tab, until it is
|
|
7
|
+
* closed — and `localStorage` would tie every tab and every future visit into
|
|
8
|
+
* one ever-growing "session" that `replay_store.lox`'s per-application cap
|
|
9
|
+
* would then never rotate.
|
|
10
|
+
*/
|
|
11
|
+
export declare const SESSION_STORAGE_KEY = "pharos-replay-session";
|
|
12
|
+
/**
|
|
13
|
+
* A REAL session id, replacing Task 7's per-sink stopgap (requirement 2).
|
|
14
|
+
*
|
|
15
|
+
* THE STOPGAP'S DEFECT: `createUploadSink` generates an id when it is called,
|
|
16
|
+
* so every page load minted a fresh, unrelated one and a user's windows never
|
|
17
|
+
* grouped into a session server-side — degrading exactly the grouping slice
|
|
18
|
+
* B3's console is built to read, and making the per-application session cap
|
|
19
|
+
* count page loads rather than sessions.
|
|
20
|
+
*
|
|
21
|
+
* Three sources, in order:
|
|
22
|
+
* 1. an explicit `sessionId` — a host that already has a session concept
|
|
23
|
+
* (`PharosContext.sessionId`) should pass it, so replay groups the same
|
|
24
|
+
* way its errors and flags do;
|
|
25
|
+
* 2. a durable per-tab id in `sessionStorage`, which SURVIVES RELOADS AND
|
|
26
|
+
* SAME-TAB NAVIGATIONS — this is the part that actually fixes the defect,
|
|
27
|
+
* and it is also what makes a window drained on the next load land in the
|
|
28
|
+
* session it belonged to;
|
|
29
|
+
* 3. a fresh random id, when storage is unavailable or refuses the write.
|
|
30
|
+
*
|
|
31
|
+
* NOTHING WAS ADDED TO B1's `ReplayWindow` (config.ts) for this, though this
|
|
32
|
+
* task authorized it. The id is a property of the UPLOAD, not of the capture:
|
|
33
|
+
* the recorder never reads it, the buffer never stores it, and the envelope
|
|
34
|
+
* carries it. Putting it on the window would have made every `ReplayWindow`
|
|
35
|
+
* in B1's own tests carry a field only slice B2 consumes, for no behaviour
|
|
36
|
+
* that is not available here. (`RecorderOptions.onWindowDropped` WAS added —
|
|
37
|
+
* see requirement 3 — because that one genuinely could not be observed from
|
|
38
|
+
* outside the recorder.)
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolveSessionId(explicit: string | undefined, storage: Storage | null): string;
|
|
41
|
+
/**
|
|
42
|
+
* Where the session's cumulative `droppedWindows` lives BETWEEN PAGE LOADS,
|
|
43
|
+
* next to the session id and with exactly the same lifecycle.
|
|
44
|
+
*
|
|
45
|
+
* --- THE DEFECT THIS CLOSES (final review, Critical 1) --------------------
|
|
46
|
+
*
|
|
47
|
+
* Two mechanisms with different lifetimes under one identity, which is the
|
|
48
|
+
* variant of this slice's recurring shape that nothing had caught: the
|
|
49
|
+
* session id SURVIVES A RELOAD (`sessionStorage`, above) while the drop
|
|
50
|
+
* ledger was minted fresh on every `startReplayUpload()` call. Both halves
|
|
51
|
+
* were verified — independently, never against each other — and the
|
|
52
|
+
* RELATIONSHIP was wrong.
|
|
53
|
+
*
|
|
54
|
+
* It matters because of what the server does with the number.
|
|
55
|
+
* `replay_store.lox`'s `_merge_dropped` takes `max(existing, incoming)`
|
|
56
|
+
* SPECIFICALLY BECAUSE the client is expected to send a running total; a
|
|
57
|
+
* max() over per-page-load counts keeps only the worst single load. A tab
|
|
58
|
+
* that reloads three times during an incident, losing two windows each time,
|
|
59
|
+
* POSTed `2`, `2`, `2` and the session document ended at `2` — while six were
|
|
60
|
+
* lost. An operator triaging in B3's console would read "2 lost": the exact
|
|
61
|
+
* false reassurance this counter exists to prevent.
|
|
62
|
+
*
|
|
63
|
+
* --- WHY THE STORED VALUE CARRIES ITS SESSION ID --------------------------
|
|
64
|
+
*
|
|
65
|
+
* Because fixing the lifetime mismatch by simply persisting a number would
|
|
66
|
+
* REINTRODUCE IT FROM THE OTHER SIDE. A host that passes an explicit
|
|
67
|
+
* `sessionId` (see `resolveSessionId`) and mints a fresh one per page load —
|
|
68
|
+
* which is exactly what `PharosContext.sessionId` does today, see the README
|
|
69
|
+
* — would then have load 2's envelope carry load 1's losses under a session
|
|
70
|
+
* id load 1 never used. Over-counting a session that lost nothing is the same
|
|
71
|
+
* class of lie as under-counting one that did.
|
|
72
|
+
*
|
|
73
|
+
* So the record is `{sessionId, total}` and a total is carried ONLY when the
|
|
74
|
+
* stored id equals the resolved one. The count's lifetime is thereby DEFINED
|
|
75
|
+
* BY the id's lifetime rather than merely running alongside it, which is the
|
|
76
|
+
* property the defect above was missing.
|
|
77
|
+
*/
|
|
78
|
+
export declare const SESSION_DROPS_KEY = "pharos-replay-drops";
|
|
79
|
+
export interface ReplayUploadOptions extends PostTarget, Omit<RecorderOptions, 'sink' | 'onWindowDropped'> {
|
|
80
|
+
/** See `resolveSessionId`. Pass `PharosContext.sessionId` where the host has one. */
|
|
81
|
+
sessionId?: string;
|
|
82
|
+
/** Force the uncompressed wire form even where `CompressionStream` exists. */
|
|
83
|
+
compress?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Injectable IndexedDB, so tests never touch a real browser database.
|
|
86
|
+
* Defaults to `globalThis.indexedDB`; where there is none, unload-time
|
|
87
|
+
* windows are counted as dropped rather than persisted (an environment
|
|
88
|
+
* without IndexedDB has nowhere to rest them).
|
|
89
|
+
*/
|
|
90
|
+
indexedDB?: IDBFactory;
|
|
91
|
+
/** Injectable session storage for `resolveSessionId`. Pass `null` to opt out of a durable id entirely. */
|
|
92
|
+
sessionStorage?: Storage | null;
|
|
93
|
+
}
|
|
94
|
+
export interface ReplayUploadHandle extends RecorderHandle {
|
|
95
|
+
/** The id every window from this recorder is grouped under server-side. */
|
|
96
|
+
readonly sessionId: string;
|
|
97
|
+
/**
|
|
98
|
+
* The session's drop ledger.
|
|
99
|
+
*
|
|
100
|
+
* `total()` is the number that goes on the wire: CUMULATIVE FOR THE SESSION,
|
|
101
|
+
* carried across page loads through `sessionStorage` (`SESSION_DROPS_KEY`),
|
|
102
|
+
* which is what makes the server's `max(existing, incoming)` merge correct.
|
|
103
|
+
* `byReason()` is THIS PAGE LOAD's breakdown only — nothing on the wire
|
|
104
|
+
* carries it and nothing stores it.
|
|
105
|
+
*/
|
|
106
|
+
drops(): DropLedger;
|
|
107
|
+
/**
|
|
108
|
+
* Resolves once every upload, persist, drain and clear this handle started
|
|
109
|
+
* has finished.
|
|
110
|
+
*
|
|
111
|
+
* `Sink` is fire-and-forget by contract, and `stop()` is synchronous
|
|
112
|
+
* because B1's `RecorderHandle.stop()` is — so the consent clear is
|
|
113
|
+
* necessarily asynchronous work started by a synchronous call. This is how
|
|
114
|
+
* a caller (an SPA tearing down, or a test asserting that `stop()` really
|
|
115
|
+
* emptied the store) waits for it. Never rejects.
|
|
116
|
+
*/
|
|
117
|
+
settled(): Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Starts a recorder whose windows are uploaded to Pharos.
|
|
121
|
+
*
|
|
122
|
+
* THE DOCUMENTED ENTRY POINT for slice B2, and the only one that produces a
|
|
123
|
+
* complete `droppedWindows`: `createUploadSink` used directly counts only its
|
|
124
|
+
* own delivery failures (see its doc comment), while this wires B1's
|
|
125
|
+
* coordinator drops and `persist.ts`'s quota evictions into the same ledger.
|
|
126
|
+
*
|
|
127
|
+
* Takes every `RecorderOptions` field except `sink` (which it supplies) and
|
|
128
|
+
* `onWindowDropped` (which it uses), so all of B1's injectable seams —
|
|
129
|
+
* `now`, `setTimer`/`clearTimer`, `scheduleIdle`, `target`, `lifecycleTarget`,
|
|
130
|
+
* `doc` — pass straight through.
|
|
131
|
+
*/
|
|
132
|
+
export declare function startReplayUpload(opts: ReplayUploadOptions): ReplayUploadHandle;
|
package/dist/stack.d.ts
ADDED