pi-browser-harness 0.11.0 → 0.13.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 (90) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +102 -1
  3. package/package.json +13 -7
  4. package/skills/pi-browser-harness/SKILL.md +84 -0
  5. package/src/cdp/commands.ts +204 -14
  6. package/src/cdp/console-buffer.ts +37 -23
  7. package/src/cdp/discovery.ts +72 -37
  8. package/src/cdp/events.ts +8 -3
  9. package/src/cdp/network-buffer.ts +38 -32
  10. package/src/cdp/ownership.ts +22 -4
  11. package/src/cdp/record-store.ts +2 -2
  12. package/src/cdp/session.ts +320 -217
  13. package/src/cdp/target-factory.ts +34 -15
  14. package/src/cdp/types.ts +17 -1
  15. package/src/client.ts +76 -43
  16. package/src/daemon/bridge.ts +115 -191
  17. package/src/daemon/connection.ts +323 -0
  18. package/src/daemon/index.ts +77 -48
  19. package/src/daemon/lines.ts +40 -0
  20. package/src/daemon/main.ts +12 -0
  21. package/src/daemon/protocol.ts +55 -25
  22. package/src/daemon/server.ts +100 -63
  23. package/src/daemon/socket.ts +10 -0
  24. package/src/daemon/spawn.ts +84 -27
  25. package/src/daemon/transport.ts +88 -52
  26. package/src/domains/ax-tree.ts +8 -8
  27. package/src/domains/cdp-call.ts +14 -7
  28. package/src/domains/click.ts +2 -7
  29. package/src/domains/concurrency.ts +50 -0
  30. package/src/domains/console.ts +20 -66
  31. package/src/domains/drag.ts +26 -28
  32. package/src/domains/element-call.ts +102 -68
  33. package/src/domains/files.ts +64 -11
  34. package/src/domains/form-batch.ts +13 -11
  35. package/src/domains/form.ts +79 -116
  36. package/src/domains/headers.ts +89 -0
  37. package/src/domains/history.ts +7 -7
  38. package/src/domains/io.ts +35 -0
  39. package/src/domains/isolated-tab.ts +6 -12
  40. package/src/domains/js.ts +68 -53
  41. package/src/domains/keyboard.ts +16 -18
  42. package/src/domains/navigate.ts +102 -69
  43. package/src/domains/network.ts +56 -98
  44. package/src/domains/page/fill.page.ts +139 -0
  45. package/src/domains/page/form.page.ts +61 -0
  46. package/src/domains/page/inject.ts +37 -0
  47. package/src/domains/page/keyboard.page.ts +16 -0
  48. package/src/domains/page/live-value.page.ts +10 -0
  49. package/src/domains/page/read-page.page.ts +51 -0
  50. package/src/domains/page/serp.page.ts +39 -0
  51. package/src/domains/page.ts +19 -58
  52. package/src/domains/poll.ts +42 -0
  53. package/src/domains/readpage/read-page.ts +48 -54
  54. package/src/domains/ref-resolve.ts +43 -29
  55. package/src/domains/ref-signature.ts +11 -0
  56. package/src/domains/render.ts +12 -23
  57. package/src/domains/screenshot-capture.ts +4 -2
  58. package/src/domains/screenshot.ts +13 -14
  59. package/src/domains/scroll.ts +4 -5
  60. package/src/domains/search/google-serp.ts +1 -1
  61. package/src/domains/search/web-search.ts +38 -34
  62. package/src/domains/setup.ts +0 -2
  63. package/src/domains/snapshot.ts +72 -77
  64. package/src/domains/tab-resolve.ts +36 -0
  65. package/src/domains/tabs.ts +19 -78
  66. package/src/domains/target.ts +1 -1
  67. package/src/domains/viewport.ts +0 -1
  68. package/src/index.ts +100 -39
  69. package/src/profile/bind.ts +45 -14
  70. package/src/profile/browser-process.ts +50 -12
  71. package/src/profile/command.ts +20 -12
  72. package/src/profile/launch.ts +13 -9
  73. package/src/profile/list.ts +9 -13
  74. package/src/profile/store.ts +2 -1
  75. package/src/prompt.ts +42 -117
  76. package/src/registry.ts +68 -4
  77. package/src/schemas/parse.ts +2 -2
  78. package/src/setup.ts +28 -19
  79. package/src/state.ts +33 -2
  80. package/src/util/debug.ts +7 -1
  81. package/src/util/guards.ts +3 -0
  82. package/src/util/mutex.ts +1 -1
  83. package/src/util/paths.ts +12 -1
  84. package/src/util/result.ts +8 -0
  85. package/src/util/sharp-shim.ts +18 -11
  86. package/src/util/sleep.ts +18 -0
  87. package/src/util/tool.ts +163 -114
  88. package/src/util/truncate.ts +1 -1
  89. package/tsconfig.json +2 -1
  90. package/src/domains/fill-engine.ts +0 -80
@@ -1,32 +1,39 @@
1
+ import { Type, type Static } from "typebox";
1
2
  import { decodeEvent } from "./events";
2
- import { createRecordStore } from "./record-store";
3
+ import { createRecordStore, type DrainPage } from "./record-store";
3
4
 
4
5
  export type ConsoleLevel = "log" | "info" | "warn" | "error" | "debug";
5
6
 
6
- export type ConsoleRecord = {
7
- seq: number;
8
- timestampMs: number;
9
- level: ConsoleLevel;
10
- text: string;
11
- source: "console-api" | "log-entry";
12
- url?: string;
13
- lineNumber?: number;
14
- stackTrace?: string;
15
- };
7
+ // The one declaration of a console record: the buffer produces it and browser_console validates replayed
8
+ // details against it, so the two cannot drift.
9
+ export const ConsoleRecordSchema = Type.Object({
10
+ seq: Type.Number(),
11
+ timestampMs: Type.Number(),
12
+ level: Type.Union([
13
+ Type.Literal("log"),
14
+ Type.Literal("info"),
15
+ Type.Literal("warn"),
16
+ Type.Literal("error"),
17
+ Type.Literal("debug"),
18
+ ]),
19
+ text: Type.String(),
20
+ source: Type.Union([Type.Literal("console-api"), Type.Literal("log-entry")]),
21
+ url: Type.Optional(Type.String()),
22
+ lineNumber: Type.Optional(Type.Number()),
23
+ stackTrace: Type.Optional(Type.String()),
24
+ });
25
+
26
+ export type ConsoleRecord = Readonly<Static<typeof ConsoleRecordSchema>>;
16
27
 
17
28
  export type ConsoleFilter = {
18
- levels?: ConsoleLevel[];
19
- textPattern?: string;
20
- sinceSeq?: number;
21
- sinceMs?: number;
22
- limit?: number;
29
+ readonly levels?: ReadonlyArray<ConsoleLevel>;
30
+ readonly textPattern?: string;
31
+ readonly sinceSeq?: number;
32
+ readonly sinceMs?: number;
33
+ readonly limit?: number;
23
34
  };
24
35
 
25
- export type ConsoleDrainResult = {
26
- readonly records: ConsoleRecord[];
27
- readonly total: number;
28
- readonly bufferOverflowed: boolean;
29
- };
36
+ export type ConsoleDrainResult = DrainPage<ConsoleRecord>;
30
37
 
31
38
  export type ConsoleBuffer = {
32
39
  ingestConsoleApi(p: unknown): void;
@@ -43,7 +50,9 @@ const compileTextMatcher = (pattern: string): ((text: string) => boolean) => {
43
50
  try {
44
51
  const re = new RegExp(pattern.slice(1, -1));
45
52
  return (text) => re.test(text);
46
- } catch {}
53
+ } catch {
54
+ // Not a valid regex: fall through and match the pattern as a literal substring.
55
+ }
47
56
  }
48
57
  return (text) => text.includes(pattern);
49
58
  };
@@ -60,7 +69,8 @@ const stringifyRemoteObject = (
60
69
  try {
61
70
  return JSON.stringify(arg.value);
62
71
  } catch {
63
- return String(arg.value);
72
+ // Circular or BigInt payloads cannot be JSON-encoded; fall back to the CDP description.
73
+ return arg.description ?? arg.type ?? "";
64
74
  }
65
75
  }
66
76
  if (arg.description !== undefined) return arg.description;
@@ -69,6 +79,8 @@ const stringifyRemoteObject = (
69
79
 
70
80
  const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => {
71
81
  switch (type) {
82
+ case undefined:
83
+ return "log";
72
84
  case "error":
73
85
  case "assert":
74
86
  return "error";
@@ -86,6 +98,8 @@ const mapConsoleApiLevel = (type: string | undefined): ConsoleLevel => {
86
98
 
87
99
  const mapLogEntryLevel = (level: string | undefined): ConsoleLevel => {
88
100
  switch (level) {
101
+ case undefined:
102
+ return "info";
89
103
  case "error":
90
104
  return "error";
91
105
  case "warning":
@@ -2,23 +2,28 @@ import { readFile, stat } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { connect as netConnect } from "node:net";
4
4
  import { request as httpRequest } from "node:http";
5
- import { Type } from "typebox";
6
- import { Compile } from "typebox/compile";
7
- import { parseJson } from "../schemas/parse";
8
5
  import { type Result, err, ok } from "../util/result";
9
6
  import { userDataDirCandidates } from "../profile/paths";
10
7
  import { type CdpError, cdpError } from "./errors";
11
- import { errnoCode } from "../util/guards";
8
+ import { asString, errnoCode, isRecord } from "../util/guards";
9
+ import { sleep } from "../util/sleep";
12
10
 
13
11
  const PORT_PROBE_DEADLINE_MS = 30_000;
14
12
  const PORT_PROBE_INTERVAL_MS = 1_000;
15
13
 
16
- const VersionResponse = Type.Object(
17
- { webSocketDebuggerUrl: Type.Optional(Type.String()) },
18
- { additionalProperties: true },
19
- );
20
-
21
- const versionValidator = Compile(VersionResponse);
14
+ // Hand-rolled on purpose: this file runs inside the daemon, a separate Node process that pi does not
15
+ // supply host-provided packages (typebox) to. Everything the daemon imports must be a runtime
16
+ // dependency or a builtin; test/daemon/entry-imports-test.ts enforces that.
17
+ export const parseVersionResponse = (raw: string): string | null => {
18
+ let parsed: unknown;
19
+ try {
20
+ parsed = JSON.parse(raw);
21
+ } catch {
22
+ return null;
23
+ }
24
+ if (!isRecord(parsed)) return null;
25
+ return asString(parsed["webSocketDebuggerUrl"]) ?? null;
26
+ };
22
27
 
23
28
  export type CdpEndpoint = {
24
29
  readonly wsUrl: string;
@@ -36,24 +41,57 @@ const probePort = (port: number): Promise<Result<void, CdpError>> =>
36
41
  sock.destroy();
37
42
  resolve(r);
38
43
  };
39
- sock.setTimeout(1000, () => finish(err(cdpError("discovery_failed", "probe timeout"))));
40
- sock.once("error", (e) => finish(err(cdpError("discovery_failed", e.message))));
41
- sock.once("connect", () => finish(ok(undefined)));
44
+ sock.setTimeout(1000, () => { finish(err(cdpError("discovery_failed", "probe timeout"))); });
45
+ sock.once("error", (e) => { finish(err(cdpError("discovery_failed", e.message))); });
46
+ sock.once("connect", () => { finish(ok(undefined)); });
42
47
  });
43
48
 
44
- const waitForPort = async (port: number): Promise<Result<void, CdpError>> => {
45
- const end = Date.now() + PORT_PROBE_DEADLINE_MS;
46
- let lastMessage = "unknown";
47
- while (Date.now() < end) {
48
- const probe = await probePort(port);
49
- if (probe.success) return probe;
50
- lastMessage = probe.error.message;
51
- await new Promise((r) => setTimeout(r, PORT_PROBE_INTERVAL_MS));
49
+ export type PortProbe = (port: number) => Promise<Result<void, CdpError>>;
50
+
51
+ export type PortRaceOptions = {
52
+ readonly probe?: PortProbe;
53
+ readonly deadlineMs?: number;
54
+ readonly intervalMs?: number;
55
+ };
56
+
57
+ // Polls every candidate port at once under ONE deadline and settles on the first that answers; a stale DevToolsActivePort can no longer hold discovery for its own full deadline before the next candidate is tried.
58
+ export const firstReadyPort = async <C extends { readonly port: number }>(
59
+ candidates: ReadonlyArray<C>,
60
+ opts: PortRaceOptions = {},
61
+ ): Promise<Result<C, CdpError>> => {
62
+ const probe = opts.probe ?? probePort;
63
+ const deadlineMs = opts.deadlineMs ?? PORT_PROBE_DEADLINE_MS;
64
+ const intervalMs = opts.intervalMs ?? PORT_PROBE_INTERVAL_MS;
65
+ if (candidates.length === 0) return err(cdpError("discovery_failed", "no DevTools port candidates to probe"));
66
+
67
+ // One controller ends every loser the moment a winner appears (or the deadline passes).
68
+ const stop = new AbortController();
69
+ const deadline = setTimeout(() => { stop.abort(); }, deadlineMs);
70
+ const lastMessages = new Map<number, string>();
71
+
72
+ const poll = async (c: C): Promise<C> => {
73
+ while (!stop.signal.aborted) {
74
+ const r = await probe(c.port);
75
+ if (r.success) return c;
76
+ lastMessages.set(c.port, r.error.message);
77
+ await sleep(intervalMs, stop.signal);
78
+ }
79
+ throw new Error(lastMessages.get(c.port) ?? "aborted");
80
+ };
81
+
82
+ try {
83
+ return ok(await Promise.any(candidates.map(poll)));
84
+ } catch {
85
+ const ports = candidates.map((k) => `127.0.0.1:${k.port}`).join(", ");
86
+ const detail = [...lastMessages.entries()].map(([port, m]) => `${port}: ${m}`).join("; ") || "unknown";
87
+ return err(cdpError(
88
+ "discovery_failed",
89
+ `Chrome's remote-debugging page is open, but DevTools is not live yet on ${ports} — if Chrome opened a profile picker, choose your normal profile first, then tick the checkbox and click Allow if shown (last error: ${detail})`,
90
+ ));
91
+ } finally {
92
+ clearTimeout(deadline);
93
+ stop.abort();
52
94
  }
53
- return err(cdpError(
54
- "discovery_failed",
55
- `Chrome's remote-debugging page is open, but DevTools is not live yet on 127.0.0.1:${port} — if Chrome opened a profile picker, choose your normal profile first, then tick the checkbox and click Allow if shown (last error: ${lastMessage})`,
56
- ));
57
95
  };
58
96
 
59
97
  const isPortLive = async (port: number): Promise<boolean> => (await probePort(port)).success;
@@ -72,13 +110,12 @@ const queryLiveWsUrl = (port: number, timeoutMs = 1_500): Promise<string | null>
72
110
  const chunks: Buffer[] = [];
73
111
  res.on("data", (c: Buffer) => chunks.push(c));
74
112
  res.on("end", () => {
75
- const parsed = parseJson(Buffer.concat(chunks).toString("utf8"), versionValidator);
76
- resolve(parsed.success ? parsed.data.webSocketDebuggerUrl ?? null : null);
113
+ resolve(parseVersionResponse(Buffer.concat(chunks).toString("utf8")));
77
114
  });
78
115
  },
79
116
  );
80
117
  req.on("timeout", () => { req.destroy(); resolve(null); });
81
- req.on("error", () => resolve(null));
118
+ req.on("error", () => { resolve(null); });
82
119
  req.end();
83
120
  });
84
121
 
@@ -123,7 +160,9 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
123
160
  raw = await readFile(portFile, "utf8");
124
161
  try {
125
162
  mtimeMs = (await stat(portFile)).mtimeMs;
126
- } catch {}
163
+ } catch {
164
+ // The file was readable a moment ago; without an mtime it is simply treated as fresh.
165
+ }
127
166
  } catch (e) {
128
167
  // EPERM/EACCES is common under sandboxes; remember it and fall back to network probing rather than failing the whole discovery.
129
168
  const code = errnoCode(e);
@@ -172,13 +211,9 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
172
211
  const ordered = liveCandidates.length > 0 ? liveCandidates : unique;
173
212
 
174
213
  // Some browsers disable the /json/version endpoint when remote debugging is toggled via chrome://inspect rather than a launch flag, hence the DevToolsActivePort fallback.
175
- let lastErr: Result<CdpEndpoint, CdpError> | null = null;
176
- for (const c of ordered) {
177
- const ready = await waitForPort(c.port);
178
- if (!ready.success) {
179
- lastErr = err(ready.error);
180
- continue;
181
- }
214
+ const ready = await firstReadyPort(ordered);
215
+ if (ready.success) {
216
+ const c = ready.data;
182
217
  const liveUrl = await queryLiveWsUrl(c.port);
183
218
  return ok({ wsUrl: liveUrl ?? `ws://127.0.0.1:${c.port}${c.path}`, userDataDir: c.userDataDir });
184
219
  }
@@ -189,7 +224,7 @@ export const discoverEndpoint = async (): Promise<Result<CdpEndpoint, CdpError>>
189
224
  if (liveUrl) return ok({ wsUrl: liveUrl });
190
225
  }
191
226
 
192
- return lastErr ?? err(cdpError("discovery_failed", "no live DevTools endpoint among discovered candidates"));
227
+ return err(ready.error);
193
228
  };
194
229
 
195
230
  export const discoverWsUrl = async (): Promise<Result<string, CdpError>> => {
package/src/cdp/events.ts CHANGED
@@ -39,6 +39,11 @@ const ConsoleArg = Type.Object(
39
39
 
40
40
  export const EVENTS = {
41
41
  "Inspector.detached": ev(Anything),
42
+ // Browser-level notice that a session ended; carries the session id in params rather than on the envelope.
43
+ "Target.detachedFromTarget": ev(
44
+ Type.Object({ sessionId: Type.String(), targetId: Type.Optional(Type.String()) }, open),
45
+ ),
46
+ "Page.javascriptDialogClosed": ev(Anything),
42
47
 
43
48
  "Page.javascriptDialogOpening": ev(
44
49
  Type.Object(
@@ -158,13 +163,13 @@ export const decodeEvent = <E extends CdpEventName>(
158
163
  name: E,
159
164
  raw: unknown,
160
165
  ): Result<EventParamsOf<E>, CdpError> => {
166
+ // `name` is a key of EVENTS, so the lookup cannot miss.
161
167
  const spec = EVENTS[name];
162
- if (spec === undefined) {
163
- return err(cdpError("invalid_response", "unknown CDP event", name));
164
- }
165
168
  if (!spec.validate.Check(raw)) {
166
169
  const why = spec.validate.Errors(raw).map((e) => e.message).join("; ");
167
170
  return err(cdpError("invalid_response", why, name));
168
171
  }
172
+ // Guarded assertion: same generic-key correlation limit as decodeResult in cdp/commands.ts.
173
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- sanctioned decoder, see scripts/boundary-allowlist.ts
169
174
  return ok(raw as EventParamsOf<E>);
170
175
  };
@@ -1,37 +1,40 @@
1
+ import { Type, type Static } from "typebox";
1
2
  import { decodeEvent, type EventParamsOf } from "./events";
2
- import { createRecordStore } from "./record-store";
3
-
4
- export type NetworkRecord = {
5
- requestId: string;
6
- method: string;
7
- url: string;
8
- status?: number;
9
- statusText?: string;
10
- type: string;
11
- mimeType?: string;
12
- requestStartedMs: number;
13
- durationMs?: number;
14
- requestBodySize: number;
15
- responseBodySize?: number;
16
- failed?: boolean;
17
- errorText?: string;
18
- body?: string | null;
19
- };
3
+ import { createRecordStore, type DrainPage } from "./record-store";
4
+
5
+ // Only the buffer itself may update a record in place as follow-up events arrive; everything it hands out is readonly.
6
+ // The one declaration of a network record: the buffer produces it and browser_network_requests validates
7
+ // replayed details against it, so the two cannot drift.
8
+ export const NetworkRecordSchema = Type.Object({
9
+ requestId: Type.String(),
10
+ method: Type.String(),
11
+ url: Type.String(),
12
+ status: Type.Optional(Type.Number()),
13
+ statusText: Type.Optional(Type.String()),
14
+ type: Type.String(),
15
+ mimeType: Type.Optional(Type.String()),
16
+ requestStartedMs: Type.Number(),
17
+ durationMs: Type.Optional(Type.Number()),
18
+ requestBodySize: Type.Number(),
19
+ responseBodySize: Type.Optional(Type.Number()),
20
+ failed: Type.Optional(Type.Boolean()),
21
+ errorText: Type.Optional(Type.String()),
22
+ body: Type.Optional(Type.Union([Type.String(), Type.Null()])),
23
+ });
24
+
25
+ type MutableNetworkRecord = Static<typeof NetworkRecordSchema>;
26
+ export type NetworkRecord = Readonly<MutableNetworkRecord>;
20
27
 
21
28
  export type NetworkFilter = {
22
- urlPattern?: string;
23
- methodFilter?: string[];
24
- statusFilter?: { min?: number; max?: number };
25
- resourceTypes?: string[];
26
- sinceMs?: number;
27
- limit?: number;
29
+ readonly urlPattern?: string;
30
+ readonly methodFilter?: ReadonlyArray<string>;
31
+ readonly statusFilter?: { readonly min?: number; readonly max?: number };
32
+ readonly resourceTypes?: ReadonlyArray<string>;
33
+ readonly sinceMs?: number;
34
+ readonly limit?: number;
28
35
  };
29
36
 
30
- export type DrainResult = {
31
- readonly records: NetworkRecord[];
32
- readonly total: number;
33
- readonly bufferOverflowed: boolean;
34
- };
37
+ export type DrainResult = DrainPage<NetworkRecord>;
35
38
 
36
39
  export type NetworkBuffer = {
37
40
  ingestRequestWillBeSent(p: unknown): void;
@@ -47,7 +50,9 @@ const compileUrlMatcher = (pattern: string): ((url: string) => boolean) => {
47
50
  try {
48
51
  const re = new RegExp(pattern.slice(1, -1));
49
52
  return (url) => re.test(url);
50
- } catch {}
53
+ } catch {
54
+ // Not a valid regex: fall through and match the pattern as a literal substring.
55
+ }
51
56
  }
52
57
  return (url) => url.includes(pattern);
53
58
  };
@@ -55,13 +60,13 @@ const compileUrlMatcher = (pattern: string): ((url: string) => boolean) => {
55
60
  type UpdateEvent = "Network.responseReceived" | "Network.loadingFinished" | "Network.loadingFailed";
56
61
 
57
62
  export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
58
- const records = createRecordStore<string, NetworkRecord>(capacity);
63
+ const records = createRecordStore<string, MutableNetworkRecord>(capacity);
59
64
 
60
65
  // All three follow-up events say the same thing first: decode, find the request they belong to, give up quietly if it is gone.
61
66
  const update = <E extends UpdateEvent>(
62
67
  method: E,
63
68
  p: unknown,
64
- apply: (r: NetworkRecord, params: EventParamsOf<E>) => void,
69
+ apply: (r: MutableNetworkRecord, params: EventParamsOf<E>) => void,
65
70
  ): void => {
66
71
  const decoded = decodeEvent(method, p);
67
72
  if (!decoded.success) return;
@@ -80,6 +85,7 @@ export const createNetworkBuffer = (capacity = 500): NetworkBuffer => {
80
85
  const url = params?.request.url;
81
86
  const method = params?.request.method;
82
87
  if (!id || !url || !method) return;
88
+ // Delete before set: the store evicts by insertion order, so a request id that is re-sent (redirects reuse it) must move to the newest slot rather than keep its old age.
83
89
  records.delete(id);
84
90
  const postData = params.request.postData;
85
91
  records.set(id, {
@@ -8,7 +8,8 @@ export type OwnershipRegistry = {
8
8
  harnessWindow(): string | undefined;
9
9
  setHarnessWindowId(windowId: number | undefined): void;
10
10
  harnessWindowId(): number | undefined;
11
- onChange(cb: () => void): void;
11
+ // Subscribe; the returned function unsubscribes. Several subscribers may be registered at once.
12
+ onChange(cb: () => void): () => void;
12
13
  };
13
14
 
14
15
  export const createOwnershipRegistry = (
@@ -21,10 +22,11 @@ export const createOwnershipRegistry = (
21
22
  const owned = new Set<string>(initial?.ownedTargetIds ?? []);
22
23
  let harnessWindow: string | undefined = initial?.harnessWindowTargetId;
23
24
  let harnessWindowId: number | undefined = initial?.harnessWindowId;
24
- let listener: (() => void) | null = null;
25
+ const listeners = new Set<() => void>();
25
26
 
26
27
  const notify = (): void => {
27
- if (listener) listener();
28
+ // Snapshot so a listener that unsubscribes (or subscribes) mid-notify does not disturb the iteration.
29
+ for (const cb of [...listeners]) cb();
28
30
  };
29
31
 
30
32
  return {
@@ -67,7 +69,23 @@ export const createOwnershipRegistry = (
67
69
  return harnessWindowId;
68
70
  },
69
71
  onChange(cb) {
70
- listener = cb;
72
+ listeners.add(cb);
73
+ return () => {
74
+ listeners.delete(cb);
75
+ };
71
76
  },
72
77
  };
73
78
  };
79
+
80
+ // Drops owned targets (and the harness window) that no longer exist in the browser. Returns the owned ids that survived.
81
+ export const reconcileOwnership = (
82
+ ownership: OwnershipRegistry,
83
+ liveTargetIds: ReadonlySet<string>,
84
+ ): ReadonlyArray<string> => {
85
+ const owned = ownership.list();
86
+ const survivors = owned.filter((id) => liveTargetIds.has(id));
87
+ if (survivors.length !== owned.length) ownership.replaceAll(survivors);
88
+ const hw = ownership.harnessWindow();
89
+ if (hw !== undefined && !liveTargetIds.has(hw)) ownership.setHarnessWindow(undefined);
90
+ return survivors;
91
+ };
@@ -1,5 +1,5 @@
1
1
  export type DrainPage<T> = {
2
- readonly records: T[];
2
+ readonly records: ReadonlyArray<T>;
3
3
  readonly total: number;
4
4
  readonly bufferOverflowed: boolean;
5
5
  };
@@ -10,7 +10,7 @@ export type RecordStore<K, T> = {
10
10
  delete(key: K): void;
11
11
  values(): IterableIterator<T>;
12
12
  // Takes the overflow flag as it pages, so each drain reports only the loss since the previous one.
13
- page(matched: T[], limit: number | undefined): DrainPage<T>;
13
+ page(matched: ReadonlyArray<T>, limit: number | undefined): DrainPage<T>;
14
14
  clear(): void;
15
15
  };
16
16