@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 ADDED
@@ -0,0 +1,360 @@
1
+ # @loxel.dev/pharos-browser
2
+
3
+ A thin browser client for [Pharos](https://pharos-ops.app):
4
+ server-evaluated feature flags, error reporting, and session replay.
5
+
6
+ Bootstraps a context against the Pharos client API, then keeps flag values
7
+ current over a Server-Sent Events stream. Uncaught errors and unhandled
8
+ rejections are reported automatically. Session replay is opt-in, and its one
9
+ dependency (`rrweb`) is loaded on demand so you do not pay for it unless you
10
+ record — see [Bundle size](#bundle-size).
11
+
12
+ Built and tested with [Bun](https://bun.sh).
13
+
14
+ Internals — the wire format, the local rest, the degradation ladder, and
15
+ the full export reference — are in `TECHNICAL-DETAILS.md`, shipped alongside
16
+ this file.
17
+
18
+ ## The server-evaluated model
19
+
20
+ This client never evaluates flags itself. Every value `flag()` returns was
21
+ computed **server-side** — rules, percentage rollouts, targeting attributes,
22
+ and bucketing all happen behind `POST /api/v1/client/bootstrap`, which
23
+ returns a flat `{key: value}` map for the given context.
24
+
25
+ The client's only job is to hold that map, keep it fresh via `GET /api/v1/client/stream`, and
26
+ hand values back synchronously. This keeps rule logic and segment membership
27
+ off the client (nothing to reverse-engineer from a bundle) and means a
28
+ targeting change takes effect for every open tab without a redeploy.
29
+
30
+ ## Install
31
+
32
+ ```sh
33
+ bun add @loxel.dev/pharos-browser # or: npm install @loxel.dev/pharos-browser
34
+ ```
35
+
36
+ The published tarball ships **built output only** (`dist/`, plus `.d.ts`
37
+ files), not TypeScript source: the `exports` map is what lets a bundler
38
+ resolve the bare specifier.
39
+
40
+ ## Before you start
41
+
42
+ **Pharos is a hosted service, and access is by invitation.** There is one
43
+ instance, at `https://pharos-ops.app`.
44
+
45
+ `init()` needs three values, and they all come from your console there:
46
+
47
+ | value | where it comes from |
48
+ |---|---|
49
+ | `baseUrl` | always `https://pharos-ops.app` |
50
+ | `clientKey` | ORGANIZATION → API keys → new key, kind **client**. Starts `phc_`. |
51
+ | `context.application` | the application that client key was minted for |
52
+
53
+ Register your browser origins on that application (ORGANIZATION →
54
+ Applications) before the first request. An origin that is not on the list is
55
+ refused by CORS, which surfaces as a network error rather than a permissions
56
+ one — the most common first-run failure.
57
+
58
+ Use the `phc_` **client** key, never the `phs_` server key. The client key is
59
+ meant to ship in your bundle: it only authorises bootstrap, error ingest and
60
+ replay upload, for one application. The server key does much more and must
61
+ stay on a backend.
62
+
63
+ ## Bundle size
64
+
65
+ **Importing this package does not cost you `rrweb`.** The session recorder is
66
+ the only thing that needs it, and the recorder is not on the root entry:
67
+
68
+ | you import | you get |
69
+ |---|---|
70
+ | `@loxel.dev/pharos-browser` | flags, error reporting, the privacy policy, the replay buffer/encoder/store — **no rrweb** |
71
+ | `@loxel.dev/pharos-browser/replay` | the recorder, and therefore rrweb |
72
+ | `await client.startReplay()` | the recorder, fetched on demand as a separate chunk |
73
+
74
+ Measured on a real application (a React + Vite app, replay switched off):
75
+ importing the client used to add **204 kB raw / 63 kB gzip** to its main
76
+ chunk, essentially all of it rrweb, paid whether or not anything ever
77
+ recorded. It now adds **8.5 kB raw / 2.9 kB gzip**.
78
+
79
+ `startReplay()` reaches the recorder through a dynamic `import()`. The chunk
80
+ still ships with your deployment, so turning replay on stays a configuration
81
+ change — it just stops being downloaded by visitors who never record.
82
+
83
+ `rrweb` (2.1.1) is an ordinary runtime dependency — **not a CDN script**.
84
+ Installing this package installs rrweb into your own `node_modules`; your
85
+ bundler compiles it into your own output, served from your own origin.
86
+
87
+ This package's build never inlines it. The published tarball contains no rrweb
88
+ files at all — only a single unresolved `import { record } from "rrweb"` for
89
+ your bundler to resolve. That is what lets a project already using rrweb end
90
+ up with one shared copy rather than two.
91
+
92
+ ## Usage
93
+
94
+ ### Set up
95
+
96
+ ```typescript
97
+ import { PharosBrowserClient } from "@loxel.dev/pharos-browser";
98
+
99
+ const client = await PharosBrowserClient.init({
100
+ baseUrl: "https://pharos-ops.app", // required
101
+ clientKey: "phc_...", // required
102
+ context: {
103
+ contextKey: "user-42", // required — who is asking; buckets rollouts
104
+ application: "tax-graph", // recommended — see below
105
+ release: "1.4.0", // recommended — see below
106
+ sessionId: "sess-abc", // optional — joins errors to replay windows
107
+ attributes: { plan: "enterprise", country: "US" }, // optional — flag targeting
108
+ },
109
+ });
110
+ ```
111
+
112
+ `contextKey` is the only context field the server enforces — a bootstrap
113
+ without one is a `400`.
114
+
115
+ **`application` and `release` are optional to the type, and you want both
116
+ anyway.** `application` is hashed into the error fingerprint, so omitting it
117
+ collapses every browser application in your organization into one fingerprint
118
+ namespace and stops the console's `application=` filter from returning your
119
+ rows. `release` drives release attribution and the `release=` filter.
120
+
121
+ `sessionId` matters only if you want an error and the replay window captured
122
+ around it to be joinable; `client.startReplay()` gives you one id across both
123
+ without passing anything. `attributes` is free-form data for your flag
124
+ targeting rules — send nothing if no rule reads it.
125
+
126
+ ### Read a flag
127
+
128
+ ```typescript
129
+ // Synchronous, no network call — the value was computed server-side and is
130
+ // already here. Your default is returned until the bootstrap lands, and for
131
+ // any key Pharos does not know.
132
+ if (client.flag("new-nav", false)) {
133
+ renderNewNav();
134
+ }
135
+
136
+ // Values stay current over the stream, so a targeting change takes effect in
137
+ // every open tab without a redeploy. Re-read on change; do not trust the
138
+ // payload's shape.
139
+ const off = client.on("change", () => rerender());
140
+
141
+ // Stream and bootstrap failures surface here rather than throwing.
142
+ client.on("error", (err) => console.warn("pharos stream error", err));
143
+ ```
144
+
145
+ ### Identify a user
146
+
147
+ ```typescript
148
+ // After login. This is a full HTTP round trip, not a local re-key — call it
149
+ // on real identity changes, not per render.
150
+ await client.identify({
151
+ contextKey: user.id,
152
+ application: "tax-graph", // identify REPLACES the stored context, so
153
+ release: "1.4.0", // resend these or errors lose them
154
+ });
155
+ ```
156
+
157
+ ### Shut down
158
+
159
+ ```typescript
160
+ off(); // unsubscribe the change listener
161
+ client.close(); // close the stream, unregister the error listeners
162
+ ```
163
+
164
+ ## Error reporting
165
+
166
+ `init()` reports errors to `POST /api/v1/errors` two ways:
167
+
168
+ - **Auto-capture** (on by default) — registers `window.addEventListener("error", ...)`
169
+ and `window.addEventListener("unhandledrejection", ...)` and reports every
170
+ uncaught exception / rejected promise automatically. Opt out with
171
+ `init({ ..., errors: false })` to only report via explicit
172
+ `captureException()` calls. `close()` unregisters both listeners.
173
+ - **`captureException(err, opts?)`** — call this yourself from a `catch`
174
+ block for errors your own code already handled. `err` can be an `Error`,
175
+ a string, or anything else (`String(err)` is used as a fallback message);
176
+ `opts.site` labels where it was caught (e.g. a route name) and
177
+ `opts.attributes` attaches extra scalar (or array-of-scalar) context to
178
+ this occurrence only.
179
+
180
+ ```typescript
181
+ // Uncaught errors and unhandled rejections are reported for you. Report a
182
+ // handled one yourself, with whatever context makes it diagnosable:
183
+ try {
184
+ await checkout(cart);
185
+ } catch (err) {
186
+ client.captureException(err, {
187
+ site: "checkout",
188
+ attributes: { step: "payment", itemCount: cart.length },
189
+ });
190
+ }
191
+ ```
192
+
193
+ **If you use an error boundary, report from it explicitly.** React hands a
194
+ render error to the nearest boundary instead of letting it reach the window,
195
+ so auto-capture never sees the one class of error that takes your whole UI
196
+ down:
197
+
198
+ ```typescript
199
+ componentDidCatch(error, info) {
200
+ client.captureException(error, {
201
+ site: "ErrorBoundary",
202
+ attributes: { componentStack: info.componentStack ?? "" },
203
+ });
204
+ }
205
+ ```
206
+
207
+ Every report is a **single-entry** batch: `message` (from `err.message`,
208
+ falling back to `String(err)`, never sent empty — `"(no message)"` is used
209
+ instead), `kind` (from `err.name`), `stack` (parsed from `err.stack` when
210
+ it's a string — V8 and Firefox stack-trace shapes are both recognized, via
211
+ the exported `parseStack`), the client's current flag map, and the same
212
+ reserved context fields `init()`'s bootstrap sends (`contextKey`,
213
+ `application`, `release`, `sessionId` — whichever are set on the current
214
+ context).
215
+
216
+ `application`/`release` matter here beyond bookkeeping: the
217
+ server hashes them into the error fingerprint and release-tracking, so
218
+ omitting them would silently merge every browser app's errors into one
219
+ fingerprint namespace and break the admin issues API's `release=` filter.
220
+
221
+ **Every field is sanitized/truncated client-side** to the server's
222
+ validation caps before sending, because the server rejects the *whole*
223
+ batch if any single field violates them (an empty flag map or `attrs` map
224
+ is simply omitted rather than sent empty) — this matters most for
225
+ auto-capture, which forwards whatever an uncaught exception happened to
226
+ contain.
227
+
228
+ Sending is fire-and-forget: a failed POST never throws, it emits
229
+ `on("error")` like the SSE stream's own failures do.
230
+
231
+ `captureException` after `close()` is a no-op (no fetch), matching `identify()`'s
232
+ documented behavior.
233
+
234
+ ## Session replay
235
+
236
+ Recording is **opt-in**. Importing this package records nothing.
237
+
238
+ One call starts a recorder, wires it to this client's errors, and uploads
239
+ windows under the same session id your errors already carry:
240
+
241
+ ```typescript
242
+ const recorder = await client.startReplay();
243
+
244
+ // Consent withdrawn: stops recording, discards the buffer, and deletes
245
+ // anything already persisted locally.
246
+ client.detachReplay();
247
+ recorder.stop();
248
+ ```
249
+
250
+ It is **async** because it loads the recorder on demand — that is what keeps
251
+ `rrweb` out of your bundle until you actually record.
252
+
253
+ **How it records.** The recorder runs as a *dashcam*: it is always capturing
254
+ into a bounded local buffer and sends nothing until something goes wrong. A
255
+ trigger — anything reaching `captureException`, including `window.onerror` and
256
+ unhandled promise rejections — flushes a window covering `preRollMs` before
257
+ the error and `postRollMs` after it. A burst of errors from one broken render
258
+ produces **one** window, not fifty: triggers inside an existing post-roll
259
+ extend that window.
260
+
261
+ **When to call it.** Call `startRecording()` after the document is parsed if
262
+ you can. rrweb defers its first snapshot while `document.readyState` is
263
+ `"loading"`; the recorder pins `recordAfter: "DOMContentLoaded"` rather than
264
+ rrweb's `"load"` default, which shrinks that gap from "until every subresource
265
+ has loaded" to "until the document is parsed" — but **it does not close it**.
266
+ A recorder started from a `<head>` script captures nothing (and `stats()`
267
+ reports `snapshots: 0`) until `DOMContentLoaded` fires.
268
+
269
+ **What it captures.** DOM structure and mutations, clicks, focus, input events
270
+ and scroll (via [rrweb](https://www.rrweb.io/)), plus one Pharos-specific
271
+ addition: an **activation mode** (`pointer`, `keyboard` or `programmatic`)
272
+ emitted as an rrweb custom event tagged `pharos-activation`.
273
+
274
+ **What it never captures: key identity.** The keyboard listener records a
275
+ single enum — *that* the last input was a key — and never reads, stores or
276
+ transmits which key was pressed. A `keydown` listener sees passwords, and this
277
+ one cannot leak one.
278
+
279
+ **Privacy.** Every node is routed through this package's privacy policy
280
+ (`decide`, `masksValue`, `maskText`, `scrubAttribute`); the recorder
281
+ re-implements no rule of its own. **Rendered text is recorded by default** and
282
+ markers hide (`pharos-exclude`) or obfuscate (`pharos-mask`) it — a deliberate
283
+ posture, not an omission. See
284
+ [the Pharos README](../../README.md#session-replay-privacy-contract) for what
285
+ is and is not masked — including what it does **not** catch.
286
+
287
+ **Retention is not yours to choose.** Stored windows expire under a
288
+ per-application **TTL**, and each application also has a **session cap**: when
289
+ it is exceeded the oldest sessions are deleted along with their windows and
290
+ their stored bytes. Both are per-application configuration delivered over
291
+ `/api/v1/client/bootstrap`; a value in an upload body is read by nothing. The
292
+ TTL is the user-facing promise, and **the cap is what protects the disk** —
293
+ one busy application would otherwise consume the whole retention window.
294
+
295
+ Wiring your own transport (`startRecording`, `startReplayUpload`), the wire
296
+ format, the local IndexedDB rest and its 24-hour expiry, what
297
+ `droppedWindows` counts, and the degradation ladder are all in
298
+ `TECHNICAL-DETAILS.md`, shipped in this package.
299
+
300
+ ## API at a glance
301
+
302
+ Full reference — every export, every option — is in `TECHNICAL-DETAILS.md`.
303
+
304
+ | | |
305
+ |---|---|
306
+ | `PharosBrowserClient.init(config)` | bootstrap and open the live stream |
307
+ | `flag(key, default)` | synchronous lookup; no network call, no evaluation |
308
+ | `on("change" \| "error", cb)` | subscribe; returns an unsubscribe function |
309
+ | `identify(context)` | re-bootstrap as a new context (a full round trip — call it on login, not per render) |
310
+ | `captureException(err, opts?)` | report a caught error; never throws |
311
+ | `startReplay(options?)` | start a recorder wired to this client, and attach it |
312
+ | `attachReplay` / `detachReplay` | route this client's errors into a recorder you started yourself |
313
+ | `close()` | stop the stream and unregister the error listeners |
314
+
315
+ The privacy policy (`decide`, `maskText`, `scrubAttribute`,
316
+ `collectRecordedStrings`, the marker constants) is exported from the root so
317
+ you can verify what a recorder would send before turning one on.
318
+
319
+ ## Version history
320
+
321
+ - `0.6.0` — **first npm release, as `@loxel.dev/pharos-browser`.** Ships built
322
+ output with an `exports` map, so it resolves as an ordinary package.
323
+
324
+ **Breaking:** the recorder moved to the `@loxel.dev/pharos-browser/replay`
325
+ subpath (`startRecording`, `startReplayUpload`, `resolveSessionId`,
326
+ `SESSION_STORAGE_KEY`, `SESSION_DROPS_KEY`), and `client.startReplay()` now
327
+ returns a promise. Everything else is unchanged on the root.
328
+
329
+ Why: `rrweb` was reachable from the root entry, so it landed in every
330
+ bundle whether or not anything recorded — **+204 kB raw / +63 kB gzip** on a
331
+ real application with replay switched off. It is now loaded on demand,
332
+ costing **8.5 kB raw / 2.9 kB gzip**.
333
+
334
+ - `0.5.2` — stops shipping most checkpoint segments twice. A segment flushed
335
+ by the unload path while still open was shipped again when it closed,
336
+ roughly doubling ingest for always-on recording. No API change.
337
+
338
+ - `0.5.1` — replay-upload correctness. `droppedWindows` is now cumulative
339
+ across page loads. A window persisted by a tab switch is uploaded when the
340
+ tab becomes visible again rather than waiting for a full page load. Adds
341
+ `startReplay()`, and a warning when replay and errors would be reported
342
+ under different session ids.
343
+
344
+ - `0.5.0` — session-replay upload: `startReplayUpload()`, gzip + JSON envelope
345
+ to `POST /api/v1/client/replay`, IndexedDB rest for windows triggered during
346
+ unload, a durable per-tab session id, and `droppedWindows`.
347
+
348
+ - `0.4.0` — session-replay recorder: `startRecording()` with a pluggable sink,
349
+ a checkpoint-segmented ring buffer, activation-mode capture that never reads
350
+ key identity, error-driven windows, and self-degradation under load.
351
+
352
+ - `0.3.0` — session-replay privacy contract: `pharos-exclude` / `pharos-mask`
353
+ markers, field-sensitivity detection, attribute/URL/style scrubbing, and
354
+ `collectRecordedStrings` for verifying what a recorder would send.
355
+
356
+ - `0.2.0` — error reporting: `captureException`, auto-capture of uncaught
357
+ errors and unhandled rejections, and `parseStack`.
358
+
359
+ - `0.1.0` — initial release: bootstrap + SSE stream, `flag` / `on` /
360
+ `identify` / `close`.