1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
|
import { type Result, err, ok } from "../util/result";
import { type CdpError, cdpError } from "./errors";
import type { DialogInfo } from "./types";
import { isInternalUrl } from "./types";
import type { CdpTransport } from "./transport";
export type CdpSession = {
attachFirstPage(): Promise<Result<{ readonly targetId: string; readonly sessionId: string }, CdpError>>;
switchTo(targetId: string): Promise<Result<void, CdpError>>;
current(): { readonly sessionId: string; readonly targetId: string } | null;
call(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
callOnTarget(method: string, params: Record<string, unknown>, sessionId: string, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
callBrowser(method: string, params?: Record<string, unknown>, opts?: { timeoutMs?: number }): Promise<Result<unknown, CdpError>>;
takeDialog(): DialogInfo | null;
drainPageInfoInvalidations(): boolean;
};
export const createCdpSession = (transport: CdpTransport): CdpSession => {
let sessionId: string | null = null;
let targetId: string | null = null;
let dialog: DialogInfo | null = null;
let pageInfoDirty = false;
let activeConsumer: Promise<void> = Promise.resolve();
const consumeEvents = async (): Promise<void> => {
for await (const ev of transport.events()) {
if (ev.method === "Page.javascriptDialogOpening") {
const params = ev.params as Partial<DialogInfo> | undefined;
dialog = {
type: (params?.type as DialogInfo["type"]) ?? "alert",
message: params?.message ?? "",
...(params?.defaultPrompt !== undefined ? { defaultPrompt: params.defaultPrompt } : {}),
};
continue;
}
// Page.javascriptDialogClosed is intentionally NOT cleared here —
// the dialog stays in the buffer until takeDialog() is called.
// This prevents fast dismiss flows from dropping a dialog the agent
// was about to read. (Fix for spec §7 predictability bug #2.)
if (ev.method === "Page.frameNavigated" || ev.method === "Page.loadEventFired") {
pageInfoDirty = true;
}
}
};
const restartConsumer = (): void => {
activeConsumer = activeConsumer.then(() => consumeEvents()).catch((e: unknown) => {
// The .then() chain calls consumeEvents() which iterates the transport's
// events() AsyncIterable. The iterable resolves cleanly on close (returns
// {done:true}); any rejection here is an unexpected bug in the event
// handler, not a normal termination. Surface it on stderr so it's not lost.
console.warn("[pi-browser-harness] CDP event consumer crashed:", e);
});
};
restartConsumer();
transport.onClose(() => {
sessionId = null;
targetId = null;
pageInfoDirty = false;
// Do NOT clear `dialog` here — same rationale as inside consumeEvents:
// the agent may have a pending takeDialog() call that should still see it.
restartConsumer();
});
// TODO(perf): the four enable calls are sequential here for predictability.
// Switching to Promise.all over a single WS pipelines the round-trips and
// saves ~3× on tab-switch latency. Defer until session.ts has tests.
const enableDomains = async (sid: string): Promise<void> => {
for (const d of ["Page", "DOM", "Runtime", "Network"]) {
await transport.request(`${d}.enable`, {}, { sessionId: sid });
}
};
// CDP response shapes are documented in chromedevtools.github.io but not
// available as TypeScript types. We cast `as` from `unknown` only in this
// file (the CDP boundary). Each cast is paired with the CDP method that
// produced the response. Adding runtime guards for every shape would be
// noise — Chrome's protocol is stable enough that a wrong cast surfaces
// as a clear Error in normal use.
return {
async attachFirstPage() {
const targets = await transport.request("Target.getTargets", {}, { sessionId: null });
if (!targets.success) return targets;
const data = targets.data as { targetInfos: ReadonlyArray<{ targetId: string; type: string; url: string }> };
let pages = data.targetInfos.filter((t) => t.type === "page" && !isInternalUrl(t.url));
if (pages.length === 0) {
const created = await transport.request("Target.createTarget", { url: "about:blank" }, { sessionId: null });
if (!created.success) return created;
const c = created.data as { targetId: string };
pages = [{ targetId: c.targetId, type: "page", url: "about:blank" }];
}
const first = pages[0];
if (!first) return err(cdpError("invalid_response", "no page targets after creation"));
const attached = await transport.request("Target.attachToTarget", { targetId: first.targetId, flatten: true }, { sessionId: null });
if (!attached.success) return attached;
const a = attached.data as { sessionId: string };
sessionId = a.sessionId;
targetId = first.targetId;
await enableDomains(a.sessionId);
return ok({ targetId: first.targetId, sessionId: a.sessionId });
},
async switchTo(tid) {
const activated = await transport.request("Target.activateTarget", { targetId: tid }, { sessionId: null });
if (!activated.success) return activated;
const attached = await transport.request("Target.attachToTarget", { targetId: tid, flatten: true }, { sessionId: null });
if (!attached.success) return attached;
const a = attached.data as { sessionId: string };
sessionId = a.sessionId;
targetId = tid;
pageInfoDirty = true;
await enableDomains(a.sessionId);
return ok(undefined);
},
current() {
return sessionId && targetId ? { sessionId, targetId } : null;
},
call(method, params = {}, opts = {}) {
return transport.request(method, params, { sessionId, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
},
callOnTarget(method, params, sid, opts = {}) {
return transport.request(method, params, { sessionId: sid, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
},
callBrowser(method, params = {}, opts = {}) {
return transport.request(method, params, { sessionId: null, ...(opts.timeoutMs !== undefined ? { timeoutMs: opts.timeoutMs } : {}) });
},
takeDialog() {
const d = dialog;
dialog = null;
return d;
},
drainPageInfoInvalidations() {
const dirty = pageInfoDirty;
pageInfoDirty = false;
return dirty;
},
};
};
|