@blue-soda/dsh-remote 0.5.5 → 0.5.7
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/README.md +2 -2
- package/cordis.patch.yml +30 -30
- package/dist/client.github.js +1 -1
- package/dist/client.js +1 -1
- package/dist/index.js +371 -80
- package/dist/index.js.map +4 -4
- package/dsh-plugin.json +41 -41
- package/package.json +165 -165
- package/dist/acp.d.ts +0 -75
- package/dist/acp.d.ts.map +0 -1
- package/dist/acp.js +0 -61
- package/dist/acp.js.map +0 -1
- package/dist/api-proxy-switch.d.ts +0 -33
- package/dist/api-proxy-switch.d.ts.map +0 -1
- package/dist/api-proxy-switch.js +0 -109
- package/dist/api-proxy-switch.js.map +0 -1
- package/dist/atomic-file.d.ts +0 -16
- package/dist/atomic-file.d.ts.map +0 -1
- package/dist/atomic-file.js +0 -81
- package/dist/atomic-file.js.map +0 -1
- package/dist/bin/doctor.d.ts +0 -3
- package/dist/bin/doctor.d.ts.map +0 -1
- package/dist/bin/doctor.js +0 -13
- package/dist/bin/doctor.js.map +0 -1
- package/dist/cli.d.ts +0 -32
- package/dist/cli.d.ts.map +0 -1
- package/dist/cli.js +0 -289
- package/dist/cli.js.map +0 -1
- package/dist/client-runtime.d.ts +0 -375
- package/dist/client-runtime.d.ts.map +0 -1
- package/dist/client-runtime.js +0 -1768
- package/dist/client-runtime.js.map +0 -1
- package/dist/client-secure-transport.d.ts +0 -23
- package/dist/client-secure-transport.d.ts.map +0 -1
- package/dist/client-secure-transport.js +0 -144
- package/dist/client-secure-transport.js.map +0 -1
- package/dist/client-target-store.d.ts +0 -40
- package/dist/client-target-store.d.ts.map +0 -1
- package/dist/client-target-store.js +0 -79
- package/dist/client-target-store.js.map +0 -1
- package/dist/client.d.ts +0 -13
- package/dist/client.d.ts.map +0 -1
- package/dist/codex/app-server.d.ts +0 -71
- package/dist/codex/app-server.d.ts.map +0 -1
- package/dist/codex/app-server.js +0 -268
- package/dist/codex/app-server.js.map +0 -1
- package/dist/codex/codex-image-codec.d.ts +0 -7
- package/dist/codex/codex-image-codec.d.ts.map +0 -1
- package/dist/codex/codex-image-codec.js +0 -288
- package/dist/codex/codex-image-codec.js.map +0 -1
- package/dist/codex/domain.d.ts +0 -102
- package/dist/codex/domain.d.ts.map +0 -1
- package/dist/codex/domain.js +0 -1323
- package/dist/codex/domain.js.map +0 -1
- package/dist/codex/method-policy.d.ts +0 -338
- package/dist/codex/method-policy.d.ts.map +0 -1
- package/dist/codex/method-policy.js +0 -141
- package/dist/codex/method-policy.js.map +0 -1
- package/dist/codex/peer-bridge.d.ts +0 -57
- package/dist/codex/peer-bridge.d.ts.map +0 -1
- package/dist/codex/peer-bridge.js +0 -299
- package/dist/codex/peer-bridge.js.map +0 -1
- package/dist/codex/permissions.d.ts +0 -4
- package/dist/codex/permissions.d.ts.map +0 -1
- package/dist/codex/permissions.js +0 -21
- package/dist/codex/permissions.js.map +0 -1
- package/dist/codex/session-id.d.ts +0 -10
- package/dist/codex/session-id.d.ts.map +0 -1
- package/dist/codex/session-id.js +0 -15
- package/dist/codex/session-id.js.map +0 -1
- package/dist/codex/virtual-harness.d.ts +0 -209
- package/dist/codex/virtual-harness.d.ts.map +0 -1
- package/dist/codex/virtual-harness.js +0 -2650
- package/dist/codex/virtual-harness.js.map +0 -1
- package/dist/codex-workspace-bridge.d.ts +0 -108
- package/dist/codex-workspace-bridge.d.ts.map +0 -1
- package/dist/codex-workspace-bridge.js +0 -502
- package/dist/codex-workspace-bridge.js.map +0 -1
- package/dist/config.d.ts +0 -99
- package/dist/config.d.ts.map +0 -1
- package/dist/config.js +0 -127
- package/dist/config.js.map +0 -1
- package/dist/connection-controller.d.ts +0 -44
- package/dist/connection-controller.d.ts.map +0 -1
- package/dist/connection-controller.js +0 -185
- package/dist/connection-controller.js.map +0 -1
- package/dist/control-route.d.ts +0 -34
- package/dist/control-route.d.ts.map +0 -1
- package/dist/control-route.js +0 -136
- package/dist/control-route.js.map +0 -1
- package/dist/control-runtime.d.ts +0 -138
- package/dist/control-runtime.d.ts.map +0 -1
- package/dist/control-runtime.js +0 -543
- package/dist/control-runtime.js.map +0 -1
- package/dist/control-stream.d.ts +0 -67
- package/dist/control-stream.d.ts.map +0 -1
- package/dist/control-stream.js +0 -179
- package/dist/control-stream.js.map +0 -1
- package/dist/defaults.d.ts +0 -9
- package/dist/defaults.d.ts.map +0 -1
- package/dist/defaults.js +0 -9
- package/dist/defaults.js.map +0 -1
- package/dist/file-viewer-bridge.d.ts +0 -30
- package/dist/file-viewer-bridge.d.ts.map +0 -1
- package/dist/file-viewer-bridge.js +0 -128
- package/dist/file-viewer-bridge.js.map +0 -1
- package/dist/file-viewer-contract.d.ts +0 -5
- package/dist/file-viewer-contract.d.ts.map +0 -1
- package/dist/file-viewer-contract.js +0 -4
- package/dist/file-viewer-contract.js.map +0 -1
- package/dist/harness-api-bridge.d.ts +0 -81
- package/dist/harness-api-bridge.d.ts.map +0 -1
- package/dist/harness-api-bridge.js +0 -862
- package/dist/harness-api-bridge.js.map +0 -1
- package/dist/harness-api-history.d.ts +0 -3
- package/dist/harness-api-history.d.ts.map +0 -1
- package/dist/harness-api-history.js +0 -59
- package/dist/harness-api-history.js.map +0 -1
- package/dist/harness-remote-bridge.d.ts +0 -60
- package/dist/harness-remote-bridge.d.ts.map +0 -1
- package/dist/harness-remote-bridge.js +0 -540
- package/dist/harness-remote-bridge.js.map +0 -1
- package/dist/harness-version.d.ts +0 -23
- package/dist/harness-version.d.ts.map +0 -1
- package/dist/harness-version.js +0 -95
- package/dist/harness-version.js.map +0 -1
- package/dist/identity-store.d.ts +0 -45
- package/dist/identity-store.d.ts.map +0 -1
- package/dist/identity-store.js +0 -186
- package/dist/identity-store.js.map +0 -1
- package/dist/ids.d.ts +0 -2
- package/dist/ids.d.ts.map +0 -1
- package/dist/ids.js +0 -17
- package/dist/ids.js.map +0 -1
- package/dist/index.d.ts +0 -78
- package/dist/index.d.ts.map +0 -1
- package/dist/logging.d.ts +0 -18
- package/dist/logging.d.ts.map +0 -1
- package/dist/logging.js +0 -30
- package/dist/logging.js.map +0 -1
- package/dist/loopback-host.d.ts +0 -20
- package/dist/loopback-host.d.ts.map +0 -1
- package/dist/loopback-host.js +0 -245
- package/dist/loopback-host.js.map +0 -1
- package/dist/loopback-preview.d.ts +0 -19
- package/dist/loopback-preview.d.ts.map +0 -1
- package/dist/loopback-preview.js +0 -232
- package/dist/loopback-preview.js.map +0 -1
- package/dist/native-rtc-helper.d.ts +0 -14
- package/dist/native-rtc-helper.d.ts.map +0 -1
- package/dist/native-rtc-helper.js +0 -575
- package/dist/native-rtc-helper.js.map +0 -1
- package/dist/remote-api-proxy.d.ts +0 -17
- package/dist/remote-api-proxy.d.ts.map +0 -1
- package/dist/remote-api-proxy.js +0 -451
- package/dist/remote-api-proxy.js.map +0 -1
- package/dist/remote-directory-browser.d.ts +0 -16
- package/dist/remote-directory-browser.d.ts.map +0 -1
- package/dist/remote-directory-browser.js +0 -48
- package/dist/remote-directory-browser.js.map +0 -1
- package/dist/remote-file-content-provider.d.ts +0 -48
- package/dist/remote-file-content-provider.d.ts.map +0 -1
- package/dist/remote-file-content-provider.js +0 -93
- package/dist/remote-file-content-provider.js.map +0 -1
- package/dist/remote-typert-gateway.d.ts +0 -20
- package/dist/remote-typert-gateway.d.ts.map +0 -1
- package/dist/remote-typert-gateway.js +0 -334
- package/dist/remote-typert-gateway.js.map +0 -1
- package/dist/rpc-binary-attachments.d.ts +0 -33
- package/dist/rpc-binary-attachments.d.ts.map +0 -1
- package/dist/rpc-binary-attachments.js +0 -142
- package/dist/rpc-binary-attachments.js.map +0 -1
- package/dist/rpc-router.d.ts +0 -31
- package/dist/rpc-router.d.ts.map +0 -1
- package/dist/rpc-router.js +0 -201
- package/dist/rpc-router.js.map +0 -1
- package/dist/safe-error.d.ts +0 -8
- package/dist/safe-error.d.ts.map +0 -1
- package/dist/safe-error.js +0 -20
- package/dist/safe-error.js.map +0 -1
- package/dist/server-api.d.ts +0 -109
- package/dist/server-api.d.ts.map +0 -1
- package/dist/server-api.js +0 -520
- package/dist/server-api.js.map +0 -1
- package/dist/server-connection.d.ts +0 -79
- package/dist/server-connection.d.ts.map +0 -1
- package/dist/server-connection.js +0 -1023
- package/dist/server-connection.js.map +0 -1
- package/dist/server-credentials.d.ts +0 -55
- package/dist/server-credentials.d.ts.map +0 -1
- package/dist/server-credentials.js +0 -108
- package/dist/server-credentials.js.map +0 -1
- package/dist/service.d.ts +0 -151
- package/dist/service.d.ts.map +0 -1
- package/dist/service.js +0 -448
- package/dist/service.js.map +0 -1
- package/dist/session-format-compat.d.ts +0 -3
- package/dist/session-format-compat.d.ts.map +0 -1
- package/dist/session-format-compat.js +0 -183
- package/dist/session-format-compat.js.map +0 -1
- package/dist/status-stream.d.ts +0 -48
- package/dist/status-stream.d.ts.map +0 -1
- package/dist/status-stream.js +0 -133
- package/dist/status-stream.js.map +0 -1
- package/dist/terminal-policy.d.ts +0 -21
- package/dist/terminal-policy.d.ts.map +0 -1
- package/dist/terminal-policy.js +0 -68
- package/dist/terminal-policy.js.map +0 -1
- package/dist/tui-command.d.ts +0 -13
- package/dist/tui-command.d.ts.map +0 -1
- package/dist/tui-command.js +0 -392
- package/dist/tui-command.js.map +0 -1
- package/dist/typert-gateway-contract.d.ts +0 -59
- package/dist/typert-gateway-contract.d.ts.map +0 -1
- package/dist/typert-gateway-contract.js +0 -2
- package/dist/typert-gateway-contract.js.map +0 -1
- package/dist/typert-gateway-switch.d.ts +0 -85
- package/dist/typert-gateway-switch.d.ts.map +0 -1
- package/dist/typert-gateway-switch.js +0 -448
- package/dist/typert-gateway-switch.js.map +0 -1
- package/dist/types.d.ts +0 -20
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/version.d.ts +0 -12
- package/dist/version.d.ts.map +0 -1
- package/dist/version.js +0 -12
- package/dist/version.js.map +0 -1
- package/dist/werift-rtc.d.ts +0 -121
- package/dist/werift-rtc.d.ts.map +0 -1
- package/dist/werift-rtc.js +0 -490
- package/dist/werift-rtc.js.map +0 -1
|
@@ -1,1023 +0,0 @@
|
|
|
1
|
-
import { waitForRelayCapacity, SerialSend } from '@dsh-remote/webrtc';
|
|
2
|
-
import { NoiseIkSession, createNoisePrologue, fromBase64Url, toBase64Url, } from '@dsh-remote/crypto';
|
|
3
|
-
import { PROTOCOL_VERSION, SecureMessageCodec, acceptNegotiatedCapabilities, createControlFrame, decodeControlFrame, decodeMessage, encodeControlFrame, encodeMessage, } from '@dsh-remote/protocol';
|
|
4
|
-
import { RtcDataChannelTransport, stunOnlyIceServers, } from '@dsh-remote/webrtc';
|
|
5
|
-
import { ServerApiError } from './server-api.js';
|
|
6
|
-
import { PLUGIN_VERSION } from './version.js';
|
|
7
|
-
const DEFAULT_WEBRTC_NEGOTIATE_TIMEOUT_MS = 12_000;
|
|
8
|
-
export class HostServerConnection {
|
|
9
|
-
config;
|
|
10
|
-
identity;
|
|
11
|
-
identities;
|
|
12
|
-
api;
|
|
13
|
-
connections;
|
|
14
|
-
logger;
|
|
15
|
-
createWebSocket;
|
|
16
|
-
rtcFactoryProvider;
|
|
17
|
-
hostCapabilities;
|
|
18
|
-
harnessVersion;
|
|
19
|
-
socket;
|
|
20
|
-
running;
|
|
21
|
-
stopped = true;
|
|
22
|
-
online = false;
|
|
23
|
-
retryWake;
|
|
24
|
-
tunnels = new Map();
|
|
25
|
-
terminalError;
|
|
26
|
-
lastActiveAt;
|
|
27
|
-
reconnectRequested = false;
|
|
28
|
-
resumeQueued = false;
|
|
29
|
-
authRecoveryAttempted = false;
|
|
30
|
-
rtcFactory;
|
|
31
|
-
negotiatedCapabilities = ['transport.relay'];
|
|
32
|
-
controlFrameLimits = {};
|
|
33
|
-
constructor(config, identity, identities, api, connections, logger, createWebSocket = url => new WebSocket(url), rtcFactoryProvider, hostCapabilities = () => ['harness.api.v1'], harnessVersion) {
|
|
34
|
-
this.config = config;
|
|
35
|
-
this.identity = identity;
|
|
36
|
-
this.identities = identities;
|
|
37
|
-
this.api = api;
|
|
38
|
-
this.connections = connections;
|
|
39
|
-
this.logger = logger;
|
|
40
|
-
this.createWebSocket = createWebSocket;
|
|
41
|
-
this.rtcFactoryProvider = rtcFactoryProvider;
|
|
42
|
-
this.hostCapabilities = hostCapabilities;
|
|
43
|
-
this.harnessVersion = harnessVersion;
|
|
44
|
-
}
|
|
45
|
-
start() {
|
|
46
|
-
if (this.running !== undefined)
|
|
47
|
-
return;
|
|
48
|
-
this.stopped = false;
|
|
49
|
-
this.running = this.run().finally(() => { this.running = undefined; });
|
|
50
|
-
}
|
|
51
|
-
resume() {
|
|
52
|
-
this.terminalError = undefined;
|
|
53
|
-
this.stopped = false;
|
|
54
|
-
if (this.running === undefined) {
|
|
55
|
-
this.start();
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
if (this.resumeQueued)
|
|
59
|
-
return;
|
|
60
|
-
this.resumeQueued = true;
|
|
61
|
-
void this.running.finally(() => {
|
|
62
|
-
this.resumeQueued = false;
|
|
63
|
-
if (!this.stopped)
|
|
64
|
-
this.start();
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
async stop() {
|
|
68
|
-
this.stopped = true;
|
|
69
|
-
this.reconnectRequested = false;
|
|
70
|
-
this.retryWake?.();
|
|
71
|
-
this.retryWake = undefined;
|
|
72
|
-
this.socket?.close(1000, 'plugin stopped');
|
|
73
|
-
await this.running;
|
|
74
|
-
await this.dropTunnels();
|
|
75
|
-
}
|
|
76
|
-
isOnline() { return this.online; }
|
|
77
|
-
lastError() { return this.terminalError; }
|
|
78
|
-
lastActivity() { return this.lastActiveAt; }
|
|
79
|
-
isReconnecting() { return !this.online && !this.stopped && this.running !== undefined; }
|
|
80
|
-
reconnect() {
|
|
81
|
-
this.terminalError = undefined;
|
|
82
|
-
this.stopped = false;
|
|
83
|
-
if (this.running === undefined) {
|
|
84
|
-
this.start();
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
this.reconnectRequested = true;
|
|
88
|
-
this.retryWake?.();
|
|
89
|
-
this.socket?.close(4000, 'manual reconnect');
|
|
90
|
-
void this.running.finally(() => {
|
|
91
|
-
if (!this.reconnectRequested || this.stopped)
|
|
92
|
-
return;
|
|
93
|
-
this.reconnectRequested = false;
|
|
94
|
-
this.start();
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
async run() {
|
|
98
|
-
let delayMs = this.config.reconnect.initialDelayMs;
|
|
99
|
-
this.authRecoveryAttempted = false;
|
|
100
|
-
while (!this.stopped) {
|
|
101
|
-
try {
|
|
102
|
-
await this.connectOnce();
|
|
103
|
-
delayMs = this.config.reconnect.initialDelayMs;
|
|
104
|
-
}
|
|
105
|
-
catch (error) {
|
|
106
|
-
const code = errorCode(error);
|
|
107
|
-
if (code === 'CREDENTIALS_REFRESHED')
|
|
108
|
-
continue;
|
|
109
|
-
this.terminalError = code;
|
|
110
|
-
this.logger.warn('server control connection failed', {
|
|
111
|
-
code, retryable: isRetryable(error),
|
|
112
|
-
...(error instanceof ServerApiError && error.phase !== undefined ? { phase: error.phase } : {}),
|
|
113
|
-
});
|
|
114
|
-
if (TERMINAL_AUTH_ERRORS.has(code)) {
|
|
115
|
-
this.logger.warn(code === 'CONNECTION_REPLACED'
|
|
116
|
-
? 'Another instance is using this Host identity. Stop it or use a separate DSH_HOME; automatic reconnect is paused.'
|
|
117
|
-
: code === 'SERVER_CREDENTIALS_BUSY'
|
|
118
|
-
? 'Credential refresh is locked. Stop other instances; after a crash, stop all instances before removing server-credentials.json.refresh-lock and authorizing again.'
|
|
119
|
-
: 'Host authorization failed. Run /remote login or authorize this Host again in Remote settings.');
|
|
120
|
-
}
|
|
121
|
-
if (code === 'DEVICE_REVOKED') {
|
|
122
|
-
try {
|
|
123
|
-
await this.api.clearAuthorization();
|
|
124
|
-
}
|
|
125
|
-
catch (clearError) {
|
|
126
|
-
this.logger.error('failed to clear revoked Host authorization', { code: errorCode(clearError) });
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
if (TERMINAL_AUTH_ERRORS.has(code) || !this.config.reconnect.enabled)
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
if (this.stopped)
|
|
133
|
-
return;
|
|
134
|
-
if (this.reconnectRequested) {
|
|
135
|
-
this.reconnectRequested = false;
|
|
136
|
-
delayMs = this.config.reconnect.initialDelayMs;
|
|
137
|
-
continue;
|
|
138
|
-
}
|
|
139
|
-
if (!this.config.reconnect.enabled)
|
|
140
|
-
return;
|
|
141
|
-
await this.waitBeforeRetry(delayMs);
|
|
142
|
-
if (this.reconnectRequested) {
|
|
143
|
-
this.reconnectRequested = false;
|
|
144
|
-
delayMs = this.config.reconnect.initialDelayMs;
|
|
145
|
-
continue;
|
|
146
|
-
}
|
|
147
|
-
delayMs = Math.min(this.config.reconnect.maxDelayMs, delayMs * 2);
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
async connectOnce() {
|
|
151
|
-
const credentials = await this.api.authenticate(this.identity);
|
|
152
|
-
if (this.stopped)
|
|
153
|
-
return;
|
|
154
|
-
const offeredCapabilities = this.rtcFactoryProvider === undefined || this.config.forceRelay
|
|
155
|
-
? ['transport.relay', ...this.hostCapabilities()]
|
|
156
|
-
: ['transport.lan', 'transport.p2p', 'transport.turn', 'transport.relay', ...this.hostCapabilities()];
|
|
157
|
-
const socket = this.createWebSocket(websocketUrl(this.api.baseUrl));
|
|
158
|
-
this.socket = socket;
|
|
159
|
-
this.controlFrameLimits = {};
|
|
160
|
-
let acknowledged = false;
|
|
161
|
-
let messageQueue = Promise.resolve();
|
|
162
|
-
await new Promise((resolve, reject) => {
|
|
163
|
-
let settled = false;
|
|
164
|
-
const helloTimer = setTimeout(() => socket.close(4001, 'hello timeout'), 10_000);
|
|
165
|
-
const finish = (error) => {
|
|
166
|
-
if (settled)
|
|
167
|
-
return;
|
|
168
|
-
settled = true;
|
|
169
|
-
clearTimeout(helloTimer);
|
|
170
|
-
this.online = false;
|
|
171
|
-
if (this.socket === socket)
|
|
172
|
-
this.socket = undefined;
|
|
173
|
-
void this.dropTunnels().finally(() => error === undefined ? resolve() : reject(error));
|
|
174
|
-
};
|
|
175
|
-
socket.onopen = () => {
|
|
176
|
-
this.sendControl('hello', {
|
|
177
|
-
role: 'host',
|
|
178
|
-
deviceId: this.identity.deviceId,
|
|
179
|
-
accessToken: credentials.accessToken,
|
|
180
|
-
protocols: [PROTOCOL_VERSION],
|
|
181
|
-
clientVersion: PLUGIN_VERSION,
|
|
182
|
-
...(this.harnessVersion === undefined ? {} : { harnessVersion: this.harnessVersion }),
|
|
183
|
-
capabilities: offeredCapabilities,
|
|
184
|
-
});
|
|
185
|
-
};
|
|
186
|
-
socket.onmessage = event => {
|
|
187
|
-
messageQueue = messageQueue.then(async () => {
|
|
188
|
-
const frame = decodeControl(event.data, this.controlFrameLimits);
|
|
189
|
-
this.lastActiveAt = Date.now();
|
|
190
|
-
if (frame.type === 'hello.ack') {
|
|
191
|
-
const payload = requireHelloAck(frame.payload);
|
|
192
|
-
this.authRecoveryAttempted = false;
|
|
193
|
-
this.controlFrameLimits = {
|
|
194
|
-
maxControlFrameBytes: payload.maxControlFrameBytes,
|
|
195
|
-
maxRelayFrameBytes: payload.maxRelayFrameBytes,
|
|
196
|
-
};
|
|
197
|
-
this.negotiatedCapabilities = acceptNegotiatedCapabilities(offeredCapabilities, payload.capabilities);
|
|
198
|
-
if (!this.negotiatedCapabilities.some(capability => capability === 'transport.relay'
|
|
199
|
-
|| capability === 'transport.lan'
|
|
200
|
-
|| capability === 'transport.p2p'
|
|
201
|
-
|| capability === 'transport.turn')) {
|
|
202
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'Server did not negotiate a transport capability.');
|
|
203
|
-
}
|
|
204
|
-
acknowledged = true;
|
|
205
|
-
clearTimeout(helloTimer);
|
|
206
|
-
this.online = true;
|
|
207
|
-
this.terminalError = undefined;
|
|
208
|
-
this.logger.info('server control connection online', {
|
|
209
|
-
serverVersion: payload.serverVersion,
|
|
210
|
-
connectionSessionId: shortId(payload.connectionSessionId),
|
|
211
|
-
});
|
|
212
|
-
return;
|
|
213
|
-
}
|
|
214
|
-
if (!acknowledged)
|
|
215
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'Server sent a frame before hello.ack.');
|
|
216
|
-
await this.handleFrame(frame);
|
|
217
|
-
}).catch(error => {
|
|
218
|
-
const code = errorCode(error);
|
|
219
|
-
this.terminalError = code;
|
|
220
|
-
this.logger.error('server control frame failed', {
|
|
221
|
-
code,
|
|
222
|
-
reason: diagnosticReason(error),
|
|
223
|
-
});
|
|
224
|
-
socket.close(4008, 'invalid control frame');
|
|
225
|
-
});
|
|
226
|
-
};
|
|
227
|
-
socket.onerror = () => {
|
|
228
|
-
if (!acknowledged)
|
|
229
|
-
finish(new ControlConnectionError('CONNECTION_FAILED', 'Unable to open the Server WebSocket.'));
|
|
230
|
-
};
|
|
231
|
-
socket.onclose = event => {
|
|
232
|
-
const close = async () => {
|
|
233
|
-
await messageQueue.catch(() => undefined);
|
|
234
|
-
if (this.stopped) {
|
|
235
|
-
finish();
|
|
236
|
-
return;
|
|
237
|
-
}
|
|
238
|
-
if (event.code === 4003) {
|
|
239
|
-
finish(new ControlConnectionError('CONNECTION_REPLACED', 'Another instance connected with this Host identity.'));
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
if (event.code === 4002) {
|
|
243
|
-
if (this.authRecoveryAttempted) {
|
|
244
|
-
finish(new ControlConnectionError('AUTH_INVALID', 'Server rejected refreshed credentials.'));
|
|
245
|
-
return;
|
|
246
|
-
}
|
|
247
|
-
try {
|
|
248
|
-
await this.api.refreshCredentials(credentials.accessToken);
|
|
249
|
-
}
|
|
250
|
-
catch (error) {
|
|
251
|
-
finish(asError(error));
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
// Only a successfully refreshed credential consumes the hello retry.
|
|
255
|
-
// Transient refresh errors must remain eligible for normal backoff.
|
|
256
|
-
this.authRecoveryAttempted = true;
|
|
257
|
-
finish(new ControlConnectionError('CREDENTIALS_REFRESHED', 'Retry hello with refreshed credentials.'));
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
if (event.code === 4004) {
|
|
261
|
-
finish(new ControlConnectionError('DEVICE_REVOKED', 'The Server revoked this Host device.'));
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
if (acknowledged)
|
|
265
|
-
this.terminalError = closeCode(event.code);
|
|
266
|
-
finish(acknowledged ? undefined : new ControlConnectionError(closeCode(event.code), event.reason || 'Server control connection closed.'));
|
|
267
|
-
};
|
|
268
|
-
void close();
|
|
269
|
-
};
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
async handleFrame(frame) {
|
|
273
|
-
if (frame.type === 'ping') {
|
|
274
|
-
const nonce = objectValue(frame.payload, 'nonce');
|
|
275
|
-
if (typeof nonce !== 'string')
|
|
276
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'Control ping has no nonce.');
|
|
277
|
-
this.sendControl('pong', { nonce });
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
if (frame.type === 'pong')
|
|
281
|
-
return;
|
|
282
|
-
if (frame.type === 'connect.incoming') {
|
|
283
|
-
await this.handleConnectIncoming(requireConnectIncoming(frame.payload));
|
|
284
|
-
return;
|
|
285
|
-
}
|
|
286
|
-
if (frame.type === 'secure.handshake') {
|
|
287
|
-
await this.handleHandshake(requireHandshake(frame.payload));
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
if (frame.type === 'relay') {
|
|
291
|
-
await this.handleRelay(requireRelay(frame.payload));
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
if (frame.type === 'signal.offer') {
|
|
295
|
-
await this.handleSignalOffer(requireSignal(frame.payload));
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
if (frame.type === 'signal.ice') {
|
|
299
|
-
this.handleSignalIce(requireSignalIce(frame.payload));
|
|
300
|
-
return;
|
|
301
|
-
}
|
|
302
|
-
if (frame.type === 'transport.selected') {
|
|
303
|
-
await this.handleTransportSelected(requireTransportSelected(frame.payload));
|
|
304
|
-
return;
|
|
305
|
-
}
|
|
306
|
-
if (frame.type === 'signal.answer')
|
|
307
|
-
return;
|
|
308
|
-
if (frame.type === 'error') {
|
|
309
|
-
const payload = requireControlError(frame.payload);
|
|
310
|
-
if (payload.code === 'DEVICE_REVOKED') {
|
|
311
|
-
this.terminalError = payload.code;
|
|
312
|
-
this.socket?.close(4004, 'device revoked');
|
|
313
|
-
}
|
|
314
|
-
else if (payload.connectionId !== undefined) {
|
|
315
|
-
await this.dropTunnel(payload.connectionId, payload.code);
|
|
316
|
-
const fields = {
|
|
317
|
-
code: payload.code,
|
|
318
|
-
connectionId: shortId(payload.connectionId),
|
|
319
|
-
retryable: payload.retryable,
|
|
320
|
-
};
|
|
321
|
-
if (payload.retryable)
|
|
322
|
-
this.logger.debug('server closed a remote connection', fields);
|
|
323
|
-
else
|
|
324
|
-
this.logger.warn('server closed a remote connection', fields);
|
|
325
|
-
}
|
|
326
|
-
else {
|
|
327
|
-
this.terminalError = payload.code;
|
|
328
|
-
this.logger.warn('server returned a control error', { code: payload.code, retryable: payload.retryable });
|
|
329
|
-
}
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
throw new ControlConnectionError('INVALID_MESSAGE', `Unexpected Server control frame: ${frame.type}`);
|
|
333
|
-
}
|
|
334
|
-
async handleConnectIncoming(payload) {
|
|
335
|
-
let descriptor;
|
|
336
|
-
try {
|
|
337
|
-
descriptor = await this.api.deviceFor(payload.clientDeviceId);
|
|
338
|
-
}
|
|
339
|
-
catch (error) {
|
|
340
|
-
this.sendControl('connect.rejected', { connectionId: payload.connectionId });
|
|
341
|
-
this.logger.warn('connection rejected by account authorization', {
|
|
342
|
-
clientDeviceId: shortId(payload.clientDeviceId),
|
|
343
|
-
code: errorCode(error),
|
|
344
|
-
});
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
if (descriptor.role !== 'client' || descriptor.deviceId !== payload.clientDeviceId
|
|
348
|
-
|| descriptor.identityKey !== payload.clientIdentityKey) {
|
|
349
|
-
this.sendControl('connect.rejected', { connectionId: payload.connectionId });
|
|
350
|
-
this.logger.warn('connection rejected by peer identity validation', {
|
|
351
|
-
clientDeviceId: shortId(payload.clientDeviceId),
|
|
352
|
-
});
|
|
353
|
-
return;
|
|
354
|
-
}
|
|
355
|
-
const existing = this.identities.trustedPeer(descriptor.deviceId);
|
|
356
|
-
if (existing !== undefined && existing.publicKey !== descriptor.identityKey) {
|
|
357
|
-
this.sendControl('connect.rejected', { connectionId: payload.connectionId });
|
|
358
|
-
this.logger.warn('connection rejected by pinned peer identity', {
|
|
359
|
-
clientDeviceId: shortId(payload.clientDeviceId),
|
|
360
|
-
});
|
|
361
|
-
return;
|
|
362
|
-
}
|
|
363
|
-
const peer = existing !== undefined
|
|
364
|
-
&& existing.membershipId === descriptor.membershipId
|
|
365
|
-
&& existing.name === descriptor.name
|
|
366
|
-
&& existing.platform === descriptor.platform
|
|
367
|
-
? existing
|
|
368
|
-
: await this.identities.trustPeer({
|
|
369
|
-
deviceId: descriptor.deviceId,
|
|
370
|
-
name: descriptor.name,
|
|
371
|
-
platform: descriptor.platform,
|
|
372
|
-
publicKey: descriptor.identityKey,
|
|
373
|
-
membershipId: descriptor.membershipId,
|
|
374
|
-
});
|
|
375
|
-
const previous = this.tunnels.get(payload.connectionId);
|
|
376
|
-
previous?.noise.destroy();
|
|
377
|
-
const noise = new NoiseIkSession({
|
|
378
|
-
role: 'responder',
|
|
379
|
-
localPrivateKey: this.identity.privateKey,
|
|
380
|
-
localPublicKey: this.identity.publicKey,
|
|
381
|
-
remotePublicKey: peer.publicKey,
|
|
382
|
-
prologue: createNoisePrologue(payload.connectionId, this.identity.deviceId, peer.deviceId),
|
|
383
|
-
});
|
|
384
|
-
this.tunnels.set(payload.connectionId, {
|
|
385
|
-
connectionId: payload.connectionId,
|
|
386
|
-
membershipId: descriptor.membershipId,
|
|
387
|
-
peer,
|
|
388
|
-
preferredTransports: payload.preferredTransports,
|
|
389
|
-
noise,
|
|
390
|
-
transport: 'negotiating',
|
|
391
|
-
});
|
|
392
|
-
this.sendControl('connect.accepted', { connectionId: payload.connectionId });
|
|
393
|
-
}
|
|
394
|
-
async handleHandshake(payload) {
|
|
395
|
-
const tunnel = this.tunnels.get(payload.connectionId);
|
|
396
|
-
if (tunnel !== undefined
|
|
397
|
-
&& tunnel.channel !== undefined
|
|
398
|
-
&& payload.targetDeviceId === this.identity.deviceId
|
|
399
|
-
&& payload.step === 1) {
|
|
400
|
-
// A queued Relay/WebRTC fallback can deliver the initiator's first
|
|
401
|
-
// handshake again after the authenticated channel is already ready.
|
|
402
|
-
// It belongs to this exact tunnel, so ignore it without taking the
|
|
403
|
-
// Host's shared control connection offline.
|
|
404
|
-
this.logger.warn('duplicate secure handshake ignored', {
|
|
405
|
-
connectionId: shortId(tunnel.connectionId),
|
|
406
|
-
peerDeviceId: shortId(tunnel.peer.deviceId),
|
|
407
|
-
});
|
|
408
|
-
return;
|
|
409
|
-
}
|
|
410
|
-
if (tunnel === undefined || payload.targetDeviceId !== this.identity.deviceId || payload.step !== 1) {
|
|
411
|
-
throw new ControlConnectionError('SECURE_CHANNEL_FAILED', 'Noise IK handshake is not valid for this connection.');
|
|
412
|
-
}
|
|
413
|
-
if (tunnel.transport === 'negotiating' && tunnel.rtc !== undefined) {
|
|
414
|
-
// transport.selected and secure.handshake traverse the Server control
|
|
415
|
-
// plane separately. Do not guess Relay if forwarding or the responder's
|
|
416
|
-
// RTC-ready callback delivers the handshake first; resume as soon as
|
|
417
|
-
// either side confirms the selected data plane.
|
|
418
|
-
if (tunnel.pendingHandshake === undefined)
|
|
419
|
-
tunnel.pendingHandshake = payload;
|
|
420
|
-
else
|
|
421
|
-
this.logger.warn('duplicate pending secure handshake ignored', {
|
|
422
|
-
connectionId: shortId(tunnel.connectionId),
|
|
423
|
-
peerDeviceId: shortId(tunnel.peer.deviceId),
|
|
424
|
-
});
|
|
425
|
-
return;
|
|
426
|
-
}
|
|
427
|
-
await this.completeHandshake(tunnel, payload);
|
|
428
|
-
}
|
|
429
|
-
async completeHandshake(tunnel, payload) {
|
|
430
|
-
tunnel.noise.readHandshake(fromBase64Url(payload.data));
|
|
431
|
-
const reply = tunnel.noise.writeHandshake();
|
|
432
|
-
if (!tunnel.noise.complete)
|
|
433
|
-
throw new ControlConnectionError('SECURE_CHANNEL_FAILED', 'Noise IK handshake did not complete.');
|
|
434
|
-
const viaWebRtc = tunnel.rtc !== undefined
|
|
435
|
-
&& (tunnel.transport === 'lan' || tunnel.transport === 'p2p' || tunnel.transport === 'turn');
|
|
436
|
-
if (!viaWebRtc && tunnel.transport === 'negotiating')
|
|
437
|
-
tunnel.transport = 'relay';
|
|
438
|
-
const mode = viaWebRtc
|
|
439
|
-
? tunnel.transportMode ?? (tunnel.transport === 'turn' ? 'TURN' : tunnel.transport === 'lan' ? 'LAN' : 'P2P')
|
|
440
|
-
: 'Relay';
|
|
441
|
-
const transmit = viaWebRtc
|
|
442
|
-
? (ciphertext) => tunnel.rtc.send(ciphertext)
|
|
443
|
-
: (ciphertext) => this.sendRelay(tunnel, ciphertext);
|
|
444
|
-
const channel = new ServerNoiseChannel(tunnel, transmit, () => {
|
|
445
|
-
if (this.tunnels.get(tunnel.connectionId) === tunnel)
|
|
446
|
-
this.tunnels.delete(tunnel.connectionId);
|
|
447
|
-
}, mode);
|
|
448
|
-
tunnel.channel = channel;
|
|
449
|
-
await this.connections.accept(channel);
|
|
450
|
-
// Publish the responder handshake only after the channel is registered.
|
|
451
|
-
// The Client starts its first RPC as soon as step 2 arrives; sending the
|
|
452
|
-
// reply earlier lets WebRTC deliver that RPC while tunnel.channel is still
|
|
453
|
-
// undefined, which silently drops the request.
|
|
454
|
-
this.sendControl('secure.handshake', {
|
|
455
|
-
connectionId: tunnel.connectionId,
|
|
456
|
-
targetDeviceId: tunnel.peer.deviceId,
|
|
457
|
-
step: 2,
|
|
458
|
-
data: toBase64Url(reply),
|
|
459
|
-
});
|
|
460
|
-
this.logger.info('authenticated peer channel ready', {
|
|
461
|
-
connectionId: shortId(tunnel.connectionId),
|
|
462
|
-
peerDeviceId: shortId(tunnel.peer.deviceId),
|
|
463
|
-
transport: mode,
|
|
464
|
-
});
|
|
465
|
-
}
|
|
466
|
-
async resumePendingHandshake(tunnel) {
|
|
467
|
-
const payload = tunnel.pendingHandshake;
|
|
468
|
-
if (payload === undefined || tunnel.channel !== undefined || tunnel.transport === 'negotiating')
|
|
469
|
-
return;
|
|
470
|
-
tunnel.pendingHandshake = undefined;
|
|
471
|
-
await this.completeHandshake(tunnel, payload);
|
|
472
|
-
}
|
|
473
|
-
async handleRelay(payload) {
|
|
474
|
-
if (!this.negotiatedCapabilities.includes('transport.relay')) {
|
|
475
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'Server forwarded Relay without negotiating it.');
|
|
476
|
-
}
|
|
477
|
-
if (payload.targetDeviceId !== this.identity.deviceId) {
|
|
478
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'Relay frame target does not match this Host.');
|
|
479
|
-
}
|
|
480
|
-
const tunnel = this.tunnels.get(payload.connectionId);
|
|
481
|
-
if (tunnel?.channel === undefined) {
|
|
482
|
-
this.logger.warn('stale relay frame ignored', {
|
|
483
|
-
connectionId: shortId(payload.connectionId),
|
|
484
|
-
});
|
|
485
|
-
return;
|
|
486
|
-
}
|
|
487
|
-
try {
|
|
488
|
-
tunnel.channel.receive(payload.counter, fromBase64Url(payload.ciphertext));
|
|
489
|
-
}
|
|
490
|
-
catch (error) {
|
|
491
|
-
await tunnel.channel.close();
|
|
492
|
-
throw new ControlConnectionError('SECURE_CHANNEL_FAILED', asError(error).message);
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
async handleSignalOffer(payload) {
|
|
496
|
-
if (!this.canUseWebRtc()) {
|
|
497
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'Server forwarded WebRTC signaling without negotiating it.');
|
|
498
|
-
}
|
|
499
|
-
const tunnel = this.tunnels.get(payload.connectionId);
|
|
500
|
-
if (tunnel === undefined || payload.targetDeviceId !== this.identity.deviceId) {
|
|
501
|
-
this.logger.warn('stale webrtc offer ignored', { connectionId: shortId(payload.connectionId) });
|
|
502
|
-
return;
|
|
503
|
-
}
|
|
504
|
-
if (tunnel.channel !== undefined || tunnel.rtc !== undefined) {
|
|
505
|
-
// Late or duplicate offer after relay establishment / RTC start: ignore.
|
|
506
|
-
this.logger.warn('duplicate webrtc offer ignored', { connectionId: shortId(tunnel.connectionId) });
|
|
507
|
-
return;
|
|
508
|
-
}
|
|
509
|
-
if (this.config.forceRelay) {
|
|
510
|
-
// Device-level forced degradation (webrtc plan §11).
|
|
511
|
-
this.logger.warn('webrtc offer ignored: forceRelay is enabled', { connectionId: shortId(tunnel.connectionId) });
|
|
512
|
-
return;
|
|
513
|
-
}
|
|
514
|
-
if (this.rtcFactory === undefined && this.rtcFactoryProvider !== undefined) {
|
|
515
|
-
this.rtcFactory = await this.rtcFactoryProvider().catch(() => undefined);
|
|
516
|
-
}
|
|
517
|
-
if (this.rtcFactory === undefined) {
|
|
518
|
-
// No Node RTC backend on this Host (webrtc plan §6.2): drop the offer so
|
|
519
|
-
// the initiator times out and falls back to the Relay data plane.
|
|
520
|
-
this.logger.warn('webrtc offer ignored: no RTC backend available', { connectionId: shortId(tunnel.connectionId) });
|
|
521
|
-
return;
|
|
522
|
-
}
|
|
523
|
-
let iceServers = [];
|
|
524
|
-
try {
|
|
525
|
-
iceServers = await this.api.turnCredentials(tunnel.connectionId);
|
|
526
|
-
}
|
|
527
|
-
catch (error) {
|
|
528
|
-
this.logger.warn('TURN credentials unavailable; trying direct candidates', {
|
|
529
|
-
connectionId: shortId(tunnel.connectionId),
|
|
530
|
-
code: errorCode(error),
|
|
531
|
-
});
|
|
532
|
-
}
|
|
533
|
-
if (!tunnel.preferredTransports.includes('turn'))
|
|
534
|
-
iceServers = stunOnlyIceServers(iceServers);
|
|
535
|
-
const rtc = new RtcDataChannelTransport({
|
|
536
|
-
role: 'responder',
|
|
537
|
-
factory: this.rtcFactory,
|
|
538
|
-
iceServers,
|
|
539
|
-
onSignal: signal => this.sendRtcSignal(tunnel, signal),
|
|
540
|
-
negotiateTimeoutMs: DEFAULT_WEBRTC_NEGOTIATE_TIMEOUT_MS,
|
|
541
|
-
label: `host<-${tunnel.peer.deviceId}`,
|
|
542
|
-
onDiagnostic: event => {
|
|
543
|
-
if (event.type !== 'local-candidate-filtered' && event.type !== 'candidate-pair-filtered')
|
|
544
|
-
return;
|
|
545
|
-
this.logger.debug('webrtc candidate filtered', {
|
|
546
|
-
connectionId: shortId(tunnel.connectionId),
|
|
547
|
-
peerDeviceId: shortId(tunnel.peer.deviceId),
|
|
548
|
-
type: event.type,
|
|
549
|
-
reason: event.reason,
|
|
550
|
-
candidate: event.type === 'local-candidate-filtered' ? event.candidate : event.localCandidate,
|
|
551
|
-
});
|
|
552
|
-
},
|
|
553
|
-
});
|
|
554
|
-
tunnel.rtc = rtc;
|
|
555
|
-
rtc.onMessage(data => tunnel.channel?.receive(undefined, data));
|
|
556
|
-
rtc.onClose(() => {
|
|
557
|
-
void this.handleRtcFailed(tunnel, rtc, new Error('WebRTC data channel closed.'));
|
|
558
|
-
});
|
|
559
|
-
void rtc.connect().then(() => {
|
|
560
|
-
this.handleRtcOpened(tunnel, rtc.selectedTransport() ?? 'p2p');
|
|
561
|
-
}).catch(error => {
|
|
562
|
-
void this.handleRtcFailed(tunnel, rtc, asError(error));
|
|
563
|
-
});
|
|
564
|
-
rtc.handleSignal({ type: 'offer', sdp: payload.sdp });
|
|
565
|
-
}
|
|
566
|
-
handleSignalIce(payload) {
|
|
567
|
-
if (!this.canUseWebRtc()) {
|
|
568
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'Server forwarded WebRTC signaling without negotiating it.');
|
|
569
|
-
}
|
|
570
|
-
const tunnel = this.tunnels.get(payload.connectionId);
|
|
571
|
-
if (tunnel === undefined || payload.targetDeviceId !== this.identity.deviceId)
|
|
572
|
-
return;
|
|
573
|
-
tunnel.rtc?.handleSignal({ type: 'ice', candidate: payload.candidate });
|
|
574
|
-
}
|
|
575
|
-
async handleTransportSelected(payload) {
|
|
576
|
-
const tunnel = this.tunnels.get(payload.connectionId);
|
|
577
|
-
if (tunnel === undefined || payload.targetDeviceId !== this.identity.deviceId) {
|
|
578
|
-
this.logger.warn('stale transport selection ignored', { connectionId: shortId(payload.connectionId) });
|
|
579
|
-
return;
|
|
580
|
-
}
|
|
581
|
-
if (tunnel.channel !== undefined) {
|
|
582
|
-
// Selection is immutable once Noise has bound the business channel to a
|
|
583
|
-
// data plane. Late duplicate control frames must not switch it beneath
|
|
584
|
-
// an authenticated connection.
|
|
585
|
-
if (tunnel.transport !== payload.transport) {
|
|
586
|
-
this.logger.warn('late transport selection ignored', {
|
|
587
|
-
connectionId: shortId(tunnel.connectionId),
|
|
588
|
-
selected: payload.transport,
|
|
589
|
-
});
|
|
590
|
-
}
|
|
591
|
-
return;
|
|
592
|
-
}
|
|
593
|
-
const requiredCapability = `transport.${payload.transport}`;
|
|
594
|
-
if (!this.negotiatedCapabilities.includes(requiredCapability)) {
|
|
595
|
-
throw new ControlConnectionError('INVALID_MESSAGE', `Client selected unnegotiated transport: ${payload.transport}`);
|
|
596
|
-
}
|
|
597
|
-
if (payload.transport === 'relay') {
|
|
598
|
-
const rtc = tunnel.rtc;
|
|
599
|
-
tunnel.rtc = undefined;
|
|
600
|
-
tunnel.transport = 'relay';
|
|
601
|
-
tunnel.transportMode = undefined;
|
|
602
|
-
await rtc?.close();
|
|
603
|
-
await this.resumePendingHandshake(tunnel);
|
|
604
|
-
return;
|
|
605
|
-
}
|
|
606
|
-
if (tunnel.rtc === undefined) {
|
|
607
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'Client selected WebRTC before creating a data channel.');
|
|
608
|
-
}
|
|
609
|
-
// The initiator emits transport.selected only after its DataChannel is
|
|
610
|
-
// open, then immediately starts Noise. Apply that ordered control frame
|
|
611
|
-
// before the handshake so a slower responder-side RTC ready callback
|
|
612
|
-
// cannot bind replies to Relay while requests arrive over WebRTC.
|
|
613
|
-
tunnel.transport = payload.transport;
|
|
614
|
-
tunnel.transportMode = tunnel.rtc.selectedPathMode();
|
|
615
|
-
await this.resumePendingHandshake(tunnel);
|
|
616
|
-
}
|
|
617
|
-
canUseWebRtc() {
|
|
618
|
-
return this.negotiatedCapabilities.includes('transport.lan')
|
|
619
|
-
|| this.negotiatedCapabilities.includes('transport.p2p')
|
|
620
|
-
|| this.negotiatedCapabilities.includes('transport.turn');
|
|
621
|
-
}
|
|
622
|
-
sendRtcSignal(tunnel, signal) {
|
|
623
|
-
if (signal.type === 'answer') {
|
|
624
|
-
this.sendControl('signal.answer', {
|
|
625
|
-
connectionId: tunnel.connectionId,
|
|
626
|
-
targetDeviceId: tunnel.peer.deviceId,
|
|
627
|
-
sdp: signal.sdp,
|
|
628
|
-
});
|
|
629
|
-
}
|
|
630
|
-
else if (signal.type === 'ice') {
|
|
631
|
-
this.sendControl('signal.ice', {
|
|
632
|
-
connectionId: tunnel.connectionId,
|
|
633
|
-
targetDeviceId: tunnel.peer.deviceId,
|
|
634
|
-
candidate: signal.candidate,
|
|
635
|
-
});
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
handleRtcOpened(tunnel, selected) {
|
|
639
|
-
if (this.tunnels.get(tunnel.connectionId) !== tunnel || tunnel.rtc === undefined)
|
|
640
|
-
return;
|
|
641
|
-
const wireSelected = selected === 'lan'
|
|
642
|
-
&& !this.negotiatedCapabilities.includes('transport.lan')
|
|
643
|
-
&& this.negotiatedCapabilities.includes('transport.p2p')
|
|
644
|
-
? 'p2p'
|
|
645
|
-
: selected;
|
|
646
|
-
const requiredCapability = wireSelected === 'lan'
|
|
647
|
-
? 'transport.lan'
|
|
648
|
-
: wireSelected === 'turn' ? 'transport.turn' : 'transport.p2p';
|
|
649
|
-
if (!this.negotiatedCapabilities.includes(requiredCapability)) {
|
|
650
|
-
const error = new Error(`WebRTC selected unnegotiated transport: ${selected}`);
|
|
651
|
-
if (!this.negotiatedCapabilities.includes('transport.relay')) {
|
|
652
|
-
void this.dropTunnel(tunnel.connectionId, 'CONNECTION_FAILED');
|
|
653
|
-
return;
|
|
654
|
-
}
|
|
655
|
-
void this.handleRtcFailed(tunnel, tunnel.rtc, error);
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
658
|
-
tunnel.transport = wireSelected;
|
|
659
|
-
tunnel.transportMode = tunnel.rtc.selectedPathMode();
|
|
660
|
-
this.sendTransportSelected(tunnel, wireSelected);
|
|
661
|
-
const diagnostics = rtcDiagnostics(tunnel.rtc);
|
|
662
|
-
this.logger.info('webrtc data channel ready', {
|
|
663
|
-
connectionId: shortId(tunnel.connectionId),
|
|
664
|
-
peerDeviceId: shortId(tunnel.peer.deviceId),
|
|
665
|
-
transport: tunnel.transportMode ?? wireSelected,
|
|
666
|
-
});
|
|
667
|
-
if (diagnostics !== undefined) {
|
|
668
|
-
this.logger.debug('webrtc data channel diagnostics', {
|
|
669
|
-
connectionId: shortId(tunnel.connectionId),
|
|
670
|
-
...webrtcDiagnosticsLogFields(diagnostics),
|
|
671
|
-
});
|
|
672
|
-
}
|
|
673
|
-
void this.resumePendingHandshake(tunnel).catch(error => {
|
|
674
|
-
this.logger.warn('pending secure handshake failed', {
|
|
675
|
-
connectionId: shortId(tunnel.connectionId),
|
|
676
|
-
reason: diagnosticReason(error),
|
|
677
|
-
});
|
|
678
|
-
void this.dropTunnel(tunnel.connectionId, 'SECURE_CHANNEL_FAILED');
|
|
679
|
-
});
|
|
680
|
-
}
|
|
681
|
-
async handleRtcFailed(tunnel, rtc, error) {
|
|
682
|
-
if (this.tunnels.get(tunnel.connectionId) !== tunnel || tunnel.rtc !== rtc)
|
|
683
|
-
return;
|
|
684
|
-
const diagnostics = rtcDiagnostics(rtc);
|
|
685
|
-
if (tunnel.transport === 'lan' || tunnel.transport === 'p2p' || tunnel.transport === 'turn') {
|
|
686
|
-
this.logger.warn('webrtc data channel failed; disconnecting peer', {
|
|
687
|
-
connectionId: shortId(tunnel.connectionId),
|
|
688
|
-
reason: diagnosticReason(error),
|
|
689
|
-
});
|
|
690
|
-
if (diagnostics !== undefined) {
|
|
691
|
-
this.logger.debug('webrtc data channel failure diagnostics', {
|
|
692
|
-
connectionId: shortId(tunnel.connectionId),
|
|
693
|
-
...webrtcDiagnosticsLogFields(diagnostics),
|
|
694
|
-
});
|
|
695
|
-
}
|
|
696
|
-
await this.dropTunnel(tunnel.connectionId, 'CONNECTION_FAILED');
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
699
|
-
tunnel.rtc = undefined;
|
|
700
|
-
tunnel.transport = 'relay';
|
|
701
|
-
await rtc.close();
|
|
702
|
-
this.logger.warn('webrtc negotiation failed; falling back to relay', {
|
|
703
|
-
connectionId: shortId(tunnel.connectionId),
|
|
704
|
-
reason: diagnosticReason(error),
|
|
705
|
-
});
|
|
706
|
-
if (diagnostics !== undefined) {
|
|
707
|
-
this.logger.debug('webrtc negotiation failure diagnostics', {
|
|
708
|
-
connectionId: shortId(tunnel.connectionId),
|
|
709
|
-
...webrtcDiagnosticsLogFields(diagnostics),
|
|
710
|
-
});
|
|
711
|
-
}
|
|
712
|
-
await this.resumePendingHandshake(tunnel);
|
|
713
|
-
}
|
|
714
|
-
sendTransportSelected(tunnel, transport) {
|
|
715
|
-
this.sendControl('transport.selected', {
|
|
716
|
-
connectionId: tunnel.connectionId,
|
|
717
|
-
targetDeviceId: tunnel.peer.deviceId,
|
|
718
|
-
transport,
|
|
719
|
-
});
|
|
720
|
-
}
|
|
721
|
-
async sendRelay(tunnel, ciphertext) {
|
|
722
|
-
const counter = Number(tunnel.noise.sendingCounter() - 1n);
|
|
723
|
-
if (!Number.isSafeInteger(counter) || counter < 0)
|
|
724
|
-
throw new ControlConnectionError('FRAME_TOO_LARGE', 'Noise transport counter overflowed.');
|
|
725
|
-
const socket = this.socket;
|
|
726
|
-
if (socket === undefined)
|
|
727
|
-
throw new Error('Relay transport closed');
|
|
728
|
-
await waitForRelayCapacity(socket);
|
|
729
|
-
if (this.socket !== socket)
|
|
730
|
-
throw new Error('Relay transport replaced');
|
|
731
|
-
this.sendControl('relay', {
|
|
732
|
-
connectionId: tunnel.connectionId,
|
|
733
|
-
targetDeviceId: tunnel.peer.deviceId,
|
|
734
|
-
counter,
|
|
735
|
-
ciphertext: toBase64Url(ciphertext),
|
|
736
|
-
});
|
|
737
|
-
}
|
|
738
|
-
sendControl(type, payload) {
|
|
739
|
-
const socket = this.socket;
|
|
740
|
-
if (socket === undefined || socket.readyState !== 1)
|
|
741
|
-
throw new ControlConnectionError('CONNECTION_FAILED', 'Server control socket is not open.');
|
|
742
|
-
socket.send(encodeControlFrame(createControlFrame(type, payload), this.controlFrameLimits));
|
|
743
|
-
}
|
|
744
|
-
async dropTunnels() {
|
|
745
|
-
const tunnels = [...this.tunnels.values()];
|
|
746
|
-
this.tunnels.clear();
|
|
747
|
-
await Promise.all(tunnels.map(async (tunnel) => {
|
|
748
|
-
if (tunnel.rtc !== undefined)
|
|
749
|
-
await tunnel.rtc.close();
|
|
750
|
-
if (tunnel.channel !== undefined)
|
|
751
|
-
await tunnel.channel.close();
|
|
752
|
-
else
|
|
753
|
-
tunnel.noise.destroy();
|
|
754
|
-
}));
|
|
755
|
-
await this.connections.close();
|
|
756
|
-
}
|
|
757
|
-
async dropTunnel(connectionId, code) {
|
|
758
|
-
const tunnel = this.tunnels.get(connectionId);
|
|
759
|
-
if (tunnel === undefined)
|
|
760
|
-
return;
|
|
761
|
-
this.tunnels.delete(connectionId);
|
|
762
|
-
try {
|
|
763
|
-
await tunnel.rtc?.close();
|
|
764
|
-
}
|
|
765
|
-
catch (error) {
|
|
766
|
-
this.logger.warn('remote connection RTC cleanup failed', {
|
|
767
|
-
connectionId: shortId(connectionId),
|
|
768
|
-
reason: diagnosticReason(error),
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
if (tunnel.channel !== undefined) {
|
|
772
|
-
try {
|
|
773
|
-
const closed = await this.connections.closeConnection(connectionId, code);
|
|
774
|
-
if (!closed)
|
|
775
|
-
await tunnel.channel.close(code);
|
|
776
|
-
}
|
|
777
|
-
catch (error) {
|
|
778
|
-
await tunnel.channel.close(code).catch(() => undefined);
|
|
779
|
-
this.logger.warn('remote connection channel cleanup failed', {
|
|
780
|
-
connectionId: shortId(connectionId),
|
|
781
|
-
reason: diagnosticReason(error),
|
|
782
|
-
});
|
|
783
|
-
}
|
|
784
|
-
}
|
|
785
|
-
else {
|
|
786
|
-
tunnel.noise.destroy();
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
waitBeforeRetry(baseDelay) {
|
|
790
|
-
const spread = baseDelay * this.config.reconnect.jitter;
|
|
791
|
-
const delay = Math.max(0, Math.round(baseDelay - spread + Math.random() * spread * 2));
|
|
792
|
-
return new Promise(resolve => {
|
|
793
|
-
const timer = setTimeout(() => { this.retryWake = undefined; resolve(); }, delay);
|
|
794
|
-
this.retryWake = () => { clearTimeout(timer); resolve(); };
|
|
795
|
-
});
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
const TERMINAL_AUTH_ERRORS = new Set([
|
|
799
|
-
'CONNECTION_REPLACED',
|
|
800
|
-
'SERVER_CREDENTIALS_BUSY',
|
|
801
|
-
'ACCOUNT_AUTH_REQUIRED',
|
|
802
|
-
'AUTH_INVALID',
|
|
803
|
-
'DEVICE_OWNERSHIP_REQUIRED',
|
|
804
|
-
'DEVICE_REVOKED',
|
|
805
|
-
'TOKEN_EXPIRED',
|
|
806
|
-
]);
|
|
807
|
-
class ServerNoiseChannel {
|
|
808
|
-
tunnel;
|
|
809
|
-
transmit;
|
|
810
|
-
onClose;
|
|
811
|
-
sends = new SerialSend();
|
|
812
|
-
security;
|
|
813
|
-
peerDeviceId;
|
|
814
|
-
peerIdentityKey;
|
|
815
|
-
mode;
|
|
816
|
-
handlers = new Set();
|
|
817
|
-
incoming = new SecureMessageCodec();
|
|
818
|
-
outgoing = new SecureMessageCodec();
|
|
819
|
-
closed = false;
|
|
820
|
-
constructor(tunnel, transmit, onClose, mode) {
|
|
821
|
-
this.tunnel = tunnel;
|
|
822
|
-
this.transmit = transmit;
|
|
823
|
-
this.onClose = onClose;
|
|
824
|
-
this.mode = mode;
|
|
825
|
-
this.security = {
|
|
826
|
-
protocol: 'Noise_IK_25519_ChaChaPoly_SHA256',
|
|
827
|
-
connectionId: tunnel.connectionId,
|
|
828
|
-
membershipId: tunnel.membershipId,
|
|
829
|
-
};
|
|
830
|
-
this.peerDeviceId = tunnel.peer.deviceId;
|
|
831
|
-
this.peerIdentityKey = tunnel.peer.publicKey;
|
|
832
|
-
}
|
|
833
|
-
async send(message) {
|
|
834
|
-
if (this.closed)
|
|
835
|
-
throw new Error('secure channel is closed');
|
|
836
|
-
const encoded = encodeMessage(message);
|
|
837
|
-
try {
|
|
838
|
-
await this.sends.run(encoded.byteLength, async () => {
|
|
839
|
-
if (this.closed)
|
|
840
|
-
throw new Error('Secure channel closed');
|
|
841
|
-
for (const plaintext of this.outgoing.encode(encoded))
|
|
842
|
-
await this.transmit(this.tunnel.noise.encrypt(plaintext));
|
|
843
|
-
});
|
|
844
|
-
}
|
|
845
|
-
catch (error) {
|
|
846
|
-
await this.close().catch(() => undefined);
|
|
847
|
-
throw error;
|
|
848
|
-
}
|
|
849
|
-
}
|
|
850
|
-
onMessage(handler) {
|
|
851
|
-
this.handlers.add(handler);
|
|
852
|
-
return () => this.handlers.delete(handler);
|
|
853
|
-
}
|
|
854
|
-
receive(counter, ciphertext) {
|
|
855
|
-
if (this.closed)
|
|
856
|
-
return;
|
|
857
|
-
if (counter !== undefined) {
|
|
858
|
-
const expected = Number(this.tunnel.noise.receivingCounter());
|
|
859
|
-
if (!Number.isSafeInteger(counter) || counter !== expected) {
|
|
860
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'Relay counter is duplicated or out of order.');
|
|
861
|
-
}
|
|
862
|
-
}
|
|
863
|
-
const plaintext = this.incoming.decode(this.tunnel.noise.decrypt(ciphertext));
|
|
864
|
-
if (plaintext === undefined)
|
|
865
|
-
return;
|
|
866
|
-
const message = decodeMessage(plaintext);
|
|
867
|
-
for (const handler of this.handlers)
|
|
868
|
-
handler(message);
|
|
869
|
-
}
|
|
870
|
-
async close(_code) {
|
|
871
|
-
if (this.closed)
|
|
872
|
-
return;
|
|
873
|
-
this.closed = true;
|
|
874
|
-
this.handlers.clear();
|
|
875
|
-
this.incoming.reset();
|
|
876
|
-
this.outgoing.reset();
|
|
877
|
-
this.tunnel.noise.destroy();
|
|
878
|
-
this.onClose();
|
|
879
|
-
}
|
|
880
|
-
}
|
|
881
|
-
class ControlConnectionError extends Error {
|
|
882
|
-
code;
|
|
883
|
-
constructor(code, message) {
|
|
884
|
-
super(message);
|
|
885
|
-
this.code = code;
|
|
886
|
-
}
|
|
887
|
-
}
|
|
888
|
-
function websocketUrl(baseUrl) {
|
|
889
|
-
const url = new URL(baseUrl);
|
|
890
|
-
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
891
|
-
url.pathname = `${url.pathname.replace(/\/$/, '')}/ws/v1/connect`;
|
|
892
|
-
return url.toString();
|
|
893
|
-
}
|
|
894
|
-
function decodeControl(data, limits) {
|
|
895
|
-
if (typeof data !== 'string')
|
|
896
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'Server control frames must be text JSON.');
|
|
897
|
-
try {
|
|
898
|
-
return decodeControlFrame(data, limits);
|
|
899
|
-
}
|
|
900
|
-
catch {
|
|
901
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'Server sent an invalid control frame.');
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
function requireHelloAck(value) {
|
|
905
|
-
const payload = requireObject(value);
|
|
906
|
-
if (payload.protocol !== PROTOCOL_VERSION || typeof payload.serverVersion !== 'string'
|
|
907
|
-
|| typeof payload.connectionSessionId !== 'string' || !Number.isSafeInteger(payload.heartbeatIntervalMs)
|
|
908
|
-
|| !Number.isSafeInteger(payload.maxControlFrameBytes) || !Number.isSafeInteger(payload.maxRelayFrameBytes)) {
|
|
909
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'hello.ack payload is invalid.');
|
|
910
|
-
}
|
|
911
|
-
return payload;
|
|
912
|
-
}
|
|
913
|
-
function requireConnectIncoming(value) {
|
|
914
|
-
const payload = requireObject(value);
|
|
915
|
-
if (typeof payload.connectionId !== 'string' || typeof payload.clientDeviceId !== 'string'
|
|
916
|
-
|| typeof payload.clientIdentityKey !== 'string' || payload.authorization !== 'account'
|
|
917
|
-
|| !Array.isArray(payload.preferredTransports)) {
|
|
918
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'connect.incoming payload is invalid.');
|
|
919
|
-
}
|
|
920
|
-
return payload;
|
|
921
|
-
}
|
|
922
|
-
function requireHandshake(value) {
|
|
923
|
-
const payload = requireObject(value);
|
|
924
|
-
if (typeof payload.connectionId !== 'string' || typeof payload.targetDeviceId !== 'string'
|
|
925
|
-
|| !Number.isSafeInteger(payload.step) || typeof payload.data !== 'string') {
|
|
926
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'secure.handshake payload is invalid.');
|
|
927
|
-
}
|
|
928
|
-
return payload;
|
|
929
|
-
}
|
|
930
|
-
function requireRelay(value) {
|
|
931
|
-
const payload = requireObject(value);
|
|
932
|
-
if (typeof payload.connectionId !== 'string' || typeof payload.targetDeviceId !== 'string'
|
|
933
|
-
|| !Number.isSafeInteger(payload.counter) || typeof payload.ciphertext !== 'string') {
|
|
934
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'relay payload is invalid.');
|
|
935
|
-
}
|
|
936
|
-
return payload;
|
|
937
|
-
}
|
|
938
|
-
function requireSignal(value) {
|
|
939
|
-
const payload = requireObject(value);
|
|
940
|
-
if (typeof payload.connectionId !== 'string' || typeof payload.targetDeviceId !== 'string'
|
|
941
|
-
|| typeof payload.sdp !== 'string' || payload.sdp.length === 0) {
|
|
942
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'signal offer/answer payload is invalid.');
|
|
943
|
-
}
|
|
944
|
-
return payload;
|
|
945
|
-
}
|
|
946
|
-
function requireSignalIce(value) {
|
|
947
|
-
const payload = requireObject(value);
|
|
948
|
-
if (typeof payload.connectionId !== 'string' || typeof payload.targetDeviceId !== 'string'
|
|
949
|
-
|| typeof payload.candidate !== 'object' || payload.candidate === null || Array.isArray(payload.candidate)) {
|
|
950
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'signal.ice payload is invalid.');
|
|
951
|
-
}
|
|
952
|
-
return payload;
|
|
953
|
-
}
|
|
954
|
-
function requireTransportSelected(value) {
|
|
955
|
-
const payload = requireObject(value);
|
|
956
|
-
if (typeof payload.connectionId !== 'string' || typeof payload.targetDeviceId !== 'string'
|
|
957
|
-
|| (payload.transport !== 'lan' && payload.transport !== 'p2p'
|
|
958
|
-
&& payload.transport !== 'turn' && payload.transport !== 'relay')) {
|
|
959
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'transport.selected payload is invalid.');
|
|
960
|
-
}
|
|
961
|
-
return payload;
|
|
962
|
-
}
|
|
963
|
-
function requireControlError(value) {
|
|
964
|
-
const payload = requireObject(value);
|
|
965
|
-
if (typeof payload.code !== 'string' || typeof payload.message !== 'string'
|
|
966
|
-
|| (payload.connectionId !== undefined && (typeof payload.connectionId !== 'string' || payload.connectionId === ''))) {
|
|
967
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'error payload is invalid.');
|
|
968
|
-
}
|
|
969
|
-
return payload;
|
|
970
|
-
}
|
|
971
|
-
function requireObject(value) {
|
|
972
|
-
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
973
|
-
throw new ControlConnectionError('INVALID_MESSAGE', 'Control payload must be an object.');
|
|
974
|
-
}
|
|
975
|
-
return value;
|
|
976
|
-
}
|
|
977
|
-
function objectValue(value, key) { return requireObject(value)[key]; }
|
|
978
|
-
function shortId(value) { return value.length <= 12 ? value : `${value.slice(0, 8)}…${value.slice(-4)}`; }
|
|
979
|
-
function asError(error) { return error instanceof Error ? error : new Error('Unknown Server connection error.'); }
|
|
980
|
-
function diagnosticReason(error) {
|
|
981
|
-
const message = asError(error).message.replace(/[\r\n]+/g, ' ').slice(0, 160);
|
|
982
|
-
return message || 'Unknown Server connection error.';
|
|
983
|
-
}
|
|
984
|
-
function webrtcDiagnosticsLogFields(diagnostics) {
|
|
985
|
-
if (diagnostics === undefined)
|
|
986
|
-
return {};
|
|
987
|
-
return {
|
|
988
|
-
rtcConnectionState: diagnostics.connectionState,
|
|
989
|
-
rtcIceConnectionState: diagnostics.iceConnectionState,
|
|
990
|
-
rtcIceGatheringState: diagnostics.iceGatheringState,
|
|
991
|
-
rtcLocalCandidates: diagnostics.localCandidates,
|
|
992
|
-
rtcRemoteCandidates: diagnostics.remoteCandidates,
|
|
993
|
-
rtcCandidatePairs: diagnostics.candidatePairs,
|
|
994
|
-
rtcFilteredLocalCandidates: diagnostics.filteredLocalCandidates,
|
|
995
|
-
rtcFilteredCandidatePairs: diagnostics.filteredCandidatePairs,
|
|
996
|
-
...(diagnostics.selectedPath === undefined ? {} : { rtcSelectedPath: diagnostics.selectedPath }),
|
|
997
|
-
};
|
|
998
|
-
}
|
|
999
|
-
function rtcDiagnostics(rtc) {
|
|
1000
|
-
try {
|
|
1001
|
-
const candidate = rtc;
|
|
1002
|
-
return typeof candidate.diagnostics === 'function' ? candidate.diagnostics() : undefined;
|
|
1003
|
-
}
|
|
1004
|
-
catch {
|
|
1005
|
-
return undefined;
|
|
1006
|
-
}
|
|
1007
|
-
}
|
|
1008
|
-
function errorCode(error) { return error instanceof ServerApiError || error instanceof ControlConnectionError ? error.code : 'CONNECTION_FAILED'; }
|
|
1009
|
-
function isRetryable(error) { return !TERMINAL_AUTH_ERRORS.has(errorCode(error)) && (!(error instanceof ServerApiError) || error.retryable); }
|
|
1010
|
-
function closeCode(code) {
|
|
1011
|
-
if (code === 4002)
|
|
1012
|
-
return 'AUTH_INVALID';
|
|
1013
|
-
if (code === 4003)
|
|
1014
|
-
return 'CONNECTION_REPLACED';
|
|
1015
|
-
if (code === 4004)
|
|
1016
|
-
return 'DEVICE_REVOKED';
|
|
1017
|
-
if (code === 4007)
|
|
1018
|
-
return 'RATE_LIMITED';
|
|
1019
|
-
if (code === 4011)
|
|
1020
|
-
return 'UNSUPPORTED_VERSION';
|
|
1021
|
-
return 'CONNECTION_FAILED';
|
|
1022
|
-
}
|
|
1023
|
-
//# sourceMappingURL=server-connection.js.map
|