@rynx-ai/server 0.1.0 → 0.1.9
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/dist/browser-surface-control-queue.d.ts +42 -0
- package/dist/browser-surface-control-queue.js +187 -0
- package/dist/browser-surface-ws.d.ts +14 -0
- package/dist/browser-surface-ws.js +355 -0
- package/dist/control-api.d.ts +59 -4
- package/dist/control-api.js +2213 -132
- package/dist/control-web/assets/highlighted-body-OFNGDK62-e-w61YLy.js +1 -0
- package/dist/control-web/assets/index-BQ7XVBKO.css +32 -0
- package/dist/control-web/assets/index-ClpzuBJx.js +606 -0
- package/dist/control-web/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/dist/control-web/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/dist/control-web/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/dist/control-web/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/dist/control-web/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/dist/control-web/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/dist/control-web/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/dist/control-web/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/dist/control-web/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/dist/control-web/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/dist/control-web/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/dist/control-web/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/dist/control-web/assets/mermaid-GHXKKRXX-DZn7Hbr2.js +131 -0
- package/dist/control-web/assets/rynx-wordmark-LL1QRYHD.png +0 -0
- package/dist/control-web/favicon.png +0 -0
- package/dist/control-web/favicon.svg +38 -0
- package/dist/control-web/index.html +16 -0
- package/dist/control-web-dist.d.ts +1 -1
- package/dist/control-web-dist.js +19 -14
- package/dist/desktop-browser-host.d.ts +153 -0
- package/dist/desktop-browser-host.js +936 -0
- package/dist/direct-runtime-browser-surface-server.d.ts +74 -0
- package/dist/direct-runtime-browser-surface-server.js +821 -0
- package/dist/direct-runtime-server.d.ts +137 -0
- package/dist/direct-runtime-server.js +1931 -0
- package/dist/emulator-surface-ws.d.ts +6 -0
- package/dist/emulator-surface-ws.js +197 -0
- package/dist/machine-session-service.d.ts +182 -0
- package/dist/machine-session-service.js +559 -0
- package/dist/provider-cli-host.d.ts +10 -0
- package/dist/provider-cli-host.js +1 -0
- package/dist/remote-runtime-dispatcher.d.ts +94 -0
- package/dist/remote-runtime-dispatcher.js +380 -0
- package/dist/remote-runtime-session-projection.d.ts +19 -0
- package/dist/remote-runtime-session-projection.js +556 -0
- package/dist/remote-runtime.d.ts +22 -0
- package/dist/remote-runtime.js +32 -0
- package/dist/runtime-web-auth.d.ts +21 -0
- package/dist/runtime-web-auth.js +92 -0
- package/dist/server.d.ts +292 -10
- package/dist/server.js +314 -98
- package/dist/session-browser-service.d.ts +218 -0
- package/dist/session-browser-service.js +686 -0
- package/dist/session-browser-surface-coordinator.d.ts +23 -0
- package/dist/session-browser-surface-coordinator.js +563 -0
- package/dist/session-emulator-service.d.ts +167 -0
- package/dist/session-emulator-service.js +464 -0
- package/dist/session-runtime-index.d.ts +17 -0
- package/dist/session-runtime-index.js +86 -0
- package/dist/session-terminal-host.d.ts +46 -0
- package/dist/session-terminal-host.js +252 -0
- package/dist/terminal-ws.d.ts +9 -20
- package/dist/terminal-ws.js +420 -97
- package/package.json +9 -7
|
@@ -0,0 +1,1931 @@
|
|
|
1
|
+
import { createServer, } from "node:http";
|
|
2
|
+
import { decodeDirectRuntimeFrame, DIRECT_RUNTIME_CLOSE_CODE, DIRECT_RUNTIME_BROWSER_SURFACE_PATH, DIRECT_RUNTIME_CONTROL_PATH, DIRECT_RUNTIME_MAX_HANDSHAKE_FRAME_BYTES, encodeDirectRuntimeFrame, parseServerAccepted, parseServerHello, } from "@rynx-ai/protocol/direct-runtime";
|
|
3
|
+
import { createServerE2EEChannel, DIRECT_RUNTIME_E2EE_MAX_ENVELOPE_BYTES, } from "@rynx-ai/remote-runtime-e2ee";
|
|
4
|
+
import { encodeRemoteRuntimeSessionEventsFrame, encodeRemoteRuntimeSessionTerminalServerFrame, encodeRemoteRuntimeRpcResponse, parseRemoteRuntimeRpcFrame, parseRemoteRuntimeSessionEventsFrame, parseRemoteRuntimeSessionTerminalClientFrame, REMOTE_RUNTIME_SESSION_TERMINAL_MAX_DATA_BYTES, REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES, REMOTE_RUNTIME_RPC_MAX_ID_CHARS, REMOTE_RUNTIME_RPC_METHOD_METADATA, } from "@rynx-ai/protocol/remote-runtime-rpc";
|
|
5
|
+
import { encodeRuntimeEmulatorSurfaceServerFrame, parseRuntimeEmulatorSurfaceClientFrame, RUNTIME_EMULATOR_SURFACE_CHUNK_BYTES, RUNTIME_EMULATOR_SURFACE_MAX_FRAME_BYTES, } from "@rynx-ai/protocol/runtime-emulator-surface";
|
|
6
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
7
|
+
import { attachDirectRuntimeBrowserSurfaceServer, } from "./direct-runtime-browser-surface-server.js";
|
|
8
|
+
export { DIRECT_RUNTIME_CONTROL_PATH };
|
|
9
|
+
const CLOSE_BAD_PROTOCOL = DIRECT_RUNTIME_CLOSE_CODE.badProtocol;
|
|
10
|
+
const CLOSE_UNAUTHORIZED = DIRECT_RUNTIME_CLOSE_CODE.unauthorized;
|
|
11
|
+
const CLOSE_AUTH_TIMEOUT = DIRECT_RUNTIME_CLOSE_CODE.authenticationTimeout;
|
|
12
|
+
const CLOSE_CAPACITY = DIRECT_RUNTIME_CLOSE_CODE.capacity;
|
|
13
|
+
const CLOSE_INTERNAL = DIRECT_RUNTIME_CLOSE_CODE.internal;
|
|
14
|
+
const DEFAULT_AUTH_TIMEOUT_MS = 10_000;
|
|
15
|
+
const DEFAULT_MAX_CONNECTIONS = 64;
|
|
16
|
+
const DEFAULT_MAX_UNAUTHENTICATED_CONNECTIONS_PER_SOURCE = 4;
|
|
17
|
+
const DEFAULT_MAX_AUTHENTICATION_ATTEMPTS_PER_SOURCE = 20;
|
|
18
|
+
const DEFAULT_AUTHENTICATION_ATTEMPT_WINDOW_MS = 60_000;
|
|
19
|
+
const DEFAULT_MAX_TRACKED_AUTHENTICATION_SOURCES = 1_024;
|
|
20
|
+
const DEFAULT_MAX_CONNECTIONS_PER_GRANT = 8;
|
|
21
|
+
const DEFAULT_MAX_QUEUED_FRAMES = 32;
|
|
22
|
+
const DEFAULT_MAX_REQUEST_IDS = 4_096;
|
|
23
|
+
const DEFAULT_MAX_BUFFERED_BYTES = 1024 * 1024;
|
|
24
|
+
const DEFAULT_MAX_SESSION_SUBSCRIPTIONS = 16;
|
|
25
|
+
const DEFAULT_MAX_QUEUED_SESSION_EVENTS = 128;
|
|
26
|
+
const DEFAULT_MAX_QUEUED_SESSION_EVENT_BYTES = 512 * 1024;
|
|
27
|
+
const DEFAULT_MAX_QUEUED_TERMINAL_OUTPUT_FRAMES = 128;
|
|
28
|
+
const DEFAULT_MAX_QUEUED_TERMINAL_OUTPUT_BYTES = 512 * 1024;
|
|
29
|
+
const DEFAULT_SHUTDOWN_DEADLINE_MS = 5_000;
|
|
30
|
+
const DEFAULT_HEARTBEAT_INTERVAL_MS = 15_000;
|
|
31
|
+
const CONNECTION_CLOSE_DEADLINE_MS = 1_000;
|
|
32
|
+
const MAX_RPC_METHOD_NAME_CHARS = 256;
|
|
33
|
+
const RPC_ENVELOPE_TOKEN_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._~-]*$/;
|
|
34
|
+
const EMULATOR_SURFACE_ACK_TIMEOUT_MS = 10_000;
|
|
35
|
+
/** Process-live connection index used to fence grants immediately after revocation. */
|
|
36
|
+
export class DirectRuntimeConnectionHub {
|
|
37
|
+
connectionsByGrant = new Map();
|
|
38
|
+
grantByConnection = new Map();
|
|
39
|
+
revokedGrantIds = new Set();
|
|
40
|
+
/** @internal Called by the Direct adapter after authentication and before acceptance. */
|
|
41
|
+
register(grantId, connection) {
|
|
42
|
+
return this.registerWithinLimit(grantId, connection, Number.MAX_SAFE_INTEGER) === "registered";
|
|
43
|
+
}
|
|
44
|
+
/** @internal Atomically fence revocation and enforce a per-grant channel bound. */
|
|
45
|
+
registerWithinLimit(grantId, connection, maxConnections) {
|
|
46
|
+
if (!Number.isSafeInteger(maxConnections) || maxConnections <= 0) {
|
|
47
|
+
throw new Error("Direct Runtime per-grant connection limit must be positive");
|
|
48
|
+
}
|
|
49
|
+
if (this.revokedGrantIds.has(grantId))
|
|
50
|
+
return "revoked";
|
|
51
|
+
const connections = this.connectionsByGrant.get(grantId) ?? new Set();
|
|
52
|
+
if (connections.size >= maxConnections)
|
|
53
|
+
return "capacity";
|
|
54
|
+
connections.add(connection);
|
|
55
|
+
this.connectionsByGrant.set(grantId, connections);
|
|
56
|
+
this.grantByConnection.set(connection, grantId);
|
|
57
|
+
return "registered";
|
|
58
|
+
}
|
|
59
|
+
/** @internal Idempotent adapter cleanup. */
|
|
60
|
+
unregister(connection) {
|
|
61
|
+
const grantId = this.grantByConnection.get(connection);
|
|
62
|
+
if (!grantId)
|
|
63
|
+
return;
|
|
64
|
+
this.grantByConnection.delete(connection);
|
|
65
|
+
const connections = this.connectionsByGrant.get(grantId);
|
|
66
|
+
connections?.delete(connection);
|
|
67
|
+
if (connections?.size === 0)
|
|
68
|
+
this.connectionsByGrant.delete(grantId);
|
|
69
|
+
}
|
|
70
|
+
/** Mark a grant revoked and immediately close every matching active channel. */
|
|
71
|
+
revokeGrant(grantId, reason = "Remote Runtime grant revoked") {
|
|
72
|
+
this.revokedGrantIds.add(grantId);
|
|
73
|
+
const connections = [...(this.connectionsByGrant.get(grantId) ?? [])];
|
|
74
|
+
for (const connection of connections)
|
|
75
|
+
connection.close(CLOSE_UNAUTHORIZED, reason);
|
|
76
|
+
return connections.length;
|
|
77
|
+
}
|
|
78
|
+
get activeConnectionCount() {
|
|
79
|
+
return this.grantByConnection.size;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/** Start a fail-closed HTTP/WebSocket listener containing only the E2EE Direct route. */
|
|
83
|
+
export async function startDirectRuntimeServer(options) {
|
|
84
|
+
const host = options.host.trim();
|
|
85
|
+
if (!host)
|
|
86
|
+
throw new Error("Direct Runtime bind host is required");
|
|
87
|
+
if (!Number.isInteger(options.port) || options.port < 0 || options.port > 65_535) {
|
|
88
|
+
throw new Error("Direct Runtime port must be an integer between 0 and 65535");
|
|
89
|
+
}
|
|
90
|
+
const limits = resolveLimits(options.limits);
|
|
91
|
+
const connectionHub = options.connectionHub ?? new DirectRuntimeConnectionHub();
|
|
92
|
+
const allConnections = new Set();
|
|
93
|
+
const activeDispatches = new Set();
|
|
94
|
+
const activeDispatchControllers = new Set();
|
|
95
|
+
const rawSockets = new Set();
|
|
96
|
+
const emptyWaiters = new Set();
|
|
97
|
+
const liveWebSockets = new WeakSet();
|
|
98
|
+
const unauthenticatedConnectionsBySource = new Map();
|
|
99
|
+
const pendingUpgradesBySource = new Map();
|
|
100
|
+
const authenticationAttempts = new SourceAuthenticationAttemptLimiter({
|
|
101
|
+
maxAttempts: limits.maxAuthenticationAttemptsPerSource,
|
|
102
|
+
windowMs: limits.authenticationAttemptWindowMs,
|
|
103
|
+
maxSources: limits.maxTrackedAuthenticationSources,
|
|
104
|
+
});
|
|
105
|
+
let pendingUpgrades = 0;
|
|
106
|
+
let stopping = false;
|
|
107
|
+
let heartbeatTimer;
|
|
108
|
+
let shutdownPromise;
|
|
109
|
+
const server = createServer((_request, response) => {
|
|
110
|
+
response.statusCode = 404;
|
|
111
|
+
response.setHeader("cache-control", "no-store");
|
|
112
|
+
response.setHeader("connection", "close");
|
|
113
|
+
response.end("not found\n");
|
|
114
|
+
});
|
|
115
|
+
const wss = new WebSocketServer({
|
|
116
|
+
noServer: true,
|
|
117
|
+
clientTracking: true,
|
|
118
|
+
perMessageDeflate: false,
|
|
119
|
+
maxPayload: DIRECT_RUNTIME_E2EE_MAX_ENVELOPE_BYTES,
|
|
120
|
+
});
|
|
121
|
+
server.maxConnections = limits.maxConnections;
|
|
122
|
+
server.headersTimeout = limits.authenticationTimeoutMs;
|
|
123
|
+
server.requestTimeout = limits.authenticationTimeoutMs;
|
|
124
|
+
server.keepAliveTimeout = Math.min(5_000, limits.authenticationTimeoutMs);
|
|
125
|
+
server.on("error", (error) => options.onError?.(error, "HTTP listener"));
|
|
126
|
+
server.on("connection", (socket) => {
|
|
127
|
+
rawSockets.add(socket);
|
|
128
|
+
socket.once("close", () => rawSockets.delete(socket));
|
|
129
|
+
});
|
|
130
|
+
const browserSurfaceServer = options.browserSurface
|
|
131
|
+
? attachDirectRuntimeBrowserSurfaceServer(server, {
|
|
132
|
+
e2ee: options.e2ee,
|
|
133
|
+
authenticator: options.authenticator,
|
|
134
|
+
host: options.browserSurface.host,
|
|
135
|
+
isPeerActive: options.isPeerActive,
|
|
136
|
+
connectionHub,
|
|
137
|
+
limits: {
|
|
138
|
+
...options.browserSurface.limits,
|
|
139
|
+
authenticationTimeoutMs: limits.authenticationTimeoutMs,
|
|
140
|
+
maxConnections: limits.maxConnections,
|
|
141
|
+
maxUnauthenticatedConnectionsPerSource: limits.maxUnauthenticatedConnectionsPerSource,
|
|
142
|
+
maxAuthenticationAttemptsPerSource: limits.maxAuthenticationAttemptsPerSource,
|
|
143
|
+
authenticationAttemptWindowMs: limits.authenticationAttemptWindowMs,
|
|
144
|
+
maxTrackedAuthenticationSources: limits.maxTrackedAuthenticationSources,
|
|
145
|
+
maxConnectionsPerGrant: limits.maxConnectionsPerGrant,
|
|
146
|
+
heartbeatIntervalMs: limits.heartbeatIntervalMs,
|
|
147
|
+
shutdownDeadlineMs: limits.shutdownDeadlineMs,
|
|
148
|
+
},
|
|
149
|
+
onError: options.onError,
|
|
150
|
+
})
|
|
151
|
+
: undefined;
|
|
152
|
+
server.on("upgrade", (request, socket, head) => {
|
|
153
|
+
const sourceAddress = socketSourceAddress(request);
|
|
154
|
+
if (stopping) {
|
|
155
|
+
rejectUpgrade(socket, 503, "Direct Runtime is stopping");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (browserSurfaceServer && request.url === DIRECT_RUNTIME_BROWSER_SURFACE_PATH) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (request.url !== DIRECT_RUNTIME_CONTROL_PATH) {
|
|
162
|
+
rejectUpgrade(socket, 404, "Direct Runtime route not found");
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (allConnections.size + pendingUpgrades >= limits.maxConnections) {
|
|
166
|
+
rejectUpgrade(socket, 503, "Direct Runtime connection capacity reached");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if ((unauthenticatedConnectionsBySource.get(sourceAddress) ?? 0) +
|
|
170
|
+
(pendingUpgradesBySource.get(sourceAddress) ?? 0) >=
|
|
171
|
+
limits.maxUnauthenticatedConnectionsPerSource) {
|
|
172
|
+
rejectUpgrade(socket, 503, "Direct Runtime source authentication capacity reached");
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
pendingUpgrades += 1;
|
|
176
|
+
incrementCount(pendingUpgradesBySource, sourceAddress);
|
|
177
|
+
let upgradePending = true;
|
|
178
|
+
const finishPendingUpgrade = () => {
|
|
179
|
+
if (!upgradePending)
|
|
180
|
+
return;
|
|
181
|
+
upgradePending = false;
|
|
182
|
+
pendingUpgrades -= 1;
|
|
183
|
+
decrementCount(pendingUpgradesBySource, sourceAddress);
|
|
184
|
+
};
|
|
185
|
+
try {
|
|
186
|
+
wss.handleUpgrade(request, socket, head, (ws) => {
|
|
187
|
+
finishPendingUpgrade();
|
|
188
|
+
const connection = acceptConnection(ws, sourceAddress);
|
|
189
|
+
allConnections.add(connection);
|
|
190
|
+
wss.emit("connection", ws, request);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
catch (error) {
|
|
194
|
+
finishPendingUpgrade();
|
|
195
|
+
options.onError?.(error, "websocket upgrade");
|
|
196
|
+
rejectUpgrade(socket, 400, "invalid Direct Runtime WebSocket upgrade");
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
function acceptConnection(ws, sourceAddress) {
|
|
200
|
+
let connection;
|
|
201
|
+
const abortController = new AbortController();
|
|
202
|
+
const authenticationTimer = setTimeout(() => {
|
|
203
|
+
abortController.abort(new Error("Direct Runtime authentication timed out"));
|
|
204
|
+
connection.close(CLOSE_AUTH_TIMEOUT, "Direct Runtime authentication timed out");
|
|
205
|
+
}, limits.authenticationTimeoutMs);
|
|
206
|
+
authenticationTimer.unref?.();
|
|
207
|
+
connection = {
|
|
208
|
+
ws,
|
|
209
|
+
sourceAddress,
|
|
210
|
+
countedAsUnauthenticated: true,
|
|
211
|
+
state: "awaiting_e2ee_hello",
|
|
212
|
+
abortController,
|
|
213
|
+
authenticationTimer,
|
|
214
|
+
receiveTail: Promise.resolve(),
|
|
215
|
+
queuedFrames: 0,
|
|
216
|
+
authenticationFrameQueued: false,
|
|
217
|
+
seenRequestIds: new Set(),
|
|
218
|
+
seenSubscriptionIds: new Set(),
|
|
219
|
+
pendingSessionSubscriptions: new Map(),
|
|
220
|
+
sessionSubscriptions: new Map(),
|
|
221
|
+
seenTerminalAttachmentIds: new Set(),
|
|
222
|
+
seenEmulatorSurfaceIds: new Set(),
|
|
223
|
+
cleaned: false,
|
|
224
|
+
close(code, reason) {
|
|
225
|
+
closeConnection(connection, code, reason);
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
incrementCount(unauthenticatedConnectionsBySource, sourceAddress);
|
|
229
|
+
liveWebSockets.add(ws);
|
|
230
|
+
ws.on("message", (data, isBinary) => {
|
|
231
|
+
// Application traffic is also positive proof that the peer is alive.
|
|
232
|
+
liveWebSockets.add(ws);
|
|
233
|
+
if (connection.state === "closing" || connection.state === "closed")
|
|
234
|
+
return;
|
|
235
|
+
if (connection.state !== "authenticated") {
|
|
236
|
+
if ((connection.state !== "awaiting_e2ee_hello" &&
|
|
237
|
+
connection.state !== "awaiting_authentication") ||
|
|
238
|
+
connection.authenticationFrameQueued) {
|
|
239
|
+
connection.close(CLOSE_BAD_PROTOCOL, "client traffic must wait for Direct Runtime authentication");
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
connection.authenticationFrameQueued = true;
|
|
243
|
+
}
|
|
244
|
+
connection.queuedFrames += 1;
|
|
245
|
+
if (connection.queuedFrames > limits.maxQueuedFrames) {
|
|
246
|
+
connection.queuedFrames -= 1;
|
|
247
|
+
connection.close(CLOSE_CAPACITY, "Direct Runtime input queue is full");
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
const run = () => handleFrame(connection, data, isBinary);
|
|
251
|
+
const handleFailure = (error) => {
|
|
252
|
+
if (connection.state === "closing" || connection.state === "closed")
|
|
253
|
+
return;
|
|
254
|
+
options.onError?.(error, "control frame");
|
|
255
|
+
connection.close(CLOSE_INTERNAL, "Direct Runtime frame handling failed");
|
|
256
|
+
};
|
|
257
|
+
const releaseFrame = () => {
|
|
258
|
+
connection.queuedFrames -= 1;
|
|
259
|
+
};
|
|
260
|
+
if (connection.state === "authenticated") {
|
|
261
|
+
// Request ids correlate responses, so authenticated unary RPCs may run
|
|
262
|
+
// concurrently. Ordering/ownership belongs to each domain service;
|
|
263
|
+
// serializing the entire channel would let a long attach block status
|
|
264
|
+
// and stop controls for the same daemon.
|
|
265
|
+
void run().catch(handleFailure).finally(releaseFrame);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
// Authentication remains strictly single-frame and ordered.
|
|
269
|
+
connection.receiveTail = connection.receiveTail
|
|
270
|
+
.then(run)
|
|
271
|
+
.catch((error) => {
|
|
272
|
+
handleFailure(error);
|
|
273
|
+
})
|
|
274
|
+
.finally(releaseFrame);
|
|
275
|
+
});
|
|
276
|
+
ws.on("ping", () => liveWebSockets.add(ws));
|
|
277
|
+
ws.on("pong", () => liveWebSockets.add(ws));
|
|
278
|
+
ws.on("close", () => cleanupConnection(connection));
|
|
279
|
+
ws.on("error", (error) => {
|
|
280
|
+
options.onError?.(error, "control socket");
|
|
281
|
+
cleanupConnection(connection);
|
|
282
|
+
});
|
|
283
|
+
return connection;
|
|
284
|
+
}
|
|
285
|
+
async function initializeServerHello(connection) {
|
|
286
|
+
try {
|
|
287
|
+
const channel = connection.e2eeChannel;
|
|
288
|
+
if (!channel)
|
|
289
|
+
throw new Error("Direct Runtime E2EE channel is unavailable");
|
|
290
|
+
const channelBinding = channel.channelBinding;
|
|
291
|
+
const hello = parseServerHello(await options.authenticator.createServerHello({
|
|
292
|
+
channelBinding: new Uint8Array(channelBinding),
|
|
293
|
+
role: "control",
|
|
294
|
+
signal: connection.abortController.signal,
|
|
295
|
+
}));
|
|
296
|
+
if (hello.role !== "control") {
|
|
297
|
+
throw new Error("Direct Runtime authenticator returned the wrong channel role");
|
|
298
|
+
}
|
|
299
|
+
if (connection.state !== "awaiting_e2ee_hello")
|
|
300
|
+
return;
|
|
301
|
+
const encoded = encodeDirectRuntimeFrame(hello);
|
|
302
|
+
const bytes = Uint8Array.from(Buffer.from(encoded, "utf8"));
|
|
303
|
+
// Publish this connection's challenge before waiting for ws.send's
|
|
304
|
+
// callback. A client can receive the frame and answer before that local
|
|
305
|
+
// callback runs; the one-frame gate still admits only authentication.
|
|
306
|
+
connection.serverHello = hello;
|
|
307
|
+
connection.serverHelloBytes = bytes;
|
|
308
|
+
connection.authenticationFrameQueued = false;
|
|
309
|
+
connection.state = "awaiting_authentication";
|
|
310
|
+
await sendText(connection, encoded, limits.maxBufferedBytes);
|
|
311
|
+
}
|
|
312
|
+
catch (error) {
|
|
313
|
+
if (connection.state === "closing" || connection.state === "closed")
|
|
314
|
+
return;
|
|
315
|
+
options.onError?.(error, "server hello");
|
|
316
|
+
connection.close(CLOSE_INTERNAL, "Direct Runtime authentication setup failed");
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
async function handleFrame(connection, data, isBinary) {
|
|
320
|
+
if (connection.state === "closing" || connection.state === "closed")
|
|
321
|
+
return;
|
|
322
|
+
if (isBinary) {
|
|
323
|
+
connection.close(CLOSE_BAD_PROTOCOL, "Direct Runtime control frames must be text");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
const wireBytes = rawDataBytes(data);
|
|
327
|
+
if (wireBytes.byteLength > DIRECT_RUNTIME_E2EE_MAX_ENVELOPE_BYTES) {
|
|
328
|
+
connection.close(CLOSE_BAD_PROTOCOL, "Direct Runtime frame is too large");
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (connection.state === "awaiting_e2ee_hello") {
|
|
332
|
+
await establishE2EEChannel(connection, wireBytes);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
const channel = connection.e2eeChannel;
|
|
336
|
+
if (!channel) {
|
|
337
|
+
connection.close(CLOSE_BAD_PROTOCOL, "Direct Runtime E2EE channel is unavailable");
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
let plaintext;
|
|
341
|
+
try {
|
|
342
|
+
plaintext = channel.open(decodeText(wireBytes));
|
|
343
|
+
}
|
|
344
|
+
catch {
|
|
345
|
+
connection.close(CLOSE_BAD_PROTOCOL, "invalid Direct Runtime E2EE frame");
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const bytes = Uint8Array.from(Buffer.from(plaintext, "utf8"));
|
|
349
|
+
const maxBytes = connection.state === "authenticated"
|
|
350
|
+
? REMOTE_RUNTIME_RPC_MAX_FRAME_BYTES
|
|
351
|
+
: DIRECT_RUNTIME_MAX_HANDSHAKE_FRAME_BYTES;
|
|
352
|
+
if (bytes.byteLength > maxBytes) {
|
|
353
|
+
connection.close(CLOSE_BAD_PROTOCOL, "Direct Runtime frame is too large");
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (connection.state === "awaiting_authentication") {
|
|
357
|
+
await authenticateConnection(connection, bytes);
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (connection.state !== "authenticated" || !connection.peer) {
|
|
361
|
+
connection.close(CLOSE_BAD_PROTOCOL, "Direct Runtime authentication is already in progress");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
let frame;
|
|
365
|
+
try {
|
|
366
|
+
frame = parseAuthenticatedClientFrame(bytes);
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
connection.close(CLOSE_BAD_PROTOCOL, "invalid Remote Runtime control request");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (frame.type === "session.events.subscribe" || frame.type === "session.events.cancel") {
|
|
373
|
+
await handleSessionEventsFrame(connection, frame);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
if (frame.type === "session.terminal.open" ||
|
|
377
|
+
frame.type === "session.terminal.input" ||
|
|
378
|
+
frame.type === "session.terminal.resize" ||
|
|
379
|
+
frame.type === "session.terminal.close") {
|
|
380
|
+
await handleTerminalFrame(connection, frame);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (frame.type === "emulator.surface.open" ||
|
|
384
|
+
frame.type === "emulator.surface.frame.ack" ||
|
|
385
|
+
frame.type === "emulator.surface.touch" ||
|
|
386
|
+
frame.type === "emulator.surface.close") {
|
|
387
|
+
await handleEmulatorSurfaceFrame(connection, frame);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const request = frame;
|
|
391
|
+
if (connection.seenRequestIds.has(request.id)) {
|
|
392
|
+
await sendRpcError(connection, {
|
|
393
|
+
type: "rpc.error",
|
|
394
|
+
id: request.id,
|
|
395
|
+
error: { code: "conflict", message: "Remote Runtime request id was already used" },
|
|
396
|
+
});
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
if (connection.seenRequestIds.size >= limits.maxRequestIds) {
|
|
400
|
+
connection.close(CLOSE_CAPACITY, "Direct Runtime request id capacity reached");
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
connection.seenRequestIds.add(request.id);
|
|
404
|
+
let active = false;
|
|
405
|
+
try {
|
|
406
|
+
active = await options.isPeerActive(connection.peer);
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
options.onError?.(error, "Direct Runtime peer activity check");
|
|
410
|
+
}
|
|
411
|
+
if (!active) {
|
|
412
|
+
await sendRpcError(connection, {
|
|
413
|
+
type: "rpc.error",
|
|
414
|
+
id: request.id,
|
|
415
|
+
error: { code: "unauthorized", message: "Remote Runtime grant is not active" },
|
|
416
|
+
});
|
|
417
|
+
connection.close(CLOSE_UNAUTHORIZED, "Remote Runtime grant is not active");
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (stopping || isConnectionClosed(connection))
|
|
421
|
+
return;
|
|
422
|
+
if (request.type === "rpc.unknown_request") {
|
|
423
|
+
await sendRpcError(connection, {
|
|
424
|
+
type: "rpc.error",
|
|
425
|
+
id: request.id,
|
|
426
|
+
error: {
|
|
427
|
+
code: "method_not_found",
|
|
428
|
+
message: "Remote Runtime method is not supported",
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
const dispatchAbortController = new AbortController();
|
|
434
|
+
const abortFromConnection = () => {
|
|
435
|
+
dispatchAbortController.abort(connection.abortController.signal.reason);
|
|
436
|
+
};
|
|
437
|
+
if (connection.abortController.signal.aborted)
|
|
438
|
+
abortFromConnection();
|
|
439
|
+
else {
|
|
440
|
+
connection.abortController.signal.addEventListener("abort", abortFromConnection, {
|
|
441
|
+
once: true,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
activeDispatchControllers.add(dispatchAbortController);
|
|
445
|
+
let responsePromise;
|
|
446
|
+
try {
|
|
447
|
+
responsePromise = options.dispatcher.dispatch(request, {
|
|
448
|
+
subject: connection.peer.subject,
|
|
449
|
+
signal: dispatchAbortController.signal,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
connection.abortController.signal.removeEventListener("abort", abortFromConnection);
|
|
454
|
+
activeDispatchControllers.delete(dispatchAbortController);
|
|
455
|
+
throw error;
|
|
456
|
+
}
|
|
457
|
+
let settledDispatch;
|
|
458
|
+
settledDispatch = responsePromise
|
|
459
|
+
.then(() => undefined, () => undefined)
|
|
460
|
+
.finally(() => {
|
|
461
|
+
connection.abortController.signal.removeEventListener("abort", abortFromConnection);
|
|
462
|
+
activeDispatchControllers.delete(dispatchAbortController);
|
|
463
|
+
activeDispatches.delete(settledDispatch);
|
|
464
|
+
});
|
|
465
|
+
activeDispatches.add(settledDispatch);
|
|
466
|
+
const response = await responsePromise;
|
|
467
|
+
await sendText(connection, encodeRemoteRuntimeRpcResponse(response, request.method), limits.maxBufferedBytes);
|
|
468
|
+
}
|
|
469
|
+
async function establishE2EEChannel(connection, bytes) {
|
|
470
|
+
if (bytes.byteLength > DIRECT_RUNTIME_MAX_HANDSHAKE_FRAME_BYTES) {
|
|
471
|
+
connection.close(CLOSE_BAD_PROTOCOL, "Direct Runtime E2EE hello is too large");
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
try {
|
|
475
|
+
const frame = decodeDirectRuntimeFrame(bytes);
|
|
476
|
+
if (frame.type !== "e2ee.client-hello") {
|
|
477
|
+
throw new Error("first client frame must be e2ee.client-hello");
|
|
478
|
+
}
|
|
479
|
+
const sharedSecret = options.e2ee.deriveSharedSecret(frame.clientEphemeralPublicKey);
|
|
480
|
+
connection.e2eeChannel = createServerE2EEChannel({
|
|
481
|
+
sharedSecret,
|
|
482
|
+
serverEncryptionPublicKey: options.e2ee.serverPublicKey,
|
|
483
|
+
clientHello: frame,
|
|
484
|
+
});
|
|
485
|
+
await sendPlainText(connection, encodeDirectRuntimeFrame({ type: "e2ee.ready" }), limits.maxBufferedBytes);
|
|
486
|
+
await initializeServerHello(connection);
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
connection.close(CLOSE_BAD_PROTOCOL, "invalid Direct Runtime E2EE client hello");
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
async function handleSessionEventsFrame(connection, frame) {
|
|
493
|
+
const peer = connection.peer;
|
|
494
|
+
if (!peer) {
|
|
495
|
+
connection.close(CLOSE_BAD_PROTOCOL, "Direct Runtime peer identity is unavailable");
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (frame.type === "session.events.cancel") {
|
|
499
|
+
const pending = connection.pendingSessionSubscriptions.get(frame.subscriptionId);
|
|
500
|
+
if (pending) {
|
|
501
|
+
// Cancellation is a transport-lifecycle reduction, so record it before
|
|
502
|
+
// any asynchronous work. The opening handler owns eventual source
|
|
503
|
+
// cleanup even if its application call resolves after this close.
|
|
504
|
+
await cancelPendingSessionSubscription(connection, pending, true);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const subscription = connection.sessionSubscriptions.get(frame.subscriptionId);
|
|
508
|
+
if (subscription) {
|
|
509
|
+
finishSessionSubscription(connection, subscription, "cancelled", undefined, true);
|
|
510
|
+
}
|
|
511
|
+
else if (!connection.seenSubscriptionIds.has(frame.subscriptionId)) {
|
|
512
|
+
connection.close(CLOSE_BAD_PROTOCOL, "Remote Session subscription id is unknown");
|
|
513
|
+
}
|
|
514
|
+
// Otherwise the id is a terminal tombstone. Do not emit a second close
|
|
515
|
+
// after a capacity/open/normal-close frame the client may have consumed.
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
let admissionFailure;
|
|
519
|
+
let pending;
|
|
520
|
+
if (connection.seenSubscriptionIds.has(frame.subscriptionId)) {
|
|
521
|
+
admissionFailure = "duplicate";
|
|
522
|
+
}
|
|
523
|
+
else if (connection.seenSubscriptionIds.size >= limits.maxRequestIds) {
|
|
524
|
+
admissionFailure = "id_capacity";
|
|
525
|
+
}
|
|
526
|
+
else {
|
|
527
|
+
connection.seenSubscriptionIds.add(frame.subscriptionId);
|
|
528
|
+
if (connection.pendingSessionSubscriptions.size + connection.sessionSubscriptions.size >=
|
|
529
|
+
limits.maxSessionSubscriptions) {
|
|
530
|
+
admissionFailure = "subscription_capacity";
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
pending = {
|
|
534
|
+
subscriptionId: frame.subscriptionId,
|
|
535
|
+
sessionId: frame.sessionId,
|
|
536
|
+
abortController: new AbortController(),
|
|
537
|
+
cancelRequested: false,
|
|
538
|
+
closeFrameClaimed: false,
|
|
539
|
+
};
|
|
540
|
+
// Reserve the id and capacity before the first await. Authenticated
|
|
541
|
+
// frame handlers run concurrently, so a following cancel can now hit
|
|
542
|
+
// this pending state instead of falling through the seen-id tombstone.
|
|
543
|
+
connection.pendingSessionSubscriptions.set(frame.subscriptionId, pending);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
let active = false;
|
|
547
|
+
try {
|
|
548
|
+
active = await options.isPeerActive(peer);
|
|
549
|
+
}
|
|
550
|
+
catch (error) {
|
|
551
|
+
options.onError?.(error, "Direct Runtime peer activity check");
|
|
552
|
+
}
|
|
553
|
+
if (!active) {
|
|
554
|
+
if (pending) {
|
|
555
|
+
connection.pendingSessionSubscriptions.delete(pending.subscriptionId);
|
|
556
|
+
abortPendingSessionSubscription(pending);
|
|
557
|
+
}
|
|
558
|
+
if (!pending?.closeFrameClaimed) {
|
|
559
|
+
if (pending)
|
|
560
|
+
pending.closeFrameClaimed = true;
|
|
561
|
+
await sendSessionEventsClosed(connection, frame.subscriptionId, "unauthorized");
|
|
562
|
+
}
|
|
563
|
+
connection.close(CLOSE_UNAUTHORIZED, "Remote Runtime grant is not active");
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
if (admissionFailure === "duplicate") {
|
|
567
|
+
await sendSessionEventsClosed(connection, frame.subscriptionId, "capacity", "Remote Session subscription id was already used");
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
if (admissionFailure === "id_capacity") {
|
|
571
|
+
connection.close(CLOSE_CAPACITY, "Direct Runtime subscription id capacity reached");
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
if (admissionFailure === "subscription_capacity") {
|
|
575
|
+
await sendSessionEventsClosed(connection, frame.subscriptionId, "capacity", "Remote Session subscription capacity reached");
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
if (!pending) {
|
|
579
|
+
connection.close(CLOSE_INTERNAL, "Remote Session pending subscription is unavailable");
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
if (pending.cancelRequested) {
|
|
583
|
+
await cancelPendingSessionSubscription(connection, pending, true);
|
|
584
|
+
connection.pendingSessionSubscriptions.delete(pending.subscriptionId);
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
let opened;
|
|
588
|
+
try {
|
|
589
|
+
opened = await options.dispatcher.openSessionEvents(frame.sessionId, {
|
|
590
|
+
subject: peer.subject,
|
|
591
|
+
signal: pending.abortController.signal,
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
catch (error) {
|
|
595
|
+
connection.pendingSessionSubscriptions.delete(pending.subscriptionId);
|
|
596
|
+
if (pending.cancelRequested) {
|
|
597
|
+
await cancelPendingSessionSubscription(connection, pending, true);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
options.onError?.(error, "Remote Session subscription open");
|
|
601
|
+
await sendSessionEventsClosed(connection, frame.subscriptionId, remoteSessionOpenCloseReason(error));
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (opened.sessionId !== frame.sessionId) {
|
|
605
|
+
connection.pendingSessionSubscriptions.delete(pending.subscriptionId);
|
|
606
|
+
await opened.cancel().catch(() => undefined);
|
|
607
|
+
connection.close(CLOSE_INTERNAL, "Remote Session subscription identity mismatch");
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (pending.cancelRequested || isConnectionClosed(connection)) {
|
|
611
|
+
connection.pendingSessionSubscriptions.delete(pending.subscriptionId);
|
|
612
|
+
await opened.cancel().catch((error) => {
|
|
613
|
+
options.onError?.(error, "Remote Session pending subscription cancel");
|
|
614
|
+
});
|
|
615
|
+
if (!isConnectionClosed(connection)) {
|
|
616
|
+
await cancelPendingSessionSubscription(connection, pending, true);
|
|
617
|
+
}
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const subscription = {
|
|
621
|
+
subscriptionId: frame.subscriptionId,
|
|
622
|
+
sessionId: frame.sessionId,
|
|
623
|
+
output: new BoundedTextQueue(limits.maxQueuedSessionEvents, limits.maxQueuedSessionEventBytes),
|
|
624
|
+
cancelSource: () => opened.cancel(),
|
|
625
|
+
finishing: false,
|
|
626
|
+
};
|
|
627
|
+
connection.pendingSessionSubscriptions.delete(pending.subscriptionId);
|
|
628
|
+
connection.sessionSubscriptions.set(subscription.subscriptionId, subscription);
|
|
629
|
+
// Drain the process bus into our own bounded queue immediately. The
|
|
630
|
+
// accepted frame is still awaited before the writer starts, preserving the
|
|
631
|
+
// ready barrier without allowing the bus implementation to become the
|
|
632
|
+
// transport's unbounded buffer.
|
|
633
|
+
void readSessionEvents(connection, subscription, opened.events);
|
|
634
|
+
try {
|
|
635
|
+
await sendText(connection, encodeRemoteRuntimeSessionEventsFrame({
|
|
636
|
+
type: "session.events.accepted",
|
|
637
|
+
subscriptionId: subscription.subscriptionId,
|
|
638
|
+
sessionId: subscription.sessionId,
|
|
639
|
+
daemonInstanceId: opened.daemonInstanceId,
|
|
640
|
+
}), limits.maxBufferedBytes);
|
|
641
|
+
void writeSessionEvents(connection, subscription);
|
|
642
|
+
}
|
|
643
|
+
catch (error) {
|
|
644
|
+
finishSessionSubscription(connection, subscription, undefined, undefined, true);
|
|
645
|
+
throw error;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
async function handleEmulatorSurfaceFrame(connection, frame) {
|
|
649
|
+
const peer = connection.peer;
|
|
650
|
+
if (!peer) {
|
|
651
|
+
connection.close(CLOSE_BAD_PROTOCOL, "Direct Runtime peer identity is unavailable");
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
if (frame.type === "emulator.surface.frame.ack") {
|
|
655
|
+
const surface = connection.emulatorSurface;
|
|
656
|
+
const awaiting = surface?.awaitingAck;
|
|
657
|
+
if (!surface || surface.frame.surfaceId !== frame.surfaceId || !awaiting || awaiting.sequence !== frame.frameSequence) {
|
|
658
|
+
connection.close(CLOSE_BAD_PROTOCOL, "Emulator Surface ACK does not match the pending frame");
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
661
|
+
if (awaiting.timer)
|
|
662
|
+
clearTimeout(awaiting.timer);
|
|
663
|
+
surface.awaitingAck = undefined;
|
|
664
|
+
awaiting.resolve();
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (frame.type === "emulator.surface.touch") {
|
|
668
|
+
const surface = connection.emulatorSurface;
|
|
669
|
+
if (!surface ||
|
|
670
|
+
surface.frame.surfaceId !== frame.surfaceId ||
|
|
671
|
+
frame.inputSequence !== surface.nextInputSequence) {
|
|
672
|
+
connection.close(CLOSE_BAD_PROTOCOL, "Emulator Surface touch does not match the active input sequence");
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
surface.nextInputSequence += 1;
|
|
676
|
+
await surface.attachment.handle(frame);
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
if (frame.type === "emulator.surface.close") {
|
|
680
|
+
const pending = connection.pendingEmulatorSurface;
|
|
681
|
+
if (pending?.frame.surfaceId === frame.surfaceId) {
|
|
682
|
+
pending.closeRequested = true;
|
|
683
|
+
pending.abortController.abort(new Error("Emulator Surface opening was cancelled"));
|
|
684
|
+
connection.pendingEmulatorSurface = undefined;
|
|
685
|
+
await sendEmulatorSurfaceClosed(connection, frame.surfaceId, "client_closed");
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
const surface = connection.emulatorSurface;
|
|
689
|
+
if (surface?.frame.surfaceId === frame.surfaceId) {
|
|
690
|
+
await finishEmulatorSurface(connection, surface, "client_closed");
|
|
691
|
+
}
|
|
692
|
+
else if (!connection.seenEmulatorSurfaceIds.has(frame.surfaceId)) {
|
|
693
|
+
connection.close(CLOSE_BAD_PROTOCOL, "Emulator Surface id is unknown");
|
|
694
|
+
}
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (!options.emulatorSurface) {
|
|
698
|
+
await sendEmulatorSurfaceClosed(connection, frame.surfaceId, "unsupported", "Runtime does not provide Emulator Surface");
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
if (connection.seenEmulatorSurfaceIds.has(frame.surfaceId)) {
|
|
702
|
+
await sendEmulatorSurfaceClosed(connection, frame.surfaceId, "capacity", "Emulator Surface id was already used");
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
if (connection.seenEmulatorSurfaceIds.size >= limits.maxRequestIds) {
|
|
706
|
+
connection.close(CLOSE_CAPACITY, "Direct Runtime Emulator Surface id capacity reached");
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
connection.seenEmulatorSurfaceIds.add(frame.surfaceId);
|
|
710
|
+
if (connection.pendingEmulatorSurface || connection.emulatorSurface) {
|
|
711
|
+
await sendEmulatorSurfaceClosed(connection, frame.surfaceId, "capacity", "One Emulator Surface is admitted per connection");
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
// Reserve the one Surface slot before awaiting peer activity. Otherwise
|
|
715
|
+
// two raw open frames can both pass admission while the check is pending.
|
|
716
|
+
const pending = {
|
|
717
|
+
frame,
|
|
718
|
+
abortController: new AbortController(),
|
|
719
|
+
closeRequested: false,
|
|
720
|
+
};
|
|
721
|
+
connection.pendingEmulatorSurface = pending;
|
|
722
|
+
let active = false;
|
|
723
|
+
try {
|
|
724
|
+
active = await options.isPeerActive(peer);
|
|
725
|
+
}
|
|
726
|
+
catch (error) {
|
|
727
|
+
options.onError?.(error, "Emulator Surface peer activity check");
|
|
728
|
+
}
|
|
729
|
+
if (pending.closeRequested ||
|
|
730
|
+
isConnectionClosed(connection) ||
|
|
731
|
+
connection.pendingEmulatorSurface !== pending)
|
|
732
|
+
return;
|
|
733
|
+
if (!active) {
|
|
734
|
+
connection.pendingEmulatorSurface = undefined;
|
|
735
|
+
await sendEmulatorSurfaceClosed(connection, frame.surfaceId, "unauthorized");
|
|
736
|
+
connection.close(CLOSE_UNAUTHORIZED, "Remote Runtime grant is not active");
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
let attachment;
|
|
740
|
+
try {
|
|
741
|
+
attachment = await options.emulatorSurface.host.open(frame, {
|
|
742
|
+
peer,
|
|
743
|
+
signal: pending.abortController.signal,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
catch (error) {
|
|
747
|
+
if (connection.pendingEmulatorSurface === pending)
|
|
748
|
+
connection.pendingEmulatorSurface = undefined;
|
|
749
|
+
if (pending.closeRequested || pending.abortController.signal.aborted)
|
|
750
|
+
return;
|
|
751
|
+
options.onError?.(error, "Emulator Surface open");
|
|
752
|
+
await sendEmulatorSurfaceClosed(connection, frame.surfaceId, emulatorSurfaceCloseReason(error), error instanceof Error ? error.message : undefined);
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
if (pending.closeRequested ||
|
|
756
|
+
isConnectionClosed(connection) ||
|
|
757
|
+
connection.pendingEmulatorSurface !== pending) {
|
|
758
|
+
if (connection.pendingEmulatorSurface === pending)
|
|
759
|
+
connection.pendingEmulatorSurface = undefined;
|
|
760
|
+
await Promise.resolve(attachment.close()).catch(() => undefined);
|
|
761
|
+
return;
|
|
762
|
+
}
|
|
763
|
+
if (attachment.ready.surfaceId !== frame.surfaceId ||
|
|
764
|
+
attachment.ready.sessionId !== frame.sessionId ||
|
|
765
|
+
attachment.ready.bindingId !== frame.bindingId) {
|
|
766
|
+
connection.pendingEmulatorSurface = undefined;
|
|
767
|
+
await Promise.resolve(attachment.close()).catch(() => undefined);
|
|
768
|
+
connection.close(CLOSE_INTERNAL, "Emulator Surface host changed the requested identity");
|
|
769
|
+
return;
|
|
770
|
+
}
|
|
771
|
+
const surface = {
|
|
772
|
+
frame,
|
|
773
|
+
attachment,
|
|
774
|
+
abortController: pending.abortController,
|
|
775
|
+
nextInputSequence: 0,
|
|
776
|
+
finishing: false,
|
|
777
|
+
closedFrameSent: false,
|
|
778
|
+
};
|
|
779
|
+
connection.pendingEmulatorSurface = undefined;
|
|
780
|
+
connection.emulatorSurface = surface;
|
|
781
|
+
await sendText(connection, encodeRuntimeEmulatorSurfaceServerFrame(attachment.ready), limits.maxBufferedBytes);
|
|
782
|
+
void runEmulatorSurface(connection, surface);
|
|
783
|
+
}
|
|
784
|
+
async function runEmulatorSurface(connection, surface) {
|
|
785
|
+
let frameSequence = 0;
|
|
786
|
+
let consecutiveCaptureFailures = 0;
|
|
787
|
+
const intervalMs = Math.ceil(1_000 / surface.frame.maximumFrameRate);
|
|
788
|
+
try {
|
|
789
|
+
while (!surface.finishing && !isConnectionClosed(connection)) {
|
|
790
|
+
const startedAt = Date.now();
|
|
791
|
+
let bytes;
|
|
792
|
+
try {
|
|
793
|
+
bytes = await surface.attachment.capture();
|
|
794
|
+
consecutiveCaptureFailures = 0;
|
|
795
|
+
}
|
|
796
|
+
catch (error) {
|
|
797
|
+
if (surface.finishing || isConnectionClosed(connection) || connection.emulatorSurface !== surface)
|
|
798
|
+
return;
|
|
799
|
+
const reason = emulatorSurfaceCloseReason(error);
|
|
800
|
+
if (reason !== "internal" || consecutiveCaptureFailures >= 4)
|
|
801
|
+
throw error;
|
|
802
|
+
consecutiveCaptureFailures += 1;
|
|
803
|
+
const retryMs = Math.min(1_000, 100 * 2 ** (consecutiveCaptureFailures - 1));
|
|
804
|
+
await new Promise((resolve) => setTimeout(resolve, retryMs));
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
if (surface.finishing || isConnectionClosed(connection) || connection.emulatorSurface !== surface)
|
|
808
|
+
return;
|
|
809
|
+
const validImage = surface.attachment.ready.format === "png"
|
|
810
|
+
? bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47
|
|
811
|
+
: bytes[0] === 0xff && bytes[1] === 0xd8 && bytes.at(-2) === 0xff && bytes.at(-1) === 0xd9;
|
|
812
|
+
if (bytes.byteLength === 0 ||
|
|
813
|
+
bytes.byteLength > RUNTIME_EMULATOR_SURFACE_MAX_FRAME_BYTES ||
|
|
814
|
+
!validImage)
|
|
815
|
+
throw new Error(`Emulator Surface host returned an invalid ${surface.attachment.ready.format} frame`);
|
|
816
|
+
const sequence = frameSequence;
|
|
817
|
+
frameSequence += 1;
|
|
818
|
+
const timestampMilliseconds = Date.now();
|
|
819
|
+
let acknowledgedResolve;
|
|
820
|
+
let acknowledgedReject;
|
|
821
|
+
const acknowledged = new Promise((resolve, reject) => {
|
|
822
|
+
acknowledgedResolve = resolve;
|
|
823
|
+
acknowledgedReject = reject;
|
|
824
|
+
});
|
|
825
|
+
// A close can reject this before the writer reaches the await below.
|
|
826
|
+
void acknowledged.catch(() => undefined);
|
|
827
|
+
const awaitingAck = {
|
|
828
|
+
sequence,
|
|
829
|
+
resolve: acknowledgedResolve,
|
|
830
|
+
reject: acknowledgedReject,
|
|
831
|
+
};
|
|
832
|
+
surface.awaitingAck = awaitingAck;
|
|
833
|
+
const chunkCount = Math.ceil(bytes.byteLength / RUNTIME_EMULATOR_SURFACE_CHUNK_BYTES);
|
|
834
|
+
for (let chunkIndex = 0; chunkIndex < chunkCount; chunkIndex += 1) {
|
|
835
|
+
if (surface.finishing ||
|
|
836
|
+
isConnectionClosed(connection) ||
|
|
837
|
+
connection.emulatorSurface !== surface)
|
|
838
|
+
return;
|
|
839
|
+
const start = chunkIndex * RUNTIME_EMULATOR_SURFACE_CHUNK_BYTES;
|
|
840
|
+
const chunk = bytes.subarray(start, Math.min(start + RUNTIME_EMULATOR_SURFACE_CHUNK_BYTES, bytes.byteLength));
|
|
841
|
+
await sendText(connection, encodeRuntimeEmulatorSurfaceServerFrame({
|
|
842
|
+
type: "emulator.surface.frame.chunk",
|
|
843
|
+
surfaceId: surface.frame.surfaceId,
|
|
844
|
+
frameSequence: sequence,
|
|
845
|
+
timestampMilliseconds,
|
|
846
|
+
chunkIndex,
|
|
847
|
+
chunkCount,
|
|
848
|
+
data: Buffer.from(chunk).toString("base64"),
|
|
849
|
+
}), limits.maxBufferedBytes);
|
|
850
|
+
if (surface.finishing ||
|
|
851
|
+
isConnectionClosed(connection) ||
|
|
852
|
+
connection.emulatorSurface !== surface)
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
if (surface.awaitingAck === awaitingAck) {
|
|
856
|
+
const timer = setTimeout(() => awaitingAck.reject(new Error("Emulator Surface frame ACK timed out")), EMULATOR_SURFACE_ACK_TIMEOUT_MS);
|
|
857
|
+
timer.unref?.();
|
|
858
|
+
awaitingAck.timer = timer;
|
|
859
|
+
}
|
|
860
|
+
await acknowledged;
|
|
861
|
+
const waitMs = intervalMs - (Date.now() - startedAt);
|
|
862
|
+
if (waitMs > 0)
|
|
863
|
+
await new Promise((resolve) => setTimeout(resolve, waitMs));
|
|
864
|
+
}
|
|
865
|
+
}
|
|
866
|
+
catch (error) {
|
|
867
|
+
if (!surface.finishing && !isConnectionClosed(connection)) {
|
|
868
|
+
options.onError?.(error, "Emulator Surface capture");
|
|
869
|
+
await finishEmulatorSurface(connection, surface, emulatorSurfaceCloseReason(error), error instanceof Error ? error.message : undefined);
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
async function finishEmulatorSurface(connection, surface, reason, message) {
|
|
874
|
+
if (surface.finishing)
|
|
875
|
+
return;
|
|
876
|
+
surface.finishing = true;
|
|
877
|
+
if (connection.emulatorSurface === surface)
|
|
878
|
+
connection.emulatorSurface = undefined;
|
|
879
|
+
surface.abortController.abort(new Error("Emulator Surface closed"));
|
|
880
|
+
const awaiting = surface.awaitingAck;
|
|
881
|
+
surface.awaitingAck = undefined;
|
|
882
|
+
if (awaiting) {
|
|
883
|
+
if (awaiting.timer)
|
|
884
|
+
clearTimeout(awaiting.timer);
|
|
885
|
+
awaiting.reject(new Error("Emulator Surface closed"));
|
|
886
|
+
}
|
|
887
|
+
await Promise.resolve(surface.attachment.close()).catch((error) => {
|
|
888
|
+
options.onError?.(error, "Emulator Surface close");
|
|
889
|
+
});
|
|
890
|
+
if (reason && !surface.closedFrameSent && !isConnectionClosed(connection)) {
|
|
891
|
+
surface.closedFrameSent = true;
|
|
892
|
+
await sendEmulatorSurfaceClosed(connection, surface.frame.surfaceId, reason, message);
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
async function sendEmulatorSurfaceClosed(connection, surfaceId, reason, message) {
|
|
896
|
+
await sendText(connection, encodeRuntimeEmulatorSurfaceServerFrame({
|
|
897
|
+
type: "emulator.surface.closed",
|
|
898
|
+
surfaceId,
|
|
899
|
+
reason,
|
|
900
|
+
...(message ? { message: message.slice(0, 1_024) } : {}),
|
|
901
|
+
}), limits.maxBufferedBytes);
|
|
902
|
+
}
|
|
903
|
+
async function readSessionEvents(connection, subscription, events) {
|
|
904
|
+
try {
|
|
905
|
+
for await (const event of events) {
|
|
906
|
+
if (subscription.finishing)
|
|
907
|
+
return;
|
|
908
|
+
const encoded = encodeRemoteRuntimeSessionEventsFrame({
|
|
909
|
+
type: "session.events.event",
|
|
910
|
+
subscriptionId: subscription.subscriptionId,
|
|
911
|
+
event,
|
|
912
|
+
});
|
|
913
|
+
if (!subscription.output.push(encoded)) {
|
|
914
|
+
finishSessionSubscription(connection, subscription, "backpressure", "Remote Session consumer is too slow", true);
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
finishSessionSubscription(connection, subscription, "session_closed", undefined, false);
|
|
919
|
+
}
|
|
920
|
+
catch (error) {
|
|
921
|
+
if (subscription.finishing)
|
|
922
|
+
return;
|
|
923
|
+
options.onError?.(error, "Remote Session event projection");
|
|
924
|
+
finishSessionSubscription(connection, subscription, "internal", "Remote Session event stream failed", true);
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
async function writeSessionEvents(connection, subscription) {
|
|
928
|
+
try {
|
|
929
|
+
for (;;) {
|
|
930
|
+
const frame = await subscription.output.shift();
|
|
931
|
+
if (frame === undefined)
|
|
932
|
+
return;
|
|
933
|
+
await sendText(connection, frame, limits.maxBufferedBytes);
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
catch (error) {
|
|
937
|
+
if (!isConnectionClosed(connection)) {
|
|
938
|
+
options.onError?.(error, "Remote Session event delivery");
|
|
939
|
+
connection.close(CLOSE_INTERNAL, "Remote Session event delivery failed");
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
finally {
|
|
943
|
+
finishSessionSubscription(connection, subscription, undefined, undefined, true);
|
|
944
|
+
}
|
|
945
|
+
}
|
|
946
|
+
function abortPendingSessionSubscription(pending) {
|
|
947
|
+
pending.cancelRequested = true;
|
|
948
|
+
if (!pending.abortController.signal.aborted) {
|
|
949
|
+
pending.abortController.abort(new Error("Remote Session subscription cancelled"));
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
async function cancelPendingSessionSubscription(connection, pending, emitClosed) {
|
|
953
|
+
abortPendingSessionSubscription(pending);
|
|
954
|
+
if (!emitClosed || pending.closeFrameClaimed || isConnectionClosed(connection))
|
|
955
|
+
return;
|
|
956
|
+
pending.closeFrameClaimed = true;
|
|
957
|
+
await sendSessionEventsClosed(connection, pending.subscriptionId, "cancelled");
|
|
958
|
+
}
|
|
959
|
+
function finishSessionSubscription(connection, subscription, reason, message, discardPending) {
|
|
960
|
+
if (subscription.finishing)
|
|
961
|
+
return;
|
|
962
|
+
subscription.finishing = true;
|
|
963
|
+
connection.sessionSubscriptions.delete(subscription.subscriptionId);
|
|
964
|
+
void subscription.cancelSource().catch((error) => {
|
|
965
|
+
options.onError?.(error, "Remote Session subscription cancel");
|
|
966
|
+
});
|
|
967
|
+
const finalFrame = reason === undefined
|
|
968
|
+
? undefined
|
|
969
|
+
: encodeRemoteRuntimeSessionEventsFrame({
|
|
970
|
+
type: "session.events.closed",
|
|
971
|
+
subscriptionId: subscription.subscriptionId,
|
|
972
|
+
reason,
|
|
973
|
+
...(message === undefined ? {} : { message }),
|
|
974
|
+
});
|
|
975
|
+
if (discardPending)
|
|
976
|
+
subscription.output.replaceAndFinish(finalFrame);
|
|
977
|
+
else
|
|
978
|
+
subscription.output.finish(finalFrame);
|
|
979
|
+
}
|
|
980
|
+
async function sendSessionEventsClosed(connection, subscriptionId, reason, message) {
|
|
981
|
+
await sendText(connection, encodeRemoteRuntimeSessionEventsFrame({
|
|
982
|
+
type: "session.events.closed",
|
|
983
|
+
subscriptionId,
|
|
984
|
+
reason,
|
|
985
|
+
...(message === undefined ? {} : { message }),
|
|
986
|
+
}), limits.maxBufferedBytes);
|
|
987
|
+
}
|
|
988
|
+
async function handleTerminalFrame(connection, frame) {
|
|
989
|
+
const peer = connection.peer;
|
|
990
|
+
if (!peer) {
|
|
991
|
+
connection.close(CLOSE_BAD_PROTOCOL, "Direct Runtime peer identity is unavailable");
|
|
992
|
+
return;
|
|
993
|
+
}
|
|
994
|
+
if (frame.type === "session.terminal.close") {
|
|
995
|
+
const pending = connection.pendingTerminal;
|
|
996
|
+
if (pending?.attachmentId === frame.attachmentId) {
|
|
997
|
+
pending.cancelRequested = true;
|
|
998
|
+
if (!pending.abortController.signal.aborted) {
|
|
999
|
+
pending.abortController.abort(new Error("Remote Session terminal open cancelled"));
|
|
1000
|
+
}
|
|
1001
|
+
if (!pending.closeFrameClaimed && !isConnectionClosed(connection)) {
|
|
1002
|
+
pending.closeFrameClaimed = true;
|
|
1003
|
+
await sendTerminalClosed(connection, frame.attachmentId, "client_closed");
|
|
1004
|
+
}
|
|
1005
|
+
return;
|
|
1006
|
+
}
|
|
1007
|
+
const terminal = connection.terminal;
|
|
1008
|
+
if (terminal?.attachmentId === frame.attachmentId) {
|
|
1009
|
+
finishTerminal(connection, terminal, "client_closed", undefined, true);
|
|
1010
|
+
}
|
|
1011
|
+
else if (!connection.seenTerminalAttachmentIds.has(frame.attachmentId)) {
|
|
1012
|
+
connection.close(CLOSE_BAD_PROTOCOL, "Remote Session terminal attachment id is unknown");
|
|
1013
|
+
}
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (frame.type === "session.terminal.input" || frame.type === "session.terminal.resize") {
|
|
1017
|
+
const terminal = connection.terminal;
|
|
1018
|
+
if (!terminal || terminal.attachmentId !== frame.attachmentId || terminal.finishing) {
|
|
1019
|
+
connection.close(CLOSE_BAD_PROTOCOL, "Remote Session terminal attachment is not active");
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
const control = terminal.controlTail.then(async () => {
|
|
1023
|
+
if (terminal.finishing)
|
|
1024
|
+
return;
|
|
1025
|
+
let active = false;
|
|
1026
|
+
try {
|
|
1027
|
+
active = await options.isPeerActive(peer);
|
|
1028
|
+
}
|
|
1029
|
+
catch (error) {
|
|
1030
|
+
options.onError?.(error, "Direct Runtime terminal peer activity check");
|
|
1031
|
+
}
|
|
1032
|
+
if (!active) {
|
|
1033
|
+
finishTerminal(connection, terminal, "unauthorized", undefined, true);
|
|
1034
|
+
connection.close(CLOSE_UNAUTHORIZED, "Remote Runtime grant is not active");
|
|
1035
|
+
return;
|
|
1036
|
+
}
|
|
1037
|
+
try {
|
|
1038
|
+
if (frame.type === "session.terminal.input") {
|
|
1039
|
+
if (terminal.attachment.role === "owner") {
|
|
1040
|
+
await terminal.attachment.write(Uint8Array.from(Buffer.from(frame.data, "base64")));
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
else {
|
|
1044
|
+
await terminal.attachment.resize(frame.cols, frame.rows);
|
|
1045
|
+
}
|
|
1046
|
+
}
|
|
1047
|
+
catch (error) {
|
|
1048
|
+
options.onError?.(error, "Remote Session terminal control");
|
|
1049
|
+
finishTerminal(connection, terminal, "internal", "Remote Session terminal control failed", true);
|
|
1050
|
+
}
|
|
1051
|
+
});
|
|
1052
|
+
terminal.controlTail = control.catch(() => undefined);
|
|
1053
|
+
await control;
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
let admissionFailure;
|
|
1057
|
+
let pending;
|
|
1058
|
+
if (connection.seenTerminalAttachmentIds.has(frame.attachmentId)) {
|
|
1059
|
+
admissionFailure = "duplicate";
|
|
1060
|
+
}
|
|
1061
|
+
else if (connection.seenTerminalAttachmentIds.size >= limits.maxRequestIds) {
|
|
1062
|
+
admissionFailure = "id_capacity";
|
|
1063
|
+
}
|
|
1064
|
+
else {
|
|
1065
|
+
connection.seenTerminalAttachmentIds.add(frame.attachmentId);
|
|
1066
|
+
if (connection.pendingTerminal || connection.terminal) {
|
|
1067
|
+
admissionFailure = "attachment_capacity";
|
|
1068
|
+
}
|
|
1069
|
+
else {
|
|
1070
|
+
pending = {
|
|
1071
|
+
attachmentId: frame.attachmentId,
|
|
1072
|
+
sessionId: frame.sessionId,
|
|
1073
|
+
abortController: new AbortController(),
|
|
1074
|
+
cancelRequested: false,
|
|
1075
|
+
closeFrameClaimed: false,
|
|
1076
|
+
};
|
|
1077
|
+
// Reserve the id before awaiting peer status or application open. A
|
|
1078
|
+
// following close can now tombstone this pending attach and abort it.
|
|
1079
|
+
connection.pendingTerminal = pending;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
let active = false;
|
|
1083
|
+
try {
|
|
1084
|
+
active = await options.isPeerActive(peer);
|
|
1085
|
+
}
|
|
1086
|
+
catch (error) {
|
|
1087
|
+
options.onError?.(error, "Direct Runtime terminal peer activity check");
|
|
1088
|
+
}
|
|
1089
|
+
if (!active) {
|
|
1090
|
+
if (pending) {
|
|
1091
|
+
connection.pendingTerminal = undefined;
|
|
1092
|
+
pending.cancelRequested = true;
|
|
1093
|
+
pending.abortController.abort(new Error("Remote Runtime grant is not active"));
|
|
1094
|
+
}
|
|
1095
|
+
if (!pending?.closeFrameClaimed) {
|
|
1096
|
+
if (pending)
|
|
1097
|
+
pending.closeFrameClaimed = true;
|
|
1098
|
+
await sendTerminalClosed(connection, frame.attachmentId, "unauthorized");
|
|
1099
|
+
}
|
|
1100
|
+
connection.close(CLOSE_UNAUTHORIZED, "Remote Runtime grant is not active");
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
if (admissionFailure === "duplicate") {
|
|
1104
|
+
await sendTerminalClosed(connection, frame.attachmentId, "capacity", "Remote Session terminal attachment id was already used");
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
if (admissionFailure === "id_capacity") {
|
|
1108
|
+
connection.close(CLOSE_CAPACITY, "Direct Runtime terminal id capacity reached");
|
|
1109
|
+
return;
|
|
1110
|
+
}
|
|
1111
|
+
if (admissionFailure === "attachment_capacity") {
|
|
1112
|
+
await sendTerminalClosed(connection, frame.attachmentId, "capacity", "Direct Runtime V1 admits one Session terminal per connection");
|
|
1113
|
+
return;
|
|
1114
|
+
}
|
|
1115
|
+
if (!pending) {
|
|
1116
|
+
connection.close(CLOSE_INTERNAL, "Remote Session pending terminal is unavailable");
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
let opened;
|
|
1120
|
+
try {
|
|
1121
|
+
opened = await options.dispatcher.openSessionTerminal(frame.sessionId, { role: frame.role, cols: frame.cols, rows: frame.rows }, { subject: peer.subject, signal: pending.abortController.signal });
|
|
1122
|
+
}
|
|
1123
|
+
catch (error) {
|
|
1124
|
+
if (connection.pendingTerminal === pending)
|
|
1125
|
+
connection.pendingTerminal = undefined;
|
|
1126
|
+
if (pending.cancelRequested) {
|
|
1127
|
+
if (!pending.closeFrameClaimed && !isConnectionClosed(connection)) {
|
|
1128
|
+
pending.closeFrameClaimed = true;
|
|
1129
|
+
await sendTerminalClosed(connection, frame.attachmentId, "client_closed");
|
|
1130
|
+
}
|
|
1131
|
+
return;
|
|
1132
|
+
}
|
|
1133
|
+
options.onError?.(error, "Remote Session terminal open");
|
|
1134
|
+
await sendTerminalClosed(connection, frame.attachmentId, remoteTerminalOpenCloseReason(error));
|
|
1135
|
+
return;
|
|
1136
|
+
}
|
|
1137
|
+
if (pending.cancelRequested ||
|
|
1138
|
+
isConnectionClosed(connection) ||
|
|
1139
|
+
connection.pendingTerminal !== pending) {
|
|
1140
|
+
if (connection.pendingTerminal === pending)
|
|
1141
|
+
connection.pendingTerminal = undefined;
|
|
1142
|
+
await opened.close().catch((error) => {
|
|
1143
|
+
options.onError?.(error, "Remote Session late terminal detach");
|
|
1144
|
+
});
|
|
1145
|
+
if (!pending.closeFrameClaimed && !isConnectionClosed(connection)) {
|
|
1146
|
+
pending.closeFrameClaimed = true;
|
|
1147
|
+
await sendTerminalClosed(connection, frame.attachmentId, "client_closed");
|
|
1148
|
+
}
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
if (opened.sessionId !== frame.sessionId) {
|
|
1152
|
+
connection.pendingTerminal = undefined;
|
|
1153
|
+
await opened.close().catch(() => undefined);
|
|
1154
|
+
connection.close(CLOSE_INTERNAL, "Remote Session terminal identity mismatch");
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
const terminal = {
|
|
1158
|
+
attachmentId: frame.attachmentId,
|
|
1159
|
+
sessionId: frame.sessionId,
|
|
1160
|
+
attachment: opened,
|
|
1161
|
+
output: new BoundedTextQueue(limits.maxQueuedTerminalOutputFrames, limits.maxQueuedTerminalOutputBytes),
|
|
1162
|
+
controlTail: Promise.resolve(),
|
|
1163
|
+
finishing: false,
|
|
1164
|
+
};
|
|
1165
|
+
connection.pendingTerminal = undefined;
|
|
1166
|
+
connection.terminal = terminal;
|
|
1167
|
+
// Drain into the bounded application queue before awaiting the opened send;
|
|
1168
|
+
// output can begin synchronously with the runner attach.
|
|
1169
|
+
void readTerminalOutput(connection, terminal);
|
|
1170
|
+
try {
|
|
1171
|
+
await sendText(connection, encodeRemoteRuntimeSessionTerminalServerFrame({
|
|
1172
|
+
type: "session.terminal.opened",
|
|
1173
|
+
attachmentId: terminal.attachmentId,
|
|
1174
|
+
sessionId: terminal.sessionId,
|
|
1175
|
+
role: opened.role,
|
|
1176
|
+
}), limits.maxBufferedBytes);
|
|
1177
|
+
void writeTerminalOutput(connection, terminal);
|
|
1178
|
+
}
|
|
1179
|
+
catch (error) {
|
|
1180
|
+
finishTerminal(connection, terminal, undefined, undefined, true);
|
|
1181
|
+
throw error;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
async function readTerminalOutput(connection, terminal) {
|
|
1185
|
+
try {
|
|
1186
|
+
for await (const data of terminal.attachment) {
|
|
1187
|
+
if (terminal.finishing)
|
|
1188
|
+
return;
|
|
1189
|
+
for (let offset = 0; offset < data.byteLength; offset += REMOTE_RUNTIME_SESSION_TERMINAL_MAX_DATA_BYTES) {
|
|
1190
|
+
const chunk = data.subarray(offset, Math.min(offset + REMOTE_RUNTIME_SESSION_TERMINAL_MAX_DATA_BYTES, data.byteLength));
|
|
1191
|
+
if (chunk.byteLength === 0)
|
|
1192
|
+
continue;
|
|
1193
|
+
const encoded = encodeRemoteRuntimeSessionTerminalServerFrame({
|
|
1194
|
+
type: "session.terminal.output",
|
|
1195
|
+
attachmentId: terminal.attachmentId,
|
|
1196
|
+
data: Buffer.from(chunk).toString("base64"),
|
|
1197
|
+
});
|
|
1198
|
+
if (!terminal.output.push(encoded)) {
|
|
1199
|
+
finishTerminal(connection, terminal, "backpressure", "Remote Session terminal consumer is too slow", true);
|
|
1200
|
+
return;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
if (terminal.finishing)
|
|
1205
|
+
return;
|
|
1206
|
+
const closed = await terminal.attachment.closed;
|
|
1207
|
+
const reason = closed.reason === "terminal_exited"
|
|
1208
|
+
? "terminal_exited"
|
|
1209
|
+
: closed.reason === "backpressure"
|
|
1210
|
+
? "backpressure"
|
|
1211
|
+
: "internal";
|
|
1212
|
+
finishTerminal(connection, terminal, reason, undefined, reason !== "terminal_exited", closed.exitCode);
|
|
1213
|
+
}
|
|
1214
|
+
catch (error) {
|
|
1215
|
+
if (terminal.finishing)
|
|
1216
|
+
return;
|
|
1217
|
+
options.onError?.(error, "Remote Session terminal output");
|
|
1218
|
+
finishTerminal(connection, terminal, "internal", "Remote Session terminal output failed", true);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
async function writeTerminalOutput(connection, terminal) {
|
|
1222
|
+
try {
|
|
1223
|
+
for (;;) {
|
|
1224
|
+
const frame = await terminal.output.shift();
|
|
1225
|
+
if (frame === undefined)
|
|
1226
|
+
return;
|
|
1227
|
+
await sendText(connection, frame, limits.maxBufferedBytes);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
catch (error) {
|
|
1231
|
+
if (!isConnectionClosed(connection)) {
|
|
1232
|
+
options.onError?.(error, "Remote Session terminal delivery");
|
|
1233
|
+
connection.close(CLOSE_INTERNAL, "Remote Session terminal delivery failed");
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
finally {
|
|
1237
|
+
finishTerminal(connection, terminal, undefined, undefined, true);
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
function finishTerminal(connection, terminal, reason, message, discardPending, exitCode) {
|
|
1241
|
+
if (terminal.finishing)
|
|
1242
|
+
return;
|
|
1243
|
+
terminal.finishing = true;
|
|
1244
|
+
if (connection.terminal === terminal)
|
|
1245
|
+
connection.terminal = undefined;
|
|
1246
|
+
void terminal.attachment.close().catch((error) => {
|
|
1247
|
+
options.onError?.(error, "Remote Session terminal detach");
|
|
1248
|
+
});
|
|
1249
|
+
const finalFrame = reason === undefined
|
|
1250
|
+
? undefined
|
|
1251
|
+
: encodeRemoteRuntimeSessionTerminalServerFrame({
|
|
1252
|
+
type: "session.terminal.closed",
|
|
1253
|
+
attachmentId: terminal.attachmentId,
|
|
1254
|
+
reason,
|
|
1255
|
+
...(exitCode === undefined ? {} : { exitCode }),
|
|
1256
|
+
...(message === undefined ? {} : { message }),
|
|
1257
|
+
});
|
|
1258
|
+
if (discardPending)
|
|
1259
|
+
terminal.output.replaceAndFinish(finalFrame);
|
|
1260
|
+
else
|
|
1261
|
+
terminal.output.finish(finalFrame);
|
|
1262
|
+
}
|
|
1263
|
+
async function sendTerminalClosed(connection, attachmentId, reason, message) {
|
|
1264
|
+
await sendText(connection, encodeRemoteRuntimeSessionTerminalServerFrame({
|
|
1265
|
+
type: "session.terminal.closed",
|
|
1266
|
+
attachmentId,
|
|
1267
|
+
reason,
|
|
1268
|
+
...(message === undefined ? {} : { message }),
|
|
1269
|
+
}), limits.maxBufferedBytes);
|
|
1270
|
+
}
|
|
1271
|
+
async function authenticateConnection(connection, bytes) {
|
|
1272
|
+
if (!authenticationAttempts.tryConsume(connection.sourceAddress)) {
|
|
1273
|
+
connection.close(CLOSE_CAPACITY, "Direct Runtime authentication attempt limit reached");
|
|
1274
|
+
return;
|
|
1275
|
+
}
|
|
1276
|
+
let authenticate;
|
|
1277
|
+
try {
|
|
1278
|
+
const frame = decodeDirectRuntimeFrame(bytes);
|
|
1279
|
+
if (frame.type !== "client.authenticate") {
|
|
1280
|
+
throw new Error("first client frame must be client.authenticate");
|
|
1281
|
+
}
|
|
1282
|
+
authenticate = frame;
|
|
1283
|
+
}
|
|
1284
|
+
catch {
|
|
1285
|
+
connection.close(CLOSE_BAD_PROTOCOL, "invalid Direct Runtime authentication frame");
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
const serverHello = connection.serverHello;
|
|
1289
|
+
const serverHelloBytes = connection.serverHelloBytes;
|
|
1290
|
+
if (!serverHello || !serverHelloBytes) {
|
|
1291
|
+
connection.close(CLOSE_INTERNAL, "Direct Runtime server hello is unavailable");
|
|
1292
|
+
return;
|
|
1293
|
+
}
|
|
1294
|
+
connection.state = "authenticating";
|
|
1295
|
+
try {
|
|
1296
|
+
const channelBinding = connection.e2eeChannel?.channelBinding;
|
|
1297
|
+
if (!channelBinding)
|
|
1298
|
+
throw new Error("Direct Runtime E2EE channel is unavailable");
|
|
1299
|
+
const result = await options.authenticator.authenticate({
|
|
1300
|
+
channelBinding: new Uint8Array(channelBinding),
|
|
1301
|
+
role: "control",
|
|
1302
|
+
serverHello,
|
|
1303
|
+
serverHelloBytes: new Uint8Array(serverHelloBytes),
|
|
1304
|
+
clientAuthenticate: authenticate,
|
|
1305
|
+
clientAuthenticateBytes: new Uint8Array(bytes),
|
|
1306
|
+
signal: connection.abortController.signal,
|
|
1307
|
+
});
|
|
1308
|
+
if (connection.state !== "authenticating")
|
|
1309
|
+
return;
|
|
1310
|
+
const accepted = parseServerAccepted(result.accepted);
|
|
1311
|
+
assertAuthenticationResult(serverHello, authenticate, result.peer, accepted);
|
|
1312
|
+
connection.peer = result.peer;
|
|
1313
|
+
const registration = connectionHub.registerWithinLimit(result.peer.grantId, connection, limits.maxConnectionsPerGrant);
|
|
1314
|
+
if (registration === "revoked") {
|
|
1315
|
+
connection.close(CLOSE_UNAUTHORIZED, "Remote Runtime grant is not active");
|
|
1316
|
+
return;
|
|
1317
|
+
}
|
|
1318
|
+
if (registration === "capacity") {
|
|
1319
|
+
connection.close(CLOSE_CAPACITY, "Remote Runtime grant connection capacity reached");
|
|
1320
|
+
return;
|
|
1321
|
+
}
|
|
1322
|
+
const acceptedSend = sendText(connection, encodeDirectRuntimeFrame(accepted), limits.maxBufferedBytes);
|
|
1323
|
+
// ws.send queues the accepted frame synchronously. Publish the authenticated
|
|
1324
|
+
// state before its callback: a client may receive the frame and issue its
|
|
1325
|
+
// first RPC before that local callback runs.
|
|
1326
|
+
if (connection.state === "authenticating") {
|
|
1327
|
+
connection.state = "authenticated";
|
|
1328
|
+
releaseUnauthenticatedConnection(connection);
|
|
1329
|
+
clearTimeout(connection.authenticationTimer);
|
|
1330
|
+
}
|
|
1331
|
+
await acceptedSend;
|
|
1332
|
+
}
|
|
1333
|
+
catch (error) {
|
|
1334
|
+
if (isConnectionClosed(connection))
|
|
1335
|
+
return;
|
|
1336
|
+
options.onError?.(error, "client authentication");
|
|
1337
|
+
connection.close(CLOSE_UNAUTHORIZED, "Direct Runtime authentication failed");
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
async function sendRpcError(connection, error) {
|
|
1341
|
+
await sendText(connection, encodeRemoteRuntimeRpcResponse(error), limits.maxBufferedBytes);
|
|
1342
|
+
}
|
|
1343
|
+
function closeConnection(connection, code, reason) {
|
|
1344
|
+
if (connection.state === "closing" || connection.state === "closed")
|
|
1345
|
+
return;
|
|
1346
|
+
connection.state = "closing";
|
|
1347
|
+
clearTimeout(connection.authenticationTimer);
|
|
1348
|
+
connection.abortController.abort(new Error(reason));
|
|
1349
|
+
for (const pending of connection.pendingSessionSubscriptions.values()) {
|
|
1350
|
+
abortPendingSessionSubscription(pending);
|
|
1351
|
+
}
|
|
1352
|
+
connection.pendingSessionSubscriptions.clear();
|
|
1353
|
+
for (const subscription of [...connection.sessionSubscriptions.values()]) {
|
|
1354
|
+
finishSessionSubscription(connection, subscription, undefined, undefined, true);
|
|
1355
|
+
}
|
|
1356
|
+
if (connection.pendingTerminal) {
|
|
1357
|
+
connection.pendingTerminal.cancelRequested = true;
|
|
1358
|
+
connection.pendingTerminal.abortController.abort(new Error(reason));
|
|
1359
|
+
connection.pendingTerminal = undefined;
|
|
1360
|
+
}
|
|
1361
|
+
if (connection.terminal) {
|
|
1362
|
+
finishTerminal(connection, connection.terminal, undefined, undefined, true);
|
|
1363
|
+
}
|
|
1364
|
+
finalizeConnectionEmulatorSurface(connection, reason);
|
|
1365
|
+
if (connection.ws.readyState === WebSocket.OPEN) {
|
|
1366
|
+
connection.ws.close(code, boundedCloseReason(reason));
|
|
1367
|
+
connection.closeTimer = setTimeout(() => {
|
|
1368
|
+
if (connection.ws.readyState !== WebSocket.CLOSED)
|
|
1369
|
+
connection.ws.terminate();
|
|
1370
|
+
}, CONNECTION_CLOSE_DEADLINE_MS);
|
|
1371
|
+
connection.closeTimer.unref?.();
|
|
1372
|
+
}
|
|
1373
|
+
else if (connection.ws.readyState !== WebSocket.CLOSED) {
|
|
1374
|
+
connection.ws.terminate();
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
function cleanupConnection(connection) {
|
|
1378
|
+
if (connection.cleaned)
|
|
1379
|
+
return;
|
|
1380
|
+
connection.cleaned = true;
|
|
1381
|
+
connection.state = "closed";
|
|
1382
|
+
clearTimeout(connection.authenticationTimer);
|
|
1383
|
+
if (connection.closeTimer)
|
|
1384
|
+
clearTimeout(connection.closeTimer);
|
|
1385
|
+
connection.abortController.abort(new Error("Direct Runtime connection closed"));
|
|
1386
|
+
for (const pending of connection.pendingSessionSubscriptions.values()) {
|
|
1387
|
+
abortPendingSessionSubscription(pending);
|
|
1388
|
+
}
|
|
1389
|
+
connection.pendingSessionSubscriptions.clear();
|
|
1390
|
+
for (const subscription of [...connection.sessionSubscriptions.values()]) {
|
|
1391
|
+
finishSessionSubscription(connection, subscription, undefined, undefined, true);
|
|
1392
|
+
}
|
|
1393
|
+
if (connection.pendingTerminal) {
|
|
1394
|
+
connection.pendingTerminal.cancelRequested = true;
|
|
1395
|
+
connection.pendingTerminal.abortController.abort(new Error("Direct Runtime connection closed"));
|
|
1396
|
+
connection.pendingTerminal = undefined;
|
|
1397
|
+
}
|
|
1398
|
+
if (connection.terminal) {
|
|
1399
|
+
finishTerminal(connection, connection.terminal, undefined, undefined, true);
|
|
1400
|
+
}
|
|
1401
|
+
finalizeConnectionEmulatorSurface(connection, "Direct Runtime connection closed");
|
|
1402
|
+
releaseUnauthenticatedConnection(connection);
|
|
1403
|
+
connectionHub.unregister(connection);
|
|
1404
|
+
allConnections.delete(connection);
|
|
1405
|
+
if (allConnections.size === 0) {
|
|
1406
|
+
for (const resolve of emptyWaiters)
|
|
1407
|
+
resolve();
|
|
1408
|
+
emptyWaiters.clear();
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
function finalizeConnectionEmulatorSurface(connection, reason) {
|
|
1412
|
+
if (connection.pendingEmulatorSurface) {
|
|
1413
|
+
connection.pendingEmulatorSurface.closeRequested = true;
|
|
1414
|
+
connection.pendingEmulatorSurface.abortController.abort(new Error(reason));
|
|
1415
|
+
connection.pendingEmulatorSurface = undefined;
|
|
1416
|
+
}
|
|
1417
|
+
if (connection.emulatorSurface) {
|
|
1418
|
+
void finishEmulatorSurface(connection, connection.emulatorSurface);
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
function releaseUnauthenticatedConnection(connection) {
|
|
1422
|
+
if (!connection.countedAsUnauthenticated)
|
|
1423
|
+
return;
|
|
1424
|
+
connection.countedAsUnauthenticated = false;
|
|
1425
|
+
decrementCount(unauthenticatedConnectionsBySource, connection.sourceAddress);
|
|
1426
|
+
}
|
|
1427
|
+
await listen(server, options.port, host);
|
|
1428
|
+
heartbeatTimer = setInterval(() => {
|
|
1429
|
+
for (const connection of allConnections) {
|
|
1430
|
+
const ws = connection.ws;
|
|
1431
|
+
if (ws.readyState !== WebSocket.OPEN)
|
|
1432
|
+
continue;
|
|
1433
|
+
if (!liveWebSockets.has(ws)) {
|
|
1434
|
+
// A graceful close can wait for the operating system's TCP timeout on
|
|
1435
|
+
// a half-open route. Terminate so cleanup and reconnect can run now.
|
|
1436
|
+
ws.terminate();
|
|
1437
|
+
continue;
|
|
1438
|
+
}
|
|
1439
|
+
liveWebSockets.delete(ws);
|
|
1440
|
+
try {
|
|
1441
|
+
ws.ping();
|
|
1442
|
+
}
|
|
1443
|
+
catch {
|
|
1444
|
+
ws.terminate();
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
}, limits.heartbeatIntervalMs);
|
|
1448
|
+
heartbeatTimer.unref?.();
|
|
1449
|
+
const managed = {
|
|
1450
|
+
connectionHub,
|
|
1451
|
+
get listening() {
|
|
1452
|
+
return server.listening;
|
|
1453
|
+
},
|
|
1454
|
+
address: () => server.address(),
|
|
1455
|
+
shutdown() {
|
|
1456
|
+
if (shutdownPromise)
|
|
1457
|
+
return shutdownPromise;
|
|
1458
|
+
stopping = true;
|
|
1459
|
+
shutdownPromise = shutdownDirectServer();
|
|
1460
|
+
return shutdownPromise;
|
|
1461
|
+
},
|
|
1462
|
+
};
|
|
1463
|
+
return managed;
|
|
1464
|
+
async function shutdownDirectServer() {
|
|
1465
|
+
if (heartbeatTimer) {
|
|
1466
|
+
clearInterval(heartbeatTimer);
|
|
1467
|
+
heartbeatTimer = undefined;
|
|
1468
|
+
}
|
|
1469
|
+
const deadline = Date.now() + limits.shutdownDeadlineMs;
|
|
1470
|
+
const browserSurfaceClosed = browserSurfaceServer?.shutdown() ?? Promise.resolve();
|
|
1471
|
+
const httpClosed = closeHttpServer(server);
|
|
1472
|
+
const shutdownReason = new Error("Direct Runtime server is shutting down");
|
|
1473
|
+
for (const controller of activeDispatchControllers)
|
|
1474
|
+
controller.abort(shutdownReason);
|
|
1475
|
+
for (const connection of [...allConnections]) {
|
|
1476
|
+
connection.close(1001, "Direct Runtime server is shutting down");
|
|
1477
|
+
}
|
|
1478
|
+
// `stopping` is set synchronously before this function starts, and frame
|
|
1479
|
+
// handlers recheck it immediately before dispatch. The snapshot therefore
|
|
1480
|
+
// contains every application dispatch that shutdown must drain.
|
|
1481
|
+
const drained = Promise.all([
|
|
1482
|
+
httpClosed,
|
|
1483
|
+
browserSurfaceClosed,
|
|
1484
|
+
waitForConnectionsEmpty(allConnections, emptyWaiters),
|
|
1485
|
+
Promise.allSettled([...activeDispatches]),
|
|
1486
|
+
]);
|
|
1487
|
+
if (!(await settleBefore(drained, deadline))) {
|
|
1488
|
+
for (const connection of [...allConnections])
|
|
1489
|
+
connection.ws.terminate();
|
|
1490
|
+
for (const socket of rawSockets)
|
|
1491
|
+
socket.destroy();
|
|
1492
|
+
server.closeAllConnections?.();
|
|
1493
|
+
await settleBefore(drained, deadline + 250);
|
|
1494
|
+
}
|
|
1495
|
+
await settleBefore(closeWebSocketServer(wss), deadline + 250);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
function parseAuthenticatedClientFrame(bytes) {
|
|
1499
|
+
let value;
|
|
1500
|
+
try {
|
|
1501
|
+
const text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
1502
|
+
value = JSON.parse(text);
|
|
1503
|
+
}
|
|
1504
|
+
catch {
|
|
1505
|
+
throw new Error("Direct Runtime control frame must be valid UTF-8 JSON");
|
|
1506
|
+
}
|
|
1507
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
1508
|
+
throw new Error("Direct Runtime control frame must be an object");
|
|
1509
|
+
}
|
|
1510
|
+
const record = value;
|
|
1511
|
+
const type = record.type;
|
|
1512
|
+
if (type === "rpc.request") {
|
|
1513
|
+
try {
|
|
1514
|
+
const frame = parseRemoteRuntimeRpcFrame(record);
|
|
1515
|
+
if (frame.type !== "rpc.request")
|
|
1516
|
+
throw new Error("client sent an RPC response");
|
|
1517
|
+
return frame;
|
|
1518
|
+
}
|
|
1519
|
+
catch (error) {
|
|
1520
|
+
const unknown = parseUnknownRpcRequestEnvelope(record);
|
|
1521
|
+
if (unknown)
|
|
1522
|
+
return unknown;
|
|
1523
|
+
throw error;
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
if (type === "session.events.subscribe" || type === "session.events.cancel") {
|
|
1527
|
+
const frame = parseRemoteRuntimeSessionEventsFrame(record);
|
|
1528
|
+
if (frame.type !== "session.events.subscribe" && frame.type !== "session.events.cancel") {
|
|
1529
|
+
throw new Error("client sent a Remote Session server frame");
|
|
1530
|
+
}
|
|
1531
|
+
return frame;
|
|
1532
|
+
}
|
|
1533
|
+
if (type === "session.terminal.open" ||
|
|
1534
|
+
type === "session.terminal.input" ||
|
|
1535
|
+
type === "session.terminal.resize" ||
|
|
1536
|
+
type === "session.terminal.close") {
|
|
1537
|
+
return parseRemoteRuntimeSessionTerminalClientFrame(record);
|
|
1538
|
+
}
|
|
1539
|
+
if (type === "emulator.surface.open" ||
|
|
1540
|
+
type === "emulator.surface.frame.ack" ||
|
|
1541
|
+
type === "emulator.surface.touch" ||
|
|
1542
|
+
type === "emulator.surface.close") {
|
|
1543
|
+
return parseRuntimeEmulatorSurfaceClientFrame(record);
|
|
1544
|
+
}
|
|
1545
|
+
throw new Error("unknown Direct Runtime control frame type");
|
|
1546
|
+
}
|
|
1547
|
+
/**
|
|
1548
|
+
* Preserve additive method compatibility without weakening strict parsing for
|
|
1549
|
+
* known methods. Only a bounded, syntactically safe request envelope reaches
|
|
1550
|
+
* method_not_found; malformed ids, method names, or params remain fatal.
|
|
1551
|
+
*/
|
|
1552
|
+
function parseUnknownRpcRequestEnvelope(value) {
|
|
1553
|
+
const id = value.id;
|
|
1554
|
+
const method = value.method;
|
|
1555
|
+
const params = value.params;
|
|
1556
|
+
if (value.type !== "rpc.request" ||
|
|
1557
|
+
Object.keys(value).some((key) => key !== "type" && key !== "id" && key !== "method" && key !== "params") ||
|
|
1558
|
+
typeof id !== "string" ||
|
|
1559
|
+
id.length === 0 ||
|
|
1560
|
+
id.length > REMOTE_RUNTIME_RPC_MAX_ID_CHARS ||
|
|
1561
|
+
!RPC_ENVELOPE_TOKEN_PATTERN.test(id) ||
|
|
1562
|
+
typeof method !== "string" ||
|
|
1563
|
+
method.length === 0 ||
|
|
1564
|
+
method.length > MAX_RPC_METHOD_NAME_CHARS ||
|
|
1565
|
+
!RPC_ENVELOPE_TOKEN_PATTERN.test(method) ||
|
|
1566
|
+
Object.hasOwn(REMOTE_RUNTIME_RPC_METHOD_METADATA, method) ||
|
|
1567
|
+
!params ||
|
|
1568
|
+
typeof params !== "object" ||
|
|
1569
|
+
Array.isArray(params)) {
|
|
1570
|
+
return undefined;
|
|
1571
|
+
}
|
|
1572
|
+
return { type: "rpc.unknown_request", id, method };
|
|
1573
|
+
}
|
|
1574
|
+
function remoteSessionOpenCloseReason(error) {
|
|
1575
|
+
if (!error || typeof error !== "object" || !("code" in error))
|
|
1576
|
+
return "internal";
|
|
1577
|
+
const code = error.code;
|
|
1578
|
+
if (code === "unauthorized")
|
|
1579
|
+
return "unauthorized";
|
|
1580
|
+
if (code === "not_found" || code === "method_not_found")
|
|
1581
|
+
return "not_found";
|
|
1582
|
+
if (code === "capacity")
|
|
1583
|
+
return "capacity";
|
|
1584
|
+
return "internal";
|
|
1585
|
+
}
|
|
1586
|
+
function remoteTerminalOpenCloseReason(error) {
|
|
1587
|
+
if (!error || typeof error !== "object" || !("code" in error))
|
|
1588
|
+
return "internal";
|
|
1589
|
+
const code = error.code;
|
|
1590
|
+
if (code === "unauthorized")
|
|
1591
|
+
return "unauthorized";
|
|
1592
|
+
if (code === "not_live")
|
|
1593
|
+
return "not_live";
|
|
1594
|
+
if (code === "capacity")
|
|
1595
|
+
return "capacity";
|
|
1596
|
+
return "internal";
|
|
1597
|
+
}
|
|
1598
|
+
function emulatorSurfaceCloseReason(error) {
|
|
1599
|
+
if (!error || typeof error !== "object")
|
|
1600
|
+
return "internal";
|
|
1601
|
+
const code = "code" in error ? error.code : undefined;
|
|
1602
|
+
if (code === "binding_mismatch")
|
|
1603
|
+
return "binding_changed";
|
|
1604
|
+
if (code === "emulator_unavailable" || code === "device_not_found")
|
|
1605
|
+
return "device_unavailable";
|
|
1606
|
+
if (code === "unauthorized")
|
|
1607
|
+
return "unauthorized";
|
|
1608
|
+
if (code === "capacity")
|
|
1609
|
+
return "capacity";
|
|
1610
|
+
if (code === "unsupported")
|
|
1611
|
+
return "unsupported";
|
|
1612
|
+
return "internal";
|
|
1613
|
+
}
|
|
1614
|
+
/** One-reader queue with explicit frame and UTF-8 byte bounds. */
|
|
1615
|
+
class BoundedTextQueue {
|
|
1616
|
+
maxItems;
|
|
1617
|
+
maxBytes;
|
|
1618
|
+
items = [];
|
|
1619
|
+
queuedBytes = 0;
|
|
1620
|
+
waiter;
|
|
1621
|
+
finished = false;
|
|
1622
|
+
constructor(maxItems, maxBytes) {
|
|
1623
|
+
this.maxItems = maxItems;
|
|
1624
|
+
this.maxBytes = maxBytes;
|
|
1625
|
+
}
|
|
1626
|
+
push(text) {
|
|
1627
|
+
if (this.finished)
|
|
1628
|
+
return false;
|
|
1629
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
1630
|
+
if (this.waiter) {
|
|
1631
|
+
const resolve = this.waiter;
|
|
1632
|
+
this.waiter = undefined;
|
|
1633
|
+
resolve(text);
|
|
1634
|
+
return true;
|
|
1635
|
+
}
|
|
1636
|
+
if (this.items.length >= this.maxItems || this.queuedBytes + bytes > this.maxBytes) {
|
|
1637
|
+
return false;
|
|
1638
|
+
}
|
|
1639
|
+
this.items.push({ text, bytes });
|
|
1640
|
+
this.queuedBytes += bytes;
|
|
1641
|
+
return true;
|
|
1642
|
+
}
|
|
1643
|
+
shift() {
|
|
1644
|
+
const item = this.items.shift();
|
|
1645
|
+
if (item) {
|
|
1646
|
+
this.queuedBytes -= item.bytes;
|
|
1647
|
+
return Promise.resolve(item.text);
|
|
1648
|
+
}
|
|
1649
|
+
if (this.finished)
|
|
1650
|
+
return Promise.resolve(undefined);
|
|
1651
|
+
return new Promise((resolve) => {
|
|
1652
|
+
this.waiter = resolve;
|
|
1653
|
+
});
|
|
1654
|
+
}
|
|
1655
|
+
finish(finalFrame) {
|
|
1656
|
+
if (this.finished)
|
|
1657
|
+
return;
|
|
1658
|
+
this.finished = true;
|
|
1659
|
+
if (finalFrame !== undefined) {
|
|
1660
|
+
if (this.waiter) {
|
|
1661
|
+
const resolve = this.waiter;
|
|
1662
|
+
this.waiter = undefined;
|
|
1663
|
+
resolve(finalFrame);
|
|
1664
|
+
}
|
|
1665
|
+
else {
|
|
1666
|
+
const bytes = Buffer.byteLength(finalFrame, "utf8");
|
|
1667
|
+
this.items.push({ text: finalFrame, bytes });
|
|
1668
|
+
this.queuedBytes += bytes;
|
|
1669
|
+
}
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
if (this.waiter) {
|
|
1673
|
+
const resolve = this.waiter;
|
|
1674
|
+
this.waiter = undefined;
|
|
1675
|
+
resolve(undefined);
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
replaceAndFinish(finalFrame) {
|
|
1679
|
+
if (this.finished)
|
|
1680
|
+
return;
|
|
1681
|
+
this.items.length = 0;
|
|
1682
|
+
this.queuedBytes = 0;
|
|
1683
|
+
this.finish(finalFrame);
|
|
1684
|
+
}
|
|
1685
|
+
}
|
|
1686
|
+
function assertAuthenticationResult(hello, authenticate, peer, accepted) {
|
|
1687
|
+
if (hello.role !== "control" ||
|
|
1688
|
+
authenticate.role !== "control" ||
|
|
1689
|
+
accepted.role !== "control" ||
|
|
1690
|
+
accepted.daemonId !== hello.daemonId ||
|
|
1691
|
+
accepted.daemonInstanceId !== hello.daemonInstanceId ||
|
|
1692
|
+
authenticate.daemonId !== hello.daemonId ||
|
|
1693
|
+
authenticate.daemonInstanceId !== hello.daemonInstanceId ||
|
|
1694
|
+
authenticate.coreProtocolVersion < hello.minimumCompatibleCoreVersion ||
|
|
1695
|
+
authenticate.coreProtocolVersion > hello.coreProtocolVersion ||
|
|
1696
|
+
accepted.coreProtocolVersion !== authenticate.coreProtocolVersion ||
|
|
1697
|
+
accepted.clientId !== authenticate.clientId ||
|
|
1698
|
+
accepted.clientId !== peer.clientId ||
|
|
1699
|
+
accepted.grantId !== peer.grantId ||
|
|
1700
|
+
peer.subject.subjectId.length === 0 ||
|
|
1701
|
+
accepted.authorities.length !== peer.subject.effectiveAuthorities.length ||
|
|
1702
|
+
accepted.authorities.some((authority, index) => authority !== peer.subject.effectiveAuthorities[index])) {
|
|
1703
|
+
throw new Error("Direct Runtime authenticator returned an inconsistent identity");
|
|
1704
|
+
}
|
|
1705
|
+
}
|
|
1706
|
+
function isConnectionClosed(connection) {
|
|
1707
|
+
return connection.state === "closing" || connection.state === "closed";
|
|
1708
|
+
}
|
|
1709
|
+
async function sendText(connection, text, maxBufferedBytes) {
|
|
1710
|
+
const channel = connection.e2eeChannel;
|
|
1711
|
+
if (!channel) {
|
|
1712
|
+
throw new Error("Direct Runtime E2EE channel is unavailable");
|
|
1713
|
+
}
|
|
1714
|
+
const encrypted = channel.seal(text);
|
|
1715
|
+
await sendWireText(connection, encrypted, maxBufferedBytes);
|
|
1716
|
+
}
|
|
1717
|
+
async function sendPlainText(connection, text, maxBufferedBytes) {
|
|
1718
|
+
await sendWireText(connection, text, maxBufferedBytes);
|
|
1719
|
+
}
|
|
1720
|
+
async function sendWireText(connection, text, maxBufferedBytes) {
|
|
1721
|
+
if (connection.ws.readyState !== WebSocket.OPEN) {
|
|
1722
|
+
throw new Error("Direct Runtime WebSocket is not open");
|
|
1723
|
+
}
|
|
1724
|
+
if (connection.ws.bufferedAmount + Buffer.byteLength(text, "utf8") > maxBufferedBytes) {
|
|
1725
|
+
connection.close(CLOSE_CAPACITY, "Direct Runtime output buffer is full");
|
|
1726
|
+
throw new Error("Direct Runtime output buffer is full");
|
|
1727
|
+
}
|
|
1728
|
+
await new Promise((resolve, reject) => {
|
|
1729
|
+
connection.ws.send(text, { binary: false, compress: false }, (error) => {
|
|
1730
|
+
if (error)
|
|
1731
|
+
reject(error);
|
|
1732
|
+
else
|
|
1733
|
+
resolve();
|
|
1734
|
+
});
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
function rawDataBytes(data) {
|
|
1738
|
+
if (Buffer.isBuffer(data))
|
|
1739
|
+
return Uint8Array.from(data);
|
|
1740
|
+
if (data instanceof ArrayBuffer)
|
|
1741
|
+
return new Uint8Array(data.slice(0));
|
|
1742
|
+
if (Array.isArray(data))
|
|
1743
|
+
return Uint8Array.from(Buffer.concat(data));
|
|
1744
|
+
return Uint8Array.from(Buffer.from(data));
|
|
1745
|
+
}
|
|
1746
|
+
function decodeText(bytes) {
|
|
1747
|
+
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
1748
|
+
}
|
|
1749
|
+
function rejectUpgrade(socket, status, message) {
|
|
1750
|
+
if (socket.destroyed)
|
|
1751
|
+
return;
|
|
1752
|
+
const body = `${message}\n`;
|
|
1753
|
+
socket.end(`HTTP/1.1 ${status} ${httpStatusText(status)}\r\n` +
|
|
1754
|
+
"Connection: close\r\n" +
|
|
1755
|
+
"Content-Type: text/plain; charset=utf-8\r\n" +
|
|
1756
|
+
`Content-Length: ${Buffer.byteLength(body, "utf8")}\r\n` +
|
|
1757
|
+
"Cache-Control: no-store\r\n\r\n" +
|
|
1758
|
+
body);
|
|
1759
|
+
}
|
|
1760
|
+
function httpStatusText(status) {
|
|
1761
|
+
if (status === 400)
|
|
1762
|
+
return "Bad Request";
|
|
1763
|
+
if (status === 404)
|
|
1764
|
+
return "Not Found";
|
|
1765
|
+
return "Service Unavailable";
|
|
1766
|
+
}
|
|
1767
|
+
function boundedCloseReason(reason) {
|
|
1768
|
+
if (Buffer.byteLength(reason, "utf8") <= 123)
|
|
1769
|
+
return reason;
|
|
1770
|
+
let bounded = "";
|
|
1771
|
+
let bytes = 0;
|
|
1772
|
+
for (const character of reason) {
|
|
1773
|
+
const characterBytes = Buffer.byteLength(character, "utf8");
|
|
1774
|
+
if (bytes + characterBytes > 123)
|
|
1775
|
+
break;
|
|
1776
|
+
bounded += character;
|
|
1777
|
+
bytes += characterBytes;
|
|
1778
|
+
}
|
|
1779
|
+
return bounded;
|
|
1780
|
+
}
|
|
1781
|
+
function resolveLimits(input) {
|
|
1782
|
+
const maxConnections = positiveInteger(input?.maxConnections, DEFAULT_MAX_CONNECTIONS);
|
|
1783
|
+
const maxUnauthenticatedConnectionsPerSource = input?.maxUnauthenticatedConnectionsPerSource === undefined
|
|
1784
|
+
? Math.min(DEFAULT_MAX_UNAUTHENTICATED_CONNECTIONS_PER_SOURCE, maxConnections)
|
|
1785
|
+
: positiveInteger(input.maxUnauthenticatedConnectionsPerSource, 1);
|
|
1786
|
+
const maxConnectionsPerGrant = input?.maxConnectionsPerGrant === undefined
|
|
1787
|
+
? Math.min(DEFAULT_MAX_CONNECTIONS_PER_GRANT, maxConnections)
|
|
1788
|
+
: positiveInteger(input.maxConnectionsPerGrant, 1);
|
|
1789
|
+
if (maxUnauthenticatedConnectionsPerSource > maxConnections) {
|
|
1790
|
+
throw new Error("Direct Runtime per-source unauthenticated limit must not exceed maxConnections");
|
|
1791
|
+
}
|
|
1792
|
+
if (maxConnectionsPerGrant > maxConnections) {
|
|
1793
|
+
throw new Error("Direct Runtime per-grant limit must not exceed maxConnections");
|
|
1794
|
+
}
|
|
1795
|
+
return {
|
|
1796
|
+
authenticationTimeoutMs: positiveInteger(input?.authenticationTimeoutMs, DEFAULT_AUTH_TIMEOUT_MS),
|
|
1797
|
+
maxConnections,
|
|
1798
|
+
maxUnauthenticatedConnectionsPerSource,
|
|
1799
|
+
maxAuthenticationAttemptsPerSource: boundedPositiveInteger(input?.maxAuthenticationAttemptsPerSource, DEFAULT_MAX_AUTHENTICATION_ATTEMPTS_PER_SOURCE, 100_000),
|
|
1800
|
+
authenticationAttemptWindowMs: boundedPositiveInteger(input?.authenticationAttemptWindowMs, DEFAULT_AUTHENTICATION_ATTEMPT_WINDOW_MS, 60 * 60_000),
|
|
1801
|
+
maxTrackedAuthenticationSources: boundedPositiveInteger(input?.maxTrackedAuthenticationSources, DEFAULT_MAX_TRACKED_AUTHENTICATION_SOURCES, 65_536),
|
|
1802
|
+
maxConnectionsPerGrant,
|
|
1803
|
+
maxQueuedFrames: positiveInteger(input?.maxQueuedFrames, DEFAULT_MAX_QUEUED_FRAMES),
|
|
1804
|
+
maxRequestIds: positiveInteger(input?.maxRequestIds, DEFAULT_MAX_REQUEST_IDS),
|
|
1805
|
+
maxBufferedBytes: positiveInteger(input?.maxBufferedBytes, DEFAULT_MAX_BUFFERED_BYTES),
|
|
1806
|
+
maxSessionSubscriptions: positiveInteger(input?.maxSessionSubscriptions, DEFAULT_MAX_SESSION_SUBSCRIPTIONS),
|
|
1807
|
+
maxQueuedSessionEvents: positiveInteger(input?.maxQueuedSessionEvents, DEFAULT_MAX_QUEUED_SESSION_EVENTS),
|
|
1808
|
+
maxQueuedSessionEventBytes: positiveInteger(input?.maxQueuedSessionEventBytes, DEFAULT_MAX_QUEUED_SESSION_EVENT_BYTES),
|
|
1809
|
+
maxQueuedTerminalOutputFrames: positiveInteger(input?.maxQueuedTerminalOutputFrames, DEFAULT_MAX_QUEUED_TERMINAL_OUTPUT_FRAMES),
|
|
1810
|
+
maxQueuedTerminalOutputBytes: positiveInteger(input?.maxQueuedTerminalOutputBytes, DEFAULT_MAX_QUEUED_TERMINAL_OUTPUT_BYTES),
|
|
1811
|
+
heartbeatIntervalMs: boundedPositiveInteger(input?.heartbeatIntervalMs, DEFAULT_HEARTBEAT_INTERVAL_MS, 5 * 60_000),
|
|
1812
|
+
shutdownDeadlineMs: positiveInteger(input?.shutdownDeadlineMs, DEFAULT_SHUTDOWN_DEADLINE_MS),
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
function positiveInteger(value, fallback) {
|
|
1816
|
+
if (value === undefined)
|
|
1817
|
+
return fallback;
|
|
1818
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
1819
|
+
throw new Error("Direct Runtime limits must be positive safe integers");
|
|
1820
|
+
}
|
|
1821
|
+
return value;
|
|
1822
|
+
}
|
|
1823
|
+
function boundedPositiveInteger(value, fallback, maximum) {
|
|
1824
|
+
const resolved = positiveInteger(value, fallback);
|
|
1825
|
+
if (resolved > maximum) {
|
|
1826
|
+
throw new Error(`Direct Runtime limit must not exceed ${maximum}`);
|
|
1827
|
+
}
|
|
1828
|
+
return resolved;
|
|
1829
|
+
}
|
|
1830
|
+
/** Fixed-window limiter keyed only by the real TCP peer address. */
|
|
1831
|
+
class SourceAuthenticationAttemptLimiter {
|
|
1832
|
+
options;
|
|
1833
|
+
entries = new Map();
|
|
1834
|
+
constructor(options) {
|
|
1835
|
+
this.options = options;
|
|
1836
|
+
}
|
|
1837
|
+
tryConsume(sourceAddress, now = Date.now()) {
|
|
1838
|
+
const existing = this.entries.get(sourceAddress);
|
|
1839
|
+
if (existing && now - existing.startedAt < this.options.windowMs) {
|
|
1840
|
+
existing.attempts = Math.min(existing.attempts + 1, this.options.maxAttempts + 1);
|
|
1841
|
+
// Refresh insertion order so bounded eviction prefers inactive sources.
|
|
1842
|
+
this.entries.delete(sourceAddress);
|
|
1843
|
+
this.entries.set(sourceAddress, existing);
|
|
1844
|
+
return existing.attempts <= this.options.maxAttempts;
|
|
1845
|
+
}
|
|
1846
|
+
if (existing)
|
|
1847
|
+
this.entries.delete(sourceAddress);
|
|
1848
|
+
this.makeSpace(now);
|
|
1849
|
+
this.entries.set(sourceAddress, { startedAt: now, attempts: 1 });
|
|
1850
|
+
return true;
|
|
1851
|
+
}
|
|
1852
|
+
makeSpace(now) {
|
|
1853
|
+
if (this.entries.size < this.options.maxSources)
|
|
1854
|
+
return;
|
|
1855
|
+
for (const [sourceAddress, entry] of this.entries) {
|
|
1856
|
+
if (now - entry.startedAt >= this.options.windowMs) {
|
|
1857
|
+
this.entries.delete(sourceAddress);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
while (this.entries.size >= this.options.maxSources) {
|
|
1861
|
+
const oldest = this.entries.keys().next().value;
|
|
1862
|
+
if (oldest === undefined)
|
|
1863
|
+
return;
|
|
1864
|
+
this.entries.delete(oldest);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
function socketSourceAddress(request) {
|
|
1869
|
+
const address = request.socket.remoteAddress;
|
|
1870
|
+
if (!address)
|
|
1871
|
+
return "unknown";
|
|
1872
|
+
return address.startsWith("::ffff:") ? address.slice("::ffff:".length) : address;
|
|
1873
|
+
}
|
|
1874
|
+
function incrementCount(counts, key) {
|
|
1875
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
1876
|
+
}
|
|
1877
|
+
function decrementCount(counts, key) {
|
|
1878
|
+
const current = counts.get(key);
|
|
1879
|
+
if (current === undefined || current <= 1)
|
|
1880
|
+
counts.delete(key);
|
|
1881
|
+
else
|
|
1882
|
+
counts.set(key, current - 1);
|
|
1883
|
+
}
|
|
1884
|
+
function listen(server, port, host) {
|
|
1885
|
+
return new Promise((resolve, reject) => {
|
|
1886
|
+
const onError = (error) => reject(error);
|
|
1887
|
+
server.once("error", onError);
|
|
1888
|
+
server.listen(port, host, () => {
|
|
1889
|
+
server.removeListener("error", onError);
|
|
1890
|
+
resolve();
|
|
1891
|
+
});
|
|
1892
|
+
});
|
|
1893
|
+
}
|
|
1894
|
+
function closeHttpServer(server) {
|
|
1895
|
+
return new Promise((resolve, reject) => {
|
|
1896
|
+
server.close((error) => {
|
|
1897
|
+
if (error && error.code !== "ERR_SERVER_NOT_RUNNING")
|
|
1898
|
+
reject(error);
|
|
1899
|
+
else
|
|
1900
|
+
resolve();
|
|
1901
|
+
});
|
|
1902
|
+
});
|
|
1903
|
+
}
|
|
1904
|
+
function closeWebSocketServer(server) {
|
|
1905
|
+
return new Promise((resolve) => {
|
|
1906
|
+
try {
|
|
1907
|
+
server.close(() => resolve());
|
|
1908
|
+
}
|
|
1909
|
+
catch {
|
|
1910
|
+
resolve();
|
|
1911
|
+
}
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
function waitForConnectionsEmpty(connections, waiters) {
|
|
1915
|
+
if (connections.size === 0)
|
|
1916
|
+
return Promise.resolve();
|
|
1917
|
+
return new Promise((resolve) => waiters.add(resolve));
|
|
1918
|
+
}
|
|
1919
|
+
async function settleBefore(promise, deadline) {
|
|
1920
|
+
const remaining = Math.max(0, deadline - Date.now());
|
|
1921
|
+
let timer;
|
|
1922
|
+
const timedOut = new Promise((resolve) => {
|
|
1923
|
+
timer = setTimeout(() => resolve(false), remaining);
|
|
1924
|
+
timer.unref?.();
|
|
1925
|
+
});
|
|
1926
|
+
const settled = promise.then(() => true, () => true);
|
|
1927
|
+
const result = await Promise.race([settled, timedOut]);
|
|
1928
|
+
if (timer)
|
|
1929
|
+
clearTimeout(timer);
|
|
1930
|
+
return result;
|
|
1931
|
+
}
|