@maxiaochao/pi-toolkit 0.1.8 → 0.2.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.
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Minimal CDP client - no puppeteer, no hangs
3
+ */
4
+
5
+ import WebSocket from "ws";
6
+
7
+ const DEBUG_HOST = process.env.BROWSER_DEBUG_HOST || "localhost";
8
+ const DEBUG_PORT = Number(process.env.BROWSER_DEBUG_PORT || 9222);
9
+ const DEBUG_HTTP_URL = `http://${DEBUG_HOST}:${DEBUG_PORT}`;
10
+
11
+ function exceptionMessage(result) {
12
+ return (
13
+ result.exceptionDetails.exception?.description || result.exceptionDetails.text
14
+ );
15
+ }
16
+
17
+ function remoteObjectValue(remoteObject) {
18
+ if (!remoteObject) return undefined;
19
+ if ("value" in remoteObject) return remoteObject.value;
20
+
21
+ switch (remoteObject.unserializableValue) {
22
+ case "NaN":
23
+ return NaN;
24
+ case "Infinity":
25
+ return Infinity;
26
+ case "-Infinity":
27
+ return -Infinity;
28
+ case "-0":
29
+ return -0;
30
+ }
31
+
32
+ if (remoteObject.type === "bigint" && remoteObject.unserializableValue) {
33
+ return BigInt(remoteObject.unserializableValue.slice(0, -1));
34
+ }
35
+
36
+ return undefined;
37
+ }
38
+
39
+ export async function connect(timeout = 5000) {
40
+ const controller = new AbortController();
41
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
42
+
43
+ try {
44
+ const resp = await fetch(`${DEBUG_HTTP_URL}/json/version`, {
45
+ signal: controller.signal,
46
+ });
47
+ const { webSocketDebuggerUrl } = await resp.json();
48
+ clearTimeout(timeoutId);
49
+
50
+ return new Promise((resolve, reject) => {
51
+ const ws = new WebSocket(webSocketDebuggerUrl);
52
+ const connectTimeout = setTimeout(() => {
53
+ ws.close();
54
+ reject(new Error("WebSocket connect timeout"));
55
+ }, timeout);
56
+
57
+ ws.on("open", () => {
58
+ clearTimeout(connectTimeout);
59
+ resolve(new CDP(ws));
60
+ });
61
+ ws.on("error", (e) => {
62
+ clearTimeout(connectTimeout);
63
+ reject(e);
64
+ });
65
+ });
66
+ } catch (e) {
67
+ clearTimeout(timeoutId);
68
+ if (e.name === "AbortError") {
69
+ throw new Error(
70
+ `Connection timeout - is Chrome running with --remote-debugging-port=${DEBUG_PORT}?`,
71
+ );
72
+ }
73
+ throw e;
74
+ }
75
+ }
76
+
77
+ class CDP {
78
+ constructor(ws) {
79
+ this.ws = ws;
80
+ this.id = 0;
81
+ this.callbacks = new Map();
82
+ this.sessions = new Map();
83
+ this.eventHandlers = new Map();
84
+
85
+ ws.on("message", (data) => {
86
+ const msg = JSON.parse(data.toString());
87
+ if (msg.id && this.callbacks.has(msg.id)) {
88
+ const { resolve, reject } = this.callbacks.get(msg.id);
89
+ this.callbacks.delete(msg.id);
90
+ if (msg.error) {
91
+ reject(new Error(msg.error.message));
92
+ } else {
93
+ resolve(msg.result);
94
+ }
95
+ return;
96
+ }
97
+
98
+ if (msg.method) {
99
+ this.emit(msg.method, msg.params || {}, msg.sessionId || null);
100
+ }
101
+ });
102
+ }
103
+
104
+ on(method, handler) {
105
+ if (!this.eventHandlers.has(method)) {
106
+ this.eventHandlers.set(method, new Set());
107
+ }
108
+ this.eventHandlers.get(method).add(handler);
109
+ return () => this.off(method, handler);
110
+ }
111
+
112
+ off(method, handler) {
113
+ const handlers = this.eventHandlers.get(method);
114
+ if (!handlers) return;
115
+ handlers.delete(handler);
116
+ if (handlers.size === 0) {
117
+ this.eventHandlers.delete(method);
118
+ }
119
+ }
120
+
121
+ emit(method, params, sessionId) {
122
+ const handlers = this.eventHandlers.get(method);
123
+ if (!handlers || handlers.size === 0) return;
124
+ for (const handler of handlers) {
125
+ try {
126
+ handler(params, sessionId);
127
+ } catch {
128
+ // Ignore handler errors to keep CDP session alive.
129
+ }
130
+ }
131
+ }
132
+
133
+ send(method, params = {}, sessionId = null, timeout = 10000) {
134
+ return new Promise((resolve, reject) => {
135
+ const msgId = ++this.id;
136
+ const msg = { id: msgId, method, params };
137
+ if (sessionId) msg.sessionId = sessionId;
138
+
139
+ const timeoutId = setTimeout(() => {
140
+ this.callbacks.delete(msgId);
141
+ reject(new Error(`CDP timeout: ${method}`));
142
+ }, timeout);
143
+
144
+ this.callbacks.set(msgId, {
145
+ resolve: (result) => {
146
+ clearTimeout(timeoutId);
147
+ resolve(result);
148
+ },
149
+ reject: (err) => {
150
+ clearTimeout(timeoutId);
151
+ reject(err);
152
+ },
153
+ });
154
+
155
+ this.ws.send(JSON.stringify(msg));
156
+ });
157
+ }
158
+
159
+ async getPages() {
160
+ const { targetInfos } = await this.send("Target.getTargets");
161
+ return targetInfos.filter((t) => t.type === "page");
162
+ }
163
+
164
+ async attachToPage(targetId) {
165
+ const { sessionId } = await this.send("Target.attachToTarget", {
166
+ targetId,
167
+ flatten: true,
168
+ });
169
+ return sessionId;
170
+ }
171
+
172
+ async evaluate(sessionId, expression, timeout = 30000) {
173
+ const result = await this.send(
174
+ "Runtime.evaluate",
175
+ {
176
+ expression,
177
+ returnByValue: true,
178
+ awaitPromise: true,
179
+ },
180
+ sessionId,
181
+ timeout
182
+ );
183
+
184
+ if (result.exceptionDetails) {
185
+ throw new Error(exceptionMessage(result));
186
+ }
187
+ return result.result?.value;
188
+ }
189
+
190
+ async evaluateRepl(sessionId, expression, timeout = 30000) {
191
+ const objectGroup = `agent-eval-${Date.now()}-${Math.random()}`;
192
+
193
+ try {
194
+ const result = await this.send(
195
+ "Runtime.evaluate",
196
+ {
197
+ expression,
198
+ objectGroup,
199
+ returnByValue: false,
200
+ replMode: true,
201
+ },
202
+ sessionId,
203
+ timeout
204
+ );
205
+
206
+ if (result.exceptionDetails) {
207
+ throw new Error(exceptionMessage(result));
208
+ }
209
+
210
+ let remoteObject = result.result;
211
+ if (remoteObject?.subtype === "promise" && remoteObject.objectId) {
212
+ const awaited = await this.send(
213
+ "Runtime.awaitPromise",
214
+ {
215
+ promiseObjectId: remoteObject.objectId,
216
+ returnByValue: true,
217
+ },
218
+ sessionId,
219
+ timeout
220
+ );
221
+
222
+ if (awaited.exceptionDetails) {
223
+ throw new Error(exceptionMessage(awaited));
224
+ }
225
+
226
+ remoteObject = awaited.result;
227
+ } else if (remoteObject?.objectId) {
228
+ const cloned = await this.send(
229
+ "Runtime.callFunctionOn",
230
+ {
231
+ objectId: remoteObject.objectId,
232
+ functionDeclaration: "function() { return this; }",
233
+ returnByValue: true,
234
+ },
235
+ sessionId,
236
+ timeout
237
+ );
238
+
239
+ if (cloned.exceptionDetails) {
240
+ throw new Error(exceptionMessage(cloned));
241
+ }
242
+
243
+ remoteObject = cloned.result;
244
+ }
245
+
246
+ return remoteObjectValue(remoteObject);
247
+ } finally {
248
+ await this.send(
249
+ "Runtime.releaseObjectGroup",
250
+ { objectGroup },
251
+ sessionId,
252
+ 1000,
253
+ ).catch(() => {});
254
+ }
255
+ }
256
+
257
+ async screenshot(sessionId, timeout = 10000) {
258
+ const { data } = await this.send(
259
+ "Page.captureScreenshot",
260
+ { format: "png" },
261
+ sessionId,
262
+ timeout
263
+ );
264
+ return Buffer.from(data, "base64");
265
+ }
266
+
267
+ async navigate(sessionId, url, timeout = 30000) {
268
+ await this.send("Page.navigate", { url }, sessionId, timeout);
269
+ }
270
+
271
+ async getFrameTree(sessionId) {
272
+ const { frameTree } = await this.send("Page.getFrameTree", {}, sessionId);
273
+ return frameTree;
274
+ }
275
+
276
+ async evaluateInFrame(sessionId, frameId, expression, timeout = 30000) {
277
+ // Create isolated world for the frame
278
+ const { executionContextId } = await this.send(
279
+ "Page.createIsolatedWorld",
280
+ { frameId, worldName: "cdp-eval" },
281
+ sessionId
282
+ );
283
+
284
+ const result = await this.send(
285
+ "Runtime.evaluate",
286
+ {
287
+ expression,
288
+ contextId: executionContextId,
289
+ returnByValue: true,
290
+ awaitPromise: true,
291
+ },
292
+ sessionId,
293
+ timeout
294
+ );
295
+
296
+ if (result.exceptionDetails) {
297
+ throw new Error(exceptionMessage(result));
298
+ }
299
+ return result.result?.value;
300
+ }
301
+
302
+ close() {
303
+ this.ws.close();
304
+ }
305
+ }
@@ -0,0 +1,141 @@
1
+ export const DEVICE_PRESETS = {
2
+ "iphone-se": {
3
+ title: "iPhone SE",
4
+ width: 375,
5
+ height: 667,
6
+ deviceScaleFactor: 2,
7
+ userAgent:
8
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
9
+ platform: "iPhone",
10
+ },
11
+ "iphone-14": {
12
+ title: "iPhone 14",
13
+ width: 390,
14
+ height: 844,
15
+ deviceScaleFactor: 3,
16
+ userAgent:
17
+ "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
18
+ platform: "iPhone",
19
+ },
20
+ "pixel-7": {
21
+ title: "Pixel 7",
22
+ width: 412,
23
+ height: 915,
24
+ deviceScaleFactor: 2.625,
25
+ userAgent:
26
+ "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36",
27
+ platform: "Android",
28
+ },
29
+ "galaxy-s20": {
30
+ title: "Galaxy S20",
31
+ width: 360,
32
+ height: 800,
33
+ deviceScaleFactor: 3,
34
+ userAgent:
35
+ "Mozilla/5.0 (Linux; Android 13; SM-G981B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Mobile Safari/537.36",
36
+ platform: "Android",
37
+ },
38
+ };
39
+
40
+ export function listDevicePresets() {
41
+ return Object.entries(DEVICE_PRESETS).map(([id, preset]) => ({
42
+ id,
43
+ ...preset,
44
+ }));
45
+ }
46
+
47
+ export function resolveDevicePreset(name) {
48
+ if (!name) return null;
49
+ const normalized = String(name).toLowerCase();
50
+ const preset = DEVICE_PRESETS[normalized];
51
+ if (!preset) return null;
52
+ return { id: normalized, ...preset };
53
+ }
54
+
55
+ export async function applyDevicePreset(cdp, sessionId, preset, options = {}) {
56
+ const landscape = options.landscape === true;
57
+ const width = landscape ? preset.height : preset.width;
58
+ const height = landscape ? preset.width : preset.height;
59
+
60
+ await cdp.send(
61
+ "Emulation.setDeviceMetricsOverride",
62
+ {
63
+ width,
64
+ height,
65
+ deviceScaleFactor: preset.deviceScaleFactor,
66
+ mobile: true,
67
+ screenOrientation: landscape
68
+ ? { type: "landscapePrimary", angle: 90 }
69
+ : { type: "portraitPrimary", angle: 0 },
70
+ },
71
+ sessionId,
72
+ );
73
+
74
+ await cdp.send(
75
+ "Emulation.setTouchEmulationEnabled",
76
+ { enabled: true, maxTouchPoints: 5 },
77
+ sessionId,
78
+ );
79
+
80
+ try {
81
+ await cdp.send(
82
+ "Emulation.setEmitTouchEventsForMouse",
83
+ { enabled: true, configuration: "mobile" },
84
+ sessionId,
85
+ );
86
+ } catch {
87
+ // Optional on some protocol versions.
88
+ }
89
+
90
+ await cdp.send(
91
+ "Emulation.setUserAgentOverride",
92
+ {
93
+ userAgent: preset.userAgent,
94
+ platform: preset.platform,
95
+ acceptLanguage: "en-US,en",
96
+ },
97
+ sessionId,
98
+ );
99
+
100
+ return {
101
+ width,
102
+ height,
103
+ deviceScaleFactor: preset.deviceScaleFactor,
104
+ landscape,
105
+ };
106
+ }
107
+
108
+ export async function clearDeviceEmulation(cdp, sessionId) {
109
+ await cdp.send("Emulation.clearDeviceMetricsOverride", {}, sessionId);
110
+
111
+ try {
112
+ await cdp.send("Emulation.clearUserAgentOverride", {}, sessionId);
113
+ } catch {
114
+ // Fallback for older CDP versions.
115
+ try {
116
+ await cdp.send(
117
+ "Emulation.setUserAgentOverride",
118
+ { userAgent: "", platform: "" },
119
+ sessionId,
120
+ );
121
+ } catch {
122
+ // Ignore
123
+ }
124
+ }
125
+
126
+ await cdp.send(
127
+ "Emulation.setTouchEmulationEnabled",
128
+ { enabled: false },
129
+ sessionId,
130
+ );
131
+
132
+ try {
133
+ await cdp.send(
134
+ "Emulation.setEmitTouchEventsForMouse",
135
+ { enabled: false, configuration: "mobile" },
136
+ sessionId,
137
+ );
138
+ } catch {
139
+ // Optional on some protocol versions.
140
+ }
141
+ }