@reticlehq/browser 2.10.0 → 2.11.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.
@@ -256,6 +256,7 @@ export function installNetwork(emit, opts = {}) {
256
256
  const contentType = res.headers.get('content-type');
257
257
  // The request is done; the BODY may not be. Watch it so settle cannot pass mid-stream.
258
258
  watchStreamedBody(emit, res, id, url, contentType, res.headers.get('content-length'));
259
+ reportedNetUrls.add(rawUrl);
259
260
  const emitRequest = (responseBodyFields) => {
260
261
  emit(EventType.NET_REQUEST, {
261
262
  id,
@@ -385,6 +386,7 @@ export function installNetwork(emit, opts = {}) {
385
386
  const cur = meta.get(this);
386
387
  if (cur === undefined)
387
388
  return;
389
+ reportedNetUrls.add(cur.rawUrl);
388
390
  const xhrContentType = this.getResponseHeader('content-type');
389
391
  let responseBodyFields = {};
390
392
  // responseText throws unless responseType is '' or 'text' — guard before reading.
@@ -523,7 +525,64 @@ export function installNetwork(emit, opts = {}) {
523
525
  };
524
526
  navProto.sendBeacon = patchedBeacon;
525
527
  }
528
+ // Document-initiated subresources (link/css/img/script/manifest): fetch and XHR patches never see
529
+ // these, so a `{net}` predicate over a favicon or manifest used to read `assertion_failed` — "your
530
+ // change is broken" — when the truthful answer is "not observable". A PerformanceObserver over
531
+ // `resource` entries reports them with no CDP. Status is the known gap: entries carry one only on
532
+ // newer Chromium, so the field is emitted ONLY when readable and the evaluation seam downgrades
533
+ // status assertions it cannot verify instead of guessing.
534
+ //
535
+ // Dedup: any URL the patched transports already reported (or later report) is skipped — resource
536
+ // timing also records fetch/XHR loads. The seen-set is filled eagerly here AND at every
537
+ // NET_REQUEST emit below, so both orders of arrival stay single-reported.
538
+ const reportedNetUrls = new Set();
539
+ let subresourceEvents = 0;
540
+ const SUBRESOURCE_CAP = 200;
541
+ let subresourceObserver;
542
+ if (typeof PerformanceObserver !== 'undefined') {
543
+ try {
544
+ const observer = new PerformanceObserver((list) => {
545
+ for (const raw of list.getEntries()) {
546
+ const entry = raw;
547
+ if (subresourceEvents >= SUBRESOURCE_CAP)
548
+ return;
549
+ const type = entry.initiatorType || 'other';
550
+ // Patched transports own their records; only document-initiated types are new here.
551
+ if ('fetch' === type || 'xmlhttprequest' === type)
552
+ continue;
553
+ const rawUrl = entry.name;
554
+ if (reportedNetUrls.has(rawUrl))
555
+ continue;
556
+ reportedNetUrls.add(rawUrl);
557
+ subresourceEvents += 1;
558
+ emit(EventType.NET_REQUEST, {
559
+ id: nextId(),
560
+ method: 'GET',
561
+ url: redactUrl(rawUrl),
562
+ durationMs: Math.round(entry.duration),
563
+ initiator: type,
564
+ ...(entry.transferSize > 0 ? { transferSize: entry.transferSize } : {}),
565
+ // responseStatus is Chromium-only; omitted entirely when unreadable so the wire never
566
+ // carries a guessed status. Its absence is what the server-side seam reads as unknown.
567
+ ...((entry.responseStatus ?? 0) > 0
568
+ ? {
569
+ status: entry.responseStatus,
570
+ ok: (entry.responseStatus ?? 0) >= 200 && (entry.responseStatus ?? 0) < 400,
571
+ }
572
+ : {}),
573
+ });
574
+ }
575
+ });
576
+ subresourceObserver = observer;
577
+ observer.observe({ type: 'resource', buffered: true });
578
+ // Disconnected in the disposer below.
579
+ }
580
+ catch {
581
+ // No observer support: document-initiated loads stay unobserved, stated rather than guessed.
582
+ }
583
+ }
526
584
  return () => {
585
+ subresourceObserver?.disconnect();
527
586
  // Restore each slot ONLY if it still holds OUR wrapper. Between connect() and disconnect() the app
528
587
  // (or Sentry/analytics/a router) may have wrapped fetch/XHR/EventSource/WebSocket/sendBeacon ON TOP
529
588
  // of ours; blindly writing the original back would silently uninstall their instrumentation — the
@@ -0,0 +1,10 @@
1
+ export interface SubresourceObservation {
2
+ url: string;
3
+ method: string;
4
+ status?: number;
5
+ ok?: boolean;
6
+ durationMs: number;
7
+ transferSize?: number;
8
+ initiatorType: string;
9
+ }
10
+ export declare function collectSubresources(seenUrls: ReadonlySet<string>, nowMs: number, sinceMs: number): SubresourceObservation[];
@@ -0,0 +1,63 @@
1
+ import { redactUrl } from './network-redact.js';
2
+ /**
3
+ * Document-initiated subresource observation.
4
+ *
5
+ * fetch/XHR patches never see requests the DOCUMENT makes on its own: `<link rel=icon>`,
6
+ * `<link rel=manifest>`, stylesheets, `<img src>`, `<script src>`. Those are exactly the loads a
7
+ * branding change touches, so a `{net}` predicate over them used to return `assertion_failed`
8
+ * ("your change is broken") when the truthful answer was "not observable" — and an agent trusting
9
+ * that verdict goes and "fixes" working code.
10
+ *
11
+ * `performance.getEntriesByType('resource')` reports these with no CDP and no patching: URL (name),
12
+ * initiator type (`link`, `css`, `img`, `script`, …), duration, transfer size. The known limitation
13
+ * is the status code — resource timing does not expose one directly (`responseStatus` exists only
14
+ * in newer Chromium). Entries carry `status` ONLY when it could be read; predicates asserting a
15
+ * status over unreadable data are handled at the evaluation seam, which downgrades rather than
16
+ * guesses.
17
+ *
18
+ * Deduplication matters: a fetch/XHR-initiated request ALSO lands in resource timing. Every entry
19
+ * is checked against the URLs the patched transports already reported this page, and only genuinely
20
+ * document-initiated loads survive.
21
+ */
22
+ /** Resource-timing initiator types that mean "the document itself asked", not a patched transport. */
23
+ const DOCUMENT_INITIATORS = new Set(['link', 'css', 'img', 'script', 'manifest', 'other']);
24
+ /** Cap per call — a pathological page must not blow the event budget. */
25
+ const MAX_SUBRESOURCE_EVENTS = 200;
26
+ export function collectSubresources(seenUrls, nowMs, sinceMs) {
27
+ const out = [];
28
+ try {
29
+ const entries = performance.getEntriesByType('resource');
30
+ for (const entry of entries) {
31
+ const initiatorType = entry.initiatorType || 'other';
32
+ // fetch/XHR already have richer records from their own patches.
33
+ if (!DOCUMENT_INITIATORS.has(initiatorType))
34
+ continue;
35
+ const raw = entry.name;
36
+ if (seenUrls.has(raw))
37
+ continue;
38
+ const startMs = nowMs - entry.duration;
39
+ if (startMs < sinceMs)
40
+ continue;
41
+ const obs = {
42
+ url: redactUrl(raw),
43
+ method: 'GET',
44
+ durationMs: Math.round(entry.duration),
45
+ initiatorType,
46
+ ...(entry.transferSize > 0 ? { transferSize: entry.transferSize } : {}),
47
+ };
48
+ // responseStatus is Chromium-only and may be 0 when unreadable. Present ONLY when it says
49
+ // something real, so the wire never carries a guessed status.
50
+ const rs = entry.responseStatus;
51
+ if ('number' === typeof rs && rs > 0) {
52
+ obs.status = rs;
53
+ obs.ok = rs >= 200 && rs < 400;
54
+ }
55
+ out.push(obs);
56
+ }
57
+ }
58
+ catch {
59
+ // No resource-timing support / security error: report nothing, honestly.
60
+ return [];
61
+ }
62
+ return out.slice(0, MAX_SUBRESOURCE_EVENTS);
63
+ }
@@ -223,6 +223,14 @@ export class ControlPanel {
223
223
  /** The full replayable-flow list from the last push; re-filtered per page on route change. */
224
224
  #flowItems = [];
225
225
  #workspaceTeardown;
226
+ /**
227
+ * One signal for every listener this controller registers.
228
+ *
229
+ * All eight are anonymous closures over `this`, so there was no reference to hand
230
+ * `removeEventListener` and teardown removed none of them — each kept the controller reachable
231
+ * for as long as its element lived, and a second mount stacked another set on top.
232
+ */
233
+ #listeners;
226
234
  #host;
227
235
  constructor(host) {
228
236
  this.#host = host;
@@ -232,6 +240,8 @@ export class ControlPanel {
232
240
  }
233
241
  /** Query control refs out of the mounted root and bind the DOM listeners, then paint active. */
234
242
  mount(root, glow) {
243
+ this.#listeners = new AbortController();
244
+ const { signal } = this.#listeners;
235
245
  this.#root = root;
236
246
  this.#glow = glow;
237
247
  this.#refs = queryControlRefs(root);
@@ -239,17 +249,17 @@ export class ControlPanel {
239
249
  if (pauseBtn !== undefined) {
240
250
  paintPauseBtn(pauseBtn, this.#state === SessionState.PAUSED);
241
251
  }
242
- this.#refs.pauseBtn?.addEventListener('click', () => this.#onPauseToggle());
243
- this.#refs.endBtn?.addEventListener('click', () => this.#onEnd());
244
- this.#refs.sendBtn?.addEventListener('click', () => this.#onSend());
252
+ this.#refs.pauseBtn?.addEventListener('click', () => this.#onPauseToggle(), { signal });
253
+ this.#refs.endBtn?.addEventListener('click', () => this.#onEnd(), { signal });
254
+ this.#refs.sendBtn?.addEventListener('click', () => this.#onSend(), { signal });
245
255
  this.#refs.input?.addEventListener('keydown', (e) => {
246
256
  // Enter sends; Shift+Enter inserts a newline (falls through to the textarea's default).
247
257
  if (e instanceof KeyboardEvent && 'Enter' === e.key && !e.shiftKey) {
248
258
  e.preventDefault();
249
259
  this.#onSend();
250
260
  }
251
- });
252
- this.#refs.input?.addEventListener('input', () => this.#autosize());
261
+ }, { signal });
262
+ this.#refs.input?.addEventListener('input', () => this.#autosize(), { signal });
253
263
  // Replay-a-flow: one ▶ click re-runs a saved flow (no agent). Delegated so it covers all chips.
254
264
  this.#refs.flows?.addEventListener('click', (e) => {
255
265
  const target = e.target;
@@ -259,9 +269,9 @@ export class ControlPanel {
259
269
  if (name !== null && name !== undefined && name.length > 0) {
260
270
  this.#host.emit(HumanControlKind.REPLAY, name);
261
271
  }
262
- });
263
- this.#refs.copyBtn?.addEventListener('click', () => this.#onCopy());
264
- this.#refs.exportBtn?.addEventListener('click', () => this.#onExport());
272
+ }, { signal });
273
+ this.#refs.copyBtn?.addEventListener('click', () => this.#onCopy(), { signal });
274
+ this.#refs.exportBtn?.addEventListener('click', () => this.#onExport(), { signal });
265
275
  this.#workspaceTeardown = mountWorkspaceSelector(root);
266
276
  this.setState(SessionState.ACTIVE);
267
277
  }
@@ -297,6 +307,9 @@ export class ControlPanel {
297
307
  }
298
308
  /** Clear any pending ended-fade timer (called from Presenter.destroy). */
299
309
  teardown() {
310
+ // All eight registrations, in one call that cannot drift from mount().
311
+ this.#listeners?.abort();
312
+ this.#listeners = undefined;
300
313
  this.#workspaceTeardown?.();
301
314
  this.#workspaceTeardown = undefined;
302
315
  if (this.#fadeTimer !== undefined)
@@ -68,8 +68,12 @@ export function installHudPositionGuards(hud, overlay) {
68
68
  const scheduleRelayout = () => {
69
69
  scheduleSyncDockLayout(hud, overlay);
70
70
  };
71
+ // One signal for all three listeners here. The observers below are NOT covered — neither
72
+ // ResizeObserver nor MutationObserver accepts one — so they keep their explicit disconnects.
73
+ const listeners = new AbortController();
74
+ const { signal } = listeners;
71
75
  const onResize = () => scheduleRelayout();
72
- window.addEventListener('resize', onResize);
76
+ window.addEventListener('resize', onResize, { signal });
73
77
  let resizeObserver;
74
78
  if (typeof ResizeObserver !== 'undefined') {
75
79
  resizeObserver = new ResizeObserver(() => scheduleRelayout());
@@ -81,12 +85,10 @@ export function installHudPositionGuards(hud, overlay) {
81
85
  attributeFilter: [MIN_ATTR, CHAT_ATTR, SETTINGS_ATTR],
82
86
  });
83
87
  const onVisualViewportResize = () => scheduleRelayout();
84
- window.visualViewport?.addEventListener('resize', onVisualViewportResize);
85
- window.visualViewport?.addEventListener('scroll', onVisualViewportResize);
88
+ window.visualViewport?.addEventListener('resize', onVisualViewportResize, { signal });
89
+ window.visualViewport?.addEventListener('scroll', onVisualViewportResize, { signal });
86
90
  return () => {
87
- window.removeEventListener('resize', onResize);
88
- window.visualViewport?.removeEventListener('resize', onVisualViewportResize);
89
- window.visualViewport?.removeEventListener('scroll', onVisualViewportResize);
91
+ listeners.abort();
90
92
  resizeObserver?.disconnect();
91
93
  minObserver.disconnect();
92
94
  };
@@ -171,15 +173,16 @@ export function installHudDrag(hud, head, callbacks = {}) {
171
173
  releaseCapture(e.pointerId);
172
174
  finishDrag();
173
175
  };
174
- head.addEventListener('pointerdown', onPointerDown);
175
- head.addEventListener('pointermove', onPointerMove);
176
- head.addEventListener('pointerup', onPointerUp);
177
- head.addEventListener('pointercancel', onPointerCancel);
176
+ // All four pointer phases share one signal: they are added together and must come off
177
+ // together, and a partial removal would leave a half-live drag.
178
+ const listeners = new AbortController();
179
+ const { signal } = listeners;
180
+ head.addEventListener('pointerdown', onPointerDown, { signal });
181
+ head.addEventListener('pointermove', onPointerMove, { signal });
182
+ head.addEventListener('pointerup', onPointerUp, { signal });
183
+ head.addEventListener('pointercancel', onPointerCancel, { signal });
178
184
  return () => {
179
- head.removeEventListener('pointerdown', onPointerDown);
180
- head.removeEventListener('pointermove', onPointerMove);
181
- head.removeEventListener('pointerup', onPointerUp);
182
- head.removeEventListener('pointercancel', onPointerCancel);
185
+ listeners.abort();
183
186
  if (dragging)
184
187
  releaseCapture(activePointerId ?? 0);
185
188
  finishDrag();
@@ -48,6 +48,15 @@ export class HudShell {
48
48
  */
49
49
  #annotateOn = false;
50
50
  #toggleSync;
51
+ /**
52
+ * One signal for every listener this shell registers, so teardown cannot drift from mount.
53
+ *
54
+ * The click handlers below are anonymous closures over `this`: there is no reference to hand to
55
+ * `removeEventListener`, so before this they were never removed at all. Mounting twice onto the
56
+ * same root therefore stacked a second set, and every handler kept the shell reachable for as
57
+ * long as its element lived.
58
+ */
59
+ #listeners;
51
60
  #report = new PresenterReport({
52
61
  onBeforeOpen: () => {
53
62
  // One panel at a time in the slot above the toolbar. The chat, the settings and the report
@@ -155,6 +164,8 @@ export class HudShell {
155
164
  this.#annotateBtn?.setAttribute('data-active', on ? '1' : '0');
156
165
  }
157
166
  mount(root) {
167
+ this.#listeners = new AbortController();
168
+ const { signal } = this.#listeners;
158
169
  this.#root = root;
159
170
  this.#dock = root.querySelector(`[${DOCK_ATTR}]`) ?? undefined;
160
171
  const fabEl = root.querySelector(`[${FAB_ATTR}]`);
@@ -170,19 +181,19 @@ export class HudShell {
170
181
  e.stopPropagation();
171
182
  this.setAnnotateOn(!this.#annotateOn);
172
183
  this.#callbacks.onAnnotateToggle?.(this.#annotateOn);
173
- });
184
+ }, { signal });
174
185
  const pillEl = root.querySelector(`[${CHAT_PILL_ATTR}]`);
175
186
  pillEl?.addEventListener('click', (e) => {
176
187
  e.preventDefault();
177
188
  e.stopPropagation();
178
189
  this.showChat();
179
- });
190
+ }, { signal });
180
191
  const chatMinEl = root.querySelector(`[${CHAT_MIN_ATTR}]`);
181
192
  chatMinEl?.addEventListener('click', (e) => {
182
193
  e.preventDefault();
183
194
  e.stopPropagation();
184
195
  this.closeChat();
185
- });
196
+ }, { signal });
186
197
  const collapseEl = root.querySelector('[data-reticle-min-btn]');
187
198
  this.#collapseBtn = collapseEl instanceof HTMLButtonElement ? collapseEl : undefined;
188
199
  root.setAttribute(MIN_ATTR, '1');
@@ -204,7 +215,7 @@ export class HudShell {
204
215
  e.preventDefault();
205
216
  e.stopPropagation();
206
217
  this.#report.toggle();
207
- });
218
+ }, { signal });
208
219
  this.#fab?.addEventListener('click', (e) => {
209
220
  e.stopPropagation();
210
221
  if (this.#suppressFabClick) {
@@ -212,15 +223,15 @@ export class HudShell {
212
223
  return;
213
224
  }
214
225
  this.expand();
215
- });
226
+ }, { signal });
216
227
  this.#collapseBtn?.addEventListener('click', (e) => {
217
228
  e.stopPropagation();
218
229
  this.collapse();
219
- });
230
+ }, { signal });
220
231
  this.#chatToggle?.addEventListener('click', (e) => {
221
232
  e.stopPropagation();
222
233
  this.toggleChat();
223
- });
234
+ }, { signal });
224
235
  const toolbarDrag = root.querySelector(`.${DRAG_HANDLE_CLASS}`);
225
236
  const dragHandles = [this.#fab, toolbarDrag].filter((el) => el instanceof HTMLElement);
226
237
  if (this.#dock !== undefined && dragHandles.length > 0) {
@@ -235,12 +246,19 @@ export class HudShell {
235
246
  });
236
247
  this.#layoutTeardown = installHudPositionGuards(this.#dock, root);
237
248
  }
238
- document.addEventListener('pointerdown', this.#onDocPointerDown);
239
- document.addEventListener('keydown', this.#onKeyDown);
249
+ document.addEventListener('pointerdown', this.#onDocPointerDown, { signal });
250
+ document.addEventListener('keydown', this.#onKeyDown, { signal });
240
251
  }
241
252
  teardown() {
242
- document.removeEventListener('pointerdown', this.#onDocPointerDown);
243
- document.removeEventListener('keydown', this.#onKeyDown);
253
+ // One call for all nine registrations. It cannot fall out of step with mount() the way a list
254
+ // of removeEventListener calls can, which is the whole point.
255
+ this.#listeners?.abort();
256
+ this.#listeners = undefined;
257
+ // NOT covered by the signal: a MutationObserver takes no `signal` option. This disconnect was
258
+ // missing entirely, so the observer kept `root` — and through it the shell — alive after
259
+ // teardown, and a re-mount left the previous one still firing.
260
+ this.#toggleSync?.disconnect();
261
+ this.#toggleSync = undefined;
244
262
  this.#dragTeardown?.();
245
263
  this.#dragTeardown = undefined;
246
264
  this.#layoutTeardown?.();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/browser",
3
- "version": "2.10.0",
3
+ "version": "2.11.0",
4
4
  "description": "Reticle browser SDK — installs observers, builds semantic snapshots, executes actions, talks to the bridge.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,7 +14,7 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@testing-library/dom": "^10.4.1",
17
- "@reticlehq/core": "2.10.0"
17
+ "@reticlehq/core": "2.11.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@iconify-json/heroicons-outline": "^1.2.1",