@rivetkit/engine-runner 2.0.23 → 2.0.24

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