@ai-ecoverse/sliccy-browser 0.1.2 → 0.1.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-ecoverse/sliccy-browser",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "sliccy:browser for jsh, over CDP",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -29,6 +29,6 @@
29
29
  "src"
30
30
  ],
31
31
  "dependencies": {
32
- "@ai-ecoverse/slicc-cdp": "1.25.1"
32
+ "@ai-ecoverse/slicc-cdp": "1.25.2"
33
33
  }
34
34
  }
package/src/browser.js CHANGED
@@ -2,14 +2,35 @@ import { appendFile } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { connect } from '@ai-ecoverse/slicc-cdp';
4
4
  import { buildBrowserFetchScript } from './fetch-script.js';
5
- import { routerSource } from './ws-router.js';
6
5
 
7
- const BINDING = '__sliccWsRouterReport';
8
6
  const LOAD_TIMEOUT_MS = 15000;
7
+ const HELD_FRAMES = 100;
8
+ const RECORDER = `(() => {
9
+ if (globalThis.__sliccWsSent) return true;
10
+ const sent = [];
11
+ const send = WebSocket.prototype.send;
12
+ WebSocket.prototype.send = function (data) {
13
+ if (typeof data === 'string') {
14
+ sent.push([this.url, data]);
15
+ if (sent.length > 64) sent.shift();
16
+ }
17
+ return send.call(this, data);
18
+ };
19
+ const take = (data) => {
20
+ const at = sent.findIndex((entry) => entry[1] === data);
21
+ return at < 0 ? null : sent.splice(at, 1)[0][0];
22
+ };
23
+ Object.defineProperty(globalThis, '__sliccWsSent', { value: Object.freeze({ take }) });
24
+ return true;
25
+ })()`;
9
26
 
10
27
  let opening;
11
28
  const sessions = new Map();
12
- const routers = new Set();
29
+ const tabsBySession = new Map();
30
+ const sockets = new Map();
31
+ const held = new Map();
32
+ const recorders = new Map();
33
+ const networkless = new Map();
13
34
  const subscribers = new Map();
14
35
  let seq = 1;
15
36
  let listening = false;
@@ -49,7 +70,11 @@ function closeIdle() {
49
70
  const closing = opening;
50
71
  opening = undefined;
51
72
  sessions.clear();
52
- routers.clear();
73
+ tabsBySession.clear();
74
+ sockets.clear();
75
+ held.clear();
76
+ recorders.clear();
77
+ networkless.clear();
53
78
  listening = false;
54
79
  closing.then(
55
80
  (client) => client.close(),
@@ -69,30 +94,152 @@ async function sessionFor(targetId) {
69
94
  const client = await openClient();
70
95
  const cached = sessions.get(targetId);
71
96
  if (cached) return { client, sessionId: cached };
97
+ listen(client);
72
98
  const sessionId = await client.attach(targetId);
99
+ tabsBySession.set(sessionId, targetId);
73
100
  await client.send('Page.enable', {}, sessionId).catch(ignoreMethod('Page.enable:'));
74
101
  await client.send('Runtime.enable', {}, sessionId).catch(ignoreMethod('Runtime.enable:'));
75
- await client.send('Network.enable', {}, sessionId).catch(ignoreMethod('Network.enable:'));
102
+ await client.send('Network.enable', {}, sessionId).catch((error) => {
103
+ ignoreMethod('Network.enable:')(error);
104
+ networkless.set(sessionId, error.message);
105
+ });
76
106
  sessions.set(targetId, sessionId);
77
- listen(client);
78
107
  return { client, sessionId };
79
108
  }
80
109
 
81
110
  function listen(client) {
82
111
  if (listening) return;
83
112
  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);
113
+ client.on('Network.webSocketCreated', (message) => {
114
+ const { requestId, url } = message?.params ?? {};
115
+ if (typeof requestId !== 'string' || typeof url !== 'string') return;
116
+ sockets.set(socketKey(message.sessionId, requestId), url);
117
+ });
118
+ client.on('Network.webSocketClosed', (message) => {
119
+ const key = socketKey(message?.sessionId, message?.params?.requestId);
120
+ sockets.delete(key);
121
+ held.delete(key);
95
122
  });
123
+ client.on('Network.webSocketFrameReceived', (message) => dispatchFrame(message));
124
+ client.on('Network.webSocketFrameSent', (message) => void learnUrl(client, message));
125
+ }
126
+
127
+ function socketKey(sessionId, requestId) {
128
+ return `${sessionId ?? ''} ${requestId ?? ''}`;
129
+ }
130
+
131
+ function dispatchFrame(message) {
132
+ const params = message?.params;
133
+ const response = params?.response;
134
+ if (response?.opcode !== 1 || typeof response.payloadData !== 'string') return;
135
+ const targetId = tabsBySession.get(message.sessionId);
136
+ if (targetId === undefined) return;
137
+ const key = socketKey(message.sessionId, params.requestId);
138
+ const url = sockets.get(key);
139
+ if (url !== undefined) return route(targetId, url, response.payloadData, () => true);
140
+ route(targetId, url, response.payloadData, (sub) => sub.urlMatch === undefined);
141
+ holdFrame(message.sessionId, targetId, key, response.payloadData);
142
+ }
143
+
144
+ function route(targetId, url, raw, which) {
145
+ for (const sub of subscribers.values()) {
146
+ if (sub.targetId !== targetId || !which(sub) || !urlMatches(sub, url)) continue;
147
+ const body = parseFrame(raw, sub.filter?.parseAs);
148
+ if (body === undefined || !whereMatches(sub.filter?.where, body)) continue;
149
+ deliver(sub, project(body, sub.filter?.project));
150
+ }
151
+ }
152
+
153
+ function holdFrame(sessionId, targetId, key, raw) {
154
+ const wanted = [...subscribers.values()].some(
155
+ (sub) => sub.targetId === targetId && sub.urlMatch !== undefined
156
+ );
157
+ if (!wanted) return;
158
+ const frames = held.get(key) ?? [];
159
+ frames.push(raw);
160
+ if (frames.length > HELD_FRAMES) frames.shift();
161
+ held.set(key, frames);
162
+ record(sessionId);
163
+ }
164
+
165
+ function record(sessionId) {
166
+ if (recorders.has(sessionId)) return recorders.get(sessionId);
167
+ const installing = openClient()
168
+ .then((client) =>
169
+ client.send('Runtime.evaluate', { expression: RECORDER, returnByValue: true }, sessionId)
170
+ )
171
+ .catch(() => undefined);
172
+ recorders.set(sessionId, installing);
173
+ return installing;
174
+ }
175
+
176
+ async function learnUrl(client, message) {
177
+ const sessionId = message?.sessionId;
178
+ const { requestId, response } = message?.params ?? {};
179
+ const key = socketKey(sessionId, requestId);
180
+ if (sockets.has(key) || !recorders.has(sessionId)) return;
181
+ if (response?.opcode !== 1 || typeof response.payloadData !== 'string') return;
182
+ await recorders.get(sessionId);
183
+ const found = await client
184
+ .send(
185
+ 'Runtime.evaluate',
186
+ {
187
+ expression: `globalThis.__sliccWsSent?.take(${JSON.stringify(response.payloadData)}) ?? null`,
188
+ returnByValue: true,
189
+ },
190
+ sessionId
191
+ )
192
+ .catch(() => undefined);
193
+ const url = found?.result?.value;
194
+ if (typeof url !== 'string' || sockets.has(key)) return;
195
+ sockets.set(key, url);
196
+ const frames = held.get(key) ?? [];
197
+ held.delete(key);
198
+ const targetId = tabsBySession.get(sessionId);
199
+ for (const raw of frames) route(targetId, url, raw, (sub) => sub.urlMatch !== undefined);
200
+ }
201
+
202
+ function urlMatches(sub, url) {
203
+ if (sub.urlMatch === undefined) return true;
204
+ return typeof url === 'string' && sub.urlMatchRe.test(url);
205
+ }
206
+
207
+ function isPlainObject(value) {
208
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
209
+ }
210
+
211
+ function subsetMatch(value, template) {
212
+ for (const key of Object.keys(template)) {
213
+ const expected = template[key];
214
+ const actual = value[key];
215
+ if (isPlainObject(expected)) {
216
+ if (!isPlainObject(actual) || !subsetMatch(actual, expected)) return false;
217
+ } else if (!Object.is(actual, expected)) {
218
+ return false;
219
+ }
220
+ }
221
+ return true;
222
+ }
223
+
224
+ function whereMatches(where, body) {
225
+ if (!isPlainObject(where) || Object.keys(where).length === 0) return true;
226
+ return isPlainObject(body) && subsetMatch(body, where);
227
+ }
228
+
229
+ function parseFrame(raw, parseAs) {
230
+ if (parseAs === 'text') return raw;
231
+ try {
232
+ return JSON.parse(raw);
233
+ } catch {
234
+ return undefined;
235
+ }
236
+ }
237
+
238
+ function project(body, fields) {
239
+ if (!Array.isArray(fields) || fields.length === 0 || !isPlainObject(body)) return body;
240
+ const out = {};
241
+ for (const field of fields) if (field in body) out[field] = body[field];
242
+ return out;
96
243
  }
97
244
 
98
245
  function targetIdOf(tab) {
@@ -415,39 +562,6 @@ function validateSink(forward) {
415
562
  }
416
563
  }
417
564
 
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
565
  function toInfo(sub) {
452
566
  const info = {
453
567
  id: sub.id,
@@ -464,24 +578,14 @@ function makeHandle(sub) {
464
578
  return {
465
579
  ...toInfo(sub),
466
580
  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;
581
+ if (patch.urlMatch === null) {
582
+ delete sub.urlMatch;
583
+ delete sub.urlMatchRe;
584
+ } else if (patch.urlMatch !== undefined) {
585
+ const next = patternOf(patch.urlMatch);
586
+ sub.urlMatchRe = compilePattern(next, 'browser.websocket');
587
+ sub.urlMatch = next;
475
588
  }
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
589
  if (patch.filter === null) delete sub.filter;
486
590
  else if (patch.filter !== undefined) sub.filter = patch.filter;
487
591
  return toInfo(sub);
@@ -489,29 +593,24 @@ function makeHandle(sub) {
489
593
  async close() {
490
594
  if (!subscribers.has(sub.id)) return false;
491
595
  subscribers.delete(sub.id);
492
- await using(() =>
493
- callRouter(sub.targetId, `globalThis.__sliccWsRouter.unregister(${JSON.stringify(sub.id)})`)
494
- );
596
+ await sub.writing;
597
+ release();
495
598
  return true;
496
599
  },
497
600
  };
498
601
  }
499
602
 
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 {
603
+ function deliver(sub, payload) {
604
+ const line = typeof payload === 'string' ? payload : JSON.stringify(payload);
605
+ if (sub.forward.sink === 'log') {
606
+ console.log(line);
513
607
  return;
514
608
  }
609
+ if (sub.forward.sink === 'vfs') {
610
+ sub.writing = sub.writing
611
+ .then(() => appendFile(sub.forward.path, `${line}\n`))
612
+ .catch(() => undefined);
613
+ }
515
614
  }
516
615
 
517
616
  export const websocket = {
@@ -535,23 +634,26 @@ export const websocket = {
535
634
  },
536
635
  };
537
636
  async function subscribe(sink) {
538
- await installRouter(targetId);
637
+ const urlMatchRe =
638
+ urlMatch === undefined ? undefined : compilePattern(urlMatch, 'browser.websocket');
639
+ const { sessionId } = await sessionFor(targetId);
640
+ if (networkless.has(sessionId)) {
641
+ throw new Error(`browser.websocket: ${networkless.get(sessionId)}`);
642
+ }
643
+ if (urlMatch !== undefined) await record(sessionId);
539
644
  const id = `wssub-${seq++}-${Date.now().toString(36)}`;
540
645
  const sub = {
541
646
  id,
542
647
  targetId,
543
648
  forward: sink,
544
649
  createdAt: new Date().toISOString(),
650
+ writing: Promise.resolve(),
545
651
  };
546
- if (urlMatch !== undefined) sub.urlMatch = urlMatch;
652
+ if (urlMatch !== undefined) {
653
+ sub.urlMatch = urlMatch;
654
+ sub.urlMatchRe = urlMatchRe;
655
+ }
547
656
  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
657
  subscribers.set(id, sub);
556
658
  return makeHandle(sub);
557
659
  }
package/src/ws-router.js DELETED
@@ -1,149 +0,0 @@
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);`;