@ai-ecoverse/slicc-cdp 1.3.3 → 1.5.0
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/README.md +1 -1
- package/bin/curlwright.wasm +0 -0
- package/bin/playwright-cli.wasm +0 -0
- package/package.json +11 -3
- package/src/cdp-client.js +217 -0
- package/src/connect.js +117 -0
- package/src/index.d.ts +33 -0
- package/src/index.js +2 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# slicc-cdp
|
|
2
2
|
|
|
3
|
-
`@ai-ecoverse/slicc-cdp` publishes `playwright-cli` and `curlwright`, Rust clients for an already-running Chrome. They speak Chrome DevTools Protocol JSON. The
|
|
3
|
+
`@ai-ecoverse/slicc-cdp` publishes `playwright-cli` and `curlwright`, Rust clients for an already-running Chrome. They speak Chrome DevTools Protocol JSON. The package root exports `CDPClient` and `connect`, one browser DevTools socket.
|
|
4
4
|
|
|
5
5
|
## playwright-cli
|
|
6
6
|
|
package/bin/curlwright.wasm
CHANGED
|
Binary file
|
package/bin/playwright-cli.wasm
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/slicc-cdp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.0",
|
|
4
4
|
"description": "SLICC's CDP layer: client, browser API, transports, HAR, and ARIA snapshot",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
"provenance": true
|
|
23
23
|
},
|
|
24
24
|
"exports": {
|
|
25
|
-
".":
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./src/index.d.ts",
|
|
27
|
+
"default": "./src/index.js"
|
|
28
|
+
}
|
|
26
29
|
},
|
|
27
30
|
"files": [
|
|
28
31
|
"src",
|
|
@@ -45,16 +48,21 @@
|
|
|
45
48
|
"build:wasm": "cargo build --release --target wasm32-wasip1 -p playwright-cli -p curlwright && mkdir -p bin && cp target/wasm32-wasip1/release/playwright-cli.wasm bin/playwright-cli.wasm && cp target/wasm32-wasip1/release/curlwright.wasm bin/curlwright.wasm",
|
|
46
49
|
"prepublishOnly": "npm run build:wasm",
|
|
47
50
|
"lint": "slicc-lint",
|
|
51
|
+
"typecheck": "node --check src/index.js && node --check src/cdp-client.js && node --check src/connect.js",
|
|
48
52
|
"format": "biome check --write .",
|
|
49
53
|
"test": "true",
|
|
54
|
+
"test:integration": "PATH=\"$HOME/.cargo/bin:$PATH\" cargo build -p playwright-cli -p curlwright && PATH=\"$HOME/.cargo/bin:$PATH\" cargo build --target wasm32-wasip1 -p playwright-cli -p curlwright && npx playwright-core install chromium && node --test --test-timeout=180000 test/integration/chromium.test.mjs",
|
|
55
|
+
"test:unit": "mcr -c node_modules/@ai-ecoverse/slicc-shared-web/mcr.config.mjs node --test 'test/unit/**/*.test.mjs'",
|
|
50
56
|
"trust": "npx fledgling sync -y --skip-publish",
|
|
51
57
|
"trust:dry": "npx fledgling sync --dry-run -y --skip-publish",
|
|
52
58
|
"release": "semantic-release"
|
|
53
59
|
},
|
|
54
60
|
"devDependencies": {
|
|
55
|
-
"@ai-ecoverse/slicc-
|
|
61
|
+
"@ai-ecoverse/slicc-kernel": "1.48.1",
|
|
62
|
+
"@ai-ecoverse/slicc-shared-web": "1.12.1",
|
|
56
63
|
"@biomejs/biome": "2.5.15",
|
|
57
64
|
"lefthook": "2.1.16",
|
|
65
|
+
"playwright-core": "1.63.0",
|
|
58
66
|
"semantic-release": "25.0.9"
|
|
59
67
|
}
|
|
60
68
|
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
export class CDPClient {
|
|
2
|
+
#WebSocket;
|
|
3
|
+
#ws = null;
|
|
4
|
+
#state = 'disconnected';
|
|
5
|
+
#nextId = 0;
|
|
6
|
+
#pending = new Map();
|
|
7
|
+
#listeners = new Map();
|
|
8
|
+
#failure;
|
|
9
|
+
#localClose = false;
|
|
10
|
+
|
|
11
|
+
constructor(WebSocketImpl = globalThis.WebSocket) {
|
|
12
|
+
this.#WebSocket = WebSocketImpl;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async open(url, options = {}) {
|
|
16
|
+
if (this.#state === 'connecting' || this.#state === 'connected') {
|
|
17
|
+
throw new Error(`Cannot connect: state is ${this.#state}\n`);
|
|
18
|
+
}
|
|
19
|
+
this.#failure = undefined;
|
|
20
|
+
this.#localClose = false;
|
|
21
|
+
this.#state = 'connecting';
|
|
22
|
+
const timeout = options.timeout ?? 10000;
|
|
23
|
+
const WS = this.#WebSocket;
|
|
24
|
+
return new Promise((resolve, reject) => {
|
|
25
|
+
let settled = false;
|
|
26
|
+
let timer;
|
|
27
|
+
const finish = (error) => {
|
|
28
|
+
if (settled) return;
|
|
29
|
+
settled = true;
|
|
30
|
+
clearTimeout(timer);
|
|
31
|
+
if (error) reject(error);
|
|
32
|
+
else resolve();
|
|
33
|
+
};
|
|
34
|
+
let ws;
|
|
35
|
+
try {
|
|
36
|
+
ws = new WS(url);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
this.#state = 'disconnected';
|
|
39
|
+
finish(err instanceof Error ? err : new Error(String(err)));
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
this.#ws = ws;
|
|
43
|
+
timer = setTimeout(() => {
|
|
44
|
+
this.#localClose = true;
|
|
45
|
+
this.#failure = new Error('CDP connection timed out\n');
|
|
46
|
+
this.#state = 'disconnected';
|
|
47
|
+
const socket = this.#ws;
|
|
48
|
+
this.#ws = null;
|
|
49
|
+
closeQuiet(socket);
|
|
50
|
+
finish(this.#failure);
|
|
51
|
+
}, timeout);
|
|
52
|
+
ws.addEventListener('open', () => {
|
|
53
|
+
if (ws !== this.#ws) return;
|
|
54
|
+
this.#state = 'connected';
|
|
55
|
+
finish();
|
|
56
|
+
});
|
|
57
|
+
ws.addEventListener('message', (event) => {
|
|
58
|
+
if (ws !== this.#ws) return;
|
|
59
|
+
this.#onMessage(event?.data);
|
|
60
|
+
});
|
|
61
|
+
ws.addEventListener('error', () => {
|
|
62
|
+
if (ws !== this.#ws) return;
|
|
63
|
+
if (this.#state !== 'connecting') return;
|
|
64
|
+
const error = new Error('CDP connection failed\n');
|
|
65
|
+
this.#failure = error;
|
|
66
|
+
this.#state = 'disconnected';
|
|
67
|
+
finish(error);
|
|
68
|
+
});
|
|
69
|
+
ws.addEventListener('close', (event) => {
|
|
70
|
+
if (ws !== this.#ws) return;
|
|
71
|
+
const error = new Error(closeMessage(event?.code, event?.reason));
|
|
72
|
+
this.#fail(error);
|
|
73
|
+
finish(error);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
send(method, params, sessionId, timeout = 30000) {
|
|
79
|
+
if (this.#failure) return Promise.reject(this.#failure);
|
|
80
|
+
if (this.#state !== 'connected' || !this.#ws) {
|
|
81
|
+
return Promise.reject(new Error('CDP client is not connected\n'));
|
|
82
|
+
}
|
|
83
|
+
const id = ++this.#nextId;
|
|
84
|
+
const message = { id, method };
|
|
85
|
+
if (params !== undefined) message.params = params;
|
|
86
|
+
if (sessionId !== undefined) message.sessionId = sessionId;
|
|
87
|
+
return new Promise((resolve, reject) => {
|
|
88
|
+
const timer = setTimeout(() => {
|
|
89
|
+
this.#pending.delete(id);
|
|
90
|
+
reject(new Error(`${method} timed out\n`));
|
|
91
|
+
}, timeout);
|
|
92
|
+
this.#pending.set(id, { resolve, reject, timer, method });
|
|
93
|
+
try {
|
|
94
|
+
this.#ws.send(JSON.stringify(message));
|
|
95
|
+
} catch (err) {
|
|
96
|
+
clearTimeout(timer);
|
|
97
|
+
this.#pending.delete(id);
|
|
98
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
on(event, listener) {
|
|
104
|
+
let set = this.#listeners.get(event);
|
|
105
|
+
if (!set) {
|
|
106
|
+
set = new Set();
|
|
107
|
+
this.#listeners.set(event, set);
|
|
108
|
+
}
|
|
109
|
+
set.add(listener);
|
|
110
|
+
return () => this.off(event, listener);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
off(event, listener) {
|
|
114
|
+
const set = this.#listeners.get(event);
|
|
115
|
+
if (!set) return;
|
|
116
|
+
set.delete(listener);
|
|
117
|
+
if (set.size === 0) this.#listeners.delete(event);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async attach(targetId) {
|
|
121
|
+
const result = await this.send('Target.attachToTarget', { targetId, flatten: true });
|
|
122
|
+
const sessionId = result && typeof result.sessionId === 'string' ? result.sessionId : '';
|
|
123
|
+
if (sessionId.length === 0) {
|
|
124
|
+
throw new Error('Target.attachToTarget returned no sessionId\n');
|
|
125
|
+
}
|
|
126
|
+
return sessionId;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
close() {
|
|
130
|
+
if (this.#localClose) return;
|
|
131
|
+
this.#localClose = true;
|
|
132
|
+
this.#fail(new Error('CDP client disconnected\n'));
|
|
133
|
+
const ws = this.#ws;
|
|
134
|
+
this.#ws = null;
|
|
135
|
+
closeQuiet(ws);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
#onMessage(raw) {
|
|
139
|
+
const text = typeof raw === 'string' ? raw : '';
|
|
140
|
+
let message;
|
|
141
|
+
try {
|
|
142
|
+
message = JSON.parse(text);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
this.#fail(jsonError(err));
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (this.#settle(message)) return;
|
|
148
|
+
this.#dispatch(message);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
#settle(message) {
|
|
152
|
+
if (!isRecord(message)) return false;
|
|
153
|
+
if (typeof message.id !== 'number') return false;
|
|
154
|
+
const entry = this.#pending.get(message.id);
|
|
155
|
+
if (!entry) return false;
|
|
156
|
+
this.#pending.delete(message.id);
|
|
157
|
+
clearTimeout(entry.timer);
|
|
158
|
+
if (message.error) {
|
|
159
|
+
entry.reject(new Error(`${entry.method}: ${errorText(message.error)}\n`));
|
|
160
|
+
} else {
|
|
161
|
+
entry.resolve(message.result ?? null);
|
|
162
|
+
}
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
#dispatch(message) {
|
|
167
|
+
if (!isRecord(message)) return;
|
|
168
|
+
if (typeof message.method !== 'string') return;
|
|
169
|
+
const set = this.#listeners.get(message.method);
|
|
170
|
+
if (!set) return;
|
|
171
|
+
for (const listener of set) {
|
|
172
|
+
try {
|
|
173
|
+
listener(message);
|
|
174
|
+
} catch {}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
#fail(error) {
|
|
179
|
+
this.#failure = this.#failure ?? error;
|
|
180
|
+
this.#state = 'closed';
|
|
181
|
+
for (const entry of this.#pending.values()) {
|
|
182
|
+
clearTimeout(entry.timer);
|
|
183
|
+
entry.reject(this.#failure);
|
|
184
|
+
}
|
|
185
|
+
this.#pending.clear();
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isRecord(value) {
|
|
190
|
+
return Boolean(value) && typeof value === 'object';
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function errorText(error) {
|
|
194
|
+
if (isRecord(error) && typeof error.message === 'string') return error.message;
|
|
195
|
+
return 'CDP error';
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function jsonError(err) {
|
|
199
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
200
|
+
return new Error(`CDP response was not JSON: ${message}\n`);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function closeMessage(code, reason) {
|
|
204
|
+
const text = typeof reason === 'string' ? reason : '';
|
|
205
|
+
if (typeof code !== 'number' || code === 1005) return 'websocket closed\n';
|
|
206
|
+
if (text.length === 0) return `websocket closed ${code}\n`;
|
|
207
|
+
return `websocket closed ${code} ${text}\n`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function closeQuiet(ws) {
|
|
211
|
+
if (!ws) return;
|
|
212
|
+
try {
|
|
213
|
+
ws.close();
|
|
214
|
+
} catch {
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
}
|
package/src/connect.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { CDPClient } from './cdp-client.js';
|
|
2
|
+
|
|
3
|
+
const UNRESERVED = /[A-Za-z0-9._~-]/u;
|
|
4
|
+
|
|
5
|
+
function nonempty(value) {
|
|
6
|
+
if (typeof value !== 'string') return undefined;
|
|
7
|
+
const trimmed = value.trim();
|
|
8
|
+
if (trimmed.length === 0) return undefined;
|
|
9
|
+
return trimmed;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function chooseStart(cdp, env) {
|
|
13
|
+
const explicit = nonempty(cdp);
|
|
14
|
+
if (explicit !== undefined) return classify(explicit);
|
|
15
|
+
const fromEnv = nonempty(env);
|
|
16
|
+
if (fromEnv !== undefined) return classify(fromEnv);
|
|
17
|
+
return { kind: 'discover', url: 'http://127.0.0.1:9222/json/version' };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function classify(url) {
|
|
21
|
+
const lower = url.toLowerCase();
|
|
22
|
+
if (lower.startsWith('ws://') || lower.startsWith('wss://')) {
|
|
23
|
+
return { kind: 'direct', url };
|
|
24
|
+
}
|
|
25
|
+
if (lower.startsWith('http://') || lower.startsWith('https://')) {
|
|
26
|
+
return { kind: 'discover', url: discoveryUrl(url) };
|
|
27
|
+
}
|
|
28
|
+
throw new Error(`unsupported CDP URL "${url}" (expected ws://, wss://, http://, or https://)\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function discoveryUrl(httpUrl) {
|
|
32
|
+
if (httpUrl.includes('/json/version')) return httpUrl;
|
|
33
|
+
const trimmed = httpUrl.replace(/\/+$/u, '');
|
|
34
|
+
return `${trimmed}/json/version`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function appendRuntime(url, runtime) {
|
|
38
|
+
const value = nonempty(runtime);
|
|
39
|
+
if (value === undefined) return url;
|
|
40
|
+
const enc = percentEncode(value);
|
|
41
|
+
if (url.includes('?')) return `${url}&runtime=${enc}`;
|
|
42
|
+
return `${url}?runtime=${enc}`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function percentEncode(value) {
|
|
46
|
+
let out = '';
|
|
47
|
+
for (const byte of new TextEncoder().encode(value)) {
|
|
48
|
+
const char = String.fromCharCode(byte);
|
|
49
|
+
if (UNRESERVED.test(char)) out += char;
|
|
50
|
+
else out += `%${byte.toString(16).toUpperCase().padStart(2, '0')}`;
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function formatHttpError(status, body) {
|
|
56
|
+
const text = String(body ?? '').replace(/\0+$/u, '');
|
|
57
|
+
if (text.length === 0) return `HTTP ${status}\n`;
|
|
58
|
+
if (text.endsWith('\n')) return `HTTP ${status}\n${text}`;
|
|
59
|
+
return `HTTP ${status}\n${text}\n`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function websocketUrlFromVersion(body) {
|
|
63
|
+
let value;
|
|
64
|
+
try {
|
|
65
|
+
value = JSON.parse(body);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
68
|
+
throw new Error(`json/version: ${message}\n`);
|
|
69
|
+
}
|
|
70
|
+
const url = isRecord(value) ? value.webSocketDebuggerUrl : undefined;
|
|
71
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
72
|
+
throw new Error('json/version returned no webSocketDebuggerUrl\n');
|
|
73
|
+
}
|
|
74
|
+
return url;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isRecord(value) {
|
|
78
|
+
return Boolean(value) && typeof value === 'object';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function connect(options = {}) {
|
|
82
|
+
const env = options.env ?? process.env.SLICC_CDP_URL;
|
|
83
|
+
const start = chooseStart(options.url, env);
|
|
84
|
+
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
85
|
+
const socketUrl = await socketFromStart(start, fetchImpl);
|
|
86
|
+
const withRuntime = appendRuntime(socketUrl, options.runtime);
|
|
87
|
+
const client = new CDPClient(options.WebSocket ?? globalThis.WebSocket);
|
|
88
|
+
await client.open(withRuntime, { timeout: options.timeout ?? 10000 });
|
|
89
|
+
return client;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function socketFromStart(start, fetchImpl) {
|
|
93
|
+
if (start.kind === 'direct') return start.url;
|
|
94
|
+
return discover(fetchImpl, start.url);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function discover(fetchImpl, url) {
|
|
98
|
+
let response;
|
|
99
|
+
try {
|
|
100
|
+
response = await fetchImpl(url);
|
|
101
|
+
} catch (err) {
|
|
102
|
+
throw discoveryFailure(err);
|
|
103
|
+
}
|
|
104
|
+
const body = await response.text();
|
|
105
|
+
if (!response.ok) throw new Error(formatHttpError(response.status, body));
|
|
106
|
+
return websocketUrlFromVersion(body);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function discoveryFailure(err) {
|
|
110
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
111
|
+
if (message.startsWith('HTTP ') || message.includes('does not launch Chrome')) {
|
|
112
|
+
return err instanceof Error ? err : new Error(message);
|
|
113
|
+
}
|
|
114
|
+
return new Error(
|
|
115
|
+
`${message}\nThis CLI does not launch Chrome. Pass --cdp or set SLICC_CDP_URL.\n`
|
|
116
|
+
);
|
|
117
|
+
}
|
package/src/index.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type CdpParams = Record<string, unknown>;
|
|
2
|
+
|
|
3
|
+
export interface CdpMessage {
|
|
4
|
+
method?: string;
|
|
5
|
+
params?: CdpParams;
|
|
6
|
+
sessionId?: string;
|
|
7
|
+
id?: number;
|
|
8
|
+
result?: unknown;
|
|
9
|
+
error?: { message?: string };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type CdpListener = (message: CdpMessage) => void;
|
|
13
|
+
|
|
14
|
+
export interface CdpConnectOptions {
|
|
15
|
+
url?: string;
|
|
16
|
+
env?: string;
|
|
17
|
+
runtime?: string;
|
|
18
|
+
timeout?: number;
|
|
19
|
+
fetch?: typeof fetch;
|
|
20
|
+
WebSocket?: typeof WebSocket;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export declare function connect(options?: CdpConnectOptions): Promise<CDPClient>;
|
|
24
|
+
|
|
25
|
+
export declare class CDPClient {
|
|
26
|
+
constructor(socket?: typeof WebSocket);
|
|
27
|
+
open(url: string, options?: { timeout?: number }): Promise<void>;
|
|
28
|
+
send(method: string, params?: CdpParams, sessionId?: string, timeout?: number): Promise<unknown>;
|
|
29
|
+
on(event: string, listener: CdpListener): () => void;
|
|
30
|
+
off(event: string, listener: CdpListener): void;
|
|
31
|
+
attach(targetId: string): Promise<string>;
|
|
32
|
+
close(): void;
|
|
33
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1 +1,2 @@
|
|
|
1
|
-
export {};
|
|
1
|
+
export { CDPClient } from './cdp-client.js';
|
|
2
|
+
export { connect } from './connect.js';
|