dsh-device-fleet 0.0.0-stage → 0.1.0-dev.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.
Files changed (51) hide show
  1. package/CONTEXT.md +123 -0
  2. package/LICENSE +21 -0
  3. package/README.md +133 -2
  4. package/cordis.patch.yml +26 -0
  5. package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
  6. package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
  7. package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
  8. package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
  9. package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
  10. package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
  11. package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
  12. package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
  13. package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
  14. package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
  15. package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
  16. package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
  17. package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
  18. package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
  19. package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
  20. package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
  21. package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
  22. package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
  23. package/docs/adr/0019-execution-context-configured-locally.md +24 -0
  24. package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
  25. package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
  26. package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
  27. package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
  28. package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
  29. package/docs/notes/design-decisions-summary.md +49 -0
  30. package/docs/notes/dev-release.md +28 -0
  31. package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
  32. package/docs/notes/network-v1-v2-implementation.md +71 -0
  33. package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
  34. package/package.json +42 -3
  35. package/src/host/direct-call.js +190 -0
  36. package/src/host/execution-slot.js +82 -0
  37. package/src/host/index.js +6 -0
  38. package/src/host/store.js +128 -0
  39. package/src/host/tool-policy.js +150 -0
  40. package/src/host/tools.js +215 -0
  41. package/src/index.js +3 -0
  42. package/src/net/direct.js +69 -0
  43. package/src/net/discovery.js +105 -0
  44. package/src/net/fleet-node.js +414 -0
  45. package/src/net/identity.js +156 -0
  46. package/src/net/index.js +9 -0
  47. package/src/net/protocol.js +73 -0
  48. package/src/net/relay-client.js +258 -0
  49. package/src/net/relay-server.js +366 -0
  50. package/src/net/secure-channel.js +538 -0
  51. package/src/plugin/index.js +285 -0
@@ -0,0 +1,538 @@
1
+ // End-to-end secure channel between two fleet devices.
2
+ //
3
+ // Handshake (newline-delimited JSON):
4
+ // initiator -> responder : HELLO (static keys + fleet proof + ephemeral X25519 + signature)
5
+ // responder -> initiator : HELLO_ACK
6
+ // both : encrypted READY frame
7
+ // Then every frame is AES-256-GCM with per-direction keys derived from
8
+ // X25519(ephemeral, ephemeral) + HKDF over the handshake transcript. The relay,
9
+ // if used, only splices the underlying bytes and cannot read frames.
10
+ import {
11
+ createCipheriv,
12
+ createDecipheriv,
13
+ createHash,
14
+ diffieHellman,
15
+ hkdfSync,
16
+ randomBytes,
17
+ sign,
18
+ verify,
19
+ } from 'node:crypto'
20
+ import { EventEmitter } from 'node:events'
21
+ import {
22
+ FRAME,
23
+ HANDSHAKE_TIMEOUT_MS,
24
+ MAX_FRAME_BYTES,
25
+ PROTOCOL_VERSION,
26
+ stableStringify,
27
+ } from './protocol.js'
28
+ import {
29
+ generateEphemeral,
30
+ importPrivateKey,
31
+ importPublicKey,
32
+ membershipProof,
33
+ verifyMembershipProof,
34
+ } from './identity.js'
35
+
36
+ const AAD = Buffer.from('dsh-device-fleet/frame/v1')
37
+ const MAX_CLOCK_SKEW_MS = 120_000
38
+
39
+ function sha256(buffer) {
40
+ return createHash('sha256').update(buffer).digest()
41
+ }
42
+
43
+ function b64(buffer) {
44
+ return Buffer.from(buffer).toString('base64url')
45
+ }
46
+
47
+ function isNonEmptyString(value) {
48
+ return typeof value === 'string' && value.length > 0
49
+ }
50
+
51
+ export class SecureChannel extends EventEmitter {
52
+ /**
53
+ * @param {object} options
54
+ * @param {import('node:net').Socket} options.socket connected socket
55
+ * @param {object} options.identity local identity ({deviceId, edPrivate, edPublic, xPublic, name})
56
+ * @param {{fleetId: string, fleetKey: string}} options.fleet
57
+ * @param {'initiator'|'responder'} options.role
58
+ * @param {object|null} [options.expectedPeer] pinned peer ({deviceId, edPublic, xPublic})
59
+ * @param {string|null} [options.accountFingerprint] expected account fingerprint
60
+ * @param {(peer: object) => boolean|Promise<boolean>} [options.verifyPeer] trust decision after protocol checks
61
+ * @param {string} [options.pluginVersion] strict plugin version (ADR-0024)
62
+ * @param {string} [options.dshVersion] strict harness version line (ADR-0024)
63
+ * @param {string[]} [options.capabilities] reserved capability declarations
64
+ */
65
+ constructor({
66
+ socket,
67
+ identity,
68
+ fleet,
69
+ role,
70
+ expectedPeer = null,
71
+ accountFingerprint = null,
72
+ verifyPeer = null,
73
+ pluginVersion = '0.0.0',
74
+ dshVersion = 'unknown',
75
+ capabilities = [],
76
+ }) {
77
+ super()
78
+ if (role !== 'initiator' && role !== 'responder') throw new Error('invalid channel role')
79
+ this.socket = socket
80
+ this.identity = identity
81
+ this.fleet = fleet
82
+ this.role = role
83
+ this.expectedPeer = expectedPeer
84
+ this.accountFingerprint = accountFingerprint
85
+ this.verifyPeer = verifyPeer
86
+ this.pluginVersion = pluginVersion
87
+ this.dshVersion = dshVersion
88
+ this.capabilities = [...capabilities].sort()
89
+
90
+ this.phase = 'handshake'
91
+ this.recvBuffer = Buffer.alloc(0)
92
+ this.readySent = false
93
+ this.readyReceived = false
94
+ this.authenticated = false
95
+ this.closed = false
96
+ this.peer = null
97
+ this.failure = null
98
+
99
+ this.sendKey = null
100
+ this.recvKey = null
101
+ this.sendPrefix = null
102
+ this.recvPrefix = null
103
+ this.sendCounter = 0n
104
+ this.recvCounter = 0n
105
+
106
+ this.handshakeTimer = setTimeout(() => {
107
+ this.fail('handshake-timeout', 'secure channel handshake timed out')
108
+ }, HANDSHAKE_TIMEOUT_MS)
109
+ this.handshakeTimer.unref?.()
110
+
111
+ socket.setNoDelay?.(true)
112
+ socket.on('data', (chunk) => this.onData(chunk))
113
+ socket.on('error', (error) => this.onSocketError(error))
114
+ socket.on('close', () => this.onSocketClose())
115
+
116
+ if (socket.connecting) socket.once('connect', () => this.start())
117
+ else this.start()
118
+ }
119
+
120
+ start() {
121
+ if (this.phase !== 'handshake' || this.closed) return
122
+ if (this.role === 'initiator') this.sendHello()
123
+ // responder waits for HELLO
124
+ }
125
+
126
+ // ---------------------------------------------------------------- handshake
127
+
128
+ sendHello() {
129
+ this.ephemeral = generateEphemeral()
130
+ const nonce = randomBytes(32)
131
+ const fields = {
132
+ t: 'hello',
133
+ v: PROTOCOL_VERSION,
134
+ fleetId: this.fleet.fleetId,
135
+ deviceId: this.identity.deviceId,
136
+ name: this.identity.name ?? 'device',
137
+ edPublic: this.identity.edPublic,
138
+ xPublic: this.identity.xPublic,
139
+ ephX: this.ephemeral.publicKey,
140
+ nonce: b64(nonce),
141
+ ts: Date.now(),
142
+ accountFingerprint: this.accountFingerprint ?? null,
143
+ pluginVersion: this.pluginVersion,
144
+ dshVersion: this.dshVersion,
145
+ capabilities: this.capabilities,
146
+ }
147
+ const proof = membershipProof({
148
+ fleetKey: this.fleet.fleetKey,
149
+ fleetId: this.fleet.fleetId,
150
+ deviceId: this.identity.deviceId,
151
+ edPublic: this.identity.edPublic,
152
+ xPublic: this.identity.xPublic,
153
+ accountFingerprint: this.accountFingerprint ?? null,
154
+ })
155
+ const signed = { ...fields, proof }
156
+ const sig = sign(null, Buffer.from(stableStringify(signed)), importPrivateKey(this.identity.edPrivate)).toString('base64url')
157
+ this.localFields = fields
158
+ this.writeLine({ ...signed, sig })
159
+ }
160
+
161
+ async onHello(message) {
162
+ const fields = this.validateIdentityMessage(message, 'hello')
163
+ if (fields === null) return
164
+ this.peer = this.peerFrom(fields)
165
+ if (!(await this.authorizePeer())) return
166
+
167
+ this.ephemeral = generateEphemeral()
168
+ const shared = diffieHellman({
169
+ privateKey: importPrivateKey(this.ephemeral.privateKeyPem),
170
+ publicKey: importPublicKey(fields.ephX),
171
+ })
172
+ const nonce = randomBytes(32)
173
+ const ackFields = {
174
+ t: 'hello.ack',
175
+ v: PROTOCOL_VERSION,
176
+ fleetId: this.fleet.fleetId,
177
+ deviceId: this.identity.deviceId,
178
+ name: this.identity.name ?? 'device',
179
+ edPublic: this.identity.edPublic,
180
+ xPublic: this.identity.xPublic,
181
+ ephX: this.ephemeral.publicKey,
182
+ nonce: b64(nonce),
183
+ ts: Date.now(),
184
+ accountFingerprint: this.accountFingerprint ?? null,
185
+ pluginVersion: this.pluginVersion,
186
+ dshVersion: this.dshVersion,
187
+ capabilities: this.capabilities,
188
+ }
189
+ const proof = membershipProof({
190
+ fleetKey: this.fleet.fleetKey,
191
+ fleetId: this.fleet.fleetId,
192
+ deviceId: this.identity.deviceId,
193
+ edPublic: this.identity.edPublic,
194
+ xPublic: this.identity.xPublic,
195
+ accountFingerprint: this.accountFingerprint ?? null,
196
+ })
197
+ const signed = { ...ackFields, proof }
198
+ const sig = sign(null, Buffer.from(stableStringify(signed)), importPrivateKey(this.identity.edPrivate)).toString('base64url')
199
+ this.localFields = ackFields
200
+ this.writeLine({ ...signed, sig })
201
+
202
+ const transcript = sha256(stableStringify({ hello: fields, ack: ackFields }))
203
+ this.deriveKeys(shared, transcript, 'responder')
204
+ this.enterFrames()
205
+ this.sendReady()
206
+ }
207
+
208
+ async onHelloAck(message) {
209
+ const fields = this.validateIdentityMessage(message, 'hello.ack')
210
+ if (fields === null) return
211
+ this.peer = this.peerFrom(fields)
212
+ if (!(await this.authorizePeer())) return
213
+
214
+ const shared = diffieHellman({
215
+ privateKey: importPrivateKey(this.ephemeral.privateKeyPem),
216
+ publicKey: importPublicKey(fields.ephX),
217
+ })
218
+ const transcript = sha256(stableStringify({ hello: this.localFields, ack: fields }))
219
+ this.deriveKeys(shared, transcript, 'initiator')
220
+ this.enterFrames()
221
+ this.sendReady()
222
+ }
223
+
224
+ validateIdentityMessage(message, kind) {
225
+ const fail = (reason) => {
226
+ this.fail('handshake-rejected', reason)
227
+ return null
228
+ }
229
+ if (!message || typeof message !== 'object') return fail(`${kind} is not an object`)
230
+ if (message.t !== kind) return fail(`expected ${kind}`)
231
+ if (message.v !== PROTOCOL_VERSION) return fail('protocol version mismatch')
232
+ if (message.pluginVersion !== this.pluginVersion) {
233
+ return fail(`plugin version mismatch: peer ${String(message.pluginVersion)} vs local ${this.pluginVersion}`)
234
+ }
235
+ if (message.dshVersion !== this.dshVersion) {
236
+ return fail(`harness version mismatch: peer ${String(message.dshVersion)} vs local ${this.dshVersion}`)
237
+ }
238
+ if (message.fleetId !== this.fleet.fleetId) return fail('fleet mismatch')
239
+ if (!isNonEmptyString(message.deviceId)) return fail('missing device id')
240
+ if (message.deviceId === this.identity.deviceId) return fail('peer is this device')
241
+ for (const key of ['edPublic', 'xPublic', 'ephX', 'nonce', 'proof', 'sig']) {
242
+ if (!isNonEmptyString(message[key])) return fail(`missing ${key}`)
243
+ }
244
+ if (typeof message.ts !== 'number' || Math.abs(Date.now() - message.ts) > MAX_CLOCK_SKEW_MS) {
245
+ return fail('clock skew too large')
246
+ }
247
+ if (this.expectedPeer?.deviceId !== undefined && message.deviceId !== this.expectedPeer.deviceId) {
248
+ return fail('unexpected device id')
249
+ }
250
+ if (this.expectedPeer?.edPublic !== undefined && message.edPublic !== this.expectedPeer.edPublic) {
251
+ return fail('device key changed')
252
+ }
253
+ if (this.expectedPeer?.xPublic !== undefined && message.xPublic !== this.expectedPeer.xPublic) {
254
+ return fail('device agreement key changed')
255
+ }
256
+ if (this.accountFingerprint !== null && this.accountFingerprint !== undefined) {
257
+ if ((message.accountFingerprint ?? null) !== this.accountFingerprint) return fail('account mismatch')
258
+ }
259
+
260
+ const { sig, ...signed } = message
261
+ let signatureOk = false
262
+ try {
263
+ signatureOk = verify(null, Buffer.from(stableStringify(signed)), importPublicKey(message.edPublic), Buffer.from(sig, 'base64url'))
264
+ } catch {
265
+ signatureOk = false
266
+ }
267
+ if (!signatureOk) return fail('invalid signature')
268
+
269
+ const proofOk = verifyMembershipProof(message.proof, {
270
+ fleetKey: this.fleet.fleetKey,
271
+ fleetId: this.fleet.fleetId,
272
+ deviceId: message.deviceId,
273
+ edPublic: message.edPublic,
274
+ xPublic: message.xPublic,
275
+ accountFingerprint: message.accountFingerprint ?? null,
276
+ })
277
+ if (!proofOk) return fail('invalid fleet membership proof')
278
+
279
+ return {
280
+ t: kind,
281
+ v: message.v,
282
+ fleetId: message.fleetId,
283
+ deviceId: message.deviceId,
284
+ name: message.name ?? 'device',
285
+ edPublic: message.edPublic,
286
+ xPublic: message.xPublic,
287
+ ephX: message.ephX,
288
+ nonce: message.nonce,
289
+ ts: message.ts,
290
+ accountFingerprint: message.accountFingerprint ?? null,
291
+ pluginVersion: message.pluginVersion,
292
+ dshVersion: message.dshVersion,
293
+ capabilities: Array.isArray(message.capabilities) ? message.capabilities : [],
294
+ }
295
+ }
296
+
297
+ peerFrom(fields) {
298
+ return {
299
+ deviceId: fields.deviceId,
300
+ name: fields.name,
301
+ edPublic: fields.edPublic,
302
+ xPublic: fields.xPublic,
303
+ accountFingerprint: fields.accountFingerprint ?? null,
304
+ pluginVersion: fields.pluginVersion,
305
+ dshVersion: fields.dshVersion,
306
+ capabilities: fields.capabilities ?? [],
307
+ }
308
+ }
309
+
310
+ async authorizePeer() {
311
+ if (typeof this.verifyPeer !== 'function') return true
312
+ try {
313
+ const allowed = await this.verifyPeer(this.peer)
314
+ if (!allowed) {
315
+ this.fail('peer-rejected', 'peer rejected by local policy')
316
+ return false
317
+ }
318
+ return true
319
+ } catch (error) {
320
+ this.fail('peer-rejected', `peer verification failed: ${String(error?.message ?? error)}`)
321
+ return false
322
+ }
323
+ }
324
+
325
+ deriveKeys(shared, transcript, role) {
326
+ const okm = Buffer.from(
327
+ hkdfSync('sha256', shared, transcript, Buffer.from('dsh-device-fleet/channel/v1'), 72),
328
+ )
329
+ const initiatorToResponderKey = okm.subarray(0, 32)
330
+ const initiatorToResponderPrefix = okm.subarray(32, 36)
331
+ const responderToInitiatorKey = okm.subarray(36, 68)
332
+ const responderToInitiatorPrefix = okm.subarray(68, 72)
333
+ if (role === 'initiator') {
334
+ this.sendKey = initiatorToResponderKey
335
+ this.sendPrefix = initiatorToResponderPrefix
336
+ this.recvKey = responderToInitiatorKey
337
+ this.recvPrefix = responderToInitiatorPrefix
338
+ } else {
339
+ this.sendKey = responderToInitiatorKey
340
+ this.sendPrefix = responderToInitiatorPrefix
341
+ this.recvKey = initiatorToResponderKey
342
+ this.recvPrefix = initiatorToResponderPrefix
343
+ }
344
+ }
345
+
346
+ enterFrames() {
347
+ this.phase = 'frames'
348
+ if (this.recvBuffer.length > 0) this.processFrames()
349
+ }
350
+
351
+ sendReady() {
352
+ if (this.readySent || this.closed) return
353
+ this.readySent = true
354
+ this.sendFrame({ t: FRAME.READY, v: PROTOCOL_VERSION, deviceId: this.identity.deviceId })
355
+ this.maybeAuthenticate()
356
+ }
357
+
358
+ onReadyFrame() {
359
+ this.readyReceived = true
360
+ this.maybeAuthenticate()
361
+ }
362
+
363
+ maybeAuthenticate() {
364
+ if (this.authenticated || !this.readySent || !this.readyReceived) return
365
+ this.authenticated = true
366
+ clearTimeout(this.handshakeTimer)
367
+ this.emit('ready', this.peer)
368
+ }
369
+
370
+ // ------------------------------------------------------------------- frames
371
+
372
+ writeLine(message) {
373
+ if (this.closed) return
374
+ this.socket.write(JSON.stringify(message) + '\n')
375
+ }
376
+
377
+ sendFrame(message) {
378
+ if (this.closed) throw new Error('secure channel is closed')
379
+ if (this.phase !== 'frames' || !this.sendKey) throw new Error('secure channel is not ready')
380
+ const plaintext = Buffer.from(JSON.stringify(message))
381
+ this.sendCounter += 1n
382
+ const nonce = Buffer.alloc(12)
383
+ this.sendPrefix.copy(nonce, 0)
384
+ nonce.writeBigUInt64BE(this.sendCounter, 4)
385
+ const cipher = createCipheriv('aes-256-gcm', this.sendKey, nonce)
386
+ cipher.setAAD(AAD)
387
+ const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()])
388
+ const tag = cipher.getAuthTag()
389
+ const payload = Buffer.concat([ciphertext, tag])
390
+ const header = Buffer.alloc(4)
391
+ header.writeUInt32BE(payload.length)
392
+ this.socket.write(Buffer.concat([header, payload]))
393
+ }
394
+
395
+ onData(chunk) {
396
+ if (this.closed) return
397
+ this.recvBuffer = Buffer.concat([this.recvBuffer, chunk])
398
+ if (this.phase === 'handshake') this.processHandshake()
399
+ if (this.phase === 'frames') this.processFrames()
400
+ }
401
+
402
+ processHandshake() {
403
+ if (this.handshakeBusy) return
404
+ this.handshakeBusy = true
405
+ this.drainHandshake().finally(() => {
406
+ this.handshakeBusy = false
407
+ if (this.phase === 'frames' && !this.closed) this.processFrames()
408
+ })
409
+ }
410
+
411
+ async drainHandshake() {
412
+ while (this.phase === 'handshake' && !this.closed) {
413
+ const newline = this.recvBuffer.indexOf(0x0a)
414
+ if (newline < 0) return
415
+ const line = this.recvBuffer.subarray(0, newline).toString('utf8')
416
+ this.recvBuffer = this.recvBuffer.subarray(newline + 1)
417
+ let message
418
+ try {
419
+ message = JSON.parse(line)
420
+ } catch {
421
+ this.fail('handshake-rejected', 'invalid handshake JSON')
422
+ return
423
+ }
424
+ if (message.t === 'hello' && this.role === 'responder') {
425
+ try {
426
+ await this.onHello(message)
427
+ } catch (error) {
428
+ this.fail('handshake-error', `hello handling failed: ${String(error?.stack ?? error)}`)
429
+ return
430
+ }
431
+ } else if (message.t === 'hello.ack' && this.role === 'initiator') {
432
+ try {
433
+ await this.onHelloAck(message)
434
+ } catch (error) {
435
+ this.fail('handshake-error', `ack handling failed: ${String(error?.stack ?? error)}`)
436
+ return
437
+ }
438
+ } else {
439
+ this.fail('handshake-rejected', `unexpected handshake message ${String(message.t)}`)
440
+ return
441
+ }
442
+ }
443
+ }
444
+
445
+ processFrames() {
446
+ while (this.phase === 'frames' && !this.closed) {
447
+ if (this.recvBuffer.length < 4) return
448
+ const length = this.recvBuffer.readUInt32BE(0)
449
+ if (length <= 16 || length > MAX_FRAME_BYTES) {
450
+ this.fail('frame-invalid', `invalid frame length ${String(length)}`)
451
+ return
452
+ }
453
+ if (this.recvBuffer.length < 4 + length) return
454
+ const payload = this.recvBuffer.subarray(4, 4 + length)
455
+ this.recvBuffer = this.recvBuffer.subarray(4 + length)
456
+ let message
457
+ try {
458
+ message = this.openFrame(payload)
459
+ } catch {
460
+ this.fail('frame-invalid', 'frame authentication failed')
461
+ return
462
+ }
463
+ this.dispatch(message)
464
+ }
465
+ }
466
+
467
+ openFrame(payload) {
468
+ const tag = payload.subarray(payload.length - 16)
469
+ const ciphertext = payload.subarray(0, payload.length - 16)
470
+ this.recvCounter += 1n
471
+ const nonce = Buffer.alloc(12)
472
+ this.recvPrefix.copy(nonce, 0)
473
+ nonce.writeBigUInt64BE(this.recvCounter, 4)
474
+ const decipher = createDecipheriv('aes-256-gcm', this.recvKey, nonce)
475
+ decipher.setAAD(AAD)
476
+ decipher.setAuthTag(tag)
477
+ const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()])
478
+ return JSON.parse(plaintext.toString('utf8'))
479
+ }
480
+
481
+ dispatch(message) {
482
+ if (!message || typeof message !== 'object') return
483
+ if (message.t === FRAME.READY) {
484
+ this.onReadyFrame()
485
+ return
486
+ }
487
+ if (message.t === FRAME.PING) {
488
+ try {
489
+ this.sendFrame({ t: FRAME.PONG, v: PROTOCOL_VERSION })
490
+ } catch {
491
+ /* closing */
492
+ }
493
+ return
494
+ }
495
+ if (message.t === FRAME.PONG) return
496
+ if (!this.authenticated) {
497
+ this.fail('frame-before-ready', 'frame received before channel was ready')
498
+ return
499
+ }
500
+ this.emit('frame', message)
501
+ }
502
+
503
+ // ----------------------------------------------------------------- lifecycle
504
+
505
+ onSocketError(error) {
506
+ if (this.closed) return
507
+ this.emit('error', error)
508
+ }
509
+
510
+ onSocketClose() {
511
+ if (this.closed) return
512
+ this.closed = true
513
+ clearTimeout(this.handshakeTimer)
514
+ this.emit('close', this.failure)
515
+ }
516
+
517
+ fail(code, message) {
518
+ if (this.closed) return
519
+ const error = new Error(message)
520
+ error.code = code
521
+ this.failure = error
522
+ this.emit('error', error)
523
+ this.destroy()
524
+ }
525
+
526
+ destroy() {
527
+ if (this.closed) return
528
+ try {
529
+ this.socket.destroy()
530
+ } catch {
531
+ /* ignore */
532
+ }
533
+ }
534
+
535
+ close() {
536
+ this.destroy()
537
+ }
538
+ }