@rivetkit/engine-runner 0.0.0-main.d6a0ba8

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/mod.ts ADDED
@@ -0,0 +1,1927 @@
1
+ import * as protocol from "@rivetkit/engine-runner-protocol";
2
+ import type { Logger } from "pino";
3
+ import type WebSocket from "ws";
4
+ import { type ActorConfig, RunnerActor } from "./actor";
5
+ import { logger, setLogger } from "./log.js";
6
+ import { stringifyToClient, stringifyToServer } from "./stringify";
7
+ import { type HibernatingWebSocketMetadata, Tunnel } from "./tunnel";
8
+ import {
9
+ calculateBackoff,
10
+ parseWebSocketCloseReason,
11
+ stringifyError,
12
+ unreachable,
13
+ } from "./utils";
14
+ import { importWebSocket } from "./websocket.js";
15
+ import {
16
+ v4 as uuidv4,
17
+ } from "uuid";
18
+
19
+ export type { HibernatingWebSocketMetadata };
20
+ export { RunnerActor, type ActorConfig };
21
+ export { idToStr } from "./utils";
22
+
23
+ const KV_EXPIRE: number = 30_000;
24
+ const PROTOCOL_VERSION: number = 7;
25
+
26
+ /** Warn once the backlog significantly exceeds the server's ack batch size. */
27
+ const EVENT_BACKLOG_WARN_THRESHOLD = 10_000;
28
+ const SIGNAL_HANDLERS: (() => void | Promise<void>)[] = [];
29
+
30
+ export class RunnerShutdownError extends Error {
31
+ constructor() {
32
+ super("Runner shut down");
33
+ }
34
+ }
35
+
36
+ export interface RunnerConfig {
37
+ logger?: Logger;
38
+ version: number;
39
+ endpoint: string;
40
+ token?: string;
41
+ pegboardEndpoint?: string;
42
+ pegboardRelayEndpoint?: string;
43
+ namespace: string;
44
+ totalSlots: number;
45
+ runnerName: string;
46
+ prepopulateActorNames: Record<string, { metadata: Record<string, any> }>;
47
+ metadata?: Record<string, any>;
48
+ onConnected: () => void;
49
+ onDisconnected: (code: number, reason: string) => void;
50
+ onShutdown: () => void;
51
+
52
+ /** Called when receiving a network request. */
53
+ fetch: (
54
+ runner: Runner,
55
+ actorId: string,
56
+ gatewayId: protocol.GatewayId,
57
+ requestId: protocol.RequestId,
58
+ request: Request,
59
+ ) => Promise<Response>;
60
+
61
+ /**
62
+ * Called when receiving a WebSocket connection.
63
+ *
64
+ * All event listeners must be added synchronously inside this function or
65
+ * else events may be missed. The open event will fire immediately after
66
+ * this function finishes.
67
+ *
68
+ * Any errors thrown here will disconnect the WebSocket immediately.
69
+ *
70
+ * While `path` and `headers` are partially redundant to the data in the
71
+ * `Request`, they may vary slightly from the actual content of `Request`.
72
+ * Prefer to persist the `path` and `headers` properties instead of the
73
+ * `Request` itself.
74
+ *
75
+ * ## Hibernating Web Sockets
76
+ *
77
+ * ### Implementation Requirements
78
+ *
79
+ * **Requirement 1: Persist HWS Immediately**
80
+ *
81
+ * This is responsible for persisting hibernatable WebSockets immediately
82
+ * (do not wait for open event). It is not time sensitive to flush the
83
+ * connection state. If this fails to persist the HWS, the client's
84
+ * WebSocket will be disconnected on next wake in the call to
85
+ * `Tunnel::restoreHibernatingRequests` since the connection entry will not
86
+ * exist.
87
+ *
88
+ * **Requirement 2: Persist Message Index On `message`**
89
+ *
90
+ * In the `message` event listener, this handler must persist the message
91
+ * index from the event. The request ID is available at
92
+ * `event.rivetRequestId` and message index at `event.rivetMessageIndex`.
93
+ *
94
+ * The message index should not be flushed immediately. Instead, this
95
+ * should:
96
+ *
97
+ * - Debounce calls to persist the message index
98
+ * - After each persist, call
99
+ * `Runner::sendHibernatableWebSocketMessageAck` to acknowledge the
100
+ * message
101
+ *
102
+ * This mechanism allows us to buffer messages on the gateway so we can
103
+ * batch-persist events on our end on a given interval.
104
+ *
105
+ * If this fails to persist, then the gateway will replay unacked
106
+ * messages when the actor starts again.
107
+ *
108
+ * **Requirement 3: Remove HWS From Storage On `close`**
109
+ *
110
+ * This handler should add an event listener for `close` to remove the
111
+ * connection from storage.
112
+ *
113
+ * If the connection remove fails to persist, the close event will be
114
+ * called again on the next actor start in
115
+ * `Tunnel::restoreHibernatingRequests` since there will be no request for
116
+ * the given connection.
117
+ *
118
+ * ### Restoring Connections
119
+ *
120
+ * The user of this library is responsible for:
121
+ * 1. Loading all persisted hibernatable WebSocket metadata for an actor
122
+ * 2. Calling `Runner::restoreHibernatingRequests` with this metadata at
123
+ * the end of `onActorStart`
124
+ *
125
+ * `restoreHibernatingRequests` will restore all connections and attach
126
+ * the appropriate event listeners.
127
+ *
128
+ * ### No Open Event On Restoration
129
+ *
130
+ * When restoring a HWS, the open event will not be called again. It will
131
+ * go straight to the message or close event.
132
+ */
133
+ websocket: (
134
+ runner: Runner,
135
+ actorId: string,
136
+ ws: any,
137
+ gatewayId: protocol.GatewayId,
138
+ requestId: protocol.RequestId,
139
+ request: Request,
140
+ path: string,
141
+ headers: Record<string, string>,
142
+ isHibernatable: boolean,
143
+ isRestoringHibernatable: boolean,
144
+ ) => Promise<void>;
145
+
146
+ hibernatableWebSocket: {
147
+ /**
148
+ * Determines if a WebSocket can continue to live while an actor goes to
149
+ * sleep.
150
+ */
151
+ canHibernate: (
152
+ actorId: string,
153
+ gatewayId: ArrayBuffer,
154
+ requestId: ArrayBuffer,
155
+ request: Request,
156
+ ) => boolean;
157
+ };
158
+
159
+ /**
160
+ * Called when an actor starts.
161
+ *
162
+ * This callback is responsible for:
163
+ * 1. Initializing the actor instance
164
+ * 2. Loading all persisted hibernatable WebSocket metadata for this actor
165
+ * 3. Calling `Runner::restoreHibernatingRequests` with the loaded metadata
166
+ * to restore hibernatable WebSocket connections
167
+ *
168
+ * The actor should not be marked as "ready" until after
169
+ * `restoreHibernatingRequests` completes to ensure all hibernatable
170
+ * connections are fully restored before the actor processes new requests.
171
+ */
172
+ onActorStart: (
173
+ actorId: string,
174
+ generation: number,
175
+ config: ActorConfig,
176
+ ) => Promise<void>;
177
+
178
+ onActorStop: (actorId: string, generation: number) => Promise<void>;
179
+ noAutoShutdown?: boolean;
180
+
181
+ /**
182
+ * Debug option to inject artificial latency (in ms) into WebSocket
183
+ * communication. Messages are queued and delivered in order after the
184
+ * configured delay.
185
+ *
186
+ * @experimental For testing only.
187
+ */
188
+ debugLatencyMs?: number;
189
+ }
190
+
191
+ export interface KvListOptions {
192
+ reverse?: boolean;
193
+ limit?: number;
194
+ }
195
+
196
+ interface KvRequestEntry {
197
+ actorId: string;
198
+ data: protocol.KvRequestData;
199
+ resolve: (value: any) => void;
200
+ reject: (error: unknown) => void;
201
+ sent: boolean;
202
+ timestamp: number;
203
+ }
204
+
205
+ export class Runner {
206
+ #config: RunnerConfig;
207
+ #runnerKey: string = uuidv4();
208
+
209
+ get config(): RunnerConfig {
210
+ return this.#config;
211
+ }
212
+
213
+ #actors: Map<string, RunnerActor> = new Map();
214
+
215
+ // WebSocket
216
+ #pegboardWebSocket?: WebSocket;
217
+ runnerId?: string;
218
+ #started: boolean = false;
219
+ #shutdown: boolean = false;
220
+ #draining: boolean = false;
221
+ #reconnectAttempt: number = 0;
222
+ #reconnectTimeout?: NodeJS.Timeout;
223
+
224
+ // Protocol metadata
225
+ #protocolMetadata?: protocol.ProtocolMetadata;
226
+
227
+ // Runner lost threshold management
228
+ #runnerLostTimeout?: NodeJS.Timeout;
229
+
230
+ // Event storage for resending
231
+ #eventBacklogWarned: boolean = false;
232
+
233
+ // Command acknowledgment
234
+ #ackInterval?: NodeJS.Timeout;
235
+
236
+ // KV operations
237
+ #nextKvRequestId: number = 0;
238
+ #kvRequests: Map<number, KvRequestEntry> = new Map();
239
+ #kvCleanupInterval?: NodeJS.Timeout;
240
+
241
+ // Tunnel for HTTP/WebSocket forwarding
242
+ #tunnel: Tunnel | undefined;
243
+
244
+ // Cached child logger with runner-specific attributes
245
+ #logCached?: Logger;
246
+
247
+ get log(): Logger | undefined {
248
+ if (this.#logCached) return this.#logCached;
249
+
250
+ const l = logger();
251
+ if (l) {
252
+ // If has connected, create child logger with relevant metadata
253
+ //
254
+ // Otherwise, return default logger
255
+ if (this.runnerId) {
256
+ this.#logCached = l.child({
257
+ runnerId: this.runnerId,
258
+ });
259
+ return this.#logCached;
260
+ } else {
261
+ return l;
262
+ }
263
+ }
264
+
265
+ return undefined;
266
+ }
267
+
268
+ constructor(config: RunnerConfig) {
269
+ this.#config = config;
270
+ if (this.#config.logger) setLogger(this.#config.logger);
271
+
272
+ // Start cleaning up old unsent KV requests every 15 seconds
273
+ this.#kvCleanupInterval = setInterval(() => {
274
+ try {
275
+ this.#cleanupOldKvRequests();
276
+ } catch (err) {
277
+ this.log?.error({
278
+ msg: "error cleaning up kv requests",
279
+ error: stringifyError(err),
280
+ });
281
+ }
282
+ }, 15000); // Run every 15 seconds
283
+ }
284
+
285
+ // MARK: Manage actors
286
+ sleepActor(actorId: string, generation?: number) {
287
+ const actor = this.getActor(actorId, generation);
288
+ if (!actor) return;
289
+
290
+ // Keep the actor instance in memory during sleep
291
+ this.#sendActorIntent(actorId, actor.generation, "sleep");
292
+
293
+ // NOTE: We do NOT remove the actor from this.#actors here
294
+ // The server will send a StopActor command if it wants to fully stop
295
+ }
296
+
297
+ async stopActor(actorId: string, generation?: number) {
298
+ const actor = this.getActor(actorId, generation);
299
+ if (!actor) return;
300
+
301
+ this.#sendActorIntent(actorId, actor.generation, "stop");
302
+
303
+ // NOTE: We do NOT remove the actor from this.#actors here
304
+ // The server will send a StopActor command if it wants to fully stop
305
+ }
306
+
307
+ /**
308
+ * Like stopActor but marks the actor for graceful destruction.
309
+ * This ensures the engine destroys the actor instead of sleeping it.
310
+ *
311
+ * NOTE: If a drain (GoingAway) occurs after this is called but before the
312
+ * stop completes, the engine's going_away flag overrides graceful_exit and
313
+ * the actor will sleep instead of being destroyed. The destroy intent is
314
+ * lost in this race. This is acceptable since the actor will be rescheduled
315
+ * elsewhere and can be destroyed on the next wake.
316
+ */
317
+ destroyActor(actorId: string, generation?: number) {
318
+ const actor = this.getActor(actorId, generation);
319
+ if (!actor) return;
320
+
321
+ actor.stopIntentSent = true;
322
+ this.#sendActorIntent(actorId, actor.generation, "stop");
323
+ }
324
+
325
+ async forceStopActor(actorId: string, generation?: number) {
326
+ this.log?.debug({
327
+ msg: "force stopping actor",
328
+ actorId,
329
+ });
330
+
331
+ const actor = this.getActor(actorId, generation);
332
+ if (!actor) return;
333
+
334
+ // If onActorStop times out, Pegboard will handle this timeout with ACTOR_STOP_THRESHOLD_DURATION_MS
335
+ //
336
+ // If we receive a request while onActorStop is running, a Service
337
+ // Unavailable error will be returned to Guard and the request will be
338
+ // retried
339
+ try {
340
+ await this.#config.onActorStop(actorId, actor.generation);
341
+ } catch (err) {
342
+ console.error(`Error in onActorStop for actor ${actorId}:`, err);
343
+ }
344
+
345
+ // Close requests after onActorStop so you can send messages over the tunnel
346
+ this.#tunnel?.closeActiveRequests(actor);
347
+
348
+ this.#sendActorStateUpdate(actorId, actor.generation, "stopped");
349
+
350
+ // Remove actor after stopping in order to ensure that we can still
351
+ // call actions on the runner
352
+ this.#removeActor(actorId, generation);
353
+ }
354
+
355
+ #handleLost() {
356
+ this.log?.info({
357
+ msg: "stopping all actors due to runner lost threshold",
358
+ });
359
+
360
+ // Remove all remaining kv requests
361
+ for (const [_, request] of this.#kvRequests.entries()) {
362
+ request.reject(new RunnerShutdownError());
363
+ }
364
+
365
+ this.#kvRequests.clear();
366
+
367
+ this.#stopAllActors();
368
+ }
369
+
370
+ #stopAllActors() {
371
+ const actorIds = Array.from(this.#actors.keys());
372
+ for (const actorId of actorIds) {
373
+ this.forceStopActor(actorId).catch((err) => {
374
+ this.log?.error({
375
+ msg: "error stopping actor",
376
+ actorId,
377
+ error: stringifyError(err),
378
+ });
379
+ });
380
+ }
381
+ }
382
+
383
+ getActor(actorId: string, generation?: number): RunnerActor | undefined {
384
+ const actor = this.#actors.get(actorId);
385
+ if (!actor) {
386
+ this.log?.warn({
387
+ msg: "actor not found",
388
+ actorId,
389
+ });
390
+ return undefined;
391
+ }
392
+ if (generation !== undefined && actor.generation !== generation) {
393
+ this.log?.warn({
394
+ msg: "actor generation mismatch",
395
+ actorId,
396
+ generation,
397
+ });
398
+ return undefined;
399
+ }
400
+
401
+ return actor;
402
+ }
403
+
404
+ async getAndWaitForActor(
405
+ actorId: string,
406
+ generation?: number,
407
+ ): Promise<RunnerActor | undefined> {
408
+ const actor = this.getActor(actorId, generation);
409
+ if (!actor) return;
410
+ await actor.actorStartPromise.promise;
411
+ return actor;
412
+ }
413
+
414
+ hasActor(actorId: string, generation?: number): boolean {
415
+ const actor = this.#actors.get(actorId);
416
+
417
+ return (
418
+ !!actor &&
419
+ (generation === undefined || actor.generation === generation)
420
+ );
421
+ }
422
+
423
+ get actors() {
424
+ return this.#actors;
425
+ }
426
+
427
+ // IMPORTANT: Make sure to call stopActiveRequests if calling #removeActor
428
+ #removeActor(
429
+ actorId: string,
430
+ generation?: number,
431
+ ): RunnerActor | undefined {
432
+ const actor = this.#actors.get(actorId);
433
+ if (!actor) {
434
+ this.log?.error({
435
+ msg: "actor not found for removal",
436
+ actorId,
437
+ });
438
+ return undefined;
439
+ }
440
+ if (generation !== undefined && actor.generation !== generation) {
441
+ this.log?.error({
442
+ msg: "actor generation mismatch",
443
+ actorId,
444
+ generation,
445
+ });
446
+ return undefined;
447
+ }
448
+
449
+ this.#actors.delete(actorId);
450
+
451
+ this.log?.info({
452
+ msg: "removed actor",
453
+ actorId,
454
+ actors: this.#actors.size,
455
+ });
456
+
457
+ return actor;
458
+ }
459
+
460
+ // MARK: Start
461
+ async start() {
462
+ if (this.#started) throw new Error("Cannot call runner.start twice");
463
+ this.#started = true;
464
+
465
+ this.log?.info({ msg: "starting runner" });
466
+
467
+ this.#tunnel = new Tunnel(this);
468
+ this.#tunnel.start();
469
+
470
+ try {
471
+ await this.#openPegboardWebSocket();
472
+ } catch (error) {
473
+ this.#started = false;
474
+ throw error;
475
+ }
476
+
477
+ // When changing SIGTERM/shutdown behavior, update
478
+ // website/src/content/docs/actors/versions.mdx (SIGTERM Handling section).
479
+ if (!this.#config.noAutoShutdown) {
480
+ if (!SIGNAL_HANDLERS.length) {
481
+ process.on("SIGTERM", async () => {
482
+ this.log?.debug("received SIGTERM");
483
+
484
+ for (const handler of SIGNAL_HANDLERS) {
485
+ await handler();
486
+ }
487
+
488
+ // TODO: Add back
489
+ // process.exit(0);
490
+ });
491
+ process.on("SIGINT", async () => {
492
+ this.log?.debug("received SIGINT");
493
+
494
+ for (const handler of SIGNAL_HANDLERS) {
495
+ await handler();
496
+ }
497
+
498
+ // TODO: Add back
499
+ // process.exit(0);
500
+ });
501
+
502
+ this.log?.debug({
503
+ msg: "added SIGTERM listeners",
504
+ });
505
+ }
506
+
507
+ SIGNAL_HANDLERS.push(async () => {
508
+ const weak = new WeakRef(this);
509
+ await weak.deref()?.shutdown(false, false);
510
+ });
511
+ }
512
+ }
513
+
514
+ // MARK: Shutdown
515
+ async shutdown(immediate: boolean, exit: boolean = false) {
516
+ // Prevent concurrent shutdowns
517
+ if (this.#shutdown) {
518
+ this.log?.debug({
519
+ msg: "shutdown already in progress, ignoring",
520
+ });
521
+ return;
522
+ }
523
+ this.#shutdown = true;
524
+ this.#draining = !immediate;
525
+
526
+ this.log?.info({
527
+ msg: "starting shutdown",
528
+ immediate,
529
+ exit,
530
+ });
531
+
532
+ // Clear reconnect timeout
533
+ if (this.#reconnectTimeout) {
534
+ clearTimeout(this.#reconnectTimeout);
535
+ this.#reconnectTimeout = undefined;
536
+ }
537
+
538
+ // Clear runner lost timeout
539
+ if (this.#runnerLostTimeout) {
540
+ clearTimeout(this.#runnerLostTimeout);
541
+ this.#runnerLostTimeout = undefined;
542
+ }
543
+
544
+ // Clear ack interval
545
+ if (this.#ackInterval) {
546
+ clearInterval(this.#ackInterval);
547
+ this.#ackInterval = undefined;
548
+ }
549
+
550
+ // Clear KV cleanup interval
551
+ if (this.#kvCleanupInterval) {
552
+ clearInterval(this.#kvCleanupInterval);
553
+ this.#kvCleanupInterval = undefined;
554
+ }
555
+
556
+ // Reject all KV requests
557
+ for (const request of this.#kvRequests.values()) {
558
+ request.reject(
559
+ new Error("WebSocket connection closed during shutdown"),
560
+ );
561
+ }
562
+ this.#kvRequests.clear();
563
+
564
+ // Close WebSocket
565
+ const pegboardWebSocket = this.getPegboardWebSocketIfReady();
566
+ if (pegboardWebSocket) {
567
+ if (immediate) {
568
+ // Stop immediately
569
+ pegboardWebSocket.close(1000, "pegboard.runner_shutdown");
570
+ } else {
571
+ // Wait for actors to shut down before stopping
572
+ try {
573
+ this.log?.info({
574
+ msg: "sending stopping message",
575
+ readyState: pegboardWebSocket.readyState,
576
+ });
577
+
578
+ // Start stopping
579
+ //
580
+ // The runner workflow will send StopActor commands for all
581
+ // actors
582
+ this.__sendToServer({
583
+ tag: "ToServerStopping",
584
+ val: null,
585
+ });
586
+
587
+ const closePromise = new Promise<void>((resolve) => {
588
+ if (!pegboardWebSocket)
589
+ throw new Error("missing pegboardWebSocket");
590
+
591
+ pegboardWebSocket.addEventListener("close", (ev) => {
592
+ this.log?.info({
593
+ msg: "connection closed",
594
+ code: ev.code,
595
+ reason: ev.reason.toString(),
596
+ });
597
+ resolve();
598
+ });
599
+ });
600
+
601
+ // Wait for all actors to stop before closing ws
602
+ await this.#waitForActorsToStop(pegboardWebSocket);
603
+
604
+ this.log?.info({
605
+ msg: "closing WebSocket",
606
+ });
607
+ pegboardWebSocket.close(1000, "pegboard.runner_shutdown");
608
+
609
+ await closePromise;
610
+
611
+ this.log?.info({
612
+ msg: "websocket shutdown completed",
613
+ });
614
+ } catch (error) {
615
+ this.log?.error({
616
+ msg: "error during websocket shutdown:",
617
+ error,
618
+ });
619
+ pegboardWebSocket.close();
620
+ }
621
+ }
622
+ } else {
623
+ // This is often logged when the serverless SSE stream closes after
624
+ // the runner has already shut down
625
+ this.log?.debug({
626
+ msg: "no runner WebSocket to shutdown or already closed",
627
+ readyState: this.#pegboardWebSocket?.readyState,
628
+ });
629
+ }
630
+
631
+ // Close tunnel
632
+ if (this.#tunnel) {
633
+ this.#tunnel.shutdown();
634
+ this.#tunnel = undefined;
635
+ }
636
+
637
+ this.#config.onShutdown();
638
+
639
+ if (exit) process.exit(0);
640
+ }
641
+
642
+ /**
643
+ * Wait for all actors to stop before proceeding with shutdown.
644
+ *
645
+ * This method polls every 100ms to check if all actors have been stopped.
646
+ *
647
+ * It will resolve early if:
648
+ * - All actors are stopped
649
+ * - The WebSocket connection is closed
650
+ * - The shutdown timeout is reached (120 seconds)
651
+ *
652
+ * When changing this timeout, update
653
+ * website/src/content/docs/actors/versions.mdx (SIGTERM Handling section).
654
+ */
655
+ async #waitForActorsToStop(ws: WebSocket): Promise<void> {
656
+ const shutdownTimeout = 120_000; // 120 seconds
657
+ const shutdownCheckInterval = 100; // Check every 100ms
658
+ const progressLogInterval = 5_000; // Log progress every 5 seconds
659
+ const shutdownStartTs = Date.now();
660
+ let lastProgressLogTs = 0; // Ensure first log happens immediately
661
+
662
+ return new Promise<void>((resolve) => {
663
+ const checkActors = () => {
664
+ const now = Date.now();
665
+ const elapsed = now - shutdownStartTs;
666
+ const wsIsClosed = ws.readyState === 2 || ws.readyState === 3;
667
+
668
+ if (this.#actors.size === 0) {
669
+ this.log?.info({
670
+ msg: "all actors stopped",
671
+ elapsed,
672
+ });
673
+ return true;
674
+ } else if (wsIsClosed) {
675
+ this.log?.warn({
676
+ msg: "websocket closed before all actors stopped",
677
+ remainingActors: this.#actors.size,
678
+ elapsed,
679
+ });
680
+ return true;
681
+ } else if (elapsed >= shutdownTimeout) {
682
+ this.log?.warn({
683
+ msg: "shutdown timeout reached, forcing close",
684
+ remainingActors: this.#actors.size,
685
+ elapsed,
686
+ });
687
+ return true;
688
+ } else {
689
+ // Log progress every 5 seconds
690
+ if (now - lastProgressLogTs >= progressLogInterval) {
691
+ this.log?.info({
692
+ msg: "waiting for actors to stop",
693
+ remainingActors: this.#actors.size,
694
+ elapsed,
695
+ });
696
+ lastProgressLogTs = now;
697
+ }
698
+ return false;
699
+ }
700
+ };
701
+
702
+ // Check immediately first
703
+ if (checkActors()) {
704
+ this.log?.debug({
705
+ msg: "actors check completed immediately",
706
+ });
707
+ resolve();
708
+ return;
709
+ }
710
+
711
+ this.log?.debug({
712
+ msg: "starting actor wait interval",
713
+ checkInterval: shutdownCheckInterval,
714
+ });
715
+
716
+ const interval = setInterval(() => {
717
+ this.log?.debug({
718
+ msg: "actor wait interval tick",
719
+ actorCount: this.#actors.size,
720
+ });
721
+ if (checkActors()) {
722
+ this.log?.debug({
723
+ msg: "actors check completed, clearing interval",
724
+ });
725
+ clearInterval(interval);
726
+ resolve();
727
+ }
728
+ }, shutdownCheckInterval);
729
+ });
730
+ }
731
+
732
+ // MARK: Networking
733
+ get pegboardEndpoint() {
734
+ return this.#config.pegboardEndpoint || this.#config.endpoint;
735
+ }
736
+ get pegboardUrl() {
737
+ const wsEndpoint = this.pegboardEndpoint
738
+ .replace("http://", "ws://")
739
+ .replace("https://", "wss://");
740
+
741
+ // Ensure the endpoint ends with /runners/connect
742
+ const baseUrl = wsEndpoint.endsWith("/")
743
+ ? wsEndpoint.slice(0, -1)
744
+ : wsEndpoint;
745
+ return `${baseUrl}/runners/connect?protocol_version=${PROTOCOL_VERSION}&namespace=${encodeURIComponent(this.#config.namespace)}&runner_key=${encodeURIComponent(this.#runnerKey)}`;
746
+ }
747
+
748
+ // MARK: Runner protocol
749
+ async #openPegboardWebSocket() {
750
+ const protocols = ["rivet"];
751
+ if (this.config.token)
752
+ protocols.push(`rivet_token.${this.config.token}`);
753
+
754
+ const WS = await importWebSocket();
755
+
756
+ // Assertion to clear previous WebSocket
757
+ if (
758
+ this.#pegboardWebSocket &&
759
+ (this.#pegboardWebSocket.readyState === WS.CONNECTING ||
760
+ this.#pegboardWebSocket.readyState === WS.OPEN)
761
+ ) {
762
+ this.log?.error(
763
+ "found duplicate pegboardWebSocket, closing previous",
764
+ );
765
+ this.#pegboardWebSocket.close(1000, "duplicate_websocket");
766
+ }
767
+
768
+ const ws = new WS(this.pegboardUrl, protocols) as any as WebSocket;
769
+ this.#pegboardWebSocket = ws;
770
+
771
+ this.log?.info({
772
+ msg: "connecting",
773
+ endpoint: this.pegboardEndpoint,
774
+ namespace: this.#config.namespace,
775
+ runnerKey: this.#runnerKey,
776
+ hasToken: !!this.config.token,
777
+ });
778
+
779
+ ws.addEventListener("open", () => {
780
+ if (this.#reconnectAttempt > 0) {
781
+ this.log?.info({
782
+ msg: "runner reconnected",
783
+ namespace: this.#config.namespace,
784
+ runnerName: this.#config.runnerName,
785
+ reconnectAttempt: this.#reconnectAttempt,
786
+ });
787
+ } else {
788
+ this.log?.debug({
789
+ msg: "runner connected",
790
+ namespace: this.#config.namespace,
791
+ runnerName: this.#config.runnerName,
792
+ });
793
+ }
794
+
795
+ // Reset reconnect attempt counter on successful connection
796
+ this.#reconnectAttempt = 0;
797
+
798
+ // Clear any pending reconnect timeout
799
+ if (this.#reconnectTimeout) {
800
+ clearTimeout(this.#reconnectTimeout);
801
+ this.#reconnectTimeout = undefined;
802
+ }
803
+
804
+ // Clear any pending runner lost timeout since we're reconnecting
805
+ if (this.#runnerLostTimeout) {
806
+ clearTimeout(this.#runnerLostTimeout);
807
+ this.#runnerLostTimeout = undefined;
808
+ }
809
+
810
+ // Send init message
811
+ const init: protocol.ToServerInit = {
812
+ name: this.#config.runnerName,
813
+ version: this.#config.version,
814
+ totalSlots: this.#config.totalSlots,
815
+ prepopulateActorNames: new Map(
816
+ Object.entries(this.#config.prepopulateActorNames).map(
817
+ ([name, data]) => [
818
+ name,
819
+ { metadata: JSON.stringify(data.metadata) },
820
+ ],
821
+ ),
822
+ ),
823
+ metadata: JSON.stringify(this.#config.metadata),
824
+ };
825
+
826
+ this.__sendToServer({
827
+ tag: "ToServerInit",
828
+ val: init,
829
+ });
830
+
831
+ // Start command acknowledgment interval (5 minutes)
832
+ const ackInterval = 5 * 60 * 1000; // 5 minutes in milliseconds
833
+ const ackLoop = setInterval(() => {
834
+ try {
835
+ if (ws.readyState === 1) {
836
+ this.#sendCommandAcknowledgment();
837
+ } else {
838
+ clearInterval(ackLoop);
839
+ this.log?.info({
840
+ msg: "WebSocket not open, stopping ack loop",
841
+ });
842
+ }
843
+ } catch (err) {
844
+ this.log?.error({
845
+ msg: "error in command acknowledgment loop",
846
+ error: stringifyError(err),
847
+ });
848
+ }
849
+ }, ackInterval);
850
+ this.#ackInterval = ackLoop;
851
+ });
852
+
853
+ ws.addEventListener("message", async (ev) => {
854
+ let buf: Uint8Array;
855
+ if (ev.data instanceof Blob) {
856
+ buf = new Uint8Array(await ev.data.arrayBuffer());
857
+ } else if (Buffer.isBuffer(ev.data)) {
858
+ buf = new Uint8Array(ev.data);
859
+ } else {
860
+ throw new Error(`expected binary data, got ${typeof ev.data}`);
861
+ }
862
+
863
+ await this.#injectLatency();
864
+
865
+ // Parse message
866
+ const message = protocol.decodeToClient(buf);
867
+ this.log?.debug({
868
+ msg: "received runner message",
869
+ data: stringifyToClient(message),
870
+ });
871
+
872
+ // Handle message
873
+ if (message.tag === "ToClientInit") {
874
+ const init = message.val;
875
+
876
+ if (this.runnerId !== init.runnerId) {
877
+ this.runnerId = init.runnerId;
878
+
879
+ // Clear actors if runner id changed
880
+ this.#stopAllActors();
881
+ }
882
+
883
+ this.#protocolMetadata = init.metadata;
884
+
885
+ this.log?.info({
886
+ msg: "received init",
887
+ protocolMetadata: this.#protocolMetadata,
888
+ });
889
+
890
+ // Resend pending events
891
+ this.#processUnsentKvRequests();
892
+ this.#resendUnacknowledgedEvents();
893
+ this.#tunnel?.resendBufferedEvents();
894
+
895
+ this.#config.onConnected();
896
+ } else if (message.tag === "ToClientCommands") {
897
+ const commands = message.val;
898
+ this.#handleCommands(commands);
899
+ } else if (message.tag === "ToClientAckEvents") {
900
+ this.#handleAckEvents(message.val);
901
+ } else if (message.tag === "ToClientKvResponse") {
902
+ const kvResponse = message.val;
903
+ this.#handleKvResponse(kvResponse);
904
+ } else if (message.tag === "ToClientTunnelMessage") {
905
+ this.#tunnel?.handleTunnelMessage(message.val).catch((err) => {
906
+ this.log?.error({
907
+ msg: "error handling tunnel message",
908
+ error: stringifyError(err),
909
+ });
910
+ });
911
+ } else if (message.tag === "ToClientPing") {
912
+ this.__sendToServer({
913
+ tag: "ToServerPong",
914
+ val: {
915
+ ts: message.val.ts,
916
+ },
917
+ });
918
+ } else {
919
+ unreachable(message);
920
+ }
921
+ });
922
+
923
+ ws.addEventListener("error", (ev) => {
924
+ this.log?.error({
925
+ msg: `WebSocket error: ${stringifyError(ev.error)}`,
926
+ });
927
+
928
+ if (!this.#shutdown) {
929
+ this.#startRunnerLostTimeout();
930
+
931
+ // Attempt to reconnect if not stopped
932
+ this.#scheduleReconnect();
933
+ }
934
+ });
935
+
936
+ ws.addEventListener("close", async (ev) => {
937
+ if (!this.#shutdown) {
938
+ const closeError = parseWebSocketCloseReason(ev.reason);
939
+ if (
940
+ closeError?.group === "ws" &&
941
+ closeError?.error === "eviction"
942
+ ) {
943
+ this.log?.info("runner websocket evicted");
944
+
945
+ this.#config.onDisconnected(ev.code, ev.reason);
946
+
947
+ await this.shutdown(true);
948
+ } else {
949
+ this.log?.warn({
950
+ msg: "runner disconnected",
951
+ code: ev.code,
952
+ reason: ev.reason.toString(),
953
+ closeError,
954
+ });
955
+
956
+ this.#config.onDisconnected(ev.code, ev.reason);
957
+ }
958
+
959
+ // Clear ack interval on close
960
+ if (this.#ackInterval) {
961
+ clearInterval(this.#ackInterval);
962
+ this.#ackInterval = undefined;
963
+ }
964
+
965
+ this.#startRunnerLostTimeout();
966
+
967
+ // Attempt to reconnect if not stopped
968
+ this.#scheduleReconnect();
969
+ } else {
970
+ this.log?.info("websocket closed");
971
+
972
+ this.#config.onDisconnected(ev.code, ev.reason);
973
+ }
974
+ });
975
+ }
976
+
977
+ #startRunnerLostTimeout() {
978
+ // Start runner lost timeout if we have a threshold and are not shutting down
979
+ if (
980
+ !this.#runnerLostTimeout &&
981
+ this.#protocolMetadata &&
982
+ this.#protocolMetadata.runnerLostThreshold > 0
983
+ ) {
984
+ this.log?.info({
985
+ msg: "starting runner lost timeout",
986
+ seconds: this.#protocolMetadata.runnerLostThreshold / 1000n,
987
+ });
988
+ this.#runnerLostTimeout = setTimeout(() => {
989
+ try {
990
+ this.#handleLost();
991
+ } catch (err) {
992
+ this.log?.error({
993
+ msg: "error handling runner lost",
994
+ error: stringifyError(err),
995
+ });
996
+ }
997
+ }, Number(this.#protocolMetadata.runnerLostThreshold));
998
+ }
999
+ }
1000
+
1001
+ #handleCommands(commands: protocol.ToClientCommands) {
1002
+ this.log?.info({
1003
+ msg: "received commands",
1004
+ commandCount: commands.length,
1005
+ });
1006
+
1007
+ for (const commandWrapper of commands) {
1008
+ if (commandWrapper.inner.tag === "CommandStartActor") {
1009
+ // Spawn background promise
1010
+ this.#handleCommandStartActor(commandWrapper).catch((err) => {
1011
+ this.log?.error({
1012
+ msg: "error handling start actor command",
1013
+ actorId: commandWrapper.checkpoint.actorId,
1014
+ error: stringifyError(err),
1015
+ });
1016
+ });
1017
+
1018
+ // NOTE: We don't do this for CommandStopActor because the actor will be removed by that call
1019
+ // so we cant update the checkpoint
1020
+ const actor = this.getActor(
1021
+ commandWrapper.checkpoint.actorId,
1022
+ commandWrapper.checkpoint.generation,
1023
+ );
1024
+ if (actor)
1025
+ actor.lastCommandIdx = commandWrapper.checkpoint.index;
1026
+ } else if (commandWrapper.inner.tag === "CommandStopActor") {
1027
+ // Spawn background promise
1028
+ this.#handleCommandStopActor(commandWrapper).catch((err) => {
1029
+ this.log?.error({
1030
+ msg: "error handling stop actor command",
1031
+ actorId: commandWrapper.checkpoint.actorId,
1032
+ error: stringifyError(err),
1033
+ });
1034
+ });
1035
+ } else {
1036
+ unreachable(commandWrapper.inner);
1037
+ }
1038
+ }
1039
+ }
1040
+
1041
+ #handleAckEvents(ack: protocol.ToClientAckEvents) {
1042
+ const originalTotalEvents = Array.from(this.#actors).reduce(
1043
+ (s, [_, actor]) => s + actor.eventHistory.length,
1044
+ 0,
1045
+ );
1046
+
1047
+ for (const [_, actor] of this.#actors) {
1048
+ const checkpoint = ack.lastEventCheckpoints.find(
1049
+ (x) => x.actorId == actor.actorId,
1050
+ );
1051
+
1052
+ if (checkpoint) actor.handleAckEvents(checkpoint.index);
1053
+ }
1054
+
1055
+ const totalEvents = Array.from(this.#actors).reduce(
1056
+ (s, [_, actor]) => s + actor.eventHistory.length,
1057
+ 0,
1058
+ );
1059
+ const prunedCount = originalTotalEvents - totalEvents;
1060
+
1061
+ if (prunedCount > 0) {
1062
+ this.log?.info({
1063
+ msg: "pruned acknowledged events",
1064
+ prunedCount,
1065
+ });
1066
+ }
1067
+
1068
+ if (totalEvents <= EVENT_BACKLOG_WARN_THRESHOLD) {
1069
+ this.#eventBacklogWarned = false;
1070
+ }
1071
+ }
1072
+
1073
+ /** Track events to send to the server in case we need to resend it on disconnect. */
1074
+ #recordEvent(eventWrapper: protocol.EventWrapper) {
1075
+ const actor = this.getActor(eventWrapper.checkpoint.actorId);
1076
+ if (!actor) return;
1077
+
1078
+ actor.recordEvent(eventWrapper);
1079
+
1080
+ const totalEvents = Array.from(this.#actors).reduce(
1081
+ (s, [_, actor]) => s + actor.eventHistory.length,
1082
+ 0,
1083
+ );
1084
+
1085
+ if (
1086
+ totalEvents > EVENT_BACKLOG_WARN_THRESHOLD &&
1087
+ !this.#eventBacklogWarned
1088
+ ) {
1089
+ this.#eventBacklogWarned = true;
1090
+ this.log?.warn({
1091
+ msg: "unacknowledged event backlog exceeds threshold",
1092
+ backlogSize: totalEvents,
1093
+ threshold: EVENT_BACKLOG_WARN_THRESHOLD,
1094
+ });
1095
+ }
1096
+ }
1097
+
1098
+ async #handleCommandStartActor(commandWrapper: protocol.CommandWrapper) {
1099
+ // IMPORTANT: Make sure no async code runs before inserting #actors and
1100
+ // calling addRequestToActor in order to prevent race conditions with
1101
+ // subsequence commands
1102
+
1103
+ if (!this.#tunnel) throw new Error("missing tunnel on actor start");
1104
+
1105
+ const startCommand = commandWrapper.inner
1106
+ .val as protocol.CommandStartActor;
1107
+
1108
+ const actorId = commandWrapper.checkpoint.actorId;
1109
+ const generation = commandWrapper.checkpoint.generation;
1110
+ const config = startCommand.config;
1111
+
1112
+ const actorConfig: ActorConfig = {
1113
+ name: config.name,
1114
+ key: config.key,
1115
+ createTs: config.createTs,
1116
+ input: config.input ? new Uint8Array(config.input) : null,
1117
+ };
1118
+
1119
+ const instance = new RunnerActor(
1120
+ actorId,
1121
+ generation,
1122
+ actorConfig,
1123
+ startCommand.hibernatingRequests,
1124
+ );
1125
+
1126
+ const existingActor = this.#actors.get(actorId);
1127
+ if (existingActor) {
1128
+ this.log?.warn({
1129
+ msg: "replacing existing actor in actors map",
1130
+ actorId,
1131
+ existingGeneration: existingActor.generation,
1132
+ newGeneration: generation,
1133
+ existingPendingRequests: existingActor.pendingRequests.length,
1134
+ });
1135
+ }
1136
+
1137
+ this.#actors.set(actorId, instance);
1138
+
1139
+ // NOTE: We have to populate the requestToActor map BEFORE running any
1140
+ // async code in order for incoming tunnel messages to wait for
1141
+ // instance.actorStartPromise before processing messages
1142
+ // TODO: Where is this GC'd if something fails?
1143
+ for (const hr of startCommand.hibernatingRequests) {
1144
+ this.#tunnel.addRequestToActor(hr.gatewayId, hr.requestId, actorId);
1145
+ }
1146
+
1147
+ this.log?.info({
1148
+ msg: "created actor",
1149
+ actors: this.#actors.size,
1150
+ actorId,
1151
+ name: config.name,
1152
+ key: config.key,
1153
+ generation,
1154
+ hibernatingRequests: startCommand.hibernatingRequests.length,
1155
+ });
1156
+
1157
+ this.#sendActorStateUpdate(actorId, generation, "running");
1158
+
1159
+ try {
1160
+ // TODO: Add timeout to onActorStart
1161
+ // Call onActorStart asynchronously and handle errors
1162
+ this.log?.debug({
1163
+ msg: "calling onActorStart",
1164
+ actorId,
1165
+ generation,
1166
+ });
1167
+ await this.#config.onActorStart(actorId, generation, actorConfig);
1168
+
1169
+ instance.actorStartPromise.resolve();
1170
+ } catch (err) {
1171
+ this.log?.error({
1172
+ msg: "error starting runner actor",
1173
+ actorId,
1174
+ err,
1175
+ });
1176
+
1177
+ instance.actorStartPromise.reject(err);
1178
+
1179
+ // TODO: Mark as crashed
1180
+ // Send stopped state update if start failed
1181
+ await this.forceStopActor(actorId, generation);
1182
+ }
1183
+ }
1184
+
1185
+ async #handleCommandStopActor(commandWrapper: protocol.CommandWrapper) {
1186
+ const stopCommand = commandWrapper.inner
1187
+ .val as protocol.CommandStopActor;
1188
+
1189
+ const actorId = commandWrapper.checkpoint.actorId;
1190
+ const generation = commandWrapper.checkpoint.generation;
1191
+
1192
+ await this.forceStopActor(actorId, generation);
1193
+ }
1194
+
1195
+ #sendActorIntent(
1196
+ actorId: string,
1197
+ generation: number,
1198
+ intentType: "sleep" | "stop",
1199
+ ) {
1200
+ const actor = this.getActor(actorId, generation);
1201
+ if (!actor) return;
1202
+
1203
+ let actorIntent: protocol.ActorIntent;
1204
+
1205
+ if (intentType === "sleep") {
1206
+ actorIntent = { tag: "ActorIntentSleep", val: null };
1207
+ } else if (intentType === "stop") {
1208
+ actorIntent = {
1209
+ tag: "ActorIntentStop",
1210
+ val: null,
1211
+ };
1212
+ } else {
1213
+ unreachable(intentType);
1214
+ }
1215
+
1216
+ const intentEvent: protocol.EventActorIntent = {
1217
+ intent: actorIntent,
1218
+ };
1219
+
1220
+ const eventWrapper: protocol.EventWrapper = {
1221
+ checkpoint: {
1222
+ actorId,
1223
+ generation,
1224
+ index: actor.nextEventIdx++,
1225
+ },
1226
+ inner: {
1227
+ tag: "EventActorIntent",
1228
+ val: intentEvent,
1229
+ },
1230
+ };
1231
+
1232
+ this.#recordEvent(eventWrapper);
1233
+
1234
+ this.__sendToServer({
1235
+ tag: "ToServerEvents",
1236
+ val: [eventWrapper],
1237
+ });
1238
+ }
1239
+
1240
+ #sendActorStateUpdate(
1241
+ actorId: string,
1242
+ generation: number,
1243
+ stateType: "running" | "stopped",
1244
+ ) {
1245
+ const actor = this.getActor(actorId, generation);
1246
+ if (!actor) return;
1247
+
1248
+ let actorState: protocol.ActorState;
1249
+
1250
+ if (stateType === "running") {
1251
+ actorState = { tag: "ActorStateRunning", val: null };
1252
+ } else if (stateType === "stopped") {
1253
+ actorState = {
1254
+ tag: "ActorStateStopped",
1255
+ val: {
1256
+ code: actor.stopIntentSent || this.#draining
1257
+ ? protocol.StopCode.Ok
1258
+ : protocol.StopCode.Error,
1259
+ message: null,
1260
+ },
1261
+ };
1262
+ } else {
1263
+ unreachable(stateType);
1264
+ }
1265
+
1266
+ const stateUpdateEvent: protocol.EventActorStateUpdate = {
1267
+ state: actorState,
1268
+ };
1269
+
1270
+ const eventWrapper: protocol.EventWrapper = {
1271
+ checkpoint: {
1272
+ actorId,
1273
+ generation,
1274
+ index: actor.nextEventIdx++,
1275
+ },
1276
+ inner: {
1277
+ tag: "EventActorStateUpdate",
1278
+ val: stateUpdateEvent,
1279
+ },
1280
+ };
1281
+
1282
+ this.#recordEvent(eventWrapper);
1283
+
1284
+ this.__sendToServer({
1285
+ tag: "ToServerEvents",
1286
+ val: [eventWrapper],
1287
+ });
1288
+ }
1289
+
1290
+ #sendCommandAcknowledgment() {
1291
+ const lastCommandCheckpoints = [];
1292
+
1293
+ for (const [_, actor] of this.#actors) {
1294
+ if (actor.lastCommandIdx < 0) {
1295
+ // No commands received yet, nothing to acknowledge
1296
+ continue;
1297
+ }
1298
+
1299
+ lastCommandCheckpoints.push({
1300
+ actorId: actor.actorId,
1301
+ generation: actor.generation,
1302
+ index: actor.lastCommandIdx,
1303
+ });
1304
+ }
1305
+
1306
+ //this.#log?.log("Sending command acknowledgment", this.#lastCommandIdx);
1307
+
1308
+ this.__sendToServer({
1309
+ tag: "ToServerAckCommands",
1310
+ val: {
1311
+ lastCommandCheckpoints,
1312
+ },
1313
+ });
1314
+ }
1315
+
1316
+ #handleKvResponse(response: protocol.ToClientKvResponse) {
1317
+ const requestId = response.requestId;
1318
+ const request = this.#kvRequests.get(requestId);
1319
+
1320
+ if (!request) {
1321
+ this.log?.error({
1322
+ msg: "received kv response for unknown request id",
1323
+ requestId,
1324
+ });
1325
+ return;
1326
+ }
1327
+
1328
+ this.#kvRequests.delete(requestId);
1329
+
1330
+ if (response.data.tag === "KvErrorResponse") {
1331
+ request.reject(
1332
+ new Error(response.data.val.message || "Unknown KV error"),
1333
+ );
1334
+ } else {
1335
+ request.resolve(response.data.val);
1336
+ }
1337
+ }
1338
+
1339
+ #parseGetResponseSimple(
1340
+ response: protocol.KvGetResponse,
1341
+ requestedKeys: Uint8Array[],
1342
+ ): (Uint8Array | null)[] {
1343
+ // Parse the response keys and values
1344
+ const responseKeys: Uint8Array[] = [];
1345
+ const responseValues: Uint8Array[] = [];
1346
+
1347
+ for (const key of response.keys) {
1348
+ responseKeys.push(new Uint8Array(key));
1349
+ }
1350
+
1351
+ for (const value of response.values) {
1352
+ responseValues.push(new Uint8Array(value));
1353
+ }
1354
+
1355
+ // Map response back to requested key order
1356
+ const result: (Uint8Array | null)[] = [];
1357
+ for (const requestedKey of requestedKeys) {
1358
+ let found = false;
1359
+ for (let i = 0; i < responseKeys.length; i++) {
1360
+ if (this.#keysEqual(requestedKey, responseKeys[i])) {
1361
+ result.push(responseValues[i]);
1362
+ found = true;
1363
+ break;
1364
+ }
1365
+ }
1366
+ if (!found) {
1367
+ result.push(null);
1368
+ }
1369
+ }
1370
+
1371
+ return result;
1372
+ }
1373
+
1374
+ #keysEqual(key1: Uint8Array, key2: Uint8Array): boolean {
1375
+ if (key1.length !== key2.length) return false;
1376
+ for (let i = 0; i < key1.length; i++) {
1377
+ if (key1[i] !== key2[i]) return false;
1378
+ }
1379
+ return true;
1380
+ }
1381
+
1382
+ //#parseGetResponse(response: protocol.KvGetResponse) {
1383
+ // const keys: string[] = [];
1384
+ // const values: Uint8Array[] = [];
1385
+ // const metadata: { version: Uint8Array; createTs: bigint }[] = [];
1386
+ //
1387
+ // for (const key of response.keys) {
1388
+ // keys.push(new TextDecoder().decode(key));
1389
+ // }
1390
+ //
1391
+ // for (const value of response.values) {
1392
+ // values.push(new Uint8Array(value));
1393
+ // }
1394
+ //
1395
+ // for (const meta of response.metadata) {
1396
+ // metadata.push({
1397
+ // version: new Uint8Array(meta.version),
1398
+ // createTs: meta.createTs,
1399
+ // });
1400
+ // }
1401
+ //
1402
+ // return { keys, values, metadata };
1403
+ //}
1404
+
1405
+ #parseListResponseSimple(
1406
+ response: protocol.KvListResponse,
1407
+ ): [Uint8Array, Uint8Array][] {
1408
+ const result: [Uint8Array, Uint8Array][] = [];
1409
+
1410
+ for (let i = 0; i < response.keys.length; i++) {
1411
+ const key = response.keys[i];
1412
+ const value = response.values[i];
1413
+
1414
+ if (key && value) {
1415
+ const keyBytes = new Uint8Array(key);
1416
+ const valueBytes = new Uint8Array(value);
1417
+ result.push([keyBytes, valueBytes]);
1418
+ }
1419
+ }
1420
+
1421
+ return result;
1422
+ }
1423
+
1424
+ //#parseListResponse(response: protocol.KvListResponse) {
1425
+ // const keys: string[] = [];
1426
+ // const values: Uint8Array[] = [];
1427
+ // const metadata: { version: Uint8Array; createTs: bigint }[] = [];
1428
+ //
1429
+ // for (const key of response.keys) {
1430
+ // keys.push(new TextDecoder().decode(key));
1431
+ // }
1432
+ //
1433
+ // for (const value of response.values) {
1434
+ // values.push(new Uint8Array(value));
1435
+ // }
1436
+ //
1437
+ // for (const meta of response.metadata) {
1438
+ // metadata.push({
1439
+ // version: new Uint8Array(meta.version),
1440
+ // createTs: meta.createTs,
1441
+ // });
1442
+ // }
1443
+ //
1444
+ // return { keys, values, metadata };
1445
+ //}
1446
+
1447
+ // MARK: KV Operations
1448
+ async kvGet(
1449
+ actorId: string,
1450
+ keys: Uint8Array[],
1451
+ ): Promise<(Uint8Array | null)[]> {
1452
+ const kvKeys: protocol.KvKey[] = keys.map(
1453
+ (key) =>
1454
+ key.buffer.slice(
1455
+ key.byteOffset,
1456
+ key.byteOffset + key.byteLength,
1457
+ ) as ArrayBuffer,
1458
+ );
1459
+
1460
+ const requestData: protocol.KvRequestData = {
1461
+ tag: "KvGetRequest",
1462
+ val: { keys: kvKeys },
1463
+ };
1464
+
1465
+ const response = await this.#sendKvRequest(actorId, requestData);
1466
+ return this.#parseGetResponseSimple(response, keys);
1467
+ }
1468
+
1469
+ async kvListAll(
1470
+ actorId: string,
1471
+ options?: KvListOptions,
1472
+ ): Promise<[Uint8Array, Uint8Array][]> {
1473
+ const requestData: protocol.KvRequestData = {
1474
+ tag: "KvListRequest",
1475
+ val: {
1476
+ query: { tag: "KvListAllQuery", val: null },
1477
+ reverse: options?.reverse || null,
1478
+ limit:
1479
+ options?.limit !== undefined ? BigInt(options.limit) : null,
1480
+ },
1481
+ };
1482
+
1483
+ const response = await this.#sendKvRequest(actorId, requestData);
1484
+ return this.#parseListResponseSimple(response);
1485
+ }
1486
+
1487
+ async kvListRange(
1488
+ actorId: string,
1489
+ start: Uint8Array,
1490
+ end: Uint8Array,
1491
+ exclusive?: boolean,
1492
+ options?: KvListOptions,
1493
+ ): Promise<[Uint8Array, Uint8Array][]> {
1494
+ const startKey: protocol.KvKey = start.buffer.slice(
1495
+ start.byteOffset,
1496
+ start.byteOffset + start.byteLength,
1497
+ ) as ArrayBuffer;
1498
+ const endKey: protocol.KvKey = end.buffer.slice(
1499
+ end.byteOffset,
1500
+ end.byteOffset + end.byteLength,
1501
+ ) as ArrayBuffer;
1502
+
1503
+ const requestData: protocol.KvRequestData = {
1504
+ tag: "KvListRequest",
1505
+ val: {
1506
+ query: {
1507
+ tag: "KvListRangeQuery",
1508
+ val: {
1509
+ start: startKey,
1510
+ end: endKey,
1511
+ exclusive: exclusive || false,
1512
+ },
1513
+ },
1514
+ reverse: options?.reverse || null,
1515
+ limit:
1516
+ options?.limit !== undefined ? BigInt(options.limit) : null,
1517
+ },
1518
+ };
1519
+
1520
+ const response = await this.#sendKvRequest(actorId, requestData);
1521
+ return this.#parseListResponseSimple(response);
1522
+ }
1523
+
1524
+ async kvListPrefix(
1525
+ actorId: string,
1526
+ prefix: Uint8Array,
1527
+ options?: KvListOptions,
1528
+ ): Promise<[Uint8Array, Uint8Array][]> {
1529
+ const prefixKey: protocol.KvKey = prefix.buffer.slice(
1530
+ prefix.byteOffset,
1531
+ prefix.byteOffset + prefix.byteLength,
1532
+ ) as ArrayBuffer;
1533
+
1534
+ const requestData: protocol.KvRequestData = {
1535
+ tag: "KvListRequest",
1536
+ val: {
1537
+ query: {
1538
+ tag: "KvListPrefixQuery",
1539
+ val: { key: prefixKey },
1540
+ },
1541
+ reverse: options?.reverse || null,
1542
+ limit:
1543
+ options?.limit !== undefined ? BigInt(options.limit) : null,
1544
+ },
1545
+ };
1546
+
1547
+ const response = await this.#sendKvRequest(actorId, requestData);
1548
+ return this.#parseListResponseSimple(response);
1549
+ }
1550
+
1551
+ async kvPut(
1552
+ actorId: string,
1553
+ entries: [Uint8Array, Uint8Array][],
1554
+ ): Promise<void> {
1555
+ const keys: protocol.KvKey[] = entries.map(
1556
+ ([key, _value]) =>
1557
+ key.buffer.slice(
1558
+ key.byteOffset,
1559
+ key.byteOffset + key.byteLength,
1560
+ ) as ArrayBuffer,
1561
+ );
1562
+ const values: protocol.KvValue[] = entries.map(
1563
+ ([_key, value]) =>
1564
+ value.buffer.slice(
1565
+ value.byteOffset,
1566
+ value.byteOffset + value.byteLength,
1567
+ ) as ArrayBuffer,
1568
+ );
1569
+
1570
+ const requestData: protocol.KvRequestData = {
1571
+ tag: "KvPutRequest",
1572
+ val: { keys, values },
1573
+ };
1574
+
1575
+ await this.#sendKvRequest(actorId, requestData);
1576
+ }
1577
+
1578
+ async kvDelete(actorId: string, keys: Uint8Array[]): Promise<void> {
1579
+ const kvKeys: protocol.KvKey[] = keys.map(
1580
+ (key) =>
1581
+ key.buffer.slice(
1582
+ key.byteOffset,
1583
+ key.byteOffset + key.byteLength,
1584
+ ) as ArrayBuffer,
1585
+ );
1586
+
1587
+ const requestData: protocol.KvRequestData = {
1588
+ tag: "KvDeleteRequest",
1589
+ val: { keys: kvKeys },
1590
+ };
1591
+
1592
+ await this.#sendKvRequest(actorId, requestData);
1593
+ }
1594
+
1595
+ async kvDeleteRange(
1596
+ actorId: string,
1597
+ start: Uint8Array,
1598
+ end: Uint8Array,
1599
+ ): Promise<void> {
1600
+ const startKey: protocol.KvKey = start.buffer.slice(
1601
+ start.byteOffset,
1602
+ start.byteOffset + start.byteLength,
1603
+ ) as ArrayBuffer;
1604
+ const endKey: protocol.KvKey = end.buffer.slice(
1605
+ end.byteOffset,
1606
+ end.byteOffset + end.byteLength,
1607
+ ) as ArrayBuffer;
1608
+
1609
+ const requestData: protocol.KvRequestData = {
1610
+ tag: "KvDeleteRangeRequest",
1611
+ val: {
1612
+ start: startKey,
1613
+ end: endKey,
1614
+ },
1615
+ };
1616
+
1617
+ await this.#sendKvRequest(actorId, requestData);
1618
+ }
1619
+
1620
+ async kvDrop(actorId: string): Promise<void> {
1621
+ const requestData: protocol.KvRequestData = {
1622
+ tag: "KvDropRequest",
1623
+ val: null,
1624
+ };
1625
+
1626
+ await this.#sendKvRequest(actorId, requestData);
1627
+ }
1628
+
1629
+ // MARK: Alarm Operations
1630
+ setAlarm(actorId: string, alarmTs: number | null, generation?: number) {
1631
+ const actor = this.getActor(actorId, generation);
1632
+ if (!actor) return;
1633
+
1634
+ const alarmEvent: protocol.EventActorSetAlarm = {
1635
+ alarmTs: alarmTs !== null ? BigInt(alarmTs) : null,
1636
+ };
1637
+
1638
+ const eventWrapper: protocol.EventWrapper = {
1639
+ checkpoint: {
1640
+ actorId,
1641
+ generation: actor.generation,
1642
+ index: actor.nextEventIdx++,
1643
+ },
1644
+ inner: {
1645
+ tag: "EventActorSetAlarm",
1646
+ val: alarmEvent,
1647
+ },
1648
+ };
1649
+
1650
+ this.#recordEvent(eventWrapper);
1651
+
1652
+ this.__sendToServer({
1653
+ tag: "ToServerEvents",
1654
+ val: [eventWrapper],
1655
+ });
1656
+ }
1657
+
1658
+ clearAlarm(actorId: string, generation?: number) {
1659
+ this.setAlarm(actorId, null, generation);
1660
+ }
1661
+
1662
+ #sendKvRequest(
1663
+ actorId: string,
1664
+ requestData: protocol.KvRequestData,
1665
+ ): Promise<any> {
1666
+ return new Promise((resolve, reject) => {
1667
+ const requestId = this.#nextKvRequestId++;
1668
+
1669
+ // Store the request
1670
+ const requestEntry = {
1671
+ actorId,
1672
+ data: requestData,
1673
+ resolve,
1674
+ reject,
1675
+ sent: false,
1676
+ timestamp: Date.now(),
1677
+ };
1678
+
1679
+ this.#kvRequests.set(requestId, requestEntry);
1680
+
1681
+ if (this.getPegboardWebSocketIfReady()) {
1682
+ // Send immediately
1683
+ this.#sendSingleKvRequest(requestId);
1684
+ }
1685
+ });
1686
+ }
1687
+
1688
+ #sendSingleKvRequest(requestId: number) {
1689
+ const request = this.#kvRequests.get(requestId);
1690
+ if (!request || request.sent) return;
1691
+
1692
+ try {
1693
+ const kvRequest: protocol.ToServerKvRequest = {
1694
+ actorId: request.actorId,
1695
+ requestId,
1696
+ data: request.data,
1697
+ };
1698
+
1699
+ this.__sendToServer({
1700
+ tag: "ToServerKvRequest",
1701
+ val: kvRequest,
1702
+ });
1703
+
1704
+ // Mark as sent and update timestamp
1705
+ request.sent = true;
1706
+ request.timestamp = Date.now();
1707
+ } catch (error) {
1708
+ this.#kvRequests.delete(requestId);
1709
+ request.reject(error);
1710
+ }
1711
+ }
1712
+
1713
+ #processUnsentKvRequests() {
1714
+ if (!this.getPegboardWebSocketIfReady()) {
1715
+ return;
1716
+ }
1717
+
1718
+ let processedCount = 0;
1719
+ for (const [requestId, request] of this.#kvRequests.entries()) {
1720
+ if (!request.sent) {
1721
+ this.#sendSingleKvRequest(requestId);
1722
+ processedCount++;
1723
+ }
1724
+ }
1725
+
1726
+ if (processedCount > 0) {
1727
+ //this.#log?.log(`Processed ${processedCount} queued KV requests`);
1728
+ }
1729
+ }
1730
+
1731
+ /** Resolves after the configured debug latency, or immediately if none. */
1732
+ #injectLatency(): Promise<void> {
1733
+ const ms = this.#config.debugLatencyMs;
1734
+ if (!ms) return Promise.resolve();
1735
+ return new Promise((resolve) => setTimeout(resolve, ms));
1736
+ }
1737
+
1738
+ /** Asserts WebSocket exists and is ready. */
1739
+ getPegboardWebSocketIfReady(): WebSocket | undefined {
1740
+ if (
1741
+ !!this.#pegboardWebSocket &&
1742
+ this.#pegboardWebSocket.readyState === 1
1743
+ ) {
1744
+ return this.#pegboardWebSocket;
1745
+ } else {
1746
+ return undefined;
1747
+ }
1748
+ }
1749
+
1750
+ __sendToServer(message: protocol.ToServer) {
1751
+ this.log?.debug({
1752
+ msg: "sending runner message",
1753
+ data: stringifyToServer(message),
1754
+ });
1755
+
1756
+ const encoded = protocol.encodeToServer(message);
1757
+
1758
+ // Normally synchronous. When debugLatencyMs is set, the send is
1759
+ // deferred but message order is preserved.
1760
+ this.#injectLatency().then(() => {
1761
+ const pegboardWebSocket = this.getPegboardWebSocketIfReady();
1762
+ if (pegboardWebSocket) {
1763
+ pegboardWebSocket.send(encoded);
1764
+ } else {
1765
+ this.log?.error({
1766
+ msg: "WebSocket not available or not open for sending data",
1767
+ });
1768
+ }
1769
+ });
1770
+ }
1771
+
1772
+ sendHibernatableWebSocketMessageAck(
1773
+ gatewayId: ArrayBuffer,
1774
+ requestId: ArrayBuffer,
1775
+ index: number,
1776
+ ) {
1777
+ if (!this.#tunnel)
1778
+ throw new Error("missing tunnel to send message ack");
1779
+ this.#tunnel.sendHibernatableWebSocketMessageAck(
1780
+ gatewayId,
1781
+ requestId,
1782
+ index,
1783
+ );
1784
+ }
1785
+
1786
+ /**
1787
+ * Restores hibernatable WebSocket connections for an actor.
1788
+ *
1789
+ * This method should be called at the end of `onActorStart` after the
1790
+ * actor instance is fully initialized.
1791
+ *
1792
+ * This method will:
1793
+ * - Restore all provided hibernatable WebSocket connections
1794
+ * - Attach event listeners to the restored WebSockets
1795
+ * - Close any WebSocket connections that failed to restore
1796
+ *
1797
+ * The provided metadata list should include all hibernatable WebSockets
1798
+ * that were persisted for this actor. The gateway will automatically
1799
+ * close any connections that are not restored (i.e., not included in
1800
+ * this list).
1801
+ *
1802
+ * **Important:** This method must be called after `onActorStart` completes
1803
+ * and before marking the actor as "ready" to ensure all hibernatable
1804
+ * connections are fully restored.
1805
+ *
1806
+ * @param actorId - The ID of the actor to restore connections for
1807
+ * @param metaEntries - Array of hibernatable WebSocket metadata to restore
1808
+ */
1809
+ async restoreHibernatingRequests(
1810
+ actorId: string,
1811
+ metaEntries: HibernatingWebSocketMetadata[],
1812
+ ) {
1813
+ if (!this.#tunnel)
1814
+ throw new Error("missing tunnel to restore hibernating requests");
1815
+ await this.#tunnel.restoreHibernatingRequests(actorId, metaEntries);
1816
+ }
1817
+
1818
+ getServerlessInitPacket(): string | undefined {
1819
+ if (!this.runnerId) return undefined;
1820
+
1821
+ const data = protocol.encodeToServerlessServer({
1822
+ tag: "ToServerlessServerInit",
1823
+ val: {
1824
+ runnerId: this.runnerId,
1825
+ runnerProtocolVersion: PROTOCOL_VERSION,
1826
+ },
1827
+ });
1828
+
1829
+ // Embed version
1830
+ const buffer = Buffer.alloc(data.length + 2);
1831
+ buffer.writeUInt16LE(PROTOCOL_VERSION, 0);
1832
+ Buffer.from(data).copy(buffer, 2);
1833
+
1834
+ return buffer.toString("base64");
1835
+ }
1836
+
1837
+ #scheduleReconnect() {
1838
+ if (this.#shutdown) {
1839
+ this.log?.debug({
1840
+ msg: "Runner is shut down, not attempting reconnect",
1841
+ });
1842
+ return;
1843
+ }
1844
+
1845
+ const delay = calculateBackoff(this.#reconnectAttempt, {
1846
+ initialDelay: 1000,
1847
+ maxDelay: 30000,
1848
+ multiplier: 2,
1849
+ jitter: true,
1850
+ });
1851
+
1852
+ this.log?.debug({
1853
+ msg: `Scheduling reconnect attempt ${this.#reconnectAttempt + 1} in ${delay}ms`,
1854
+ });
1855
+
1856
+ if (this.#reconnectTimeout) {
1857
+ this.log?.info(
1858
+ "clearing previous reconnect timeout in schedule reconnect",
1859
+ );
1860
+ clearTimeout(this.#reconnectTimeout);
1861
+ }
1862
+
1863
+ this.#reconnectTimeout = setTimeout(() => {
1864
+ if (!this.#shutdown) {
1865
+ this.#reconnectAttempt++;
1866
+ this.log?.debug({
1867
+ msg: `Attempting to reconnect (attempt ${this.#reconnectAttempt})...`,
1868
+ });
1869
+ this.#openPegboardWebSocket().catch((err) => {
1870
+ this.log?.error({
1871
+ msg: "error during websocket reconnection",
1872
+ error: stringifyError(err),
1873
+ });
1874
+ });
1875
+ }
1876
+ }, delay);
1877
+ }
1878
+
1879
+ #resendUnacknowledgedEvents() {
1880
+ const eventsToResend = [];
1881
+
1882
+ for (const [_, actor] of this.#actors) {
1883
+ eventsToResend.push(...actor.eventHistory);
1884
+ }
1885
+
1886
+ if (eventsToResend.length === 0) return;
1887
+
1888
+ this.log?.info({
1889
+ msg: "resending unacknowledged events",
1890
+ count: eventsToResend.length,
1891
+ });
1892
+
1893
+ // Resend events in batches
1894
+ this.__sendToServer({
1895
+ tag: "ToServerEvents",
1896
+ val: eventsToResend,
1897
+ });
1898
+ }
1899
+
1900
+ #cleanupOldKvRequests() {
1901
+ const thirtySecondsAgo = Date.now() - KV_EXPIRE;
1902
+ const toDelete: number[] = [];
1903
+
1904
+ for (const [requestId, request] of this.#kvRequests.entries()) {
1905
+ if (request.timestamp < thirtySecondsAgo) {
1906
+ request.reject(
1907
+ new Error(
1908
+ "KV request timed out waiting for WebSocket connection",
1909
+ ),
1910
+ );
1911
+ toDelete.push(requestId);
1912
+ }
1913
+ }
1914
+
1915
+ for (const requestId of toDelete) {
1916
+ this.#kvRequests.delete(requestId);
1917
+ }
1918
+
1919
+ if (toDelete.length > 0) {
1920
+ //this.#log?.log(`Cleaned up ${toDelete.length} expired KV requests`);
1921
+ }
1922
+ }
1923
+
1924
+ getProtocolMetadata(): protocol.ProtocolMetadata | undefined {
1925
+ return this.#protocolMetadata;
1926
+ }
1927
+ }