@volter/browser-cdp 0.1.48 → 0.1.49
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/dist/chromium-face.d.ts +24 -0
- package/dist/chromium-face.d.ts.map +1 -0
- package/dist/chromium-face.js +144 -0
- package/dist/chromium-face.js.map +1 -0
- package/dist/preview-browser-host.d.ts +42 -0
- package/dist/preview-browser-host.d.ts.map +1 -0
- package/dist/preview-browser-host.js +270 -0
- package/dist/preview-browser-host.js.map +1 -0
- package/dist/preview-cdp-endpoint.d.ts +84 -0
- package/dist/preview-cdp-endpoint.d.ts.map +1 -0
- package/dist/preview-cdp-endpoint.js +153 -0
- package/dist/preview-cdp-endpoint.js.map +1 -0
- package/dist/sandbox-surface.d.ts +2 -0
- package/dist/sandbox-surface.d.ts.map +1 -0
- package/dist/sandbox-surface.js +27 -0
- package/dist/sandbox-surface.js.map +1 -0
- package/dist/tab-chromium.d.ts +51 -0
- package/dist/tab-chromium.d.ts.map +1 -0
- package/dist/tab-chromium.js +113 -0
- package/dist/tab-chromium.js.map +1 -0
- package/package.json +6 -2
- package/src/chromium-face.ts +0 -130
- package/src/preview-browser-host.ts +0 -273
- package/src/preview-cdp-endpoint.ts +0 -206
- package/src/sandbox-surface.ts +0 -27
- package/src/tab-chromium.ts +0 -143
- package/tsconfig.build.json +0 -5
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
import type { DispatcherOptions, ProtocolPeer } from "@volter/almostcdp/core";
|
|
2
|
-
import { BrowserSandboxBridge, type BrowserSandboxBridgeOptions } from "@volter/browser-runtime/browser-sandbox-bridge.js";
|
|
3
|
-
import { SANDBOX_CHANNEL } from "@volter/browser-runtime/sandbox-protocol.js";
|
|
4
|
-
import { MessagePortTransport } from "@volter/almostcdp/message-port";
|
|
5
|
-
|
|
6
|
-
type BrowserContextHost = NonNullable<DispatcherOptions["contextHost"]>;
|
|
7
|
-
type BrowserPageHost = NonNullable<DispatcherOptions["pageHost"]>;
|
|
8
|
-
type SharedBridgeOptions = Omit<BrowserSandboxBridgeOptions, "preview" | "origin" | "nonce" | "port" | "onReady" | "onPreviewLoad">;
|
|
9
|
-
|
|
10
|
-
export interface PreviewBrowserHostOptions {
|
|
11
|
-
/** Where page frames are mounted; the embedding decides how they are shown. */
|
|
12
|
-
mount: HTMLElement;
|
|
13
|
-
/** The deployment's sandbox origin: the default context's pages live on it. */
|
|
14
|
-
sandboxOrigin: string;
|
|
15
|
-
/** The sandbox bootstrap document every page frame loads. */
|
|
16
|
-
sandboxPath?: string;
|
|
17
|
-
/**
|
|
18
|
-
* The origin a context's label is served at, where the deployment's sandbox
|
|
19
|
-
* has its own zone of hosts. By default the label is a subdomain of the
|
|
20
|
-
* sandbox host, which a loopback name (`c-1.localhost`) or a zone apex serves.
|
|
21
|
-
*/
|
|
22
|
-
contextOrigin?(label: string): string;
|
|
23
|
-
bridge: SharedBridgeOptions;
|
|
24
|
-
/** Registers a created page's surface with the endpoint; resolves once the endpoint lists it. */
|
|
25
|
-
attachSurface(peer: ProtocolPeer, id: string, signal: AbortSignal): Promise<void>;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export interface PreviewBrowserHost {
|
|
29
|
-
readonly contextHost: BrowserContextHost;
|
|
30
|
-
readonly pageHost: BrowserPageHost;
|
|
31
|
-
dispose(): Promise<void>;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
interface Page {
|
|
35
|
-
frame: HTMLIFrameElement;
|
|
36
|
-
bridge: BrowserSandboxBridge;
|
|
37
|
-
contextId?: string;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const CLEAR_TIMEOUT_MS = 15_000;
|
|
41
|
-
/** The page's record of the context origins it made, in its own storage. */
|
|
42
|
-
const ORIGINS_RECORD = "browser-substrate.context-origins";
|
|
43
|
-
/** A context origin in use is held under this lock by the tab using it. */
|
|
44
|
-
const ORIGIN_LOCK = "browser-substrate.context-origin:";
|
|
45
|
-
/** Emptied origins kept for the next contexts; past this, one is emptied for good. */
|
|
46
|
-
const SPARE_ORIGINS = 4;
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Browser contexts and pages for the tab's CDP endpoint. A context is its own
|
|
50
|
-
* origin, a sibling of the sandbox origin, so its storage and cookies are the
|
|
51
|
-
* browser's own partition; its pages are sandbox frames on that origin, each
|
|
52
|
-
* lent to the endpoint like the preview. A closed context's origin is emptied
|
|
53
|
-
* and given to the next context, keeping its service worker: a fresh origin
|
|
54
|
-
* per context registered a worker per context, and a browser with a debugger
|
|
55
|
-
* attached keeps every one of them running (a suite run reached about 985
|
|
56
|
-
* renderers in ten minutes). Every context shares the tab's container: the
|
|
57
|
-
* servers a page reaches are the project's, as they are for a real browser's
|
|
58
|
-
* contexts.
|
|
59
|
-
*/
|
|
60
|
-
export function createPreviewBrowserHost(options: PreviewBrowserHostOptions): PreviewBrowserHost {
|
|
61
|
-
const sandboxPath = options.sandboxPath ?? "/sandbox.html";
|
|
62
|
-
const contexts = new Map<string, { origin: string }>();
|
|
63
|
-
/** Closed contexts' origins, each usable once its emptying has succeeded. */
|
|
64
|
-
const emptied: { origin: string; ready: Promise<boolean> }[] = [];
|
|
65
|
-
const held = new Map<string, () => void>();
|
|
66
|
-
|
|
67
|
-
// A tab that ends before its contexts are closed leaves their origins, each
|
|
68
|
-
// with its worker registered and its storage, and no page knew them again
|
|
69
|
-
// (a morning's work left eight). The page records every origin it makes and
|
|
70
|
-
// holds a lock on each while it lives: a recorded origin whose lock is free
|
|
71
|
-
// belongs to no tab, and is taken for the next context or emptied for good.
|
|
72
|
-
const recorded = (): string[] => {
|
|
73
|
-
try {
|
|
74
|
-
const value: unknown = JSON.parse(localStorage.getItem(ORIGINS_RECORD) ?? "[]");
|
|
75
|
-
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
76
|
-
} catch { return []; }
|
|
77
|
-
};
|
|
78
|
-
const record = (origins: string[]): void => {
|
|
79
|
-
try { localStorage.setItem(ORIGINS_RECORD, JSON.stringify([...new Set(origins)])); } catch { /* storage refused: the origin is still usable */ }
|
|
80
|
-
};
|
|
81
|
-
/** Takes an origin's lock, or with `ifFree` only if no tab holds it; resolves whether it is held. */
|
|
82
|
-
const hold = (origin: string, ifFree: boolean): Promise<boolean> => new Promise((acquired) => {
|
|
83
|
-
if (!("locks" in navigator)) { acquired(!ifFree); return; }
|
|
84
|
-
void navigator.locks.request(`${ORIGIN_LOCK}${origin}`, { ifAvailable: ifFree }, (lock) => {
|
|
85
|
-
if (!lock) { acquired(false); return undefined; }
|
|
86
|
-
acquired(true);
|
|
87
|
-
return new Promise<void>((release) => held.set(origin, release));
|
|
88
|
-
});
|
|
89
|
-
});
|
|
90
|
-
/** Empties an origin for good: its worker too. It leaves the record once that succeeded. */
|
|
91
|
-
const retire = (origin: string): Promise<void> => clearOrigin(origin).then(
|
|
92
|
-
() => { record(recorded().filter((item) => item !== origin)); },
|
|
93
|
-
() => undefined,
|
|
94
|
-
).finally(() => { held.get(origin)?.(); held.delete(origin); });
|
|
95
|
-
/** A closed context's origin, emptied for the next context, or for good past the spares. */
|
|
96
|
-
const release = (origin: string): void => {
|
|
97
|
-
if (emptied.length >= SPARE_ORIGINS) { void retire(origin); return; }
|
|
98
|
-
emptied.push({ origin, ready: clearOrigin(origin, "reuse").then(() => true, () => false) });
|
|
99
|
-
};
|
|
100
|
-
let adopting = Promise.resolve();
|
|
101
|
-
for (const origin of recorded()) {
|
|
102
|
-
// One at a time: each is a frame of its origin loaded to empty it.
|
|
103
|
-
adopting = adopting.then(async () => { if (await hold(origin, true)) release(origin); });
|
|
104
|
-
}
|
|
105
|
-
const pages = new Map<string, Page>();
|
|
106
|
-
|
|
107
|
-
const contextOrigin = (): string => {
|
|
108
|
-
const label = `c-${[...crypto.getRandomValues(new Uint8Array(8))].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
109
|
-
if (options.contextOrigin) return new URL(options.contextOrigin(label)).origin;
|
|
110
|
-
const base = new URL(options.sandboxOrigin);
|
|
111
|
-
if (/^[0-9.]+$/u.test(base.hostname) || base.hostname.startsWith("[")) {
|
|
112
|
-
throw new Error(`Isolated browser contexts need a sandbox host name; ${base.hostname} is an address.`);
|
|
113
|
-
}
|
|
114
|
-
// workers.dev serves one level of names: a context host under the
|
|
115
|
-
// sandbox's own name does not exist there.
|
|
116
|
-
if (base.hostname.endsWith(".workers.dev")) {
|
|
117
|
-
throw new Error(`Isolated browser contexts need the sandbox on a zone of its own; ${base.hostname} is a workers.dev host.`);
|
|
118
|
-
}
|
|
119
|
-
return `${base.protocol}//${label}.${base.host}`;
|
|
120
|
-
};
|
|
121
|
-
|
|
122
|
-
const frameFor = (origin: string, query: Record<string, string>): HTMLIFrameElement => {
|
|
123
|
-
const frame = document.createElement("iframe");
|
|
124
|
-
frame.style.cssText = "border:0;width:1280px;height:720px;display:block";
|
|
125
|
-
const url = new URL(sandboxPath, origin);
|
|
126
|
-
for (const [key, value] of Object.entries(query)) url.searchParams.set(key, value);
|
|
127
|
-
frame.src = url.href;
|
|
128
|
-
options.mount.append(frame);
|
|
129
|
-
return frame;
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
const closePage = async (id: string): Promise<void> => {
|
|
133
|
-
const page = pages.get(id);
|
|
134
|
-
if (!page) return;
|
|
135
|
-
pages.delete(id);
|
|
136
|
-
page.bridge.dispose();
|
|
137
|
-
page.frame.remove();
|
|
138
|
-
};
|
|
139
|
-
|
|
140
|
-
/** Empties an origin's storage from a frame of that origin, then removes the frame. */
|
|
141
|
-
const clearOrigin = async (origin: string, clear: "1" | "reuse" = "1"): Promise<void> => {
|
|
142
|
-
const nonce = crypto.randomUUID();
|
|
143
|
-
const frame = frameFor(origin, { parentOrigin: location.origin, nonce, clear });
|
|
144
|
-
try {
|
|
145
|
-
await new Promise<void>((resolve, reject) => {
|
|
146
|
-
const timer = setTimeout(() => { cleanup(); reject(new Error(`Clearing ${origin} timed out.`)); }, CLEAR_TIMEOUT_MS);
|
|
147
|
-
const receive = (event: MessageEvent): void => {
|
|
148
|
-
if (event.origin !== origin || event.source !== frame.contentWindow) return;
|
|
149
|
-
const data = event.data as { channel?: unknown; nonce?: unknown; type?: unknown; payload?: { message?: string } };
|
|
150
|
-
if (data?.channel !== SANDBOX_CHANNEL || data.nonce !== nonce) return;
|
|
151
|
-
if (data.type === "cleared") { cleanup(); resolve(); }
|
|
152
|
-
else if (data.type === "error") { cleanup(); reject(new Error(data.payload?.message ?? `Clearing ${origin} failed.`)); }
|
|
153
|
-
};
|
|
154
|
-
const cleanup = (): void => { clearTimeout(timer); window.removeEventListener("message", receive); };
|
|
155
|
-
window.addEventListener("message", receive);
|
|
156
|
-
});
|
|
157
|
-
} finally {
|
|
158
|
-
frame.remove();
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
const contextHost: BrowserContextHost = {
|
|
163
|
-
async createContext(context, signal) {
|
|
164
|
-
if (context.proxyServer !== undefined || context.proxyBypassList !== undefined) {
|
|
165
|
-
throw new Error("A context's network is the tab's; a per-context proxy is unavailable.");
|
|
166
|
-
}
|
|
167
|
-
if (context.originsWithUniversalNetworkAccess?.length) {
|
|
168
|
-
throw new Error("Universal network access for named origins is unavailable in the tab.");
|
|
169
|
-
}
|
|
170
|
-
signal.throwIfAborted();
|
|
171
|
-
let origin: string | undefined;
|
|
172
|
-
while (!origin && emptied.length > 0) {
|
|
173
|
-
const candidate = emptied.shift()!;
|
|
174
|
-
if (await candidate.ready) origin = candidate.origin;
|
|
175
|
-
// Not emptied: it stays recorded, and a later page tries again.
|
|
176
|
-
else { held.get(candidate.origin)?.(); held.delete(candidate.origin); }
|
|
177
|
-
signal.throwIfAborted();
|
|
178
|
-
}
|
|
179
|
-
if (!origin) {
|
|
180
|
-
origin = contextOrigin();
|
|
181
|
-
await hold(origin, false);
|
|
182
|
-
record([...recorded(), origin]);
|
|
183
|
-
}
|
|
184
|
-
contexts.set(context.id, { origin });
|
|
185
|
-
},
|
|
186
|
-
async disposeContext(id) {
|
|
187
|
-
const context = contexts.get(id);
|
|
188
|
-
if (!context) return;
|
|
189
|
-
await Promise.all([...pages].filter(([, page]) => page.contextId === id).map(([pageId]) => closePage(pageId)));
|
|
190
|
-
contexts.delete(id);
|
|
191
|
-
// The context is closed once its pages are, so emptying its storage
|
|
192
|
-
// (a frame of the origin loaded to do it) goes on after the answer, as
|
|
193
|
-
// Chromium's closing does not wait on disk: awaited, it held
|
|
194
|
-
// Playwright's context teardown past a test's timeout. The next context
|
|
195
|
-
// waits for it instead; an origin that could not be emptied is never
|
|
196
|
-
// given out again.
|
|
197
|
-
release(context.origin);
|
|
198
|
-
},
|
|
199
|
-
};
|
|
200
|
-
|
|
201
|
-
const pageHost: BrowserPageHost = {
|
|
202
|
-
async createPage(page, signal) {
|
|
203
|
-
const context = page.browserContextId === undefined ? undefined : contexts.get(page.browserContextId);
|
|
204
|
-
if (page.browserContextId !== undefined && !context) throw new Error("No browser context with given id");
|
|
205
|
-
const origin = context?.origin ?? new URL(options.sandboxOrigin).origin;
|
|
206
|
-
const nonce = crypto.randomUUID();
|
|
207
|
-
// A page opens blank, is attached, and is then taken to the guest
|
|
208
|
-
// server it names under that port's prefix, the way a client's own
|
|
209
|
-
// first navigation takes it there, as Chromium's page starts blank and
|
|
210
|
-
// then loads its URL. Opened on the guest's port and navigated before
|
|
211
|
-
// attaching, a page never attached (a window.open's popup,
|
|
212
|
-
// Target.createTarget with a URL): creation hung.
|
|
213
|
-
const opening = guestAddress(page.url);
|
|
214
|
-
const port = 0;
|
|
215
|
-
const frame = frameFor(origin, { parentOrigin: location.origin, nonce, port: String(port) });
|
|
216
|
-
const bridge = new BrowserSandboxBridge({ ...options.bridge, preview: frame, origin, nonce, port });
|
|
217
|
-
pages.set(page.id, { frame, bridge, ...(page.browserContextId !== undefined ? { contextId: page.browserContextId } : {}) });
|
|
218
|
-
const abort = (): void => { void closePage(page.id); };
|
|
219
|
-
signal.addEventListener("abort", abort, { once: true });
|
|
220
|
-
try {
|
|
221
|
-
// The surface registers under the target id before creation resolves.
|
|
222
|
-
const channel = await bridge.connectProtocol(page.id);
|
|
223
|
-
const transport = new MessagePortTransport(channel.port);
|
|
224
|
-
void channel.closed.then((reason) => transport.close(1001, reason));
|
|
225
|
-
// A page is handed over once its first document has loaded, as
|
|
226
|
-
// Chromium's is committed before `Target.createTarget` answers: a
|
|
227
|
-
// client's first navigation otherwise interrupts the frame's own.
|
|
228
|
-
await bridge.initialPreviewReady;
|
|
229
|
-
signal.throwIfAborted();
|
|
230
|
-
await options.attachSurface(transport, page.id, signal);
|
|
231
|
-
// Attached first, then taken to its URL: a navigation while the
|
|
232
|
-
// surface attached kept it from ever attaching.
|
|
233
|
-
if (opening) await bridge.navigate(`${origin}/__virtual__/${opening.port}${opening.path}`);
|
|
234
|
-
} catch (cause) {
|
|
235
|
-
await closePage(page.id);
|
|
236
|
-
throw cause;
|
|
237
|
-
} finally {
|
|
238
|
-
signal.removeEventListener("abort", abort);
|
|
239
|
-
}
|
|
240
|
-
},
|
|
241
|
-
closePage,
|
|
242
|
-
};
|
|
243
|
-
|
|
244
|
-
return {
|
|
245
|
-
contextHost,
|
|
246
|
-
pageHost,
|
|
247
|
-
async dispose() {
|
|
248
|
-
await Promise.allSettled([...pages.keys()].map(closePage));
|
|
249
|
-
const origins = [...contexts.values()].map((context) => context.origin);
|
|
250
|
-
contexts.clear();
|
|
251
|
-
await adopting;
|
|
252
|
-
for (const entry of emptied.splice(0)) if (await entry.ready) origins.push(entry.origin);
|
|
253
|
-
await Promise.allSettled(origins.map(retire));
|
|
254
|
-
},
|
|
255
|
-
};
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/**
|
|
259
|
-
* The guest server a page is opened on: `about:blank` opens the default port,
|
|
260
|
-
* a loopback URL opens its own port and path. Anything else is not served in
|
|
261
|
-
* the tab.
|
|
262
|
-
*/
|
|
263
|
-
function guestAddress(url: string): { port: number; path: string } | undefined {
|
|
264
|
-
if (url === "" || url === "about:blank") return undefined;
|
|
265
|
-
let parsed: URL;
|
|
266
|
-
try { parsed = new URL(url); } catch { throw new Error(`Invalid page URL: ${url}`); }
|
|
267
|
-
const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]";
|
|
268
|
-
if ((parsed.protocol !== "http:" && parsed.protocol !== "https:") || !loopback) {
|
|
269
|
-
throw new Error(`A page opens on a guest server of the tab; ${url} is not one.`);
|
|
270
|
-
}
|
|
271
|
-
const port = Number(parsed.port || (parsed.protocol === "https:" ? 443 : 80));
|
|
272
|
-
return { port, path: `${parsed.pathname}${parsed.search}${parsed.hash}` };
|
|
273
|
-
}
|
|
@@ -1,206 +0,0 @@
|
|
|
1
|
-
import { createSocketEndpoint, type ByteSocket, type SocketEndpoint } from "@volter/almostcdp/socket";
|
|
2
|
-
import type { ProtocolPeer } from "@volter/almostcdp/core";
|
|
3
|
-
import { MessagePortTransport } from "@volter/almostcdp/message-port";
|
|
4
|
-
import type { BrowserSandboxBridge } from "@volter/browser-runtime/browser-sandbox-bridge.js";
|
|
5
|
-
import type { BrowserServerBridge } from "@volter/browser-runtime/browser-http.js";
|
|
6
|
-
import type { BrowserVirtualWebSocketRegistry, VirtualWebSocketHandlers } from "@volter/browser-runtime/browser-virtual-websocket.js";
|
|
7
|
-
import { createPreviewBrowserHost, type PreviewBrowserHostOptions } from "./preview-browser-host.js";
|
|
8
|
-
|
|
9
|
-
/** A loopback listener of the tab: WALI's `listenTcp`, a Node container's `listenNet`. */
|
|
10
|
-
export type PreviewCdpListener = (port: number, accept: (socket: ByteSocket) => void) => () => void;
|
|
11
|
-
|
|
12
|
-
export interface PreviewCdpPublication {
|
|
13
|
-
readonly port: number;
|
|
14
|
-
/** What a client inside the tab passes to `connectOverCDP`. */
|
|
15
|
-
readonly url: string;
|
|
16
|
-
/** A browser-level client over a transport the host decodes, such as a launch pipe. */
|
|
17
|
-
attachClient(peer: ProtocolPeer): void;
|
|
18
|
-
close(): Promise<void>;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const REATTACH_DELAY_MS = 250;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* The preview as a Chrome DevTools Protocol endpoint on a loopback port of the
|
|
25
|
-
* tab. AlmostCDP serves discovery and the DevTools WebSockets on each accepted
|
|
26
|
-
* connection; the preview's surface is lent through the authenticated sandbox
|
|
27
|
-
* bridge and lent again whenever the sandbox document is replaced, which a
|
|
28
|
-
* client sees as its page target closing and a new one appearing.
|
|
29
|
-
*/
|
|
30
|
-
export function publishPreviewCdp(options: {
|
|
31
|
-
/**
|
|
32
|
-
* The preview's bridge, which lends the preview as a page target. Without
|
|
33
|
-
* one the endpoint still serves contexts and pages a client creates, which
|
|
34
|
-
* is all a launched browser needs.
|
|
35
|
-
*/
|
|
36
|
-
bridge?: BrowserSandboxBridge;
|
|
37
|
-
port: number;
|
|
38
|
-
listen: PreviewCdpListener;
|
|
39
|
-
targetId?: string;
|
|
40
|
-
/**
|
|
41
|
-
* Browser contexts and pages beyond the preview: `Target.createBrowserContext`
|
|
42
|
-
* and `Target.createTarget` open sandbox frames here. Without it they refuse.
|
|
43
|
-
*/
|
|
44
|
-
browser?: Omit<PreviewBrowserHostOptions, "attachSurface">;
|
|
45
|
-
/**
|
|
46
|
-
* The same endpoint as a server in the tab's port table, so a page of the
|
|
47
|
-
* tab reaches it and a live tunnel of that port carries it off the machine.
|
|
48
|
-
*/
|
|
49
|
-
portTable?: { port: number; ports: Pick<BrowserServerBridge, "registerServer" | "unregisterServer">; webSockets: BrowserVirtualWebSocketRegistry };
|
|
50
|
-
/**
|
|
51
|
-
* The endpoint's discovery documents alone (`/json/version`, `/json/list`)
|
|
52
|
-
* in the tab's port table, where a program's `fetch` to a loopback port
|
|
53
|
-
* goes: Puppeteer reads `browserURL` so. The WebSocket they name stays on
|
|
54
|
-
* the listener, which the tab's programs reach and a page of the tab does
|
|
55
|
-
* not, so a page learns the address and cannot drive the browser.
|
|
56
|
-
*/
|
|
57
|
-
discoveryTable?: { port: number; ports: Pick<BrowserServerBridge, "registerServer" | "unregisterServer"> };
|
|
58
|
-
/**
|
|
59
|
-
* Where a download a page makes is written: the path Playwright names in
|
|
60
|
-
* its client's filesystem, which is the container's. Without it the
|
|
61
|
-
* endpoint refuses download policy by name.
|
|
62
|
-
*/
|
|
63
|
-
downloads?: { write(path: string, bytes: Uint8Array): Promise<void> };
|
|
64
|
-
/**
|
|
65
|
-
* Reads a file a client names by path for a page's file input
|
|
66
|
-
* (DOM.setFileInputFiles): the client's filesystem, the container's. The
|
|
67
|
-
* page is given those files' bytes alone. Without it the endpoint refuses
|
|
68
|
-
* file paths by name.
|
|
69
|
-
*/
|
|
70
|
-
files?: {
|
|
71
|
-
read(path: string): Promise<{ data: Uint8Array; name?: string; type?: string; lastModified?: number }>;
|
|
72
|
-
/** A directory's files by their paths relative to it (a folder upload); undefined for a file. */
|
|
73
|
-
list?(path: string): Promise<string[] | undefined>;
|
|
74
|
-
};
|
|
75
|
-
}): PreviewCdpPublication {
|
|
76
|
-
const { bridge, port } = options;
|
|
77
|
-
let endpoint!: SocketEndpoint;
|
|
78
|
-
const host = options.browser ? createPreviewBrowserHost({
|
|
79
|
-
...options.browser,
|
|
80
|
-
attachSurface: (peer, id, signal) => attachAndAwait(endpoint, peer, id, signal),
|
|
81
|
-
}) : undefined;
|
|
82
|
-
endpoint = createSocketEndpoint({
|
|
83
|
-
address: `127.0.0.1:${port}`,
|
|
84
|
-
...(host ? { contextHost: host.contextHost, pageHost: host.pageHost } : {}),
|
|
85
|
-
...(options.downloads ? { downloadHost: { write: (_context: string | undefined, path: string, bytes: Uint8Array) => options.downloads!.write(path, bytes) } } : {}),
|
|
86
|
-
...(options.files ? { fileHost: options.files } : {}),
|
|
87
|
-
});
|
|
88
|
-
const unlisten = options.listen(port, (socket) => endpoint.serve(socket));
|
|
89
|
-
const unpublish = options.portTable ? servePortTable(endpoint, options.portTable)
|
|
90
|
-
: options.discoveryTable ? serveDiscovery(endpoint, options.discoveryTable) : undefined;
|
|
91
|
-
const targetId = options.targetId ?? "preview";
|
|
92
|
-
let closed = false;
|
|
93
|
-
|
|
94
|
-
if (bridge) void (async () => {
|
|
95
|
-
while (!closed) {
|
|
96
|
-
try {
|
|
97
|
-
const channel = await bridge.connectProtocol(targetId);
|
|
98
|
-
const transport = new MessagePortTransport(channel.port);
|
|
99
|
-
void channel.closed.then((reason) => transport.close(1001, reason));
|
|
100
|
-
if (closed) { transport.close(1001, "Preview CDP publication closed"); return; }
|
|
101
|
-
const lost = new Promise<void>((resolve) => transport.on("close", resolve));
|
|
102
|
-
endpoint.attachSurface(transport);
|
|
103
|
-
await lost;
|
|
104
|
-
} catch (error) {
|
|
105
|
-
// A disposed bridge never lends its preview again; every other failure
|
|
106
|
-
// (not yet ready, a dropped transfer, a replaced document) is retried.
|
|
107
|
-
if (error instanceof Error && error.message === "Browser sandbox bridge is disposed.") return;
|
|
108
|
-
}
|
|
109
|
-
if (!closed) await new Promise((resolve) => setTimeout(resolve, REATTACH_DELAY_MS));
|
|
110
|
-
}
|
|
111
|
-
})();
|
|
112
|
-
|
|
113
|
-
return {
|
|
114
|
-
port,
|
|
115
|
-
url: `http://127.0.0.1:${port}`,
|
|
116
|
-
attachClient: (peer) => endpoint.attachClient(peer),
|
|
117
|
-
async close() {
|
|
118
|
-
if (closed) return;
|
|
119
|
-
closed = true;
|
|
120
|
-
unlisten();
|
|
121
|
-
unpublish?.();
|
|
122
|
-
await endpoint.close();
|
|
123
|
-
await host?.dispose();
|
|
124
|
-
},
|
|
125
|
-
};
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Lends a page surface and resolves once the endpoint lists it under its target id. */
|
|
129
|
-
async function attachAndAwait(endpoint: SocketEndpoint, peer: ProtocolPeer, id: string, signal: AbortSignal): Promise<void> {
|
|
130
|
-
let lost = false;
|
|
131
|
-
peer.on("close", () => { lost = true; });
|
|
132
|
-
endpoint.attachSurface(peer);
|
|
133
|
-
while (!endpoint.targets().some((target) => target.id === id)) {
|
|
134
|
-
signal.throwIfAborted();
|
|
135
|
-
if (lost) throw new Error("The page surface closed before it registered.");
|
|
136
|
-
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Discovery as the port's HTTP server and DevTools WebSockets as its socket
|
|
142
|
-
* endpoint. Discovery names the address the request came in on: a loopback
|
|
143
|
-
* host plainly, any other host (a live tunnel's) behind TLS.
|
|
144
|
-
*/
|
|
145
|
-
/** The discovery documents as a GET-only server of the port table. */
|
|
146
|
-
function serveDiscovery(
|
|
147
|
-
endpoint: SocketEndpoint,
|
|
148
|
-
table: { port: number; ports: Pick<BrowserServerBridge, "registerServer" | "unregisterServer"> },
|
|
149
|
-
): () => void {
|
|
150
|
-
const encoder = new TextEncoder();
|
|
151
|
-
table.ports.registerServer({
|
|
152
|
-
listening: true,
|
|
153
|
-
address: () => ({ port: table.port, address: "0.0.0.0", family: "IPv4" }),
|
|
154
|
-
async handleRequest(method, url, headers) {
|
|
155
|
-
if (method !== "GET" && method !== "HEAD") return { statusCode: 405, headers: { allow: "GET, HEAD" } };
|
|
156
|
-
const host = headers.host ?? headers.Host ?? `127.0.0.1:${table.port}`;
|
|
157
|
-
const loopback = /^(localhost|127\.0\.0\.1|\[::1\])(:|$)/u.test(host);
|
|
158
|
-
const { status, body } = endpoint.discovery(new URL(url, "http://localhost").pathname, loopback ? host : `wss://${host}`);
|
|
159
|
-
return { statusCode: status, headers: { "content-type": "application/json; charset=UTF-8" }, ...(method === "GET" ? { body: encoder.encode(JSON.stringify(body)) } : {}) };
|
|
160
|
-
},
|
|
161
|
-
}, table.port);
|
|
162
|
-
return () => table.ports.unregisterServer(table.port);
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function servePortTable(
|
|
166
|
-
endpoint: SocketEndpoint,
|
|
167
|
-
table: { port: number; ports: Pick<BrowserServerBridge, "registerServer" | "unregisterServer">; webSockets: BrowserVirtualWebSocketRegistry },
|
|
168
|
-
): () => void {
|
|
169
|
-
const unregisterDiscovery = serveDiscovery(endpoint, table);
|
|
170
|
-
const unregisterSockets = table.webSockets.register(table.port, {
|
|
171
|
-
open(path: string, _protocols: readonly string[], handlers: VirtualWebSocketHandlers) {
|
|
172
|
-
const messageListeners: ((data: { toString(): string }) => void)[] = [];
|
|
173
|
-
const closeListeners: (() => void)[] = [];
|
|
174
|
-
let state = 1;
|
|
175
|
-
const finish = (): void => {
|
|
176
|
-
if (state === 3) return;
|
|
177
|
-
state = 3;
|
|
178
|
-
for (const listener of closeListeners) listener();
|
|
179
|
-
};
|
|
180
|
-
const peer: ProtocolPeer = {
|
|
181
|
-
get readyState() { return state; },
|
|
182
|
-
send: (data) => { if (state === 1) handlers.message({ kind: "text", text: data }); },
|
|
183
|
-
close: (code, reason) => { if (state !== 1) return; handlers.close(code, reason, true); finish(); },
|
|
184
|
-
on: ((event: "message" | "close", listener: ((data: { toString(): string }) => void) | (() => void)) => {
|
|
185
|
-
if (event === "message") messageListeners.push(listener as (data: { toString(): string }) => void);
|
|
186
|
-
else closeListeners.push(listener as () => void);
|
|
187
|
-
}) as ProtocolPeer["on"],
|
|
188
|
-
};
|
|
189
|
-
queueMicrotask(() => {
|
|
190
|
-
handlers.open("");
|
|
191
|
-
endpoint.connect(peer, path);
|
|
192
|
-
});
|
|
193
|
-
return {
|
|
194
|
-
send(data) {
|
|
195
|
-
if (data.kind !== "text") { peer.close(1003, "CDP messages are text"); return; }
|
|
196
|
-
for (const listener of messageListeners) listener(data.text);
|
|
197
|
-
},
|
|
198
|
-
close(code = 1000, reason = "") { handlers.close(code, reason, true); finish(); },
|
|
199
|
-
};
|
|
200
|
-
},
|
|
201
|
-
});
|
|
202
|
-
return () => {
|
|
203
|
-
unregisterSockets();
|
|
204
|
-
unregisterDiscovery();
|
|
205
|
-
};
|
|
206
|
-
}
|
package/src/sandbox-surface.ts
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { connectDomSurface } from "@volter/almostcdp/dom";
|
|
2
|
-
import { MessagePortTransport } from "@volter/almostcdp/message-port";
|
|
3
|
-
import { createCaptureAdapter } from "@volter/almostcdp/capture-stream";
|
|
4
|
-
import { createDomAccessibility } from "@volter/almostcdp/accessibility";
|
|
5
|
-
import { registerPreviewProtocolSurface } from "@volter/browser-runtime/sandbox-preview-inspector.js";
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* The sandbox page lends its preview as an AlmostCDP DOM surface. Imported by
|
|
9
|
-
* the sandbox bootstrap of an embedding that publishes the tab's CDP
|
|
10
|
-
* endpoint; the runtime itself lends no protocol.
|
|
11
|
-
*/
|
|
12
|
-
registerPreviewProtocolSurface((port, id, hooks) => connectDomSurface({
|
|
13
|
-
id,
|
|
14
|
-
target: hooks.window,
|
|
15
|
-
validateDocument: hooks.validateDocument,
|
|
16
|
-
urls: hooks.urls,
|
|
17
|
-
transport: new MessagePortTransport(port),
|
|
18
|
-
navigation: hooks.navigation,
|
|
19
|
-
// The sandbox bridge carries the preview's document requests through the
|
|
20
|
-
// surface's network door, so a navigation answers with its outcome.
|
|
21
|
-
carriedDocuments: true,
|
|
22
|
-
documentStart: hooks.documentStart,
|
|
23
|
-
capture: createCaptureAdapter((params) => hooks.capture(params), "dom-rasterized"),
|
|
24
|
-
// The accessibility tree read from the page's DOM, as a client's
|
|
25
|
-
// Accessibility.getFullAXTree asks (chrome-devtools-mcp's snapshot).
|
|
26
|
-
accessibility: createDomAccessibility(),
|
|
27
|
-
}));
|
package/src/tab-chromium.ts
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
import type { BrowserRuntime } from "@volter/browser-runtime";
|
|
2
|
-
import type { BrowserSandboxBridge } from "@volter/browser-runtime/browser-sandbox-bridge.js";
|
|
3
|
-
import { CHROMIUM_FACE_PROGRAMS, chromiumFaceRunner, placeChromiumFace, playwrightChromiumRevisions } from "./chromium-face.js";
|
|
4
|
-
import type { PreviewBrowserHostOptions } from "./preview-browser-host.js";
|
|
5
|
-
import { publishPreviewCdp, type PreviewCdpListener, type PreviewCdpPublication } from "./preview-cdp-endpoint.js";
|
|
6
|
-
|
|
7
|
-
/** The port a client in the tab passes to `connectOverCDP`, as Chromium's default. */
|
|
8
|
-
export const TAB_CDP_PORT = 9222;
|
|
9
|
-
|
|
10
|
-
export interface TabChromiumOptions {
|
|
11
|
-
runtime: BrowserRuntime;
|
|
12
|
-
/** The preview's bridge, to publish the preview itself as a page target; a launched browser does not need it. */
|
|
13
|
-
bridge?: BrowserSandboxBridge;
|
|
14
|
-
/** A loopback listener of the tab, such as a Node container's `listenNet`. */
|
|
15
|
-
listen: PreviewCdpListener;
|
|
16
|
-
/** Where browser contexts and pages the endpoint creates are opened. */
|
|
17
|
-
browser: Omit<PreviewBrowserHostOptions, "attachSurface">;
|
|
18
|
-
/** The home Playwright's registry looks under: the `HOME` the tab's programs run with. */
|
|
19
|
-
home: string;
|
|
20
|
-
/** The project roots whose installed playwright-core pins are read for the revisions to place. */
|
|
21
|
-
roots: readonly string[];
|
|
22
|
-
/** The same endpoint in the tab's port table, so a page of the tab or a shared port reaches it. */
|
|
23
|
-
portTable?: Parameters<typeof publishPreviewCdp>[0]["portTable"];
|
|
24
|
-
/** The discovery documents alone in the tab's port table, under the endpoint's port. */
|
|
25
|
-
discoveryTable?: Parameters<typeof publishPreviewCdp>[0]["discoveryTable"];
|
|
26
|
-
port?: number;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export interface TabChromium {
|
|
30
|
-
readonly publication: PreviewCdpPublication;
|
|
31
|
-
dispose(): Promise<void>;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* A Chromium for the tab's own Playwright: the preview published as a CDP
|
|
36
|
-
* endpoint, and the face registered under Chromium's executable names and
|
|
37
|
-
* placed where each installed playwright-core's registry looks, so a project's
|
|
38
|
-
* unchanged `playwright` launches it over its debugging pipe. A page's
|
|
39
|
-
* download is written where the Playwright client in the container reads it.
|
|
40
|
-
*/
|
|
41
|
-
export function installTabChromium(options: TabChromiumOptions): TabChromium {
|
|
42
|
-
const { runtime } = options;
|
|
43
|
-
const fs = runtime.filesystem;
|
|
44
|
-
const publication = publishPreviewCdp({
|
|
45
|
-
...(options.bridge ? { bridge: options.bridge } : {}),
|
|
46
|
-
port: options.port ?? TAB_CDP_PORT,
|
|
47
|
-
listen: options.listen,
|
|
48
|
-
browser: options.browser,
|
|
49
|
-
...(options.portTable ? { portTable: options.portTable } : {}),
|
|
50
|
-
...(options.discoveryTable ? { discoveryTable: options.discoveryTable } : {}),
|
|
51
|
-
downloads: {
|
|
52
|
-
async write(path, bytes) {
|
|
53
|
-
fs.mkdirSync(path.slice(0, path.lastIndexOf("/")) || "/", { recursive: true });
|
|
54
|
-
fs.writeFileSync(path, bytes);
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
files: {
|
|
58
|
-
async read(path) {
|
|
59
|
-
const stat = fs.statSync(path);
|
|
60
|
-
if (!stat.isFile()) throw new Error(`Not a file: ${path}`);
|
|
61
|
-
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
62
|
-
return { data: await fs.readFile(path), name, type: mimeTypeOf(name), lastModified: Math.round(stat.mtimeMs) };
|
|
63
|
-
},
|
|
64
|
-
async list(path) {
|
|
65
|
-
if (!fs.statSync(path).isDirectory()) return undefined;
|
|
66
|
-
const files: string[] = [];
|
|
67
|
-
const walk = (directory: string, prefix: string): void => {
|
|
68
|
-
for (const name of fs.readdirSync(directory).map(String).sort()) {
|
|
69
|
-
const entry = `${directory}/${name}`;
|
|
70
|
-
if (fs.statSync(entry).isDirectory()) walk(entry, `${prefix}${name}/`);
|
|
71
|
-
else files.push(`${prefix}${name}`);
|
|
72
|
-
}
|
|
73
|
-
};
|
|
74
|
-
walk(path.replace(/\/+$/u, ""), "");
|
|
75
|
-
return files;
|
|
76
|
-
},
|
|
77
|
-
},
|
|
78
|
-
});
|
|
79
|
-
const face = chromiumFaceRunner((peer) => publication.attachClient(peer));
|
|
80
|
-
for (const name of CHROMIUM_FACE_PROGRAMS) runtime.registerProgram(name, face, { descriptors: true });
|
|
81
|
-
const stopPlacing = keepFacePlaced(runtime, options.home, options.roots);
|
|
82
|
-
return {
|
|
83
|
-
publication,
|
|
84
|
-
async dispose() {
|
|
85
|
-
stopPlacing();
|
|
86
|
-
await publication.close();
|
|
87
|
-
},
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* Where the tab has no Chromium to give (a page that runs its project in-page,
|
|
93
|
-
* with no sandbox to lend frames from), the face is still placed, and
|
|
94
|
-
* launching it says why and where the Chromium is. Without it a project's
|
|
95
|
-
* Playwright reports a missing executable and advises `npx playwright
|
|
96
|
-
* install`, a download that can never run in a tab.
|
|
97
|
-
*/
|
|
98
|
-
export function installUnavailableTabChromium(options: {
|
|
99
|
-
runtime: BrowserRuntime;
|
|
100
|
-
home: string;
|
|
101
|
-
roots: readonly string[];
|
|
102
|
-
/** One line naming what to do instead. */
|
|
103
|
-
reason: string;
|
|
104
|
-
}): () => void {
|
|
105
|
-
const message = `chromium: ${options.reason}\n`;
|
|
106
|
-
const refuse = async (_argv: readonly string[], run: { onStderr?: (text: string) => void }) => {
|
|
107
|
-
run.onStderr?.(message);
|
|
108
|
-
return { stdout: "", stderr: message, exitCode: 1 };
|
|
109
|
-
};
|
|
110
|
-
// Declared like the real face: Playwright launches it with its pipe on fds
|
|
111
|
-
// 3 and 4, and a program refusing those would be refused first, generically.
|
|
112
|
-
for (const name of CHROMIUM_FACE_PROGRAMS) options.runtime.registerProgram(name, refuse, { descriptors: true });
|
|
113
|
-
return keepFacePlaced(options.runtime, options.home, options.roots);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Places the face for every playwright-core revision the roots install, again whenever that set changes. */
|
|
117
|
-
function keepFacePlaced(runtime: BrowserRuntime, home: string, roots: readonly string[]): () => void {
|
|
118
|
-
const fs = runtime.filesystem;
|
|
119
|
-
let placed = "";
|
|
120
|
-
const place = async (): Promise<void> => {
|
|
121
|
-
const revisions = (await playwrightChromiumRevisions(fs, roots)).sort().join(",");
|
|
122
|
-
if (revisions === placed) return;
|
|
123
|
-
placed = revisions;
|
|
124
|
-
// Playwright looks under os.homedir(), which is HOME.
|
|
125
|
-
await placeChromiumFace(fs, `${home}/.cache/ms-playwright`, revisions ? revisions.split(",") : []);
|
|
126
|
-
};
|
|
127
|
-
// Playwright can arrive with the image or later; its pin is one file, read when it changes.
|
|
128
|
-
const placing = setInterval(() => { void place().catch((cause: unknown) => console.error("Chromium face placement failed:", cause)); }, 2_000);
|
|
129
|
-
void place().catch((cause: unknown) => console.error("Chromium face placement failed:", cause));
|
|
130
|
-
return () => clearInterval(placing);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** A file's type as Chromium gives a picked file one: by its extension. */
|
|
134
|
-
function mimeTypeOf(name: string): string {
|
|
135
|
-
const extension = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
|
136
|
-
return ({
|
|
137
|
-
png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", svg: "image/svg+xml",
|
|
138
|
-
ico: "image/x-icon", bmp: "image/bmp", avif: "image/avif", txt: "text/plain", html: "text/html", htm: "text/html",
|
|
139
|
-
css: "text/css", js: "text/javascript", mjs: "text/javascript", json: "application/json", xml: "text/xml", csv: "text/csv",
|
|
140
|
-
pdf: "application/pdf", zip: "application/zip", gz: "application/gzip", wasm: "application/wasm", mp3: "audio/mpeg",
|
|
141
|
-
wav: "audio/wav", ogg: "audio/ogg", mp4: "video/mp4", webm: "video/webm", woff: "font/woff", woff2: "font/woff2",
|
|
142
|
-
} as Record<string, string>)[extension] ?? "";
|
|
143
|
-
}
|