@rivetkit/engine-runner 25.7.1-rc.1
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/.turbo/turbo-build.log +23 -0
- package/.turbo/turbo-check-types.log +5 -0
- package/.turbo/turbo-test.log +5537 -0
- package/benches/actor-lifecycle.bench.ts +190 -0
- package/benches/utils.ts +143 -0
- package/dist/mod.cjs +2044 -0
- package/dist/mod.cjs.map +1 -0
- package/dist/mod.d.cts +67 -0
- package/dist/mod.d.ts +67 -0
- package/dist/mod.js +2044 -0
- package/dist/mod.js.map +1 -0
- package/package.json +38 -0
- package/src/log.ts +11 -0
- package/src/mod.ts +1354 -0
- package/src/tunnel.ts +841 -0
- package/src/utils.ts +31 -0
- package/src/websocket-tunnel-adapter.ts +486 -0
- package/src/websocket.ts +43 -0
- package/tests/lifecycle.test.ts +596 -0
- package/tsconfig.json +13 -0
- package/tsup.config.ts +4 -0
- package/turbo.json +4 -0
- package/vitest.config.ts +17 -0
package/src/tunnel.ts
ADDED
|
@@ -0,0 +1,841 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import * as tunnel from "@rivetkit/engine-tunnel-protocol";
|
|
3
|
+
import { WebSocketTunnelAdapter } from "./websocket-tunnel-adapter";
|
|
4
|
+
import { calculateBackoff } from "./utils";
|
|
5
|
+
import type { Runner, ActorInstance } from "./mod";
|
|
6
|
+
import { v4 as uuidv4 } from "uuid";
|
|
7
|
+
import { logger } from "./log";
|
|
8
|
+
|
|
9
|
+
const GC_INTERVAL = 60000; // 60 seconds
|
|
10
|
+
const MESSAGE_ACK_TIMEOUT = 5000; // 5 seconds
|
|
11
|
+
|
|
12
|
+
interface PendingRequest {
|
|
13
|
+
resolve: (response: Response) => void;
|
|
14
|
+
reject: (error: Error) => void;
|
|
15
|
+
streamController?: ReadableStreamDefaultController<Uint8Array>;
|
|
16
|
+
actorId?: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface TunnelCallbacks {
|
|
20
|
+
onConnected(): void;
|
|
21
|
+
onDisconnected(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PendingMessage {
|
|
25
|
+
sentAt: number;
|
|
26
|
+
requestIdStr: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class Tunnel {
|
|
30
|
+
#pegboardTunnelUrl: string;
|
|
31
|
+
|
|
32
|
+
#runner: Runner;
|
|
33
|
+
|
|
34
|
+
#tunnelWs?: WebSocket;
|
|
35
|
+
#shutdown = false;
|
|
36
|
+
#reconnectTimeout?: NodeJS.Timeout;
|
|
37
|
+
#reconnectAttempt = 0;
|
|
38
|
+
|
|
39
|
+
#actorPendingRequests: Map<string, PendingRequest> = new Map();
|
|
40
|
+
#actorWebSockets: Map<string, WebSocketTunnelAdapter> = new Map();
|
|
41
|
+
|
|
42
|
+
#pendingMessages: Map<string, PendingMessage> = new Map();
|
|
43
|
+
#gcInterval?: NodeJS.Timeout;
|
|
44
|
+
|
|
45
|
+
#callbacks: TunnelCallbacks;
|
|
46
|
+
|
|
47
|
+
constructor(
|
|
48
|
+
runner: Runner,
|
|
49
|
+
pegboardTunnelUrl: string,
|
|
50
|
+
callbacks: TunnelCallbacks,
|
|
51
|
+
) {
|
|
52
|
+
this.#pegboardTunnelUrl = pegboardTunnelUrl;
|
|
53
|
+
this.#runner = runner;
|
|
54
|
+
this.#callbacks = callbacks;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
start(): void {
|
|
58
|
+
if (this.#tunnelWs?.readyState === WebSocket.OPEN) {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
this.#connect();
|
|
63
|
+
this.#startGarbageCollector();
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
shutdown() {
|
|
67
|
+
this.#shutdown = true;
|
|
68
|
+
|
|
69
|
+
if (this.#reconnectTimeout) {
|
|
70
|
+
clearTimeout(this.#reconnectTimeout);
|
|
71
|
+
this.#reconnectTimeout = undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (this.#gcInterval) {
|
|
75
|
+
clearInterval(this.#gcInterval);
|
|
76
|
+
this.#gcInterval = undefined;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (this.#tunnelWs) {
|
|
80
|
+
this.#tunnelWs.close();
|
|
81
|
+
this.#tunnelWs = undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// TODO: Should we use unregisterActor instead
|
|
85
|
+
|
|
86
|
+
// Reject all pending requests
|
|
87
|
+
for (const [_, request] of this.#actorPendingRequests) {
|
|
88
|
+
request.reject(new Error("Tunnel shutting down"));
|
|
89
|
+
}
|
|
90
|
+
this.#actorPendingRequests.clear();
|
|
91
|
+
|
|
92
|
+
// Close all WebSockets
|
|
93
|
+
for (const [_, ws] of this.#actorWebSockets) {
|
|
94
|
+
ws.close();
|
|
95
|
+
}
|
|
96
|
+
this.#actorWebSockets.clear();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
#sendMessage(requestId: tunnel.RequestId, messageKind: tunnel.MessageKind) {
|
|
100
|
+
if (!this.#tunnelWs || this.#tunnelWs.readyState !== WebSocket.OPEN) {
|
|
101
|
+
console.warn("Cannot send tunnel message, WebSocket not connected");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Build message
|
|
106
|
+
const messageId = generateUuidBuffer();
|
|
107
|
+
|
|
108
|
+
const requestIdStr = bufferToString(requestId);
|
|
109
|
+
this.#pendingMessages.set(bufferToString(messageId), {
|
|
110
|
+
sentAt: Date.now(),
|
|
111
|
+
requestIdStr,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// Send message
|
|
115
|
+
const message: tunnel.RunnerMessage = {
|
|
116
|
+
requestId,
|
|
117
|
+
messageId,
|
|
118
|
+
messageKind,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const encoded = tunnel.encodeRunnerMessage(message);
|
|
122
|
+
this.#tunnelWs.send(encoded);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#sendAck(requestId: tunnel.RequestId, messageId: tunnel.MessageId) {
|
|
126
|
+
if (!this.#tunnelWs || this.#tunnelWs.readyState !== WebSocket.OPEN) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const message: tunnel.RunnerMessage = {
|
|
131
|
+
requestId,
|
|
132
|
+
messageId,
|
|
133
|
+
messageKind: { tag: "Ack", val: null },
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const encoded = tunnel.encodeRunnerMessage(message);
|
|
137
|
+
this.#tunnelWs.send(encoded);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
#startGarbageCollector() {
|
|
141
|
+
if (this.#gcInterval) {
|
|
142
|
+
clearInterval(this.#gcInterval);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
this.#gcInterval = setInterval(() => {
|
|
146
|
+
this.#gc();
|
|
147
|
+
}, GC_INTERVAL);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#gc() {
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
const messagesToDelete: string[] = [];
|
|
153
|
+
|
|
154
|
+
for (const [messageId, pendingMessage] of this.#pendingMessages) {
|
|
155
|
+
// Check if message is older than timeout
|
|
156
|
+
if (now - pendingMessage.sentAt > MESSAGE_ACK_TIMEOUT) {
|
|
157
|
+
messagesToDelete.push(messageId);
|
|
158
|
+
|
|
159
|
+
const requestIdStr = pendingMessage.requestIdStr;
|
|
160
|
+
|
|
161
|
+
// Check if this is an HTTP request
|
|
162
|
+
const pendingRequest =
|
|
163
|
+
this.#actorPendingRequests.get(requestIdStr);
|
|
164
|
+
if (pendingRequest) {
|
|
165
|
+
// Reject the pending HTTP request
|
|
166
|
+
pendingRequest.reject(
|
|
167
|
+
new Error("Message acknowledgment timeout"),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
// Close stream controller if it exists
|
|
171
|
+
if (pendingRequest.streamController) {
|
|
172
|
+
pendingRequest.streamController.error(
|
|
173
|
+
new Error("Message acknowledgment timeout"),
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Clean up from actorPendingRequests map
|
|
178
|
+
this.#actorPendingRequests.delete(requestIdStr);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Check if this is a WebSocket
|
|
182
|
+
const webSocket = this.#actorWebSockets.get(requestIdStr);
|
|
183
|
+
if (webSocket) {
|
|
184
|
+
// Close the WebSocket connection
|
|
185
|
+
webSocket.close(1000, "Message acknowledgment timeout");
|
|
186
|
+
|
|
187
|
+
// Clean up from actorWebSockets map
|
|
188
|
+
this.#actorWebSockets.delete(requestIdStr);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Remove timed out messages
|
|
194
|
+
for (const messageId of messagesToDelete) {
|
|
195
|
+
this.#pendingMessages.delete(messageId);
|
|
196
|
+
console.warn(`Purged unacked message: ${messageId}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
unregisterActor(actor: ActorInstance) {
|
|
201
|
+
const actorId = actor.actorId;
|
|
202
|
+
|
|
203
|
+
// Terminate all requests for this actor
|
|
204
|
+
for (const requestId of actor.requests) {
|
|
205
|
+
const pending = this.#actorPendingRequests.get(requestId);
|
|
206
|
+
if (pending) {
|
|
207
|
+
pending.reject(new Error(`Actor ${actorId} stopped`));
|
|
208
|
+
this.#actorPendingRequests.delete(requestId);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
actor.requests.clear();
|
|
212
|
+
|
|
213
|
+
// Close all WebSockets for this actor
|
|
214
|
+
for (const webSocketId of actor.webSockets) {
|
|
215
|
+
const ws = this.#actorWebSockets.get(webSocketId);
|
|
216
|
+
if (ws) {
|
|
217
|
+
ws.close(1000, "Actor stopped");
|
|
218
|
+
this.#actorWebSockets.delete(webSocketId);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
actor.webSockets.clear();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
async #fetch(actorId: string, request: Request): Promise<Response> {
|
|
225
|
+
// Validate actor exists
|
|
226
|
+
if (!this.#runner.hasActor(actorId)) {
|
|
227
|
+
logger()?.warn({
|
|
228
|
+
msg: "ignoring request for unknown actor",
|
|
229
|
+
actorId,
|
|
230
|
+
});
|
|
231
|
+
return new Response("Actor not found", { status: 404 });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const fetchHandler = this.#runner.config.fetch(actorId, request);
|
|
235
|
+
|
|
236
|
+
if (!fetchHandler) {
|
|
237
|
+
return new Response("Not Implemented", { status: 501 });
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return fetchHandler;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
#connect() {
|
|
244
|
+
if (this.#shutdown) return;
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
this.#tunnelWs = new WebSocket(this.#pegboardTunnelUrl, {
|
|
248
|
+
headers: {
|
|
249
|
+
"x-rivet-target": "tunnel",
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
this.#tunnelWs.binaryType = "arraybuffer";
|
|
254
|
+
|
|
255
|
+
this.#tunnelWs.addEventListener("open", () => {
|
|
256
|
+
this.#reconnectAttempt = 0;
|
|
257
|
+
|
|
258
|
+
if (this.#reconnectTimeout) {
|
|
259
|
+
clearTimeout(this.#reconnectTimeout);
|
|
260
|
+
this.#reconnectTimeout = undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
this.#callbacks.onConnected();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
this.#tunnelWs.addEventListener("message", async (event) => {
|
|
267
|
+
try {
|
|
268
|
+
await this.#handleMessage(event.data as ArrayBuffer);
|
|
269
|
+
} catch (error) {
|
|
270
|
+
logger()?.error({
|
|
271
|
+
msg: "error handling tunnel message",
|
|
272
|
+
error,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
this.#tunnelWs.addEventListener("error", (event) => {
|
|
278
|
+
logger()?.error({ msg: "tunnel websocket error", event });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
this.#tunnelWs.addEventListener("close", () => {
|
|
282
|
+
this.#callbacks.onDisconnected();
|
|
283
|
+
|
|
284
|
+
if (!this.#shutdown) {
|
|
285
|
+
this.#scheduleReconnect();
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
} catch (error) {
|
|
289
|
+
logger()?.error({ msg: "failed to connect tunnel", error });
|
|
290
|
+
if (!this.#shutdown) {
|
|
291
|
+
this.#scheduleReconnect();
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
#scheduleReconnect() {
|
|
297
|
+
if (this.#shutdown) return;
|
|
298
|
+
|
|
299
|
+
const delay = calculateBackoff(this.#reconnectAttempt, {
|
|
300
|
+
initialDelay: 1000,
|
|
301
|
+
maxDelay: 30000,
|
|
302
|
+
multiplier: 2,
|
|
303
|
+
jitter: true,
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
this.#reconnectAttempt++;
|
|
307
|
+
|
|
308
|
+
this.#reconnectTimeout = setTimeout(() => {
|
|
309
|
+
this.#connect();
|
|
310
|
+
}, delay);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async #handleMessage(data: ArrayBuffer) {
|
|
314
|
+
const message = tunnel.decodeRunnerMessage(new Uint8Array(data));
|
|
315
|
+
|
|
316
|
+
if (message.messageKind.tag === "Ack") {
|
|
317
|
+
// Mark pending message as acknowledged and remove it
|
|
318
|
+
const msgIdStr = bufferToString(message.messageId);
|
|
319
|
+
const pending = this.#pendingMessages.get(msgIdStr);
|
|
320
|
+
if (pending) {
|
|
321
|
+
this.#pendingMessages.delete(msgIdStr);
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
this.#sendAck(message.requestId, message.messageId);
|
|
325
|
+
switch (message.messageKind.tag) {
|
|
326
|
+
case "ToServerRequestStart":
|
|
327
|
+
await this.#handleRequestStart(
|
|
328
|
+
message.requestId,
|
|
329
|
+
message.messageKind.val,
|
|
330
|
+
);
|
|
331
|
+
break;
|
|
332
|
+
case "ToServerRequestChunk":
|
|
333
|
+
await this.#handleRequestChunk(
|
|
334
|
+
message.requestId,
|
|
335
|
+
message.messageKind.val,
|
|
336
|
+
);
|
|
337
|
+
break;
|
|
338
|
+
case "ToServerRequestAbort":
|
|
339
|
+
await this.#handleRequestAbort(message.requestId);
|
|
340
|
+
break;
|
|
341
|
+
case "ToServerWebSocketOpen":
|
|
342
|
+
await this.#handleWebSocketOpen(
|
|
343
|
+
message.requestId,
|
|
344
|
+
message.messageKind.val,
|
|
345
|
+
);
|
|
346
|
+
break;
|
|
347
|
+
case "ToServerWebSocketMessage":
|
|
348
|
+
await this.#handleWebSocketMessage(
|
|
349
|
+
message.requestId,
|
|
350
|
+
message.messageKind.val,
|
|
351
|
+
);
|
|
352
|
+
break;
|
|
353
|
+
case "ToServerWebSocketClose":
|
|
354
|
+
await this.#handleWebSocketClose(
|
|
355
|
+
message.requestId,
|
|
356
|
+
message.messageKind.val,
|
|
357
|
+
);
|
|
358
|
+
break;
|
|
359
|
+
case "ToClientResponseStart":
|
|
360
|
+
this.#handleResponseStart(
|
|
361
|
+
message.requestId,
|
|
362
|
+
message.messageKind.val,
|
|
363
|
+
);
|
|
364
|
+
break;
|
|
365
|
+
case "ToClientResponseChunk":
|
|
366
|
+
this.#handleResponseChunk(
|
|
367
|
+
message.requestId,
|
|
368
|
+
message.messageKind.val,
|
|
369
|
+
);
|
|
370
|
+
break;
|
|
371
|
+
case "ToClientResponseAbort":
|
|
372
|
+
this.#handleResponseAbort(message.requestId);
|
|
373
|
+
break;
|
|
374
|
+
case "ToClientWebSocketOpen":
|
|
375
|
+
this.#handleWebSocketOpenResponse(
|
|
376
|
+
message.requestId,
|
|
377
|
+
message.messageKind.val,
|
|
378
|
+
);
|
|
379
|
+
break;
|
|
380
|
+
case "ToClientWebSocketMessage":
|
|
381
|
+
this.#handleWebSocketMessageResponse(
|
|
382
|
+
message.requestId,
|
|
383
|
+
message.messageKind.val,
|
|
384
|
+
);
|
|
385
|
+
break;
|
|
386
|
+
case "ToClientWebSocketClose":
|
|
387
|
+
this.#handleWebSocketCloseResponse(
|
|
388
|
+
message.requestId,
|
|
389
|
+
message.messageKind.val,
|
|
390
|
+
);
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async #handleRequestStart(
|
|
397
|
+
requestId: ArrayBuffer,
|
|
398
|
+
req: tunnel.ToServerRequestStart,
|
|
399
|
+
) {
|
|
400
|
+
// Track this request for the actor
|
|
401
|
+
const requestIdStr = bufferToString(requestId);
|
|
402
|
+
const actor = this.#runner.getActor(req.actorId);
|
|
403
|
+
if (actor) {
|
|
404
|
+
actor.requests.add(requestIdStr);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
try {
|
|
408
|
+
// Convert headers map to Headers object
|
|
409
|
+
const headers = new Headers();
|
|
410
|
+
for (const [key, value] of req.headers) {
|
|
411
|
+
headers.append(key, value);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Create Request object
|
|
415
|
+
const request = new Request(`http://localhost${req.path}`, {
|
|
416
|
+
method: req.method,
|
|
417
|
+
headers,
|
|
418
|
+
body: req.body ? new Uint8Array(req.body) : undefined,
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
// Handle streaming request
|
|
422
|
+
if (req.stream) {
|
|
423
|
+
// Create a stream for the request body
|
|
424
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
425
|
+
start: (controller) => {
|
|
426
|
+
// Store controller for chunks
|
|
427
|
+
const existing =
|
|
428
|
+
this.#actorPendingRequests.get(requestIdStr);
|
|
429
|
+
if (existing) {
|
|
430
|
+
existing.streamController = controller;
|
|
431
|
+
existing.actorId = req.actorId;
|
|
432
|
+
} else {
|
|
433
|
+
this.#actorPendingRequests.set(requestIdStr, {
|
|
434
|
+
resolve: () => {},
|
|
435
|
+
reject: () => {},
|
|
436
|
+
streamController: controller,
|
|
437
|
+
actorId: req.actorId,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
},
|
|
441
|
+
});
|
|
442
|
+
|
|
443
|
+
// Create request with streaming body
|
|
444
|
+
const streamingRequest = new Request(request, {
|
|
445
|
+
body: stream,
|
|
446
|
+
duplex: "half",
|
|
447
|
+
} as any);
|
|
448
|
+
|
|
449
|
+
// Call fetch handler with validation
|
|
450
|
+
const response = await this.#fetch(
|
|
451
|
+
req.actorId,
|
|
452
|
+
streamingRequest,
|
|
453
|
+
);
|
|
454
|
+
await this.#sendResponse(requestId, response);
|
|
455
|
+
} else {
|
|
456
|
+
// Non-streaming request
|
|
457
|
+
const response = await this.#fetch(req.actorId, request);
|
|
458
|
+
await this.#sendResponse(requestId, response);
|
|
459
|
+
}
|
|
460
|
+
} catch (error) {
|
|
461
|
+
logger()?.error({ msg: "error handling request", error });
|
|
462
|
+
this.#sendResponseError(requestId, 500, "Internal Server Error");
|
|
463
|
+
} finally {
|
|
464
|
+
// Clean up request tracking
|
|
465
|
+
const actor = this.#runner.getActor(req.actorId);
|
|
466
|
+
if (actor) {
|
|
467
|
+
actor.requests.delete(requestIdStr);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async #handleRequestChunk(
|
|
473
|
+
requestId: ArrayBuffer,
|
|
474
|
+
chunk: tunnel.ToServerRequestChunk,
|
|
475
|
+
) {
|
|
476
|
+
const requestIdStr = bufferToString(requestId);
|
|
477
|
+
const pending = this.#actorPendingRequests.get(requestIdStr);
|
|
478
|
+
if (pending?.streamController) {
|
|
479
|
+
pending.streamController.enqueue(new Uint8Array(chunk.body));
|
|
480
|
+
if (chunk.finish) {
|
|
481
|
+
pending.streamController.close();
|
|
482
|
+
this.#actorPendingRequests.delete(requestIdStr);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async #handleRequestAbort(requestId: ArrayBuffer) {
|
|
488
|
+
const requestIdStr = bufferToString(requestId);
|
|
489
|
+
const pending = this.#actorPendingRequests.get(requestIdStr);
|
|
490
|
+
if (pending?.streamController) {
|
|
491
|
+
pending.streamController.error(new Error("Request aborted"));
|
|
492
|
+
}
|
|
493
|
+
this.#actorPendingRequests.delete(requestIdStr);
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
async #sendResponse(requestId: ArrayBuffer, response: Response) {
|
|
497
|
+
// Always treat responses as non-streaming for now
|
|
498
|
+
// In the future, we could detect streaming responses based on:
|
|
499
|
+
// - Transfer-Encoding: chunked
|
|
500
|
+
// - Content-Type: text/event-stream
|
|
501
|
+
// - Explicit stream flag from the handler
|
|
502
|
+
|
|
503
|
+
// Read the body first to get the actual content
|
|
504
|
+
const body = response.body ? await response.arrayBuffer() : null;
|
|
505
|
+
|
|
506
|
+
// Convert headers to map and add Content-Length if not present
|
|
507
|
+
const headers = new Map<string, string>();
|
|
508
|
+
response.headers.forEach((value, key) => {
|
|
509
|
+
headers.set(key, value);
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
// Add Content-Length header if we have a body and it's not already set
|
|
513
|
+
if (body && !headers.has("content-length")) {
|
|
514
|
+
headers.set("content-length", String(body.byteLength));
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// Send as non-streaming response
|
|
518
|
+
this.#sendMessage(requestId, {
|
|
519
|
+
tag: "ToClientResponseStart",
|
|
520
|
+
val: {
|
|
521
|
+
status: response.status as tunnel.u16,
|
|
522
|
+
headers,
|
|
523
|
+
body: body || null,
|
|
524
|
+
stream: false,
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
#sendResponseError(
|
|
530
|
+
requestId: ArrayBuffer,
|
|
531
|
+
status: number,
|
|
532
|
+
message: string,
|
|
533
|
+
) {
|
|
534
|
+
const headers = new Map<string, string>();
|
|
535
|
+
headers.set("content-type", "text/plain");
|
|
536
|
+
|
|
537
|
+
this.#sendMessage(requestId, {
|
|
538
|
+
tag: "ToClientResponseStart",
|
|
539
|
+
val: {
|
|
540
|
+
status: status as tunnel.u16,
|
|
541
|
+
headers,
|
|
542
|
+
body: new TextEncoder().encode(message).buffer as ArrayBuffer,
|
|
543
|
+
stream: false,
|
|
544
|
+
},
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async #handleWebSocketOpen(
|
|
549
|
+
requestId: ArrayBuffer,
|
|
550
|
+
open: tunnel.ToServerWebSocketOpen,
|
|
551
|
+
) {
|
|
552
|
+
const webSocketId = bufferToString(requestId);
|
|
553
|
+
// Validate actor exists
|
|
554
|
+
const actor = this.#runner.getActor(open.actorId);
|
|
555
|
+
if (!actor) {
|
|
556
|
+
logger()?.warn({
|
|
557
|
+
msg: "ignoring websocket for unknown actor",
|
|
558
|
+
actorId: open.actorId,
|
|
559
|
+
});
|
|
560
|
+
// Send close immediately
|
|
561
|
+
this.#sendMessage(requestId, {
|
|
562
|
+
tag: "ToClientWebSocketClose",
|
|
563
|
+
val: {
|
|
564
|
+
code: 1011,
|
|
565
|
+
reason: "Actor not found",
|
|
566
|
+
},
|
|
567
|
+
});
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const websocketHandler = this.#runner.config.websocket;
|
|
572
|
+
|
|
573
|
+
if (!websocketHandler) {
|
|
574
|
+
console.error("No websocket handler configured for tunnel");
|
|
575
|
+
logger()?.error({
|
|
576
|
+
msg: "no websocket handler configured for tunnel",
|
|
577
|
+
});
|
|
578
|
+
// Send close immediately
|
|
579
|
+
this.#sendMessage(requestId, {
|
|
580
|
+
tag: "ToClientWebSocketClose",
|
|
581
|
+
val: {
|
|
582
|
+
code: 1011,
|
|
583
|
+
reason: "Not Implemented",
|
|
584
|
+
},
|
|
585
|
+
});
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Track this WebSocket for the actor
|
|
590
|
+
if (actor) {
|
|
591
|
+
actor.webSockets.add(webSocketId);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
try {
|
|
595
|
+
// Create WebSocket adapter
|
|
596
|
+
const adapter = new WebSocketTunnelAdapter(
|
|
597
|
+
webSocketId,
|
|
598
|
+
(data: ArrayBuffer | string, isBinary: boolean) => {
|
|
599
|
+
// Send message through tunnel
|
|
600
|
+
const dataBuffer =
|
|
601
|
+
typeof data === "string"
|
|
602
|
+
? (new TextEncoder().encode(data)
|
|
603
|
+
.buffer as ArrayBuffer)
|
|
604
|
+
: data;
|
|
605
|
+
|
|
606
|
+
this.#sendMessage(requestId, {
|
|
607
|
+
tag: "ToClientWebSocketMessage",
|
|
608
|
+
val: {
|
|
609
|
+
data: dataBuffer,
|
|
610
|
+
binary: isBinary,
|
|
611
|
+
},
|
|
612
|
+
});
|
|
613
|
+
},
|
|
614
|
+
(code?: number, reason?: string) => {
|
|
615
|
+
// Send close through tunnel
|
|
616
|
+
this.#sendMessage(requestId, {
|
|
617
|
+
tag: "ToClientWebSocketClose",
|
|
618
|
+
val: {
|
|
619
|
+
code: code || null,
|
|
620
|
+
reason: reason || null,
|
|
621
|
+
},
|
|
622
|
+
});
|
|
623
|
+
|
|
624
|
+
// Remove from map
|
|
625
|
+
this.#actorWebSockets.delete(webSocketId);
|
|
626
|
+
|
|
627
|
+
// Clean up actor tracking
|
|
628
|
+
if (actor) {
|
|
629
|
+
actor.webSockets.delete(webSocketId);
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
);
|
|
633
|
+
|
|
634
|
+
// Store adapter
|
|
635
|
+
this.#actorWebSockets.set(webSocketId, adapter);
|
|
636
|
+
|
|
637
|
+
// Send open confirmation
|
|
638
|
+
this.#sendMessage(requestId, {
|
|
639
|
+
tag: "ToClientWebSocketOpen",
|
|
640
|
+
val: null,
|
|
641
|
+
});
|
|
642
|
+
|
|
643
|
+
// Notify adapter that connection is open
|
|
644
|
+
adapter._handleOpen();
|
|
645
|
+
|
|
646
|
+
// Create a minimal request object for the websocket handler
|
|
647
|
+
// Include original headers from the open message
|
|
648
|
+
const headerInit: Record<string, string> = {};
|
|
649
|
+
if (open.headers) {
|
|
650
|
+
for (const [k, v] of open.headers as ReadonlyMap<
|
|
651
|
+
string,
|
|
652
|
+
string
|
|
653
|
+
>) {
|
|
654
|
+
headerInit[k] = v;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
// Ensure websocket upgrade headers are present
|
|
658
|
+
headerInit["Upgrade"] = "websocket";
|
|
659
|
+
headerInit["Connection"] = "Upgrade";
|
|
660
|
+
|
|
661
|
+
const request = new Request(`http://localhost${open.path}`, {
|
|
662
|
+
method: "GET",
|
|
663
|
+
headers: headerInit,
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
// Call websocket handler
|
|
667
|
+
await websocketHandler(open.actorId, adapter, request);
|
|
668
|
+
} catch (error) {
|
|
669
|
+
logger()?.error({ msg: "error handling websocket open", error });
|
|
670
|
+
// Send close on error
|
|
671
|
+
this.#sendMessage(requestId, {
|
|
672
|
+
tag: "ToClientWebSocketClose",
|
|
673
|
+
val: {
|
|
674
|
+
code: 1011,
|
|
675
|
+
reason: "Server Error",
|
|
676
|
+
},
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
this.#actorWebSockets.delete(webSocketId);
|
|
680
|
+
|
|
681
|
+
// Clean up actor tracking
|
|
682
|
+
if (actor) {
|
|
683
|
+
actor.webSockets.delete(webSocketId);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async #handleWebSocketMessage(
|
|
689
|
+
requestId: ArrayBuffer,
|
|
690
|
+
msg: tunnel.ToServerWebSocketMessage,
|
|
691
|
+
) {
|
|
692
|
+
const webSocketId = bufferToString(requestId);
|
|
693
|
+
const adapter = this.#actorWebSockets.get(webSocketId);
|
|
694
|
+
if (adapter) {
|
|
695
|
+
const data = msg.binary
|
|
696
|
+
? new Uint8Array(msg.data)
|
|
697
|
+
: new TextDecoder().decode(new Uint8Array(msg.data));
|
|
698
|
+
|
|
699
|
+
adapter._handleMessage(data, msg.binary);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
async #handleWebSocketClose(
|
|
704
|
+
requestId: ArrayBuffer,
|
|
705
|
+
close: tunnel.ToServerWebSocketClose,
|
|
706
|
+
) {
|
|
707
|
+
const webSocketId = bufferToString(requestId);
|
|
708
|
+
const adapter = this.#actorWebSockets.get(webSocketId);
|
|
709
|
+
if (adapter) {
|
|
710
|
+
adapter._handleClose(
|
|
711
|
+
close.code || undefined,
|
|
712
|
+
close.reason || undefined,
|
|
713
|
+
);
|
|
714
|
+
this.#actorWebSockets.delete(webSocketId);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
#handleResponseStart(
|
|
719
|
+
requestId: ArrayBuffer,
|
|
720
|
+
resp: tunnel.ToClientResponseStart,
|
|
721
|
+
) {
|
|
722
|
+
const requestIdStr = bufferToString(requestId);
|
|
723
|
+
const pending = this.#actorPendingRequests.get(requestIdStr);
|
|
724
|
+
if (!pending) {
|
|
725
|
+
logger()?.warn({
|
|
726
|
+
msg: "received response for unknown request",
|
|
727
|
+
requestId: requestIdStr,
|
|
728
|
+
});
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// Convert headers map to Headers object
|
|
733
|
+
const headers = new Headers();
|
|
734
|
+
for (const [key, value] of resp.headers) {
|
|
735
|
+
headers.append(key, value);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
if (resp.stream) {
|
|
739
|
+
// Create streaming response
|
|
740
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
741
|
+
start: (controller) => {
|
|
742
|
+
pending.streamController = controller;
|
|
743
|
+
},
|
|
744
|
+
});
|
|
745
|
+
|
|
746
|
+
const response = new Response(stream, {
|
|
747
|
+
status: resp.status,
|
|
748
|
+
headers,
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
pending.resolve(response);
|
|
752
|
+
} else {
|
|
753
|
+
// Non-streaming response
|
|
754
|
+
const body = resp.body ? new Uint8Array(resp.body) : null;
|
|
755
|
+
const response = new Response(body, {
|
|
756
|
+
status: resp.status,
|
|
757
|
+
headers,
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
pending.resolve(response);
|
|
761
|
+
this.#actorPendingRequests.delete(requestIdStr);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
#handleResponseChunk(
|
|
766
|
+
requestId: ArrayBuffer,
|
|
767
|
+
chunk: tunnel.ToClientResponseChunk,
|
|
768
|
+
) {
|
|
769
|
+
const requestIdStr = bufferToString(requestId);
|
|
770
|
+
const pending = this.#actorPendingRequests.get(requestIdStr);
|
|
771
|
+
if (pending?.streamController) {
|
|
772
|
+
pending.streamController.enqueue(new Uint8Array(chunk.body));
|
|
773
|
+
if (chunk.finish) {
|
|
774
|
+
pending.streamController.close();
|
|
775
|
+
this.#actorPendingRequests.delete(requestIdStr);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
#handleResponseAbort(requestId: ArrayBuffer) {
|
|
781
|
+
const requestIdStr = bufferToString(requestId);
|
|
782
|
+
const pending = this.#actorPendingRequests.get(requestIdStr);
|
|
783
|
+
if (pending?.streamController) {
|
|
784
|
+
pending.streamController.error(new Error("Response aborted"));
|
|
785
|
+
}
|
|
786
|
+
this.#actorPendingRequests.delete(requestIdStr);
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
#handleWebSocketOpenResponse(
|
|
790
|
+
requestId: ArrayBuffer,
|
|
791
|
+
open: tunnel.ToClientWebSocketOpen,
|
|
792
|
+
) {
|
|
793
|
+
const webSocketId = bufferToString(requestId);
|
|
794
|
+
const adapter = this.#actorWebSockets.get(webSocketId);
|
|
795
|
+
if (adapter) {
|
|
796
|
+
adapter._handleOpen();
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
#handleWebSocketMessageResponse(
|
|
801
|
+
requestId: ArrayBuffer,
|
|
802
|
+
msg: tunnel.ToClientWebSocketMessage,
|
|
803
|
+
) {
|
|
804
|
+
const webSocketId = bufferToString(requestId);
|
|
805
|
+
const adapter = this.#actorWebSockets.get(webSocketId);
|
|
806
|
+
if (adapter) {
|
|
807
|
+
const data = msg.binary
|
|
808
|
+
? new Uint8Array(msg.data)
|
|
809
|
+
: new TextDecoder().decode(new Uint8Array(msg.data));
|
|
810
|
+
|
|
811
|
+
adapter._handleMessage(data, msg.binary);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
#handleWebSocketCloseResponse(
|
|
816
|
+
requestId: ArrayBuffer,
|
|
817
|
+
close: tunnel.ToClientWebSocketClose,
|
|
818
|
+
) {
|
|
819
|
+
const webSocketId = bufferToString(requestId);
|
|
820
|
+
const adapter = this.#actorWebSockets.get(webSocketId);
|
|
821
|
+
if (adapter) {
|
|
822
|
+
adapter._handleClose(
|
|
823
|
+
close.code || undefined,
|
|
824
|
+
close.reason || undefined,
|
|
825
|
+
);
|
|
826
|
+
this.#actorWebSockets.delete(webSocketId);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Converts a buffer to a string. Used for storing strings in a lookup map. */
|
|
832
|
+
function bufferToString(buffer: ArrayBuffer): string {
|
|
833
|
+
return Buffer.from(buffer).toString("base64");
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/** Generates a UUID as bytes. */
|
|
837
|
+
function generateUuidBuffer(): ArrayBuffer {
|
|
838
|
+
const buffer = new Uint8Array(16);
|
|
839
|
+
uuidv4(undefined, buffer);
|
|
840
|
+
return buffer.buffer;
|
|
841
|
+
}
|