@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,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;
@@ -0,0 +1,8 @@
1
+ export interface Frame {
2
+ fn?: string;
3
+ file?: string;
4
+ line?: number;
5
+ col?: number;
6
+ }
7
+ /** Parses a raw `Error.stack` string into structured frames, capped at 50. */
8
+ export declare function parseStack(stack: string): Frame[];