@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,9 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined")
5
+ return require.apply(this, arguments);
6
+ throw Error('Dynamic require of "' + x + '" is not supported');
7
+ });
8
+
9
+ export { __require };
@@ -0,0 +1,235 @@
1
+ import type { RecorderHandle } from "./replay/config";
2
+ import type { ReplayUploadHandle, ReplayUploadOptions } from "./replay/wire";
3
+ /** Context identifying who is asking, plus free-form targeting attributes. */
4
+ export interface PharosContext {
5
+ contextKey: string;
6
+ application?: string;
7
+ release?: string;
8
+ sessionId?: string;
9
+ attributes?: Record<string, unknown>;
10
+ }
11
+ /**
12
+ * Minimal listener-registration surface auto-capture needs — matched by
13
+ * `window`/`globalThis` in a real browser, and by a plain fake in tests
14
+ * (bun test has no DOM, so `window` does not exist there).
15
+ */
16
+ export interface ErrorListenerTarget {
17
+ addEventListener(type: string, listener: (event: unknown) => void): void;
18
+ removeEventListener(type: string, listener: (event: unknown) => void): void;
19
+ }
20
+ export interface PharosConfig {
21
+ baseUrl: string;
22
+ clientKey: string;
23
+ context: PharosContext;
24
+ /**
25
+ * Injectable EventSource constructor, defaulting to the global
26
+ * `window.EventSource`. Exists so tests (and non-browser hosts) can
27
+ * supply a fake without a DOM.
28
+ */
29
+ eventSourceFactory?: (url: string) => EventSource;
30
+ /**
31
+ * Auto-capture uncaught errors (`window.onerror`) and unhandled promise
32
+ * rejections (`window.onunhandledrejection`) via `captureException`.
33
+ * Defaults to `true`; set `false` to only report via explicit
34
+ * `captureException` calls.
35
+ */
36
+ errors?: boolean;
37
+ /**
38
+ * Injectable event-listener target for auto-capture, defaulting to the
39
+ * global `window`/`globalThis`. Exists so tests (and non-browser hosts)
40
+ * can supply a fake without a DOM — mirrors `eventSourceFactory`.
41
+ */
42
+ errorTarget?: ErrorListenerTarget;
43
+ }
44
+ /** Options accepted by {@link PharosBrowserClient.captureException}. */
45
+ export interface CaptureExceptionOptions {
46
+ /** Free-form label for where the error was caught (e.g. a route or component name). */
47
+ site?: string;
48
+ /** Extra scalar (or array-of-scalar) attributes to attach to this occurrence only. */
49
+ attributes?: Record<string, unknown>;
50
+ }
51
+ type PharosEvent = "change" | "error";
52
+ type Listener = (payload: unknown) => void;
53
+ export declare class PharosBrowserClient {
54
+ private baseUrl;
55
+ private clientKey;
56
+ private context;
57
+ private eventSourceFactory;
58
+ private flags;
59
+ private version;
60
+ private streamToken;
61
+ private eventSource;
62
+ private listeners;
63
+ private closed;
64
+ private reconnectTimer;
65
+ private errorsEnabled;
66
+ private errorTarget;
67
+ private replayHandle;
68
+ private replayTriggerKind;
69
+ private replayErrorListeners;
70
+ private onReplayError;
71
+ private onReplayRejection;
72
+ private constructor();
73
+ /** Bootstraps against the server, opens the live-update stream, and (by default) wires auto-capture. */
74
+ static init(config: PharosConfig): Promise<PharosBrowserClient>;
75
+ private bootstrap;
76
+ private connectStream;
77
+ /**
78
+ * EventSource auto-reconnects on transient drops. The one case it cannot
79
+ * recover from alone is an expired stream session (401 on the SSE
80
+ * request): the browser sees this as an error and keeps retrying the same
81
+ * dead token forever. Heuristic: when the source has settled into CLOSED
82
+ * (its terminal state — EventSource does not reach CLOSED on a retryable
83
+ * drop), re-bootstrap once after a short backoff to mint a fresh token,
84
+ * then reconnect the stream. This is intentionally simple — no queue, no
85
+ * exponential ramp — matching the brief's "don't over-engineer" guidance.
86
+ */
87
+ private maybeReconnect;
88
+ private onWindowError;
89
+ private onUnhandledRejection;
90
+ private registerErrorListeners;
91
+ private unregisterErrorListeners;
92
+ /**
93
+ * Reports one exception as a single-entry `/api/v1/errors` batch: message
94
+ * from `err.message ?? String(err)` (empty message becomes
95
+ * `"(no message)"`), `kind` from `err.name`, and `stack` parsed via
96
+ * {@link parseStack} when `err.stack` is a string. Every field is
97
+ * sanitized/truncated to the server's caps before sending — the server
98
+ * rejects the whole batch on any single field violating them, and this is
99
+ * most often called from uncaught-exception paths where the input isn't
100
+ * under the caller's control.
101
+ *
102
+ * Fire-and-forget: never throws, never returns a promise for the caller to
103
+ * await. A failed POST (network error, non-2xx) is reported via
104
+ * `on("error")`, matching the stream's existing error-reporting shape.
105
+ *
106
+ * A no-op after `close()`, matching `identify()`'s documented behavior —
107
+ * this client is terminal past `close()`.
108
+ */
109
+ captureException(err: unknown, opts?: CaptureExceptionOptions): void;
110
+ /**
111
+ * Routes this client's error paths into a running session recorder, so an
112
+ * error flushes a replay window around itself.
113
+ *
114
+ * The recorder is started SEPARATELY, by the application, via
115
+ * `startRecording()` — attaching a client never starts recording. That
116
+ * separation is the consent boundary: adding the SDK must not begin
117
+ * recording a user's screen.
118
+ *
119
+ * ONE RECORDER AT A TIME, ENFORCED BY THROWING. rrweb's `record` is a
120
+ * process singleton (`wrappedEmit`, `takeFullSnapshot`, `mirror` and the
121
+ * `recording` flag are all module-level in rrweb 2.1.1), so a second
122
+ * `startRecording()` silently takes the session over: the first recorder's
123
+ * observers begin emitting into the SECOND recorder's buffer while the
124
+ * first handle still reports healthy stats. This is the one place a client
125
+ * can observe two recorders at once, and it refuses rather than swapping —
126
+ * swapping would leave the caller holding a handle it believes is wired,
127
+ * and stopping the first for them would set rrweb's module-level
128
+ * `recording = false` and half-kill the survivor. Re-attaching the SAME
129
+ * handle is idempotent; deliberately swapping means `detachReplay()` first.
130
+ *
131
+ * Unhandled promise rejections are guaranteed triggers. With auto-capture
132
+ * on (the default) they already route through `captureException`, and no
133
+ * extra listener is added — a second listener would double-count one
134
+ * rejection. With `errors: false` the window listeners were never
135
+ * registered, so this registers REPLAY-ONLY listeners that trigger the
136
+ * recorder without reporting anything to the errors API, leaving the app's
137
+ * opt-out intact.
138
+ *
139
+ * TWO MISUSES, TWO DIFFERENT ANSWERS, ON PURPOSE. Attaching after `close()`
140
+ * is a SILENT no-op, because a closed client is terminal — the same answer
141
+ * `identify()` and `captureException()` give, and nothing is left running
142
+ * for the caller to be wrong about. Attaching a SECOND, different handle
143
+ * THROWS, because that is a live contract violation with a resource
144
+ * collision behind it: two recorders exist, one of them is already being
145
+ * taken over, and silence would leave the caller holding a handle it
146
+ * believes is wired.
147
+ *
148
+ * @throws if a DIFFERENT recorder is already attached.
149
+ */
150
+ attachReplay(handle: RecorderHandle): void;
151
+ /**
152
+ * TWO INDEPENDENTLY-MINTED VALUES UNDER ONE NAME (final review, Important 4).
153
+ *
154
+ * `PharosContext.sessionId` is what rides the errors envelope
155
+ * (`captureException` above) and what `error_store.lox` stores.
156
+ * `startReplayUpload` mints its OWN unless the host passes one. When they
157
+ * disagree, an error row and the replay window captured around that same
158
+ * error CANNOT BE JOINED — which is precisely the correlation slice B3's
159
+ * console exists to perform, and nothing anywhere would have said so.
160
+ *
161
+ * A WARNING, NOT A THROW, and not a silent repair. `attachReplay` throws for
162
+ * a second recorder because that is a live resource collision; this is a
163
+ * degraded-correlation wiring mistake on an already-working setup, and the
164
+ * app author is the only one who can decide which id is authoritative.
165
+ * Repairing it here would mean re-keying windows the recorder has already
166
+ * uploaded under the other id, which this client cannot do. `startReplay()`
167
+ * below is the version that cannot get it wrong.
168
+ */
169
+ private warnIfSessionIdsDisagree;
170
+ /**
171
+ * Starts a replay recorder wired to THIS client — same base URL, same client
172
+ * key, and THE SAME `sessionId` the errors envelope carries.
173
+ *
174
+ * The reason this exists rather than leaving every application to call
175
+ * `startReplayUpload` itself: the two session ids above are two independent
176
+ * values under one name, and an application that never noticed gets a
177
+ * replay pillar whose windows cannot be joined to the errors that produced
178
+ * them. Here there is one id by construction.
179
+ *
180
+ * Also attaches the handle (`attachReplay`), so an uncaught error triggers a
181
+ * window — the whole point of the pairing. A client that already has a
182
+ * different recorder attached throws, exactly as `attachReplay` does —
183
+ * except that by the time `attachReplay` can throw, THIS function has
184
+ * already started a live recorder `attachReplay` never gets to own:
185
+ * `handle`'s `visibilitychange`/`pagehide` listeners are attached, its
186
+ * start-up drain has already run, and no caller holds the reference needed
187
+ * to `stop()` any of that. So the throw here also stops the orphan first,
188
+ * which is not part of what `attachReplay` itself does or needs to do —
189
+ * `attachReplay` never allocates.
190
+ *
191
+ * `endpoint` and `appKey` are supplied and may not be overridden; every
192
+ * other `startReplayUpload` option passes through unchanged. Resolves to
193
+ * `null` after `close()`, matching `attachReplay`'s silent no-op on a
194
+ * terminal client — a handle recording into a closed client would be a
195
+ * recorder nothing triggers.
196
+ *
197
+ * ASYNC BECAUSE IT LOADS THE RECORDER ON DEMAND (0.6.0). rrweb is ~63 kB
198
+ * gzipped, and while this was synchronous it was reachable from the root
199
+ * entry and therefore bundled by every consumer — including the ones that
200
+ * never record. It is now a dynamic `import()`, so the recorder is a
201
+ * separate chunk fetched only when an application actually starts replay.
202
+ * The chunk still ships with the deployment, which is the point: enabling
203
+ * replay stays a pure configuration change (loxel#979), it just stops
204
+ * costing anything up front. Rejects only if the chunk itself cannot load.
205
+ */
206
+ startReplay(options?: Omit<ReplayUploadOptions, "endpoint" | "appKey">): Promise<ReplayUploadHandle | null>;
207
+ /** Stops routing errors into the recorder. Does not stop the recorder itself. */
208
+ detachReplay(): void;
209
+ /** Synchronous flag lookup; falls back to defaultValue for unknown keys. */
210
+ flag<T>(key: string, defaultValue: T): T;
211
+ /** Subscribes to "change" (a new flags payload was applied) or "error". Returns an unsubscribe function. */
212
+ on(event: PharosEvent, cb: Listener): () => void;
213
+ private emit;
214
+ /**
215
+ * Re-identifies as a new context: closes the current stream, re-bootstraps
216
+ * (a full round trip — flags plus a fresh streamToken), and reconnects.
217
+ */
218
+ identify(context: PharosContext): Promise<void>;
219
+ /** Closes the stream, stops any pending reconnect, and unregisters auto-capture. Terminal — construct a new client to resume. */
220
+ close(): void;
221
+ }
222
+ export { MASK_TOKEN, maskText } from './privacy/mask';
223
+ export { EXCLUDE_CLASS, MASK_CLASS, shouldExclude, shouldMaskByMarker } from './privacy/markers';
224
+ export { isSensitiveField } from './privacy/sensitivity';
225
+ export { scrubAttribute, scrubUrl } from './privacy/attributes';
226
+ export { decide, masksValue, type Decision } from './privacy/policy';
227
+ export { collectRecordedStrings, type RecordedString } from './privacy/collect';
228
+ export { MAX_BUFFER_BYTES, RECORDER_DEFAULTS, resolveConfig, type ActivationMode, type DegradationReason, type DegradationRecord, type RecorderHandle, type RecorderOptions, type RecorderStats, type ReplayWindow, type ResolvedRecorderConfig, type Sink, type TriggerInfo, type TriggerKind, type TriggerSource, } from './replay/config';
229
+ export { ACTIVATION_EVENT_TAG } from './replay/interactions';
230
+ export { beginsWithSnapshot } from './replay/buffer';
231
+ export type { ReplayUploadHandle, ReplayUploadOptions } from './replay/wire';
232
+ export { MAX_WINDOW_BYTES, createUploadSink, encodeWindow, envelopeMetaFor, postEnvelope, type PostTarget, type UploadEnvelopeMeta, type UploadOptions, } from './replay/upload';
233
+ export { LOCAL_TTL_MS, clearPersisted, drainPersisted, persistWindow, quotaDroppedWindows, resetQuotaDroppedWindows, } from './replay/persist';
234
+ export { createDropLedger, type DropLedger, type DropReason } from './replay/drops';
235
+ export type { WindowDropReason } from './replay/config';