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.
- package/CONTEXT.md +123 -0
- package/LICENSE +21 -0
- package/README.md +135 -2
- package/client.js +167 -0
- package/cordis.patch.yml +26 -0
- package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
- package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
- package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
- package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
- package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
- package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
- package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
- package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
- package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
- package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
- package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
- package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
- package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
- package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
- package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
- package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
- package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
- package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
- package/docs/adr/0019-execution-context-configured-locally.md +24 -0
- package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
- package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
- package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
- package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
- package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
- package/docs/notes/approval-inbox-phase.md +31 -0
- package/docs/notes/design-decisions-summary.md +49 -0
- package/docs/notes/dev-release.md +67 -0
- package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
- package/docs/notes/network-v1-v2-implementation.md +71 -0
- package/docs/notes/pairing-session-2026-11-09.md +30 -0
- package/docs/notes/remote-tests-2026-11-09.md +33 -0
- package/docs/notes/room-logic-phase.md +31 -0
- package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
- package/package.json +51 -3
- package/src/host/approval-inbox.js +135 -0
- package/src/host/approval-web.js +95 -0
- package/src/host/delegation.js +127 -0
- package/src/host/direct-call.js +209 -0
- package/src/host/execution-slot.js +96 -0
- package/src/host/index.js +10 -0
- package/src/host/room-service.js +324 -0
- package/src/host/store.js +156 -0
- package/src/host/tool-policy.js +151 -0
- package/src/host/tools.js +590 -0
- package/src/index.js +3 -0
- package/src/net/direct.js +69 -0
- package/src/net/discovery.js +105 -0
- package/src/net/fleet-node.js +509 -0
- package/src/net/identity.js +167 -0
- package/src/net/index.js +9 -0
- package/src/net/protocol.js +73 -0
- package/src/net/relay-client.js +258 -0
- package/src/net/relay-server.js +366 -0
- package/src/net/secure-channel.js +538 -0
- 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
|
+
}
|
package/src/net/index.js
ADDED
|
@@ -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
|
+
}
|