@rivetkit/engine-runner 0.0.0-pr.4600.db261bc

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/src/tunnel.ts ADDED
@@ -0,0 +1,1168 @@
1
+ import type * as protocol from "@rivetkit/engine-runner-protocol";
2
+ import type {
3
+ GatewayId,
4
+ MessageId,
5
+ RequestId,
6
+ } from "@rivetkit/engine-runner-protocol";
7
+ import type { Logger } from "pino";
8
+ import { type Runner, type RunnerActor, RunnerShutdownError } from "./mod";
9
+ import {
10
+ stringifyToClientTunnelMessageKind,
11
+ stringifyToServerTunnelMessageKind,
12
+ } from "./stringify";
13
+ import { arraysEqual, idToStr, MAX_PAYLOAD_SIZE, stringifyError, unreachable } from "./utils";
14
+ import {
15
+ HIBERNATABLE_SYMBOL,
16
+ WebSocketTunnelAdapter,
17
+ } from "./websocket-tunnel-adapter";
18
+
19
+ export interface PendingRequest {
20
+ resolve: (response: Response) => void;
21
+ reject: (error: Error) => void;
22
+ streamController?: ReadableStreamDefaultController<Uint8Array>;
23
+ actorId?: string;
24
+ gatewayId?: GatewayId;
25
+ requestId?: RequestId;
26
+ clientMessageIndex: number;
27
+ }
28
+
29
+ export interface HibernatingWebSocketMetadata {
30
+ gatewayId: GatewayId;
31
+ requestId: RequestId;
32
+ clientMessageIndex: number;
33
+ serverMessageIndex: number;
34
+
35
+ path: string;
36
+ headers: Record<string, string>;
37
+ }
38
+
39
+ export class Tunnel {
40
+ #runner: Runner;
41
+
42
+ /** Maps request IDs to actor IDs for lookup */
43
+ #requestToActor: Array<{
44
+ gatewayId: GatewayId;
45
+ requestId: RequestId;
46
+ actorId: string;
47
+ }> = [];
48
+
49
+ /** Buffer for messages when not connected */
50
+ #bufferedMessages: Array<{
51
+ gatewayId: GatewayId;
52
+ requestId: RequestId;
53
+ messageKind: protocol.ToServerTunnelMessageKind;
54
+ }> = [];
55
+
56
+ get log(): Logger | undefined {
57
+ return this.#runner.log;
58
+ }
59
+
60
+ constructor(runner: Runner) {
61
+ this.#runner = runner;
62
+ }
63
+
64
+ start(): void {
65
+ // No-op - kept for compatibility
66
+ }
67
+
68
+ resendBufferedEvents(): void {
69
+ if (this.#bufferedMessages.length === 0) {
70
+ return;
71
+ }
72
+
73
+ this.log?.info({
74
+ msg: "resending buffered tunnel messages",
75
+ count: this.#bufferedMessages.length,
76
+ });
77
+
78
+ const messages = this.#bufferedMessages;
79
+ this.#bufferedMessages = [];
80
+
81
+ for (const { gatewayId, requestId, messageKind } of messages) {
82
+ this.#sendMessage(gatewayId, requestId, messageKind);
83
+ }
84
+ }
85
+
86
+ shutdown() {
87
+ // NOTE: Pegboard WS already closed at this point, cannot send
88
+ // anything. All teardown logic is handled by pegboard-runner.
89
+
90
+ // Reject all pending requests and close all WebSockets for all actors
91
+ // RunnerShutdownError will be explicitly ignored
92
+ for (const [_actorId, actor] of this.#runner.actors) {
93
+ // Reject all pending requests for this actor
94
+ for (const entry of actor.pendingRequests) {
95
+ entry.request.reject(new RunnerShutdownError());
96
+ }
97
+ actor.pendingRequests = [];
98
+
99
+ // Close all WebSockets for this actor
100
+ // The WebSocket close event with retry is automatically sent when the
101
+ // runner WS closes, so we only need to notify the client that the WS
102
+ // closed:
103
+ // https://github.com/rivet-dev/rivet/blob/00d4f6a22da178a6f8115e5db50d96c6f8387c2e/engine/packages/pegboard-runner/src/lib.rs#L157
104
+ for (const entry of actor.webSockets) {
105
+ // Only close non-hibernatable websockets to prevent sending
106
+ // unnecessary close messages for websockets that will be hibernated
107
+ if (!entry.ws[HIBERNATABLE_SYMBOL]) {
108
+ entry.ws._closeWithoutCallback(1000, "ws.tunnel_shutdown");
109
+ }
110
+ }
111
+ actor.webSockets = [];
112
+ }
113
+
114
+ // Clear the request-to-actor mapping
115
+ this.#requestToActor = [];
116
+ }
117
+
118
+ async restoreHibernatingRequests(
119
+ actorId: string,
120
+ metaEntries: HibernatingWebSocketMetadata[],
121
+ ) {
122
+ const actor = this.#runner.getActor(actorId);
123
+ if (!actor) {
124
+ throw new Error(
125
+ `Actor ${actorId} not found for restoring hibernating requests`,
126
+ );
127
+ }
128
+
129
+ if (actor.hibernationRestored) {
130
+ throw new Error(
131
+ `Actor ${actorId} already restored hibernating requests`,
132
+ );
133
+ }
134
+
135
+ this.log?.debug({
136
+ msg: "restoring hibernating requests",
137
+ actorId,
138
+ requests: actor.hibernatingRequests.length,
139
+ });
140
+
141
+ // Track all background operations
142
+ const backgroundOperations: Promise<void>[] = [];
143
+
144
+ // Process connected WebSockets
145
+ let connectedButNotLoadedCount = 0;
146
+ let restoredCount = 0;
147
+ for (const { gatewayId, requestId } of actor.hibernatingRequests) {
148
+ const requestIdStr = idToStr(requestId);
149
+ const meta = metaEntries.find(
150
+ (entry) =>
151
+ arraysEqual(entry.gatewayId, gatewayId) &&
152
+ arraysEqual(entry.requestId, requestId),
153
+ );
154
+
155
+ if (!meta) {
156
+ // Connected but not loaded (not persisted) - close it
157
+ //
158
+ // This may happen if the metadata was not successfully persisted
159
+ this.log?.warn({
160
+ msg: "closing websocket that is not persisted",
161
+ requestId: requestIdStr,
162
+ });
163
+
164
+ this.#sendMessage(gatewayId, requestId, {
165
+ tag: "ToServerWebSocketClose",
166
+ val: {
167
+ code: 1000,
168
+ reason: "ws.meta_not_found_during_restore",
169
+ hibernate: false,
170
+ },
171
+ });
172
+
173
+ connectedButNotLoadedCount++;
174
+ } else {
175
+ // Both connected and persisted - restore it
176
+ const request = buildRequestForWebSocket(
177
+ meta.path,
178
+ meta.headers,
179
+ );
180
+
181
+ // This will call `runner.config.websocket` under the hood to
182
+ // attach the event listeners to the WebSocket.
183
+ // Track this operation to ensure it completes
184
+ const restoreOperation = this.#createWebSocket(
185
+ actorId,
186
+ gatewayId,
187
+ requestId,
188
+ requestIdStr,
189
+ meta.serverMessageIndex,
190
+ true,
191
+ true,
192
+ request,
193
+ meta.path,
194
+ meta.headers,
195
+ false,
196
+ )
197
+ .then(() => {
198
+ // Create a PendingRequest entry to track the message index
199
+ const actor = this.#runner.getActor(actorId);
200
+ if (actor) {
201
+ actor.createPendingRequest(
202
+ gatewayId,
203
+ requestId,
204
+ meta.clientMessageIndex,
205
+ );
206
+ }
207
+
208
+ this.log?.info({
209
+ msg: "connection successfully restored",
210
+ actorId,
211
+ requestId: requestIdStr,
212
+ });
213
+ })
214
+ .catch((err) => {
215
+ this.log?.error({
216
+ msg: "error creating websocket during restore",
217
+ requestId: requestIdStr,
218
+ error: stringifyError(err),
219
+ });
220
+
221
+ // Close the WebSocket on error
222
+ this.#sendMessage(gatewayId, requestId, {
223
+ tag: "ToServerWebSocketClose",
224
+ val: {
225
+ code: 1011,
226
+ reason: "ws.restore_error",
227
+ hibernate: false,
228
+ },
229
+ });
230
+ });
231
+
232
+ backgroundOperations.push(restoreOperation);
233
+ restoredCount++;
234
+ }
235
+ }
236
+
237
+ // Process loaded but not connected (stale) - remove them
238
+ let loadedButNotConnectedCount = 0;
239
+ for (const meta of metaEntries) {
240
+ const requestIdStr = idToStr(meta.requestId);
241
+ const isConnected = actor.hibernatingRequests.some(
242
+ (req) =>
243
+ arraysEqual(req.gatewayId, meta.gatewayId) &&
244
+ arraysEqual(req.requestId, meta.requestId),
245
+ );
246
+ if (!isConnected) {
247
+ this.log?.warn({
248
+ msg: "removing stale persisted websocket",
249
+ requestId: requestIdStr,
250
+ });
251
+
252
+ const request = buildRequestForWebSocket(
253
+ meta.path,
254
+ meta.headers,
255
+ );
256
+
257
+ // Create adapter to register user's event listeners.
258
+ // Pass engineAlreadyClosed=true so close callback won't send tunnel message.
259
+ // Track this operation to ensure it completes
260
+ const cleanupOperation = this.#createWebSocket(
261
+ actorId,
262
+ meta.gatewayId,
263
+ meta.requestId,
264
+ requestIdStr,
265
+ meta.serverMessageIndex,
266
+ true,
267
+ true,
268
+ request,
269
+ meta.path,
270
+ meta.headers,
271
+ true,
272
+ )
273
+ .then((adapter) => {
274
+ // Close the adapter normally - this will fire user's close event handler
275
+ // (which should clean up persistence) and trigger the close callback
276
+ // (which will clean up maps but skip sending tunnel message)
277
+ adapter.close(1000, "ws.stale_metadata");
278
+ })
279
+ .catch((err) => {
280
+ this.log?.error({
281
+ msg: "error creating stale websocket during restore",
282
+ requestId: requestIdStr,
283
+ error: stringifyError(err),
284
+ });
285
+ });
286
+
287
+ backgroundOperations.push(cleanupOperation);
288
+ loadedButNotConnectedCount++;
289
+ }
290
+ }
291
+
292
+ // Wait for all background operations to complete before finishing
293
+ await Promise.allSettled(backgroundOperations);
294
+
295
+ // Mark restoration as complete
296
+ actor.hibernationRestored = true;
297
+
298
+ this.log?.info({
299
+ msg: "restored hibernatable websockets",
300
+ actorId,
301
+ restoredCount,
302
+ connectedButNotLoadedCount,
303
+ loadedButNotConnectedCount,
304
+ });
305
+ }
306
+
307
+ /**
308
+ * Called from WebSocketOpen message and when restoring hibernatable WebSockets.
309
+ *
310
+ * engineAlreadyClosed will be true if this is only being called to trigger
311
+ * the close callback and not to send a close message to the server. This
312
+ * is used specifically to clean up zombie WebSocket connections.
313
+ */
314
+ async #createWebSocket(
315
+ actorId: string,
316
+ gatewayId: GatewayId,
317
+ requestId: RequestId,
318
+ requestIdStr: string,
319
+ serverMessageIndex: number,
320
+ isHibernatable: boolean,
321
+ isRestoringHibernatable: boolean,
322
+ request: Request,
323
+ path: string,
324
+ headers: Record<string, string>,
325
+ engineAlreadyClosed: boolean,
326
+ ): Promise<WebSocketTunnelAdapter> {
327
+ this.log?.debug({
328
+ msg: "createWebSocket creating adapter",
329
+ actorId,
330
+ requestIdStr,
331
+ isHibernatable,
332
+ path,
333
+ });
334
+ // Create WebSocket adapter
335
+ const adapter = new WebSocketTunnelAdapter(
336
+ this,
337
+ actorId,
338
+ requestIdStr,
339
+ serverMessageIndex,
340
+ isHibernatable,
341
+ isRestoringHibernatable,
342
+ request,
343
+ (data: ArrayBuffer | string, isBinary: boolean) => {
344
+ // Send message through tunnel
345
+ const dataBuffer =
346
+ typeof data === "string"
347
+ ? (new TextEncoder().encode(data).buffer as ArrayBuffer)
348
+ : data;
349
+
350
+ this.#sendMessage(gatewayId, requestId, {
351
+ tag: "ToServerWebSocketMessage",
352
+ val: {
353
+ data: dataBuffer,
354
+ binary: isBinary,
355
+ },
356
+ });
357
+ },
358
+ (code?: number, reason?: string) => {
359
+ // Send close through tunnel if engine doesn't already know it's closed
360
+ if (!engineAlreadyClosed) {
361
+ this.#sendMessage(gatewayId, requestId, {
362
+ tag: "ToServerWebSocketClose",
363
+ val: {
364
+ code: code || null,
365
+ reason: reason || null,
366
+ hibernate: false,
367
+ },
368
+ });
369
+ }
370
+
371
+ // Clean up actor tracking
372
+ const actor = this.#runner.getActor(actorId);
373
+ if (actor) {
374
+ actor.deleteWebSocket(gatewayId, requestId);
375
+ actor.deletePendingRequest(gatewayId, requestId);
376
+ }
377
+
378
+ // Clean up request-to-actor mapping
379
+ this.#removeRequestToActor(gatewayId, requestId);
380
+ },
381
+ );
382
+
383
+ // Get actor and add websocket to it
384
+ const actor = this.#runner.getActor(actorId);
385
+ if (!actor) {
386
+ throw new Error(`Actor ${actorId} not found`);
387
+ }
388
+
389
+ actor.setWebSocket(gatewayId, requestId, adapter);
390
+ this.addRequestToActor(gatewayId, requestId, actorId);
391
+
392
+ // Call WebSocket handler. This handler will add event listeners
393
+ // for `open`, etc. Pass the VirtualWebSocket (not the adapter) to the actor.
394
+ await this.#runner.config.websocket(
395
+ this.#runner,
396
+ actorId,
397
+ adapter.websocket,
398
+ gatewayId,
399
+ requestId,
400
+ request,
401
+ path,
402
+ headers,
403
+ isHibernatable,
404
+ isRestoringHibernatable,
405
+ );
406
+
407
+ return adapter;
408
+ }
409
+
410
+ addRequestToActor(
411
+ gatewayId: GatewayId,
412
+ requestId: RequestId,
413
+ actorId: string,
414
+ ) {
415
+ this.#requestToActor.push({ gatewayId, requestId, actorId });
416
+ }
417
+
418
+ #removeRequestToActor(gatewayId: GatewayId, requestId: RequestId) {
419
+ const index = this.#requestToActor.findIndex(
420
+ (entry) =>
421
+ arraysEqual(entry.gatewayId, gatewayId) &&
422
+ arraysEqual(entry.requestId, requestId),
423
+ );
424
+ if (index !== -1) {
425
+ this.#requestToActor.splice(index, 1);
426
+ }
427
+ }
428
+
429
+ getRequestActor(
430
+ gatewayId: GatewayId,
431
+ requestId: RequestId,
432
+ ): RunnerActor | undefined {
433
+ const entry = this.#requestToActor.find(
434
+ (entry) =>
435
+ arraysEqual(entry.gatewayId, gatewayId) &&
436
+ arraysEqual(entry.requestId, requestId),
437
+ );
438
+
439
+ if (!entry) {
440
+ this.log?.warn({
441
+ msg: "missing requestToActor entry",
442
+ requestId: idToStr(requestId),
443
+ });
444
+ return undefined;
445
+ }
446
+
447
+ const actor = this.#runner.getActor(entry.actorId);
448
+ if (!actor) {
449
+ this.log?.warn({
450
+ msg: "missing actor for requestToActor lookup",
451
+ requestId: idToStr(requestId),
452
+ actorId: entry.actorId,
453
+ });
454
+ return undefined;
455
+ }
456
+
457
+ return actor;
458
+ }
459
+
460
+ async getAndWaitForRequestActor(
461
+ gatewayId: GatewayId,
462
+ requestId: RequestId,
463
+ ): Promise<RunnerActor | undefined> {
464
+ const actor = this.getRequestActor(gatewayId, requestId);
465
+ if (!actor) return;
466
+ await actor.actorStartPromise.promise;
467
+ return actor;
468
+ }
469
+
470
+ #sendMessage(
471
+ gatewayId: GatewayId,
472
+ requestId: RequestId,
473
+ messageKind: protocol.ToServerTunnelMessageKind,
474
+ ) {
475
+ // Buffer message if not connected
476
+ if (!this.#runner.getPegboardWebSocketIfReady()) {
477
+ this.log?.debug({
478
+ msg: "buffering tunnel message, socket not connected to engine",
479
+ requestId: idToStr(requestId),
480
+ message: stringifyToServerTunnelMessageKind(messageKind),
481
+ });
482
+ this.#bufferedMessages.push({ gatewayId, requestId, messageKind });
483
+ return;
484
+ }
485
+
486
+ // Get or initialize message index for this request
487
+ //
488
+ // We don't have to wait for the actor to start since we're not calling
489
+ // any callbacks on the actor
490
+ const gatewayIdStr = idToStr(gatewayId);
491
+ const requestIdStr = idToStr(requestId);
492
+ const actor = this.getRequestActor(gatewayId, requestId);
493
+ if (!actor) {
494
+ this.log?.warn({
495
+ msg: "cannot send tunnel message, actor not found",
496
+ gatewayId: gatewayIdStr,
497
+ requestId: requestIdStr,
498
+ });
499
+ return;
500
+ }
501
+
502
+ // Get message index from pending request
503
+ let clientMessageIndex: number;
504
+ const pending = actor.getPendingRequest(gatewayId, requestId);
505
+ if (pending) {
506
+ clientMessageIndex = pending.clientMessageIndex;
507
+ pending.clientMessageIndex++;
508
+ } else {
509
+ // No pending request
510
+ this.log?.warn({
511
+ msg: "missing pending request for send message, defaulting to message index 0",
512
+ gatewayId: gatewayIdStr,
513
+ requestId: requestIdStr,
514
+ });
515
+ clientMessageIndex = 0;
516
+ }
517
+
518
+ // Build message ID from gatewayId + requestId + messageIndex
519
+ const messageId: protocol.MessageId = {
520
+ gatewayId,
521
+ requestId,
522
+ messageIndex: clientMessageIndex,
523
+ };
524
+ const messageIdStr = `${idToStr(messageId.gatewayId)}-${idToStr(messageId.requestId)}-${messageId.messageIndex}`;
525
+
526
+ this.log?.debug({
527
+ msg: "sending tunnel msg",
528
+ messageId: messageIdStr,
529
+ gatewayId: gatewayIdStr,
530
+ requestId: requestIdStr,
531
+ messageIndex: clientMessageIndex,
532
+ message: stringifyToServerTunnelMessageKind(messageKind),
533
+ });
534
+
535
+ // Send message
536
+ const message: protocol.ToServer = {
537
+ tag: "ToServerTunnelMessage",
538
+ val: {
539
+ messageId,
540
+ messageKind,
541
+ },
542
+ };
543
+ this.#runner.__sendToServer(message);
544
+ }
545
+
546
+ closeActiveRequests(actor: RunnerActor) {
547
+ const actorId = actor.actorId;
548
+
549
+ // Terminate all requests for this actor. This will no send a
550
+ // ToServerResponse* message since the actor will no longer be loaded.
551
+ // The gateway is responsible for closing the request.
552
+ for (const entry of actor.pendingRequests) {
553
+ entry.request.reject(new Error(`Actor ${actorId} stopped`));
554
+ if (entry.gatewayId && entry.requestId) {
555
+ this.#removeRequestToActor(entry.gatewayId, entry.requestId);
556
+ }
557
+ }
558
+
559
+ // Close all WebSockets. Only send close event to non-HWS. The gateway is
560
+ // responsible for hibernating HWS and closing regular WS.
561
+ for (const entry of actor.webSockets) {
562
+ const isHibernatable = entry.ws[HIBERNATABLE_SYMBOL];
563
+ if (!isHibernatable) {
564
+ entry.ws._closeWithoutCallback(1000, "actor.stopped");
565
+ }
566
+ // Note: request-to-actor mapping is cleaned up in the close callback
567
+ }
568
+ }
569
+
570
+ async #fetch(
571
+ actorId: string,
572
+ gatewayId: protocol.GatewayId,
573
+ requestId: protocol.RequestId,
574
+ request: Request,
575
+ ): Promise<Response> {
576
+ // Validate actor exists
577
+ if (!this.#runner.hasActor(actorId)) {
578
+ this.log?.warn({
579
+ msg: "ignoring request for unknown actor",
580
+ actorId,
581
+ });
582
+
583
+ // NOTE: This is a special response that will cause Guard to retry the request
584
+ //
585
+ // See should_retry_request_inner
586
+ // https://github.com/rivet-dev/rivet/blob/222dae87e3efccaffa2b503de40ecf8afd4e31eb/engine/packages/guard-core/src/proxy_service.rs#L2458
587
+ return new Response("Actor not found", {
588
+ status: 503,
589
+ headers: { "x-rivet-error": "runner.actor_not_found" },
590
+ });
591
+ }
592
+
593
+ const fetchHandler = this.#runner.config.fetch(
594
+ this.#runner,
595
+ actorId,
596
+ gatewayId,
597
+ requestId,
598
+ request,
599
+ );
600
+
601
+ if (!fetchHandler) {
602
+ return new Response("Not Implemented", { status: 501 });
603
+ }
604
+
605
+ return fetchHandler;
606
+ }
607
+
608
+ async handleTunnelMessage(message: protocol.ToClientTunnelMessage) {
609
+ // Parse the gateway ID, request ID, and message index from the messageId
610
+ const { gatewayId, requestId, messageIndex } = message.messageId;
611
+
612
+ const gatewayIdStr = idToStr(gatewayId);
613
+ const requestIdStr = idToStr(requestId);
614
+ this.log?.debug({
615
+ msg: "receive tunnel msg",
616
+ gatewayId: gatewayIdStr,
617
+ requestId: requestIdStr,
618
+ messageIndex: message.messageId.messageIndex,
619
+ message: stringifyToClientTunnelMessageKind(message.messageKind),
620
+ });
621
+
622
+ switch (message.messageKind.tag) {
623
+ case "ToClientRequestStart":
624
+ await this.#handleRequestStart(
625
+ gatewayId,
626
+ requestId,
627
+ message.messageKind.val,
628
+ );
629
+ break;
630
+ case "ToClientRequestChunk":
631
+ await this.#handleRequestChunk(
632
+ gatewayId,
633
+ requestId,
634
+ message.messageKind.val,
635
+ );
636
+ break;
637
+ case "ToClientRequestAbort":
638
+ await this.#handleRequestAbort(gatewayId, requestId);
639
+ break;
640
+ case "ToClientWebSocketOpen":
641
+ await this.#handleWebSocketOpen(
642
+ gatewayId,
643
+ requestId,
644
+ message.messageKind.val,
645
+ );
646
+ break;
647
+ case "ToClientWebSocketMessage": {
648
+ await this.#handleWebSocketMessage(
649
+ gatewayId,
650
+ requestId,
651
+ messageIndex,
652
+ message.messageKind.val,
653
+ );
654
+ break;
655
+ }
656
+ case "ToClientWebSocketClose":
657
+ await this.#handleWebSocketClose(
658
+ gatewayId,
659
+ requestId,
660
+ message.messageKind.val,
661
+ );
662
+ break;
663
+ default:
664
+ unreachable(message.messageKind);
665
+ }
666
+ }
667
+
668
+ async #handleRequestStart(
669
+ gatewayId: GatewayId,
670
+ requestId: RequestId,
671
+ req: protocol.ToClientRequestStart,
672
+ ) {
673
+ // Track this request for the actor
674
+ const requestIdStr = idToStr(requestId);
675
+ const actor = await this.#runner.getAndWaitForActor(req.actorId);
676
+ if (!actor) {
677
+ this.log?.warn({
678
+ msg: "actor does not exist in handleRequestStart, request will leak",
679
+ actorId: req.actorId,
680
+ requestId: requestIdStr,
681
+ });
682
+ return;
683
+ }
684
+
685
+ // Add to request-to-actor mapping
686
+ this.addRequestToActor(gatewayId, requestId, req.actorId);
687
+
688
+ try {
689
+ // Convert headers map to Headers object
690
+ const headers = new Headers();
691
+ for (const [key, value] of req.headers) {
692
+ headers.append(key, value);
693
+ }
694
+
695
+ // Create Request object
696
+ const request = new Request(`http://localhost${req.path}`, {
697
+ method: req.method,
698
+ headers,
699
+ body: req.body ? new Uint8Array(req.body) : undefined,
700
+ });
701
+
702
+ // Handle streaming request
703
+ if (req.stream) {
704
+ // Create a stream for the request body
705
+ const stream = new ReadableStream<Uint8Array>({
706
+ start: (controller) => {
707
+ // Store controller for chunks
708
+ const existing = actor.getPendingRequest(
709
+ gatewayId,
710
+ requestId,
711
+ );
712
+ if (existing) {
713
+ existing.streamController = controller;
714
+ existing.actorId = req.actorId;
715
+ existing.gatewayId = gatewayId;
716
+ existing.requestId = requestId;
717
+ } else {
718
+ actor.createPendingRequestWithStreamController(
719
+ gatewayId,
720
+ requestId,
721
+ 0,
722
+ controller,
723
+ );
724
+ }
725
+ },
726
+ });
727
+
728
+ // Create request with streaming body
729
+ const streamingRequest = new Request(request, {
730
+ body: stream,
731
+ duplex: "half",
732
+ } as any);
733
+
734
+ // Call fetch handler with validation
735
+ const response = await this.#fetch(
736
+ req.actorId,
737
+ gatewayId,
738
+ requestId,
739
+ streamingRequest,
740
+ );
741
+ await this.#sendResponse(
742
+ actor.actorId,
743
+ actor.generation,
744
+ gatewayId,
745
+ requestId,
746
+ response,
747
+ );
748
+ } else {
749
+ // Non-streaming request
750
+ // Create a pending request entry to track messageIndex for the response
751
+ actor.createPendingRequest(gatewayId, requestId, 0);
752
+
753
+ const response = await this.#fetch(
754
+ req.actorId,
755
+ gatewayId,
756
+ requestId,
757
+ request,
758
+ );
759
+ await this.#sendResponse(
760
+ actor.actorId,
761
+ actor.generation,
762
+ gatewayId,
763
+ requestId,
764
+ response,
765
+ );
766
+ }
767
+ } catch (error) {
768
+ if (error instanceof RunnerShutdownError) {
769
+ this.log?.debug({ msg: "catught runner shutdown error" });
770
+ } else {
771
+ this.log?.error({ msg: "error handling request", error });
772
+ this.#sendResponseError(
773
+ actor.actorId,
774
+ actor.generation,
775
+ gatewayId,
776
+ requestId,
777
+ 500,
778
+ "Internal Server Error",
779
+ );
780
+ }
781
+ } finally {
782
+ // Clean up request tracking
783
+ if (this.#runner.hasActor(req.actorId, actor.generation)) {
784
+ actor.deletePendingRequest(gatewayId, requestId);
785
+ this.#removeRequestToActor(gatewayId, requestId);
786
+ }
787
+ }
788
+ }
789
+
790
+ async #handleRequestChunk(
791
+ gatewayId: GatewayId,
792
+ requestId: RequestId,
793
+ chunk: protocol.ToClientRequestChunk,
794
+ ) {
795
+ const actor = await this.getAndWaitForRequestActor(
796
+ gatewayId,
797
+ requestId,
798
+ );
799
+ if (actor) {
800
+ const pending = actor.getPendingRequest(gatewayId, requestId);
801
+ if (pending?.streamController) {
802
+ pending.streamController.enqueue(new Uint8Array(chunk.body));
803
+ if (chunk.finish) {
804
+ pending.streamController.close();
805
+ actor.deletePendingRequest(gatewayId, requestId);
806
+ this.#removeRequestToActor(gatewayId, requestId);
807
+ }
808
+ }
809
+ }
810
+ }
811
+
812
+ async #handleRequestAbort(gatewayId: GatewayId, requestId: RequestId) {
813
+ const actor = await this.getAndWaitForRequestActor(
814
+ gatewayId,
815
+ requestId,
816
+ );
817
+ if (actor) {
818
+ const pending = actor.getPendingRequest(gatewayId, requestId);
819
+ if (pending?.streamController) {
820
+ pending.streamController.error(new Error("Request aborted"));
821
+ }
822
+ actor.deletePendingRequest(gatewayId, requestId);
823
+ this.#removeRequestToActor(gatewayId, requestId);
824
+ }
825
+ }
826
+
827
+ async #sendResponse(
828
+ actorId: string,
829
+ generation: number,
830
+ gatewayId: GatewayId,
831
+ requestId: ArrayBuffer,
832
+ response: Response,
833
+ ) {
834
+ if (!this.#runner.hasActor(actorId, generation)) {
835
+ this.log?.warn({
836
+ msg: "actor not loaded to send response, assuming gateway has closed request",
837
+ actorId,
838
+ generation,
839
+ requestId,
840
+ });
841
+ return;
842
+ }
843
+
844
+ // Always treat responses as non-streaming for now
845
+ // In the future, we could detect streaming responses based on:
846
+ // - Transfer-Encoding: chunked
847
+ // - Content-Type: text/event-stream
848
+ // - Explicit stream flag from the handler
849
+
850
+ // Read the body first to get the actual content
851
+ const body = response.body ? await response.arrayBuffer() : null;
852
+
853
+ if (body && body.byteLength > MAX_PAYLOAD_SIZE) {
854
+ throw new Error("Response body too large");
855
+ }
856
+
857
+ // Convert headers to map and add Content-Length if not present
858
+ const headers = new Map<string, string>();
859
+ response.headers.forEach((value, key) => {
860
+ headers.set(key, value);
861
+ });
862
+
863
+ // Add Content-Length header if we have a body and it's not already set
864
+ if (body && !headers.has("content-length")) {
865
+ headers.set("content-length", String(body.byteLength));
866
+ }
867
+
868
+ // Send as non-streaming response if actor has not stopped
869
+ this.#sendMessage(gatewayId, requestId, {
870
+ tag: "ToServerResponseStart",
871
+ val: {
872
+ status: response.status as protocol.u16,
873
+ headers,
874
+ body: body || null,
875
+ stream: false,
876
+ },
877
+ });
878
+ }
879
+
880
+ #sendResponseError(
881
+ actorId: string,
882
+ generation: number,
883
+ gatewayId: GatewayId,
884
+ requestId: ArrayBuffer,
885
+ status: number,
886
+ message: string,
887
+ ) {
888
+ if (!this.#runner.hasActor(actorId, generation)) {
889
+ this.log?.warn({
890
+ msg: "actor not loaded to send response, assuming gateway has closed request",
891
+ actorId,
892
+ generation,
893
+ requestId,
894
+ });
895
+ return;
896
+ }
897
+
898
+ const headers = new Map<string, string>();
899
+ headers.set("content-type", "text/plain");
900
+
901
+ this.#sendMessage(gatewayId, requestId, {
902
+ tag: "ToServerResponseStart",
903
+ val: {
904
+ status: status as protocol.u16,
905
+ headers,
906
+ body: new TextEncoder().encode(message).buffer as ArrayBuffer,
907
+ stream: false,
908
+ },
909
+ });
910
+ }
911
+
912
+ async #handleWebSocketOpen(
913
+ gatewayId: GatewayId,
914
+ requestId: RequestId,
915
+ open: protocol.ToClientWebSocketOpen,
916
+ ) {
917
+ // NOTE: This method is safe to be async since we will not receive any
918
+ // further WebSocket events until we send a ToServerWebSocketOpen
919
+ // tunnel message. We can do any async logic we need to between those two events.
920
+ //
921
+ // Sending a ToServerWebSocketClose will terminate the WebSocket early.
922
+
923
+ const requestIdStr = idToStr(requestId);
924
+
925
+ // Validate actor exists
926
+ const actor = await this.#runner.getAndWaitForActor(open.actorId);
927
+ if (!actor) {
928
+ this.log?.warn({
929
+ msg: "ignoring websocket for unknown actor",
930
+ actorId: open.actorId,
931
+ });
932
+
933
+ // NOTE: Closing a WebSocket before open is equivalent to a Service
934
+ // Unavailable error and will cause Guard to retry the request
935
+ //
936
+ // See
937
+ // https://github.com/rivet-dev/rivet/blob/222dae87e3efccaffa2b503de40ecf8afd4e31eb/engine/packages/pegboard-gateway/src/lib.rs#L238
938
+ this.#sendMessage(gatewayId, requestId, {
939
+ tag: "ToServerWebSocketClose",
940
+ val: {
941
+ code: 1011,
942
+ reason: "Actor not found",
943
+ hibernate: false,
944
+ },
945
+ });
946
+ return;
947
+ }
948
+
949
+ // Close existing WebSocket if one already exists for this request ID.
950
+ // This should never happen, but prevents any potential duplicate
951
+ // WebSockets from retransmits.
952
+ const existingAdapter = actor.getWebSocket(gatewayId, requestId);
953
+ if (existingAdapter) {
954
+ this.log?.warn({
955
+ msg: "closing existing websocket for duplicate open event for the same request id",
956
+ requestId: requestIdStr,
957
+ });
958
+ // Close without sending a message through the tunnel since the server
959
+ // already knows about the new connection
960
+ existingAdapter._closeWithoutCallback(1000, "ws.duplicate_open");
961
+ }
962
+
963
+ // Create WebSocket
964
+ try {
965
+ const request = buildRequestForWebSocket(
966
+ open.path,
967
+ Object.fromEntries(open.headers),
968
+ );
969
+
970
+ const canHibernate =
971
+ this.#runner.config.hibernatableWebSocket.canHibernate(
972
+ actor.actorId,
973
+ gatewayId,
974
+ requestId,
975
+ request,
976
+ );
977
+
978
+ // #createWebSocket will call `runner.config.websocket` under the
979
+ // hood to add the event listeners for open, etc. If this handler
980
+ // throws, then the WebSocket will be closed before sending the
981
+ // open event.
982
+ const adapter = await this.#createWebSocket(
983
+ actor.actorId,
984
+ gatewayId,
985
+ requestId,
986
+ requestIdStr,
987
+ 0,
988
+ canHibernate,
989
+ false,
990
+ request,
991
+ open.path,
992
+ Object.fromEntries(open.headers),
993
+ false,
994
+ );
995
+
996
+ // Create a PendingRequest entry to track the message index
997
+ actor.createPendingRequest(gatewayId, requestId, 0);
998
+
999
+ // Open the WebSocket after `config.socket` so (a) the event
1000
+ // handlers can be added and (b) any errors in `config.websocket`
1001
+ // will cause the WebSocket to terminate before the open event.
1002
+ this.#sendMessage(gatewayId, requestId, {
1003
+ tag: "ToServerWebSocketOpen",
1004
+ val: {
1005
+ canHibernate,
1006
+ },
1007
+ });
1008
+
1009
+ // Dispatch open event
1010
+ adapter._handleOpen(requestId);
1011
+ } catch (error) {
1012
+ this.log?.error({ msg: "error handling websocket open", error });
1013
+
1014
+ // TODO: Call close event on adapter if needed
1015
+
1016
+ // Send close on error
1017
+ this.#sendMessage(gatewayId, requestId, {
1018
+ tag: "ToServerWebSocketClose",
1019
+ val: {
1020
+ code: 1011,
1021
+ reason: "Server Error",
1022
+ hibernate: false,
1023
+ },
1024
+ });
1025
+
1026
+ // Clean up actor tracking
1027
+ actor.deleteWebSocket(gatewayId, requestId);
1028
+ actor.deletePendingRequest(gatewayId, requestId);
1029
+ this.#removeRequestToActor(gatewayId, requestId);
1030
+ }
1031
+ }
1032
+
1033
+ async #handleWebSocketMessage(
1034
+ gatewayId: GatewayId,
1035
+ requestId: RequestId,
1036
+ serverMessageIndex: number,
1037
+ msg: protocol.ToClientWebSocketMessage,
1038
+ ) {
1039
+ const actor = await this.getAndWaitForRequestActor(
1040
+ gatewayId,
1041
+ requestId,
1042
+ );
1043
+ if (actor) {
1044
+ const adapter = actor.getWebSocket(gatewayId, requestId);
1045
+ if (adapter) {
1046
+ const data = msg.binary
1047
+ ? new Uint8Array(msg.data)
1048
+ : new TextDecoder().decode(new Uint8Array(msg.data));
1049
+
1050
+ adapter._handleMessage(
1051
+ requestId,
1052
+ data,
1053
+ serverMessageIndex,
1054
+ msg.binary,
1055
+ );
1056
+ return;
1057
+ }
1058
+ }
1059
+
1060
+ // TODO: This will never retransmit the socket and the socket will close
1061
+ this.log?.warn({
1062
+ msg: "missing websocket for incoming websocket message, this may indicate the actor stopped before processing a message",
1063
+ requestId,
1064
+ });
1065
+ }
1066
+
1067
+ sendHibernatableWebSocketMessageAck(
1068
+ gatewayId: ArrayBuffer,
1069
+ requestId: ArrayBuffer,
1070
+ clientMessageIndex: number,
1071
+ ) {
1072
+ const requestIdStr = idToStr(requestId);
1073
+
1074
+ this.log?.debug({
1075
+ msg: "ack ws msg",
1076
+ requestId: requestIdStr,
1077
+ index: clientMessageIndex,
1078
+ });
1079
+
1080
+ if (clientMessageIndex < 0 || clientMessageIndex > 65535)
1081
+ throw new Error("Invalid websocket ack index");
1082
+
1083
+ // Get the actor to find the gatewayId
1084
+ //
1085
+ // We don't have to wait for the actor to start since we're not calling
1086
+ // any callbacks on the actor
1087
+ const actor = this.getRequestActor(gatewayId, requestId);
1088
+ if (!actor) {
1089
+ this.log?.warn({
1090
+ msg: "cannot send websocket ack, actor not found",
1091
+ requestId: requestIdStr,
1092
+ });
1093
+ return;
1094
+ }
1095
+
1096
+ // Get gatewayId from the pending request
1097
+ const pending = actor.getPendingRequest(gatewayId, requestId);
1098
+ if (!pending?.gatewayId) {
1099
+ this.log?.warn({
1100
+ msg: "cannot send websocket ack, gatewayId not found in pending request",
1101
+ requestId: requestIdStr,
1102
+ });
1103
+ return;
1104
+ }
1105
+
1106
+ // Send the ack message
1107
+ this.#sendMessage(pending.gatewayId, requestId, {
1108
+ tag: "ToServerWebSocketMessageAck",
1109
+ val: {
1110
+ index: clientMessageIndex,
1111
+ },
1112
+ });
1113
+ }
1114
+
1115
+ async #handleWebSocketClose(
1116
+ gatewayId: GatewayId,
1117
+ requestId: RequestId,
1118
+ close: protocol.ToClientWebSocketClose,
1119
+ ) {
1120
+ const actor = await this.getAndWaitForRequestActor(
1121
+ gatewayId,
1122
+ requestId,
1123
+ );
1124
+ if (actor) {
1125
+ const adapter = actor.getWebSocket(gatewayId, requestId);
1126
+ if (adapter) {
1127
+ // We don't need to send a close response
1128
+ adapter._handleClose(
1129
+ requestId,
1130
+ close.code || undefined,
1131
+ close.reason || undefined,
1132
+ );
1133
+ actor.deleteWebSocket(gatewayId, requestId);
1134
+ actor.deletePendingRequest(gatewayId, requestId);
1135
+ this.#removeRequestToActor(gatewayId, requestId);
1136
+ }
1137
+ }
1138
+ }
1139
+ }
1140
+
1141
+ /**
1142
+ * Builds a request that represents the incoming request for a given WebSocket.
1143
+ *
1144
+ * This request is not a real request and will never be sent. It's used to be passed to the actor to behave like a real incoming request.
1145
+ */
1146
+ function buildRequestForWebSocket(
1147
+ path: string,
1148
+ headers: Record<string, string>,
1149
+ ): Request {
1150
+ // We need to manually ensure the original Upgrade/Connection WS
1151
+ // headers are present
1152
+ const fullHeaders = {
1153
+ ...headers,
1154
+ Upgrade: "websocket",
1155
+ Connection: "Upgrade",
1156
+ };
1157
+
1158
+ if (!path.startsWith("/")) {
1159
+ throw new Error("Path must start with leading slash");
1160
+ }
1161
+
1162
+ const request = new Request(`http://actor${path}`, {
1163
+ method: "GET",
1164
+ headers: fullHeaders,
1165
+ });
1166
+
1167
+ return request;
1168
+ }