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.
- package/CHANGELOG.md +62 -0
- package/README.md +102 -1
- package/package.json +13 -7
- package/skills/pi-browser-harness/SKILL.md +84 -0
- package/src/cdp/commands.ts +204 -14
- package/src/cdp/console-buffer.ts +37 -23
- package/src/cdp/discovery.ts +72 -37
- package/src/cdp/events.ts +8 -3
- package/src/cdp/network-buffer.ts +38 -32
- package/src/cdp/ownership.ts +22 -4
- package/src/cdp/record-store.ts +2 -2
- package/src/cdp/session.ts +320 -217
- package/src/cdp/target-factory.ts +34 -15
- package/src/cdp/types.ts +17 -1
- package/src/client.ts +76 -43
- package/src/daemon/bridge.ts +115 -191
- package/src/daemon/connection.ts +323 -0
- package/src/daemon/index.ts +77 -48
- package/src/daemon/lines.ts +40 -0
- package/src/daemon/main.ts +12 -0
- package/src/daemon/protocol.ts +55 -25
- package/src/daemon/server.ts +100 -63
- package/src/daemon/socket.ts +10 -0
- package/src/daemon/spawn.ts +84 -27
- package/src/daemon/transport.ts +88 -52
- package/src/domains/ax-tree.ts +8 -8
- package/src/domains/cdp-call.ts +14 -7
- package/src/domains/click.ts +2 -7
- package/src/domains/concurrency.ts +50 -0
- package/src/domains/console.ts +20 -66
- package/src/domains/drag.ts +26 -28
- package/src/domains/element-call.ts +102 -68
- package/src/domains/files.ts +64 -11
- package/src/domains/form-batch.ts +13 -11
- package/src/domains/form.ts +79 -116
- package/src/domains/headers.ts +89 -0
- package/src/domains/history.ts +7 -7
- package/src/domains/io.ts +35 -0
- package/src/domains/isolated-tab.ts +6 -12
- package/src/domains/js.ts +68 -53
- package/src/domains/keyboard.ts +16 -18
- package/src/domains/navigate.ts +102 -69
- package/src/domains/network.ts +56 -98
- package/src/domains/page/fill.page.ts +139 -0
- package/src/domains/page/form.page.ts +61 -0
- package/src/domains/page/inject.ts +37 -0
- package/src/domains/page/keyboard.page.ts +16 -0
- package/src/domains/page/live-value.page.ts +10 -0
- package/src/domains/page/read-page.page.ts +51 -0
- package/src/domains/page/serp.page.ts +39 -0
- package/src/domains/page.ts +19 -58
- package/src/domains/poll.ts +42 -0
- package/src/domains/readpage/read-page.ts +48 -54
- package/src/domains/ref-resolve.ts +43 -29
- package/src/domains/ref-signature.ts +11 -0
- package/src/domains/render.ts +12 -23
- package/src/domains/screenshot-capture.ts +4 -2
- package/src/domains/screenshot.ts +13 -14
- package/src/domains/scroll.ts +4 -5
- package/src/domains/search/google-serp.ts +1 -1
- package/src/domains/search/web-search.ts +38 -34
- package/src/domains/setup.ts +0 -2
- package/src/domains/snapshot.ts +72 -77
- package/src/domains/tab-resolve.ts +36 -0
- package/src/domains/tabs.ts +19 -78
- package/src/domains/target.ts +1 -1
- package/src/domains/viewport.ts +0 -1
- package/src/index.ts +100 -39
- package/src/profile/bind.ts +45 -14
- package/src/profile/browser-process.ts +50 -12
- package/src/profile/command.ts +20 -12
- package/src/profile/launch.ts +13 -9
- package/src/profile/list.ts +9 -13
- package/src/profile/store.ts +2 -1
- package/src/prompt.ts +42 -117
- package/src/registry.ts +68 -4
- package/src/schemas/parse.ts +2 -2
- package/src/setup.ts +28 -19
- package/src/state.ts +33 -2
- package/src/util/debug.ts +7 -1
- package/src/util/guards.ts +3 -0
- package/src/util/mutex.ts +1 -1
- package/src/util/paths.ts +12 -1
- package/src/util/result.ts +8 -0
- package/src/util/sharp-shim.ts +18 -11
- package/src/util/sleep.ts +18 -0
- package/src/util/tool.ts +163 -114
- package/src/util/truncate.ts +1 -1
- package/tsconfig.json +2 -1
- 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
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
|
|
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":
|
package/src/cdp/discovery.ts
CHANGED
|
@@ -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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
const
|
|
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
|
|
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
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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,
|
|
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:
|
|
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, {
|
package/src/cdp/ownership.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
25
|
+
const listeners = new Set<() => void>();
|
|
25
26
|
|
|
26
27
|
const notify = (): void => {
|
|
27
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/src/cdp/record-store.ts
CHANGED
|
@@ -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
|
|
13
|
+
page(matched: ReadonlyArray<T>, limit: number | undefined): DrainPage<T>;
|
|
14
14
|
clear(): void;
|
|
15
15
|
};
|
|
16
16
|
|