1// Electron allows one debugger per webContents; share the manager's attachment.
2
3export interface CdpRelayTarget {
4 readonly send: (
5 method: string,
6 params: Record<string, unknown>,
7 sessionId: string | undefined,
8 /** The extra session that sent it, such as a viewer's; the main connection has none. */
9 owner?: string,
10 ) => Promise<unknown>;
11 readonly sessionDetached?: (owner: string) => void;
12 /** Whether a page session, by owner as `send` names it, gets the page's screencast frames. */
13 readonly receivesScreencast: (owner: string | undefined) => boolean;
14 /** The page's own CDP target id; Playwright keys the main frame by it. */
15 readonly targetId: () => Promise<string>;
16 readonly url: () => string;
17 readonly title: () => string;
18 readonly userAgent: () => string;
19 /** The desktop and its server share a disk; download paths need no translation. */
20 readonly setDownloadDirectory: (directory: string | null) => void;
21}
22
23export interface CdpRelayConnection {
24 readonly receive: (raw: string) => void;
25 readonly event: (method: string, params: unknown, sessionId: string | undefined) => void;
26}
27
28interface CdpCommand {
29 readonly id: number;
30 readonly method: string;
31 readonly params?: Record<string, unknown>;
32 readonly sessionId?: string;
33}
34
35const isCommand = (value: unknown): value is CdpCommand =>
36 typeof value === "object" &&
37 value !== null &&
38 typeof (value as { id?: unknown }).id === "number" &&
39 typeof (value as { method?: unknown }).method === "string";
40
41// CDP values are plain JSON data, so their serialized size can be bounded before
42// JSON.stringify allocates it. A string counts at its length unless it holds a
43// character JSON may escape, which can take six code units.
44const MAX_CDP_PAYLOAD_SIZE = 64 * 1024 * 1024;
45const MAY_ESCAPE = /[^\x20\x21\x23-\x5b\x5d-\ud7ff\ue000-\uffff]/;
46const serializedSize = (text: string) =>
47 (MAY_ESCAPE.test(text) ? text.length * 6 : text.length) + 3;
48
49const fitsPayloadBudget = (message: unknown) => {
50 let remaining = MAX_CDP_PAYLOAD_SIZE;
51 const visit = (value: unknown): boolean => {
52 remaining -= typeof value === "string" ? serializedSize(value) : 26;
53 if (remaining < 0) return false;
54 if (typeof value !== "object" || value === null) return true;
55 if (Array.isArray(value)) return value.every(visit);
56 return Object.entries(value).every(([key, item]) => {
57 remaining -= serializedSize(key);
58 return visit(item);
59 });
60 };
61 return visit(message);
62};
63
64const BROWSER_CONTEXT_ID = "t3-preview";
65const ANNOUNCE = Symbol("announce");
66const PAGE_SESSION_ID = "t3-preview-page";
67
68const ACKNOWLEDGED = new Set([
69 "Target.setDiscoverTargets",
70 "Browser.grantPermissions",
71 "Browser.resetPermissions",
72 "Browser.cancelDownload",
73]);
74
75export function createCdpRelayConnection(
76 target: CdpRelayTarget,
77 write: (message: string) => void,
78 close: () => void,
79): CdpRelayConnection {
80 let closed = false;
81 const send = (message: Record<string, unknown>) => {
82 if (closed) return;
83 if (fitsPayloadBudget(message)) {
84 write(JSON.stringify(message));
85 return;
86 }
87 // An error reply fails only that command. A dropped event would leave
88 // Playwright out of step with the page, so the relay closes instead.
89 if (typeof message["id"] === "number") {
90 write(
91 JSON.stringify({
92 id: message["id"],
93 error: { code: -32000, message: "CDP payload too large" },
94 ...(message["sessionId"] === undefined ? {} : { sessionId: message["sessionId"] }),
95 }),
96 );
97 return;
98 }
99 closed = true;
100 close();
101 };
102 let attached = false;
103 let downloadEventsEnabled = false;
104 /** Relay-created page sessions share one real debugger; child frame sessions retain their own ids. */
105 const sessions = new Map<string, "browser" | "page">([[PAGE_SESSION_ID, "page"]]);
106 let sessionSequence = 0;
107 const newSession = (kind: "browser" | "page") => {
108 const sessionId = `t3-preview-${kind}-${++sessionSequence}`;
109 sessions.set(sessionId, kind);
110 return sessionId;
111 };
112 const targetInfo = async () => ({
113 targetId: await target.targetId(),
114 type: "page",
115 title: target.title(),
116 url: target.url(),
117 attached: true,
118 canAccessOpener: false,
119 browserContextId: BROWSER_CONTEXT_ID,
120 });
121
122 const browserCommand = async (command: CdpCommand): Promise<unknown> => {
123 if (ACKNOWLEDGED.has(command.method)) return {};
124 switch (command.method) {
125 case "Browser.setDownloadBehavior": {
126 const directory = command.params?.["downloadPath"];
127 const allowed = command.params?.["behavior"] !== "deny" && typeof directory === "string";
128 // Browser-scoped behavior targets Electron's app session; preview paths belong to will-download.
129 const result = await target.send(
130 "Page.setDownloadBehavior",
131 { behavior: command.params?.["behavior"] === "deny" ? "deny" : "default" },
132 undefined,
133 );
134 target.setDownloadDirectory(allowed ? directory : null);
135 downloadEventsEnabled = command.params?.["eventsEnabled"] === true;
136 return result;
137 }
138 case "Browser.getVersion":
139 return {
140 protocolVersion: "1.3",
141 product: "Electron",
142 revision: "",
143 userAgent: target.userAgent(),
144 jsVersion: "",
145 };
146 case "Target.getTargetInfo":
147 return {
148 targetInfo: {
149 targetId: "browser",
150 type: "browser",
151 title: "",
152 url: "",
153 attached: true,
154 canAccessOpener: false,
155 },
156 };
157 case "Target.getBrowserContexts":
158 return { browserContextIds: [] };
159 case "Target.getTargets":
160 return { targetInfos: [await targetInfo()] };
161 case "Target.attachToBrowserTarget":
162 return { sessionId: newSession("browser") };
163 case "Target.attachToTarget": {
164 const targetId = command.params?.["targetId"];
165 if (targetId !== (await target.targetId()))
166 throw new Error("Only this preview tab's page can be attached.");
167 return { sessionId: newSession("page") };
168 }
169 case "Target.detachFromTarget": {
170 const sessionId = command.params?.["sessionId"];
171 if (typeof sessionId === "string" && sessionId !== PAGE_SESSION_ID) {
172 sessions.delete(sessionId);
173 target.sessionDetached?.(sessionId);
174 }
175 return {};
176 }
177 case "Target.setAutoAttach": {
178 if (attached) return {};
179 attached = true;
180 const info = await targetInfo();
181 return {
182 [ANNOUNCE]: {
183 method: "Target.attachedToTarget",
184 params: { sessionId: PAGE_SESSION_ID, targetInfo: info, waitingForDebugger: false },
185 },
186 };
187 }
188 default:
189 throw new Error(`Not supported for a desktop preview tab: ${command.method}`);
190 }
191 };
192
193 let runtimeReset: Promise<unknown> | undefined;
194
195 const pageCommand = (command: CdpCommand): Promise<unknown> => {
196 const child = sessions.get(command.sessionId!) === "page" ? undefined : command.sessionId;
197 const owner =
198 child === undefined && command.sessionId !== PAGE_SESSION_ID ? command.sessionId : undefined;
199 // Child frames/workers pause on auto-attach until resumed; only the main page can skip this command.
200 if (command.method === "Runtime.runIfWaitingForDebugger" && child === undefined) {
201 return Promise.resolve({});
202 }
203 // Playwright asks for this on every page; the host emulates focus only while it is in use.
204 if (command.method === "Emulation.setFocusEmulationEnabled") return Promise.resolve({});
205 // The shared debugger keeps Runtime enabled after an earlier connection, and a repeated
206 // enable reports no existing contexts; Playwright waits for them, so reset once per connection.
207 if (command.method === "Runtime.enable" && child === undefined) {
208 runtimeReset ??= target.send("Runtime.disable", {}, undefined, owner).catch(() => undefined);
209 return runtimeReset.then(() =>
210 target.send(command.method, command.params ?? {}, undefined, owner),
211 );
212 }
213 return target.send(command.method, command.params ?? {}, child, owner);
214 };
215
216 return {
217 receive: (raw) => {
218 if (closed) return;
219 let command: unknown;
220 try {
221 command = JSON.parse(raw);
222 } catch {
223 return;
224 }
225 if (!isCommand(command)) return;
226 const result =
227 command.sessionId === undefined || sessions.get(command.sessionId) === "browser"
228 ? browserCommand(command)
229 : pageCommand(command);
230 const route = command.sessionId === undefined ? {} : { sessionId: command.sessionId };
231 // Reply as commands finish so slow commands cannot block later replies.
232 void result.then(
233 (value) => {
234 const announce =
235 typeof value === "object" && value !== null && ANNOUNCE in value
236 ? (value as { [ANNOUNCE]: Record<string, unknown> })[ANNOUNCE]
237 : undefined;
238 send({ id: command.id, result: announce ? {} : (value ?? {}), ...route });
239 if (announce) send(announce);
240 },
241 (cause: unknown) =>
242 send({
243 id: command.id,
244 error: {
245 code: -32000,
246 message: cause instanceof Error ? cause.message : String(cause),
247 },
248 ...route,
249 }),
250 );
251 },
252 event: (method, params, sessionId) => {
253 if (!attached) return;
254 if (method.startsWith("Page.download")) method = method.replace("Page.", "Browser.");
255 // Download events are the browser's; Playwright listens on its root session.
256 if (method.startsWith("Browser.download")) {
257 if (downloadEventsEnabled) send({ method, params });
258 return;
259 }
260 // Electron reports the page's own events with an empty session id.
261 if (sessionId) {
262 send({ method, params, sessionId });
263 return;
264 }
265 for (const [id, kind] of sessions) {
266 if (kind !== "page") continue;
267 if (
268 method === "Page.screencastFrame" &&
269 !target.receivesScreencast(id === PAGE_SESSION_ID ? undefined : id)
270 )
271 continue;
272 send({ method, params, sessionId: id });
273 }
274 },
275 };
276}