@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
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`.
|