@kinocode/dsh-mobile-bridge 0.0.0-stage → 0.3.2

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,149 @@
1
+ // What a phone may see about a pending request.
2
+ // - `describe` builds the full in-app view (served only to signed devices).
3
+ // - `pushPreview` builds what leaves the computer through the relay and FCM: redacted by default.
4
+
5
+ const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g
6
+
7
+ /**
8
+ * @param {string} text
9
+ * @param {number} max
10
+ */
11
+ export function clip(text, max) {
12
+ const clean = String(text ?? '').replace(CONTROL_CHARS, '').trim()
13
+ if (clean.length <= max) return clean
14
+ return `${clean.slice(0, Math.max(0, max - 1))}…`
15
+ }
16
+
17
+ /**
18
+ * Human-readable arguments: prefer a shell command, then a path, else compact JSON.
19
+ * @param {string} raw - tool-call arguments JSON text
20
+ */
21
+ export function formatArguments(raw) {
22
+ if (typeof raw !== 'string' || raw === '') return ''
23
+ let parsed
24
+ try {
25
+ parsed = JSON.parse(raw)
26
+ } catch {
27
+ return raw
28
+ }
29
+ if (parsed === null || typeof parsed !== 'object') return String(parsed)
30
+ for (const key of ['command', 'cmd', 'script']) {
31
+ const value = parsed[key]
32
+ if (typeof value === 'string') return value
33
+ if (Array.isArray(value) && value.every(item => typeof item === 'string')) return value.join(' ')
34
+ }
35
+ for (const key of ['path', 'file_path', 'filePath', 'url', 'pattern']) {
36
+ if (typeof parsed[key] === 'string') return `${key}: ${parsed[key]}`
37
+ }
38
+ return JSON.stringify(parsed)
39
+ }
40
+
41
+ /**
42
+ * @typedef {object} QuestionView
43
+ * @property {string} id
44
+ * @property {string} question
45
+ * @property {string} header
46
+ * @property {boolean} multiSelect
47
+ * @property {string[]} options
48
+ */
49
+
50
+ /**
51
+ * Normalize ask_user_question items from a forwarded request.
52
+ * @param {unknown} questions
53
+ * @returns {QuestionView[]}
54
+ */
55
+ export function questionViews(questions) {
56
+ if (!Array.isArray(questions)) return []
57
+ return questions
58
+ .filter(q => q !== null && typeof q === 'object' && typeof q.id === 'string' && typeof q.question === 'string')
59
+ .slice(0, 20)
60
+ .map(q => ({
61
+ id: q.id,
62
+ question: clip(q.question, 2000),
63
+ header: typeof q.header === 'string' ? clip(q.header, 80) : '',
64
+ multiSelect: q.multiSelect === true,
65
+ options: Array.isArray(q.options)
66
+ ? q.options.filter(o => o !== null && typeof o === 'object' && typeof o.label === 'string').slice(0, 20).map(o => clip(o.label, 200))
67
+ : [],
68
+ }))
69
+ }
70
+
71
+ /**
72
+ * Full view for the in-app inbox.
73
+ * @param {import('./gateway-client.mjs').PendingEvent} pending
74
+ * @param {import('./sessions.mjs').SessionTracker} sessions
75
+ * @param {number} maxChars
76
+ */
77
+ export function describe(pending, sessions, maxChars) {
78
+ const request = pending.request
79
+ const base = {
80
+ eventId: pending.eventId,
81
+ kind: pending.kind,
82
+ sessionId: pending.sessionId,
83
+ sessionTitle: sessions.title(pending.sessionId),
84
+ receivedAt: pending.receivedAt,
85
+ }
86
+ if (pending.kind === 'approval') {
87
+ const callId = typeof request.callId === 'string' ? request.callId : undefined
88
+ const call = sessions.call(pending.sessionId, callId)
89
+ return {
90
+ ...base,
91
+ toolName: typeof request.toolName === 'string' ? request.toolName : '',
92
+ reason: typeof request.reason === 'string' ? clip(request.reason, 1000) : '',
93
+ preview: call === undefined ? '' : clip(formatArguments(call.arguments), maxChars),
94
+ questions: [],
95
+ }
96
+ }
97
+ return { ...base, toolName: 'ask_user_question', reason: '', preview: '', questions: questionViews(request.questions) }
98
+ }
99
+
100
+ /**
101
+ * Text that may leave the computer in a push notification.
102
+ * @param {ReturnType<typeof describe>} view
103
+ * @param {'redacted'|'full'} mode
104
+ */
105
+ export function pushPreview(view, mode) {
106
+ if (view.kind === 'approval') {
107
+ if (mode !== 'full') return view.toolName
108
+ return clip(view.preview !== '' ? `${view.toolName}: ${view.preview}` : `${view.toolName} ${view.reason}`, 400)
109
+ }
110
+ const first = view.questions[0]
111
+ if (mode !== 'full' || first === undefined) return first?.header ?? ''
112
+ return clip(first.question, 400)
113
+ }
114
+
115
+ /**
116
+ * Validate an answer value against the pending request.
117
+ * @param {ReturnType<typeof describe>} view
118
+ * @param {unknown} value
119
+ * @returns {unknown} the value to forward
120
+ * @throws {Error} with `.code = 'invalid_value'`
121
+ */
122
+ export function validateAnswer(view, value) {
123
+ const fail = (message) => Object.assign(new Error(message), { code: 'invalid_value' })
124
+ if (view.kind === 'approval') {
125
+ if (value === 'allowed-once' || value === 'rejected') return value
126
+ throw fail('approval value must be "allowed-once" or "rejected"')
127
+ }
128
+ if (value === null || typeof value !== 'object' || !Array.isArray(value.answers)) throw fail('answers array required')
129
+ const byId = new Map(view.questions.map(q => [q.id, q]))
130
+ const seen = new Set()
131
+ const answers = value.answers.map((answer) => {
132
+ if (answer === null || typeof answer !== 'object' || typeof answer.id !== 'string') throw fail('answer id required')
133
+ const question = byId.get(answer.id)
134
+ if (question === undefined || seen.has(answer.id)) throw fail('unknown or duplicate question id')
135
+ seen.add(answer.id)
136
+ const selected = Array.isArray(answer.selected) ? answer.selected : []
137
+ if (!selected.every(label => typeof label === 'string' && question.options.includes(label))) throw fail('selected must be option labels')
138
+ if (new Set(selected).size !== selected.length) throw fail('duplicate selection')
139
+ if (!question.multiSelect && selected.length > 1) throw fail('single-select question')
140
+ const out = { id: answer.id, selected }
141
+ if (answer.custom !== undefined) {
142
+ if (typeof answer.custom !== 'string' || answer.custom.length > 4000) throw fail('custom must be a string ≤ 4000 chars')
143
+ if (answer.custom.trim() !== '') out.custom = answer.custom
144
+ }
145
+ return out
146
+ })
147
+ if (seen.size !== byId.size) throw fail('every question must be answered')
148
+ return { answers }
149
+ }
package/lib/push.mjs ADDED
@@ -0,0 +1,173 @@
1
+ // Decides which phones hear about what, and what text leaves the computer (docs/PROTOCOL.md §6).
2
+
3
+ import { clip, describe, pushPreview } from './preview.mjs'
4
+
5
+ const NOTIFIED_TTL_MS = 6 * 60 * 60 * 1000
6
+ const TURN_STATUSES = new Set(['completed', 'error', 'blocked', 'max-tokens', 'aborted', 'interrupted'])
7
+
8
+ export class Notifier {
9
+ /**
10
+ * @param {object} deps
11
+ * @param {import('./state.mjs').StateStore} deps.state
12
+ * @param {import('./sessions.mjs').SessionTracker} deps.sessions
13
+ * @param {() => (import('./relay-tunnel.mjs').RelayTunnel | undefined)} deps.tunnel
14
+ * @param {number} deps.previewMaxChars
15
+ * @param {(...args: unknown[]) => void} deps.log
16
+ * @param {() => number} [deps.now]
17
+ */
18
+ constructor(deps) {
19
+ this.deps = deps
20
+ this.now = deps.now ?? Date.now
21
+ /** eventId -> {devices, sessionId, at} so `resolved` reaches exactly the phones that were told. */
22
+ this.notified = new Map()
23
+ }
24
+
25
+ /** @param {import('./gateway-client.mjs').PendingEvent} pending */
26
+ async pending(pending) {
27
+ // The Gateway redelivers open requests after a reconnect; notify once per request.
28
+ if (this.notified.has(pending.eventId)) return
29
+ const watchers = this.deps.state.watchers(pending.sessionId)
30
+ if (watchers.length === 0) {
31
+ this.deps.log('push: no watching phone', JSON.stringify({ kind: pending.kind, sessionId: pending.sessionId }))
32
+ return
33
+ }
34
+ const view = describe(pending, this.deps.sessions, this.deps.previewMaxChars)
35
+ const title = view.sessionTitle !== '' ? clip(view.sessionTitle, 80) : 'DeepSeek Harness'
36
+ const firstQuestion = view.questions[0]
37
+ const groups = groupByMode(watchers)
38
+ const told = new Set()
39
+ for (const [mode, devices] of groups) {
40
+ /** @type {Record<string, string>} */
41
+ const data = {
42
+ v: '1',
43
+ kind: pending.kind,
44
+ hostId: this.deps.state.hostId,
45
+ sessionId: pending.sessionId,
46
+ eventId: pending.eventId,
47
+ title,
48
+ preview: pushPreview(view, mode),
49
+ createdAt: String(pending.receivedAt),
50
+ }
51
+ if (pending.kind === 'approval') data.toolName = clip(view.toolName, 80)
52
+ if (pending.kind === 'question' && firstQuestion !== undefined) {
53
+ data.questionId = firstQuestion.id
54
+ data.multiSelect = String(firstQuestion.multiSelect)
55
+ const usable = view.questions.length === 1 && !firstQuestion.multiSelect && firstQuestion.options.length > 0 && firstQuestion.options.length <= 3
56
+ data.options = JSON.stringify(usable ? firstQuestion.options.map(o => clip(o, 40)) : [])
57
+ }
58
+ if (await this.send(devices, data, { collapseKey: pending.eventId, ttlSec: 6 * 3600 })) {
59
+ for (const [id] of devices) told.add(id)
60
+ }
61
+ }
62
+ if (told.size > 0) this.remember(pending.eventId, told, pending.sessionId)
63
+ }
64
+
65
+ /**
66
+ * Withdraw notifications for a request that is no longer pending.
67
+ * @param {string} eventId
68
+ * @param {string} [answeredBy] - device id that answered (it updates its own notification)
69
+ */
70
+ async resolved(eventId, answeredBy) {
71
+ const entry = this.notified.get(eventId)
72
+ if (entry === undefined) return
73
+ this.notified.delete(eventId)
74
+ const devices = [...entry.devices]
75
+ .filter(id => id !== answeredBy)
76
+ .map(id => [id, this.deps.state.device(id)])
77
+ .filter(([, record]) => record?.fcmToken)
78
+ if (devices.length === 0) return
79
+ await this.send(devices, {
80
+ v: '1',
81
+ kind: 'resolved',
82
+ hostId: this.deps.state.hostId,
83
+ sessionId: entry.sessionId,
84
+ eventId,
85
+ createdAt: String(this.now()),
86
+ }, { collapseKey: eventId, ttlSec: 6 * 3600 })
87
+ }
88
+
89
+ /**
90
+ * Withdraw every notification this process sent for a still-open request, for example when DSH
91
+ * is quitting and the requests die with it. Best effort; the app also drops notifications that
92
+ * are missing from `/v1/pending` when it next lists them.
93
+ */
94
+ async withdrawAll() {
95
+ const eventIds = [...this.notified.keys()]
96
+ await Promise.allSettled(eventIds.map(eventId => this.resolved(eventId)))
97
+ return eventIds.length
98
+ }
99
+
100
+ /**
101
+ * @param {string} sessionId
102
+ * @param {string} reason - TurnEndReason kind
103
+ */
104
+ async turnEnded(sessionId, reason) {
105
+ if (!TURN_STATUSES.has(reason)) return
106
+ const watchers = this.deps.state.watchers(sessionId)
107
+ if (watchers.length === 0) {
108
+ this.deps.log('push: no watching phone', JSON.stringify({ kind: 'turn', sessionId }))
109
+ return
110
+ }
111
+ const title = this.deps.sessions.title(sessionId)
112
+ for (const [mode, devices] of groupByMode(watchers)) {
113
+ const text = this.deps.sessions.lastAssistantText(sessionId)
114
+ await this.send(devices, {
115
+ v: '1',
116
+ kind: 'turn',
117
+ hostId: this.deps.state.hostId,
118
+ sessionId,
119
+ eventId: '',
120
+ status: reason,
121
+ title: title !== '' ? clip(title, 80) : 'DeepSeek Harness',
122
+ preview: mode === 'full' ? clip(text, 200) : '',
123
+ createdAt: String(this.now()),
124
+ }, { collapseKey: `turn-${sessionId}`, ttlSec: 3600 })
125
+ }
126
+ }
127
+
128
+ /**
129
+ * @param {Array<[string, import('./state.mjs').DeviceRecord]>} devices
130
+ * @param {Record<string, string>} data
131
+ * @param {{collapseKey?: string, ttlSec?: number}} options
132
+ */
133
+ async send(devices, data, options) {
134
+ const tunnel = this.deps.tunnel()
135
+ if (tunnel === undefined || !tunnel.connected) {
136
+ this.deps.log('push skipped: relay not connected', JSON.stringify({ kind: data.kind }))
137
+ return false
138
+ }
139
+ const tokens = devices.map(([, record]) => record.fcmToken).filter(Boolean)
140
+ const result = await tunnel.push(tokens, data, options)
141
+ this.deps.log('push', JSON.stringify({ kind: data.kind, devices: tokens.length, ok: result.ok }))
142
+ if (result.invalidTokens.length > 0) {
143
+ const invalid = new Set(result.invalidTokens)
144
+ for (const [id, record] of devices) {
145
+ if (invalid.has(record.fcmToken)) this.deps.state.updateDevice(id, (r) => { r.fcmToken = null }, true)
146
+ }
147
+ this.deps.log('dropped invalid FCM tokens', JSON.stringify({ count: invalid.size }))
148
+ }
149
+ return result.ok
150
+ }
151
+
152
+ remember(eventId, devices, sessionId) {
153
+ const now = this.now()
154
+ this.notified.set(eventId, { devices, sessionId, at: now })
155
+ if (this.notified.size > 256) {
156
+ for (const [id, entry] of this.notified) if (now - entry.at > NOTIFIED_TTL_MS) this.notified.delete(id)
157
+ }
158
+ }
159
+ }
160
+
161
+ /**
162
+ * @param {Array<[string, import('./state.mjs').DeviceRecord]>} devices
163
+ * @returns {Map<'redacted'|'full', Array<[string, import('./state.mjs').DeviceRecord]>>}
164
+ */
165
+ function groupByMode(devices) {
166
+ const groups = new Map()
167
+ for (const entry of devices) {
168
+ const mode = entry[1].previewMode === 'full' ? 'full' : 'redacted'
169
+ if (!groups.has(mode)) groups.set(mode, [])
170
+ groups.get(mode).push(entry)
171
+ }
172
+ return groups
173
+ }
@@ -0,0 +1,256 @@
1
+ // Outbound WebSocket from this computer to the relay (docs/PROTOCOL.md §5).
2
+ // The relay forwards phone control requests over it and asks us nothing else;
3
+ // we send push requests the other way.
4
+
5
+ import { randomUUID } from 'node:crypto'
6
+
7
+ const PUSH_TIMEOUT_MS = 15_000
8
+ const HELLO_TIMEOUT_MS = 10_000
9
+ const MAX_FRAME_BYTES = 256 * 1024
10
+
11
+ /**
12
+ * @typedef {object} TunnelRequest
13
+ * @property {string} method
14
+ * @property {string} path
15
+ * @property {Record<string, string>} headers
16
+ * @property {string} body
17
+ */
18
+
19
+ export class RelayTunnel {
20
+ /**
21
+ * @param {object} options
22
+ * @param {string} options.relayUrl - https://… base
23
+ * @param {string} options.hostId
24
+ * @param {string} options.hostSecret
25
+ * @param {string} [options.enrollToken]
26
+ * @param {string} options.label
27
+ * @param {string} options.version
28
+ * @param {(request: TunnelRequest) => Promise<{status: number, body: unknown}>} options.handle
29
+ * @param {(...args: unknown[]) => void} options.log
30
+ * @param {typeof WebSocket} [options.WebSocketImpl]
31
+ */
32
+ constructor(options) {
33
+ this.options = options
34
+ this.url = tunnelUrl(options.relayUrl, options.hostId)
35
+ this.socket = undefined
36
+ this.open = false
37
+ /** @type {Map<string, {resolve: Function, timer: NodeJS.Timeout}>} */
38
+ this.pushes = new Map()
39
+ this.stopped = false
40
+ }
41
+
42
+ get connected() { return this.open }
43
+
44
+ /** @param {AbortSignal} signal */
45
+ async run(signal) {
46
+ const WebSocketImpl = this.options.WebSocketImpl ?? await loadWebSocket()
47
+ let delay = 1000
48
+ while (!signal.aborted) {
49
+ const started = Date.now()
50
+ await this.connectOnce(WebSocketImpl, signal)
51
+ if (signal.aborted) break
52
+ if (Date.now() - started > 60_000) delay = 1000
53
+ const jitter = Math.floor(Math.random() * 500)
54
+ await sleep(delay + jitter, signal)
55
+ delay = Math.min(delay * 2, 60_000)
56
+ }
57
+ this.failPushes('tunnel stopped')
58
+ }
59
+
60
+ /**
61
+ * @param {typeof WebSocket} WebSocketImpl
62
+ * @param {AbortSignal} signal
63
+ */
64
+ connectOnce(WebSocketImpl, signal) {
65
+ return new Promise((resolve) => {
66
+ let settled = false
67
+ let socket
68
+ try {
69
+ socket = new WebSocketImpl(this.url)
70
+ } catch (error) {
71
+ this.options.log('relay connect failed:', error?.message ?? String(error))
72
+ resolve()
73
+ return
74
+ }
75
+ this.socket = socket
76
+ const helloTimer = setTimeout(() => {
77
+ this.options.log('relay hello timed out')
78
+ try { socket.close(4408, 'hello timeout') } catch { /* already closing */ }
79
+ }, HELLO_TIMEOUT_MS)
80
+ const keepAlive = setInterval(() => this.send({ type: 'ping' }), 25_000)
81
+ const finish = () => {
82
+ if (settled) return
83
+ settled = true
84
+ clearTimeout(helloTimer)
85
+ clearInterval(keepAlive)
86
+ signal.removeEventListener('abort', onAbort)
87
+ this.open = false
88
+ this.socket = undefined
89
+ this.failPushes('tunnel closed')
90
+ resolve()
91
+ }
92
+ const onAbort = () => {
93
+ try { socket.close(1000, 'plugin stopping') } catch { /* already closed */ }
94
+ finish()
95
+ }
96
+ signal.addEventListener('abort', onAbort, { once: true })
97
+ socket.addEventListener('open', () => {
98
+ socket.send(JSON.stringify({
99
+ type: 'hello',
100
+ v: 1,
101
+ hostId: this.options.hostId,
102
+ hostSecret: this.options.hostSecret,
103
+ ...(this.options.enrollToken ? { enrollToken: this.options.enrollToken } : {}),
104
+ label: this.options.label,
105
+ plugin: this.options.version,
106
+ }))
107
+ })
108
+ socket.addEventListener('message', (event) => {
109
+ const raw = typeof event.data === 'string' ? event.data : Buffer.from(event.data).toString('utf8')
110
+ if (raw.length > MAX_FRAME_BYTES) return
111
+ let frame
112
+ try {
113
+ frame = JSON.parse(raw)
114
+ } catch {
115
+ return
116
+ }
117
+ if (frame?.type === 'hello-ok') {
118
+ clearTimeout(helloTimer)
119
+ this.open = true
120
+ this.options.log('relay connected')
121
+ return
122
+ }
123
+ if (!this.open) return
124
+ this.onFrame(frame)
125
+ })
126
+ socket.addEventListener('close', (event) => {
127
+ if (event.code === 4401) this.options.log('relay refused this host (check relayEnrollToken / host secret)')
128
+ else if (event.code === 4000) this.options.log('relay tunnel replaced by a newer connection')
129
+ finish()
130
+ })
131
+ socket.addEventListener('error', () => { /* close follows */ })
132
+ })
133
+ }
134
+
135
+ /** @param {any} frame */
136
+ onFrame(frame) {
137
+ switch (frame?.type) {
138
+ case 'request':
139
+ void this.serve(frame)
140
+ return
141
+ case 'push-result': {
142
+ const waiter = this.pushes.get(frame.id)
143
+ if (waiter === undefined) return
144
+ this.pushes.delete(frame.id)
145
+ clearTimeout(waiter.timer)
146
+ waiter.resolve({ ok: frame.ok === true, invalidTokens: Array.isArray(frame.invalidTokens) ? frame.invalidTokens : [] })
147
+ return
148
+ }
149
+ case 'ping':
150
+ this.send({ type: 'pong' })
151
+ return
152
+ default:
153
+ }
154
+ }
155
+
156
+ /** @param {any} frame */
157
+ async serve(frame) {
158
+ if (typeof frame.id !== 'string' || typeof frame.method !== 'string' || typeof frame.path !== 'string') return
159
+ let result
160
+ try {
161
+ result = await this.options.handle({
162
+ method: frame.method,
163
+ path: frame.path,
164
+ headers: frame.headers !== null && typeof frame.headers === 'object' ? frame.headers : {},
165
+ body: typeof frame.body === 'string' ? frame.body : '',
166
+ })
167
+ } catch (error) {
168
+ this.options.log('control handler failed:', error?.message ?? String(error))
169
+ result = { status: 500, body: { error: 'internal_error' } }
170
+ }
171
+ this.send({ type: 'response', id: frame.id, status: result.status, body: JSON.stringify(result.body ?? {}) })
172
+ }
173
+
174
+ /**
175
+ * Ask the relay to deliver one FCM data message.
176
+ * @param {string[]} tokens
177
+ * @param {Record<string, string>} data
178
+ * @param {{ttlSec?: number, collapseKey?: string}} [options]
179
+ * @returns {Promise<{ok: boolean, invalidTokens: string[]}>}
180
+ */
181
+ push(tokens, data, options = {}) {
182
+ if (!this.open || tokens.length === 0) return Promise.resolve({ ok: false, invalidTokens: [] })
183
+ const id = randomUUID()
184
+ return new Promise((resolve) => {
185
+ const timer = setTimeout(() => {
186
+ this.pushes.delete(id)
187
+ resolve({ ok: false, invalidTokens: [] })
188
+ }, PUSH_TIMEOUT_MS)
189
+ this.pushes.set(id, { resolve, timer })
190
+ const sent = this.send({ type: 'push', id, tokens, data, ttlSec: options.ttlSec ?? 3600, ...(options.collapseKey ? { collapseKey: options.collapseKey } : {}) })
191
+ if (!sent) {
192
+ clearTimeout(timer)
193
+ this.pushes.delete(id)
194
+ resolve({ ok: false, invalidTokens: [] })
195
+ }
196
+ })
197
+ }
198
+
199
+ /** @param {object} frame */
200
+ send(frame) {
201
+ const socket = this.socket
202
+ if (socket === undefined || socket.readyState !== 1) return false
203
+ try {
204
+ socket.send(JSON.stringify(frame))
205
+ return true
206
+ } catch {
207
+ return false
208
+ }
209
+ }
210
+
211
+ failPushes(reason) {
212
+ for (const [id, waiter] of this.pushes) {
213
+ clearTimeout(waiter.timer)
214
+ waiter.resolve({ ok: false, invalidTokens: [], error: reason })
215
+ this.pushes.delete(id)
216
+ }
217
+ }
218
+ }
219
+
220
+ /**
221
+ * @param {string} relayUrl
222
+ * @param {string} hostId
223
+ */
224
+ export function tunnelUrl(relayUrl, hostId) {
225
+ const url = new URL(relayUrl)
226
+ if (url.protocol !== 'https:' && !(url.protocol === 'http:' && ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname))) {
227
+ throw new Error('relayUrl must be https:// (http:// only for loopback testing)')
228
+ }
229
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
230
+ url.pathname = `${url.pathname.replace(/\/+$/, '')}/v1/hosts/${encodeURIComponent(hostId)}/tunnel`
231
+ url.search = ''
232
+ url.hash = ''
233
+ return url.toString()
234
+ }
235
+
236
+ async function loadWebSocket() {
237
+ if (typeof globalThis.WebSocket === 'function') return globalThis.WebSocket
238
+ try {
239
+ const mod = await import('ws')
240
+ return mod.WebSocket ?? mod.default
241
+ } catch {
242
+ throw new Error('no WebSocket implementation available (needs Node 22+ or the "ws" package)')
243
+ }
244
+ }
245
+
246
+ function sleep(ms, signal) {
247
+ return new Promise((resolve) => {
248
+ const timer = setTimeout(done, ms)
249
+ function done() {
250
+ signal?.removeEventListener('abort', done)
251
+ clearTimeout(timer)
252
+ resolve()
253
+ }
254
+ signal?.addEventListener('abort', done, { once: true })
255
+ })
256
+ }