@jobshimo/browser-link 0.34.1 → 0.35.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.
Files changed (36) hide show
  1. package/dist/agent-instructions/content.js +9 -6
  2. package/dist/agent-instructions/content.js.map +1 -1
  3. package/dist/bridge/events.d.ts +1 -1
  4. package/dist/bridge/events.js +8 -1
  5. package/dist/bridge/events.js.map +1 -1
  6. package/dist/bridge/ws-bridge.d.ts +17 -0
  7. package/dist/bridge/ws-bridge.js +34 -0
  8. package/dist/bridge/ws-bridge.js.map +1 -1
  9. package/dist/cdp/inpage/builders.d.ts +18 -0
  10. package/dist/cdp/inpage/builders.js +21 -1
  11. package/dist/cdp/inpage/builders.js.map +1 -1
  12. package/dist/cdp/inpage/observer.d.ts +57 -0
  13. package/dist/cdp/inpage/observer.js +405 -0
  14. package/dist/cdp/inpage/observer.js.map +1 -0
  15. package/dist/cdp/observer-host.d.ts +137 -0
  16. package/dist/cdp/observer-host.js +188 -0
  17. package/dist/cdp/observer-host.js.map +1 -0
  18. package/dist/cdp/transport.js +160 -25
  19. package/dist/cdp/transport.js.map +1 -1
  20. package/dist/extension/background.js +182 -33
  21. package/dist/extension/background.js.map +1 -1
  22. package/dist/extension/inpage/builders.d.ts +18 -0
  23. package/dist/extension/inpage/builders.js +20 -0
  24. package/dist/extension/inpage/builders.js.map +1 -1
  25. package/dist/extension/inpage/observer.d.ts +68 -0
  26. package/dist/extension/inpage/observer.js +416 -0
  27. package/dist/extension/inpage/observer.js.map +1 -0
  28. package/dist/extension/manifest.json +1 -1
  29. package/dist/extension/observer-host.d.ts +84 -0
  30. package/dist/extension/observer-host.js +172 -0
  31. package/dist/extension/observer-host.js.map +1 -0
  32. package/dist/tools/browser-definitions.js +3 -3
  33. package/dist/tools/browser-definitions.js.map +1 -1
  34. package/dist/tools/server-instructions.js +9 -5
  35. package/dist/tools/server-instructions.js.map +1 -1
  36. package/package.json +1 -1
@@ -0,0 +1,416 @@
1
+ /**
2
+ * The page observer: a persistent, coalescing MutationObserver that runs in
3
+ * the page's MAIN world (spec v4 §5.1–§5.3). The host installs it once per
4
+ * tab with `Runtime.addBinding` + `Page.addScriptToEvaluateOnNewDocument`
5
+ * (`runImmediately: true`), so Chrome re-runs it in every new document and
6
+ * in every same-process frame before page scripts. It pushes one summary
7
+ * per flush through the binding and keeps a bounded ring of summaries so an
8
+ * action window (mark → changes) can be read back exactly.
9
+ *
10
+ * Lab findings this source honours (`packages/plan/lab/2026-11-06-live-page-model/`):
11
+ * - flushes are scheduled with `setTimeout`, never `requestAnimationFrame`
12
+ * (rAF never fires in a hidden tab);
13
+ * - the instance is versioned: an older instance is torn down and replaced,
14
+ * an equal or newer one is left alone — never "if present, return";
15
+ * - one observer per root (an observer on `document` alone misses shadow
16
+ * roots), with `Element.prototype.attachShadow` patched to catch every
17
+ * root created later, open or closed.
18
+ *
19
+ * Verbatim sibling: `packages/server/src/cdp/inpage/observer.ts` (the server
20
+ * publishes standalone and cannot import this package). Everything below this
21
+ * header must stay byte-identical — `cdp/drift.test.ts` enforces it and
22
+ * `cdp/builders-parity.test.ts` checks the built output.
23
+ */
24
+ /** Bump whenever the in-page protocol (global shape, messages, methods) changes. */
25
+ export const OBSERVER_VERSION = 1;
26
+ /** Name of the `Runtime.addBinding` function the observer pushes messages through. */
27
+ export const OBSERVER_BINDING = '__blPageObserverPush';
28
+ /** Well-known global that holds the installed observer instance. */
29
+ export const OBSERVER_GLOBAL = '__blPageObserver';
30
+ const DEFAULT_DESCRIPTOR_CAP = 20;
31
+ const MAX_DESCRIPTOR_CAP = 100;
32
+ const DEFAULT_RING_SIZE = 64;
33
+ const MAX_RING_SIZE = 1024;
34
+ function clampInt(value, fallback, min, max) {
35
+ if (typeof value !== 'number' || !Number.isFinite(value))
36
+ return fallback;
37
+ return Math.min(max, Math.max(min, Math.floor(value)));
38
+ }
39
+ /**
40
+ * Build the observer install source. Valid both as a
41
+ * `Page.addScriptToEvaluateOnNewDocument` source and as a one-off
42
+ * `Runtime.evaluate` expression, which evaluates to
43
+ * `{ ok: true, installed, version, doc_id, coverage }`.
44
+ */
45
+ export function buildObserverInstallJs(opts = {}) {
46
+ const optsJson = JSON.stringify({
47
+ key: OBSERVER_GLOBAL,
48
+ binding: typeof opts.binding === 'string' && opts.binding.length > 0 ? opts.binding : OBSERVER_BINDING,
49
+ version: clampInt(opts.version, OBSERVER_VERSION, 0, Number.MAX_SAFE_INTEGER),
50
+ cap: clampInt(opts.descriptor_cap, DEFAULT_DESCRIPTOR_CAP, 1, MAX_DESCRIPTOR_CAP),
51
+ ringSize: clampInt(opts.ring_size, DEFAULT_RING_SIZE, 1, MAX_RING_SIZE),
52
+ });
53
+ return `
54
+ (() => {
55
+ const opts = ${optsJson};
56
+ const existing = window[opts.key];
57
+ if (existing && typeof existing.version === 'number' && existing.version >= opts.version) {
58
+ return { ok: true, installed: false, version: existing.version, doc_id: existing.doc_id, coverage: existing.coverage };
59
+ }
60
+ if (existing && typeof existing.teardown === 'function') {
61
+ try { existing.teardown(); } catch (e) {}
62
+ }
63
+ let isFrame = true;
64
+ try { isFrame = window !== window.top; } catch (e) {}
65
+ const zeroKinds = () => ({ childList: 0, attributes: 0, characterData: 0 });
66
+ const g = {
67
+ version: opts.version,
68
+ doc_id: Math.random().toString(36).slice(2, 10) + Date.now().toString(36),
69
+ seq: 0,
70
+ ring: [],
71
+ pending: new Map(),
72
+ pendingRecords: 0,
73
+ pendingKinds: zeroKinds(),
74
+ pendingRoots: { document: false, shadow: false },
75
+ t0: 0,
76
+ timer: null,
77
+ rescan: null,
78
+ roots: 0,
79
+ coverage: 'full',
80
+ baselines: new Map(),
81
+ dead: false,
82
+ };
83
+ const observers = [];
84
+ const observed = new WeakSet();
85
+ const round = (n) => Math.round(n * 10) / 10;
86
+
87
+ function push(message) {
88
+ const fn = window[opts.binding];
89
+ if (typeof fn !== 'function') return;
90
+ try { fn(JSON.stringify(message)); } catch (e) {}
91
+ }
92
+
93
+ function fold(records, kind) {
94
+ for (const rec of records) {
95
+ if (g.pendingRecords === 0) g.t0 = performance.now();
96
+ g.pendingRecords++;
97
+ g.pendingKinds[rec.type]++;
98
+ g.pendingRoots[kind] = true;
99
+ const target = rec.type === 'characterData' ? rec.target.parentNode : rec.target;
100
+ if (!target) continue;
101
+ g.pending.set(target, (g.pending.get(target) || 0) + 1);
102
+ }
103
+ }
104
+
105
+ function drain() {
106
+ for (const o of observers) {
107
+ const recs = o.mo.takeRecords();
108
+ if (recs.length) fold(recs, o.kind);
109
+ }
110
+ }
111
+
112
+ function segment(el) {
113
+ const tag = el.localName || String(el.nodeName).toLowerCase();
114
+ return el.id ? tag + '#' + el.id : tag;
115
+ }
116
+
117
+ function pathOf(node) {
118
+ const parts = [];
119
+ let n = node;
120
+ let segments = 0;
121
+ while (n && segments < 6) {
122
+ if (n.nodeType === 1) {
123
+ if (n === document.documentElement) break;
124
+ parts.unshift(segment(n));
125
+ segments++;
126
+ n = n.parentNode;
127
+ } else if (n.nodeType === 11 && n.host) {
128
+ parts.unshift('#shadow');
129
+ n = n.host;
130
+ } else {
131
+ break;
132
+ }
133
+ }
134
+ return parts.join('>');
135
+ }
136
+
137
+ function describe(node) {
138
+ if (node.nodeType === 11) {
139
+ return { tag: '#shadow-root', path: node.host ? pathOf(node.host) + '>#shadow' : '#shadow' };
140
+ }
141
+ if (node.nodeType === 9) return { tag: '#document', path: '#document' };
142
+ const d = { tag: node.localName || String(node.nodeName).toLowerCase() };
143
+ if (typeof node.id === 'string' && node.id) d.id = node.id;
144
+ const role = typeof node.getAttribute === 'function' ? node.getAttribute('role') : null;
145
+ if (role) d.role = role;
146
+ d.path = pathOf(node);
147
+ return d;
148
+ }
149
+
150
+ function flush() {
151
+ if (g.timer !== null) {
152
+ clearTimeout(g.timer);
153
+ g.timer = null;
154
+ }
155
+ drain();
156
+ if (g.pendingRecords === 0) return null;
157
+ const entries = Array.from(g.pending.entries()).sort((a, b) => b[1] - a[1]);
158
+ const top = entries.slice(0, opts.cap);
159
+ const summary = {
160
+ type: 'summary',
161
+ doc_id: g.doc_id,
162
+ seq: ++g.seq,
163
+ t0: round(g.t0),
164
+ t1: round(performance.now()),
165
+ records: g.pendingRecords,
166
+ targets: entries.length,
167
+ kinds: g.pendingKinds,
168
+ roots: { document: g.pendingRoots.document, shadow: g.pendingRoots.shadow, frame: isFrame },
169
+ descriptors: top.map((e) => describe(e[0])),
170
+ overflow: entries.length - top.length,
171
+ };
172
+ g.ring.push({ summary: summary, counts: top.map((e) => e[1]) });
173
+ while (g.ring.length > opts.ringSize) g.ring.shift();
174
+ g.pending = new Map();
175
+ g.pendingRecords = 0;
176
+ g.pendingKinds = zeroKinds();
177
+ g.pendingRoots = { document: false, shadow: false };
178
+ push(summary);
179
+ return summary;
180
+ }
181
+
182
+ function onTimer() {
183
+ g.timer = null;
184
+ try { flush(); } catch (e) {}
185
+ }
186
+
187
+ function onRecords(kind) {
188
+ return (records) => {
189
+ if (g.dead) return;
190
+ try {
191
+ fold(records, kind);
192
+ if (g.pendingRecords > 0 && g.timer === null) g.timer = setTimeout(onTimer, 16);
193
+ } catch (e) {}
194
+ };
195
+ }
196
+
197
+ function observe(root, kind) {
198
+ if (!root || observed.has(root)) return;
199
+ observed.add(root);
200
+ try {
201
+ const mo = new MutationObserver(onRecords(kind));
202
+ mo.observe(root, { subtree: true, childList: true, attributes: true, characterData: true });
203
+ observers.push({ mo: mo, kind: kind });
204
+ g.roots++;
205
+ } catch (e) {}
206
+ }
207
+
208
+ function walk(root) {
209
+ let all;
210
+ try { all = root.querySelectorAll('*'); } catch (e) { return; }
211
+ for (const el of all) {
212
+ const sr = el.shadowRoot;
213
+ if (sr) {
214
+ observe(sr, 'shadow');
215
+ walk(sr);
216
+ }
217
+ }
218
+ }
219
+
220
+ observe(document, 'document');
221
+ walk(document);
222
+
223
+ const proto = Element.prototype;
224
+ const originalAttach = proto.attachShadow;
225
+ let patchedAttach = null;
226
+ if (typeof originalAttach === 'function') {
227
+ try {
228
+ const candidate = function attachShadow() {
229
+ const root = originalAttach.apply(this, arguments);
230
+ if (!g.dead) {
231
+ try { observe(root, 'shadow'); } catch (e) {}
232
+ }
233
+ return root;
234
+ };
235
+ proto.attachShadow = candidate;
236
+ if (proto.attachShadow !== candidate) throw new Error('attachShadow not patchable');
237
+ patchedAttach = candidate;
238
+ } catch (e) {
239
+ g.coverage = 'partial';
240
+ g.rescan = setInterval(() => {
241
+ try { walk(document); } catch (err) {}
242
+ }, 2000);
243
+ }
244
+ }
245
+
246
+ function onNav() {
247
+ if (g.dead) return;
248
+ push({ type: 'nav', doc_id: g.doc_id, url: location.href });
249
+ }
250
+ const hist = window.history;
251
+ const historyPatches = [];
252
+ for (const name of ['pushState', 'replaceState']) {
253
+ const original = hist[name];
254
+ if (typeof original !== 'function') continue;
255
+ const hadOwn = Object.prototype.hasOwnProperty.call(hist, name);
256
+ const wrapped = function () {
257
+ const result = original.apply(this, arguments);
258
+ try { onNav(); } catch (e) {}
259
+ return result;
260
+ };
261
+ try {
262
+ hist[name] = wrapped;
263
+ historyPatches.push({ name: name, original: original, wrapped: wrapped, hadOwn: hadOwn });
264
+ } catch (e) {}
265
+ }
266
+ window.addEventListener('popstate', onNav);
267
+ window.addEventListener('hashchange', onNav);
268
+
269
+ g.flush = flush;
270
+
271
+ g.mark = function () {
272
+ flush();
273
+ const mark = g.seq;
274
+ g.baselines.delete(mark);
275
+ g.baselines.set(mark, { url: location.href, active: document.activeElement });
276
+ while (g.baselines.size > 16) g.baselines.delete(g.baselines.keys().next().value);
277
+ return { ok: true, doc_id: g.doc_id, mark: mark, url: location.href, has_focus_baseline: true };
278
+ };
279
+
280
+ g.changes = function (mark, docId) {
281
+ if (docId && docId !== g.doc_id) return { ok: false, reason: 'doc-changed' };
282
+ const base = g.baselines.get(mark);
283
+ if (!base) return { ok: false, reason: 'doc-changed' };
284
+ flush();
285
+ const items = g.ring.filter((it) => it.summary.seq > mark);
286
+ const kinds = zeroKinds();
287
+ const roots = { document: false, shadow: false, frame: isFrame };
288
+ const merged = new Map();
289
+ let records = 0;
290
+ let targets = 0;
291
+ let overflow = 0;
292
+ for (const it of items) {
293
+ const s = it.summary;
294
+ records += s.records;
295
+ targets += s.targets;
296
+ overflow += s.overflow;
297
+ kinds.childList += s.kinds.childList;
298
+ kinds.attributes += s.kinds.attributes;
299
+ kinds.characterData += s.kinds.characterData;
300
+ roots.document = roots.document || s.roots.document;
301
+ roots.shadow = roots.shadow || s.roots.shadow;
302
+ s.descriptors.forEach((d, i) => {
303
+ const key = d.tag + '\\u0000' + (d.id || '') + '\\u0000' + d.path;
304
+ const m = merged.get(key);
305
+ if (m) m.n += it.counts[i];
306
+ else merged.set(key, { d: d, n: it.counts[i] });
307
+ });
308
+ }
309
+ const sorted = Array.from(merged.values()).sort((a, b) => b.n - a.n);
310
+ const descriptors = sorted.slice(0, opts.cap).map((m) => m.d);
311
+ overflow += sorted.length - descriptors.length;
312
+ const out = {
313
+ ok: true,
314
+ doc_id: g.doc_id,
315
+ since: mark,
316
+ records: records,
317
+ targets: targets,
318
+ kinds: kinds,
319
+ roots: roots,
320
+ descriptors: descriptors,
321
+ overflow: overflow,
322
+ };
323
+ if (g.seq > mark && (items.length === 0 || items[0].summary.seq !== mark + 1)) out.truncated = true;
324
+ if (location.href !== base.url) out.url_changed = location.href;
325
+ if (document.activeElement !== base.active) out.focus_moved = true;
326
+ return out;
327
+ };
328
+
329
+ g.teardown = function () {
330
+ if (g.dead) return;
331
+ g.dead = true;
332
+ if (g.timer !== null) clearTimeout(g.timer);
333
+ if (g.rescan !== null) clearInterval(g.rescan);
334
+ for (const o of observers) {
335
+ try { o.mo.disconnect(); } catch (e) {}
336
+ }
337
+ observers.length = 0;
338
+ if (patchedAttach && proto.attachShadow === patchedAttach) {
339
+ try { proto.attachShadow = originalAttach; } catch (e) {}
340
+ }
341
+ for (const p of historyPatches) {
342
+ if (hist[p.name] !== p.wrapped) continue;
343
+ try {
344
+ if (p.hadOwn) hist[p.name] = p.original;
345
+ else delete hist[p.name];
346
+ } catch (e) {}
347
+ }
348
+ window.removeEventListener('popstate', onNav);
349
+ window.removeEventListener('hashchange', onNav);
350
+ g.baselines.clear();
351
+ if (window[opts.key] === g) {
352
+ try { delete window[opts.key]; } catch (e) {}
353
+ }
354
+ };
355
+
356
+ Object.defineProperty(window, opts.key, { value: g, configurable: true, writable: true, enumerable: false });
357
+ push({ type: 'doc_start', doc_id: g.doc_id, url: location.href, version: g.version, coverage: g.coverage });
358
+ return { ok: true, installed: true, version: g.version, doc_id: g.doc_id, coverage: g.coverage };
359
+ })()`;
360
+ }
361
+ const UNAVAILABLE = "{ ok: false, reason: 'observer-unavailable' }";
362
+ function callInstance(call) {
363
+ return `
364
+ (() => {
365
+ const g = window[${JSON.stringify(OBSERVER_GLOBAL)}];
366
+ if (!g || g.dead || typeof g.doc_id !== 'string') return ${UNAVAILABLE};
367
+ try {
368
+ return ${call};
369
+ } catch (e) {
370
+ return ${UNAVAILABLE};
371
+ }
372
+ })()`;
373
+ }
374
+ /**
375
+ * Build the action-mark expression. Forces a flush (including records still
376
+ * queued in the observers) so the mark is exact, then baselines URL and focus.
377
+ * Evaluates to `{ ok: true, doc_id, mark, url, has_focus_baseline: true }` or
378
+ * `{ ok: false, reason: 'observer-unavailable' }`.
379
+ */
380
+ export function buildObserverMarkJs() {
381
+ return callInstance('g.mark()');
382
+ }
383
+ /**
384
+ * Build the "changes since mark" expression. Forces a flush, then aggregates
385
+ * the ring summaries with `seq > mark`. `docId`, when given, must match the
386
+ * current document. Evaluates to the aggregated changes (see
387
+ * `SettleChanges` in `@browser-link/shared`) plus `ok`, `doc_id`, `since`,
388
+ * and `url_changed` / `focus_moved` only when they differ from the mark's
389
+ * baseline; or `{ ok: false, reason: 'observer-unavailable' | 'doc-changed' }`.
390
+ */
391
+ export function buildObserverChangesJs(mark, docId) {
392
+ const markJson = JSON.stringify(clampInt(mark, 0, 0, Number.MAX_SAFE_INTEGER));
393
+ const docJson = typeof docId === 'string' && docId.length > 0 ? JSON.stringify(docId) : 'null';
394
+ return callInstance(`g.changes(${markJson}, ${docJson})`);
395
+ }
396
+ /**
397
+ * Build the teardown expression: disconnects every observer, restores the
398
+ * `attachShadow` and `history` patches when they are still the observer's,
399
+ * and removes the global. Idempotent; evaluates to `{ ok: true, removed }`.
400
+ */
401
+ export function buildObserverTeardownJs() {
402
+ return `
403
+ (() => {
404
+ const key = ${JSON.stringify(OBSERVER_GLOBAL)};
405
+ const g = window[key];
406
+ if (!g) return { ok: true, removed: false };
407
+ if (typeof g.teardown === 'function') {
408
+ try { g.teardown(); } catch (e) {}
409
+ }
410
+ if (window[key] === g) {
411
+ try { delete window[key]; } catch (e) {}
412
+ }
413
+ return { ok: true, removed: true };
414
+ })()`;
415
+ }
416
+ //# sourceMappingURL=observer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"observer.js","sourceRoot":"","sources":["../../src/inpage/observer.ts"],"names":[],"mappings":"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"}
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "browser-link",
4
- "version": "0.34.1",
4
+ "version": "0.35.0",
5
5
  "description": "Bridge between Chrome and an MCP client (Claude Code, OpenCode, GitHub Copilot CLI, …). Per-tab manual activation.",
6
6
  "permissions": [
7
7
  "debugger",
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Host side of the page observer (spec v4 §5.4–§5.5): the per-tab mirror of
3
+ * what the in-page observer (`inpage/observer.ts`) has pushed through its
4
+ * binding, and the rule that folds an action's "changes since mark" into
5
+ * the settle result. Caller-context logic, not injected page JS, and free of
6
+ * `chrome.*` / CDP calls so it is unit-testable on its own and can be copied
7
+ * verbatim by the cdp-direct transport on the server. The caller owns every
8
+ * evaluation; this module only parses and decides.
9
+ */
10
+ import type { PageObserverMessage, PageObserverSummary } from '@browser-link/shared';
11
+ /** Summaries kept in the host mirror (the page keeps its own, larger ring). */
12
+ export declare const OBSERVER_RECENT_CAP = 32;
13
+ /** Per-tab host state for the page observer of the tab's top document. */
14
+ export interface ObserverMirror {
15
+ /** True once the install commands succeeded on this tab. */
16
+ installed: boolean;
17
+ doc_id?: string;
18
+ url?: string;
19
+ coverage?: 'full' | 'partial';
20
+ last_seq: number;
21
+ recent: PageObserverSummary[];
22
+ }
23
+ export type ObserverMessageOutcome = {
24
+ kind: 'doc_start';
25
+ url: string;
26
+ } | {
27
+ kind: 'nav';
28
+ url: string;
29
+ } | {
30
+ kind: 'summary';
31
+ } | {
32
+ kind: 'ignored';
33
+ };
34
+ /** Reason attached as `settle.observer` when `changes` could not be read. */
35
+ export type ObserverChangesFailure = 'observer-unavailable' | 'doc-changed' | 'changes-error';
36
+ /** The settle result as the action callers carry it (`settleSafely`). */
37
+ export type SettleResultLike = Record<string, unknown>;
38
+ export declare function createObserverMirror(): ObserverMirror;
39
+ /**
40
+ * Parse one binding payload. The binding is a page-visible global, so the
41
+ * payload is untrusted: anything that is not valid JSON in one of the three
42
+ * observer message shapes yields `null`.
43
+ */
44
+ export declare function parseObserverMessage(payload: string): PageObserverMessage | null;
45
+ /**
46
+ * Fold one observer message into the mirror. `doc_start` and `nav` are
47
+ * assumed to come from the top document — the caller confirms that first
48
+ * with `TOP_DOCUMENT_PROBE_JS` / `isTopDocumentMessage`, because the install
49
+ * script also runs in same-process child frames. Summaries from a child
50
+ * frame (`roots.frame`) or from a document other than the mirrored one are
51
+ * ignored. A `nav` that does not change the URL (e.g. a state-only
52
+ * `replaceState`) is ignored so it does not emit a navigation event.
53
+ */
54
+ export declare function applyObserverMessage(mirror: ObserverMirror, message: PageObserverMessage): ObserverMessageOutcome;
55
+ /**
56
+ * Expression evaluated in the tab's top document (default `Runtime.evaluate`
57
+ * context) to tell whether a `doc_start` / `nav` came from it: evaluates to
58
+ * `{ doc_id }` of the observer installed there, or `{ doc_id: null }`.
59
+ */
60
+ export declare const TOP_DOCUMENT_PROBE_JS: string;
61
+ /** True when the top-document probe reports the message's `doc_id`. */
62
+ export declare function isTopDocumentMessage(probe: unknown, docId: string): boolean;
63
+ /** The part of an action mark (`buildObserverMarkJs`) the changes read needs. */
64
+ export interface ObserverMark {
65
+ mark: number;
66
+ doc_id: string;
67
+ }
68
+ /**
69
+ * Validate the result of the mark evaluation. `null` means there is no
70
+ * usable mark (observer missing, `ok: false`, or a malformed value); the
71
+ * caller then skips the changes read and reports the failure instead.
72
+ */
73
+ export declare function readObserverMark(value: unknown): ObserverMark | null;
74
+ /**
75
+ * Fold the observer's "changes since mark" into a settle result (spec v4
76
+ * §5.4). With valid changes: attach `changes` (the `SettleChanges` shape),
77
+ * recompute `mutation_count` from the observer's records, and replace
78
+ * `url_changed` / `focus_moved` with the values baselined at the action
79
+ * mark (present only when they changed). Otherwise keep every settle field
80
+ * as `settleSafely` produced it and add `changes: null` plus
81
+ * `observer: <reason>`. A `null` settle (settle disabled) stays `null`.
82
+ * Returns a new object; never throws.
83
+ */
84
+ export declare function mergeSettleChanges(settle: SettleResultLike | null, changes: unknown): SettleResultLike | null;
@@ -0,0 +1,172 @@
1
+ import { OBSERVER_GLOBAL } from './inpage/observer.js';
2
+ /** Summaries kept in the host mirror (the page keeps its own, larger ring). */
3
+ export const OBSERVER_RECENT_CAP = 32;
4
+ export function createObserverMirror() {
5
+ return { installed: false, last_seq: 0, recent: [] };
6
+ }
7
+ function isRecord(value) {
8
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
9
+ }
10
+ function isCount(value) {
11
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0;
12
+ }
13
+ function isKinds(value) {
14
+ return (isRecord(value) &&
15
+ isCount(value.childList) &&
16
+ isCount(value.attributes) &&
17
+ isCount(value.characterData));
18
+ }
19
+ function isRoots(value) {
20
+ return (isRecord(value) &&
21
+ typeof value.document === 'boolean' &&
22
+ typeof value.shadow === 'boolean' &&
23
+ typeof value.frame === 'boolean');
24
+ }
25
+ /** Shared by summaries and by the "changes since mark" aggregate. */
26
+ function hasChangeCounts(value) {
27
+ return (isCount(value.records) &&
28
+ isCount(value.targets) &&
29
+ isKinds(value.kinds) &&
30
+ isRoots(value.roots) &&
31
+ Array.isArray(value.descriptors) &&
32
+ isCount(value.overflow));
33
+ }
34
+ /**
35
+ * Parse one binding payload. The binding is a page-visible global, so the
36
+ * payload is untrusted: anything that is not valid JSON in one of the three
37
+ * observer message shapes yields `null`.
38
+ */
39
+ export function parseObserverMessage(payload) {
40
+ let raw;
41
+ try {
42
+ raw = JSON.parse(payload);
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ if (!isRecord(raw) || typeof raw.doc_id !== 'string')
48
+ return null;
49
+ switch (raw.type) {
50
+ case 'summary':
51
+ return isCount(raw.seq) && hasChangeCounts(raw)
52
+ ? raw
53
+ : null;
54
+ case 'doc_start':
55
+ return typeof raw.url === 'string' && (raw.coverage === 'full' || raw.coverage === 'partial')
56
+ ? raw
57
+ : null;
58
+ case 'nav':
59
+ return typeof raw.url === 'string' ? raw : null;
60
+ default:
61
+ return null;
62
+ }
63
+ }
64
+ /**
65
+ * Fold one observer message into the mirror. `doc_start` and `nav` are
66
+ * assumed to come from the top document — the caller confirms that first
67
+ * with `TOP_DOCUMENT_PROBE_JS` / `isTopDocumentMessage`, because the install
68
+ * script also runs in same-process child frames. Summaries from a child
69
+ * frame (`roots.frame`) or from a document other than the mirrored one are
70
+ * ignored. A `nav` that does not change the URL (e.g. a state-only
71
+ * `replaceState`) is ignored so it does not emit a navigation event.
72
+ */
73
+ export function applyObserverMessage(mirror, message) {
74
+ switch (message.type) {
75
+ case 'doc_start':
76
+ mirror.doc_id = message.doc_id;
77
+ mirror.url = message.url;
78
+ mirror.coverage = message.coverage;
79
+ mirror.last_seq = 0;
80
+ mirror.recent = [];
81
+ return { kind: 'doc_start', url: message.url };
82
+ case 'nav':
83
+ if (mirror.doc_id !== undefined && message.doc_id !== mirror.doc_id)
84
+ return { kind: 'ignored' };
85
+ if (message.url === mirror.url)
86
+ return { kind: 'ignored' };
87
+ mirror.url = message.url;
88
+ return { kind: 'nav', url: message.url };
89
+ case 'summary':
90
+ if (message.roots.frame)
91
+ return { kind: 'ignored' };
92
+ if (mirror.doc_id !== undefined && message.doc_id !== mirror.doc_id)
93
+ return { kind: 'ignored' };
94
+ mirror.last_seq = Math.max(mirror.last_seq, message.seq);
95
+ mirror.recent.push(message);
96
+ while (mirror.recent.length > OBSERVER_RECENT_CAP)
97
+ mirror.recent.shift();
98
+ return { kind: 'summary' };
99
+ default:
100
+ return { kind: 'ignored' };
101
+ }
102
+ }
103
+ /**
104
+ * Expression evaluated in the tab's top document (default `Runtime.evaluate`
105
+ * context) to tell whether a `doc_start` / `nav` came from it: evaluates to
106
+ * `{ doc_id }` of the observer installed there, or `{ doc_id: null }`.
107
+ */
108
+ export const TOP_DOCUMENT_PROBE_JS = `(() => {
109
+ const g = window[${JSON.stringify(OBSERVER_GLOBAL)}];
110
+ return { doc_id: g && typeof g.doc_id === 'string' ? g.doc_id : null };
111
+ })()`;
112
+ /** True when the top-document probe reports the message's `doc_id`. */
113
+ export function isTopDocumentMessage(probe, docId) {
114
+ return isRecord(probe) && typeof probe.doc_id === 'string' && probe.doc_id === docId;
115
+ }
116
+ /**
117
+ * Validate the result of the mark evaluation. `null` means there is no
118
+ * usable mark (observer missing, `ok: false`, or a malformed value); the
119
+ * caller then skips the changes read and reports the failure instead.
120
+ */
121
+ export function readObserverMark(value) {
122
+ if (!isRecord(value) || value.ok !== true)
123
+ return null;
124
+ if (!isCount(value.mark) || typeof value.doc_id !== 'string')
125
+ return null;
126
+ return { mark: value.mark, doc_id: value.doc_id };
127
+ }
128
+ function failureReason(changes) {
129
+ if (isRecord(changes) &&
130
+ changes.ok === false &&
131
+ (changes.reason === 'observer-unavailable' || changes.reason === 'doc-changed')) {
132
+ return changes.reason;
133
+ }
134
+ return 'changes-error';
135
+ }
136
+ /**
137
+ * Fold the observer's "changes since mark" into a settle result (spec v4
138
+ * §5.4). With valid changes: attach `changes` (the `SettleChanges` shape),
139
+ * recompute `mutation_count` from the observer's records, and replace
140
+ * `url_changed` / `focus_moved` with the values baselined at the action
141
+ * mark (present only when they changed). Otherwise keep every settle field
142
+ * as `settleSafely` produced it and add `changes: null` plus
143
+ * `observer: <reason>`. A `null` settle (settle disabled) stays `null`.
144
+ * Returns a new object; never throws.
145
+ */
146
+ export function mergeSettleChanges(settle, changes) {
147
+ if (settle === null)
148
+ return null;
149
+ if (!isRecord(changes) || changes.ok !== true || !hasChangeCounts(changes)) {
150
+ return { ...settle, changes: null, observer: failureReason(changes) };
151
+ }
152
+ const summary = {
153
+ records: changes.records,
154
+ targets: changes.targets,
155
+ kinds: changes.kinds,
156
+ roots: changes.roots,
157
+ descriptors: changes.descriptors,
158
+ overflow: changes.overflow,
159
+ };
160
+ if (changes.truncated === true)
161
+ summary.truncated = true;
162
+ const merged = { ...settle, mutation_count: summary.records };
163
+ delete merged.url_changed;
164
+ delete merged.focus_moved;
165
+ if (typeof changes.url_changed === 'string')
166
+ merged.url_changed = changes.url_changed;
167
+ if (changes.focus_moved === true)
168
+ merged.focus_moved = true;
169
+ merged.changes = summary;
170
+ return merged;
171
+ }
172
+ //# sourceMappingURL=observer-host.js.map