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