dsh-device-fleet 0.0.0-stage → 0.1.0-dev.10

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.
Files changed (60) hide show
  1. package/CONTEXT.md +123 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -2
  4. package/client.js +167 -0
  5. package/cordis.patch.yml +26 -0
  6. package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
  7. package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
  8. package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
  9. package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
  10. package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
  11. package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
  12. package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
  13. package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
  14. package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
  15. package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
  16. package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
  17. package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
  18. package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
  19. package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
  20. package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
  21. package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
  22. package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
  23. package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
  24. package/docs/adr/0019-execution-context-configured-locally.md +24 -0
  25. package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
  26. package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
  27. package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
  28. package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
  29. package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
  30. package/docs/notes/approval-inbox-phase.md +31 -0
  31. package/docs/notes/design-decisions-summary.md +49 -0
  32. package/docs/notes/dev-release.md +67 -0
  33. package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
  34. package/docs/notes/network-v1-v2-implementation.md +71 -0
  35. package/docs/notes/pairing-session-2026-11-09.md +30 -0
  36. package/docs/notes/remote-tests-2026-11-09.md +33 -0
  37. package/docs/notes/room-logic-phase.md +31 -0
  38. package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
  39. package/package.json +51 -3
  40. package/src/host/approval-inbox.js +135 -0
  41. package/src/host/approval-web.js +95 -0
  42. package/src/host/delegation.js +127 -0
  43. package/src/host/direct-call.js +209 -0
  44. package/src/host/execution-slot.js +96 -0
  45. package/src/host/index.js +10 -0
  46. package/src/host/room-service.js +324 -0
  47. package/src/host/store.js +156 -0
  48. package/src/host/tool-policy.js +151 -0
  49. package/src/host/tools.js +590 -0
  50. package/src/index.js +3 -0
  51. package/src/net/direct.js +69 -0
  52. package/src/net/discovery.js +105 -0
  53. package/src/net/fleet-node.js +509 -0
  54. package/src/net/identity.js +167 -0
  55. package/src/net/index.js +9 -0
  56. package/src/net/protocol.js +73 -0
  57. package/src/net/relay-client.js +258 -0
  58. package/src/net/relay-server.js +366 -0
  59. package/src/net/secure-channel.js +538 -0
  60. package/src/plugin/index.js +380 -0
@@ -0,0 +1,167 @@
1
+ // Device identity, fleet invites and membership proofs.
2
+ //
3
+ // Every device owns an Ed25519 keypair (authentication) and an X25519 keypair
4
+ // (key agreement). A Fleet is identified by `fleetId` and a shared 32-byte
5
+ // `fleetKey` carried by invites; the key proves membership and bootstraps trust
6
+ // on first contact (TOFU), after which peer public keys are pinned in the roster.
7
+ import {
8
+ createHash,
9
+ createHmac,
10
+ createPrivateKey,
11
+ createPublicKey,
12
+ generateKeyPairSync,
13
+ randomBytes,
14
+ randomUUID,
15
+ } from 'node:crypto'
16
+
17
+ const INVITE_PREFIX = 'DSHF1.'
18
+ const PROOF_PREFIX = 'member'
19
+ const RELAY_PREFIX = 'relay-register'
20
+ const ACCOUNT_PREFIX = 'account'
21
+
22
+ export function exportKey(key) {
23
+ return key.export({ type: key.type === 'private' ? 'pkcs8' : 'spki', format: 'pem' }).toString()
24
+ }
25
+
26
+ export function importPrivateKey(pem) {
27
+ return createPrivateKey(pem)
28
+ }
29
+
30
+ export function importPublicKey(pem) {
31
+ return createPublicKey(pem)
32
+ }
33
+
34
+ function randomBase64Url(bytes) {
35
+ return randomBytes(bytes).toString('base64url')
36
+ }
37
+
38
+ /** A fresh device identity. Caller persists it (e.g. `$DSH_HOME/device-fleet/identity.json`). */
39
+ export function generateIdentity({ name = 'device' } = {}) {
40
+ const ed = generateKeyPairSync('ed25519')
41
+ const x = generateKeyPairSync('x25519')
42
+ return {
43
+ deviceId: randomUUID(),
44
+ name,
45
+ createdAt: Date.now(),
46
+ edPrivate: exportKey(ed.privateKey),
47
+ edPublic: exportKey(ed.publicKey),
48
+ xPrivate: exportKey(x.privateKey),
49
+ xPublic: exportKey(x.publicKey),
50
+ }
51
+ }
52
+
53
+ /** Public half of an identity, safe to put in a roster/invite. */
54
+ export function publicIdentity(identity) {
55
+ return {
56
+ deviceId: identity.deviceId,
57
+ name: identity.name,
58
+ edPublic: identity.edPublic,
59
+ xPublic: identity.xPublic,
60
+ }
61
+ }
62
+
63
+ /** A new fleet. `fleetKey` is the fleet-wide membership secret carried by invites. */
64
+ export function createFleet({ name = 'fleet' } = {}) {
65
+ return {
66
+ fleetId: randomUUID(),
67
+ fleetKey: randomBase64Url(32),
68
+ name,
69
+ createdAt: Date.now(),
70
+ }
71
+ }
72
+
73
+ export function encodeInvite({ fleet, accountFingerprint = null, relay = null, endpoints = [] }) {
74
+ const payload = {
75
+ v: 1,
76
+ fleetId: fleet.fleetId,
77
+ fleetKey: fleet.fleetKey,
78
+ name: fleet.name,
79
+ accountFingerprint,
80
+ relay,
81
+ endpoints: Array.isArray(endpoints) ? endpoints : [],
82
+ createdAt: Date.now(),
83
+ }
84
+ return INVITE_PREFIX + Buffer.from(JSON.stringify(payload)).toString('base64url')
85
+ }
86
+
87
+ export function decodeInvite(code) {
88
+ if (typeof code !== 'string' || !code.startsWith(INVITE_PREFIX)) throw new Error('invalid invite')
89
+ const raw = code.slice(INVITE_PREFIX.length)
90
+ let payload
91
+ try {
92
+ payload = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'))
93
+ } catch {
94
+ throw new Error('invalid invite payload')
95
+ }
96
+ if (payload?.v !== 1 || typeof payload.fleetId !== 'string' || typeof payload.fleetKey !== 'string') {
97
+ throw new Error('unsupported invite')
98
+ }
99
+ return {
100
+ fleet: { fleetId: payload.fleetId, fleetKey: payload.fleetKey, name: payload.name ?? 'fleet' },
101
+ accountFingerprint: payload.accountFingerprint ?? null,
102
+ relay: payload.relay ?? null,
103
+ endpoints: Array.isArray(payload.endpoints)
104
+ ? payload.endpoints.filter(
105
+ (entry) =>
106
+ entry &&
107
+ typeof entry.host === 'string' &&
108
+ Number.isInteger(entry.port) &&
109
+ entry.port > 0 &&
110
+ (entry.deviceId === undefined || typeof entry.deviceId === 'string'),
111
+ )
112
+ : [],
113
+ createdAt: payload.createdAt ?? null,
114
+ }
115
+ }
116
+
117
+ /** Account fingerprint: proves "same account" inside the fleet without exposing the account id. */
118
+ export function accountFingerprint(fleetKey, accountUserId) {
119
+ if (!accountUserId) return null
120
+ return createHmac('sha256', fleetKey).update(`${ACCOUNT_PREFIX}|${accountUserId}`).digest('base64url')
121
+ }
122
+
123
+ /** HMAC proof that this device belongs to the fleet (checked by peers during the handshake). */
124
+ export function membershipProof({ fleetKey, fleetId, deviceId, edPublic, xPublic, accountFingerprint = null }) {
125
+ return createHmac('sha256', fleetKey)
126
+ .update(`${PROOF_PREFIX}|${fleetId}|${deviceId}|${edPublic}|${xPublic}|${accountFingerprint ?? ''}`)
127
+ .digest('base64url')
128
+ }
129
+
130
+ export function verifyMembershipProof(proof, fields) {
131
+ const expected = membershipProof(fields)
132
+ const a = Buffer.from(String(proof))
133
+ const b = Buffer.from(expected)
134
+ return a.length === b.length && a.equals(b)
135
+ }
136
+
137
+ /** `fleetId` hash advertised in LAN announcements (the full id stays off the wire). */
138
+ export function fleetHash(fleetId) {
139
+ return createHash('sha256').update(`fleet|${fleetId}`).digest('hex').slice(0, 16)
140
+ }
141
+
142
+ export function shortFingerprint(edPublic, xPublic) {
143
+ return createHash('sha256')
144
+ .update(`${edPublic}|${xPublic}`)
145
+ .digest('hex')
146
+ .slice(0, 16)
147
+ }
148
+
149
+ /** Token proving fleet membership to a self-hosted relay. */
150
+ export function relayAuthToken({ fleetKey, fleetId, deviceId, ts }) {
151
+ return createHmac('sha256', fleetKey)
152
+ .update(`${RELAY_PREFIX}|${fleetId}|${deviceId}|${ts}`)
153
+ .digest('base64url')
154
+ }
155
+
156
+ export function verifyRelayAuthToken(token, { fleetKey, fleetId, deviceId, ts }) {
157
+ const expected = relayAuthToken({ fleetKey, fleetId, deviceId, ts })
158
+ const a = Buffer.from(String(token))
159
+ const b = Buffer.from(expected)
160
+ return a.length === b.length && a.equals(b)
161
+ }
162
+
163
+ /** Ephemeral X25519 pair for one handshake. */
164
+ export function generateEphemeral() {
165
+ const pair = generateKeyPairSync('x25519')
166
+ return { privateKey: pair.privateKey, publicKey: exportKey(pair.publicKey), privateKeyPem: exportKey(pair.privateKey) }
167
+ }
@@ -0,0 +1,9 @@
1
+ // Network layer exports.
2
+ export * from './identity.js'
3
+ export * from './protocol.js'
4
+ export * from './secure-channel.js'
5
+ export * from './direct.js'
6
+ export * from './discovery.js'
7
+ export * from './relay-server.js'
8
+ export * from './relay-client.js'
9
+ export * from './fleet-node.js'
@@ -0,0 +1,73 @@
1
+ // Wire protocol constants, canonical JSON and RPC envelope helpers.
2
+
3
+ export const PROTOCOL_VERSION = 1
4
+
5
+ /** Largest accepted frame payload (ciphertext bytes, excluding the length prefix). */
6
+ export const MAX_FRAME_BYTES = 32 * 1024 * 1024
7
+
8
+ /** Handshake messages are newline-delimited JSON; encrypted frames are length-prefixed. */
9
+ export const HANDSHAKE_TIMEOUT_MS = 10_000
10
+
11
+ export const FRAME = Object.freeze({
12
+ READY: 'ready',
13
+ RPC_REQUEST: 'rpc.req',
14
+ RPC_RESULT: 'rpc.res',
15
+ RPC_ERROR: 'rpc.err',
16
+ EVENT: 'event',
17
+ PING: 'ping',
18
+ PONG: 'pong',
19
+ })
20
+
21
+ export function stableStringify(value) {
22
+ return JSON.stringify(sortValue(value))
23
+ }
24
+
25
+ function sortValue(value) {
26
+ if (Array.isArray(value)) return value.map(sortValue)
27
+ if (value && typeof value === 'object') {
28
+ const out = {}
29
+ for (const key of Object.keys(value).sort()) out[key] = sortValue(value[key])
30
+ return out
31
+ }
32
+ return value
33
+ }
34
+
35
+ export function rpcRequest({ id, method, params }) {
36
+ return { t: FRAME.RPC_REQUEST, v: PROTOCOL_VERSION, id, method, params: params ?? null }
37
+ }
38
+
39
+ export function rpcResult({ id, result }) {
40
+ return { t: FRAME.RPC_RESULT, v: PROTOCOL_VERSION, id, result: result ?? null }
41
+ }
42
+
43
+ export function rpcError({ id, code, message, data = null }) {
44
+ return { t: FRAME.RPC_ERROR, v: PROTOCOL_VERSION, id, error: { code, message, data } }
45
+ }
46
+
47
+ export function eventFrame({ topic, data }) {
48
+ return { t: FRAME.EVENT, v: PROTOCOL_VERSION, topic, data: data ?? null }
49
+ }
50
+
51
+ export function pingFrame() {
52
+ return { t: FRAME.PING, v: PROTOCOL_VERSION }
53
+ }
54
+
55
+ export function pongFrame() {
56
+ return { t: FRAME.PONG, v: PROTOCOL_VERSION }
57
+ }
58
+
59
+ export function serializeError(error) {
60
+ if (!error) return { code: 'internal', message: 'unknown error', data: null }
61
+ return {
62
+ code: typeof error.code === 'string' ? error.code : 'internal',
63
+ message: String(error.message ?? error),
64
+ data: error.data ?? null,
65
+ }
66
+ }
67
+
68
+ export function makeError(code, message, data = null) {
69
+ const error = new Error(message)
70
+ error.code = code
71
+ error.data = data
72
+ return error
73
+ }
@@ -0,0 +1,258 @@
1
+ // Relay client: keeps a control connection to the self-hosted relay and opens
2
+ // spliced data connections to other fleet devices.
3
+ import { EventEmitter } from 'node:events'
4
+ import net from 'node:net'
5
+ import { randomUUID } from 'node:crypto'
6
+ import { relayAuthToken } from './identity.js'
7
+
8
+ const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
9
+
10
+ export class RelayClient extends EventEmitter {
11
+ /**
12
+ * @param {object} options
13
+ * @param {string} options.host relay host
14
+ * @param {number} options.port relay control port
15
+ * @param {number} options.dataPort relay data port
16
+ * @param {string} options.fleetId
17
+ * @param {string} options.deviceId
18
+ * @param {string} options.fleetKey
19
+ * @param {{initialDelayMs?: number, maxDelayMs?: number}} [options.reconnect]
20
+ */
21
+ constructor({ host, port, dataPort, fleetId, deviceId, fleetKey, reconnect = {} }) {
22
+ super()
23
+ this.host = host
24
+ this.port = port
25
+ this.dataPort = dataPort
26
+ this.fleetId = fleetId
27
+ this.deviceId = deviceId
28
+ this.fleetKey = fleetKey
29
+ this.reconnect = { initialDelayMs: 500, maxDelayMs: 10_000, ...reconnect }
30
+
31
+ this.socket = null
32
+ this.buffer = ''
33
+ this.online = false
34
+ this.closed = false
35
+ this.attempt = 0
36
+ this.reconnectTimer = null
37
+ this.waiters = new Set()
38
+ this.readyWaiters = new Map()
39
+ this.controllers = new Map()
40
+ }
41
+
42
+ /** Start connecting; resolves once registered (or rejects on timeout). */
43
+ async start(timeoutMs = 5_000) {
44
+ if (this.socket) return
45
+ this.connect()
46
+ await this.whenOnline(timeoutMs)
47
+ }
48
+
49
+ whenOnline(timeoutMs = 5_000) {
50
+ if (this.online) return Promise.resolve()
51
+ return new Promise((resolve, reject) => {
52
+ const waiter = { resolve, reject }
53
+ this.waiters.add(waiter)
54
+ const timer = setTimeout(() => {
55
+ this.waiters.delete(waiter)
56
+ reject(new Error('relay connection timed out'))
57
+ }, timeoutMs)
58
+ timer.unref?.()
59
+ waiter.timer = timer
60
+ })
61
+ }
62
+
63
+ connect() {
64
+ if (this.closed) return
65
+ const socket = net.connect({ host: this.host, port: this.port })
66
+ this.socket = socket
67
+ this.buffer = ''
68
+ socket.setNoDelay?.(true)
69
+ socket.once('connect', () => {
70
+ const ts = Date.now()
71
+ this.send({
72
+ t: 'register',
73
+ fleetId: this.fleetId,
74
+ deviceId: this.deviceId,
75
+ ts,
76
+ token: relayAuthToken({ fleetKey: this.fleetKey, fleetId: this.fleetId, deviceId: this.deviceId, ts }),
77
+ })
78
+ })
79
+ socket.on('data', (chunk) => this.onData(chunk))
80
+ socket.on('error', () => {})
81
+ socket.once('close', () => this.onClose(socket))
82
+ }
83
+
84
+ send(message) {
85
+ if (!this.socket || this.socket.destroyed || !this.socket.writable) return false
86
+ this.socket.write(JSON.stringify(message) + '\n')
87
+ return true
88
+ }
89
+
90
+ onData(chunk) {
91
+ this.buffer += chunk.toString('utf8')
92
+ let newline = this.buffer.indexOf('\n')
93
+ while (newline >= 0) {
94
+ const line = this.buffer.slice(0, newline)
95
+ this.buffer = this.buffer.slice(newline + 1)
96
+ if (line.trim() !== '') this.onMessage(line)
97
+ newline = this.buffer.indexOf('\n')
98
+ }
99
+ }
100
+
101
+ onMessage(line) {
102
+ let message
103
+ try {
104
+ message = JSON.parse(line)
105
+ } catch {
106
+ return
107
+ }
108
+ switch (message?.t) {
109
+ case 'register.ok': {
110
+ this.online = true
111
+ this.attempt = 0
112
+ for (const waiter of this.waiters) {
113
+ this.waiters.delete(waiter)
114
+ clearTimeout(waiter.timer)
115
+ waiter.resolve()
116
+ }
117
+ this.emit('online', { deviceId: this.deviceId })
118
+ return
119
+ }
120
+ case 'error': {
121
+ this.emit('relay-error', message)
122
+ if (message.code === 'unauthorized' || message.code === 'unknown-fleet' || message.code === 'replaced') {
123
+ this.closed = true
124
+ this.socket?.destroy()
125
+ }
126
+ return
127
+ }
128
+ case 'incoming': {
129
+ const { reqId, fromDeviceId } = message
130
+ const controller = {
131
+ reqId,
132
+ fromDeviceId,
133
+ decided: false,
134
+ accept: () => this.acceptIncoming(controller),
135
+ reject: () => {
136
+ controller.decided = true
137
+ this.controllers.delete(reqId)
138
+ this.send({ t: 'reject', reqId })
139
+ },
140
+ }
141
+ this.controllers.set(reqId, controller)
142
+ this.emit('incoming', controller)
143
+ return
144
+ }
145
+ case 'connect.pending':
146
+ return
147
+ case 'connect.ready': {
148
+ const waiter = this.readyWaiters.get(message.reqId)
149
+ if (!waiter) return
150
+ this.readyWaiters.delete(message.reqId)
151
+ clearTimeout(waiter.timer)
152
+ waiter.resolve({ dataPort: message.dataPort, token: message.token })
153
+ return
154
+ }
155
+ case 'connect.fail': {
156
+ const waiter = this.readyWaiters.get(message.reqId)
157
+ if (waiter) {
158
+ this.readyWaiters.delete(message.reqId)
159
+ clearTimeout(waiter.timer)
160
+ waiter.reject(Object.assign(new Error(`relay connect failed: ${message.reason}`), { code: message.reason }))
161
+ }
162
+ const connectWaiter = this.connectWaiters?.get(message.reqId)
163
+ if (connectWaiter) {
164
+ this.connectWaiters.delete(message.reqId)
165
+ clearTimeout(connectWaiter.timer)
166
+ connectWaiter.reject(Object.assign(new Error(`relay connect failed: ${message.reason}`), { code: message.reason }))
167
+ }
168
+ return
169
+ }
170
+ default:
171
+ return
172
+ }
173
+ }
174
+
175
+ waitReady(reqId, timeoutMs = 15_000) {
176
+ return new Promise((resolve, reject) => {
177
+ const timer = setTimeout(() => {
178
+ this.readyWaiters.delete(reqId)
179
+ reject(Object.assign(new Error('relay data path timed out'), { code: 'data-timeout' }))
180
+ }, timeoutMs)
181
+ timer.unref?.()
182
+ this.readyWaiters.set(reqId, { resolve, reject, timer })
183
+ })
184
+ }
185
+
186
+ async acceptIncoming(controller) {
187
+ if (controller.decided) throw new Error('incoming request already settled')
188
+ controller.decided = true
189
+ this.controllers.delete(controller.reqId)
190
+ const ready = this.waitReady(controller.reqId)
191
+ this.send({ t: 'accept', reqId: controller.reqId })
192
+ const { dataPort, token } = await ready
193
+ return this.openDataSocket(controller.reqId, token, dataPort)
194
+ }
195
+
196
+ async requestConnect(targetDeviceId, timeoutMs = DEFAULT_CONNECT_TIMEOUT_MS) {
197
+ if (!this.online) await this.whenOnline(Math.min(timeoutMs, 5_000))
198
+ const reqId = randomUUID()
199
+ const ready = this.waitReady(reqId, timeoutMs)
200
+ this.send({ t: 'connect', reqId, targetDeviceId })
201
+ const { dataPort, token } = await ready
202
+ return this.openDataSocket(reqId, token, dataPort)
203
+ }
204
+
205
+ openDataSocket(reqId, token, dataPort = this.dataPort) {
206
+ return new Promise((resolve, reject) => {
207
+ const socket = net.connect({ host: this.host, port: dataPort })
208
+ const timer = setTimeout(() => {
209
+ socket.destroy()
210
+ reject(Object.assign(new Error('relay data connection timed out'), { code: 'data-timeout' }))
211
+ }, DEFAULT_CONNECT_TIMEOUT_MS)
212
+ timer.unref?.()
213
+ socket.setNoDelay?.(true)
214
+ socket.once('connect', () => {
215
+ clearTimeout(timer)
216
+ socket.write(
217
+ JSON.stringify({ fleetId: this.fleetId, deviceId: this.deviceId, reqId, token }) + '\n',
218
+ )
219
+ resolve(socket)
220
+ })
221
+ socket.once('error', (error) => {
222
+ clearTimeout(timer)
223
+ reject(error)
224
+ })
225
+ })
226
+ }
227
+
228
+ onClose(socket) {
229
+ if (this.socket !== socket) return
230
+ this.socket = null
231
+ const wasOnline = this.online
232
+ this.online = false
233
+ if (wasOnline) this.emit('offline')
234
+ for (const [reqId, waiter] of this.readyWaiters) {
235
+ this.readyWaiters.delete(reqId)
236
+ clearTimeout(waiter.timer)
237
+ waiter.reject(Object.assign(new Error('relay connection lost'), { code: 'relay-offline' }))
238
+ }
239
+ if (this.closed) return
240
+ const delay = Math.min(
241
+ this.reconnect.maxDelayMs,
242
+ this.reconnect.initialDelayMs * 2 ** Math.min(this.attempt++, 5),
243
+ )
244
+ this.reconnectTimer = setTimeout(() => this.connect(), delay)
245
+ this.reconnectTimer.unref?.()
246
+ }
247
+
248
+ close() {
249
+ this.closed = true
250
+ clearTimeout(this.reconnectTimer)
251
+ try {
252
+ this.socket?.destroy()
253
+ } catch {
254
+ /* ignore */
255
+ }
256
+ this.socket = null
257
+ }
258
+ }