@reticlehq/browser 2.11.0 → 2.12.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.
@@ -1,5 +1,5 @@
1
1
  import { PresenterReport, reportPanelHtml } from './presenter-report.js';
2
- import { CHAT_MIN_ATTR, CHAT_PILL_ATTR, REPORT_ATTR, REPORT_BTN_ATTR, ANNOTATE_BTN_ATTR, CHAT_ATTR, CHAT_TOGGLE_ATTR, CHAT_PANEL_ATTR, DOCK_ATTR, FAB_ATTR, MIN_ATTR, HUD_DRAGGED_ATTR, SETTINGS_ATTR, SETTINGS_BTN_ATTR, } from './presenter-config.js';
2
+ import { CHAT_MIN_ATTR, CHAT_PILL_ATTR, REPORT_ATTR, REPORT_BTN_ATTR, ANNOTATE_BTN_ATTR, CHAT_ATTR, CHAT_TOGGLE_ATTR, CHAT_PANEL_ATTR, DOCK_ATTR, FAB_ATTR, MIN_ATTR, SETTINGS_ATTR, SETTINGS_BTN_ATTR, } from './presenter-config.js';
3
3
  import { BRAND_NAME, FAB_TOGGLE_HTML, MARK_SVG } from './presenter-brand.js';
4
4
  import { settleLogAtLatest } from './presenter-log.js';
5
5
  import { installHudDragHandles, installHudPositionGuards } from './presenter-drag.js';
@@ -420,7 +420,3 @@ export class HudShell {
420
420
  }
421
421
  };
422
422
  }
423
- /** Whether the dock has been dragged off the default bottom-right position. */
424
- export function isDockDragged(dock) {
425
- return '1' === dock.getAttribute(HUD_DRAGGED_ATTR);
426
- }
package/dist/reticle.d.ts CHANGED
@@ -55,6 +55,8 @@ export declare function buildEvent(args: {
55
55
  sessionId: string;
56
56
  data: Record<string, unknown>;
57
57
  ref?: string | undefined;
58
+ documentId?: string | undefined;
59
+ editEpoch?: number | undefined;
58
60
  }): ReticleEvent;
59
61
  /**
60
62
  * The browser-side orchestrator. Wires observers -> events -> bridge, and bridge
@@ -79,5 +81,15 @@ export declare class Reticle {
79
81
  describe(input: CapabilitiesInput): void;
80
82
  /** Live-control: end the session programmatically from the host app (drives the panel to ended). */
81
83
  endSession(): void;
84
+ /**
85
+ * Hand the SDK the page's hot-update channel, so a stale ref can say the code changed underneath it.
86
+ *
87
+ * `unknown` on purpose: the only caller that has one is the build integration, the shape it passes
88
+ * is Vite's `import.meta.hot`, and the SDK must not depend on Vite — it ships to Next, Electron,
89
+ * Tauri and plain pages. Anything that is not a subscribable channel is ignored, which is the
90
+ * normal case: with no channel the epoch stays at `NO_EDITS_OBSERVED`, meaning "no edits OBSERVED",
91
+ * never "no edits happened".
92
+ */
93
+ observeHotUpdates(hot: unknown): void;
82
94
  disconnect(): void;
83
95
  }
package/dist/reticle.js CHANGED
@@ -1,5 +1,6 @@
1
- import { EventType, RETICLE_DEFAULT_PORT, RETICLE_PROTOCOL_VERSION, RETICLE_URL_PARAM, bridgeWsUrl, ReticleCommand, MessageKind, SESSION_AUTO, SessionState, TRANSPORT_LIMITS, buildRedactionPolicy, isLoopbackHostname, isLocalPage, setActiveRedactionPolicy, wireRedactionKeys, RETICLE_ROOT_GLOBAL, RETICLE_SDK_VERSION_GLOBAL, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
1
+ import { EventType, RETICLE_DEFAULT_PORT, RETICLE_PROTOCOL_VERSION, RETICLE_URL_PARAM, bridgeWsUrl, ReticleCommand, MessageKind, SESSION_AUTO, SessionState, TRANSPORT_LIMITS, buildRedactionPolicy, isLoopbackHostname, isLocalPage, setActiveRedactionPolicy, wireRedactionKeys, RETICLE_ROOT_GLOBAL, RETICLE_SDK_VERSION_GLOBAL, CONTRACT_FINGERPRINT, newDocumentId, NO_EDITS_OBSERVED, } from '@reticlehq/core';
2
2
  import { rememberSessionLabel } from './session-continuity.js';
3
+ import { editEpoch } from './edit-epoch.js';
3
4
  import { createCommandRegistry, RELOAD_CACHE_BUST_PARAM, } from './commands/commands.js';
4
5
  import { Transport } from './transport/transport.js';
5
6
  import { unreachableMessage } from './transport/unreachable-message.js';
@@ -128,6 +129,15 @@ export function buildEvent(args) {
128
129
  type: args.type,
129
130
  sessionId: args.sessionId,
130
131
  ref: args.ref,
132
+ // Which document this was observed under, so the server can refuse evidence minted before a
133
+ // navigation replaced the page. Stamped HERE because it is the one place every event passes
134
+ // through: an observer added later would otherwise emit unstamped events, which read as
135
+ // "current" by design and would reintroduce the defect silently for one event type.
136
+ documentId: args.documentId,
137
+ // Which round of source edits this was observed under. Stamped HERE for exactly the reason
138
+ // documentId is, and omitted while nothing has hot-updated: absence already reads as "current"
139
+ // downstream, so `NO_EDITS_OBSERVED` on the wire would be bytes spent saying "unknown".
140
+ editEpoch: NO_EDITS_OBSERVED === args.editEpoch ? undefined : args.editEpoch,
131
141
  data: args.data,
132
142
  };
133
143
  }
@@ -141,6 +151,13 @@ export class Reticle {
141
151
  #teardowns = [];
142
152
  #connected = false;
143
153
  #session = 'default';
154
+ /**
155
+ * Minted once, here, because this class is constructed once per document: a full navigation tears
156
+ * down the JavaScript context and builds a new one, so the id dies with the document it names. An
157
+ * SPA route change does not reconstruct it, which is correct — same context, same in-flight
158
+ * requests, same evidence.
159
+ */
160
+ #documentId = newDocumentId(Math.random);
144
161
  #start = 0;
145
162
  #overlay;
146
163
  #presenter;
@@ -321,6 +338,18 @@ export class Reticle {
321
338
  endSession() {
322
339
  this.#presenter?.setState(SessionState.ENDED);
323
340
  }
341
+ /**
342
+ * Hand the SDK the page's hot-update channel, so a stale ref can say the code changed underneath it.
343
+ *
344
+ * `unknown` on purpose: the only caller that has one is the build integration, the shape it passes
345
+ * is Vite's `import.meta.hot`, and the SDK must not depend on Vite — it ships to Next, Electron,
346
+ * Tauri and plain pages. Anything that is not a subscribable channel is ignored, which is the
347
+ * normal case: with no channel the epoch stays at `NO_EDITS_OBSERVED`, meaning "no edits OBSERVED",
348
+ * never "no edits happened".
349
+ */
350
+ observeHotUpdates(hot) {
351
+ editEpoch.observe(hot);
352
+ }
324
353
  disconnect() {
325
354
  if (!this.#connected)
326
355
  return;
@@ -347,6 +376,8 @@ export class Reticle {
347
376
  t: Math.round(performance.now() - this.#start),
348
377
  type,
349
378
  sessionId: this.#session,
379
+ documentId: this.#documentId,
380
+ editEpoch: editEpoch.current,
350
381
  data,
351
382
  ref,
352
383
  });
@@ -5,12 +5,13 @@
5
5
  * than a template inlined in a warn call. The rule it exists to hold: report what this page observed
6
6
  * and what it could not determine, never a cause. See unreachable-message.test.ts.
7
7
  */
8
+ import { UNREACHABLE_NOTICE_PREFIX } from '@reticlehq/core';
8
9
  /**
9
10
  * The page tried, failed, and cannot see why. Names the address and the attempt count, refuses to
10
11
  * diagnose the daemon, and lists the checks in the order that actually resolves the ambiguity.
11
12
  */
12
13
  export function unreachableMessage(url, attempts) {
13
- return (`[Reticle] this page could not open a websocket to ${url}. ${String(attempts)} attempts, all ` +
14
+ return (`${UNREACHABLE_NOTICE_PREFIX}${url}. ${String(attempts)} attempts, all ` +
14
15
  `failed. That is everything the page can see: from inside the browser it cannot tell a daemon ` +
15
16
  `that is not there from one it is not allowed to reach, so this is not evidence about the ` +
16
17
  `daemon. What answers it: run \`npx @reticlehq/server status\` to see whether one is listening ` +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/browser",
3
- "version": "2.11.0",
3
+ "version": "2.12.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",
@@ -13,21 +13,18 @@
13
13
  "./package.json": "./package.json"
14
14
  },
15
15
  "dependencies": {
16
- "@testing-library/dom": "^10.4.1",
17
- "@reticlehq/core": "2.11.0"
16
+ "@reticlehq/core": "2.12.0"
18
17
  },
19
18
  "devDependencies": {
20
19
  "@iconify-json/heroicons-outline": "^1.2.1",
21
20
  "@iconify-json/heroicons-solid": "^1.2.1",
22
21
  "@tanstack/query-core": "^5.101.4",
23
- "@types/jsdom": "^30.0.0",
24
22
  "jotai": "^2.20.2",
25
23
  "jsdom": "^30.0.1",
26
24
  "mobx": "^7.0.0",
27
25
  "pinia": "^4.0.3",
28
26
  "react": "^19.2.8",
29
27
  "recoil": "^0.7.7",
30
- "sharp": "^0.35.3",
31
28
  "svelte": "^5.56.8",
32
29
  "valtio": "^2.3.2",
33
30
  "vue": "^3.5.41",
@@ -1,13 +0,0 @@
1
- /**
2
- * Blind-spot detection, the detectable half). Cross-origin iframes are a real observation gap — the
3
- * SDK cannot see inside them (same-origin policy). We COUNT them so a result can say "coverage: partial —
4
- * 2 cross-origin frames unobserved" instead of implying it saw the whole page. Closed shadow roots are a
5
- * blind spot too, but undetectable by design (element.shadowRoot === null), so they are not counted here.
6
- */
7
- /** Pure: how many frames are cross-origin, given each frame's same-origin flag + whether it has a src. */
8
- export declare function classifyCrossOriginFrames(frames: ReadonlyArray<{
9
- sameOrigin: boolean;
10
- hasSrc: boolean;
11
- }>): number;
12
- /** Probe the live document: count iframes whose document is unreachable (cross-origin). */
13
- export declare function countCrossOriginFrames(): number;
@@ -1,29 +0,0 @@
1
- /**
2
- * Blind-spot detection, the detectable half). Cross-origin iframes are a real observation gap — the
3
- * SDK cannot see inside them (same-origin policy). We COUNT them so a result can say "coverage: partial —
4
- * 2 cross-origin frames unobserved" instead of implying it saw the whole page. Closed shadow roots are a
5
- * blind spot too, but undetectable by design (element.shadowRoot === null), so they are not counted here.
6
- */
7
- /** Pure: how many frames are cross-origin, given each frame's same-origin flag + whether it has a src. */
8
- export function classifyCrossOriginFrames(frames) {
9
- return frames.filter((f) => f.hasSrc && !f.sameOrigin).length;
10
- }
11
- /** Probe the live document: count iframes whose document is unreachable (cross-origin). */
12
- export function countCrossOriginFrames() {
13
- if ('undefined' === typeof document)
14
- return 0;
15
- const frames = [];
16
- for (const frame of document.querySelectorAll('iframe')) {
17
- const hasSrc = frame.getAttribute('src') !== null && frame.getAttribute('src') !== '';
18
- let sameOrigin;
19
- try {
20
- // Reaching contentDocument throws (or yields null) for a cross-origin frame.
21
- sameOrigin = frame.contentDocument !== null;
22
- }
23
- catch {
24
- sameOrigin = false;
25
- }
26
- frames.push({ sameOrigin, hasSrc });
27
- }
28
- return classifyCrossOriginFrames(frames);
29
- }
@@ -1,10 +0,0 @@
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[];
@@ -1,63 +0,0 @@
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
- }