@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/utils.ts ADDED
@@ -0,0 +1,175 @@
1
+ import { logger } from "./log";
2
+
3
+ export function unreachable(x: never): never {
4
+ throw `Unreachable: ${x}`;
5
+ }
6
+
7
+ export interface BackoffOptions {
8
+ initialDelay?: number;
9
+ maxDelay?: number;
10
+ multiplier?: number;
11
+ jitter?: boolean;
12
+ }
13
+
14
+ export function calculateBackoff(
15
+ attempt: number,
16
+ options: BackoffOptions = {},
17
+ ): number {
18
+ const {
19
+ initialDelay = 1000,
20
+ maxDelay = 30000,
21
+ multiplier = 2,
22
+ jitter = true,
23
+ } = options;
24
+
25
+ let delay = Math.min(initialDelay * multiplier ** attempt, maxDelay);
26
+
27
+ if (jitter) {
28
+ // Add random jitter between 0% and 25% of the delay
29
+ delay = delay * (1 + Math.random() * 0.25);
30
+ }
31
+
32
+ return Math.floor(delay);
33
+ }
34
+
35
+ export interface ParsedCloseReason {
36
+ group: string;
37
+ error: string;
38
+ rayId?: string;
39
+ }
40
+
41
+ /**
42
+ * Parses a WebSocket close reason in the format: {group}.{error} or {group}.{error}#{ray_id}
43
+ *
44
+ * Examples:
45
+ * - "ws.eviction#t1s80so6h3irenp8ymzltfoittcl00"
46
+ * - "ws.client_closed"
47
+ *
48
+ * Returns undefined if the format is invalid
49
+ */
50
+ export function parseWebSocketCloseReason(
51
+ reason: string,
52
+ ): ParsedCloseReason | undefined {
53
+ const [mainPart, rayId] = reason.split("#");
54
+ const [group, error] = mainPart.split(".");
55
+
56
+ if (!group || !error) {
57
+ logger()?.warn({ msg: "failed to parse close reason", reason });
58
+ return undefined;
59
+ }
60
+
61
+ return {
62
+ group,
63
+ error,
64
+ rayId,
65
+ };
66
+ }
67
+
68
+ const U16_MAX = 65535;
69
+
70
+ /**
71
+ * Wrapping greater than comparison for u16 values.
72
+ * Based on shared_state.rs wrapping_gt implementation.
73
+ */
74
+ export function wrappingGtU16(a: number, b: number): boolean {
75
+ return a !== b && wrappingSub(a, b, U16_MAX) < U16_MAX / 2;
76
+ }
77
+
78
+ /**
79
+ * Wrapping less than comparison for u16 values.
80
+ * Based on shared_state.rs wrapping_lt implementation.
81
+ */
82
+ export function wrappingLtU16(a: number, b: number): boolean {
83
+ return a !== b && wrappingSub(b, a, U16_MAX) < U16_MAX / 2;
84
+ }
85
+
86
+ /**
87
+ * Wrapping greater than or equal comparison for u16 values.
88
+ */
89
+ export function wrappingGteU16(a: number, b: number): boolean {
90
+ return a === b || wrappingGtU16(a, b);
91
+ }
92
+
93
+ /**
94
+ * Wrapping less than or equal comparison for u16 values.
95
+ */
96
+ export function wrappingLteU16(a: number, b: number): boolean {
97
+ return a === b || wrappingLtU16(a, b);
98
+ }
99
+
100
+ /**
101
+ * Performs wrapping addition for u16 values.
102
+ */
103
+ export function wrappingAddU16(a: number, b: number): number {
104
+ return (a + b) % (U16_MAX + 1);
105
+ }
106
+
107
+ /**
108
+ * Performs wrapping subtraction for u16 values.
109
+ */
110
+ export function wrappingSubU16(a: number, b: number): number {
111
+ return wrappingSub(a, b, U16_MAX);
112
+ }
113
+
114
+ /**
115
+ * Performs wrapping subtraction for unsigned integers.
116
+ */
117
+ function wrappingSub(a: number, b: number, max: number): number {
118
+ const result = a - b;
119
+ if (result < 0) {
120
+ return result + max + 1;
121
+ }
122
+ return result;
123
+ }
124
+
125
+ export function arraysEqual(a: ArrayBuffer, b: ArrayBuffer): boolean {
126
+ const ua = new Uint8Array(a);
127
+ const ub = new Uint8Array(b);
128
+ if (ua.length !== ub.length) return false;
129
+ for (let i = 0; i < ua.length; i++) {
130
+ if (ua[i] !== ub[i]) return false;
131
+ }
132
+ return true;
133
+ }
134
+
135
+ /**
136
+ * Polyfill for Promise.withResolvers().
137
+ *
138
+ * This is specifically for Cloudflare Workers. Their implementation of Promise.withResolvers does not work correctly.
139
+ */
140
+ export function promiseWithResolvers<T>(): {
141
+ promise: Promise<T>;
142
+ resolve: (value: T | PromiseLike<T>) => void;
143
+ reject: (reason?: any) => void;
144
+ } {
145
+ let resolve!: (value: T | PromiseLike<T>) => void;
146
+ let reject!: (reason?: any) => void;
147
+ const promise = new Promise<T>((res, rej) => {
148
+ resolve = res;
149
+ reject = rej;
150
+ });
151
+ return { promise, resolve, reject };
152
+ }
153
+
154
+ export function idToStr(id: ArrayBuffer): string {
155
+ const bytes = new Uint8Array(id);
156
+ return Array.from(bytes)
157
+ .map((byte) => byte.toString(16).padStart(2, "0"))
158
+ .join("");
159
+ }
160
+
161
+ export function stringifyError(error: unknown): string {
162
+ if (error instanceof Error) {
163
+ return `${error.name}: ${error.message}${error.stack ? `\n${error.stack}` : ""}`;
164
+ } else if (typeof error === "string") {
165
+ return error;
166
+ } else if (typeof error === "object" && error !== null) {
167
+ try {
168
+ return `${JSON.stringify(error)}`;
169
+ } catch {
170
+ return `[object ${error.constructor?.name || "Object"}]`;
171
+ }
172
+ } else {
173
+ return String(error);
174
+ }
175
+ }
@@ -0,0 +1,201 @@
1
+ import type { Logger } from "pino";
2
+ import { VirtualWebSocket, type UniversalWebSocket, type RivetMessageEvent } from "@rivetkit/virtual-websocket";
3
+ import type { Tunnel } from "./tunnel";
4
+ import { wrappingAddU16, wrappingLteU16, wrappingSubU16 } from "./utils";
5
+
6
+ export const HIBERNATABLE_SYMBOL = Symbol("hibernatable");
7
+
8
+ export class WebSocketTunnelAdapter {
9
+ #readyState: 0 | 1 | 2 | 3 = 0;
10
+ #binaryType: "nodebuffer" | "arraybuffer" | "blob" = "nodebuffer";
11
+ #ws: VirtualWebSocket;
12
+ #tunnel: Tunnel;
13
+ #actorId: string;
14
+ #requestId: string;
15
+ #hibernatable: boolean;
16
+ #serverMessageIndex: number;
17
+ #sendCallback: (data: ArrayBuffer | string, isBinary: boolean) => void;
18
+ #closeCallback: (code?: number, reason?: string) => void;
19
+
20
+ get [HIBERNATABLE_SYMBOL](): boolean {
21
+ return this.#hibernatable;
22
+ }
23
+
24
+ get #log(): Logger | undefined {
25
+ return this.#tunnel.log;
26
+ }
27
+
28
+ constructor(
29
+ tunnel: Tunnel,
30
+ actorId: string,
31
+ requestId: string,
32
+ serverMessageIndex: number,
33
+ hibernatable: boolean,
34
+ isRestoringHibernatable: boolean,
35
+ public readonly request: Request,
36
+ sendCallback: (data: ArrayBuffer | string, isBinary: boolean) => void,
37
+ closeCallback: (code?: number, reason?: string) => void,
38
+ ) {
39
+ this.#tunnel = tunnel;
40
+ this.#actorId = actorId;
41
+ this.#requestId = requestId;
42
+ this.#hibernatable = hibernatable;
43
+ this.#serverMessageIndex = serverMessageIndex;
44
+ this.#sendCallback = sendCallback;
45
+ this.#closeCallback = closeCallback;
46
+
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
+
54
+ if (isRestoringHibernatable) {
55
+ this.#log?.debug({
56
+ msg: "setting WebSocket to OPEN state for restored connection",
57
+ actorId: this.#actorId,
58
+ requestId: this.#requestId,
59
+ });
60
+ this.#readyState = 1;
61
+ }
62
+ }
63
+
64
+ get websocket(): UniversalWebSocket {
65
+ return this.#ws;
66
+ }
67
+
68
+ #handleSend(data: string | ArrayBufferLike | Blob | ArrayBufferView): void {
69
+ let isBinary = false;
70
+ let messageData: string | ArrayBuffer;
71
+
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
+ }
87
+
88
+ this.#sendCallback(messageData, isBinary);
89
+ }
90
+
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 });
96
+ }
97
+
98
+ // Called by Tunnel when message is received
99
+ _handleMessage(
100
+ requestId: ArrayBuffer,
101
+ data: string | Uint8Array,
102
+ serverMessageIndex: number,
103
+ isBinary: boolean,
104
+ ): boolean {
105
+ if (this.#readyState !== 1) {
106
+ this.#log?.warn({
107
+ msg: "WebSocket message ignored - not in OPEN state",
108
+ requestId: this.#requestId,
109
+ actorId: this.#actorId,
110
+ currentReadyState: this.#readyState,
111
+ });
112
+ return true;
113
+ }
114
+
115
+ // Validate message index for hibernatable websockets
116
+ if (this.#hibernatable) {
117
+ const previousIndex = this.#serverMessageIndex;
118
+
119
+ if (wrappingLteU16(serverMessageIndex, previousIndex)) {
120
+ this.#log?.info({
121
+ msg: "received duplicate hibernating websocket message",
122
+ requestId,
123
+ actorId: this.#actorId,
124
+ previousIndex,
125
+ receivedIndex: serverMessageIndex,
126
+ });
127
+ return true;
128
+ }
129
+
130
+ const expectedIndex = wrappingAddU16(previousIndex, 1);
131
+ if (serverMessageIndex !== expectedIndex) {
132
+ const closeReason = "ws.message_index_skip";
133
+ this.#log?.warn({
134
+ msg: "hibernatable websocket message index out of sequence, closing connection",
135
+ requestId,
136
+ actorId: this.#actorId,
137
+ previousIndex,
138
+ expectedIndex,
139
+ receivedIndex: serverMessageIndex,
140
+ closeReason,
141
+ gap: wrappingSubU16(wrappingSubU16(serverMessageIndex, previousIndex), 1),
142
+ });
143
+ this.#close(1008, closeReason, true);
144
+ return true;
145
+ }
146
+
147
+ this.#serverMessageIndex = serverMessageIndex;
148
+ }
149
+
150
+ // Convert data based on binaryType
151
+ let messageData: any = data;
152
+ if (isBinary && data instanceof Uint8Array) {
153
+ if (this.#binaryType === "nodebuffer") {
154
+ messageData = Buffer.from(data);
155
+ } else if (this.#binaryType === "arraybuffer") {
156
+ messageData = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
157
+ }
158
+ }
159
+
160
+ this.#ws.dispatchEvent({
161
+ type: "message",
162
+ data: messageData,
163
+ rivetRequestId: requestId,
164
+ rivetMessageIndex: serverMessageIndex,
165
+ target: this.#ws,
166
+ } as RivetMessageEvent);
167
+
168
+ return false;
169
+ }
170
+
171
+ // Called by Tunnel when close is received
172
+ _handleClose(_requestId: ArrayBuffer, code?: number, reason?: string): void {
173
+ this.#close(code, reason, true);
174
+ }
175
+
176
+ // Close without sending close message to tunnel
177
+ _closeWithoutCallback(code?: number, reason?: string): void {
178
+ this.#close(code, reason, false);
179
+ }
180
+
181
+ // Public close method (used by tunnel.ts for stale websocket cleanup)
182
+ close(code?: number, reason?: string): void {
183
+ this.#close(code, reason, true);
184
+ }
185
+
186
+ #close(code: number | undefined, reason: string | undefined, sendCallback: boolean): void {
187
+ if (this.#readyState >= 2) return;
188
+
189
+ this.#readyState = 2;
190
+ if (sendCallback) this.#closeCallback(code, reason);
191
+ this.#readyState = 3;
192
+ this.#ws.triggerClose(code ?? 1000, reason ?? "");
193
+ }
194
+
195
+ #terminate(): void {
196
+ // Immediate close without close frame
197
+ this.#readyState = 3;
198
+ this.#closeCallback(1006, "Abnormal Closure");
199
+ this.#ws.triggerClose(1006, "Abnormal Closure", false);
200
+ }
201
+ }
@@ -0,0 +1,43 @@
1
+ import { logger } from "./log";
2
+
3
+ // Global singleton promise that will be reused for subsequent calls
4
+ let webSocketPromise: Promise<typeof WebSocket> | null = null;
5
+
6
+ export async function importWebSocket(): Promise<typeof WebSocket> {
7
+ // Return existing promise if we already started loading
8
+ if (webSocketPromise !== null) {
9
+ return webSocketPromise;
10
+ }
11
+
12
+ // Create and store the promise
13
+ webSocketPromise = (async () => {
14
+ let _WebSocket: typeof WebSocket;
15
+
16
+ if (typeof WebSocket !== "undefined") {
17
+ // Native
18
+ _WebSocket = WebSocket as unknown as typeof WebSocket;
19
+ logger()?.debug({ msg: "using native websocket" });
20
+ } else {
21
+ // Node.js package
22
+ try {
23
+ const ws = await import("ws");
24
+ _WebSocket = ws.default as unknown as typeof WebSocket;
25
+ logger()?.debug({ msg: "using websocket from npm" });
26
+ } catch {
27
+ // WS not available
28
+ _WebSocket = class MockWebSocket {
29
+ constructor() {
30
+ throw new Error(
31
+ 'WebSocket support requires installing the "ws" peer dependency.',
32
+ );
33
+ }
34
+ } as unknown as typeof WebSocket;
35
+ logger()?.debug({ msg: "using mock websocket" });
36
+ }
37
+ }
38
+
39
+ return _WebSocket;
40
+ })();
41
+
42
+ return webSocketPromise;
43
+ }