@ai-ecoverse/sliccy-browser 0.0.0-stage → 0.1.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/package.json +32 -4
- package/src/browser.js +576 -0
- package/src/fetch-script.js +139 -0
- package/src/ws-router.js +149 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,6 +1,34 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-ecoverse/sliccy-browser",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "sliccy:browser for jsh, over CDP",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ai-ecoverse/slicc-cdp.git",
|
|
10
|
+
"directory": "packages/sliccy-browser"
|
|
11
|
+
},
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=24"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public",
|
|
17
|
+
"provenance": true
|
|
18
|
+
},
|
|
19
|
+
"exports": {
|
|
20
|
+
".": {
|
|
21
|
+
"slicc": "./src/browser.js",
|
|
22
|
+
"import": "./src/browser.js",
|
|
23
|
+
"require": "./src/browser.js",
|
|
24
|
+
"default": "./src/browser.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"main": "./src/browser.js",
|
|
28
|
+
"files": [
|
|
29
|
+
"src"
|
|
30
|
+
],
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"@ai-ecoverse/slicc-cdp": "1.19.1"
|
|
33
|
+
}
|
|
34
|
+
}
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
import { appendFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { connect } from '@ai-ecoverse/slicc-cdp';
|
|
4
|
+
import { buildBrowserFetchScript } from './fetch-script.js';
|
|
5
|
+
import { routerSource } from './ws-router.js';
|
|
6
|
+
|
|
7
|
+
const BINDING = '__sliccWsRouterReport';
|
|
8
|
+
const LOAD_TIMEOUT_MS = 15000;
|
|
9
|
+
|
|
10
|
+
let opening;
|
|
11
|
+
const sessions = new Map();
|
|
12
|
+
const routers = new Set();
|
|
13
|
+
const subscribers = new Map();
|
|
14
|
+
let seq = 1;
|
|
15
|
+
let listening = false;
|
|
16
|
+
let active = 0;
|
|
17
|
+
let idle;
|
|
18
|
+
|
|
19
|
+
function openClient() {
|
|
20
|
+
if (!opening) {
|
|
21
|
+
opening = connect().catch((error) => {
|
|
22
|
+
opening = undefined;
|
|
23
|
+
throw error;
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
return opening;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function using(work) {
|
|
30
|
+
active += 1;
|
|
31
|
+
clearTimeout(idle);
|
|
32
|
+
idle = undefined;
|
|
33
|
+
try {
|
|
34
|
+
return await work();
|
|
35
|
+
} finally {
|
|
36
|
+
active -= 1;
|
|
37
|
+
release();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function release() {
|
|
42
|
+
if (active > 0 || subscribers.size > 0 || !opening || idle) return;
|
|
43
|
+
idle = setTimeout(closeIdle, 0);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function closeIdle() {
|
|
47
|
+
idle = undefined;
|
|
48
|
+
if (active > 0 || subscribers.size > 0 || !opening) return;
|
|
49
|
+
const closing = opening;
|
|
50
|
+
opening = undefined;
|
|
51
|
+
sessions.clear();
|
|
52
|
+
routers.clear();
|
|
53
|
+
listening = false;
|
|
54
|
+
closing.then(
|
|
55
|
+
(client) => client.close(),
|
|
56
|
+
() => undefined
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function ignoreMethod(prefix) {
|
|
61
|
+
return (error) => {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
if (message.startsWith(prefix)) return undefined;
|
|
64
|
+
throw error;
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function sessionFor(targetId) {
|
|
69
|
+
const client = await openClient();
|
|
70
|
+
const cached = sessions.get(targetId);
|
|
71
|
+
if (cached) return { client, sessionId: cached };
|
|
72
|
+
const sessionId = await client.attach(targetId);
|
|
73
|
+
await client.send('Page.enable', {}, sessionId).catch(ignoreMethod('Page.enable:'));
|
|
74
|
+
await client.send('Runtime.enable', {}, sessionId).catch(ignoreMethod('Runtime.enable:'));
|
|
75
|
+
await client.send('Network.enable', {}, sessionId).catch(ignoreMethod('Network.enable:'));
|
|
76
|
+
sessions.set(targetId, sessionId);
|
|
77
|
+
listen(client);
|
|
78
|
+
return { client, sessionId };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function listen(client) {
|
|
82
|
+
if (listening) return;
|
|
83
|
+
listening = true;
|
|
84
|
+
client.on('Runtime.bindingCalled', (message) => {
|
|
85
|
+
const params = message?.params;
|
|
86
|
+
if (!params || params.name !== BINDING || typeof params.payload !== 'string') return;
|
|
87
|
+
let body;
|
|
88
|
+
try {
|
|
89
|
+
body = JSON.parse(params.payload);
|
|
90
|
+
} catch {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (!body || typeof body.subId !== 'string') return;
|
|
94
|
+
void deliver(body.subId, body.payload);
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function targetIdOf(tab) {
|
|
99
|
+
if (typeof tab === 'string') return tab;
|
|
100
|
+
if (tab && typeof tab === 'object' && typeof tab.targetId === 'string') return tab.targetId;
|
|
101
|
+
throw new TypeError('browser: expected a tab handle or targetId string');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function patternOf(value) {
|
|
105
|
+
if (value === undefined) return undefined;
|
|
106
|
+
if (value instanceof RegExp) return value.source;
|
|
107
|
+
if (typeof value === 'string') return value;
|
|
108
|
+
throw new TypeError('browser: url match must be a string or RegExp');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function compilePattern(source, label) {
|
|
112
|
+
try {
|
|
113
|
+
return new RegExp(source);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
116
|
+
throw new Error(`${label}: invalid url match regex: ${message}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function hostnameOf(url) {
|
|
121
|
+
try {
|
|
122
|
+
return new URL(url).hostname;
|
|
123
|
+
} catch {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function originOf(url) {
|
|
129
|
+
try {
|
|
130
|
+
return new URL(url).origin;
|
|
131
|
+
} catch {
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function listTabs() {
|
|
137
|
+
const client = await openClient();
|
|
138
|
+
const result = await client.send('Target.getTargets');
|
|
139
|
+
const infos = Array.isArray(result?.targetInfos) ? result.targetInfos : [];
|
|
140
|
+
return infos
|
|
141
|
+
.filter((info) => info && info.type === 'page' && typeof info.targetId === 'string')
|
|
142
|
+
.map((info) => ({
|
|
143
|
+
targetId: info.targetId,
|
|
144
|
+
url: typeof info.url === 'string' ? info.url : '',
|
|
145
|
+
title: typeof info.title === 'string' ? info.title : '',
|
|
146
|
+
}));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function findTab(query) {
|
|
150
|
+
return using(() => findTabNow(query));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function findTabNow(query = {}) {
|
|
154
|
+
const domain = typeof query.domain === 'string' ? query.domain : undefined;
|
|
155
|
+
const urlMatch = patternOf(query.urlMatch);
|
|
156
|
+
if (domain === undefined && urlMatch === undefined) {
|
|
157
|
+
throw new Error('browser.findTab: query requires `domain` or `urlMatch`');
|
|
158
|
+
}
|
|
159
|
+
const tabs = await listTabs();
|
|
160
|
+
if (domain !== undefined) {
|
|
161
|
+
const wanted = domain.toLowerCase();
|
|
162
|
+
return tabs.find((tab) => hostnameOf(tab.url)?.toLowerCase() === wanted) ?? null;
|
|
163
|
+
}
|
|
164
|
+
const re = compilePattern(urlMatch, 'browser.findTab');
|
|
165
|
+
return tabs.find((tab) => re.test(tab.url)) ?? null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export async function ensureTab(url, options) {
|
|
169
|
+
return using(() => ensureTabNow(url, options));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async function ensureTabNow(url, options = {}) {
|
|
173
|
+
if (typeof url !== 'string' || url.length === 0) {
|
|
174
|
+
throw new TypeError('browser.ensureTab: url must be a string');
|
|
175
|
+
}
|
|
176
|
+
const tabs = await listTabs();
|
|
177
|
+
const matchUrl = patternOf(options.matchUrl);
|
|
178
|
+
if (matchUrl !== undefined) {
|
|
179
|
+
const re = compilePattern(matchUrl, 'browser.ensureTab');
|
|
180
|
+
const hit = tabs.find((tab) => re.test(tab.url));
|
|
181
|
+
if (hit) return hit;
|
|
182
|
+
} else {
|
|
183
|
+
const wanted = originOf(url);
|
|
184
|
+
if (wanted) {
|
|
185
|
+
const hit = tabs.find((tab) => originOf(tab.url) === wanted);
|
|
186
|
+
if (hit) return hit;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const client = await openClient();
|
|
190
|
+
const created = await client.send('Target.createTarget', { url: 'about:blank' });
|
|
191
|
+
const targetId = created?.targetId;
|
|
192
|
+
if (typeof targetId !== 'string' || targetId.length === 0) {
|
|
193
|
+
throw new Error('Target.createTarget returned no targetId');
|
|
194
|
+
}
|
|
195
|
+
const { sessionId } = await sessionFor(targetId);
|
|
196
|
+
if (url === 'about:blank') await waitForLoad(client, sessionId);
|
|
197
|
+
else await navigateAndWait(client, sessionId, url);
|
|
198
|
+
const live = (await listTabs()).find((tab) => tab.targetId === targetId);
|
|
199
|
+
return live ?? { targetId, url, title: '' };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function evalSource(tab, source) {
|
|
203
|
+
return evaluate(tab, source, false);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function evalSourceAsync(tab, source) {
|
|
207
|
+
return evaluate(tab, source, true);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export { evalSource as eval, evalSourceAsync as evalAsync };
|
|
211
|
+
|
|
212
|
+
function evaluate(tab, source, awaitPromise) {
|
|
213
|
+
return using(() => evaluateNow(tab, source, awaitPromise));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function evaluateNow(tab, source, awaitPromise) {
|
|
217
|
+
const { client, sessionId } = await sessionFor(targetIdOf(tab));
|
|
218
|
+
const result = await client.send(
|
|
219
|
+
'Runtime.evaluate',
|
|
220
|
+
{
|
|
221
|
+
expression: serializeEvalSource(source),
|
|
222
|
+
returnByValue: true,
|
|
223
|
+
awaitPromise,
|
|
224
|
+
},
|
|
225
|
+
sessionId
|
|
226
|
+
);
|
|
227
|
+
if (result?.exceptionDetails) {
|
|
228
|
+
throw new Error(exceptionMessage(result.exceptionDetails));
|
|
229
|
+
}
|
|
230
|
+
return unwrapEvalResult(result?.result?.value);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function cookie(tab, name) {
|
|
234
|
+
if (typeof name !== 'string') throw new TypeError('browser.cookie: name must be a string');
|
|
235
|
+
return using(() => cookieNow(tab, name));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function cookieNow(tab, name) {
|
|
239
|
+
const { client, sessionId } = await sessionFor(targetIdOf(tab));
|
|
240
|
+
const result = await client.send('Network.getCookies', {}, sessionId);
|
|
241
|
+
const cookies = Array.isArray(result?.cookies) ? result.cookies : [];
|
|
242
|
+
const hit = cookies.find((item) => item && item.name === name);
|
|
243
|
+
return hit && typeof hit.value === 'string' ? hit.value : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export async function localStorage(tab, key) {
|
|
247
|
+
if (typeof key !== 'string') throw new TypeError('browser.localStorage: key must be a string');
|
|
248
|
+
const raw = await evaluate(
|
|
249
|
+
tab,
|
|
250
|
+
`(function(){try{var v=window.localStorage.getItem(${JSON.stringify(key)});return v===null?null:String(v);}catch(e){return null;}})()`,
|
|
251
|
+
false
|
|
252
|
+
);
|
|
253
|
+
if (raw === null || raw === undefined) return null;
|
|
254
|
+
return typeof raw === 'string' ? raw : String(raw);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function fetch(tab, url, opts = {}) {
|
|
258
|
+
if (typeof url !== 'string') throw new TypeError('browser.fetch: url must be a string');
|
|
259
|
+
const script = await buildBrowserFetchScript(url, opts);
|
|
260
|
+
return evalSourceAsync(tab, script);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function serializeEvalSource(source) {
|
|
264
|
+
if (typeof source === 'function') return `(${source.toString()})()`;
|
|
265
|
+
if (typeof source === 'string') return source;
|
|
266
|
+
throw new TypeError('browser.eval/evalAsync: source must be a function or string');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function exceptionMessage(details) {
|
|
270
|
+
const description = details.exception?.description;
|
|
271
|
+
if (typeof description === 'string') return description;
|
|
272
|
+
if (typeof details.text === 'string') return details.text;
|
|
273
|
+
return 'evaluation failed';
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function unwrapEvalResult(value) {
|
|
277
|
+
if (typeof value !== 'string') return value;
|
|
278
|
+
const first = tryParseJson(value);
|
|
279
|
+
if (first === undefined) return value;
|
|
280
|
+
if (first !== null && typeof first === 'object') return first;
|
|
281
|
+
if (typeof first === 'string') {
|
|
282
|
+
const trimmed = first.trim();
|
|
283
|
+
if (trimmed.length > 0 && (trimmed[0] === '{' || trimmed[0] === '[')) {
|
|
284
|
+
const second = tryParseJson(first);
|
|
285
|
+
if (second !== null && typeof second === 'object') return second;
|
|
286
|
+
}
|
|
287
|
+
return first;
|
|
288
|
+
}
|
|
289
|
+
return value;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function tryParseJson(text) {
|
|
293
|
+
const trimmed = text.trim();
|
|
294
|
+
if (trimmed.length === 0) return undefined;
|
|
295
|
+
const first = trimmed[0];
|
|
296
|
+
const looksJson =
|
|
297
|
+
first === '{' ||
|
|
298
|
+
first === '[' ||
|
|
299
|
+
first === '"' ||
|
|
300
|
+
first === '-' ||
|
|
301
|
+
(first >= '0' && first <= '9') ||
|
|
302
|
+
trimmed === 'true' ||
|
|
303
|
+
trimmed === 'false' ||
|
|
304
|
+
trimmed === 'null';
|
|
305
|
+
if (!looksJson) return undefined;
|
|
306
|
+
try {
|
|
307
|
+
return JSON.parse(trimmed);
|
|
308
|
+
} catch {
|
|
309
|
+
return undefined;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function pageIsSettled(client, sessionId) {
|
|
314
|
+
return client
|
|
315
|
+
.send(
|
|
316
|
+
'Runtime.evaluate',
|
|
317
|
+
{ expression: 'document.readyState', returnByValue: true },
|
|
318
|
+
sessionId,
|
|
319
|
+
1000
|
|
320
|
+
)
|
|
321
|
+
.then((result) => {
|
|
322
|
+
const state = result?.result?.value;
|
|
323
|
+
return state !== 'loading' && state !== 'interactive';
|
|
324
|
+
})
|
|
325
|
+
.catch((error) => {
|
|
326
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
327
|
+
if (
|
|
328
|
+
message.startsWith('Runtime.evaluate timed out') ||
|
|
329
|
+
message.startsWith('Runtime.evaluate:')
|
|
330
|
+
) {
|
|
331
|
+
return true;
|
|
332
|
+
}
|
|
333
|
+
throw error;
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function navigateAndWait(client, sessionId, url) {
|
|
338
|
+
return new Promise((resolve, reject) => {
|
|
339
|
+
let settled = false;
|
|
340
|
+
let timer;
|
|
341
|
+
const finish = (error) => {
|
|
342
|
+
if (settled) return;
|
|
343
|
+
settled = true;
|
|
344
|
+
clearTimeout(timer);
|
|
345
|
+
client.off('Page.loadEventFired', listener);
|
|
346
|
+
if (error) reject(error);
|
|
347
|
+
else resolve();
|
|
348
|
+
};
|
|
349
|
+
const listener = (message) => {
|
|
350
|
+
if (typeof message?.sessionId === 'string' && message.sessionId !== sessionId) return;
|
|
351
|
+
finish();
|
|
352
|
+
};
|
|
353
|
+
client.on('Page.loadEventFired', listener);
|
|
354
|
+
timer = setTimeout(() => finish(), LOAD_TIMEOUT_MS);
|
|
355
|
+
client.send('Page.navigate', { url }, sessionId).then(
|
|
356
|
+
(result) => {
|
|
357
|
+
if (result?.errorText) finish(new Error(`navigate: ${result.errorText}`));
|
|
358
|
+
else if (!result?.loaderId) finish();
|
|
359
|
+
},
|
|
360
|
+
(error) => finish(error)
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function waitForLoad(client, sessionId) {
|
|
366
|
+
return new Promise((resolve, reject) => {
|
|
367
|
+
let settled = false;
|
|
368
|
+
let timer;
|
|
369
|
+
const finish = (error) => {
|
|
370
|
+
if (settled) return;
|
|
371
|
+
settled = true;
|
|
372
|
+
clearTimeout(timer);
|
|
373
|
+
client.off('Page.loadEventFired', listener);
|
|
374
|
+
if (error) reject(error);
|
|
375
|
+
else resolve();
|
|
376
|
+
};
|
|
377
|
+
const listener = (message) => {
|
|
378
|
+
if (typeof message?.sessionId === 'string' && message.sessionId !== sessionId) return;
|
|
379
|
+
finish();
|
|
380
|
+
};
|
|
381
|
+
client.on('Page.loadEventFired', listener);
|
|
382
|
+
timer = setTimeout(() => finish(), LOAD_TIMEOUT_MS);
|
|
383
|
+
pageIsSettled(client, sessionId).then(
|
|
384
|
+
(ready) => {
|
|
385
|
+
if (ready) finish();
|
|
386
|
+
},
|
|
387
|
+
(error) => finish(error)
|
|
388
|
+
);
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function unsupported(sink) {
|
|
393
|
+
const error = new Error(`browser.websocket: ${sink} is only available inside SLICC`);
|
|
394
|
+
error.code = 'ENOTSUP';
|
|
395
|
+
return error;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function validateSink(forward) {
|
|
399
|
+
if (!forward || typeof forward !== 'object' || typeof forward.sink !== 'string') {
|
|
400
|
+
throw new Error('browser.websocket: forward.sink is required');
|
|
401
|
+
}
|
|
402
|
+
switch (forward.sink) {
|
|
403
|
+
case 'webhook':
|
|
404
|
+
case 'scoop':
|
|
405
|
+
throw unsupported(forward.sink);
|
|
406
|
+
case 'vfs':
|
|
407
|
+
if (typeof forward.path !== 'string' || !path.isAbsolute(forward.path)) {
|
|
408
|
+
throw new Error('browser.websocket: vfs sink path must be an absolute path');
|
|
409
|
+
}
|
|
410
|
+
return;
|
|
411
|
+
case 'log':
|
|
412
|
+
return;
|
|
413
|
+
default:
|
|
414
|
+
throw new Error(`browser.websocket: unknown sink "${forward.sink ?? ''}"`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
async function installRouter(targetId) {
|
|
419
|
+
if (routers.has(targetId)) return;
|
|
420
|
+
const { client, sessionId } = await sessionFor(targetId);
|
|
421
|
+
try {
|
|
422
|
+
await client.send('Runtime.addBinding', { name: BINDING }, sessionId);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
425
|
+
if (!message.includes('already exists')) throw error;
|
|
426
|
+
}
|
|
427
|
+
await client.send('Page.addScriptToEvaluateOnNewDocument', { source: routerSource }, sessionId);
|
|
428
|
+
const installed = await client.send(
|
|
429
|
+
'Runtime.evaluate',
|
|
430
|
+
{ expression: routerSource, returnByValue: true },
|
|
431
|
+
sessionId
|
|
432
|
+
);
|
|
433
|
+
if (installed?.exceptionDetails) {
|
|
434
|
+
throw new Error(exceptionMessage(installed.exceptionDetails));
|
|
435
|
+
}
|
|
436
|
+
routers.add(targetId);
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function callRouter(targetId, expression) {
|
|
440
|
+
const { client, sessionId } = await sessionFor(targetId);
|
|
441
|
+
const result = await client.send(
|
|
442
|
+
'Runtime.evaluate',
|
|
443
|
+
{ expression, returnByValue: true },
|
|
444
|
+
sessionId
|
|
445
|
+
);
|
|
446
|
+
if (result?.exceptionDetails) {
|
|
447
|
+
throw new Error(exceptionMessage(result.exceptionDetails));
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function toInfo(sub) {
|
|
452
|
+
const info = {
|
|
453
|
+
id: sub.id,
|
|
454
|
+
targetId: sub.targetId,
|
|
455
|
+
forward: sub.forward,
|
|
456
|
+
createdAt: sub.createdAt,
|
|
457
|
+
};
|
|
458
|
+
if (sub.urlMatch !== undefined) info.urlMatch = sub.urlMatch;
|
|
459
|
+
if (sub.filter !== undefined) info.filter = sub.filter;
|
|
460
|
+
return info;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function makeHandle(sub) {
|
|
464
|
+
return {
|
|
465
|
+
...toInfo(sub),
|
|
466
|
+
async update(patch = {}) {
|
|
467
|
+
const wire = {};
|
|
468
|
+
if (patch.urlMatch !== undefined) {
|
|
469
|
+
wire.urlMatch =
|
|
470
|
+
patch.urlMatch === null
|
|
471
|
+
? null
|
|
472
|
+
: patch.urlMatch instanceof RegExp
|
|
473
|
+
? patch.urlMatch.source
|
|
474
|
+
: patch.urlMatch;
|
|
475
|
+
}
|
|
476
|
+
if (patch.filter !== undefined) wire.filter = patch.filter;
|
|
477
|
+
await using(() =>
|
|
478
|
+
callRouter(
|
|
479
|
+
sub.targetId,
|
|
480
|
+
`globalThis.__sliccWsRouter.update(${JSON.stringify(sub.id)}, ${JSON.stringify(wire)})`
|
|
481
|
+
)
|
|
482
|
+
);
|
|
483
|
+
if (wire.urlMatch === null) delete sub.urlMatch;
|
|
484
|
+
else if (wire.urlMatch !== undefined) sub.urlMatch = wire.urlMatch;
|
|
485
|
+
if (patch.filter === null) delete sub.filter;
|
|
486
|
+
else if (patch.filter !== undefined) sub.filter = patch.filter;
|
|
487
|
+
return toInfo(sub);
|
|
488
|
+
},
|
|
489
|
+
async close() {
|
|
490
|
+
if (!subscribers.has(sub.id)) return false;
|
|
491
|
+
subscribers.delete(sub.id);
|
|
492
|
+
await using(() =>
|
|
493
|
+
callRouter(sub.targetId, `globalThis.__sliccWsRouter.unregister(${JSON.stringify(sub.id)})`)
|
|
494
|
+
);
|
|
495
|
+
return true;
|
|
496
|
+
},
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function deliver(subId, payload) {
|
|
501
|
+
const sub = subscribers.get(subId);
|
|
502
|
+
if (!sub) return;
|
|
503
|
+
try {
|
|
504
|
+
if (sub.forward.sink === 'log') {
|
|
505
|
+
console.log(typeof payload === 'string' ? payload : JSON.stringify(payload));
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
if (sub.forward.sink === 'vfs') {
|
|
509
|
+
const line = `${typeof payload === 'string' ? payload : JSON.stringify(payload)}\n`;
|
|
510
|
+
await appendFile(sub.forward.path, line);
|
|
511
|
+
}
|
|
512
|
+
} catch {
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export const websocket = {
|
|
518
|
+
on(tab, opts = {}) {
|
|
519
|
+
const targetId = targetIdOf(tab);
|
|
520
|
+
const urlMatch = patternOf(opts.urlMatch);
|
|
521
|
+
let selector;
|
|
522
|
+
const builder = {
|
|
523
|
+
filter(next) {
|
|
524
|
+
if (typeof next === 'function' || typeof next === 'string') {
|
|
525
|
+
throw new TypeError(
|
|
526
|
+
'browser.websocket: filter must be a declarative JSON object, not a function or string'
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
selector = next;
|
|
530
|
+
return builder;
|
|
531
|
+
},
|
|
532
|
+
async forward(sink) {
|
|
533
|
+
validateSink(sink);
|
|
534
|
+
return using(() => subscribe(sink));
|
|
535
|
+
},
|
|
536
|
+
};
|
|
537
|
+
async function subscribe(sink) {
|
|
538
|
+
await installRouter(targetId);
|
|
539
|
+
const id = `wssub-${seq++}-${Date.now().toString(36)}`;
|
|
540
|
+
const sub = {
|
|
541
|
+
id,
|
|
542
|
+
targetId,
|
|
543
|
+
forward: sink,
|
|
544
|
+
createdAt: new Date().toISOString(),
|
|
545
|
+
};
|
|
546
|
+
if (urlMatch !== undefined) sub.urlMatch = urlMatch;
|
|
547
|
+
if (selector !== undefined) sub.filter = selector;
|
|
548
|
+
const registered = { id };
|
|
549
|
+
if (urlMatch !== undefined) registered.urlMatch = urlMatch;
|
|
550
|
+
if (selector !== undefined) registered.filter = selector;
|
|
551
|
+
await callRouter(
|
|
552
|
+
targetId,
|
|
553
|
+
`globalThis.__sliccWsRouter.register(${JSON.stringify(registered)})`
|
|
554
|
+
);
|
|
555
|
+
subscribers.set(id, sub);
|
|
556
|
+
return makeHandle(sub);
|
|
557
|
+
}
|
|
558
|
+
return builder;
|
|
559
|
+
},
|
|
560
|
+
async list() {
|
|
561
|
+
return [...subscribers.values()].map((sub) => toInfo(sub));
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const browser = {
|
|
566
|
+
findTab,
|
|
567
|
+
ensureTab,
|
|
568
|
+
eval: evalSource,
|
|
569
|
+
evalAsync: evalSourceAsync,
|
|
570
|
+
cookie,
|
|
571
|
+
localStorage,
|
|
572
|
+
fetch,
|
|
573
|
+
websocket,
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
export default browser;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
function bytesToBase64(bytes) {
|
|
2
|
+
let binary = '';
|
|
3
|
+
const chunkSize = 0x8000;
|
|
4
|
+
for (let i = 0; i < bytes.length; i += chunkSize) {
|
|
5
|
+
binary += String.fromCharCode(...Array.from(bytes.subarray(i, i + chunkSize)));
|
|
6
|
+
}
|
|
7
|
+
return btoa(binary);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
async function serializeFormData(form) {
|
|
11
|
+
const entries = [];
|
|
12
|
+
for (const [name, value] of form.entries()) {
|
|
13
|
+
if (typeof value === 'string') {
|
|
14
|
+
entries.push({ name, value });
|
|
15
|
+
continue;
|
|
16
|
+
}
|
|
17
|
+
const bytes = new Uint8Array(await value.arrayBuffer());
|
|
18
|
+
entries.push({
|
|
19
|
+
name,
|
|
20
|
+
file: {
|
|
21
|
+
data: bytesToBase64(bytes),
|
|
22
|
+
filename: typeof value.name === 'string' ? value.name : 'blob',
|
|
23
|
+
type: value.type || '',
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
return { kind: 'formdata', entries };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function serializeBody(raw, headers) {
|
|
31
|
+
const hasContentType = () =>
|
|
32
|
+
Object.keys(headers).some((key) => key.toLowerCase() === 'content-type');
|
|
33
|
+
if (typeof raw === 'string') return { body: raw };
|
|
34
|
+
if (raw instanceof URLSearchParams) {
|
|
35
|
+
if (!hasContentType()) {
|
|
36
|
+
headers['Content-Type'] = 'application/x-www-form-urlencoded;charset=UTF-8';
|
|
37
|
+
}
|
|
38
|
+
return { body: raw.toString() };
|
|
39
|
+
}
|
|
40
|
+
if (raw instanceof Blob) {
|
|
41
|
+
const bytes = new Uint8Array(await raw.arrayBuffer());
|
|
42
|
+
return { descriptor: { kind: 'blob', data: bytesToBase64(bytes), type: raw.type || '' } };
|
|
43
|
+
}
|
|
44
|
+
if (raw instanceof ArrayBuffer) {
|
|
45
|
+
return { descriptor: { kind: 'bytes', data: bytesToBase64(new Uint8Array(raw)) } };
|
|
46
|
+
}
|
|
47
|
+
if (ArrayBuffer.isView(raw)) {
|
|
48
|
+
const bytes = new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
|
|
49
|
+
return { descriptor: { kind: 'bytes', data: bytesToBase64(bytes) } };
|
|
50
|
+
}
|
|
51
|
+
if (typeof FormData !== 'undefined' && raw instanceof FormData) {
|
|
52
|
+
return { descriptor: await serializeFormData(raw) };
|
|
53
|
+
}
|
|
54
|
+
if (!hasContentType()) headers['Content-Type'] = 'application/json';
|
|
55
|
+
return { body: JSON.stringify(raw) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function bodyReconstruction(descriptor) {
|
|
59
|
+
if (!descriptor) return '';
|
|
60
|
+
return (
|
|
61
|
+
'const __b64 = (s) => { const bin = atob(s); const n = bin.length; ' +
|
|
62
|
+
'const u = new Uint8Array(n); for (let i = 0; i < n; i++) u[i] = bin.charCodeAt(i); return u; };' +
|
|
63
|
+
`const __body = ${JSON.stringify(descriptor)};` +
|
|
64
|
+
"if (__body.kind === 'bytes') { __init.body = __b64(__body.data); }" +
|
|
65
|
+
"else if (__body.kind === 'blob') { __init.body = new Blob([__b64(__body.data)], { type: __body.type }); }" +
|
|
66
|
+
"else if (__body.kind === 'formdata') { const __fd = new FormData(); " +
|
|
67
|
+
'for (const e of __body.entries) { ' +
|
|
68
|
+
'if (e.file) { __fd.append(e.name, new Blob([__b64(e.file.data)], { type: e.file.type }), e.file.filename); } ' +
|
|
69
|
+
'else { __fd.append(e.name, e.value); } } __init.body = __fd; }'
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function responseHandling(responseType) {
|
|
74
|
+
return (
|
|
75
|
+
'const h = {};' +
|
|
76
|
+
'r.headers.forEach((v, k) => { h[k] = v; });' +
|
|
77
|
+
"const ct = r.headers.get('content-type') || '';" +
|
|
78
|
+
`const __rt = ${JSON.stringify(responseType ?? null)};` +
|
|
79
|
+
'const __ctl = ct.toLowerCase();' +
|
|
80
|
+
"const __binPrefixes = ['image/','audio/','video/','application/octet-stream'," +
|
|
81
|
+
"'application/pdf','application/protobuf','application/x-protobuf','application/wasm','application/zip'];" +
|
|
82
|
+
"const __isXml = __ctl.indexOf('+xml') !== -1 || __ctl.indexOf('application/xml') === 0 || __ctl.indexOf('text/xml') === 0;" +
|
|
83
|
+
"const __isBinary = __rt === 'binary' || (__rt !== 'text' && __rt !== 'json' && !__isXml && " +
|
|
84
|
+
'__binPrefixes.some((p) => __ctl.indexOf(p) === 0));' +
|
|
85
|
+
'const __meta = { ok: r.ok, status: r.status, statusText: r.statusText, ' +
|
|
86
|
+
'url: r.url, redirected: !!r.redirected, headers: h };' +
|
|
87
|
+
'if (__isBinary) {' +
|
|
88
|
+
'const __u = new Uint8Array(await r.arrayBuffer());' +
|
|
89
|
+
"let __s = ''; const __cs = 0x8000;" +
|
|
90
|
+
'for (let __i = 0; __i < __u.length; __i += __cs) { ' +
|
|
91
|
+
'__s += String.fromCharCode.apply(null, __u.subarray(__i, __i + __cs)); }' +
|
|
92
|
+
"return Object.assign(__meta, { body: btoa(__s), bodyEncoding: 'base64' });" +
|
|
93
|
+
'}' +
|
|
94
|
+
'const t = await r.text();' +
|
|
95
|
+
'let b;' +
|
|
96
|
+
"const __jsonWanted = __rt === 'json' || (__rt !== 'text' && ct.indexOf('application/json') !== -1);" +
|
|
97
|
+
'if (__jsonWanted) { if (!t) { b = null; } else { try { b = JSON.parse(t); } catch (e) { b = t; } } }' +
|
|
98
|
+
'else { b = t; }' +
|
|
99
|
+
'return Object.assign(__meta, { body: b });'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function buildBrowserFetchScript(url, opts = {}) {
|
|
104
|
+
const headers = {};
|
|
105
|
+
for (const [key, value] of Object.entries(opts.headers ?? {})) {
|
|
106
|
+
if (typeof value === 'string') headers[key] = value;
|
|
107
|
+
}
|
|
108
|
+
const method = typeof opts.method === 'string' ? opts.method : 'GET';
|
|
109
|
+
const credentials =
|
|
110
|
+
opts.credentials === 'same-origin' || opts.credentials === 'omit'
|
|
111
|
+
? opts.credentials
|
|
112
|
+
: 'include';
|
|
113
|
+
const raw = opts.body;
|
|
114
|
+
const { body, descriptor } =
|
|
115
|
+
raw === undefined || raw === null ? {} : await serializeBody(raw, headers);
|
|
116
|
+
const init = { method, credentials, headers };
|
|
117
|
+
if (body !== undefined) init.body = body;
|
|
118
|
+
if (opts.mode !== undefined) init.mode = opts.mode;
|
|
119
|
+
if (opts.cache !== undefined) init.cache = opts.cache;
|
|
120
|
+
if (opts.redirect !== undefined) init.redirect = opts.redirect;
|
|
121
|
+
if (opts.referrer !== undefined) init.referrer = opts.referrer;
|
|
122
|
+
if (opts.referrerPolicy !== undefined) init.referrerPolicy = opts.referrerPolicy;
|
|
123
|
+
if (opts.integrity !== undefined) init.integrity = opts.integrity;
|
|
124
|
+
if (opts.keepalive !== undefined) init.keepalive = opts.keepalive;
|
|
125
|
+
const timeoutMs =
|
|
126
|
+
typeof opts.timeoutMs === 'number' && Number.isFinite(opts.timeoutMs) && opts.timeoutMs > 0
|
|
127
|
+
? Math.round(opts.timeoutMs)
|
|
128
|
+
: 0;
|
|
129
|
+
const timeoutScript = timeoutMs > 0 ? `__init.signal = AbortSignal.timeout(${timeoutMs});` : '';
|
|
130
|
+
return (
|
|
131
|
+
'(async () => {' +
|
|
132
|
+
`const __init = ${JSON.stringify(init)};` +
|
|
133
|
+
timeoutScript +
|
|
134
|
+
bodyReconstruction(descriptor) +
|
|
135
|
+
`const r = await fetch(${JSON.stringify(url)}, __init);` +
|
|
136
|
+
responseHandling(opts.responseType) +
|
|
137
|
+
'})()'
|
|
138
|
+
);
|
|
139
|
+
}
|
package/src/ws-router.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
export function installWsRouter(win) {
|
|
2
|
+
const w = win;
|
|
3
|
+
if (w.__sliccWsRouter) return;
|
|
4
|
+
|
|
5
|
+
const subs = new Map();
|
|
6
|
+
|
|
7
|
+
function compileUrlMatch(pattern) {
|
|
8
|
+
if (pattern === undefined) return undefined;
|
|
9
|
+
try {
|
|
10
|
+
return new RegExp(pattern);
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function isPlainObject(value) {
|
|
17
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function subsetMatch(value, template) {
|
|
21
|
+
for (const key of Object.keys(template)) {
|
|
22
|
+
const expected = template[key];
|
|
23
|
+
const actual = value[key];
|
|
24
|
+
if (isPlainObject(expected)) {
|
|
25
|
+
if (!isPlainObject(actual)) return false;
|
|
26
|
+
if (!subsetMatch(actual, expected)) return false;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
if (!Object.is(actual, expected)) return false;
|
|
30
|
+
}
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseFrame(raw, parseAs) {
|
|
35
|
+
if (parseAs === 'text') return raw;
|
|
36
|
+
try {
|
|
37
|
+
return JSON.parse(raw);
|
|
38
|
+
} catch {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function project(body, fields) {
|
|
44
|
+
if (!fields || fields.length === 0) return body;
|
|
45
|
+
if (!isPlainObject(body)) return body;
|
|
46
|
+
const out = {};
|
|
47
|
+
for (const field of fields) if (field in body) out[field] = body[field];
|
|
48
|
+
return out;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function report(subId, payload) {
|
|
52
|
+
const reporter = w.__sliccWsRouterReport;
|
|
53
|
+
if (typeof reporter !== 'function') return;
|
|
54
|
+
try {
|
|
55
|
+
reporter(JSON.stringify({ subId, payload }));
|
|
56
|
+
} catch {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function urlMatches(sub, url) {
|
|
62
|
+
if (sub.urlMatch === undefined) return true;
|
|
63
|
+
const re = sub._urlMatchRe;
|
|
64
|
+
if (re === null) return false;
|
|
65
|
+
return !re || re.test(url);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function whereMatches(sub, body) {
|
|
69
|
+
const where = sub.filter?.where;
|
|
70
|
+
if (!where || Object.keys(where).length === 0) return true;
|
|
71
|
+
if (!isPlainObject(body)) return false;
|
|
72
|
+
return subsetMatch(body, where);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function dispatchFrame(url, raw) {
|
|
76
|
+
if (subs.size === 0) return;
|
|
77
|
+
for (const sub of subs.values()) {
|
|
78
|
+
if (!urlMatches(sub, url)) continue;
|
|
79
|
+
const body = parseFrame(raw, sub.filter?.parseAs);
|
|
80
|
+
if (body === undefined) continue;
|
|
81
|
+
if (!whereMatches(sub, body)) continue;
|
|
82
|
+
report(sub.id, project(body, sub.filter?.project));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const seen = new WeakSet();
|
|
87
|
+
function wrapInstance(ws) {
|
|
88
|
+
if (seen.has(ws)) return;
|
|
89
|
+
seen.add(ws);
|
|
90
|
+
ws.addEventListener('message', (ev) => {
|
|
91
|
+
if (typeof ev.data !== 'string') return;
|
|
92
|
+
try {
|
|
93
|
+
dispatchFrame(ws.url, ev.data);
|
|
94
|
+
} catch {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const WS = w.WebSocket;
|
|
101
|
+
if (!WS) return;
|
|
102
|
+
const origSend = WS.prototype.send;
|
|
103
|
+
WS.prototype.send = function patchedSend(data) {
|
|
104
|
+
wrapInstance(this);
|
|
105
|
+
return origSend.call(this, data);
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function applyUrlMatchPatch(next, patch) {
|
|
109
|
+
if (!Object.prototype.hasOwnProperty.call(patch, 'urlMatch')) return;
|
|
110
|
+
if (patch.urlMatch === null) {
|
|
111
|
+
delete next.urlMatch;
|
|
112
|
+
next._urlMatchRe = undefined;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
if (typeof patch.urlMatch === 'string') {
|
|
116
|
+
next.urlMatch = patch.urlMatch;
|
|
117
|
+
next._urlMatchRe = compileUrlMatch(patch.urlMatch);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function applyFilterPatch(next, patch) {
|
|
122
|
+
if (!Object.prototype.hasOwnProperty.call(patch, 'filter')) return;
|
|
123
|
+
if (patch.filter === null) {
|
|
124
|
+
delete next.filter;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (patch.filter !== undefined) next.filter = patch.filter;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const router = {
|
|
131
|
+
register(sub) {
|
|
132
|
+
subs.set(sub.id, { ...sub, _urlMatchRe: compileUrlMatch(sub.urlMatch) });
|
|
133
|
+
},
|
|
134
|
+
update(id, patch) {
|
|
135
|
+
const cur = subs.get(id);
|
|
136
|
+
if (!cur) return;
|
|
137
|
+
const next = { ...cur, id };
|
|
138
|
+
applyUrlMatchPatch(next, patch);
|
|
139
|
+
applyFilterPatch(next, patch);
|
|
140
|
+
subs.set(id, next);
|
|
141
|
+
},
|
|
142
|
+
unregister(id) {
|
|
143
|
+
subs.delete(id);
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
Object.defineProperty(w, '__sliccWsRouter', { value: router, configurable: false });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const routerSource = `(${installWsRouter.toString()})(globalThis);`;
|
package/README.md
DELETED