1/**
2 * Presents one preview tab's debugger as a minimal CDP browser endpoint, so
3 * the environment server can drive a desktop `<webview>` with the same
4 * Playwright engine it uses for headless tabs.
5 *
6 * Only the tab's own page is reachable. Browser-level commands Playwright sends
7 * while connecting are answered here, and every page command goes to the tab's
8 * `webContents.debugger`. Electron allows one debugger client per webContents,
9 * so the relay shares the attach the preview manager already holds.
10 */
11
12export interface CdpRelayTarget {
13 /** Sends a command to the tab's page, or to a child session such as an iframe. */
14 readonly send: (
15 method: string,
16 params: Record<string, unknown>,
17 sessionId: string | undefined,
18 ) => Promise<unknown>;
19 /** The page's own CDP target id; Playwright keys the main frame by it. */
20 readonly targetId: () => Promise<string>;
21 readonly url: () => string;
22 readonly title: () => string;
23 readonly userAgent: () => string;
24 /**
25 * Where the server wants this tab's downloads, named by their CDP guid. The
26 * desktop app and the server it launched share a disk, so the server's
27 * Playwright reads the file where it asked for it.
28 */
29 readonly setDownloadDirectory: (directory: string | null) => void;
30}
31
32export interface CdpRelayConnection {
33 /** Feed one message from the server. */
34 readonly receive: (raw: string) => void;
35 /** Feed one debugger event from the tab. */
36 readonly event: (method: string, params: unknown, sessionId: string | undefined) => void;
37}
38
39interface CdpCommand {
40 readonly id: number;
41 readonly method: string;
42 readonly params?: Record<string, unknown>;
43 readonly sessionId?: string;
44}
45
46const isCommand = (value: unknown): value is CdpCommand =>
47 typeof value === "object" &&
48 value !== null &&
49 typeof (value as { id?: unknown }).id === "number" &&
50 typeof (value as { method?: unknown }).method === "string";
51
52const BROWSER_CONTEXT_ID = "t3-preview";
53/** Marks an event to send right after a command's reply. */
54const ANNOUNCE = Symbol("announce");
55/** The page's root session id, as Playwright sees it. */
56const PAGE_SESSION_ID = "t3-preview-page";
57
58/** Browser-level commands that only need acknowledging for a page the desktop owns. */
59const ACKNOWLEDGED = new Set([
60 "Target.setDiscoverTargets",
61 "Browser.grantPermissions",
62 "Browser.resetPermissions",
63 "Browser.cancelDownload",
64]);
65
66export function createCdpRelayConnection(
67 target: CdpRelayTarget,
68 write: (message: string) => void,
69): CdpRelayConnection {
70 const send = (message: Record<string, unknown>) => write(JSON.stringify(message));
71 let attached = false;
72 /**
73 * Sessions this relay invented. Extra page sessions (Playwright's
74 * `newCDPSession`) all reach the one real debugger session, so each gets the
75 * page's events; other ids are the page's own child sessions, such as iframes.
76 */
77 const sessions = new Map<string, "browser" | "page">([[PAGE_SESSION_ID, "page"]]);
78 let sessionSequence = 0;
79 const newSession = (kind: "browser" | "page") => {
80 const sessionId = `t3-preview-${kind}-${++sessionSequence}`;
81 sessions.set(sessionId, kind);
82 return sessionId;
83 };
84 const targetInfo = async () => ({
85 targetId: await target.targetId(),
86 type: "page",
87 title: target.title(),
88 url: target.url(),
89 attached: true,
90 canAccessOpener: false,
91 browserContextId: BROWSER_CONTEXT_ID,
92 });
93
94 const browserCommand = async (command: CdpCommand): Promise<unknown> => {
95 if (ACKNOWLEDGED.has(command.method)) return {};
96 switch (command.method) {
97 case "Browser.setDownloadBehavior": {
98 // The tab's debugger reports downloads as Chromium does, so the server
99 // sees them as its own. Only the directory needs the desktop's help.
100 const directory = command.params?.["downloadPath"];
101 const allowed = command.params?.["behavior"] !== "deny" && typeof directory === "string";
102 target.setDownloadDirectory(allowed ? directory : null);
103 const { browserContextId: _ignored, ...params } = command.params ?? {};
104 return target.send("Browser.setDownloadBehavior", params, undefined);
105 }
106 case "Browser.getVersion":
107 return {
108 protocolVersion: "1.3",
109 product: "Electron",
110 revision: "",
111 userAgent: target.userAgent(),
112 jsVersion: "",
113 };
114 case "Target.getTargetInfo":
115 return {
116 targetInfo: {
117 targetId: "browser",
118 type: "browser",
119 title: "",
120 url: "",
121 attached: true,
122 canAccessOpener: false,
123 },
124 };
125 case "Target.getBrowserContexts":
126 return { browserContextIds: [] };
127 case "Target.getTargets":
128 return { targetInfos: [await targetInfo()] };
129 case "Target.attachToBrowserTarget":
130 return { sessionId: newSession("browser") };
131 case "Target.attachToTarget": {
132 const targetId = command.params?.["targetId"];
133 if (targetId !== (await target.targetId()))
134 throw new Error("Only this preview tab's page can be attached.");
135 return { sessionId: newSession("page") };
136 }
137 case "Target.detachFromTarget": {
138 const sessionId = command.params?.["sessionId"];
139 if (typeof sessionId === "string" && sessionId !== PAGE_SESSION_ID)
140 sessions.delete(sessionId);
141 return {};
142 }
143 case "Target.setAutoAttach": {
144 if (attached) return {};
145 attached = true;
146 // The page is announced once, right after this reply, as Chromium does.
147 const info = await targetInfo();
148 return {
149 [ANNOUNCE]: {
150 method: "Target.attachedToTarget",
151 params: { sessionId: PAGE_SESSION_ID, targetInfo: info, waitingForDebugger: false },
152 },
153 };
154 }
155 default:
156 throw new Error(`Not supported for a desktop preview tab: ${command.method}`);
157 }
158 };
159
160 const pageCommand = (command: CdpCommand): Promise<unknown> => {
161 const child = sessions.get(command.sessionId!) === "page" ? undefined : command.sessionId;
162 // The page itself is already running, but its child frames are not:
163 // Playwright auto-attaches with waitForDebuggerOnStart, so Chromium pauses
164 // every new cross-site iframe and worker until this reaches it. Swallowing
165 // it left sign-in checks and captchas, which run in such frames, frozen.
166 if (command.method === "Runtime.runIfWaitingForDebugger" && child === undefined) {
167 return Promise.resolve({});
168 }
169 return target.send(command.method, command.params ?? {}, child);
170 };
171
172 return {
173 receive: (raw) => {
174 let command: unknown;
175 try {
176 command = JSON.parse(raw);
177 } catch {
178 return;
179 }
180 if (!isCommand(command)) return;
181 const result =
182 command.sessionId === undefined || sessions.get(command.sessionId) === "browser"
183 ? browserCommand(command)
184 : pageCommand(command);
185 const route = command.sessionId === undefined ? {} : { sessionId: command.sessionId };
186 // Replies leave as commands finish, as Chromium's do, so a slow command
187 // such as a screenshot never holds up the ones behind it.
188 void result.then(
189 (value) => {
190 const announce =
191 typeof value === "object" && value !== null && ANNOUNCE in value
192 ? (value as { [ANNOUNCE]: Record<string, unknown> })[ANNOUNCE]
193 : undefined;
194 send({ id: command.id, result: announce ? {} : (value ?? {}), ...route });
195 if (announce) send(announce);
196 },
197 (cause: unknown) =>
198 send({
199 id: command.id,
200 error: {
201 code: -32000,
202 message: cause instanceof Error ? cause.message : String(cause),
203 },
204 ...route,
205 }),
206 );
207 },
208 event: (method, params, sessionId) => {
209 if (!attached) return;
210 // Download events are the browser's; Playwright listens on its root session.
211 if (method.startsWith("Browser.download")) {
212 send({ method, params });
213 return;
214 }
215 // Electron reports the page's own events with an empty session id.
216 if (sessionId) {
217 send({ method, params, sessionId });
218 return;
219 }
220 for (const [id, kind] of sessions)
221 if (kind === "page") send({ method, params, sessionId: id });
222 },
223 };
224}