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,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
+ }
@@ -0,0 +1,366 @@
1
+ // Self-hosted relay for fleet devices behind NAT (v2).
2
+ //
3
+ // Control plane: TCP with newline-delimited JSON.
4
+ // register -> register.ok
5
+ // connect(reqId,target) -> connect.pending; target gets incoming(reqId,from)
6
+ // accept/reject on the target side -> connect.ready (both sides, with data token)
7
+ // Data plane: a second TCP listener. Each side connects with a one-line header
8
+ // {fleetId, deviceId, reqId, token}\n
9
+ // and the relay splices the two raw sockets together. Payload bytes are the
10
+ // end-to-end encrypted frames from secure-channel.js, so the relay cannot read them.
11
+ import { randomBytes } from 'node:crypto'
12
+ import { EventEmitter } from 'node:events'
13
+ import net from 'node:net'
14
+ import { verifyRelayAuthToken } from './identity.js'
15
+
16
+ const MAX_CONTROL_LINE = 64 * 1024
17
+ const MAX_CLOCK_SKEW_MS = 120_000
18
+ const ACCEPT_TIMEOUT_MS = 20_000
19
+ const PAIR_TIMEOUT_MS = 20_000
20
+
21
+ function randomToken() {
22
+ return randomBytes(24).toString('base64url')
23
+ }
24
+
25
+ function normalizeFleets(fleets) {
26
+ const map = new Map()
27
+ if (fleets instanceof Map) {
28
+ for (const [fleetId, key] of fleets) map.set(fleetId, key)
29
+ return map
30
+ }
31
+ for (const [fleetId, key] of Object.entries(fleets ?? {})) map.set(fleetId, key)
32
+ return map
33
+ }
34
+
35
+ function listen(server, port, host) {
36
+ return new Promise((resolve, reject) => {
37
+ const onError = (error) => {
38
+ server.off('listening', onListening)
39
+ reject(error)
40
+ }
41
+ const onListening = () => {
42
+ server.off('error', onError)
43
+ const address = server.address()
44
+ resolve(address?.port ?? port)
45
+ }
46
+ server.once('error', onError)
47
+ server.once('listening', onListening)
48
+ server.listen(port, host)
49
+ })
50
+ }
51
+
52
+ /**
53
+ * @param {{host?: string, port?: number, dataHost?: string, dataPort?: number, fleets?: Record<string,string>|Map<string,string>}} [options]
54
+ */
55
+ export function createRelayServer({
56
+ host = '127.0.0.1',
57
+ dataHost = host,
58
+ port = 0,
59
+ dataPort = 0,
60
+ fleets = {},
61
+ } = {}) {
62
+ const fleetKeys = normalizeFleets(fleets)
63
+ if (fleetKeys.size === 0) throw new Error('relay needs at least one fleet key')
64
+ const relay = new EventEmitter()
65
+ /** @type {Map<string, {deviceId: string, fleetId: string, socket: net.Socket}>} */
66
+ const devices = new Map()
67
+ /** @type {Map<string, any>} */
68
+ const pending = new Map()
69
+ const state = { port: null, dataPort: null, closed: false }
70
+ const trackedSockets = new Set()
71
+
72
+ const send = (socket, message) => {
73
+ if (!socket || socket.destroyed || !socket.writable) return
74
+ try {
75
+ socket.write(JSON.stringify(message) + '\n')
76
+ } catch {
77
+ /* socket is going away */
78
+ }
79
+ }
80
+
81
+ const cleanupPending = (reqId, reason, notify = true) => {
82
+ const entry = pending.get(reqId)
83
+ if (!entry) return
84
+ pending.delete(reqId)
85
+ clearTimeout(entry.acceptTimer)
86
+ clearTimeout(entry.pairTimer)
87
+ for (const socket of Object.values(entry.sockets ?? {})) {
88
+ try {
89
+ socket.destroy()
90
+ } catch {
91
+ /* ignore */
92
+ }
93
+ }
94
+ if (!notify) return
95
+ const fromDevice = devices.get(entry.fromDeviceId)
96
+ const targetDevice = devices.get(entry.targetDeviceId)
97
+ send(fromDevice?.socket, { t: 'connect.fail', reqId, reason })
98
+ send(targetDevice?.socket, { t: 'connect.fail', reqId, reason })
99
+ }
100
+
101
+ const removeDevice = (deviceId, socket) => {
102
+ const record = devices.get(deviceId)
103
+ if (!record || record.socket !== socket) return
104
+ devices.delete(deviceId)
105
+ for (const [reqId, entry] of pending) {
106
+ if (entry.fromDeviceId === deviceId || entry.targetDeviceId === deviceId) {
107
+ cleanupPending(reqId, 'peer-offline')
108
+ }
109
+ }
110
+ }
111
+
112
+ const handleControlLine = (conn, line) => {
113
+ let message
114
+ try {
115
+ message = JSON.parse(line)
116
+ } catch {
117
+ send(conn.socket, { t: 'error', code: 'bad-json' })
118
+ return
119
+ }
120
+ switch (message?.t) {
121
+ case 'register': {
122
+ const { fleetId, deviceId, ts, token } = message
123
+ if (typeof fleetId !== 'string' || typeof deviceId !== 'string') {
124
+ send(conn.socket, { t: 'error', code: 'bad-register' })
125
+ return
126
+ }
127
+ const fleetKey = fleetKeys.get(fleetId)
128
+ if (!fleetKey) {
129
+ send(conn.socket, { t: 'error', code: 'unknown-fleet' })
130
+ conn.socket.destroy()
131
+ return
132
+ }
133
+ if (typeof ts !== 'number' || Math.abs(Date.now() - ts) > MAX_CLOCK_SKEW_MS) {
134
+ send(conn.socket, { t: 'error', code: 'stale-token' })
135
+ conn.socket.destroy()
136
+ return
137
+ }
138
+ if (!verifyRelayAuthToken(token, { fleetKey, fleetId, deviceId, ts })) {
139
+ send(conn.socket, { t: 'error', code: 'unauthorized' })
140
+ conn.socket.destroy()
141
+ return
142
+ }
143
+ const previous = devices.get(deviceId)
144
+ if (previous && previous.socket !== conn.socket) {
145
+ send(previous.socket, { t: 'error', code: 'replaced' })
146
+ previous.socket.destroy()
147
+ }
148
+ devices.set(deviceId, { deviceId, fleetId, socket: conn.socket })
149
+ conn.registered = { deviceId, fleetId }
150
+ send(conn.socket, { t: 'register.ok', deviceId, server: 'dsh-device-fleet-relay', v: 1 })
151
+ return
152
+ }
153
+ case 'ping': {
154
+ send(conn.socket, { t: 'pong' })
155
+ return
156
+ }
157
+ case 'connect': {
158
+ if (!conn.registered) {
159
+ send(conn.socket, { t: 'connect.fail', reqId: message.reqId, reason: 'not-registered' })
160
+ return
161
+ }
162
+ const { reqId, targetDeviceId } = message
163
+ if (typeof reqId !== 'string' || typeof targetDeviceId !== 'string') {
164
+ send(conn.socket, { t: 'connect.fail', reqId: message.reqId, reason: 'bad-request' })
165
+ return
166
+ }
167
+ const target = devices.get(targetDeviceId)
168
+ if (!target || target.fleetId !== conn.registered.fleetId || target.socket === conn.socket) {
169
+ send(conn.socket, { t: 'connect.fail', reqId, reason: 'offline' })
170
+ return
171
+ }
172
+ if (pending.has(reqId)) {
173
+ send(conn.socket, { t: 'connect.fail', reqId, reason: 'duplicate-request' })
174
+ return
175
+ }
176
+ const entry = {
177
+ reqId,
178
+ fleetId: conn.registered.fleetId,
179
+ fromDeviceId: conn.registered.deviceId,
180
+ targetDeviceId,
181
+ sockets: {},
182
+ tokens: null,
183
+ accepted: false,
184
+ acceptTimer: setTimeout(() => cleanupPending(reqId, 'accept-timeout'), ACCEPT_TIMEOUT_MS),
185
+ pairTimer: null,
186
+ }
187
+ entry.acceptTimer.unref?.()
188
+ pending.set(reqId, entry)
189
+ send(target.socket, { t: 'incoming', reqId, fromDeviceId: entry.fromDeviceId })
190
+ send(conn.socket, { t: 'connect.pending', reqId })
191
+ return
192
+ }
193
+ case 'accept': {
194
+ if (!conn.registered) return
195
+ const entry = pending.get(message.reqId)
196
+ if (!entry || entry.targetDeviceId !== conn.registered.deviceId) {
197
+ send(conn.socket, { t: 'error', code: 'unknown-request', reqId: message.reqId })
198
+ return
199
+ }
200
+ if (entry.accepted) return
201
+ entry.accepted = true
202
+ clearTimeout(entry.acceptTimer)
203
+ entry.tokens = { [entry.fromDeviceId]: randomToken(), [entry.targetDeviceId]: randomToken() }
204
+ entry.pairTimer = setTimeout(() => cleanupPending(entry.reqId, 'data-timeout'), PAIR_TIMEOUT_MS)
205
+ entry.pairTimer.unref?.()
206
+ const from = devices.get(entry.fromDeviceId)
207
+ const target = devices.get(entry.targetDeviceId)
208
+ send(from?.socket, { t: 'connect.ready', reqId: entry.reqId, dataPort: state.dataPort, token: entry.tokens[entry.fromDeviceId] })
209
+ send(target?.socket, { t: 'connect.ready', reqId: entry.reqId, dataPort: state.dataPort, token: entry.tokens[entry.targetDeviceId] })
210
+ return
211
+ }
212
+ case 'reject': {
213
+ const entry = pending.get(message.reqId)
214
+ if (!entry || entry.targetDeviceId !== conn.registered?.deviceId) return
215
+ cleanupPending(entry.reqId, 'rejected')
216
+ return
217
+ }
218
+ default:
219
+ send(conn.socket, { t: 'error', code: 'unknown-message' })
220
+ }
221
+ }
222
+
223
+ const onControlSocket = (socket) => {
224
+ trackedSockets.add(socket)
225
+ socket.setNoDelay?.(true)
226
+ const conn = { socket, registered: null, buffer: '' }
227
+ socket.on('data', (chunk) => {
228
+ conn.buffer += chunk.toString('utf8')
229
+ if (conn.buffer.length > MAX_CONTROL_LINE * 8) {
230
+ socket.destroy()
231
+ return
232
+ }
233
+ let newline = conn.buffer.indexOf('\n')
234
+ while (newline >= 0) {
235
+ const line = conn.buffer.slice(0, newline)
236
+ conn.buffer = conn.buffer.slice(newline + 1)
237
+ if (line.trim() !== '') handleControlLine(conn, line)
238
+ newline = conn.buffer.indexOf('\n')
239
+ }
240
+ })
241
+ socket.on('error', () => {})
242
+ socket.on('close', () => {
243
+ trackedSockets.delete(socket)
244
+ if (conn.registered) removeDevice(conn.registered.deviceId, socket)
245
+ })
246
+ }
247
+
248
+ const pairDataSockets = (reqId) => {
249
+ const entry = pending.get(reqId)
250
+ if (!entry) return
251
+ const entries = Object.entries(entry.sockets)
252
+ if (entries.length !== 2) return
253
+ pending.delete(reqId)
254
+ clearTimeout(entry.pairTimer)
255
+ clearTimeout(entry.acceptTimer)
256
+ const [[sideA, socketA], [sideB, socketB]] = entries
257
+ for (const socket of [socketA, socketB]) trackedSockets.delete(socket)
258
+ const destroyBoth = () => {
259
+ for (const socket of [socketA, socketB]) {
260
+ try {
261
+ socket.destroy()
262
+ } catch {
263
+ /* ignore */
264
+ }
265
+ }
266
+ }
267
+ socketA.on('error', destroyBoth)
268
+ socketB.on('error', destroyBoth)
269
+ socketA.on('close', () => socketB.destroy())
270
+ socketB.on('close', () => socketA.destroy())
271
+ socketA.pipe(socketB)
272
+ socketB.pipe(socketA)
273
+ socketA.resume()
274
+ socketB.resume()
275
+ relay.emit('spliced', {
276
+ reqId,
277
+ fleetId: entry.fleetId,
278
+ fromDeviceId: entry.fromDeviceId,
279
+ targetDeviceId: entry.targetDeviceId,
280
+ sides: [sideA, sideB],
281
+ })
282
+ }
283
+
284
+ const onDataSocket = (socket) => {
285
+ trackedSockets.add(socket)
286
+ socket.setNoDelay?.(true)
287
+ let headerBuffer = Buffer.alloc(0)
288
+ const onData = (chunk) => {
289
+ headerBuffer = Buffer.concat([headerBuffer, chunk])
290
+ const newline = headerBuffer.indexOf(0x0a)
291
+ if (newline < 0) {
292
+ if (headerBuffer.length > MAX_CONTROL_LINE) socket.destroy()
293
+ return
294
+ }
295
+ socket.off('data', onData)
296
+ let header
297
+ try {
298
+ header = JSON.parse(headerBuffer.subarray(0, newline).toString('utf8'))
299
+ } catch {
300
+ socket.destroy()
301
+ return
302
+ }
303
+ const leftover = headerBuffer.subarray(newline + 1)
304
+ const entry = pending.get(header?.reqId)
305
+ if (!entry || !entry.tokens) {
306
+ socket.destroy()
307
+ return
308
+ }
309
+ const side =
310
+ header.deviceId === entry.fromDeviceId ? entry.fromDeviceId : header.deviceId === entry.targetDeviceId ? entry.targetDeviceId : null
311
+ if (!side || header.fleetId !== entry.fleetId || header.token !== entry.tokens[side]) {
312
+ socket.destroy()
313
+ return
314
+ }
315
+ if (entry.sockets[side]) {
316
+ socket.destroy()
317
+ return
318
+ }
319
+ socket.pause()
320
+ entry.sockets[side] = socket
321
+ if (leftover.length > 0) socket.unshift(leftover)
322
+ pairDataSockets(entry.reqId)
323
+ }
324
+ socket.on('data', onData)
325
+ socket.once('close', () => {
326
+ trackedSockets.delete(socket)
327
+ for (const [reqId, entry] of pending) {
328
+ if (entry.sockets && Object.values(entry.sockets).includes(socket)) cleanupPending(reqId, 'data-closed')
329
+ }
330
+ })
331
+ socket.on('error', () => {})
332
+ }
333
+
334
+ const controlServer = net.createServer(onControlSocket)
335
+ const dataServer = net.createServer(onDataSocket)
336
+
337
+ const ready = (async () => {
338
+ state.dataPort = await listen(dataServer, dataPort, dataHost)
339
+ state.port = await listen(controlServer, port, host)
340
+ return { port: state.port, dataPort: state.dataPort }
341
+ })()
342
+ ready.catch(() => {})
343
+
344
+ return {
345
+ ready,
346
+ state,
347
+ on: relay.on.bind(relay),
348
+ once: relay.once.bind(relay),
349
+ close: async () => {
350
+ if (state.closed) return
351
+ state.closed = true
352
+ for (const [reqId] of pending) cleanupPending(reqId, 'server-closing', false)
353
+ for (const socket of trackedSockets) {
354
+ try {
355
+ socket.destroy()
356
+ } catch {
357
+ /* ignore */
358
+ }
359
+ }
360
+ await Promise.all([
361
+ new Promise((resolve) => (controlServer.listening ? controlServer.close(() => resolve()) : resolve())),
362
+ new Promise((resolve) => (dataServer.listening ? dataServer.close(() => resolve()) : resolve())),
363
+ ])
364
+ },
365
+ }
366
+ }