@loxel.dev/pharos-browser 0.6.0 → 0.8.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 CHANGED
@@ -127,8 +127,10 @@ targeting rules — send nothing if no rule reads it.
127
127
 
128
128
  ```typescript
129
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.
130
+ // already here. Your default is returned until the bootstrap lands, for any
131
+ // key Pharos does not know, and for a value whose type disagrees with your
132
+ // default — a flag sent as the string "false" is not a boolean, and reading
133
+ // it through a boolean gate would otherwise turn the gate ON.
132
134
  if (client.flag("new-nav", false)) {
133
135
  renderNewNav();
134
136
  }
@@ -154,6 +156,13 @@ await client.identify({
154
156
  });
155
157
  ```
156
158
 
159
+ The new user's flags arrive with it, so `identify()` fires `change` the same
160
+ way a server push does — a component that mounted while the user was
161
+ anonymous re-renders against the identified bucket without the app arranging
162
+ anything. If the re-bootstrap fails it still rejects (catch it), and the
163
+ client schedules a reconnect rather than leaving live updates dead until a
164
+ page reload.
165
+
157
166
  ### Shut down
158
167
 
159
168
  ```typescript
@@ -318,6 +327,124 @@ you can verify what a recorder would send before turning one on.
318
327
 
319
328
  ## Version history
320
329
 
330
+ - `0.8.0` — **`identify()` tells you the flags changed, and a failed one no
331
+ longer kills live updates** (#788).
332
+
333
+ `identify()` swaps the whole flags map and emitted nothing, so a component
334
+ that rendered against the anonymous bootstrap kept that value for the life
335
+ of its mount while the server evaluated the real user's bucket on every
336
+ request. It now emits `change` with the new payload. If its bootstrap
337
+ rejects it still rejects — an app awaiting it on login has to see that —
338
+ but it now also schedules a reconnect, where before the stream was left
339
+ dead with nothing pending and nothing reported. Reconnects back off
340
+ exponentially (1 s doubling to a 30 s ceiling, reset by a delivered `flags`
341
+ push) instead of re-bootstrapping every second for the length of an outage.
342
+
343
+ `flag(key, default)` now **returns your default when the stored value's
344
+ type disagrees with it**. It was a cast (`as T`), so a flag delivered as the
345
+ string `"false"` read as ON through a boolean gate. `null`/`undefined`
346
+ defaults opt out of the check, since `typeof` describes the absence of a
347
+ value for both.
348
+
349
+ **The replay sensitive-field word list is no longer English-only** (#823).
350
+ It now carries the common Spanish, French, German, Portuguese, Japanese and
351
+ Chinese spellings of the same fields, and normalization folds accents
352
+ instead of deleting them (`contraseña` → `contrasena`, `Straße` →
353
+ `strasse`) — without which every accented entry would have missed by a
354
+ character. A localised app previously got essentially nothing from this
355
+ layer. Annotating `autocomplete` is still the recommended integration step:
356
+ it is language-independent and is checked first.
357
+
358
+ **Why MINOR:** `change` now fires on a path where it did not, a
359
+ type-mismatched flag stops being returned, and a localised app's fields are
360
+ masked where they were recorded — so a pinned `collectRecordedStrings`
361
+ snapshot will differ. Every move is in the safe direction, but a consumer
362
+ sees different behaviour either way.
363
+
364
+ - `0.7.0` — **a marker on a shadow host now protects its shadow root** (#821),
365
+ and the verification helper sees what the recorder sees (#822).
366
+
367
+ `hasMarkerAncestor` walked `parentElement`, which is `null` at a shadow
368
+ boundary, so `pharos-mask`/`pharos-exclude` on a web component's host
369
+ protected nothing inside it — while rrweb recorded that content regardless.
370
+ Established empirically before the fix: rrweb captures shadow-root text
371
+ unconditionally (no recorder option gates it), and `maskTextHook` *is*
372
+ invoked for those nodes, so the decision function was the sole failure
373
+ point. The walk now re-roots through `getRootNode()`/`.host`, bounded
374
+ against a malformed tree.
375
+
376
+ `collectRecordedStrings` did not traverse shadow DOM either. That was not a
377
+ compensating margin — **the auditor's blind spot lined up exactly with the
378
+ recorder's leak surface**, so an app could run the helper, get a clean
379
+ report, and be shipping shadow-root content. It now traverses, and
380
+ `pathOf()` renders a boundary crossing as `>>>` rather than truncating the
381
+ path to `''` (which was indistinguishable from a root-level row).
382
+
383
+ **Why MINOR, not PATCH:** two consumer-visible outputs change. Content
384
+ inside a marked shadow host is now masked where it previously shipped in
385
+ the clear, and `collectRecordedStrings` reports strings it previously
386
+ omitted — so a pinned audit snapshot will differ. Both changes are in the
387
+ safe direction, but a consumer sees different output either way.
388
+
389
+ `STRUCTURAL_ATTRS` is unchanged in membership and is now derived from a
390
+ justification map: each entry declares a `kind` — `closed-enumeration`
391
+ (which must name its finite vocabulary) or `cost-tradeoff` (which must
392
+ carry `meetsImpossibilityBar: false`). `class` is recorded honestly as the
393
+ second kind. The gate cannot verify a claim's truth; that stays human
394
+ review.
395
+
396
+ **The first round of that fix only reached the FULL SNAPSHOT.** rrweb
397
+ decides whether to consult `maskTextFn` in `needMaskingText`, which returns
398
+ "nothing to mask" for any node that is not an element and has no parent
399
+ *element* — and a text node whose parent is a `ShadowRoot` is exactly that.
400
+ So on the **incremental** streams (`characterData`, `childList` adds) the
401
+ decision function was never called at all and a **marked** host still
402
+ shipped `shadowRoot.textContent = user.name` in the clear once recording had
403
+ started. An element-wrapped `<p>` inside the same shadow root did not
404
+ reproduce it (rrweb takes its element branch there), which is how it
405
+ shipped. The recorder now post-processes both streams through the same
406
+ policy, and applies it over rrweb's answer in the snapshot too — in the
407
+ masking direction only, never unmasking.
408
+
409
+ **`pharos-exclude` across a shadow boundary shipped attributes.** rrweb's
410
+ `isBlocked` uses `closest()`, which does not cross a boundary, so an add
411
+ into an excluded host's shadow root was not dropped — and the scrub pass
412
+ judges a serialized node through a detached surrogate that by construction
413
+ has no host and no ancestry. Measured: `<p title="SECRET">TEXT</p>` appended
414
+ into an excluded host's shadow root shipped its `title` verbatim while its
415
+ text was masked. Such adds are now dropped, judged against the **live**
416
+ parent the mutation names by id.
417
+
418
+ **Snapshot noise to expect on your first re-run:** shadow roots almost
419
+ always contain a `<style>`, and `collectRecordedStrings` now reaches it and
420
+ reports its CSS text as an ordinary text row. That is not a leak — author
421
+ CSS is not user content — but a pinned audit snapshot will grow by more than
422
+ the app's own strings.
423
+
424
+ **Still not covered:**
425
+ - **Closed shadow roots**, which rrweb cannot reach either (`n.shadowRoot`
426
+ is `null`), so neither the recorder nor the helper sees them.
427
+ - **The full snapshot's own answer for a bare shadow text node is rrweb's,
428
+ not the policy's**, and it flips on the host's shape:
429
+ `needMaskingText` returns `false` for an element with no child nodes, so
430
+ whether a shadow subtree inherits masking depends on whether the host
431
+ happens to have light-DOM children. The policy is now applied over the
432
+ top, but only ever to mask MORE — so an **unmarked** host's bare shadow
433
+ text can be masked in the snapshot while `collectRecordedStrings` reports
434
+ it. The report is the truthful one: the recorder ships that string
435
+ verbatim on the incremental stream.
436
+ - **An excluded host's shadow subtree can still leave structural traces.**
437
+ Content is dropped, but rrweb's mirror holds nodes this SDK removed from
438
+ the `adds` stream, so a later `removes` entry can carry their ids. Ids and
439
+ tree shape, never text or attributes.
440
+ - Light-DOM content projected through a native `<slot>` is **no longer**
441
+ on this list: it was measured and is covered. A slotted node is an
442
+ ordinary light-DOM child of the host — rrweb serializes it under the host
443
+ and the helper reaches it via `el.children` — so the two agree with no
444
+ slot-specific handling, and a marker on the host governs it through
445
+ ordinary light-DOM ancestry. There is a test for it so it does not have to
446
+ be re-derived.
447
+
321
448
  - `0.6.0` — **first npm release, as `@loxel.dev/pharos-browser`.** Ships built
322
449
  output with an `exports` map, so it resolves as an ordinary package.
323
450
 
@@ -185,14 +185,23 @@ documented in full in
185
185
  - `flag<T>(key, defaultValue): T` — synchronous map lookup, no network call,
186
186
  no evaluation. Returns `defaultValue` for any key not present in the
187
187
  current flags map (unknown key, or a `server_side`-only flag the bootstrap
188
- endpoint never sent).
188
+ endpoint never sent), and for a stored value whose `typeof` disagrees with
189
+ `defaultValue`'s — the string `"false"` is not a boolean and must not read
190
+ as one. A `null`/`undefined` default opts out of that check, because
191
+ `typeof` describes the absence of a value for both and checking against it
192
+ would return the default unconditionally.
189
193
  - `on(event, cb): () => void` — subscribe to `"change"` (a new flags payload
190
194
  was applied — fires exactly once per accepted server push) or `"error"`
191
195
  (stream/bootstrap failure). Returns an unsubscribe function.
192
196
  - `identify(context): Promise<void>` — re-bootstraps as a new context and
193
197
  reopens the stream against the fresh `streamToken`. **This is a full HTTP
194
198
  round trip, not a local re-key** — call it on meaningful identity changes
195
- (login, plan change), not per-render.
199
+ (login, plan change), not per-render. It **fires `"change"`** with the new
200
+ `{version, flags}` once the stream is back up: it replaced the flags map,
201
+ and a consumer that only watched `"change"` would otherwise render the
202
+ previous context's values for the life of its mount. A rejected bootstrap
203
+ **schedules a reconnect** as well as rejecting, so a failure here costs the
204
+ call, not the session's live updates.
196
205
  - `captureException(err, opts?): void` — reports one exception; see
197
206
  "Error reporting" above. `opts` is `{site?: string, attributes?: Record<string, unknown>}`.
198
207
  Never throws; a failed POST is reported via `on("error")`. A no-op after
@@ -295,10 +304,19 @@ own. The one case it cannot recover from is an **expired stream session**
295
304
  (the server holds a 10-minute token TTL, refreshed only at connect): the
296
305
  browser sees repeated errors and keeps retrying the same dead token forever.
297
306
  When `EventSource` settles into its terminal `CLOSED` state, this client
298
- re-bootstraps once (short fixed backoff) to mint a fresh token and reconnect.
299
- It does not attempt a queue or exponential ramp if the server is down, the
300
- next successful `identify()`/reconnect will resync from scratch, and `flag()`
301
- keeps serving the last-known values in the meantime.
307
+ re-bootstraps to mint a fresh token and reconnect. The same schedule covers a
308
+ rejected `identify()`, which is otherwise the one way to end up with no
309
+ stream and no `EventSource` left to report an error.
310
+
311
+ Retries **back off exponentially**: 1 s, doubling to a 30 s ceiling. It is a
312
+ delay cap rather than an attempt cap — giving up entirely would leave a tab
313
+ permanently stale after an outage it survived — and the ramp is reset by a
314
+ **delivered `flags` push**, not by a successful bootstrap, so a stream that
315
+ opens and dies before sending anything keeps backing off instead of flapping
316
+ at the floor delay. Against a Pharos that is present-but-down this is the
317
+ difference between one request per second per open tab and two per minute.
318
+ There is still no queue and no jitter; `flag()` keeps serving the last-known
319
+ values throughout, and the next successful reconnect resyncs from scratch.
302
320
 
303
321
  Only payloads with `version >= current` are applied; stale (older-version)
304
322
  pushes are ignored. An equal-version push (e.g. the replay a fresh stream
@@ -1,3 +1,11 @@
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
+
1
9
  // src/privacy/mask.ts
2
10
  var MASK_TOKEN = "••••";
3
11
  function maskText(_text) {
@@ -7,12 +15,23 @@ function maskText(_text) {
7
15
  // src/privacy/markers.ts
8
16
  var EXCLUDE_CLASS = "pharos-exclude";
9
17
  var MASK_CLASS = "pharos-mask";
18
+ var MAX_HOPS = 1000;
10
19
  function hasMarkerAncestor(el, cls) {
11
20
  let node = el;
21
+ let hops = 0;
12
22
  while (node) {
13
23
  if (node.classList?.contains(cls))
14
24
  return true;
15
- node = node.parentElement;
25
+ const parentEl = node.parentElement;
26
+ if (parentEl) {
27
+ node = parentEl;
28
+ } else {
29
+ const root = node.getRootNode();
30
+ node = typeof ShadowRoot !== "undefined" && root instanceof ShadowRoot ? root.host : null;
31
+ }
32
+ hops += 1;
33
+ if (hops > MAX_HOPS)
34
+ return true;
16
35
  }
17
36
  return false;
18
37
  }
@@ -89,11 +108,101 @@ var SENSITIVE_WORDS = [
89
108
  "cvc",
90
109
  "iban",
91
110
  "passport",
92
- "driverslicense"
111
+ "driverslicense",
112
+ "contrasena",
113
+ "contrasenya",
114
+ "usuario",
115
+ "secreto",
116
+ "correo",
117
+ "movil",
118
+ "nombre",
119
+ "apellido",
120
+ "direccion",
121
+ "codigopostal",
122
+ "nacimiento",
123
+ "cumpleanos",
124
+ "dni",
125
+ "seguridadsocial",
126
+ "tarjeta",
127
+ "motdepasse",
128
+ "utilisateur",
129
+ "identifiant",
130
+ "courriel",
131
+ "prenom",
132
+ "nomdefamille",
133
+ "adresse",
134
+ "codepostal",
135
+ "naissance",
136
+ "anniversaire",
137
+ "securitesociale",
138
+ "carteidentite",
139
+ "permisconduire",
140
+ "passwort",
141
+ "kennwort",
142
+ "benutzer",
143
+ "benutzername",
144
+ "vorname",
145
+ "nachname",
146
+ "familienname",
147
+ "anschrift",
148
+ "strasse",
149
+ "postleitzahl",
150
+ "geburt",
151
+ "kreditkarte",
152
+ "kontonummer",
153
+ "steuernummer",
154
+ "sozialversicherung",
155
+ "ausweis",
156
+ "senha",
157
+ "palavrapasse",
158
+ "correio",
159
+ "sobrenome",
160
+ "nomecompleto",
161
+ "endereco",
162
+ "nascimento",
163
+ "cartao",
164
+ "cpf",
165
+ "cnpj",
166
+ "パスワード",
167
+ "メール",
168
+ "電話",
169
+ "氏名",
170
+ "名前",
171
+ "住所",
172
+ "生年月日",
173
+ "誕生日",
174
+ "郵便番号",
175
+ "クレジットカード",
176
+ "マイナンバー",
177
+ "密码",
178
+ "密碼",
179
+ "口令",
180
+ "邮箱",
181
+ "郵箱",
182
+ "邮件",
183
+ "郵件",
184
+ "电话",
185
+ "手机",
186
+ "手機",
187
+ "姓名",
188
+ "地址",
189
+ "生日",
190
+ "出生日期",
191
+ "邮编",
192
+ "身份证",
193
+ "身份證",
194
+ "信用卡",
195
+ "银行卡"
93
196
  ];
94
197
  var NAME_ATTRS = ["name", "id", "aria-label"];
95
- function normalize(value) {
96
- return value.toLowerCase().replace(/[^a-z0-9]/g, "");
198
+ function foldDiacritics(value) {
199
+ return value.toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, "").normalize("NFC").replace(/ß/g, "ss");
200
+ }
201
+ function normalForms(value) {
202
+ const folded = foldDiacritics(value);
203
+ const ascii = folded.replace(/[^a-z0-9]/g, "");
204
+ const script = folded.replace(/[^\p{L}\p{N}]/gu, "");
205
+ return ascii === script ? [ascii] : [ascii, script];
97
206
  }
98
207
  function isSensitiveField(el) {
99
208
  if (el.tagName.toLowerCase() === "input") {
@@ -112,9 +221,10 @@ function isSensitiveField(el) {
112
221
  const raw = el.getAttribute(attr);
113
222
  if (!raw)
114
223
  continue;
115
- const normalized = normalize(raw);
116
- if (SENSITIVE_WORDS.some((word) => normalized.includes(word)))
117
- return true;
224
+ for (const normalized of normalForms(raw)) {
225
+ if (SENSITIVE_WORDS.some((word) => normalized.includes(word)))
226
+ return true;
227
+ }
118
228
  }
119
229
  return false;
120
230
  }
@@ -841,4 +951,4 @@ async function clearPersisted(indexedDB = defaultFactory()) {
841
951
  }
842
952
  }
843
953
 
844
- export { MASK_TOKEN, maskText, EXCLUDE_CLASS, MASK_CLASS, shouldExclude, shouldMaskByMarker, isSensitiveField, scrubUrl, scrubAttribute, decide, masksValue, MAX_BUFFER_BYTES, RECORDER_DEFAULTS, resolveConfig, ACTIVATION_EVENT_TAG, attachInteractions, EVENT_TYPE_FULL_SNAPSHOT, EVENT_TYPE_META, beginsWithSnapshot, ReplayBuffer, createDropLedger, MAX_WINDOW_BYTES, encodeWindow, randomSessionId, postEnvelope, envelopeMetaFor, encodeForUpload, createUploadSink, LOCAL_TTL_MS, quotaDroppedWindows, resetQuotaDroppedWindows, isQuotaExceeded, persistWindow, drainPersisted, clearPersisted };
954
+ export { __require, MASK_TOKEN, maskText, EXCLUDE_CLASS, MASK_CLASS, shouldExclude, shouldMaskByMarker, isSensitiveField, scrubUrl, scrubAttribute, decide, masksValue, MAX_BUFFER_BYTES, RECORDER_DEFAULTS, resolveConfig, ACTIVATION_EVENT_TAG, attachInteractions, EVENT_TYPE_FULL_SNAPSHOT, EVENT_TYPE_META, beginsWithSnapshot, ReplayBuffer, createDropLedger, MAX_WINDOW_BYTES, encodeWindow, randomSessionId, postEnvelope, envelopeMetaFor, encodeForUpload, createUploadSink, LOCAL_TTL_MS, quotaDroppedWindows, resetQuotaDroppedWindows, isQuotaExceeded, persistWindow, drainPersisted, clearPersisted };
@@ -26,8 +26,7 @@ import {
26
26
  resolveConfig,
27
27
  scrubAttribute,
28
28
  scrubUrl
29
- } from "./index-bc4bw3ba.js";
30
- import"./index-c3taa3cg.js";
29
+ } from "./index-temc0wzg.js";
31
30
 
32
31
  // src/replay/recorder.ts
33
32
  import { record } from "rrweb";
@@ -146,8 +145,34 @@ function isStyleDiff(value) {
146
145
  return typeof value === "object" && value !== null && !Array.isArray(value);
147
146
  }
148
147
  var SERIALIZED_ELEMENT = 2;
148
+ var SERIALIZED_TEXT = 3;
149
149
  var SOURCE_MUTATION = 0;
150
150
  var SOURCE_INPUT = 5;
151
+ function shadowHostOf(node) {
152
+ if (node && typeof ShadowRoot !== "undefined" && node instanceof ShadowRoot)
153
+ return node.host;
154
+ return null;
155
+ }
156
+ function governingElementOfTextNode(node) {
157
+ if (!node)
158
+ return null;
159
+ return node.parentElement ?? shadowHostOf(node.parentNode);
160
+ }
161
+ function governingElementOfParent(parent) {
162
+ if (!parent)
163
+ return null;
164
+ if (parent.nodeType === 1)
165
+ return parent;
166
+ return shadowHostOf(parent);
167
+ }
168
+ function addLandsInExcludedSubtree(parent) {
169
+ if (!parent)
170
+ return true;
171
+ const el = governingElementOfParent(parent);
172
+ if (el)
173
+ return decide(el) === "exclude";
174
+ return false;
175
+ }
151
176
  function shouldMaskTextOf(el) {
152
177
  if (!el)
153
178
  return true;
@@ -271,14 +296,33 @@ function scrubSerializedNode(node, doc) {
271
296
  scrubSerializedNode(child, doc);
272
297
  }
273
298
  }
299
+ function maskUnjudgedShadowText(node, ctx) {
300
+ const children = node.childNodes;
301
+ if (!children)
302
+ return;
303
+ let host;
304
+ for (const child of children) {
305
+ if (child.type === SERIALIZED_TEXT && child.isShadow === true && typeof child.textContent === "string") {
306
+ if (host === undefined) {
307
+ const live = typeof node.id === "number" ? ctx.resolveNode(node.id) : null;
308
+ host = live && live.nodeType === 1 ? live : null;
309
+ }
310
+ if (shouldMaskTextOf(host))
311
+ child.textContent = maskText(child.textContent);
312
+ }
313
+ maskUnjudgedShadowText(child, ctx);
314
+ }
315
+ }
274
316
  function scrubEventAttributes(event, ctx) {
275
317
  const e = event;
276
318
  const data = e.data;
277
319
  if (!data)
278
320
  return event;
279
321
  const node = data.node;
280
- if (node && typeof node === "object")
322
+ if (node && typeof node === "object") {
281
323
  scrubSerializedNode(node, ctx.doc);
324
+ maskUnjudgedShadowText(node, ctx);
325
+ }
282
326
  if (data.source === SOURCE_INPUT && typeof data.text === "string") {
283
327
  const live = ctx.resolveNode(data.id);
284
328
  const el = live && live.nodeType === 1 ? live : null;
@@ -286,11 +330,34 @@ function scrubEventAttributes(event, ctx) {
286
330
  }
287
331
  if (data.source !== SOURCE_MUTATION)
288
332
  return event;
333
+ const texts = data.texts;
334
+ if (Array.isArray(texts)) {
335
+ for (const entry of texts) {
336
+ if (typeof entry?.value !== "string")
337
+ continue;
338
+ if (shouldMaskTextOf(governingElementOfTextNode(ctx.resolveNode(entry.id)))) {
339
+ entry.value = maskText(entry.value);
340
+ }
341
+ }
342
+ }
289
343
  const adds = data.adds;
290
344
  if (Array.isArray(adds)) {
291
- for (const add of adds) {
292
- if (add?.node)
293
- scrubSerializedNode(add.node, ctx.doc);
345
+ for (let i = adds.length - 1;i >= 0; i--) {
346
+ const add = adds[i];
347
+ if (!add?.node)
348
+ continue;
349
+ const parent = typeof add.parentId === "number" ? ctx.resolveNode(add.parentId) : null;
350
+ if (addLandsInExcludedSubtree(parent)) {
351
+ adds.splice(i, 1);
352
+ continue;
353
+ }
354
+ if (add.node.type === SERIALIZED_TEXT && typeof add.node.textContent === "string") {
355
+ if (shouldMaskTextOf(governingElementOfParent(parent))) {
356
+ add.node.textContent = maskText(add.node.textContent);
357
+ }
358
+ }
359
+ scrubSerializedNode(add.node, ctx.doc);
360
+ maskUnjudgedShadowText(add.node, ctx);
294
361
  }
295
362
  }
296
363
  const attributes = data.attributes;
@@ -918,9 +985,5 @@ function startReplayUpload(opts) {
918
985
  }
919
986
  };
920
987
  }
921
- export {
922
- SESSION_DROPS_KEY,
923
- SESSION_STORAGE_KEY,
924
- resolveSessionId,
925
- startReplayUpload
926
- };
988
+
989
+ export { startRecording, SESSION_STORAGE_KEY, resolveSessionId, SESSION_DROPS_KEY, startReplayUpload };
package/dist/index.d.ts CHANGED
@@ -62,6 +62,7 @@ export declare class PharosBrowserClient {
62
62
  private listeners;
63
63
  private closed;
64
64
  private reconnectTimer;
65
+ private reconnectAttempts;
65
66
  private errorsEnabled;
66
67
  private errorTarget;
67
68
  private replayHandle;
@@ -80,11 +81,22 @@ export declare class PharosBrowserClient {
80
81
  * request): the browser sees this as an error and keeps retrying the same
81
82
  * dead token forever. Heuristic: when the source has settled into CLOSED
82
83
  * (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 simpleno queue, no
85
- * exponential ramp matching the brief's "don't over-engineer" guidance.
84
+ * drop), re-bootstrap after a backoff to mint a fresh token, then reconnect
85
+ * the stream. Still no queue and no jitter the one thing it is not simple
86
+ * about is the ramp, for the reason on `scheduleReconnect`.
86
87
  */
87
88
  private maybeReconnect;
89
+ /**
90
+ * Arms the single pending reconnect, backing off exponentially from
91
+ * `RECONNECT_BACKOFF_MS` to `MAX_RECONNECT_BACKOFF_MS`.
92
+ *
93
+ * The ramp is not decoration: against a Pharos that is present-but-down
94
+ * every re-bootstrap fails and re-arms this timer, so a fixed delay is a
95
+ * request per second per open tab for the length of the outage — load
96
+ * arriving exactly when the server can least take it. `reconnectAttempts`
97
+ * is reset by a delivered `flags` push, not here.
98
+ */
99
+ private scheduleReconnect;
88
100
  private onWindowError;
89
101
  private onUnhandledRejection;
90
102
  private registerErrorListeners;
@@ -206,14 +218,41 @@ export declare class PharosBrowserClient {
206
218
  startReplay(options?: Omit<ReplayUploadOptions, "endpoint" | "appKey">): Promise<ReplayUploadHandle | null>;
207
219
  /** Stops routing errors into the recorder. Does not stop the recorder itself. */
208
220
  detachReplay(): void;
209
- /** Synchronous flag lookup; falls back to defaultValue for unknown keys. */
221
+ /**
222
+ * Synchronous flag lookup; falls back to defaultValue for an unknown key,
223
+ * and for a value of a different type than the default (issue #788).
224
+ *
225
+ * `as T` was a cast, not a check, so a flag whose value arrived as the
226
+ * STRING `"false"` — truthy — read as ON through a boolean gate, while the
227
+ * dashboard, the payload and the code all looked right. The default carries
228
+ * the type the caller is prepared to handle, so a value that disagrees with
229
+ * it is a misconfiguration the default is the safe answer to.
230
+ *
231
+ * `null`/`undefined` defaults opt out: `typeof` describes the ABSENCE of a
232
+ * value for both, so checking against them would turn `flag(key, null)`
233
+ * into "always null" and stop returning the server's answer at all.
234
+ */
210
235
  flag<T>(key: string, defaultValue: T): T;
211
236
  /** Subscribes to "change" (a new flags payload was applied) or "error". Returns an unsubscribe function. */
212
237
  on(event: PharosEvent, cb: Listener): () => void;
213
238
  private emit;
214
239
  /**
215
240
  * Re-identifies as a new context: closes the current stream, re-bootstraps
216
- * (a full round trip — flags plus a fresh streamToken), and reconnects.
241
+ * (a full round trip — flags plus a fresh streamToken), reconnects, and
242
+ * emits `change`.
243
+ *
244
+ * THE `change` IS NOT OPTIONAL (issue #788). Replacing the flags map is the
245
+ * whole point of this call, and the SSE handler is the only other thing that
246
+ * emits — so without this a component that rendered against the anonymous
247
+ * bootstrap kept that value for the life of its mount, while the server
248
+ * evaluated the real user's bucket on every request. That is not a corner
249
+ * case: it is every authenticated page load that mounts before login.
250
+ *
251
+ * REJECTS, AND STILL SCHEDULES A RECONNECT. An app awaiting this on login
252
+ * has to be able to see it fail, so the error propagates; but it was also
253
+ * the only way to lose the stream permanently — `eventSource` left `null`
254
+ * with nothing pending, since `maybeReconnect` runs only from an
255
+ * `es.onerror` and there is no `es` any more.
217
256
  */
218
257
  identify(context: PharosContext): Promise<void>;
219
258
  /** Closes the stream, stops any pending reconnect, and unregisters auto-capture. Terminal — construct a new client to resume. */
package/dist/index.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  MAX_BUFFER_BYTES,
8
8
  MAX_WINDOW_BYTES,
9
9
  RECORDER_DEFAULTS,
10
+ __require,
10
11
  beginsWithSnapshot,
11
12
  clearPersisted,
12
13
  createDropLedger,
@@ -27,10 +28,7 @@ import {
27
28
  scrubUrl,
28
29
  shouldExclude,
29
30
  shouldMaskByMarker
30
- } from "./index-bc4bw3ba.js";
31
- import {
32
- __require
33
- } from "./index-c3taa3cg.js";
31
+ } from "./index-temc0wzg.js";
34
32
 
35
33
  // src/stack.ts
36
34
  var MAX_FRAMES = 50;
@@ -71,17 +69,74 @@ function parseStack(stack) {
71
69
  return frames;
72
70
  }
73
71
  // src/privacy/collect.ts
74
- var STRUCTURAL_ATTRS = new Set(["class", "type", "contenteditable"]);
72
+ var STRUCTURAL_ATTR_JUSTIFICATIONS = {
73
+ type: {
74
+ kind: "closed-enumeration",
75
+ claim: "The `type` attribute's value space is a fixed, closed enumeration defined by the HTML Living Standard, scoped per element. No element accepts an application-supplied free-text value there — an out-of-vocabulary `type` is simply not a recognized state.",
76
+ vocabulary: [
77
+ "text",
78
+ "button",
79
+ "checkbox",
80
+ "color",
81
+ "date",
82
+ "datetime-local",
83
+ "email",
84
+ "file",
85
+ "hidden",
86
+ "image",
87
+ "month",
88
+ "number",
89
+ "password",
90
+ "radio",
91
+ "range",
92
+ "reset",
93
+ "search",
94
+ "submit",
95
+ "tel",
96
+ "time",
97
+ "url",
98
+ "week",
99
+ "1",
100
+ "a",
101
+ "A",
102
+ "i",
103
+ "I"
104
+ ]
105
+ },
106
+ contenteditable: {
107
+ kind: "closed-enumeration",
108
+ claim: "The `contenteditable` attribute's value space is a closed enumeration defined by the HTML Living Standard. Any other string is not a recognized keyword state, so this attribute cannot carry free text.",
109
+ vocabulary: ["true", "false", "plaintext-only", "inherit", ""]
110
+ },
111
+ class: {
112
+ kind: "cost-tradeoff",
113
+ claim: "class has no closed vocabulary and CAN carry templated content in principle, so it does not clear the content-impossibility bar the enumeration entries clear. It is kept anyway as a stated cost/benefit call: every element has a class attribute, and treating it as content would drown every snapshot in noise.",
114
+ meetsImpossibilityBar: false
115
+ }
116
+ };
117
+ var STRUCTURAL_ATTRS = new Set(Object.keys(STRUCTURAL_ATTR_JUSTIFICATIONS));
118
+ var MAX_PATH_HOPS = 1000;
75
119
  function pathOf(el, root) {
76
120
  const parts = [];
77
121
  let node = el;
122
+ let hops = 0;
78
123
  while (node && node !== root) {
79
124
  const parent = node.parentElement;
80
- if (!parent)
125
+ if (parent) {
126
+ const index = Array.prototype.indexOf.call(parent.children, node) + 1;
127
+ parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`);
128
+ node = parent;
129
+ } else {
130
+ const shadowRoot = node.getRootNode();
131
+ if (!(typeof ShadowRoot !== "undefined" && shadowRoot instanceof ShadowRoot))
132
+ break;
133
+ const index = Array.prototype.indexOf.call(shadowRoot.children, node) + 1;
134
+ parts.unshift(">>>", `${node.tagName.toLowerCase()}:nth-child(${index})`);
135
+ node = shadowRoot.host;
136
+ }
137
+ hops += 1;
138
+ if (hops > MAX_PATH_HOPS)
81
139
  break;
82
- const index = Array.prototype.indexOf.call(parent.children, node) + 1;
83
- parts.unshift(`${node.tagName.toLowerCase()}:nth-child(${index})`);
84
- node = parent;
85
140
  }
86
141
  return parts.join(" > ");
87
142
  }
@@ -92,8 +147,8 @@ function collectRecordedStrings(root) {
92
147
  if (decision === "exclude")
93
148
  return;
94
149
  const path = pathOf(el, root);
150
+ const valueIsMasked = masksValue(el);
95
151
  if (decision === "record" || decision === "mask") {
96
- const valueIsMasked = masksValue(el);
97
152
  for (const attr of Array.from(el.attributes)) {
98
153
  const name = attr.name.toLowerCase();
99
154
  if (STRUCTURAL_ATTRS.has(name))
@@ -119,6 +174,22 @@ function collectRecordedStrings(root) {
119
174
  }
120
175
  for (const child of Array.from(el.children))
121
176
  visit(child);
177
+ if (el.shadowRoot) {
178
+ const shadowPath = path ? `${path} > >>>` : ">>>";
179
+ for (const child of Array.from(el.shadowRoot.childNodes)) {
180
+ if (child.nodeType === 1) {
181
+ visit(child);
182
+ continue;
183
+ }
184
+ if (child.nodeType !== 3)
185
+ continue;
186
+ if (decision !== "record" || valueIsMasked)
187
+ continue;
188
+ const text = (child.textContent ?? "").trim();
189
+ if (text)
190
+ out.push({ value: text, path: shadowPath, origin: "text" });
191
+ }
192
+ }
122
193
  };
123
194
  visit(root);
124
195
  return out;
@@ -203,6 +274,7 @@ function sanitizeAttrs(attrs) {
203
274
  return out;
204
275
  }
205
276
  var RECONNECT_BACKOFF_MS = 1000;
277
+ var MAX_RECONNECT_BACKOFF_MS = 30000;
206
278
  function buildBootstrapBody(context) {
207
279
  const { contextKey, application, release, sessionId, attributes } = context;
208
280
  const pharos = { contextKey };
@@ -230,6 +302,7 @@ class PharosBrowserClient {
230
302
  };
231
303
  closed = false;
232
304
  reconnectTimer = null;
305
+ reconnectAttempts = 0;
233
306
  errorsEnabled;
234
307
  errorTarget;
235
308
  replayHandle = null;
@@ -290,6 +363,7 @@ class PharosBrowserClient {
290
363
  }
291
364
  this.flags = payload.flags ?? {};
292
365
  this.version = payload.version;
366
+ this.reconnectAttempts = 0;
293
367
  this.emit("change", payload);
294
368
  } catch (err) {
295
369
  this.emit("error", err);
@@ -302,16 +376,21 @@ class PharosBrowserClient {
302
376
  this.eventSource = es;
303
377
  }
304
378
  maybeReconnect(es) {
305
- if (this.closed || this.reconnectTimer)
306
- return;
307
379
  if (es.readyState !== 2)
308
380
  return;
381
+ this.scheduleReconnect();
382
+ }
383
+ scheduleReconnect() {
384
+ if (this.closed || this.reconnectTimer)
385
+ return;
386
+ const delay = Math.min(RECONNECT_BACKOFF_MS * 2 ** this.reconnectAttempts, MAX_RECONNECT_BACKOFF_MS);
387
+ this.reconnectAttempts += 1;
309
388
  this.reconnectTimer = setTimeout(() => {
310
389
  this.reconnectTimer = null;
311
390
  if (this.closed)
312
391
  return;
313
392
  this.identify(this.context).catch((err) => this.emit("error", err));
314
- }, RECONNECT_BACKOFF_MS);
393
+ }, delay);
315
394
  }
316
395
  onWindowError = (event) => {
317
396
  const e = event;
@@ -400,7 +479,7 @@ class PharosBrowserClient {
400
479
  async startReplay(options = {}) {
401
480
  if (this.closed)
402
481
  return null;
403
- const { startReplayUpload } = await import("./wire-992wvzs1.js");
482
+ const { startReplayUpload } = await import("./wire-90whxzan.js");
404
483
  if (this.closed)
405
484
  return null;
406
485
  const handle = startReplayUpload({
@@ -423,10 +502,13 @@ class PharosBrowserClient {
423
502
  this.replayErrorListeners = null;
424
503
  }
425
504
  flag(key, defaultValue) {
426
- if (Object.prototype.hasOwnProperty.call(this.flags, key)) {
427
- return this.flags[key];
505
+ if (!Object.prototype.hasOwnProperty.call(this.flags, key))
506
+ return defaultValue;
507
+ const value = this.flags[key];
508
+ if (defaultValue !== null && defaultValue !== undefined && typeof value !== typeof defaultValue) {
509
+ return defaultValue;
428
510
  }
429
- return defaultValue;
511
+ return value;
430
512
  }
431
513
  on(event, cb) {
432
514
  this.listeners[event].add(cb);
@@ -447,9 +529,15 @@ class PharosBrowserClient {
447
529
  this.eventSource.close();
448
530
  this.eventSource = null;
449
531
  }
450
- await this.bootstrap();
532
+ try {
533
+ await this.bootstrap();
534
+ } catch (err) {
535
+ this.scheduleReconnect();
536
+ throw err;
537
+ }
451
538
  if (!this.closed) {
452
539
  this.connectStream();
540
+ this.emit("change", { version: this.version, flags: this.flags });
453
541
  }
454
542
  }
455
543
  close() {
@@ -471,34 +559,34 @@ class PharosBrowserClient {
471
559
  }
472
560
  }
473
561
  export {
474
- ACTIVATION_EVENT_TAG,
475
- EXCLUDE_CLASS,
476
- LOCAL_TTL_MS,
477
- MASK_CLASS,
478
- MASK_TOKEN,
479
- MAX_BUFFER_BYTES,
480
- MAX_WINDOW_BYTES,
481
- PharosBrowserClient,
482
- RECORDER_DEFAULTS,
483
- beginsWithSnapshot,
484
- clearPersisted,
485
- collectRecordedStrings,
486
- createDropLedger,
487
- createUploadSink,
488
- decide,
489
- drainPersisted,
490
- encodeWindow,
491
- envelopeMetaFor,
492
- isSensitiveField,
493
- maskText,
494
- masksValue,
495
- persistWindow,
496
- postEnvelope,
497
- quotaDroppedWindows,
498
- resetQuotaDroppedWindows,
499
- resolveConfig,
500
- scrubAttribute,
501
- scrubUrl,
562
+ shouldMaskByMarker,
502
563
  shouldExclude,
503
- shouldMaskByMarker
564
+ scrubUrl,
565
+ scrubAttribute,
566
+ resolveConfig,
567
+ resetQuotaDroppedWindows,
568
+ quotaDroppedWindows,
569
+ postEnvelope,
570
+ persistWindow,
571
+ masksValue,
572
+ maskText,
573
+ isSensitiveField,
574
+ envelopeMetaFor,
575
+ encodeWindow,
576
+ drainPersisted,
577
+ decide,
578
+ createUploadSink,
579
+ createDropLedger,
580
+ collectRecordedStrings,
581
+ clearPersisted,
582
+ beginsWithSnapshot,
583
+ RECORDER_DEFAULTS,
584
+ PharosBrowserClient,
585
+ MAX_WINDOW_BYTES,
586
+ MAX_BUFFER_BYTES,
587
+ MASK_TOKEN,
588
+ MASK_CLASS,
589
+ LOCAL_TTL_MS,
590
+ EXCLUDE_CLASS,
591
+ ACTIVATION_EVENT_TAG
504
592
  };
@@ -4,5 +4,16 @@ export interface RecordedString {
4
4
  origin: 'text' | 'value' | 'attribute';
5
5
  attribute?: string;
6
6
  }
7
+ type StructuralAttrJustification = {
8
+ readonly kind: 'closed-enumeration';
9
+ readonly claim: string;
10
+ readonly vocabulary: readonly string[];
11
+ } | {
12
+ readonly kind: 'cost-tradeoff';
13
+ readonly claim: string;
14
+ readonly meetsImpossibilityBar: false;
15
+ };
16
+ export declare const STRUCTURAL_ATTR_JUSTIFICATIONS: Readonly<Record<string, StructuralAttrJustification>>;
7
17
  export declare const STRUCTURAL_ATTRS: Set<string>;
8
18
  export declare function collectRecordedStrings(root: Element): RecordedString[];
19
+ export {};
@@ -1,8 +1,15 @@
1
- import"../index-c3taa3cg.js";
2
- export {
1
+ import {
3
2
  SESSION_DROPS_KEY,
4
3
  SESSION_STORAGE_KEY,
5
4
  resolveSessionId,
6
5
  startRecording,
7
6
  startReplayUpload
7
+ } from "../index-xs32mrd9.js";
8
+ import"../index-temc0wzg.js";
9
+ export {
10
+ startReplayUpload,
11
+ startRecording,
12
+ resolveSessionId,
13
+ SESSION_STORAGE_KEY,
14
+ SESSION_DROPS_KEY
8
15
  };
@@ -43,6 +43,8 @@ export interface SerializedNode {
43
43
  tagName?: string;
44
44
  attributes?: Record<string, unknown>;
45
45
  childNodes?: SerializedNode[];
46
+ /** rrweb-snapshot's text payload — present on `type === SERIALIZED_TEXT`. */
47
+ textContent?: string;
46
48
  id?: number;
47
49
  [key: string]: unknown;
48
50
  }
@@ -0,0 +1,13 @@
1
+ import {
2
+ SESSION_DROPS_KEY,
3
+ SESSION_STORAGE_KEY,
4
+ resolveSessionId,
5
+ startReplayUpload
6
+ } from "./index-xs32mrd9.js";
7
+ import"./index-temc0wzg.js";
8
+ export {
9
+ startReplayUpload,
10
+ resolveSessionId,
11
+ SESSION_STORAGE_KEY,
12
+ SESSION_DROPS_KEY
13
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@loxel.dev/pharos-browser",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Browser client for Pharos — server-evaluated feature flags, error capture, and session replay.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -45,7 +45,8 @@
45
45
  "access": "public"
46
46
  },
47
47
  "scripts": {
48
- "test": "bun test",
48
+ "typecheck": "tsc --noEmit -p tsconfig.json",
49
+ "test": "bun run typecheck && bun test",
49
50
  "build": "bun build src/index.ts src/replay/index.ts --outdir dist --root src --splitting --target browser --format esm --external rrweb && bun run build:types",
50
51
  "build:types": "tsc -p tsconfig.build.json",
51
52
  "prepublishOnly": "bun run build"
@@ -1,9 +0,0 @@
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 };