scanruler-mcp 0.3.0 → 0.4.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/src/link.js CHANGED
@@ -1,293 +1,304 @@
1
- // SPDX-License-Identifier: AGPL-3.0-only
2
- // The link to the ScanRuler page: a WebSocket server on 127.0.0.1 that the
3
- // page connects to when its "Local agent connection" is on. A page is
4
- // accepted from an allowed origin, with the pairing token, one at a time.
5
- // Once in, the page says which commands it has; this side sends it commands
6
- // to run and gets their results back — see framing.js for the frames.
7
- //
8
- // Other copies of this server are let in beside the page, with the same
9
- // token: an agent may start more than one — Claude Desktop starts one for its
10
- // chats and one for its other sessions — and only one can hold the port.
11
- // The others send their calls through this one (relay.js); it passes them to
12
- // the page and the answers back, and tells them when the page comes and goes.
13
-
14
- import { EventEmitter } from 'node:events'
15
- import { WebSocketServer } from 'ws'
16
- import { decoder, encode } from './framing.js'
17
-
18
- /** The hosted app. Loopback pages — a dev server, `--serve` — are allowed
19
- * on any port; anything else takes `--allow-origin`. */
20
- const HOSTED_ORIGINS = ['https://scanruler.com', 'https://www.scanruler.com']
21
-
22
- const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]'])
23
-
24
- /** Whether a page from `origin` may connect. A connection without an Origin
25
- * is not from a browser page; the token alone decides for it. */
26
- export function originAllowed(origin, extra = []) {
27
- if (!origin) return true
28
- if (HOSTED_ORIGINS.includes(origin) || extra.includes(origin)) return true
29
- try {
30
- const url = new URL(origin)
31
- return (url.protocol === 'http:' || url.protocol === 'https:') && LOOPBACK.has(url.hostname)
32
- } catch {
33
- return false
34
- }
35
- }
36
-
37
- /** Close codes the page reads, to say why it was turned away. */
38
- export const CLOSE = { badToken: 4001, origin: 4003, taken: 4009, noHello: 4008 }
39
-
40
- /** Scans run to hundreds of megabytes. */
41
- export const MAX_PAYLOAD = 1024 ** 3
42
- const HEARTBEAT_MS = 15_000
43
- const HELLO_MS = 10_000
44
-
45
- /** Where a result carries a file's bytes, if it does. */
46
- const resultBinary = (result) => (result?.file?.bytes instanceof Uint8Array ? 'result.file.bytes' : undefined)
47
-
48
- /** A socket's frame, as the decoder takes it. */
49
- export const frameOf = (data, isBinary) => (isBinary ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : data.toString('utf8'))
50
-
51
- /**
52
- * Asking the far end of a socket something and hearing the answer, by id —
53
- * what the page link and a relay to another copy share. `socket` is the
54
- * socket asked; `page` what the page said about itself, null while there is
55
- * none.
56
- */
57
- export class Asker extends EventEmitter {
58
- constructor() {
59
- super()
60
- this.socket = null
61
- this.page = null
62
- this.pending = new Map()
63
- this.nextId = 1
64
- }
65
-
66
- get connected() {
67
- return this.page !== null
68
- }
69
-
70
- /** Take an answer, or a progress line, for something asked. */
71
- answer(message) {
72
- if (message.event === 'progress') {
73
- this.pending.get(message.id)?.onProgress?.(String(message.text ?? ''))
74
- return
75
- }
76
- const entry = this.pending.get(message.id)
77
- if (!entry) return
78
- this.pending.delete(message.id)
79
- if (message.error) entry.reject(message.error)
80
- else entry.resolve(message.result)
81
- }
82
-
83
- /** Turn down everything still waiting for an answer. */
84
- dropPending(message) {
85
- for (const { reject } of this.pending.values()) reject({ code: 'unavailable', message })
86
- this.pending.clear()
87
- }
88
-
89
- /** Ask the page something; resolves with its result, rejects with its
90
- * `{ code, message }`. `binaryPath` names bytes in the request that go as
91
- * a binary frame; `onProgress` hears the page's progress lines. */
92
- request(message, binaryPath, onProgress) {
93
- const ws = this.socket
94
- if (!ws || ws.readyState !== ws.OPEN) return Promise.reject({ code: 'unavailable', message: 'No ScanRuler page is connected.' })
95
- const id = this.nextId++
96
- return new Promise((resolve, reject) => {
97
- this.pending.set(id, { resolve, reject, onProgress })
98
- try {
99
- for (const frame of encode({ id, ...message }, binaryPath)) ws.send(frame)
100
- } catch (e) {
101
- this.pending.delete(id)
102
- reject({ code: 'internal', message: e.message })
103
- }
104
- })
105
- }
106
-
107
- /** Run a command on the page. `binaryKey` is the input field holding a
108
- * file's bytes, if one does. */
109
- run(name, input, binaryKey, onProgress) {
110
- return this.request({ method: 'run', name, input }, binaryKey ? `input.${binaryKey}` : undefined, onProgress)
111
- }
112
-
113
- /** Resolves true once a page is connected, false after `timeoutMs`. */
114
- waitForPage(timeoutMs) {
115
- return waitForPage(this, timeoutMs)
116
- }
117
- }
118
-
119
- /** Resolves true once `link` has a page, false after `timeoutMs`. */
120
- export function waitForPage(link, timeoutMs) {
121
- if (link.connected) return Promise.resolve(true)
122
- return new Promise((resolve) => {
123
- const done = (value) => {
124
- clearTimeout(timer)
125
- link.off('connected', onConnected)
126
- resolve(value)
127
- }
128
- const onConnected = () => done(true)
129
- const timer = setTimeout(() => done(false), timeoutMs)
130
- link.on('connected', onConnected)
131
- })
132
- }
133
-
134
- export class PageLink extends Asker {
135
- /** @param {{ token: string, origins?: string[], log?: (line: string) => void }} options */
136
- constructor({ token, origins = [], log = () => {} }) {
137
- super()
138
- this.token = token
139
- this.origins = origins
140
- this.log = log
141
- /** The other copies relaying through this one. */
142
- this.peers = new Set()
143
- this.wss = null
144
- }
145
-
146
- /** Take WebSocket upgrades on an HTTP server. */
147
- attach(server) {
148
- this.wss = new WebSocketServer({
149
- server,
150
- maxPayload: MAX_PAYLOAD,
151
- verifyClient: ({ origin }, done) => {
152
- if (originAllowed(origin, this.origins)) done(true)
153
- else {
154
- this.log(`Refused a page from ${origin}: not an allowed origin (see --allow-origin).`)
155
- done(false, 403, 'Origin not allowed')
156
- }
157
- },
158
- })
159
- // ws hands the HTTP server's errors on to this one too — a port another
160
- // copy holds among them. The HTTP server's own listener deals with them;
161
- // unheard here, Node would end the process over it.
162
- this.wss.on('error', () => {})
163
- this.wss.on('connection', (ws, req) => this.#greet(ws, req.headers.origin))
164
- const beat = setInterval(() => {
165
- for (const ws of this.wss.clients) {
166
- if (ws.alive === false) {
167
- ws.terminate()
168
- continue
169
- }
170
- ws.alive = false
171
- ws.ping()
172
- }
173
- }, HEARTBEAT_MS)
174
- beat.unref()
175
- this.wss.on('close', () => clearInterval(beat))
176
- }
177
-
178
- #greet(ws, origin) {
179
- ws.alive = true
180
- ws.on('pong', () => {
181
- ws.alive = true
182
- })
183
- const timer = setTimeout(() => ws.close(CLOSE.noHello, 'No hello'), HELLO_MS)
184
- let accepted = false
185
- const read = decoder((message) => {
186
- if (!accepted) {
187
- clearTimeout(timer)
188
- accepted = this.#hello(ws, message, origin)
189
- return
190
- }
191
- if (this.peers.has(ws)) this.#relay(ws, message)
192
- else this.#receive(message)
193
- })
194
- ws.on('message', (data, isBinary) => {
195
- try {
196
- read(frameOf(data, isBinary))
197
- } catch (e) {
198
- this.log(`A broken message ${this.peers.has(ws) ? 'from another scanruler-mcp' : 'from the page'}: ${e.message}`)
199
- ws.close(1003, 'Broken message')
200
- }
201
- })
202
- ws.on('close', () => {
203
- clearTimeout(timer)
204
- if (this.peers.delete(ws)) return
205
- if (this.socket !== ws) return
206
- this.socket = null
207
- this.page = null
208
- this.dropPending('The ScanRuler page closed the connection before it answered.')
209
- this.log('The ScanRuler page disconnected.')
210
- this.#tellPeers()
211
- this.emit('disconnected')
212
- })
213
- }
214
-
215
- /** The first message: the page's — who it is, its token, its commands — or
216
- * another copy's. True when it is taken in. */
217
- #hello(ws, message, origin) {
218
- const refuse = (code, reason) => {
219
- ws.send(JSON.stringify({ refused: reason }))
220
- ws.close(code, reason.slice(0, 100))
221
- this.log(`Refused ${message.peer ? 'another scanruler-mcp' : 'a page'}: ${reason}`)
222
- return false
223
- }
224
- if (message.peer && typeof message.peer === 'object') {
225
- if (message.peer.token !== this.token) return refuse(CLOSE.badToken, 'Its pairing token is not this one’s.')
226
- this.peers.add(ws)
227
- ws.send(JSON.stringify({ welcome: { server: 'scanruler-mcp', page: this.page } }))
228
- this.log(`Another scanruler-mcp${message.peer.version ? ` ${message.peer.version}` : ''} sends its calls through this one.`)
229
- return true
230
- }
231
- const hello = message.hello
232
- if (!hello || typeof hello !== 'object') return refuse(CLOSE.noHello, 'The page did not say hello.')
233
- if (hello.token !== this.token) {
234
- return refuse(CLOSE.badToken, 'The pairing token does not match — copy it again from `npx scanruler-mcp --pair` into ScanRuler’s Settings.')
235
- }
236
- if (this.socket && this.socket.readyState === this.socket.OPEN) {
237
- return refuse(CLOSE.taken, 'Another ScanRuler tab is connected to this agent already — close the connection there first.')
238
- }
239
- this.socket = ws
240
- const { token: _token, ...page } = hello
241
- this.page = { ...page, origin: origin ?? null, commands: Array.isArray(hello.commands) ? hello.commands : [] }
242
- ws.send(JSON.stringify({ welcome: { server: 'scanruler-mcp' } }))
243
- this.log(`ScanRuler ${page.version ?? ''} connected${origin ? ` from ${origin}` : ''}, with ${this.page.commands.length} commands.`)
244
- this.#tellPeers()
245
- this.emit('connected', this.page)
246
- return true
247
- }
248
-
249
- #receive(message) {
250
- // The tab hidden or shown again — in the background, its window
251
- // covered, the screen locked — for the status to say.
252
- if (message.event === 'visibility') {
253
- this.page = { ...this.page, hidden: message.hidden === true }
254
- this.#tellPeers()
255
- this.emit('visibility', this.page.hidden)
256
- return
257
- }
258
- if (message.event === 'commands' && Array.isArray(message.commands)) {
259
- this.page = { ...this.page, commands: message.commands }
260
- this.#tellPeers()
261
- this.emit('commands', message.commands)
262
- return
263
- }
264
- this.answer(message)
265
- }
266
-
267
- /** Tell the other copies what page there is now. */
268
- #tellPeers() {
269
- const frame = JSON.stringify({ event: 'page', page: this.page })
270
- for (const ws of this.peers) if (ws.readyState === ws.OPEN) ws.send(frame)
271
- }
272
-
273
- /** Pass another copy's request to the page, and the page's answer back. */
274
- #relay(ws, { id, ...message }) {
275
- const send = (reply, binaryPath) => {
276
- if (ws.readyState !== ws.OPEN) return
277
- for (const frame of encode({ id, ...reply }, binaryPath)) ws.send(frame)
278
- }
279
- if (message.method !== 'run' && message.method !== 'list') {
280
- send({ error: { code: 'invalid_input', message: `No such request: ${message.method}.` } })
281
- return
282
- }
283
- const key = Object.keys(message.input ?? {}).find((k) => message.input[k] instanceof Uint8Array)
284
- this.request(message, key ? `input.${key}` : undefined, (text) => send({ event: 'progress', text }))
285
- .then((result) => send({ result }, resultBinary(result)))
286
- .catch((e) => send({ error: { code: e?.code ?? 'failed', message: e?.message ?? String(e) } }))
287
- }
288
-
289
- close() {
290
- for (const ws of this.wss?.clients ?? []) ws.terminate()
291
- this.wss?.close()
292
- }
293
- }
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // The link to the ScanRuler page: a WebSocket server on 127.0.0.1 that the
3
+ // page connects to when its "Local agent connection" is on. A page is
4
+ // accepted from an allowed origin, with the pairing token, one at a time.
5
+ // Once in, the page says which commands it has; this side sends it commands
6
+ // to run and gets their results back — see framing.js for the frames.
7
+ //
8
+ // Other copies of this server are let in beside the page, with the same
9
+ // token: an agent may start more than one — Claude Desktop starts one for its
10
+ // chats and one for its other sessions — and only one can hold the port.
11
+ // The others send their calls through this one (relay.js); it passes them to
12
+ // the page and the answers back, and tells them when the page comes and goes.
13
+
14
+ import { EventEmitter } from 'node:events'
15
+ import { WebSocketServer } from 'ws'
16
+ import { decoder, encode } from './framing.js'
17
+
18
+ /** The hosted app. Loopback pages — a dev server, `--serve` — are allowed
19
+ * on any port; anything else takes `--allow-origin`. */
20
+ const HOSTED_ORIGINS = ['https://scanruler.com', 'https://www.scanruler.com']
21
+
22
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]'])
23
+
24
+ /** Whether a page from `origin` may connect. A connection without an Origin
25
+ * is not from a browser page; the token alone decides for it. */
26
+ export function originAllowed(origin, extra = []) {
27
+ if (!origin) return true
28
+ if (HOSTED_ORIGINS.includes(origin) || extra.includes(origin)) return true
29
+ try {
30
+ const url = new URL(origin)
31
+ return (url.protocol === 'http:' || url.protocol === 'https:') && LOOPBACK.has(url.hostname)
32
+ } catch {
33
+ return false
34
+ }
35
+ }
36
+
37
+ /** Close codes the page reads, to say why it was turned away. */
38
+ export const CLOSE = { badToken: 4001, origin: 4003, taken: 4009, noHello: 4008 }
39
+
40
+ /** Scans run to hundreds of megabytes. */
41
+ export const MAX_PAYLOAD = 1024 ** 3
42
+ const HEARTBEAT_MS = 15_000
43
+ const HELLO_MS = 10_000
44
+
45
+ /** Where a result carries a file's bytes, if it does. */
46
+ const resultBinary = (result) => (result?.file?.bytes instanceof Uint8Array ? 'result.file.bytes' : undefined)
47
+
48
+ /** What a call dropped unanswered says after why. */
49
+ export const MAY_HAVE_LANDED =
50
+ 'If it was a change, it may still have been made on the page: read session_state (history.undo names the last step taken) before running it again, rather than retry it blindly.'
51
+
52
+ /** A socket's frame, as the decoder takes it. */
53
+ export const frameOf = (data, isBinary) => (isBinary ? new Uint8Array(data.buffer, data.byteOffset, data.byteLength) : data.toString('utf8'))
54
+
55
+ /**
56
+ * Asking the far end of a socket something and hearing the answer, by id —
57
+ * what the page link and a relay to another copy share. `socket` is the
58
+ * socket asked; `page` what the page said about itself, null while there is
59
+ * none.
60
+ */
61
+ export class Asker extends EventEmitter {
62
+ constructor() {
63
+ super()
64
+ this.socket = null
65
+ this.page = null
66
+ this.pending = new Map()
67
+ this.nextId = 1
68
+ }
69
+
70
+ get connected() {
71
+ return this.page !== null
72
+ }
73
+
74
+ /** Take an answer, or a progress line, for something asked. */
75
+ answer(message) {
76
+ if (message.event === 'progress') {
77
+ this.pending.get(message.id)?.onProgress?.(String(message.text ?? ''))
78
+ return
79
+ }
80
+ const entry = this.pending.get(message.id)
81
+ if (!entry) return
82
+ this.pending.delete(message.id)
83
+ if (message.error) entry.reject(message.error)
84
+ else entry.resolve(message.result)
85
+ }
86
+
87
+ /** Turn down everything still waiting for an answer — saying, besides
88
+ * why, that a change may have been made all the same: the page may have
89
+ * run it and gone before its answer came back. */
90
+ dropPending(message) {
91
+ for (const { reject } of this.pending.values()) reject({ code: 'unavailable', message: `${message} ${MAY_HAVE_LANDED}` })
92
+ this.pending.clear()
93
+ }
94
+
95
+ /** Ask the page something; resolves with its result, rejects with its
96
+ * `{ code, message }`. `binaryPath` names bytes in the request that go as
97
+ * a binary frame; `onProgress` hears the page's progress lines. */
98
+ request(message, binaryPath, onProgress) {
99
+ const ws = this.socket
100
+ if (!ws || ws.readyState !== ws.OPEN) return Promise.reject({ code: 'unavailable', message: 'No ScanRuler page is connected.' })
101
+ const id = this.nextId++
102
+ return new Promise((resolve, reject) => {
103
+ this.pending.set(id, { resolve, reject, onProgress })
104
+ try {
105
+ for (const frame of encode({ id, ...message }, binaryPath)) ws.send(frame)
106
+ } catch (e) {
107
+ this.pending.delete(id)
108
+ reject({ code: 'internal', message: e.message })
109
+ }
110
+ })
111
+ }
112
+
113
+ /** Run a command on the page. `binaryKey` is the input field holding a
114
+ * file's bytes, if one does; `job` the job the run is part of, which the
115
+ * page keeps its outcome under (session.recent); `deferred`, that more
116
+ * changes follow straight after it, which build once after the last. */
117
+ run(name, input, binaryKey, onProgress, job, deferred = false) {
118
+ return this.request({ method: 'run', name, input, ...(job ? { job } : {}), ...(deferred ? { deferred: true } : {}) }, binaryKey ? `input.${binaryKey}` : undefined, onProgress)
119
+ }
120
+
121
+ /** Resolves true once a page is connected, false after `timeoutMs`. */
122
+ waitForPage(timeoutMs) {
123
+ return waitForPage(this, timeoutMs)
124
+ }
125
+ }
126
+
127
+ /** Resolves true once `link` has a page, false after `timeoutMs`. */
128
+ export function waitForPage(link, timeoutMs) {
129
+ if (link.connected) return Promise.resolve(true)
130
+ return new Promise((resolve) => {
131
+ const done = (value) => {
132
+ clearTimeout(timer)
133
+ link.off('connected', onConnected)
134
+ resolve(value)
135
+ }
136
+ const onConnected = () => done(true)
137
+ const timer = setTimeout(() => done(false), timeoutMs)
138
+ link.on('connected', onConnected)
139
+ })
140
+ }
141
+
142
+ export class PageLink extends Asker {
143
+ /** @param {{ token: string, origins?: string[], log?: (line: string) => void, version?: string }} options */
144
+ constructor({ token, origins = [], log = () => {}, version = '' }) {
145
+ super()
146
+ this.token = token
147
+ /** This server's version, in the page's welcome — the page tells an out
148
+ * of date server by it (before 0.4.0 the welcome had none). */
149
+ this.version = version
150
+ this.origins = origins
151
+ this.log = log
152
+ /** The other copies relaying through this one. */
153
+ this.peers = new Set()
154
+ this.wss = null
155
+ }
156
+
157
+ /** Take WebSocket upgrades on an HTTP server. */
158
+ attach(server) {
159
+ this.wss = new WebSocketServer({
160
+ server,
161
+ maxPayload: MAX_PAYLOAD,
162
+ verifyClient: ({ origin }, done) => {
163
+ if (originAllowed(origin, this.origins)) done(true)
164
+ else {
165
+ this.log(`Refused a page from ${origin}: not an allowed origin (see --allow-origin).`)
166
+ done(false, 403, 'Origin not allowed')
167
+ }
168
+ },
169
+ })
170
+ // ws hands the HTTP server's errors on to this one too — a port another
171
+ // copy holds among them. The HTTP server's own listener deals with them;
172
+ // unheard here, Node would end the process over it.
173
+ this.wss.on('error', () => {})
174
+ this.wss.on('connection', (ws, req) => this.#greet(ws, req.headers.origin))
175
+ const beat = setInterval(() => {
176
+ for (const ws of this.wss.clients) {
177
+ if (ws.alive === false) {
178
+ ws.terminate()
179
+ continue
180
+ }
181
+ ws.alive = false
182
+ ws.ping()
183
+ }
184
+ }, HEARTBEAT_MS)
185
+ beat.unref()
186
+ this.wss.on('close', () => clearInterval(beat))
187
+ }
188
+
189
+ #greet(ws, origin) {
190
+ ws.alive = true
191
+ ws.on('pong', () => {
192
+ ws.alive = true
193
+ })
194
+ const timer = setTimeout(() => ws.close(CLOSE.noHello, 'No hello'), HELLO_MS)
195
+ let accepted = false
196
+ const read = decoder((message) => {
197
+ if (!accepted) {
198
+ clearTimeout(timer)
199
+ accepted = this.#hello(ws, message, origin)
200
+ return
201
+ }
202
+ if (this.peers.has(ws)) this.#relay(ws, message)
203
+ else this.#receive(message)
204
+ })
205
+ ws.on('message', (data, isBinary) => {
206
+ try {
207
+ read(frameOf(data, isBinary))
208
+ } catch (e) {
209
+ this.log(`A broken message ${this.peers.has(ws) ? 'from another scanruler-mcp' : 'from the page'}: ${e.message}`)
210
+ ws.close(1003, 'Broken message')
211
+ }
212
+ })
213
+ ws.on('close', () => {
214
+ clearTimeout(timer)
215
+ if (this.peers.delete(ws)) return
216
+ if (this.socket !== ws) return
217
+ this.socket = null
218
+ this.page = null
219
+ this.dropPending('The ScanRuler page closed the connection before it answered.')
220
+ this.log('The ScanRuler page disconnected.')
221
+ this.#tellPeers()
222
+ this.emit('disconnected')
223
+ })
224
+ }
225
+
226
+ /** The first message: the page's — who it is, its token, its commands — or
227
+ * another copy's. True when it is taken in. */
228
+ #hello(ws, message, origin) {
229
+ const refuse = (code, reason) => {
230
+ ws.send(JSON.stringify({ refused: reason }))
231
+ ws.close(code, reason.slice(0, 100))
232
+ this.log(`Refused ${message.peer ? 'another scanruler-mcp' : 'a page'}: ${reason}`)
233
+ return false
234
+ }
235
+ if (message.peer && typeof message.peer === 'object') {
236
+ if (message.peer.token !== this.token) return refuse(CLOSE.badToken, 'Its pairing token is not this one’s.')
237
+ this.peers.add(ws)
238
+ ws.send(JSON.stringify({ welcome: { server: 'scanruler-mcp', page: this.page } }))
239
+ this.log(`Another scanruler-mcp${message.peer.version ? ` ${message.peer.version}` : ''} sends its calls through this one.`)
240
+ return true
241
+ }
242
+ const hello = message.hello
243
+ if (!hello || typeof hello !== 'object') return refuse(CLOSE.noHello, 'The page did not say hello.')
244
+ if (hello.token !== this.token) {
245
+ return refuse(CLOSE.badToken, 'The pairing token does not match — copy it again from `npx -y scanruler-mcp@latest --pair` into ScanRuler’s Settings.')
246
+ }
247
+ if (this.socket && this.socket.readyState === this.socket.OPEN) {
248
+ return refuse(CLOSE.taken, 'Another ScanRuler tab is connected to this agent already — close the connection there first.')
249
+ }
250
+ this.socket = ws
251
+ const { token: _token, ...page } = hello
252
+ this.page = { ...page, origin: origin ?? null, commands: Array.isArray(hello.commands) ? hello.commands : [] }
253
+ ws.send(JSON.stringify({ welcome: { server: 'scanruler-mcp', ...(this.version ? { version: this.version } : {}) } }))
254
+ this.log(`ScanRuler ${page.version ?? ''} connected${origin ? ` from ${origin}` : ''}, with ${this.page.commands.length} commands.`)
255
+ this.#tellPeers()
256
+ this.emit('connected', this.page)
257
+ return true
258
+ }
259
+
260
+ #receive(message) {
261
+ // The tab hidden or shown again — in the background, its window
262
+ // covered, the screen locked — for the status to say.
263
+ if (message.event === 'visibility') {
264
+ this.page = { ...this.page, hidden: message.hidden === true }
265
+ this.#tellPeers()
266
+ this.emit('visibility', this.page.hidden)
267
+ return
268
+ }
269
+ if (message.event === 'commands' && Array.isArray(message.commands)) {
270
+ this.page = { ...this.page, commands: message.commands }
271
+ this.#tellPeers()
272
+ this.emit('commands', message.commands)
273
+ return
274
+ }
275
+ this.answer(message)
276
+ }
277
+
278
+ /** Tell the other copies what page there is now. */
279
+ #tellPeers() {
280
+ const frame = JSON.stringify({ event: 'page', page: this.page })
281
+ for (const ws of this.peers) if (ws.readyState === ws.OPEN) ws.send(frame)
282
+ }
283
+
284
+ /** Pass another copy's request to the page, and the page's answer back. */
285
+ #relay(ws, { id, ...message }) {
286
+ const send = (reply, binaryPath) => {
287
+ if (ws.readyState !== ws.OPEN) return
288
+ for (const frame of encode({ id, ...reply }, binaryPath)) ws.send(frame)
289
+ }
290
+ if (message.method !== 'run' && message.method !== 'list') {
291
+ send({ error: { code: 'invalid_input', message: `No such request: ${message.method}.` } })
292
+ return
293
+ }
294
+ const key = Object.keys(message.input ?? {}).find((k) => message.input[k] instanceof Uint8Array)
295
+ this.request(message, key ? `input.${key}` : undefined, (text) => send({ event: 'progress', text }))
296
+ .then((result) => send({ result }, resultBinary(result)))
297
+ .catch((e) => send({ error: { code: e?.code ?? 'failed', message: e?.message ?? String(e) } }))
298
+ }
299
+
300
+ close() {
301
+ for (const ws of this.wss?.clients ?? []) ws.terminate()
302
+ this.wss?.close()
303
+ }
304
+ }