@rivetkit/engine-runner 2.0.33 → 2.0.34-rc.2

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.
@@ -1,56 +1,26 @@
1
- // WebSocket-like adapter for tunneled connections
2
- // Implements a subset of the WebSocket interface for compatibility with runner code
3
-
4
1
  import type { Logger } from "pino";
5
- import { logger } from "./log";
2
+ import { VirtualWebSocket, type UniversalWebSocket, type RivetMessageEvent } from "@rivetkit/virtual-websocket";
6
3
  import type { Tunnel } from "./tunnel";
7
4
  import { wrappingAddU16, wrappingLteU16, wrappingSubU16 } from "./utils";
8
5
 
9
6
  export const HIBERNATABLE_SYMBOL = Symbol("hibernatable");
10
7
 
11
8
  export class WebSocketTunnelAdapter {
12
- // MARK: - WebSocket Compat Variables
13
- #readyState: number = 0; // CONNECTING
14
- #eventListeners: Map<string, Set<(event: any) => void>> = new Map();
15
- #onopen: ((this: any, ev: any) => any) | null = null;
16
- #onclose: ((this: any, ev: any) => any) | null = null;
17
- #onerror: ((this: any, ev: any) => any) | null = null;
18
- #onmessage: ((this: any, ev: any) => any) | null = null;
19
- #bufferedAmount = 0;
9
+ #readyState: 0 | 1 | 2 | 3 = 0;
20
10
  #binaryType: "nodebuffer" | "arraybuffer" | "blob" = "nodebuffer";
21
- #extensions = "";
22
- #protocol = "";
23
- #url = "";
24
-
25
- // mARK: - Internal State
11
+ #ws: VirtualWebSocket;
26
12
  #tunnel: Tunnel;
27
13
  #actorId: string;
28
14
  #requestId: string;
29
15
  #hibernatable: boolean;
30
16
  #serverMessageIndex: number;
17
+ #sendCallback: (data: ArrayBuffer | string, isBinary: boolean) => void;
18
+ #closeCallback: (code?: number, reason?: string) => void;
31
19
 
32
20
  get [HIBERNATABLE_SYMBOL](): boolean {
33
21
  return this.#hibernatable;
34
22
  }
35
23
 
36
- /**
37
- * Called when sending a message from this WebSocket.
38
- *
39
- * Used to send a tunnel message from Tunnel.
40
- */
41
- #sendCallback: (data: ArrayBuffer | string, isBinary: boolean) => void;
42
-
43
- /**
44
- * Called when closing this WebSocket.
45
- *
46
- * Used to send a tunnel message from Tunnel
47
- */
48
- #closeCallback: (
49
- code?: number,
50
- reason?: string,
51
- hibernate?: boolean,
52
- ) => void;
53
-
54
24
  get #log(): Logger | undefined {
55
25
  return this.#tunnel.log;
56
26
  }
@@ -62,7 +32,6 @@ export class WebSocketTunnelAdapter {
62
32
  serverMessageIndex: number,
63
33
  hibernatable: boolean,
64
34
  isRestoringHibernatable: boolean,
65
- /** @experimental */
66
35
  public readonly request: Request,
67
36
  sendCallback: (data: ArrayBuffer | string, isBinary: boolean) => void,
68
37
  closeCallback: (code?: number, reason?: string) => void,
@@ -75,40 +44,58 @@ export class WebSocketTunnelAdapter {
75
44
  this.#sendCallback = sendCallback;
76
45
  this.#closeCallback = closeCallback;
77
46
 
78
- // For restored WebSockets, immediately set to OPEN state
47
+ this.#ws = new VirtualWebSocket({
48
+ getReadyState: () => this.#readyState,
49
+ onSend: (data) => this.#handleSend(data),
50
+ onClose: (code, reason) => this.#close(code, reason, true),
51
+ onTerminate: () => this.#terminate(),
52
+ });
53
+
79
54
  if (isRestoringHibernatable) {
80
55
  this.#log?.debug({
81
56
  msg: "setting WebSocket to OPEN state for restored connection",
82
57
  actorId: this.#actorId,
83
58
  requestId: this.#requestId,
84
- hibernatable: this.#hibernatable,
85
59
  });
86
- this.#readyState = 1; // OPEN
60
+ this.#readyState = 1;
87
61
  }
88
62
  }
89
63
 
90
- // MARK: - Lifecycle
91
- get bufferedAmount(): number {
92
- return this.#bufferedAmount;
64
+ get websocket(): UniversalWebSocket {
65
+ return this.#ws;
93
66
  }
94
67
 
95
- _handleOpen(requestId: ArrayBuffer): void {
96
- if (this.#readyState !== 0) {
97
- // CONNECTING
98
- return;
99
- }
68
+ #handleSend(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
69
+ let isBinary = false;
70
+ let messageData: string | ArrayBuffer;
100
71
 
101
- this.#readyState = 1; // OPEN
72
+ if (typeof data === "string") {
73
+ messageData = data;
74
+ } else if (data instanceof ArrayBuffer) {
75
+ isBinary = true;
76
+ messageData = data;
77
+ } else if (ArrayBuffer.isView(data)) {
78
+ isBinary = true;
79
+ const view = data;
80
+ const buffer = view.buffer instanceof SharedArrayBuffer
81
+ ? new Uint8Array(view.buffer, view.byteOffset, view.byteLength).slice().buffer
82
+ : view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength);
83
+ messageData = buffer as ArrayBuffer;
84
+ } else {
85
+ throw new Error("Unsupported data type");
86
+ }
102
87
 
103
- const event = {
104
- type: "open",
105
- rivetRequestId: requestId,
106
- target: this,
107
- };
88
+ this.#sendCallback(messageData, isBinary);
89
+ }
108
90
 
109
- this.#fireEvent("open", event);
91
+ // Called by Tunnel when WebSocket is opened
92
+ _handleOpen(requestId: ArrayBuffer): void {
93
+ if (this.#readyState !== 0) return;
94
+ this.#readyState = 1;
95
+ this.#ws.dispatchEvent({ type: "open", rivetRequestId: requestId, target: this.#ws });
110
96
  }
111
97
 
98
+ // Called by Tunnel when message is received
112
99
  _handleMessage(
113
100
  requestId: ArrayBuffer,
114
101
  data: string | Uint8Array,
@@ -121,45 +108,28 @@ export class WebSocketTunnelAdapter {
121
108
  requestId: this.#requestId,
122
109
  actorId: this.#actorId,
123
110
  currentReadyState: this.#readyState,
124
- expectedReadyState: 1,
125
- serverMessageIndex: serverMessageIndex,
126
- hibernatable: this.#hibernatable,
127
111
  });
128
112
  return true;
129
113
  }
130
114
 
131
- // Validate message index
115
+ // Validate message index for hibernatable websockets
132
116
  if (this.#hibernatable) {
133
117
  const previousIndex = this.#serverMessageIndex;
134
118
 
135
- // Ignore duplicate old messages
136
- //
137
- // This should only happen if something goes wrong
138
- // between persisting the previous index and acking the
139
- // message index to the gateway. If the ack is never
140
- // received by the gateway (due to a crash or network
141
- // issue), the gateway will resend all messages from
142
- // the last ack on reconnect.
143
119
  if (wrappingLteU16(serverMessageIndex, previousIndex)) {
144
120
  this.#log?.info({
145
- msg: "received duplicate hibernating websocket message, this indicates the actor failed to ack the message index before restarting",
121
+ msg: "received duplicate hibernating websocket message",
146
122
  requestId,
147
123
  actorId: this.#actorId,
148
124
  previousIndex,
149
- expectedIndex: wrappingAddU16(previousIndex, 1),
150
125
  receivedIndex: serverMessageIndex,
151
126
  });
152
-
153
127
  return true;
154
128
  }
155
129
 
156
- // Close message if skipped message in sequence
157
- //
158
- // There is no scenario where this should ever happen
159
130
  const expectedIndex = wrappingAddU16(previousIndex, 1);
160
131
  if (serverMessageIndex !== expectedIndex) {
161
132
  const closeReason = "ws.message_index_skip";
162
-
163
133
  this.#log?.warn({
164
134
  msg: "hibernatable websocket message index out of sequence, closing connection",
165
135
  requestId,
@@ -168,400 +138,64 @@ export class WebSocketTunnelAdapter {
168
138
  expectedIndex,
169
139
  receivedIndex: serverMessageIndex,
170
140
  closeReason,
171
- gap: wrappingSubU16(
172
- wrappingSubU16(serverMessageIndex, previousIndex),
173
- 1,
174
- ),
141
+ gap: wrappingSubU16(wrappingSubU16(serverMessageIndex, previousIndex), 1),
175
142
  });
176
-
177
- // Close the WebSocket and skip processing
178
- this.close(1008, closeReason);
179
-
143
+ this.#close(1008, closeReason, true);
180
144
  return true;
181
145
  }
182
146
 
183
- // Update to the next index
184
147
  this.#serverMessageIndex = serverMessageIndex;
185
148
  }
186
149
 
187
- // Dispatch event
188
- let messageData: any;
189
- if (isBinary) {
190
- // Handle binary data based on binaryType
150
+ // Convert data based on binaryType
151
+ let messageData: any = data;
152
+ if (isBinary && data instanceof Uint8Array) {
191
153
  if (this.#binaryType === "nodebuffer") {
192
- // Convert to Buffer for Node.js compatibility
193
- messageData = Buffer.from(data as Uint8Array);
154
+ messageData = Buffer.from(data);
194
155
  } else if (this.#binaryType === "arraybuffer") {
195
- // Convert to ArrayBuffer
196
- if (data instanceof Uint8Array) {
197
- messageData = data.buffer.slice(
198
- data.byteOffset,
199
- data.byteOffset + data.byteLength,
200
- );
201
- } else {
202
- messageData = data;
203
- }
204
- } else {
205
- // Blob type - not commonly used in Node.js
206
- throw new Error(
207
- "Blob binaryType not supported in tunnel adapter",
208
- );
156
+ messageData = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
209
157
  }
210
- } else {
211
- messageData = data;
212
158
  }
213
159
 
214
- const event = {
160
+ this.#ws.dispatchEvent({
215
161
  type: "message",
216
162
  data: messageData,
217
163
  rivetRequestId: requestId,
218
164
  rivetMessageIndex: serverMessageIndex,
219
- target: this,
220
- };
221
-
222
- this.#fireEvent("message", event);
165
+ target: this.#ws,
166
+ } as RivetMessageEvent);
223
167
 
224
168
  return false;
225
169
  }
226
170
 
227
- _handleClose(
228
- _requestId: ArrayBuffer,
229
- code?: number,
230
- reason?: string,
231
- ): void {
232
- this.#closeInner(code, reason, true);
233
- }
234
-
235
- _handleError(error: Error): void {
236
- const event = {
237
- type: "error",
238
- target: this,
239
- error,
240
- };
241
-
242
- this.#fireEvent("error", event);
171
+ // Called by Tunnel when close is received
172
+ _handleClose(_requestId: ArrayBuffer, code?: number, reason?: string): void {
173
+ this.#close(code, reason, true);
243
174
  }
244
175
 
176
+ // Close without sending close message to tunnel
245
177
  _closeWithoutCallback(code?: number, reason?: string): void {
246
- this.#closeInner(code, reason, false);
247
- }
248
-
249
- #fireEvent(type: string, event: any): void {
250
- // Call all registered event listeners
251
- const listeners = this.#eventListeners.get(type);
252
-
253
- if (listeners && listeners.size > 0) {
254
- for (const listener of listeners) {
255
- try {
256
- listener.call(this, event);
257
- } catch (error) {
258
- logger()?.error({
259
- msg: "error in websocket event listener",
260
- error,
261
- type,
262
- });
263
- }
264
- }
265
- }
266
-
267
- // Call the onX property if set
268
- switch (type) {
269
- case "open":
270
- if (this.#onopen) {
271
- try {
272
- this.#onopen.call(this, event);
273
- } catch (error) {
274
- logger()?.error({
275
- msg: "error in onopen handler",
276
- error,
277
- });
278
- }
279
- }
280
- break;
281
- case "close":
282
- if (this.#onclose) {
283
- try {
284
- this.#onclose.call(this, event);
285
- } catch (error) {
286
- logger()?.error({
287
- msg: "error in onclose handler",
288
- error,
289
- });
290
- }
291
- }
292
- break;
293
- case "error":
294
- if (this.#onerror) {
295
- try {
296
- this.#onerror.call(this, event);
297
- } catch (error) {
298
- logger()?.error({
299
- msg: "error in onerror handler",
300
- error,
301
- });
302
- }
303
- }
304
- break;
305
- case "message":
306
- if (this.#onmessage) {
307
- try {
308
- this.#onmessage.call(this, event);
309
- } catch (error) {
310
- logger()?.error({
311
- msg: "error in onmessage handler",
312
- error,
313
- });
314
- }
315
- }
316
- break;
317
- }
318
- }
319
-
320
- #closeInner(
321
- code: number | undefined,
322
- reason: string | undefined,
323
- callback: boolean,
324
- ): void {
325
- if (
326
- this.#readyState === 2 || // CLOSING
327
- this.#readyState === 3 // CLOSED
328
- ) {
329
- return;
330
- }
331
-
332
- this.#readyState = 2; // CLOSING
333
-
334
- // Send close through tunnel
335
- if (callback) {
336
- this.#closeCallback(code, reason);
337
- }
338
-
339
- // Update state and fire event
340
- this.#readyState = 3; // CLOSED
341
-
342
- const closeEvent = {
343
- wasClean: true,
344
- code: code || 1000,
345
- reason: reason || "",
346
- type: "close",
347
- target: this,
348
- };
349
-
350
- this.#fireEvent("close", closeEvent);
351
- }
352
-
353
- // MARK: - WebSocket Compatible API
354
- get readyState(): number {
355
- return this.#readyState;
356
- }
357
-
358
- get binaryType(): string {
359
- return this.#binaryType;
360
- }
361
-
362
- set binaryType(value: string) {
363
- if (
364
- value === "nodebuffer" ||
365
- value === "arraybuffer" ||
366
- value === "blob"
367
- ) {
368
- this.#binaryType = value;
369
- }
370
- }
371
-
372
- get extensions(): string {
373
- return this.#extensions;
374
- }
375
-
376
- get protocol(): string {
377
- return this.#protocol;
378
- }
379
-
380
- get url(): string {
381
- return this.#url;
382
- }
383
-
384
- get onopen(): ((this: any, ev: any) => any) | null {
385
- return this.#onopen;
386
- }
387
-
388
- set onopen(value: ((this: any, ev: any) => any) | null) {
389
- this.#onopen = value;
390
- }
391
-
392
- get onclose(): ((this: any, ev: any) => any) | null {
393
- return this.#onclose;
394
- }
395
-
396
- set onclose(value: ((this: any, ev: any) => any) | null) {
397
- this.#onclose = value;
398
- }
399
-
400
- get onerror(): ((this: any, ev: any) => any) | null {
401
- return this.#onerror;
402
- }
403
-
404
- set onerror(value: ((this: any, ev: any) => any) | null) {
405
- this.#onerror = value;
406
- }
407
-
408
- get onmessage(): ((this: any, ev: any) => any) | null {
409
- return this.#onmessage;
410
- }
411
-
412
- set onmessage(value: ((this: any, ev: any) => any) | null) {
413
- this.#onmessage = value;
414
- }
415
-
416
- send(data: string | ArrayBuffer | ArrayBufferView | Blob | Buffer): void {
417
- // Handle different ready states
418
- if (this.#readyState === 0) {
419
- // CONNECTING
420
- throw new DOMException(
421
- "WebSocket is still in CONNECTING state",
422
- "InvalidStateError",
423
- );
424
- }
425
-
426
- if (this.#readyState === 2 || this.#readyState === 3) {
427
- // CLOSING or CLOSED - silently ignore
428
- return;
429
- }
430
-
431
- let isBinary = false;
432
- let messageData: string | ArrayBuffer;
433
-
434
- if (typeof data === "string") {
435
- messageData = data;
436
- } else if (data instanceof ArrayBuffer) {
437
- isBinary = true;
438
- messageData = data;
439
- } else if (ArrayBuffer.isView(data)) {
440
- isBinary = true;
441
- // Convert ArrayBufferView to ArrayBuffer
442
- const view = data as ArrayBufferView;
443
- // Check if it's a SharedArrayBuffer
444
- if (view.buffer instanceof SharedArrayBuffer) {
445
- // Copy SharedArrayBuffer to regular ArrayBuffer
446
- const bytes = new Uint8Array(
447
- view.buffer,
448
- view.byteOffset,
449
- view.byteLength,
450
- );
451
- messageData = bytes.buffer.slice(
452
- bytes.byteOffset,
453
- bytes.byteOffset + bytes.byteLength,
454
- ) as unknown as ArrayBuffer;
455
- } else {
456
- messageData = view.buffer.slice(
457
- view.byteOffset,
458
- view.byteOffset + view.byteLength,
459
- ) as ArrayBuffer;
460
- }
461
- } else if (data instanceof Blob) {
462
- throw new Error("Blob sending not implemented in tunnel adapter");
463
- } else if (typeof Buffer !== "undefined" && Buffer.isBuffer(data)) {
464
- isBinary = true;
465
- // Convert Buffer to ArrayBuffer
466
- const buf = data as Buffer;
467
- // Check if it's a SharedArrayBuffer
468
- if (buf.buffer instanceof SharedArrayBuffer) {
469
- // Copy SharedArrayBuffer to regular ArrayBuffer
470
- const bytes = new Uint8Array(
471
- buf.buffer,
472
- buf.byteOffset,
473
- buf.byteLength,
474
- );
475
- messageData = bytes.buffer.slice(
476
- bytes.byteOffset,
477
- bytes.byteOffset + bytes.byteLength,
478
- ) as unknown as ArrayBuffer;
479
- } else {
480
- messageData = buf.buffer.slice(
481
- buf.byteOffset,
482
- buf.byteOffset + buf.byteLength,
483
- ) as ArrayBuffer;
484
- }
485
- } else {
486
- throw new Error("Invalid data type");
487
- }
488
-
489
- // Send through tunnel
490
- this.#sendCallback(messageData, isBinary);
178
+ this.#close(code, reason, false);
491
179
  }
492
180
 
181
+ // Public close method (used by tunnel.ts for stale websocket cleanup)
493
182
  close(code?: number, reason?: string): void {
494
- this.#closeInner(code, reason, true);
183
+ this.#close(code, reason, true);
495
184
  }
496
185
 
497
- addEventListener(
498
- type: string,
499
- listener: (event: any) => void,
500
- options?: boolean | any,
501
- ): void {
502
- if (typeof listener === "function") {
503
- let listeners = this.#eventListeners.get(type);
504
- if (!listeners) {
505
- listeners = new Set();
506
- this.#eventListeners.set(type, listeners);
507
- }
508
- listeners.add(listener);
509
- }
510
- }
511
-
512
- removeEventListener(
513
- type: string,
514
- listener: (event: any) => void,
515
- options?: boolean | any,
516
- ): void {
517
- if (typeof listener === "function") {
518
- const listeners = this.#eventListeners.get(type);
519
- if (listeners) {
520
- listeners.delete(listener);
521
- }
522
- }
523
- }
524
-
525
- dispatchEvent(event: any): boolean {
526
- // TODO:
527
- return true;
528
- }
529
-
530
- static readonly CONNECTING = 0;
531
- static readonly OPEN = 1;
532
- static readonly CLOSING = 2;
533
- static readonly CLOSED = 3;
186
+ #close(code: number | undefined, reason: string | undefined, sendCallback: boolean): void {
187
+ if (this.#readyState >= 2) return;
534
188
 
535
- readonly CONNECTING = 0;
536
- readonly OPEN = 1;
537
- readonly CLOSING = 2;
538
- readonly CLOSED = 3;
539
-
540
- // Additional methods for compatibility
541
- ping(data?: any, mask?: boolean, cb?: (err: Error) => void): void {
542
- // Not implemented for tunnel - could be added if needed
543
- if (cb) cb(new Error("Ping not supported in tunnel adapter"));
189
+ this.#readyState = 2;
190
+ if (sendCallback) this.#closeCallback(code, reason);
191
+ this.#readyState = 3;
192
+ this.#ws.triggerClose(code ?? 1000, reason ?? "");
544
193
  }
545
194
 
546
- pong(data?: any, mask?: boolean, cb?: (err: Error) => void): void {
547
- // Not implemented for tunnel - could be added if needed
548
- if (cb) cb(new Error("Pong not supported in tunnel adapter"));
549
- }
550
-
551
- /** @experimental */
552
- terminate(): void {
195
+ #terminate(): void {
553
196
  // Immediate close without close frame
554
- this.#readyState = 3; // CLOSED
197
+ this.#readyState = 3;
555
198
  this.#closeCallback(1006, "Abnormal Closure");
556
-
557
- const event = {
558
- wasClean: false,
559
- code: 1006,
560
- reason: "Abnormal Closure",
561
- type: "close",
562
- target: this,
563
- };
564
-
565
- this.#fireEvent("close", event);
199
+ this.#ws.triggerClose(1006, "Abnormal Closure", false);
566
200
  }
567
201
  }