ds-harness-remote 0.3.8
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/LICENSE +21 -0
- package/README.md +103 -0
- package/cordis.patch.yml +7 -0
- package/dist/adapters/agent-adapter.d.ts +35 -0
- package/dist/adapters/agent-adapter.d.ts.map +1 -0
- package/dist/adapters/agent-adapter.js +80 -0
- package/dist/adapters/agent-adapter.js.map +1 -0
- package/dist/adapters/permission-adapter.d.ts +13 -0
- package/dist/adapters/permission-adapter.d.ts.map +1 -0
- package/dist/adapters/permission-adapter.js +67 -0
- package/dist/adapters/permission-adapter.js.map +1 -0
- package/dist/adapters/session-adapter.d.ts +38 -0
- package/dist/adapters/session-adapter.d.ts.map +1 -0
- package/dist/adapters/session-adapter.js +230 -0
- package/dist/adapters/session-adapter.js.map +1 -0
- package/dist/adapters/workspace-adapter.d.ts +13 -0
- package/dist/adapters/workspace-adapter.d.ts.map +1 -0
- package/dist/adapters/workspace-adapter.js +21 -0
- package/dist/adapters/workspace-adapter.js.map +1 -0
- package/dist/api-proxy-switch.d.ts +33 -0
- package/dist/api-proxy-switch.d.ts.map +1 -0
- package/dist/api-proxy-switch.js +107 -0
- package/dist/api-proxy-switch.js.map +1 -0
- package/dist/bin/doctor.d.ts +3 -0
- package/dist/bin/doctor.d.ts.map +1 -0
- package/dist/bin/doctor.js +13 -0
- package/dist/bin/doctor.js.map +1 -0
- package/dist/client-runtime.d.ts +98 -0
- package/dist/client-runtime.d.ts.map +1 -0
- package/dist/client-runtime.js +421 -0
- package/dist/client-runtime.js.map +1 -0
- package/dist/client-secure-transport.d.ts +22 -0
- package/dist/client-secure-transport.d.ts.map +1 -0
- package/dist/client-secure-transport.js +129 -0
- package/dist/client-secure-transport.js.map +1 -0
- package/dist/client.d.ts +12 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.github.js +2938 -0
- package/dist/client.js +2938 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +33 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +80 -0
- package/dist/config.js.map +1 -0
- package/dist/connection-controller.d.ts +39 -0
- package/dist/connection-controller.d.ts.map +1 -0
- package/dist/connection-controller.js +170 -0
- package/dist/connection-controller.js.map +1 -0
- package/dist/control-runtime.d.ts +36 -0
- package/dist/control-runtime.d.ts.map +1 -0
- package/dist/control-runtime.js +265 -0
- package/dist/control-runtime.js.map +1 -0
- package/dist/event-sequencer.d.ts +23 -0
- package/dist/event-sequencer.d.ts.map +1 -0
- package/dist/event-sequencer.js +54 -0
- package/dist/event-sequencer.js.map +1 -0
- package/dist/harness-api-bridge.d.ts +55 -0
- package/dist/harness-api-bridge.d.ts.map +1 -0
- package/dist/harness-api-bridge.js +387 -0
- package/dist/harness-api-bridge.js.map +1 -0
- package/dist/identity-store.d.ts +45 -0
- package/dist/identity-store.d.ts.map +1 -0
- package/dist/identity-store.js +183 -0
- package/dist/identity-store.js.map +1 -0
- package/dist/ids.d.ts +2 -0
- package/dist/ids.d.ts.map +1 -0
- package/dist/ids.js +17 -0
- package/dist/ids.js.map +1 -0
- package/dist/index.d.ts +38 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16091 -0
- package/dist/index.js.map +7 -0
- package/dist/logging.d.ts +18 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +30 -0
- package/dist/logging.js.map +1 -0
- package/dist/pairing-controller.d.ts +52 -0
- package/dist/pairing-controller.d.ts.map +1 -0
- package/dist/pairing-controller.js +73 -0
- package/dist/pairing-controller.js.map +1 -0
- package/dist/pending-approvals.d.ts +14 -0
- package/dist/pending-approvals.d.ts.map +1 -0
- package/dist/pending-approvals.js +52 -0
- package/dist/pending-approvals.js.map +1 -0
- package/dist/remote-api-proxy.d.ts +12 -0
- package/dist/remote-api-proxy.d.ts.map +1 -0
- package/dist/remote-api-proxy.js +191 -0
- package/dist/remote-api-proxy.js.map +1 -0
- package/dist/remote-directory-browser.d.ts +16 -0
- package/dist/remote-directory-browser.d.ts.map +1 -0
- package/dist/remote-directory-browser.js +48 -0
- package/dist/remote-directory-browser.js.map +1 -0
- package/dist/rpc-router.d.ts +21 -0
- package/dist/rpc-router.d.ts.map +1 -0
- package/dist/rpc-router.js +93 -0
- package/dist/rpc-router.js.map +1 -0
- package/dist/server-api.d.ts +80 -0
- package/dist/server-api.d.ts.map +1 -0
- package/dist/server-api.js +405 -0
- package/dist/server-api.js.map +1 -0
- package/dist/server-connection.d.ts +70 -0
- package/dist/server-connection.d.ts.map +1 -0
- package/dist/server-connection.js +759 -0
- package/dist/server-connection.js.map +1 -0
- package/dist/server-credentials.d.ts +46 -0
- package/dist/server-credentials.d.ts.map +1 -0
- package/dist/server-credentials.js +71 -0
- package/dist/server-credentials.js.map +1 -0
- package/dist/service.d.ts +56 -0
- package/dist/service.d.ts.map +1 -0
- package/dist/service.js +162 -0
- package/dist/service.js.map +1 -0
- package/dist/typert-gateway-switch.d.ts +19 -0
- package/dist/typert-gateway-switch.d.ts.map +1 -0
- package/dist/typert-gateway-switch.js +41 -0
- package/dist/typert-gateway-switch.js.map +1 -0
- package/dist/types.d.ts +20 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/version.d.ts +12 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +12 -0
- package/dist/version.js.map +1 -0
- package/dist/werift-rtc.d.ts +85 -0
- package/dist/werift-rtc.d.ts.map +1 -0
- package/dist/werift-rtc.js +164 -0
- package/dist/werift-rtc.js.map +1 -0
- package/package.json +98 -0
- package/public.d.ts +19 -0
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
import { NoiseIkSession, createNoisePrologue, fromBase64Url, toBase64Url, } from '@dsh-remote/crypto';
|
|
2
|
+
import { PROTOCOL_VERSION, SecureMessageCodec, createControlFrame, decodeMessage, encodeMessage, parseControlFrame, } from '@dsh-remote/protocol';
|
|
3
|
+
import { RtcDataChannelTransport, } from '@dsh-remote/webrtc';
|
|
4
|
+
import { ServerApiError } from './server-api.js';
|
|
5
|
+
import { PLUGIN_VERSION } from './version.js';
|
|
6
|
+
const DEFAULT_WEBRTC_NEGOTIATE_TIMEOUT_MS = 8_000;
|
|
7
|
+
export class HostServerConnection {
|
|
8
|
+
config;
|
|
9
|
+
identity;
|
|
10
|
+
identities;
|
|
11
|
+
api;
|
|
12
|
+
connections;
|
|
13
|
+
logger;
|
|
14
|
+
createWebSocket;
|
|
15
|
+
rtcFactoryProvider;
|
|
16
|
+
socket;
|
|
17
|
+
running;
|
|
18
|
+
stopped = true;
|
|
19
|
+
online = false;
|
|
20
|
+
retryWake;
|
|
21
|
+
tunnels = new Map();
|
|
22
|
+
terminalError;
|
|
23
|
+
lastActiveAt;
|
|
24
|
+
reconnectRequested = false;
|
|
25
|
+
resumeQueued = false;
|
|
26
|
+
rtcFactory;
|
|
27
|
+
constructor(config, identity, identities, api, connections, logger, createWebSocket = url => new WebSocket(url), rtcFactoryProvider) {
|
|
28
|
+
this.config = config;
|
|
29
|
+
this.identity = identity;
|
|
30
|
+
this.identities = identities;
|
|
31
|
+
this.api = api;
|
|
32
|
+
this.connections = connections;
|
|
33
|
+
this.logger = logger;
|
|
34
|
+
this.createWebSocket = createWebSocket;
|
|
35
|
+
this.rtcFactoryProvider = rtcFactoryProvider;
|
|
36
|
+
}
|
|
37
|
+
start() {
|
|
38
|
+
if (this.running !== undefined)
|
|
39
|
+
return;
|
|
40
|
+
this.stopped = false;
|
|
41
|
+
this.running = this.run().finally(() => { this.running = undefined; });
|
|
42
|
+
}
|
|
43
|
+
resume() {
|
|
44
|
+
this.terminalError = undefined;
|
|
45
|
+
this.stopped = false;
|
|
46
|
+
if (this.running === undefined) {
|
|
47
|
+
this.start();
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (this.resumeQueued)
|
|
51
|
+
return;
|
|
52
|
+
this.resumeQueued = true;
|
|
53
|
+
void this.running.finally(() => {
|
|
54
|
+
this.resumeQueued = false;
|
|
55
|
+
if (!this.stopped)
|
|
56
|
+
this.start();
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
async stop() {
|
|
60
|
+
this.stopped = true;
|
|
61
|
+
this.reconnectRequested = false;
|
|
62
|
+
this.retryWake?.();
|
|
63
|
+
this.retryWake = undefined;
|
|
64
|
+
this.socket?.close(1000, 'plugin stopped');
|
|
65
|
+
await this.running;
|
|
66
|
+
await this.dropTunnels();
|
|
67
|
+
}
|
|
68
|
+
isOnline() { return this.online; }
|
|
69
|
+
lastError() { return this.terminalError; }
|
|
70
|
+
lastActivity() { return this.lastActiveAt; }
|
|
71
|
+
isReconnecting() { return !this.online && !this.stopped && this.running !== undefined; }
|
|
72
|
+
reconnect() {
|
|
73
|
+
this.terminalError = undefined;
|
|
74
|
+
this.stopped = false;
|
|
75
|
+
if (this.running === undefined) {
|
|
76
|
+
this.start();
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
this.reconnectRequested = true;
|
|
80
|
+
this.retryWake?.();
|
|
81
|
+
this.socket?.close(4000, 'manual reconnect');
|
|
82
|
+
void this.running.finally(() => {
|
|
83
|
+
if (!this.reconnectRequested || this.stopped)
|
|
84
|
+
return;
|
|
85
|
+
this.reconnectRequested = false;
|
|
86
|
+
this.start();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async run() {
|
|
90
|
+
let delayMs = this.config.reconnect.initialDelayMs;
|
|
91
|
+
while (!this.stopped) {
|
|
92
|
+
try {
|
|
93
|
+
await this.connectOnce();
|
|
94
|
+
delayMs = this.config.reconnect.initialDelayMs;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
const code = errorCode(error);
|
|
98
|
+
this.terminalError = code;
|
|
99
|
+
this.logger.warn('server control connection failed', { code, retryable: isRetryable(error) });
|
|
100
|
+
if (TERMINAL_AUTH_ERRORS.has(code) || !this.config.reconnect.enabled)
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (this.stopped)
|
|
104
|
+
return;
|
|
105
|
+
if (this.reconnectRequested) {
|
|
106
|
+
this.reconnectRequested = false;
|
|
107
|
+
delayMs = this.config.reconnect.initialDelayMs;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!this.config.reconnect.enabled)
|
|
111
|
+
return;
|
|
112
|
+
await this.waitBeforeRetry(delayMs);
|
|
113
|
+
if (this.reconnectRequested) {
|
|
114
|
+
this.reconnectRequested = false;
|
|
115
|
+
delayMs = this.config.reconnect.initialDelayMs;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
delayMs = Math.min(this.config.reconnect.maxDelayMs, delayMs * 2);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
async connectOnce() {
|
|
122
|
+
const credentials = await this.api.authenticate(this.identity);
|
|
123
|
+
if (this.stopped)
|
|
124
|
+
return;
|
|
125
|
+
const socket = this.createWebSocket(websocketUrl(this.api.baseUrl));
|
|
126
|
+
this.socket = socket;
|
|
127
|
+
let acknowledged = false;
|
|
128
|
+
let messageQueue = Promise.resolve();
|
|
129
|
+
await new Promise((resolve, reject) => {
|
|
130
|
+
let settled = false;
|
|
131
|
+
const helloTimer = setTimeout(() => socket.close(4001, 'hello timeout'), 10_000);
|
|
132
|
+
const finish = (error) => {
|
|
133
|
+
if (settled)
|
|
134
|
+
return;
|
|
135
|
+
settled = true;
|
|
136
|
+
clearTimeout(helloTimer);
|
|
137
|
+
this.online = false;
|
|
138
|
+
if (this.socket === socket)
|
|
139
|
+
this.socket = undefined;
|
|
140
|
+
void this.dropTunnels().finally(() => error === undefined ? resolve() : reject(error));
|
|
141
|
+
};
|
|
142
|
+
socket.onopen = () => {
|
|
143
|
+
this.sendControl('hello', {
|
|
144
|
+
role: 'host',
|
|
145
|
+
deviceId: this.identity.deviceId,
|
|
146
|
+
accessToken: credentials.accessToken,
|
|
147
|
+
protocols: [PROTOCOL_VERSION],
|
|
148
|
+
clientVersion: PLUGIN_VERSION,
|
|
149
|
+
capabilities: this.rtcFactoryProvider === undefined || this.config.forceRelay
|
|
150
|
+
? ['transport.relay', 'harness.api.v1']
|
|
151
|
+
: ['transport.p2p', 'transport.turn', 'transport.relay', 'harness.api.v1'],
|
|
152
|
+
});
|
|
153
|
+
};
|
|
154
|
+
socket.onmessage = event => {
|
|
155
|
+
messageQueue = messageQueue.then(async () => {
|
|
156
|
+
const frame = decodeControl(event.data);
|
|
157
|
+
this.lastActiveAt = Date.now();
|
|
158
|
+
if (frame.type === 'hello.ack') {
|
|
159
|
+
const payload = requireHelloAck(frame.payload);
|
|
160
|
+
acknowledged = true;
|
|
161
|
+
clearTimeout(helloTimer);
|
|
162
|
+
this.online = true;
|
|
163
|
+
this.terminalError = undefined;
|
|
164
|
+
this.logger.info('server control connection online', {
|
|
165
|
+
serverVersion: payload.serverVersion,
|
|
166
|
+
connectionSessionId: shortId(payload.connectionSessionId),
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (!acknowledged)
|
|
171
|
+
throw new ControlConnectionError('INVALID_MESSAGE', 'Server sent a frame before hello.ack.');
|
|
172
|
+
await this.handleFrame(frame);
|
|
173
|
+
}).catch(error => {
|
|
174
|
+
const code = errorCode(error);
|
|
175
|
+
this.terminalError = code;
|
|
176
|
+
this.logger.error('server control frame failed', {
|
|
177
|
+
code,
|
|
178
|
+
reason: diagnosticReason(error),
|
|
179
|
+
});
|
|
180
|
+
socket.close(4008, 'invalid control frame');
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
socket.onerror = () => {
|
|
184
|
+
if (!acknowledged)
|
|
185
|
+
finish(new ControlConnectionError('CONNECTION_FAILED', 'Unable to open the Server WebSocket.'));
|
|
186
|
+
};
|
|
187
|
+
socket.onclose = event => {
|
|
188
|
+
const close = async () => {
|
|
189
|
+
await messageQueue.catch(() => undefined);
|
|
190
|
+
if (event.code === 4002) {
|
|
191
|
+
try {
|
|
192
|
+
await this.api.refreshCredentials();
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
finish(asError(error));
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (event.code === 4004) {
|
|
200
|
+
finish(new ControlConnectionError('DEVICE_REVOKED', 'The Server revoked this Host device.'));
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (acknowledged)
|
|
204
|
+
this.terminalError = closeCode(event.code);
|
|
205
|
+
finish(acknowledged ? undefined : new ControlConnectionError(closeCode(event.code), event.reason || 'Server control connection closed.'));
|
|
206
|
+
};
|
|
207
|
+
void close();
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
async handleFrame(frame) {
|
|
212
|
+
if (frame.type === 'ping') {
|
|
213
|
+
const nonce = objectValue(frame.payload, 'nonce');
|
|
214
|
+
if (typeof nonce !== 'string')
|
|
215
|
+
throw new ControlConnectionError('INVALID_MESSAGE', 'Control ping has no nonce.');
|
|
216
|
+
this.sendControl('pong', { nonce });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
if (frame.type === 'pong')
|
|
220
|
+
return;
|
|
221
|
+
if (frame.type === 'connect.incoming') {
|
|
222
|
+
await this.handleConnectIncoming(requireConnectIncoming(frame.payload));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (frame.type === 'secure.handshake') {
|
|
226
|
+
await this.handleHandshake(requireHandshake(frame.payload));
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (frame.type === 'relay') {
|
|
230
|
+
await this.handleRelay(requireRelay(frame.payload));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
if (frame.type === 'signal.offer') {
|
|
234
|
+
await this.handleSignalOffer(requireSignal(frame.payload));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (frame.type === 'signal.ice') {
|
|
238
|
+
this.handleSignalIce(requireSignalIce(frame.payload));
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (frame.type === 'transport.selected')
|
|
242
|
+
return;
|
|
243
|
+
if (frame.type === 'signal.answer')
|
|
244
|
+
return;
|
|
245
|
+
if (frame.type === 'error') {
|
|
246
|
+
const payload = requireControlError(frame.payload);
|
|
247
|
+
if (payload.code === 'DEVICE_REVOKED') {
|
|
248
|
+
this.terminalError = payload.code;
|
|
249
|
+
this.socket?.close(4004, 'device revoked');
|
|
250
|
+
}
|
|
251
|
+
else if (payload.connectionId !== undefined) {
|
|
252
|
+
await this.dropTunnel(payload.connectionId, payload.code);
|
|
253
|
+
this.logger.warn('server closed a remote connection', {
|
|
254
|
+
code: payload.code,
|
|
255
|
+
connectionId: shortId(payload.connectionId),
|
|
256
|
+
retryable: payload.retryable,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
else {
|
|
260
|
+
this.terminalError = payload.code;
|
|
261
|
+
this.logger.warn('server returned a control error', { code: payload.code, retryable: payload.retryable });
|
|
262
|
+
}
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
throw new ControlConnectionError('INVALID_MESSAGE', `Unexpected Server control frame: ${frame.type}`);
|
|
266
|
+
}
|
|
267
|
+
async handleConnectIncoming(payload) {
|
|
268
|
+
let descriptor;
|
|
269
|
+
try {
|
|
270
|
+
descriptor = await this.api.deviceFor(payload.clientDeviceId);
|
|
271
|
+
}
|
|
272
|
+
catch (error) {
|
|
273
|
+
this.sendControl('connect.rejected', { connectionId: payload.connectionId });
|
|
274
|
+
this.logger.warn('connection rejected by account authorization', {
|
|
275
|
+
clientDeviceId: shortId(payload.clientDeviceId),
|
|
276
|
+
code: errorCode(error),
|
|
277
|
+
});
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
if (descriptor.role !== 'client' || descriptor.deviceId !== payload.clientDeviceId
|
|
281
|
+
|| descriptor.identityKey !== payload.clientIdentityKey) {
|
|
282
|
+
this.sendControl('connect.rejected', { connectionId: payload.connectionId });
|
|
283
|
+
this.logger.warn('connection rejected by peer identity validation', {
|
|
284
|
+
clientDeviceId: shortId(payload.clientDeviceId),
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const existing = this.identities.trustedPeer(descriptor.deviceId);
|
|
289
|
+
if (existing !== undefined && existing.publicKey !== descriptor.identityKey) {
|
|
290
|
+
this.sendControl('connect.rejected', { connectionId: payload.connectionId });
|
|
291
|
+
this.logger.warn('connection rejected by pinned peer identity', {
|
|
292
|
+
clientDeviceId: shortId(payload.clientDeviceId),
|
|
293
|
+
});
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const peer = existing !== undefined
|
|
297
|
+
&& existing.membershipId === descriptor.membershipId
|
|
298
|
+
&& existing.name === descriptor.name
|
|
299
|
+
&& existing.platform === descriptor.platform
|
|
300
|
+
? existing
|
|
301
|
+
: await this.identities.trustPeer({
|
|
302
|
+
deviceId: descriptor.deviceId,
|
|
303
|
+
name: descriptor.name,
|
|
304
|
+
platform: descriptor.platform,
|
|
305
|
+
publicKey: descriptor.identityKey,
|
|
306
|
+
membershipId: descriptor.membershipId,
|
|
307
|
+
});
|
|
308
|
+
const previous = this.tunnels.get(payload.connectionId);
|
|
309
|
+
previous?.noise.destroy();
|
|
310
|
+
const noise = new NoiseIkSession({
|
|
311
|
+
role: 'responder',
|
|
312
|
+
localPrivateKey: this.identity.privateKey,
|
|
313
|
+
localPublicKey: this.identity.publicKey,
|
|
314
|
+
remotePublicKey: peer.publicKey,
|
|
315
|
+
prologue: createNoisePrologue(payload.connectionId, this.identity.deviceId, peer.deviceId),
|
|
316
|
+
});
|
|
317
|
+
this.tunnels.set(payload.connectionId, {
|
|
318
|
+
connectionId: payload.connectionId,
|
|
319
|
+
membershipId: descriptor.membershipId,
|
|
320
|
+
peer,
|
|
321
|
+
noise,
|
|
322
|
+
transport: 'negotiating',
|
|
323
|
+
});
|
|
324
|
+
this.sendControl('connect.accepted', { connectionId: payload.connectionId });
|
|
325
|
+
}
|
|
326
|
+
async handleHandshake(payload) {
|
|
327
|
+
const tunnel = this.tunnels.get(payload.connectionId);
|
|
328
|
+
if (tunnel !== undefined
|
|
329
|
+
&& tunnel.channel !== undefined
|
|
330
|
+
&& payload.targetDeviceId === this.identity.deviceId
|
|
331
|
+
&& payload.step === 1) {
|
|
332
|
+
// A queued Relay/WebRTC fallback can deliver the initiator's first
|
|
333
|
+
// handshake again after the authenticated channel is already ready.
|
|
334
|
+
// It belongs to this exact tunnel, so ignore it without taking the
|
|
335
|
+
// Host's shared control connection offline.
|
|
336
|
+
this.logger.warn('duplicate secure handshake ignored', {
|
|
337
|
+
connectionId: shortId(tunnel.connectionId),
|
|
338
|
+
peerDeviceId: shortId(tunnel.peer.deviceId),
|
|
339
|
+
});
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
if (tunnel === undefined || payload.targetDeviceId !== this.identity.deviceId || payload.step !== 1) {
|
|
343
|
+
throw new ControlConnectionError('SECURE_CHANNEL_FAILED', 'Noise IK handshake is not valid for this connection.');
|
|
344
|
+
}
|
|
345
|
+
tunnel.noise.readHandshake(fromBase64Url(payload.data));
|
|
346
|
+
const reply = tunnel.noise.writeHandshake();
|
|
347
|
+
if (!tunnel.noise.complete)
|
|
348
|
+
throw new ControlConnectionError('SECURE_CHANNEL_FAILED', 'Noise IK handshake did not complete.');
|
|
349
|
+
const viaWebRtc = tunnel.rtc !== undefined && (tunnel.transport === 'p2p' || tunnel.transport === 'turn');
|
|
350
|
+
if (!viaWebRtc && tunnel.transport === 'negotiating')
|
|
351
|
+
tunnel.transport = 'relay';
|
|
352
|
+
const mode = viaWebRtc ? (tunnel.transport === 'turn' ? 'TURN' : 'P2P') : 'Relay';
|
|
353
|
+
const transmit = viaWebRtc
|
|
354
|
+
? (ciphertext) => tunnel.rtc.send(ciphertext)
|
|
355
|
+
: (ciphertext) => this.sendRelay(tunnel, ciphertext);
|
|
356
|
+
const channel = new ServerNoiseChannel(tunnel, transmit, () => {
|
|
357
|
+
if (this.tunnels.get(tunnel.connectionId) === tunnel)
|
|
358
|
+
this.tunnels.delete(tunnel.connectionId);
|
|
359
|
+
}, mode);
|
|
360
|
+
tunnel.channel = channel;
|
|
361
|
+
await this.connections.accept(channel);
|
|
362
|
+
// Publish the responder handshake only after the channel is registered.
|
|
363
|
+
// The Client starts its first RPC as soon as step 2 arrives; sending the
|
|
364
|
+
// reply earlier lets WebRTC deliver that RPC while tunnel.channel is still
|
|
365
|
+
// undefined, which silently drops the request.
|
|
366
|
+
this.sendControl('secure.handshake', {
|
|
367
|
+
connectionId: tunnel.connectionId,
|
|
368
|
+
targetDeviceId: tunnel.peer.deviceId,
|
|
369
|
+
step: 2,
|
|
370
|
+
data: toBase64Url(reply),
|
|
371
|
+
});
|
|
372
|
+
this.logger.info('authenticated peer channel ready', {
|
|
373
|
+
connectionId: shortId(tunnel.connectionId),
|
|
374
|
+
peerDeviceId: shortId(tunnel.peer.deviceId),
|
|
375
|
+
transport: mode,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
async handleRelay(payload) {
|
|
379
|
+
if (payload.targetDeviceId !== this.identity.deviceId) {
|
|
380
|
+
throw new ControlConnectionError('INVALID_MESSAGE', 'Relay frame target does not match this Host.');
|
|
381
|
+
}
|
|
382
|
+
const tunnel = this.tunnels.get(payload.connectionId);
|
|
383
|
+
if (tunnel?.channel === undefined) {
|
|
384
|
+
this.logger.warn('stale relay frame ignored', {
|
|
385
|
+
connectionId: shortId(payload.connectionId),
|
|
386
|
+
});
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
tunnel.channel.receive(payload.counter, fromBase64Url(payload.ciphertext));
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
await tunnel.channel.close();
|
|
394
|
+
throw new ControlConnectionError('SECURE_CHANNEL_FAILED', asError(error).message);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
async handleSignalOffer(payload) {
|
|
398
|
+
const tunnel = this.tunnels.get(payload.connectionId);
|
|
399
|
+
if (tunnel === undefined || payload.targetDeviceId !== this.identity.deviceId) {
|
|
400
|
+
this.logger.warn('stale webrtc offer ignored', { connectionId: shortId(payload.connectionId) });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
if (tunnel.channel !== undefined || tunnel.rtc !== undefined) {
|
|
404
|
+
// Late or duplicate offer after relay establishment / RTC start: ignore.
|
|
405
|
+
this.logger.warn('duplicate webrtc offer ignored', { connectionId: shortId(tunnel.connectionId) });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (this.config.forceRelay) {
|
|
409
|
+
// Device-level forced degradation (webrtc plan §11).
|
|
410
|
+
this.logger.warn('webrtc offer ignored: forceRelay is enabled', { connectionId: shortId(tunnel.connectionId) });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (this.rtcFactory === undefined && this.rtcFactoryProvider !== undefined) {
|
|
414
|
+
this.rtcFactory = await this.rtcFactoryProvider().catch(() => undefined);
|
|
415
|
+
}
|
|
416
|
+
if (this.rtcFactory === undefined) {
|
|
417
|
+
// No Node RTC backend on this Host (webrtc plan §6.2): drop the offer so
|
|
418
|
+
// the initiator times out and falls back to the Relay data plane.
|
|
419
|
+
this.logger.warn('webrtc offer ignored: no RTC backend available', { connectionId: shortId(tunnel.connectionId) });
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
let iceServers = [];
|
|
423
|
+
try {
|
|
424
|
+
iceServers = await this.api.turnCredentials(tunnel.connectionId);
|
|
425
|
+
}
|
|
426
|
+
catch (error) {
|
|
427
|
+
this.logger.warn('TURN credentials unavailable; trying direct candidates', {
|
|
428
|
+
connectionId: shortId(tunnel.connectionId),
|
|
429
|
+
code: errorCode(error),
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
const rtc = new RtcDataChannelTransport({
|
|
433
|
+
role: 'responder',
|
|
434
|
+
factory: this.rtcFactory,
|
|
435
|
+
iceServers,
|
|
436
|
+
onSignal: signal => this.sendRtcSignal(tunnel, signal),
|
|
437
|
+
negotiateTimeoutMs: DEFAULT_WEBRTC_NEGOTIATE_TIMEOUT_MS,
|
|
438
|
+
label: `host<-${tunnel.peer.deviceId}`,
|
|
439
|
+
});
|
|
440
|
+
tunnel.rtc = rtc;
|
|
441
|
+
rtc.onMessage(data => tunnel.channel?.receive(undefined, data));
|
|
442
|
+
rtc.onClose(() => {
|
|
443
|
+
void this.handleRtcFailed(tunnel, rtc, new Error('WebRTC data channel closed.'));
|
|
444
|
+
});
|
|
445
|
+
void rtc.connect().then(() => {
|
|
446
|
+
this.handleRtcOpened(tunnel, rtc.selectedTransport() ?? 'p2p');
|
|
447
|
+
}).catch(error => {
|
|
448
|
+
void this.handleRtcFailed(tunnel, rtc, asError(error));
|
|
449
|
+
});
|
|
450
|
+
rtc.handleSignal({ type: 'offer', sdp: payload.sdp });
|
|
451
|
+
}
|
|
452
|
+
handleSignalIce(payload) {
|
|
453
|
+
const tunnel = this.tunnels.get(payload.connectionId);
|
|
454
|
+
if (tunnel === undefined || payload.targetDeviceId !== this.identity.deviceId)
|
|
455
|
+
return;
|
|
456
|
+
tunnel.rtc?.handleSignal({ type: 'ice', candidate: payload.candidate });
|
|
457
|
+
}
|
|
458
|
+
sendRtcSignal(tunnel, signal) {
|
|
459
|
+
if (signal.type === 'answer') {
|
|
460
|
+
this.sendControl('signal.answer', {
|
|
461
|
+
connectionId: tunnel.connectionId,
|
|
462
|
+
targetDeviceId: tunnel.peer.deviceId,
|
|
463
|
+
sdp: signal.sdp,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
else if (signal.type === 'ice') {
|
|
467
|
+
this.sendControl('signal.ice', {
|
|
468
|
+
connectionId: tunnel.connectionId,
|
|
469
|
+
targetDeviceId: tunnel.peer.deviceId,
|
|
470
|
+
candidate: signal.candidate,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
handleRtcOpened(tunnel, selected) {
|
|
475
|
+
if (this.tunnels.get(tunnel.connectionId) !== tunnel || tunnel.rtc === undefined)
|
|
476
|
+
return;
|
|
477
|
+
tunnel.transport = selected;
|
|
478
|
+
this.sendTransportSelected(tunnel, selected);
|
|
479
|
+
this.logger.info('webrtc data channel ready', {
|
|
480
|
+
connectionId: shortId(tunnel.connectionId),
|
|
481
|
+
peerDeviceId: shortId(tunnel.peer.deviceId),
|
|
482
|
+
transport: selected,
|
|
483
|
+
});
|
|
484
|
+
}
|
|
485
|
+
async handleRtcFailed(tunnel, rtc, error) {
|
|
486
|
+
if (this.tunnels.get(tunnel.connectionId) !== tunnel || tunnel.rtc !== rtc)
|
|
487
|
+
return;
|
|
488
|
+
if (tunnel.channel !== undefined || tunnel.transport === 'p2p' || tunnel.transport === 'turn') {
|
|
489
|
+
this.logger.warn('webrtc data channel failed; disconnecting peer', {
|
|
490
|
+
connectionId: shortId(tunnel.connectionId),
|
|
491
|
+
reason: diagnosticReason(error),
|
|
492
|
+
});
|
|
493
|
+
await this.dropTunnel(tunnel.connectionId, 'CONNECTION_FAILED');
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
tunnel.rtc = undefined;
|
|
497
|
+
tunnel.transport = 'relay';
|
|
498
|
+
await rtc.close();
|
|
499
|
+
this.logger.warn('webrtc negotiation failed; falling back to relay', {
|
|
500
|
+
connectionId: shortId(tunnel.connectionId),
|
|
501
|
+
reason: diagnosticReason(error),
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
sendTransportSelected(tunnel, transport) {
|
|
505
|
+
this.sendControl('transport.selected', {
|
|
506
|
+
connectionId: tunnel.connectionId,
|
|
507
|
+
targetDeviceId: tunnel.peer.deviceId,
|
|
508
|
+
transport,
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
async sendRelay(tunnel, ciphertext) {
|
|
512
|
+
const counter = Number(tunnel.noise.sendingCounter() - 1n);
|
|
513
|
+
if (!Number.isSafeInteger(counter) || counter < 0)
|
|
514
|
+
throw new ControlConnectionError('FRAME_TOO_LARGE', 'Noise transport counter overflowed.');
|
|
515
|
+
this.sendControl('relay', {
|
|
516
|
+
connectionId: tunnel.connectionId,
|
|
517
|
+
targetDeviceId: tunnel.peer.deviceId,
|
|
518
|
+
counter,
|
|
519
|
+
ciphertext: toBase64Url(ciphertext),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
sendControl(type, payload) {
|
|
523
|
+
const socket = this.socket;
|
|
524
|
+
if (socket === undefined || socket.readyState !== 1)
|
|
525
|
+
throw new ControlConnectionError('CONNECTION_FAILED', 'Server control socket is not open.');
|
|
526
|
+
socket.send(JSON.stringify(createControlFrame(type, payload)));
|
|
527
|
+
}
|
|
528
|
+
async dropTunnels() {
|
|
529
|
+
const tunnels = [...this.tunnels.values()];
|
|
530
|
+
this.tunnels.clear();
|
|
531
|
+
await Promise.all(tunnels.map(async (tunnel) => {
|
|
532
|
+
if (tunnel.rtc !== undefined)
|
|
533
|
+
await tunnel.rtc.close();
|
|
534
|
+
if (tunnel.channel !== undefined)
|
|
535
|
+
await tunnel.channel.close();
|
|
536
|
+
else
|
|
537
|
+
tunnel.noise.destroy();
|
|
538
|
+
}));
|
|
539
|
+
await this.connections.close();
|
|
540
|
+
}
|
|
541
|
+
async dropTunnel(connectionId, code) {
|
|
542
|
+
const tunnel = this.tunnels.get(connectionId);
|
|
543
|
+
if (tunnel === undefined)
|
|
544
|
+
return;
|
|
545
|
+
this.tunnels.delete(connectionId);
|
|
546
|
+
try {
|
|
547
|
+
await tunnel.rtc?.close();
|
|
548
|
+
}
|
|
549
|
+
catch (error) {
|
|
550
|
+
this.logger.warn('remote connection RTC cleanup failed', {
|
|
551
|
+
connectionId: shortId(connectionId),
|
|
552
|
+
reason: diagnosticReason(error),
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
if (tunnel.channel !== undefined) {
|
|
556
|
+
try {
|
|
557
|
+
const closed = await this.connections.closeConnection(connectionId, code);
|
|
558
|
+
if (!closed)
|
|
559
|
+
await tunnel.channel.close(code);
|
|
560
|
+
}
|
|
561
|
+
catch (error) {
|
|
562
|
+
await tunnel.channel.close(code).catch(() => undefined);
|
|
563
|
+
this.logger.warn('remote connection channel cleanup failed', {
|
|
564
|
+
connectionId: shortId(connectionId),
|
|
565
|
+
reason: diagnosticReason(error),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
else {
|
|
570
|
+
tunnel.noise.destroy();
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
waitBeforeRetry(baseDelay) {
|
|
574
|
+
const spread = baseDelay * this.config.reconnect.jitter;
|
|
575
|
+
const delay = Math.max(0, Math.round(baseDelay - spread + Math.random() * spread * 2));
|
|
576
|
+
return new Promise(resolve => {
|
|
577
|
+
const timer = setTimeout(() => { this.retryWake = undefined; resolve(); }, delay);
|
|
578
|
+
this.retryWake = () => { clearTimeout(timer); resolve(); };
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
const TERMINAL_AUTH_ERRORS = new Set([
|
|
583
|
+
'ACCOUNT_AUTH_REQUIRED',
|
|
584
|
+
'AUTH_INVALID',
|
|
585
|
+
'DEVICE_OWNERSHIP_REQUIRED',
|
|
586
|
+
'DEVICE_REVOKED',
|
|
587
|
+
'TOKEN_EXPIRED',
|
|
588
|
+
]);
|
|
589
|
+
class ServerNoiseChannel {
|
|
590
|
+
tunnel;
|
|
591
|
+
transmit;
|
|
592
|
+
onClose;
|
|
593
|
+
security;
|
|
594
|
+
peerDeviceId;
|
|
595
|
+
peerIdentityKey;
|
|
596
|
+
mode;
|
|
597
|
+
handlers = new Set();
|
|
598
|
+
incoming = new SecureMessageCodec();
|
|
599
|
+
outgoing = new SecureMessageCodec();
|
|
600
|
+
closed = false;
|
|
601
|
+
constructor(tunnel, transmit, onClose, mode) {
|
|
602
|
+
this.tunnel = tunnel;
|
|
603
|
+
this.transmit = transmit;
|
|
604
|
+
this.onClose = onClose;
|
|
605
|
+
this.mode = mode;
|
|
606
|
+
this.security = {
|
|
607
|
+
protocol: 'Noise_IK_25519_ChaChaPoly_SHA256',
|
|
608
|
+
connectionId: tunnel.connectionId,
|
|
609
|
+
membershipId: tunnel.membershipId,
|
|
610
|
+
};
|
|
611
|
+
this.peerDeviceId = tunnel.peer.deviceId;
|
|
612
|
+
this.peerIdentityKey = tunnel.peer.publicKey;
|
|
613
|
+
}
|
|
614
|
+
async send(message) {
|
|
615
|
+
if (this.closed)
|
|
616
|
+
throw new Error('secure channel is closed');
|
|
617
|
+
for (const plaintext of this.outgoing.encode(encodeMessage(message))) {
|
|
618
|
+
await this.transmit(this.tunnel.noise.encrypt(plaintext));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
onMessage(handler) {
|
|
622
|
+
this.handlers.add(handler);
|
|
623
|
+
return () => this.handlers.delete(handler);
|
|
624
|
+
}
|
|
625
|
+
receive(counter, ciphertext) {
|
|
626
|
+
if (this.closed)
|
|
627
|
+
return;
|
|
628
|
+
if (counter !== undefined) {
|
|
629
|
+
const expected = Number(this.tunnel.noise.receivingCounter());
|
|
630
|
+
if (!Number.isSafeInteger(counter) || counter !== expected) {
|
|
631
|
+
throw new ControlConnectionError('INVALID_MESSAGE', 'Relay counter is duplicated or out of order.');
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
const plaintext = this.incoming.decode(this.tunnel.noise.decrypt(ciphertext));
|
|
635
|
+
if (plaintext === undefined)
|
|
636
|
+
return;
|
|
637
|
+
const message = decodeMessage(plaintext);
|
|
638
|
+
for (const handler of this.handlers)
|
|
639
|
+
handler(message);
|
|
640
|
+
}
|
|
641
|
+
async close(_code) {
|
|
642
|
+
if (this.closed)
|
|
643
|
+
return;
|
|
644
|
+
this.closed = true;
|
|
645
|
+
this.handlers.clear();
|
|
646
|
+
this.incoming.reset();
|
|
647
|
+
this.outgoing.reset();
|
|
648
|
+
this.tunnel.noise.destroy();
|
|
649
|
+
this.onClose();
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
class ControlConnectionError extends Error {
|
|
653
|
+
code;
|
|
654
|
+
constructor(code, message) {
|
|
655
|
+
super(message);
|
|
656
|
+
this.code = code;
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
function websocketUrl(baseUrl) {
|
|
660
|
+
const url = new URL(baseUrl);
|
|
661
|
+
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
662
|
+
url.pathname = `${url.pathname.replace(/\/$/, '')}/ws/v1/connect`;
|
|
663
|
+
return url.toString();
|
|
664
|
+
}
|
|
665
|
+
function decodeControl(data) {
|
|
666
|
+
if (typeof data !== 'string')
|
|
667
|
+
throw new ControlConnectionError('INVALID_MESSAGE', 'Server control frames must be text JSON.');
|
|
668
|
+
try {
|
|
669
|
+
return parseControlFrame(JSON.parse(data));
|
|
670
|
+
}
|
|
671
|
+
catch {
|
|
672
|
+
throw new ControlConnectionError('INVALID_MESSAGE', 'Server sent an invalid control frame.');
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
function requireHelloAck(value) {
|
|
676
|
+
const payload = requireObject(value);
|
|
677
|
+
if (payload.protocol !== PROTOCOL_VERSION || typeof payload.serverVersion !== 'string'
|
|
678
|
+
|| typeof payload.connectionSessionId !== 'string' || !Number.isSafeInteger(payload.heartbeatIntervalMs)
|
|
679
|
+
|| !Number.isSafeInteger(payload.maxControlFrameBytes) || !Number.isSafeInteger(payload.maxRelayFrameBytes)) {
|
|
680
|
+
throw new ControlConnectionError('INVALID_MESSAGE', 'hello.ack payload is invalid.');
|
|
681
|
+
}
|
|
682
|
+
return payload;
|
|
683
|
+
}
|
|
684
|
+
function requireConnectIncoming(value) {
|
|
685
|
+
const payload = requireObject(value);
|
|
686
|
+
if (typeof payload.connectionId !== 'string' || typeof payload.clientDeviceId !== 'string'
|
|
687
|
+
|| typeof payload.clientIdentityKey !== 'string' || payload.authorization !== 'account'
|
|
688
|
+
|| !Array.isArray(payload.preferredTransports)) {
|
|
689
|
+
throw new ControlConnectionError('INVALID_MESSAGE', 'connect.incoming payload is invalid.');
|
|
690
|
+
}
|
|
691
|
+
return payload;
|
|
692
|
+
}
|
|
693
|
+
function requireHandshake(value) {
|
|
694
|
+
const payload = requireObject(value);
|
|
695
|
+
if (typeof payload.connectionId !== 'string' || typeof payload.targetDeviceId !== 'string'
|
|
696
|
+
|| !Number.isSafeInteger(payload.step) || typeof payload.data !== 'string') {
|
|
697
|
+
throw new ControlConnectionError('INVALID_MESSAGE', 'secure.handshake payload is invalid.');
|
|
698
|
+
}
|
|
699
|
+
return payload;
|
|
700
|
+
}
|
|
701
|
+
function requireRelay(value) {
|
|
702
|
+
const payload = requireObject(value);
|
|
703
|
+
if (typeof payload.connectionId !== 'string' || typeof payload.targetDeviceId !== 'string'
|
|
704
|
+
|| !Number.isSafeInteger(payload.counter) || typeof payload.ciphertext !== 'string') {
|
|
705
|
+
throw new ControlConnectionError('INVALID_MESSAGE', 'relay payload is invalid.');
|
|
706
|
+
}
|
|
707
|
+
return payload;
|
|
708
|
+
}
|
|
709
|
+
function requireSignal(value) {
|
|
710
|
+
const payload = requireObject(value);
|
|
711
|
+
if (typeof payload.connectionId !== 'string' || typeof payload.targetDeviceId !== 'string'
|
|
712
|
+
|| typeof payload.sdp !== 'string' || payload.sdp.length === 0) {
|
|
713
|
+
throw new ControlConnectionError('INVALID_MESSAGE', 'signal offer/answer payload is invalid.');
|
|
714
|
+
}
|
|
715
|
+
return payload;
|
|
716
|
+
}
|
|
717
|
+
function requireSignalIce(value) {
|
|
718
|
+
const payload = requireObject(value);
|
|
719
|
+
if (typeof payload.connectionId !== 'string' || typeof payload.targetDeviceId !== 'string'
|
|
720
|
+
|| typeof payload.candidate !== 'object' || payload.candidate === null || Array.isArray(payload.candidate)) {
|
|
721
|
+
throw new ControlConnectionError('INVALID_MESSAGE', 'signal.ice payload is invalid.');
|
|
722
|
+
}
|
|
723
|
+
return payload;
|
|
724
|
+
}
|
|
725
|
+
function requireControlError(value) {
|
|
726
|
+
const payload = requireObject(value);
|
|
727
|
+
if (typeof payload.code !== 'string' || typeof payload.message !== 'string'
|
|
728
|
+
|| (payload.connectionId !== undefined && (typeof payload.connectionId !== 'string' || payload.connectionId === ''))) {
|
|
729
|
+
throw new ControlConnectionError('INVALID_MESSAGE', 'error payload is invalid.');
|
|
730
|
+
}
|
|
731
|
+
return payload;
|
|
732
|
+
}
|
|
733
|
+
function requireObject(value) {
|
|
734
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
735
|
+
throw new ControlConnectionError('INVALID_MESSAGE', 'Control payload must be an object.');
|
|
736
|
+
}
|
|
737
|
+
return value;
|
|
738
|
+
}
|
|
739
|
+
function objectValue(value, key) { return requireObject(value)[key]; }
|
|
740
|
+
function shortId(value) { return value.length <= 12 ? value : `${value.slice(0, 8)}…${value.slice(-4)}`; }
|
|
741
|
+
function asError(error) { return error instanceof Error ? error : new Error('Unknown Server connection error.'); }
|
|
742
|
+
function diagnosticReason(error) {
|
|
743
|
+
const message = asError(error).message.replace(/[\r\n]+/g, ' ').slice(0, 160);
|
|
744
|
+
return message || 'Unknown Server connection error.';
|
|
745
|
+
}
|
|
746
|
+
function errorCode(error) { return error instanceof ServerApiError || error instanceof ControlConnectionError ? error.code : 'CONNECTION_FAILED'; }
|
|
747
|
+
function isRetryable(error) { return error instanceof ServerApiError ? error.retryable : errorCode(error) !== 'DEVICE_REVOKED'; }
|
|
748
|
+
function closeCode(code) {
|
|
749
|
+
if (code === 4002)
|
|
750
|
+
return 'AUTH_INVALID';
|
|
751
|
+
if (code === 4004)
|
|
752
|
+
return 'DEVICE_REVOKED';
|
|
753
|
+
if (code === 4007)
|
|
754
|
+
return 'RATE_LIMITED';
|
|
755
|
+
if (code === 4011)
|
|
756
|
+
return 'UNSUPPORTED_VERSION';
|
|
757
|
+
return 'CONNECTION_FAILED';
|
|
758
|
+
}
|
|
759
|
+
//# sourceMappingURL=server-connection.js.map
|