@omercnet/paseo-shared-browser 0.3.1-next.72.1
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 +71 -0
- package/LICENSE +21 -0
- package/README.md +182 -0
- package/client/browser.tsx +2098 -0
- package/docs/images/shared-browser-compact.png +0 -0
- package/docs/images/shared-browser-wide.png +0 -0
- package/index.client.tsx +35 -0
- package/index.server.ts +118 -0
- package/package.json +78 -0
- package/paseo-plugin.json +10 -0
- package/scripts/prepare-dependencies.mjs +25 -0
- package/scripts/prepare-runtime.mjs +168 -0
- package/server/agent-browser-runtime.ts +970 -0
- package/server/browser-policy.ts +836 -0
- package/server/browser.ts +306 -0
- package/server/cdp.ts +265 -0
- package/server/electron.d.ts +1 -0
- package/server/mcp-entry.ts +194 -0
- package/server/runtime-owner.ts +122 -0
- package/server/runtime-protocol.ts +260 -0
- package/server/supervisor-client.ts +402 -0
- package/server/supervisor-entry.ts +9 -0
- package/server/supervisor.ts +1081 -0
- package/shared/browser.ts +364 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import type { RpcInput, RpcOutput } from "@getpaseo/plugin";
|
|
2
|
+
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { access } from "node:fs/promises";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import {
|
|
9
|
+
acquireControlRpc,
|
|
10
|
+
applyDevicePresetRpc,
|
|
11
|
+
attachBrowserRpc,
|
|
12
|
+
captureBrowserRpc,
|
|
13
|
+
detachBrowserRpc,
|
|
14
|
+
listOpenBrowserWorkspacesRpc,
|
|
15
|
+
navigateBrowserRpc,
|
|
16
|
+
releaseControlRpc,
|
|
17
|
+
resizeBrowserRpc,
|
|
18
|
+
sendBrowserInputRpc,
|
|
19
|
+
} from "../shared/browser";
|
|
20
|
+
import type { JsonValue } from "./runtime-protocol";
|
|
21
|
+
import { SupervisorClient } from "./supervisor-client";
|
|
22
|
+
import { resolveSupervisorPaths } from "./supervisor";
|
|
23
|
+
|
|
24
|
+
export { SessionManager, normalizeBrowserUrl } from "./browser-policy";
|
|
25
|
+
export type {
|
|
26
|
+
BrowserRuntimeClient,
|
|
27
|
+
SessionManagerOptions,
|
|
28
|
+
WorkspaceValidator,
|
|
29
|
+
} from "./browser-policy";
|
|
30
|
+
|
|
31
|
+
type AttachInput = RpcInput<typeof attachBrowserRpc>;
|
|
32
|
+
type AttachOutput = RpcOutput<typeof attachBrowserRpc>;
|
|
33
|
+
type DetachInput = RpcInput<typeof detachBrowserRpc>;
|
|
34
|
+
type DetachOutput = RpcOutput<typeof detachBrowserRpc>;
|
|
35
|
+
type CaptureInput = RpcInput<typeof captureBrowserRpc>;
|
|
36
|
+
type CaptureOutput = RpcOutput<typeof captureBrowserRpc>;
|
|
37
|
+
type AcquireControlInput = RpcInput<typeof acquireControlRpc>;
|
|
38
|
+
type AcquireControlOutput = RpcOutput<typeof acquireControlRpc>;
|
|
39
|
+
type ReleaseControlInput = RpcInput<typeof releaseControlRpc>;
|
|
40
|
+
type ReleaseControlOutput = RpcOutput<typeof releaseControlRpc>;
|
|
41
|
+
type ListOpenOutput = RpcOutput<typeof listOpenBrowserWorkspacesRpc>;
|
|
42
|
+
type NavigateInput = RpcInput<typeof navigateBrowserRpc>;
|
|
43
|
+
type NavigateOutput = RpcOutput<typeof navigateBrowserRpc>;
|
|
44
|
+
type ResizeInput = RpcInput<typeof resizeBrowserRpc>;
|
|
45
|
+
type ResizeOutput = RpcOutput<typeof resizeBrowserRpc>;
|
|
46
|
+
type ApplyDevicePresetInput = RpcInput<typeof applyDevicePresetRpc>;
|
|
47
|
+
type ApplyDevicePresetOutput = RpcOutput<typeof applyDevicePresetRpc>;
|
|
48
|
+
type SendInput = RpcInput<typeof sendBrowserInputRpc>;
|
|
49
|
+
type SendOutput = RpcOutput<typeof sendBrowserInputRpc>;
|
|
50
|
+
|
|
51
|
+
function paseoHome(): string {
|
|
52
|
+
return process.env.PASEO_HOME ?? join(homedir(), ".paseo");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class RemoteBrowserManager {
|
|
56
|
+
constructor(private readonly client: SupervisorClient) {}
|
|
57
|
+
|
|
58
|
+
async connect(): Promise<void> {
|
|
59
|
+
await this.client.connect();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
attach(workspaceId: string, viewerLabel: string): Promise<AttachOutput> {
|
|
63
|
+
return this.client.requestBrowser<AttachOutput>("attach", { workspaceId, viewerLabel });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
detach(viewerToken: string): Promise<DetachOutput> {
|
|
67
|
+
return this.client.requestBrowser<DetachOutput>("detach", { viewerToken });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
capture(
|
|
71
|
+
viewerToken: string,
|
|
72
|
+
quality: CaptureInput["quality"],
|
|
73
|
+
knownFrameId: string | null,
|
|
74
|
+
): Promise<CaptureOutput> {
|
|
75
|
+
return this.client.requestBrowser<CaptureOutput>("capture", {
|
|
76
|
+
viewerToken,
|
|
77
|
+
quality,
|
|
78
|
+
knownFrameId,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
acquireControl(viewerToken: string, takeover: boolean): Promise<AcquireControlOutput> {
|
|
83
|
+
return this.client.requestBrowser<AcquireControlOutput>("acquire-control", {
|
|
84
|
+
viewerToken,
|
|
85
|
+
takeover,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
releaseControl(viewerToken: string, controlToken: string): Promise<ReleaseControlOutput> {
|
|
90
|
+
return this.client.requestBrowser<ReleaseControlOutput>("release-control", {
|
|
91
|
+
viewerToken,
|
|
92
|
+
controlToken,
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
navigate(input: NavigateInput): Promise<NavigateOutput> {
|
|
97
|
+
return this.client.requestBrowser<NavigateOutput>("navigate", input as unknown as JsonValue);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
resize(input: ResizeInput): Promise<ResizeOutput> {
|
|
101
|
+
return this.client.requestBrowser<ResizeOutput>("viewport", input as unknown as JsonValue);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
applyDevicePreset(input: ApplyDevicePresetInput): Promise<ApplyDevicePresetOutput> {
|
|
105
|
+
return this.client.requestBrowser<ApplyDevicePresetOutput>(
|
|
106
|
+
"device",
|
|
107
|
+
input as unknown as JsonValue,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
sendInput(input: SendInput): Promise<SendOutput> {
|
|
112
|
+
return this.client.requestBrowser<SendOutput>("input", input as unknown as JsonValue);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async listOpenWorkspaceIds(): Promise<string[]> {
|
|
116
|
+
const result = await this.client.requestBrowser("list", {});
|
|
117
|
+
if (
|
|
118
|
+
!result ||
|
|
119
|
+
typeof result !== "object" ||
|
|
120
|
+
Array.isArray(result) ||
|
|
121
|
+
!("workspaceIds" in result)
|
|
122
|
+
)
|
|
123
|
+
throw new Error("Supervisor returned an invalid workspace list");
|
|
124
|
+
const workspaceIds = result.workspaceIds;
|
|
125
|
+
if (
|
|
126
|
+
!Array.isArray(workspaceIds) ||
|
|
127
|
+
!workspaceIds.every((value): value is string => typeof value === "string")
|
|
128
|
+
)
|
|
129
|
+
throw new Error("Supervisor returned an invalid workspace list");
|
|
130
|
+
return workspaceIds;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async archiveWorkspace(workspaceId: string): Promise<void> {
|
|
134
|
+
await this.client.requestBrowser("archive", { workspaceId });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
issueAgentTicket(ticket: string): Promise<void> {
|
|
138
|
+
return this.client.issueAgentTicket(ticket);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
bindAgentTicket(ticket: string, agentId: string, workspaceId: string): Promise<void> {
|
|
142
|
+
return this.client.bindAgentTicket(ticket, agentId, workspaceId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
revokeAgent(agentId: string): Promise<void> {
|
|
146
|
+
return this.client.revokeAgent(agentId);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
disconnect(): void {
|
|
150
|
+
this.client.disconnect();
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
let productionManager: RemoteBrowserManager | null = null;
|
|
155
|
+
let productionStart: Promise<RemoteBrowserManager> | null = null;
|
|
156
|
+
let productionStopped = false;
|
|
157
|
+
|
|
158
|
+
async function launchSupervisor(): Promise<void> {
|
|
159
|
+
const supervisorPath = join(
|
|
160
|
+
paseoHome(),
|
|
161
|
+
"plugin-data",
|
|
162
|
+
"shared-browser",
|
|
163
|
+
"runtime",
|
|
164
|
+
"supervisor.cjs",
|
|
165
|
+
);
|
|
166
|
+
try {
|
|
167
|
+
await access(supervisorPath);
|
|
168
|
+
} catch {
|
|
169
|
+
throw new Error(`Shared Browser supervisor runtime is missing: ${supervisorPath}`);
|
|
170
|
+
}
|
|
171
|
+
await new Promise<void>((resolve, reject) => {
|
|
172
|
+
const child = spawn(process.execPath, [supervisorPath], {
|
|
173
|
+
detached: true,
|
|
174
|
+
stdio: "ignore",
|
|
175
|
+
env: { ...process.env, PASEO_HOME: paseoHome() },
|
|
176
|
+
});
|
|
177
|
+
child.once("spawn", () => {
|
|
178
|
+
child.unref();
|
|
179
|
+
resolve();
|
|
180
|
+
});
|
|
181
|
+
child.once("error", reject);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function getProductionManager(): Promise<RemoteBrowserManager> {
|
|
186
|
+
if (productionStopped) throw new Error("Shared Browser plugin is stopping");
|
|
187
|
+
if (productionManager) return productionManager;
|
|
188
|
+
productionStart ??= (async () => {
|
|
189
|
+
const manager = new RemoteBrowserManager(
|
|
190
|
+
new SupervisorClient({ bridgeId: randomUUID(), paths: resolveSupervisorPaths(paseoHome()) }),
|
|
191
|
+
);
|
|
192
|
+
try {
|
|
193
|
+
await manager.connect();
|
|
194
|
+
} catch {
|
|
195
|
+
await launchSupervisor();
|
|
196
|
+
let lastError: unknown;
|
|
197
|
+
for (let attempt = 0; attempt < 50; attempt += 1) {
|
|
198
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
199
|
+
try {
|
|
200
|
+
await manager.connect();
|
|
201
|
+
lastError = undefined;
|
|
202
|
+
break;
|
|
203
|
+
} catch (error) {
|
|
204
|
+
lastError = error;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (lastError) throw lastError;
|
|
208
|
+
}
|
|
209
|
+
if (productionStopped) {
|
|
210
|
+
manager.disconnect();
|
|
211
|
+
throw new Error("Shared Browser plugin is stopping");
|
|
212
|
+
}
|
|
213
|
+
productionManager = manager;
|
|
214
|
+
return manager;
|
|
215
|
+
})();
|
|
216
|
+
try {
|
|
217
|
+
return await productionStart;
|
|
218
|
+
} finally {
|
|
219
|
+
productionStart = null;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export async function issueAgentTicket(ticket: string): Promise<void> {
|
|
224
|
+
await (await getProductionManager()).issueAgentTicket(ticket);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export async function bindAgentTicket(
|
|
228
|
+
ticket: string,
|
|
229
|
+
agentId: string,
|
|
230
|
+
workspaceId: string,
|
|
231
|
+
): Promise<void> {
|
|
232
|
+
await (await getProductionManager()).bindAgentTicket(ticket, agentId, workspaceId);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export async function revokeAgentBrowserAccess(agentId: string): Promise<void> {
|
|
236
|
+
await (await getProductionManager()).revokeAgent(agentId);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export async function handleAttachBrowser(
|
|
240
|
+
input: AttachInput,
|
|
241
|
+
context: PluginHandlerContext,
|
|
242
|
+
): Promise<AttachOutput> {
|
|
243
|
+
const workspace = await context.paseo.workspaces.ref(input.workspaceId).refresh();
|
|
244
|
+
if (!workspace) throw new Error("Workspace not found");
|
|
245
|
+
return (await getProductionManager()).attach(input.workspaceId, input.viewerLabel);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export async function handleDetachBrowser({ viewerToken }: DetachInput): Promise<DetachOutput> {
|
|
249
|
+
return (await getProductionManager()).detach(viewerToken);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function handleWorkspaceArchived(workspaceId: string): Promise<void> {
|
|
253
|
+
if (productionStopped) return;
|
|
254
|
+
await (await getProductionManager()).archiveWorkspace(workspaceId);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function handleListOpenBrowserWorkspaces(): Promise<ListOpenOutput> {
|
|
258
|
+
return { workspaceIds: await (await getProductionManager()).listOpenWorkspaceIds() };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export async function handleCaptureBrowser({
|
|
262
|
+
viewerToken,
|
|
263
|
+
quality,
|
|
264
|
+
knownFrameId,
|
|
265
|
+
}: CaptureInput): Promise<CaptureOutput> {
|
|
266
|
+
return (await getProductionManager()).capture(viewerToken, quality, knownFrameId);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export async function handleAcquireControl({
|
|
270
|
+
viewerToken,
|
|
271
|
+
takeover,
|
|
272
|
+
}: AcquireControlInput): Promise<AcquireControlOutput> {
|
|
273
|
+
return (await getProductionManager()).acquireControl(viewerToken, takeover);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function handleReleaseControl({
|
|
277
|
+
viewerToken,
|
|
278
|
+
controlToken,
|
|
279
|
+
}: ReleaseControlInput): Promise<ReleaseControlOutput> {
|
|
280
|
+
return (await getProductionManager()).releaseControl(viewerToken, controlToken);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function handleNavigateBrowser(input: NavigateInput): Promise<NavigateOutput> {
|
|
284
|
+
return (await getProductionManager()).navigate(input);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function handleResizeBrowser(input: ResizeInput): Promise<ResizeOutput> {
|
|
288
|
+
return (await getProductionManager()).resize(input);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function handleApplyDevicePreset(
|
|
292
|
+
input: ApplyDevicePresetInput,
|
|
293
|
+
): Promise<ApplyDevicePresetOutput> {
|
|
294
|
+
return (await getProductionManager()).applyDevicePreset(input);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function handleSendBrowserInput(input: SendInput): Promise<SendOutput> {
|
|
298
|
+
return (await getProductionManager()).sendInput(input);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function cleanupBrowserServer(): Promise<void> {
|
|
302
|
+
productionStopped = true;
|
|
303
|
+
if (productionStart) await productionStart.catch(() => undefined);
|
|
304
|
+
productionManager?.disconnect();
|
|
305
|
+
productionManager = null;
|
|
306
|
+
}
|
package/server/cdp.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
|
|
3
|
+
export class CdpUnavailableError extends Error {
|
|
4
|
+
override readonly name = "CdpUnavailableError";
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export class CdpUnknownOutcomeError extends Error {
|
|
8
|
+
override readonly name = "CdpUnknownOutcomeError";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface CdpEvent<T = unknown> {
|
|
12
|
+
method: string;
|
|
13
|
+
params: T;
|
|
14
|
+
sessionId?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface PendingCommand {
|
|
18
|
+
readonly method: string;
|
|
19
|
+
readonly mutation: boolean;
|
|
20
|
+
readonly resolve: (value: unknown) => void;
|
|
21
|
+
readonly reject: (reason: unknown) => void;
|
|
22
|
+
timer: NodeJS.Timeout;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface CdpMessage {
|
|
26
|
+
id?: number;
|
|
27
|
+
method?: string;
|
|
28
|
+
params?: unknown;
|
|
29
|
+
result?: unknown;
|
|
30
|
+
error?: { code?: number; message?: string; data?: unknown };
|
|
31
|
+
sessionId?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface WebSocketLike {
|
|
35
|
+
readonly readyState: number;
|
|
36
|
+
binaryType: string;
|
|
37
|
+
onopen: (() => void) | null;
|
|
38
|
+
onerror: ((event: unknown) => void) | null;
|
|
39
|
+
onclose: ((event: { code?: number; reason?: string }) => void) | null;
|
|
40
|
+
onmessage: ((event: { data: string | ArrayBuffer | Blob }) => void) | null;
|
|
41
|
+
send(data: string): void;
|
|
42
|
+
close(code?: number, reason?: string): void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type WebSocketConstructor = new (url: string) => WebSocketLike;
|
|
46
|
+
|
|
47
|
+
export interface CdpConnectionOptions {
|
|
48
|
+
commandTimeoutMs?: number;
|
|
49
|
+
connectTimeoutMs?: number;
|
|
50
|
+
webSocket?: WebSocketConstructor;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class CdpConnection extends EventEmitter {
|
|
54
|
+
private readonly socket: WebSocketLike;
|
|
55
|
+
private readonly commandTimeoutMs: number;
|
|
56
|
+
private readonly pending = new Map<number, PendingCommand>();
|
|
57
|
+
private nextId = 1;
|
|
58
|
+
private closed = false;
|
|
59
|
+
|
|
60
|
+
private constructor(socket: WebSocketLike, commandTimeoutMs: number) {
|
|
61
|
+
super();
|
|
62
|
+
this.socket = socket;
|
|
63
|
+
this.commandTimeoutMs = commandTimeoutMs;
|
|
64
|
+
socket.onmessage = (event) => void this.receive(event.data);
|
|
65
|
+
socket.onclose = (event) =>
|
|
66
|
+
this.disconnect(event.reason || `WebSocket closed (${event.code ?? 0})`);
|
|
67
|
+
socket.onerror = () => this.disconnect("CDP WebSocket failed");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
static connect(url: string, options: CdpConnectionOptions = {}): Promise<CdpConnection> {
|
|
71
|
+
const parsed = new URL(url);
|
|
72
|
+
if (parsed.protocol !== "ws:" && parsed.protocol !== "wss:") {
|
|
73
|
+
throw new CdpUnavailableError("CDP endpoint must use ws or wss");
|
|
74
|
+
}
|
|
75
|
+
const WebSocketImpl =
|
|
76
|
+
options.webSocket ??
|
|
77
|
+
(globalThis as typeof globalThis & { WebSocket?: WebSocketConstructor }).WebSocket;
|
|
78
|
+
if (!WebSocketImpl) throw new CdpUnavailableError("This runtime does not provide WebSocket");
|
|
79
|
+
const socket = new WebSocketImpl(parsed.href);
|
|
80
|
+
socket.binaryType = "arraybuffer";
|
|
81
|
+
const timeoutMs = options.connectTimeoutMs ?? 10_000;
|
|
82
|
+
const { promise, resolve, reject } = Promise.withResolvers<CdpConnection>();
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
socket.close();
|
|
85
|
+
reject(new CdpUnavailableError(`CDP connection timed out after ${timeoutMs}ms`));
|
|
86
|
+
}, timeoutMs);
|
|
87
|
+
socket.onopen = () => {
|
|
88
|
+
clearTimeout(timer);
|
|
89
|
+
resolve(new CdpConnection(socket, options.commandTimeoutMs ?? 15_000));
|
|
90
|
+
};
|
|
91
|
+
socket.onerror = () => {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
reject(new CdpUnavailableError("Could not connect to CDP endpoint"));
|
|
94
|
+
};
|
|
95
|
+
return promise;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get isOpen(): boolean {
|
|
99
|
+
return !this.closed && this.socket.readyState === 1;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async send<T = unknown>(
|
|
103
|
+
method: string,
|
|
104
|
+
params: Record<string, unknown> = {},
|
|
105
|
+
options: { sessionId?: string; mutation?: boolean; timeoutMs?: number } = {},
|
|
106
|
+
): Promise<T> {
|
|
107
|
+
if (!this.isOpen) throw new CdpUnavailableError("CDP connection is closed");
|
|
108
|
+
const id = this.nextId++;
|
|
109
|
+
const mutation = options.mutation ?? false;
|
|
110
|
+
const timeoutMs = options.timeoutMs ?? this.commandTimeoutMs;
|
|
111
|
+
const message: Record<string, unknown> = { id, method, params };
|
|
112
|
+
if (options.sessionId) message.sessionId = options.sessionId;
|
|
113
|
+
const { promise, resolve, reject } = Promise.withResolvers<T>();
|
|
114
|
+
const timer = setTimeout(() => {
|
|
115
|
+
this.pending.delete(id);
|
|
116
|
+
reject(
|
|
117
|
+
mutation
|
|
118
|
+
? new CdpUnknownOutcomeError(`${method} timed out; mutation outcome is unknown`)
|
|
119
|
+
: new CdpUnavailableError(`${method} timed out after ${timeoutMs}ms`),
|
|
120
|
+
);
|
|
121
|
+
}, timeoutMs);
|
|
122
|
+
this.pending.set(id, {
|
|
123
|
+
method,
|
|
124
|
+
mutation,
|
|
125
|
+
timer,
|
|
126
|
+
resolve: resolve as (value: unknown) => void,
|
|
127
|
+
reject,
|
|
128
|
+
});
|
|
129
|
+
try {
|
|
130
|
+
this.socket.send(JSON.stringify(message));
|
|
131
|
+
} catch (error) {
|
|
132
|
+
clearTimeout(timer);
|
|
133
|
+
this.pending.delete(id);
|
|
134
|
+
reject(
|
|
135
|
+
mutation
|
|
136
|
+
? new CdpUnknownOutcomeError(`${method} send failed; mutation outcome is unknown`)
|
|
137
|
+
: error,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return promise;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
close(): void {
|
|
144
|
+
if (this.closed) return;
|
|
145
|
+
this.closed = true;
|
|
146
|
+
this.socket.close(1000, "client shutdown");
|
|
147
|
+
this.rejectPending("CDP connection closed");
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async receive(data: string | ArrayBuffer | Blob): Promise<void> {
|
|
151
|
+
try {
|
|
152
|
+
const text =
|
|
153
|
+
typeof data === "string"
|
|
154
|
+
? data
|
|
155
|
+
: data instanceof ArrayBuffer
|
|
156
|
+
? Buffer.from(data).toString("utf8")
|
|
157
|
+
: await data.text();
|
|
158
|
+
const message = JSON.parse(text) as CdpMessage;
|
|
159
|
+
if (message.id !== undefined) {
|
|
160
|
+
const pending = this.pending.get(message.id);
|
|
161
|
+
if (!pending) return;
|
|
162
|
+
clearTimeout(pending.timer);
|
|
163
|
+
this.pending.delete(message.id);
|
|
164
|
+
if (message.error) {
|
|
165
|
+
pending.reject(
|
|
166
|
+
new Error(
|
|
167
|
+
`CDP ${pending.method} failed${message.error.code === undefined ? "" : ` (${message.error.code})`}: ${message.error.message ?? "unknown error"}`,
|
|
168
|
+
),
|
|
169
|
+
);
|
|
170
|
+
} else {
|
|
171
|
+
pending.resolve(message.result);
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (message.method) {
|
|
176
|
+
const event: CdpEvent = { method: message.method, params: message.params ?? {} };
|
|
177
|
+
if (message.sessionId) event.sessionId = message.sessionId;
|
|
178
|
+
this.emit("event", event);
|
|
179
|
+
this.emit(message.method, event.params, event.sessionId);
|
|
180
|
+
}
|
|
181
|
+
} catch (error) {
|
|
182
|
+
this.disconnect(
|
|
183
|
+
`Invalid CDP message: ${error instanceof Error ? error.message : String(error)}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private disconnect(message: string): void {
|
|
189
|
+
if (this.closed) return;
|
|
190
|
+
this.closed = true;
|
|
191
|
+
this.rejectPending(message);
|
|
192
|
+
this.emit("disconnect", new CdpUnavailableError(message));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private rejectPending(message: string): void {
|
|
196
|
+
for (const pending of this.pending.values()) {
|
|
197
|
+
clearTimeout(pending.timer);
|
|
198
|
+
pending.reject(
|
|
199
|
+
pending.mutation
|
|
200
|
+
? new CdpUnknownOutcomeError(`${pending.method} interrupted; mutation outcome is unknown`)
|
|
201
|
+
: new CdpUnavailableError(message),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
this.pending.clear();
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export class CdpSession extends EventEmitter {
|
|
209
|
+
constructor(
|
|
210
|
+
readonly connection: CdpConnection,
|
|
211
|
+
readonly sessionId: string,
|
|
212
|
+
readonly targetId: string,
|
|
213
|
+
) {
|
|
214
|
+
super();
|
|
215
|
+
connection.on("event", this.forwardEvent);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
send<T = unknown>(
|
|
219
|
+
method: string,
|
|
220
|
+
params: Record<string, unknown> = {},
|
|
221
|
+
options: { mutation?: boolean; timeoutMs?: number } = {},
|
|
222
|
+
): Promise<T> {
|
|
223
|
+
return this.connection.send<T>(method, params, { ...options, sessionId: this.sessionId });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
detach(): Promise<void> {
|
|
227
|
+
this.connection.off("event", this.forwardEvent);
|
|
228
|
+
return this.connection.send(
|
|
229
|
+
"Target.detachFromTarget",
|
|
230
|
+
{ sessionId: this.sessionId },
|
|
231
|
+
{ mutation: true },
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
private readonly forwardEvent = (event: CdpEvent): void => {
|
|
236
|
+
if (event.sessionId !== this.sessionId) return;
|
|
237
|
+
this.emit("event", event);
|
|
238
|
+
this.emit(event.method, event.params);
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export interface CdpTarget {
|
|
243
|
+
targetId: string;
|
|
244
|
+
type: string;
|
|
245
|
+
title: string;
|
|
246
|
+
url: string;
|
|
247
|
+
attached: boolean;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function listPageTargets(connection: CdpConnection): Promise<CdpTarget[]> {
|
|
251
|
+
const result = await connection.send<{ targetInfos: CdpTarget[] }>("Target.getTargets");
|
|
252
|
+
return result.targetInfos.filter((target) => target.type === "page");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function attachToTarget(
|
|
256
|
+
connection: CdpConnection,
|
|
257
|
+
targetId: string,
|
|
258
|
+
): Promise<CdpSession> {
|
|
259
|
+
const result = await connection.send<{ sessionId: string }>(
|
|
260
|
+
"Target.attachToTarget",
|
|
261
|
+
{ targetId, flatten: true },
|
|
262
|
+
{ mutation: true },
|
|
263
|
+
);
|
|
264
|
+
return new CdpSession(connection, result.sessionId, targetId);
|
|
265
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
declare module "electron" {}
|