@rivetkit/engine-runner 2.0.21

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,1485 @@
1
+ import * as protocol from "@rivetkit/engine-runner-protocol";
2
+ import type { Logger } from "pino";
3
+ import type WebSocket from "ws";
4
+ import { logger, setLogger } from "./log.js";
5
+ import { Tunnel } from "./tunnel";
6
+ import { calculateBackoff, unreachable } from "./utils";
7
+ import { importWebSocket } from "./websocket.js";
8
+ import type { WebSocketTunnelAdapter } from "./websocket-tunnel-adapter";
9
+
10
+ const KV_EXPIRE: number = 30_000;
11
+ const PROTOCOL_VERSION: number = 1;
12
+
13
+ /** Warn once the backlog significantly exceeds the server's ack batch size. */
14
+ const EVENT_BACKLOG_WARN_THRESHOLD = 10_000;
15
+ const SIGNAL_HANDLERS: (() => void)[] = [];
16
+
17
+ export interface ActorInstance {
18
+ actorId: string;
19
+ generation: number;
20
+ config: ActorConfig;
21
+ requests: Set<string>; // Track active request IDs
22
+ webSockets: Set<string>; // Track active WebSocket IDs
23
+ }
24
+
25
+ export interface ActorConfig {
26
+ name: string;
27
+ key: string | null;
28
+ createTs: bigint;
29
+ input: Uint8Array | null;
30
+ }
31
+
32
+ export interface RunnerConfig {
33
+ logger?: Logger;
34
+ version: number;
35
+ endpoint: string;
36
+ token?: string;
37
+ pegboardEndpoint?: string;
38
+ pegboardRelayEndpoint?: string;
39
+ namespace: string;
40
+ totalSlots: number;
41
+ runnerName: string;
42
+ runnerKey: string;
43
+ prepopulateActorNames: Record<string, { metadata: Record<string, any> }>;
44
+ metadata?: Record<string, any>;
45
+ onConnected: () => void;
46
+ onDisconnected: () => void;
47
+ onShutdown: () => void;
48
+ fetch: (
49
+ runner: Runner,
50
+ actorId: string,
51
+ request: Request,
52
+ ) => Promise<Response>;
53
+ websocket?: (
54
+ runner: Runner,
55
+ actorId: string,
56
+ ws: any,
57
+ request: Request,
58
+ ) => Promise<void>;
59
+ onActorStart: (
60
+ actorId: string,
61
+ generation: number,
62
+ config: ActorConfig,
63
+ ) => Promise<void>;
64
+ onActorStop: (actorId: string, generation: number) => Promise<void>;
65
+ noAutoShutdown?: boolean;
66
+ }
67
+
68
+ export interface KvListOptions {
69
+ reverse?: boolean;
70
+ limit?: number;
71
+ }
72
+
73
+ interface KvRequestEntry {
74
+ actorId: string;
75
+ data: protocol.KvRequestData;
76
+ resolve: (value: any) => void;
77
+ reject: (error: unknown) => void;
78
+ sent: boolean;
79
+ timestamp: number;
80
+ }
81
+
82
+ export class Runner {
83
+ #config: RunnerConfig;
84
+
85
+ get config(): RunnerConfig {
86
+ return this.#config;
87
+ }
88
+
89
+ #actors: Map<string, ActorInstance> = new Map();
90
+ #actorWebSockets: Map<string, Set<WebSocketTunnelAdapter>> = new Map();
91
+
92
+ // WebSocket
93
+ #pegboardWebSocket?: WebSocket;
94
+ runnerId?: string;
95
+ #lastCommandIdx: number = -1;
96
+ #pingLoop?: NodeJS.Timeout;
97
+ #nextEventIdx: bigint = 0n;
98
+ #started: boolean = false;
99
+ #shutdown: boolean = false;
100
+ #reconnectAttempt: number = 0;
101
+ #reconnectTimeout?: NodeJS.Timeout;
102
+
103
+ // Runner lost threshold management
104
+ #runnerLostThreshold?: number;
105
+ #runnerLostTimeout?: NodeJS.Timeout;
106
+
107
+ // Event storage for resending
108
+ #eventHistory: protocol.EventWrapper[] = [];
109
+ #eventBacklogWarned: boolean = false;
110
+
111
+ // Command acknowledgment
112
+ #ackInterval?: NodeJS.Timeout;
113
+
114
+ // KV operations
115
+ #nextRequestId: number = 0;
116
+ #kvRequests: Map<number, KvRequestEntry> = new Map();
117
+ #kvCleanupInterval?: NodeJS.Timeout;
118
+
119
+ // Tunnel for HTTP/WebSocket forwarding
120
+ #tunnel: Tunnel | undefined;
121
+
122
+ constructor(config: RunnerConfig) {
123
+ this.#config = config;
124
+ if (this.#config.logger) setLogger(this.#config.logger);
125
+
126
+ // Start cleaning up old unsent KV requests every 15 seconds
127
+ this.#kvCleanupInterval = setInterval(() => {
128
+ this.#cleanupOldKvRequests();
129
+ }, 15000); // Run every 15 seconds
130
+ }
131
+
132
+ // MARK: Manage actors
133
+ sleepActor(actorId: string, generation?: number) {
134
+ const actor = this.getActor(actorId, generation);
135
+ if (!actor) return;
136
+
137
+ // Keep the actor instance in memory during sleep
138
+ this.#sendActorIntent(actorId, actor.generation, "sleep");
139
+
140
+ // NOTE: We do NOT remove the actor from this.#actors here
141
+ // The server will send a StopActor command if it wants to fully stop
142
+ }
143
+
144
+ async stopActor(actorId: string, generation?: number) {
145
+ const actor = this.getActor(actorId, generation);
146
+ if (!actor) return;
147
+
148
+ this.#sendActorIntent(actorId, actor.generation, "stop");
149
+
150
+ // NOTE: We do NOT remove the actor from this.#actors here
151
+ // The server will send a StopActor command if it wants to fully stop
152
+ }
153
+
154
+ async forceStopActor(actorId: string, generation?: number) {
155
+ const actor = this.#removeActor(actorId, generation);
156
+ if (!actor) return;
157
+
158
+ // Unregister actor from tunnel
159
+ this.#tunnel?.unregisterActor(actor);
160
+
161
+ // If onActorStop times out, Pegboard will handle this timeout with ACTOR_STOP_THRESHOLD_DURATION_MS
162
+ try {
163
+ await this.#config.onActorStop(actorId, actor.generation);
164
+ } catch (err) {
165
+ console.error(`Error in onActorStop for actor ${actorId}:`, err);
166
+ }
167
+
168
+ this.#sendActorStateUpdate(actorId, actor.generation, "stopped");
169
+
170
+ this.#config.onActorStop(actorId, actor.generation).catch((err) => {
171
+ logger()?.error({
172
+ msg: "error in onactorstop for actor",
173
+ runnerId: this.runnerId,
174
+ actorId,
175
+ err,
176
+ });
177
+ });
178
+ }
179
+
180
+ #stopAllActors() {
181
+ logger()?.info({
182
+ msg: "stopping all actors due to runner lost threshold exceeded",
183
+ runnerId: this.runnerId,
184
+ });
185
+
186
+ const actorIds = Array.from(this.#actors.keys());
187
+ for (const actorId of actorIds) {
188
+ this.forceStopActor(actorId);
189
+ }
190
+ }
191
+
192
+ getActor(actorId: string, generation?: number): ActorInstance | undefined {
193
+ const actor = this.#actors.get(actorId);
194
+ if (!actor) {
195
+ logger()?.error({
196
+ msg: "actor not found",
197
+ runnerId: this.runnerId,
198
+ actorId,
199
+ });
200
+ return undefined;
201
+ }
202
+ if (generation !== undefined && actor.generation !== generation) {
203
+ logger()?.error({
204
+ msg: "actor generation mismatch",
205
+ runnerId: this.runnerId,
206
+ actorId,
207
+ generation,
208
+ });
209
+ return undefined;
210
+ }
211
+
212
+ return actor;
213
+ }
214
+
215
+ hasActor(actorId: string, generation?: number): boolean {
216
+ const actor = this.#actors.get(actorId);
217
+
218
+ return (
219
+ !!actor &&
220
+ (generation === undefined || actor.generation === generation)
221
+ );
222
+ }
223
+
224
+ #removeActor(
225
+ actorId: string,
226
+ generation?: number,
227
+ ): ActorInstance | undefined {
228
+ const actor = this.#actors.get(actorId);
229
+ if (!actor) {
230
+ logger()?.error({
231
+ msg: "actor not found for removal",
232
+ runnerId: this.runnerId,
233
+ actorId,
234
+ });
235
+ return undefined;
236
+ }
237
+ if (generation !== undefined && actor.generation !== generation) {
238
+ logger()?.error({
239
+ msg: "actor generation mismatch",
240
+ runnerId: this.runnerId,
241
+ actorId,
242
+ generation,
243
+ });
244
+ return undefined;
245
+ }
246
+
247
+ this.#actors.delete(actorId);
248
+
249
+ // Close all WebSocket connections for this actor
250
+ const actorWebSockets = this.#actorWebSockets.get(actorId);
251
+ if (actorWebSockets) {
252
+ for (const ws of actorWebSockets) {
253
+ try {
254
+ ws.close(1000, "Actor stopped");
255
+ } catch (err) {
256
+ logger()?.error({
257
+ msg: "error closing websocket for actor",
258
+ runnerId: this.runnerId,
259
+ actorId,
260
+ err,
261
+ });
262
+ }
263
+ }
264
+ this.#actorWebSockets.delete(actorId);
265
+ }
266
+
267
+ return actor;
268
+ }
269
+
270
+ // MARK: Start
271
+ async start() {
272
+ if (this.#started) throw new Error("Cannot call runner.start twice");
273
+ this.#started = true;
274
+
275
+ logger()?.info({ msg: "starting runner" });
276
+
277
+ this.#tunnel = new Tunnel(this);
278
+ this.#tunnel.start();
279
+
280
+ try {
281
+ await this.#openPegboardWebSocket();
282
+ } catch (error) {
283
+ this.#started = false;
284
+ throw error;
285
+ }
286
+
287
+ if (!this.#config.noAutoShutdown) {
288
+ if (!SIGNAL_HANDLERS.length) {
289
+ process.on("SIGTERM", () => {
290
+ logger()?.debug("received SIGTERM");
291
+
292
+ for (const handler of SIGNAL_HANDLERS) {
293
+ handler();
294
+ }
295
+
296
+ process.exit(0);
297
+ });
298
+ process.on("SIGINT", () => {
299
+ logger()?.debug("received SIGINT");
300
+
301
+ for (const handler of SIGNAL_HANDLERS) {
302
+ handler();
303
+ }
304
+
305
+ process.exit(0);
306
+ });
307
+
308
+ logger()?.debug({
309
+ msg: "added SIGTERM listeners",
310
+ });
311
+ }
312
+
313
+ SIGNAL_HANDLERS.push(() => {
314
+ const weak = new WeakRef(this);
315
+ weak.deref()?.shutdown(false, false);
316
+ });
317
+ }
318
+ }
319
+
320
+ // MARK: Shutdown
321
+ async shutdown(immediate: boolean, exit: boolean = false) {
322
+ logger()?.info({
323
+ msg: "starting shutdown",
324
+ runnerId: this.runnerId,
325
+ immediate,
326
+ exit,
327
+ });
328
+ this.#shutdown = true;
329
+
330
+ // Clear reconnect timeout
331
+ if (this.#reconnectTimeout) {
332
+ clearTimeout(this.#reconnectTimeout);
333
+ this.#reconnectTimeout = undefined;
334
+ }
335
+
336
+ // Clear runner lost timeout
337
+ if (this.#runnerLostTimeout) {
338
+ clearTimeout(this.#runnerLostTimeout);
339
+ this.#runnerLostTimeout = undefined;
340
+ }
341
+
342
+ // Clear ping loop
343
+ if (this.#pingLoop) {
344
+ clearInterval(this.#pingLoop);
345
+ this.#pingLoop = undefined;
346
+ }
347
+
348
+ // Clear ack interval
349
+ if (this.#ackInterval) {
350
+ clearInterval(this.#ackInterval);
351
+ this.#ackInterval = undefined;
352
+ }
353
+
354
+ // Clear KV cleanup interval
355
+ if (this.#kvCleanupInterval) {
356
+ clearInterval(this.#kvCleanupInterval);
357
+ this.#kvCleanupInterval = undefined;
358
+ }
359
+
360
+ // Reject all KV requests
361
+ for (const request of this.#kvRequests.values()) {
362
+ request.reject(
363
+ new Error("WebSocket connection closed during shutdown"),
364
+ );
365
+ }
366
+ this.#kvRequests.clear();
367
+
368
+ // Close WebSocket
369
+ if (
370
+ this.#pegboardWebSocket &&
371
+ this.#pegboardWebSocket.readyState === 1
372
+ ) {
373
+ const pegboardWebSocket = this.#pegboardWebSocket;
374
+ if (immediate) {
375
+ // Stop immediately
376
+ pegboardWebSocket.close(1000, "Stopping");
377
+ } else {
378
+ // Wait for actors to shut down before stopping
379
+ try {
380
+ logger()?.info({
381
+ msg: "sending stopping message",
382
+ runnerId: this.runnerId,
383
+ readyState: pegboardWebSocket.readyState,
384
+ });
385
+
386
+ // NOTE: We don't use #sendToServer here because that function checks if the runner is
387
+ // shut down
388
+ const encoded = protocol.encodeToServer({
389
+ tag: "ToServerStopping",
390
+ val: null,
391
+ });
392
+ if (
393
+ this.#pegboardWebSocket &&
394
+ this.#pegboardWebSocket.readyState === 1
395
+ ) {
396
+ this.#pegboardWebSocket.send(encoded);
397
+ } else {
398
+ logger()?.error(
399
+ "WebSocket not available or not open for sending data",
400
+ );
401
+ }
402
+
403
+ const closePromise = new Promise<void>((resolve) => {
404
+ if (!pegboardWebSocket)
405
+ throw new Error("missing pegboardWebSocket");
406
+
407
+ pegboardWebSocket.addEventListener("close", (ev) => {
408
+ logger()?.info({
409
+ msg: "connection closed",
410
+ runnerId: this.runnerId,
411
+ code: ev.code,
412
+ reason: ev.reason.toString(),
413
+ });
414
+ resolve();
415
+ });
416
+ });
417
+
418
+ // TODO: Wait for all actors to stop before closing ws
419
+
420
+ logger()?.info({
421
+ msg: "closing WebSocket",
422
+ runnerId: this.runnerId,
423
+ });
424
+ pegboardWebSocket.close(1000, "Stopping");
425
+
426
+ await closePromise;
427
+
428
+ logger()?.info({
429
+ msg: "websocket shutdown completed",
430
+ runnerId: this.runnerId,
431
+ });
432
+ } catch (error) {
433
+ logger()?.error({
434
+ msg: "error during websocket shutdown:",
435
+ runnerId: this.runnerId,
436
+ error,
437
+ });
438
+ pegboardWebSocket.close();
439
+ }
440
+ }
441
+ } else {
442
+ logger()?.warn({
443
+ msg: "no runner WebSocket to shutdown or already closed",
444
+ runnerId: this.runnerId,
445
+ readyState: this.#pegboardWebSocket?.readyState,
446
+ });
447
+ }
448
+
449
+ // Close tunnel
450
+ if (this.#tunnel) {
451
+ this.#tunnel.shutdown();
452
+ this.#tunnel = undefined;
453
+ }
454
+
455
+ if (exit) process.exit(0);
456
+
457
+ this.#config.onShutdown();
458
+ }
459
+
460
+ // MARK: Networking
461
+ get pegboardUrl() {
462
+ const endpoint = this.#config.pegboardEndpoint || this.#config.endpoint;
463
+ const wsEndpoint = endpoint
464
+ .replace("http://", "ws://")
465
+ .replace("https://", "wss://");
466
+ return `${wsEndpoint}?protocol_version=${PROTOCOL_VERSION}&namespace=${encodeURIComponent(this.#config.namespace)}&runner_key=${encodeURIComponent(this.#config.runnerKey)}`;
467
+ }
468
+
469
+ // MARK: Runner protocol
470
+ async #openPegboardWebSocket() {
471
+ const protocols = ["rivet", `rivet_target.runner`];
472
+ if (this.config.token)
473
+ protocols.push(`rivet_token.${this.config.token}`);
474
+
475
+ const WS = await importWebSocket();
476
+ const ws = new WS(this.pegboardUrl, protocols) as any as WebSocket;
477
+ this.#pegboardWebSocket = ws;
478
+
479
+ ws.addEventListener("open", () => {
480
+ logger()?.info({ msg: "Connected" });
481
+
482
+ // Reset reconnect attempt counter on successful connection
483
+ this.#reconnectAttempt = 0;
484
+
485
+ // Clear any pending reconnect timeout
486
+ if (this.#reconnectTimeout) {
487
+ clearTimeout(this.#reconnectTimeout);
488
+ this.#reconnectTimeout = undefined;
489
+ }
490
+
491
+ // Clear any pending runner lost timeout since we're reconnecting
492
+ if (this.#runnerLostTimeout) {
493
+ clearTimeout(this.#runnerLostTimeout);
494
+ this.#runnerLostTimeout = undefined;
495
+ }
496
+
497
+ // Send init message
498
+ const init: protocol.ToServerInit = {
499
+ name: this.#config.runnerName,
500
+ version: this.#config.version,
501
+ totalSlots: this.#config.totalSlots,
502
+ lastCommandIdx:
503
+ this.#lastCommandIdx >= 0
504
+ ? BigInt(this.#lastCommandIdx)
505
+ : null,
506
+ prepopulateActorNames: new Map(
507
+ Object.entries(this.#config.prepopulateActorNames).map(
508
+ ([name, data]) => [
509
+ name,
510
+ { metadata: JSON.stringify(data.metadata) },
511
+ ],
512
+ ),
513
+ ),
514
+ metadata: JSON.stringify(this.#config.metadata),
515
+ };
516
+
517
+ this.__sendToServer({
518
+ tag: "ToServerInit",
519
+ val: init,
520
+ });
521
+
522
+ // Process unsent KV requests
523
+ this.#processUnsentKvRequests();
524
+
525
+ // Start ping interval
526
+ const pingInterval = 1000;
527
+ const pingLoop = setInterval(() => {
528
+ if (ws.readyState === 1) {
529
+ this.__sendToServer({
530
+ tag: "ToServerPing",
531
+ val: {
532
+ ts: BigInt(Date.now()),
533
+ },
534
+ });
535
+ } else {
536
+ clearInterval(pingLoop);
537
+ logger()?.info({
538
+ msg: "WebSocket not open, stopping ping loop",
539
+ runnerId: this.runnerId,
540
+ });
541
+ }
542
+ }, pingInterval);
543
+ this.#pingLoop = pingLoop;
544
+
545
+ // Start command acknowledgment interval (5 minutes)
546
+ const ackInterval = 5 * 60 * 1000; // 5 minutes in milliseconds
547
+ const ackLoop = setInterval(() => {
548
+ if (ws.readyState === 1) {
549
+ this.#sendCommandAcknowledgment();
550
+ } else {
551
+ clearInterval(ackLoop);
552
+ logger()?.info({
553
+ msg: "WebSocket not open, stopping ack loop",
554
+ runnerId: this.runnerId,
555
+ });
556
+ }
557
+ }, ackInterval);
558
+ this.#ackInterval = ackLoop;
559
+ });
560
+
561
+ ws.addEventListener("message", async (ev) => {
562
+ let buf: Uint8Array;
563
+ if (ev.data instanceof Blob) {
564
+ buf = new Uint8Array(await ev.data.arrayBuffer());
565
+ } else if (Buffer.isBuffer(ev.data)) {
566
+ buf = new Uint8Array(ev.data);
567
+ } else {
568
+ throw new Error(`expected binary data, got ${typeof ev.data}`);
569
+ }
570
+
571
+ // Parse message
572
+ const message = protocol.decodeToClient(buf);
573
+
574
+ // Handle message
575
+ if (message.tag === "ToClientInit") {
576
+ const init = message.val;
577
+
578
+ if (this.runnerId !== init.runnerId) {
579
+ this.runnerId = init.runnerId;
580
+
581
+ // Clear history if runner id changed
582
+ this.#eventHistory.length = 0;
583
+ }
584
+
585
+ // Store the runner lost threshold from metadata
586
+ this.#runnerLostThreshold = init.metadata?.runnerLostThreshold
587
+ ? Number(init.metadata.runnerLostThreshold)
588
+ : undefined;
589
+
590
+ logger()?.info({
591
+ msg: "received init",
592
+ runnerId: init.runnerId,
593
+ lastEventIdx: init.lastEventIdx,
594
+ runnerLostThreshold: this.#runnerLostThreshold,
595
+ });
596
+
597
+ // Resend events that haven't been acknowledged
598
+ this.#resendUnacknowledgedEvents(init.lastEventIdx);
599
+
600
+ this.#config.onConnected();
601
+ } else if (message.tag === "ToClientCommands") {
602
+ const commands = message.val;
603
+ this.#handleCommands(commands);
604
+ } else if (message.tag === "ToClientAckEvents") {
605
+ this.#handleAckEvents(message.val);
606
+ } else if (message.tag === "ToClientKvResponse") {
607
+ const kvResponse = message.val;
608
+ this.#handleKvResponse(kvResponse);
609
+ } else if (message.tag === "ToClientTunnelMessage") {
610
+ this.#tunnel?.handleTunnelMessage(message.val);
611
+ } else if (message.tag === "ToClientClose") {
612
+ this.#tunnel?.shutdown();
613
+ ws.close(1000, "manual closure");
614
+ } else {
615
+ unreachable(message);
616
+ }
617
+ });
618
+
619
+ ws.addEventListener("error", (ev) => {
620
+ logger()?.error({
621
+ msg: `WebSocket error: ${ev.error}`,
622
+ runnerId: this.runnerId,
623
+ });
624
+
625
+ if (!this.#shutdown) {
626
+ // Start runner lost timeout if we have a threshold and are not shutting down
627
+ if (
628
+ !this.#runnerLostTimeout &&
629
+ this.#runnerLostThreshold &&
630
+ this.#runnerLostThreshold > 0
631
+ ) {
632
+ logger()?.info({
633
+ msg: "starting runner lost timeout",
634
+ runnerId: this.runnerId,
635
+ seconds: this.#runnerLostThreshold / 1000,
636
+ });
637
+ this.#runnerLostTimeout = setTimeout(() => {
638
+ this.#stopAllActors();
639
+ }, this.#runnerLostThreshold);
640
+ }
641
+
642
+ // Attempt to reconnect if not stopped
643
+ this.#scheduleReconnect();
644
+ }
645
+ });
646
+
647
+ ws.addEventListener("close", async (ev) => {
648
+ logger()?.info({
649
+ msg: "connection closed",
650
+ runnerId: this.runnerId,
651
+ code: ev.code,
652
+ reason: ev.reason.toString(),
653
+ });
654
+
655
+ this.#config.onDisconnected();
656
+
657
+ if (ev.reason.toString().startsWith("ws.eviction")) {
658
+ logger()?.info({
659
+ msg: "runner evicted",
660
+ runnerId: this.runnerId,
661
+ });
662
+
663
+ await this.shutdown(true);
664
+ }
665
+
666
+ // Clear ping loop on close
667
+ if (this.#pingLoop) {
668
+ clearInterval(this.#pingLoop);
669
+ this.#pingLoop = undefined;
670
+ }
671
+
672
+ // Clear ack interval on close
673
+ if (this.#ackInterval) {
674
+ clearInterval(this.#ackInterval);
675
+ this.#ackInterval = undefined;
676
+ }
677
+
678
+ if (!this.#shutdown) {
679
+ // Start runner lost timeout if we have a threshold and are not shutting down
680
+ if (
681
+ !this.#runnerLostTimeout &&
682
+ this.#runnerLostThreshold &&
683
+ this.#runnerLostThreshold > 0
684
+ ) {
685
+ logger()?.info({
686
+ msg: "starting runner lost timeout",
687
+ runnerId: this.runnerId,
688
+ seconds: this.#runnerLostThreshold / 1000,
689
+ });
690
+ this.#runnerLostTimeout = setTimeout(() => {
691
+ this.#stopAllActors();
692
+ }, this.#runnerLostThreshold);
693
+ }
694
+
695
+ // Attempt to reconnect if not stopped
696
+ this.#scheduleReconnect();
697
+ }
698
+ });
699
+ }
700
+
701
+ #handleCommands(commands: protocol.ToClientCommands) {
702
+ logger()?.info({
703
+ msg: "received commands",
704
+ runnerId: this.runnerId,
705
+ commandCount: commands.length,
706
+ });
707
+
708
+ for (const commandWrapper of commands) {
709
+ logger()?.info({
710
+ msg: "received command",
711
+ runnerId: this.runnerId,
712
+ commandWrapper,
713
+ });
714
+ if (commandWrapper.inner.tag === "CommandStartActor") {
715
+ this.#handleCommandStartActor(commandWrapper);
716
+ } else if (commandWrapper.inner.tag === "CommandStopActor") {
717
+ this.#handleCommandStopActor(commandWrapper);
718
+ } else {
719
+ unreachable(commandWrapper.inner);
720
+ }
721
+
722
+ this.#lastCommandIdx = Number(commandWrapper.index);
723
+ }
724
+ }
725
+
726
+ #handleAckEvents(ack: protocol.ToClientAckEvents) {
727
+ const lastAckedIdx = ack.lastEventIdx;
728
+
729
+ const originalLength = this.#eventHistory.length;
730
+ this.#eventHistory = this.#eventHistory.filter(
731
+ (event) => event.index > lastAckedIdx,
732
+ );
733
+
734
+ const prunedCount = originalLength - this.#eventHistory.length;
735
+ if (prunedCount > 0) {
736
+ logger()?.info({
737
+ msg: "pruned acknowledged events",
738
+ runnerId: this.runnerId,
739
+ lastAckedIdx: lastAckedIdx.toString(),
740
+ prunedCount,
741
+ });
742
+ }
743
+
744
+ if (this.#eventHistory.length <= EVENT_BACKLOG_WARN_THRESHOLD) {
745
+ this.#eventBacklogWarned = false;
746
+ }
747
+ }
748
+
749
+ /** Track events to send to the server in case we need to resend it on disconnect. */
750
+ #recordEvent(eventWrapper: protocol.EventWrapper) {
751
+ this.#eventHistory.push(eventWrapper);
752
+
753
+ if (
754
+ this.#eventHistory.length > EVENT_BACKLOG_WARN_THRESHOLD &&
755
+ !this.#eventBacklogWarned
756
+ ) {
757
+ this.#eventBacklogWarned = true;
758
+ logger()?.warn({
759
+ msg: "unacknowledged event backlog exceeds threshold",
760
+ runnerId: this.runnerId,
761
+ backlogSize: this.#eventHistory.length,
762
+ threshold: EVENT_BACKLOG_WARN_THRESHOLD,
763
+ });
764
+ }
765
+ }
766
+
767
+ #handleCommandStartActor(commandWrapper: protocol.CommandWrapper) {
768
+ const startCommand = commandWrapper.inner
769
+ .val as protocol.CommandStartActor;
770
+
771
+ const actorId = startCommand.actorId;
772
+ const generation = startCommand.generation;
773
+ const config = startCommand.config;
774
+
775
+ const actorConfig: ActorConfig = {
776
+ name: config.name,
777
+ key: config.key,
778
+ createTs: config.createTs,
779
+ input: config.input ? new Uint8Array(config.input) : null,
780
+ };
781
+
782
+ const instance: ActorInstance = {
783
+ actorId,
784
+ generation,
785
+ config: actorConfig,
786
+ requests: new Set(),
787
+ webSockets: new Set(),
788
+ };
789
+
790
+ this.#actors.set(actorId, instance);
791
+
792
+ this.#sendActorStateUpdate(actorId, generation, "running");
793
+
794
+ // TODO: Add timeout to onActorStart
795
+ // Call onActorStart asynchronously and handle errors
796
+ this.#config
797
+ .onActorStart(actorId, generation, actorConfig)
798
+ .catch((err) => {
799
+ logger()?.error({
800
+ msg: "error in onactorstart for actor",
801
+ runnerId: this.runnerId,
802
+ actorId,
803
+ err,
804
+ });
805
+
806
+ // TODO: Mark as crashed
807
+ // Send stopped state update if start failed
808
+ this.forceStopActor(actorId, generation);
809
+ });
810
+ }
811
+
812
+ #handleCommandStopActor(commandWrapper: protocol.CommandWrapper) {
813
+ const stopCommand = commandWrapper.inner
814
+ .val as protocol.CommandStopActor;
815
+
816
+ const actorId = stopCommand.actorId;
817
+ const generation = stopCommand.generation;
818
+
819
+ this.forceStopActor(actorId, generation);
820
+ }
821
+
822
+ #sendActorIntent(
823
+ actorId: string,
824
+ generation: number,
825
+ intentType: "sleep" | "stop",
826
+ ) {
827
+ if (this.#shutdown) {
828
+ logger()?.warn({
829
+ msg: "Runner is shut down, cannot send actor intent",
830
+ runnerId: this.runnerId,
831
+ });
832
+ return;
833
+ }
834
+ let actorIntent: protocol.ActorIntent;
835
+
836
+ if (intentType === "sleep") {
837
+ actorIntent = { tag: "ActorIntentSleep", val: null };
838
+ } else if (intentType === "stop") {
839
+ actorIntent = {
840
+ tag: "ActorIntentStop",
841
+ val: null,
842
+ };
843
+ } else {
844
+ unreachable(intentType);
845
+ }
846
+
847
+ const intentEvent: protocol.EventActorIntent = {
848
+ actorId,
849
+ generation,
850
+ intent: actorIntent,
851
+ };
852
+
853
+ const eventIndex = this.#nextEventIdx++;
854
+ const eventWrapper: protocol.EventWrapper = {
855
+ index: eventIndex,
856
+ inner: {
857
+ tag: "EventActorIntent",
858
+ val: intentEvent,
859
+ },
860
+ };
861
+
862
+ this.#recordEvent(eventWrapper);
863
+
864
+ logger()?.info({
865
+ msg: "sending event to server",
866
+ runnerId: this.runnerId,
867
+ index: eventWrapper.index,
868
+ tag: eventWrapper.inner.tag,
869
+ val: eventWrapper.inner.val,
870
+ });
871
+
872
+ this.__sendToServer({
873
+ tag: "ToServerEvents",
874
+ val: [eventWrapper],
875
+ });
876
+ }
877
+
878
+ #sendActorStateUpdate(
879
+ actorId: string,
880
+ generation: number,
881
+ stateType: "running" | "stopped",
882
+ ) {
883
+ if (this.#shutdown) {
884
+ logger()?.warn({
885
+ msg: "Runner is shut down, cannot send actor state update",
886
+ runnerId: this.runnerId,
887
+ });
888
+ return;
889
+ }
890
+ let actorState: protocol.ActorState;
891
+
892
+ if (stateType === "running") {
893
+ actorState = { tag: "ActorStateRunning", val: null };
894
+ } else if (stateType === "stopped") {
895
+ actorState = {
896
+ tag: "ActorStateStopped",
897
+ val: {
898
+ code: protocol.StopCode.Ok,
899
+ message: null,
900
+ },
901
+ };
902
+ } else {
903
+ unreachable(stateType);
904
+ }
905
+
906
+ const stateUpdateEvent: protocol.EventActorStateUpdate = {
907
+ actorId,
908
+ generation,
909
+ state: actorState,
910
+ };
911
+
912
+ const eventIndex = this.#nextEventIdx++;
913
+ const eventWrapper: protocol.EventWrapper = {
914
+ index: eventIndex,
915
+ inner: {
916
+ tag: "EventActorStateUpdate",
917
+ val: stateUpdateEvent,
918
+ },
919
+ };
920
+
921
+ this.#recordEvent(eventWrapper);
922
+
923
+ logger()?.info({
924
+ msg: "sending event to server",
925
+ runnerId: this.runnerId,
926
+ index: eventWrapper.index,
927
+ tag: eventWrapper.inner.tag,
928
+ val: eventWrapper.inner.val,
929
+ });
930
+
931
+ this.__sendToServer({
932
+ tag: "ToServerEvents",
933
+ val: [eventWrapper],
934
+ });
935
+ }
936
+
937
+ #sendCommandAcknowledgment() {
938
+ if (this.#shutdown) {
939
+ logger()?.warn({
940
+ msg: "Runner is shut down, cannot send command acknowledgment",
941
+ runnerId: this.runnerId,
942
+ });
943
+ return;
944
+ }
945
+
946
+ if (this.#lastCommandIdx < 0) {
947
+ // No commands received yet, nothing to acknowledge
948
+ return;
949
+ }
950
+
951
+ //logger()?.log("Sending command acknowledgment", this.#lastCommandIdx);
952
+
953
+ this.__sendToServer({
954
+ tag: "ToServerAckCommands",
955
+ val: {
956
+ lastCommandIdx: BigInt(this.#lastCommandIdx),
957
+ },
958
+ });
959
+ }
960
+
961
+ #handleKvResponse(response: protocol.ToClientKvResponse) {
962
+ const requestId = response.requestId;
963
+ const request = this.#kvRequests.get(requestId);
964
+
965
+ if (!request) {
966
+ const msg = "received kv response for unknown request id";
967
+ logger()?.error({ msg, runnerId: this.runnerId, requestId });
968
+ return;
969
+ }
970
+
971
+ this.#kvRequests.delete(requestId);
972
+
973
+ if (response.data.tag === "KvErrorResponse") {
974
+ request.reject(
975
+ new Error(response.data.val.message || "Unknown KV error"),
976
+ );
977
+ } else {
978
+ request.resolve(response.data.val);
979
+ }
980
+ }
981
+
982
+ #parseGetResponseSimple(
983
+ response: protocol.KvGetResponse,
984
+ requestedKeys: Uint8Array[],
985
+ ): (Uint8Array | null)[] {
986
+ // Parse the response keys and values
987
+ const responseKeys: Uint8Array[] = [];
988
+ const responseValues: Uint8Array[] = [];
989
+
990
+ for (const key of response.keys) {
991
+ responseKeys.push(new Uint8Array(key));
992
+ }
993
+
994
+ for (const value of response.values) {
995
+ responseValues.push(new Uint8Array(value));
996
+ }
997
+
998
+ // Map response back to requested key order
999
+ const result: (Uint8Array | null)[] = [];
1000
+ for (const requestedKey of requestedKeys) {
1001
+ let found = false;
1002
+ for (let i = 0; i < responseKeys.length; i++) {
1003
+ if (this.#keysEqual(requestedKey, responseKeys[i])) {
1004
+ result.push(responseValues[i]);
1005
+ found = true;
1006
+ break;
1007
+ }
1008
+ }
1009
+ if (!found) {
1010
+ result.push(null);
1011
+ }
1012
+ }
1013
+
1014
+ return result;
1015
+ }
1016
+
1017
+ #keysEqual(key1: Uint8Array, key2: Uint8Array): boolean {
1018
+ if (key1.length !== key2.length) return false;
1019
+ for (let i = 0; i < key1.length; i++) {
1020
+ if (key1[i] !== key2[i]) return false;
1021
+ }
1022
+ return true;
1023
+ }
1024
+
1025
+ //#parseGetResponse(response: protocol.KvGetResponse) {
1026
+ // const keys: string[] = [];
1027
+ // const values: Uint8Array[] = [];
1028
+ // const metadata: { version: Uint8Array; createTs: bigint }[] = [];
1029
+ //
1030
+ // for (const key of response.keys) {
1031
+ // keys.push(new TextDecoder().decode(key));
1032
+ // }
1033
+ //
1034
+ // for (const value of response.values) {
1035
+ // values.push(new Uint8Array(value));
1036
+ // }
1037
+ //
1038
+ // for (const meta of response.metadata) {
1039
+ // metadata.push({
1040
+ // version: new Uint8Array(meta.version),
1041
+ // createTs: meta.createTs,
1042
+ // });
1043
+ // }
1044
+ //
1045
+ // return { keys, values, metadata };
1046
+ //}
1047
+
1048
+ #parseListResponseSimple(
1049
+ response: protocol.KvListResponse,
1050
+ ): [Uint8Array, Uint8Array][] {
1051
+ const result: [Uint8Array, Uint8Array][] = [];
1052
+
1053
+ for (let i = 0; i < response.keys.length; i++) {
1054
+ const key = response.keys[i];
1055
+ const value = response.values[i];
1056
+
1057
+ if (key && value) {
1058
+ const keyBytes = new Uint8Array(key);
1059
+ const valueBytes = new Uint8Array(value);
1060
+ result.push([keyBytes, valueBytes]);
1061
+ }
1062
+ }
1063
+
1064
+ return result;
1065
+ }
1066
+
1067
+ //#parseListResponse(response: protocol.KvListResponse) {
1068
+ // const keys: string[] = [];
1069
+ // const values: Uint8Array[] = [];
1070
+ // const metadata: { version: Uint8Array; createTs: bigint }[] = [];
1071
+ //
1072
+ // for (const key of response.keys) {
1073
+ // keys.push(new TextDecoder().decode(key));
1074
+ // }
1075
+ //
1076
+ // for (const value of response.values) {
1077
+ // values.push(new Uint8Array(value));
1078
+ // }
1079
+ //
1080
+ // for (const meta of response.metadata) {
1081
+ // metadata.push({
1082
+ // version: new Uint8Array(meta.version),
1083
+ // createTs: meta.createTs,
1084
+ // });
1085
+ // }
1086
+ //
1087
+ // return { keys, values, metadata };
1088
+ //}
1089
+
1090
+ // MARK: KV Operations
1091
+ async kvGet(
1092
+ actorId: string,
1093
+ keys: Uint8Array[],
1094
+ ): Promise<(Uint8Array | null)[]> {
1095
+ const kvKeys: protocol.KvKey[] = keys.map(
1096
+ (key) =>
1097
+ key.buffer.slice(
1098
+ key.byteOffset,
1099
+ key.byteOffset + key.byteLength,
1100
+ ) as ArrayBuffer,
1101
+ );
1102
+
1103
+ const requestData: protocol.KvRequestData = {
1104
+ tag: "KvGetRequest",
1105
+ val: { keys: kvKeys },
1106
+ };
1107
+
1108
+ const response = await this.#sendKvRequest(actorId, requestData);
1109
+ return this.#parseGetResponseSimple(response, keys);
1110
+ }
1111
+
1112
+ async kvListAll(
1113
+ actorId: string,
1114
+ options?: KvListOptions,
1115
+ ): Promise<[Uint8Array, Uint8Array][]> {
1116
+ const requestData: protocol.KvRequestData = {
1117
+ tag: "KvListRequest",
1118
+ val: {
1119
+ query: { tag: "KvListAllQuery", val: null },
1120
+ reverse: options?.reverse || null,
1121
+ limit:
1122
+ options?.limit !== undefined ? BigInt(options.limit) : null,
1123
+ },
1124
+ };
1125
+
1126
+ const response = await this.#sendKvRequest(actorId, requestData);
1127
+ return this.#parseListResponseSimple(response);
1128
+ }
1129
+
1130
+ async kvListRange(
1131
+ actorId: string,
1132
+ start: Uint8Array,
1133
+ end: Uint8Array,
1134
+ exclusive?: boolean,
1135
+ options?: KvListOptions,
1136
+ ): Promise<[Uint8Array, Uint8Array][]> {
1137
+ const startKey: protocol.KvKey = start.buffer.slice(
1138
+ start.byteOffset,
1139
+ start.byteOffset + start.byteLength,
1140
+ ) as ArrayBuffer;
1141
+ const endKey: protocol.KvKey = end.buffer.slice(
1142
+ end.byteOffset,
1143
+ end.byteOffset + end.byteLength,
1144
+ ) as ArrayBuffer;
1145
+
1146
+ const requestData: protocol.KvRequestData = {
1147
+ tag: "KvListRequest",
1148
+ val: {
1149
+ query: {
1150
+ tag: "KvListRangeQuery",
1151
+ val: {
1152
+ start: startKey,
1153
+ end: endKey,
1154
+ exclusive: exclusive || false,
1155
+ },
1156
+ },
1157
+ reverse: options?.reverse || null,
1158
+ limit:
1159
+ options?.limit !== undefined ? BigInt(options.limit) : null,
1160
+ },
1161
+ };
1162
+
1163
+ const response = await this.#sendKvRequest(actorId, requestData);
1164
+ return this.#parseListResponseSimple(response);
1165
+ }
1166
+
1167
+ async kvListPrefix(
1168
+ actorId: string,
1169
+ prefix: Uint8Array,
1170
+ options?: KvListOptions,
1171
+ ): Promise<[Uint8Array, Uint8Array][]> {
1172
+ const prefixKey: protocol.KvKey = prefix.buffer.slice(
1173
+ prefix.byteOffset,
1174
+ prefix.byteOffset + prefix.byteLength,
1175
+ ) as ArrayBuffer;
1176
+
1177
+ const requestData: protocol.KvRequestData = {
1178
+ tag: "KvListRequest",
1179
+ val: {
1180
+ query: {
1181
+ tag: "KvListPrefixQuery",
1182
+ val: { key: prefixKey },
1183
+ },
1184
+ reverse: options?.reverse || null,
1185
+ limit:
1186
+ options?.limit !== undefined ? BigInt(options.limit) : null,
1187
+ },
1188
+ };
1189
+
1190
+ const response = await this.#sendKvRequest(actorId, requestData);
1191
+ return this.#parseListResponseSimple(response);
1192
+ }
1193
+
1194
+ async kvPut(
1195
+ actorId: string,
1196
+ entries: [Uint8Array, Uint8Array][],
1197
+ ): Promise<void> {
1198
+ const keys: protocol.KvKey[] = entries.map(
1199
+ ([key, _value]) =>
1200
+ key.buffer.slice(
1201
+ key.byteOffset,
1202
+ key.byteOffset + key.byteLength,
1203
+ ) as ArrayBuffer,
1204
+ );
1205
+ const values: protocol.KvValue[] = entries.map(
1206
+ ([_key, value]) =>
1207
+ value.buffer.slice(
1208
+ value.byteOffset,
1209
+ value.byteOffset + value.byteLength,
1210
+ ) as ArrayBuffer,
1211
+ );
1212
+
1213
+ const requestData: protocol.KvRequestData = {
1214
+ tag: "KvPutRequest",
1215
+ val: { keys, values },
1216
+ };
1217
+
1218
+ await this.#sendKvRequest(actorId, requestData);
1219
+ }
1220
+
1221
+ async kvDelete(actorId: string, keys: Uint8Array[]): Promise<void> {
1222
+ const kvKeys: protocol.KvKey[] = keys.map(
1223
+ (key) =>
1224
+ key.buffer.slice(
1225
+ key.byteOffset,
1226
+ key.byteOffset + key.byteLength,
1227
+ ) as ArrayBuffer,
1228
+ );
1229
+
1230
+ const requestData: protocol.KvRequestData = {
1231
+ tag: "KvDeleteRequest",
1232
+ val: { keys: kvKeys },
1233
+ };
1234
+
1235
+ await this.#sendKvRequest(actorId, requestData);
1236
+ }
1237
+
1238
+ async kvDrop(actorId: string): Promise<void> {
1239
+ const requestData: protocol.KvRequestData = {
1240
+ tag: "KvDropRequest",
1241
+ val: null,
1242
+ };
1243
+
1244
+ await this.#sendKvRequest(actorId, requestData);
1245
+ }
1246
+
1247
+ // MARK: Alarm Operations
1248
+ setAlarm(actorId: string, alarmTs: number | null, generation?: number) {
1249
+ const actor = this.getActor(actorId, generation);
1250
+ if (!actor) return;
1251
+
1252
+ if (this.#shutdown) {
1253
+ console.warn("Runner is shut down, cannot set alarm");
1254
+ return;
1255
+ }
1256
+
1257
+ const alarmEvent: protocol.EventActorSetAlarm = {
1258
+ actorId,
1259
+ generation: actor.generation,
1260
+ alarmTs: alarmTs !== null ? BigInt(alarmTs) : null,
1261
+ };
1262
+
1263
+ const eventIndex = this.#nextEventIdx++;
1264
+ const eventWrapper: protocol.EventWrapper = {
1265
+ index: eventIndex,
1266
+ inner: {
1267
+ tag: "EventActorSetAlarm",
1268
+ val: alarmEvent,
1269
+ },
1270
+ };
1271
+
1272
+ this.#recordEvent(eventWrapper);
1273
+
1274
+ this.__sendToServer({
1275
+ tag: "ToServerEvents",
1276
+ val: [eventWrapper],
1277
+ });
1278
+ }
1279
+
1280
+ clearAlarm(actorId: string, generation?: number) {
1281
+ this.setAlarm(actorId, null, generation);
1282
+ }
1283
+
1284
+ #sendKvRequest(
1285
+ actorId: string,
1286
+ requestData: protocol.KvRequestData,
1287
+ ): Promise<any> {
1288
+ return new Promise((resolve, reject) => {
1289
+ if (this.#shutdown) {
1290
+ reject(new Error("Runner is shut down"));
1291
+ return;
1292
+ }
1293
+
1294
+ const requestId = this.#nextRequestId++;
1295
+ const isConnected =
1296
+ this.#pegboardWebSocket &&
1297
+ this.#pegboardWebSocket.readyState === 1;
1298
+
1299
+ // Store the request
1300
+ const requestEntry = {
1301
+ actorId,
1302
+ data: requestData,
1303
+ resolve,
1304
+ reject,
1305
+ sent: false,
1306
+ timestamp: Date.now(),
1307
+ };
1308
+
1309
+ this.#kvRequests.set(requestId, requestEntry);
1310
+
1311
+ if (isConnected) {
1312
+ // Send immediately
1313
+ this.#sendSingleKvRequest(requestId);
1314
+ }
1315
+ });
1316
+ }
1317
+
1318
+ #sendSingleKvRequest(requestId: number) {
1319
+ const request = this.#kvRequests.get(requestId);
1320
+ if (!request || request.sent) return;
1321
+
1322
+ try {
1323
+ const kvRequest: protocol.ToServerKvRequest = {
1324
+ actorId: request.actorId,
1325
+ requestId,
1326
+ data: request.data,
1327
+ };
1328
+
1329
+ this.__sendToServer({
1330
+ tag: "ToServerKvRequest",
1331
+ val: kvRequest,
1332
+ });
1333
+
1334
+ // Mark as sent and update timestamp
1335
+ request.sent = true;
1336
+ request.timestamp = Date.now();
1337
+ } catch (error) {
1338
+ this.#kvRequests.delete(requestId);
1339
+ request.reject(error);
1340
+ }
1341
+ }
1342
+
1343
+ #processUnsentKvRequests() {
1344
+ if (
1345
+ !this.#pegboardWebSocket ||
1346
+ this.#pegboardWebSocket.readyState !== 1
1347
+ ) {
1348
+ return;
1349
+ }
1350
+
1351
+ let processedCount = 0;
1352
+ for (const [requestId, request] of this.#kvRequests.entries()) {
1353
+ if (!request.sent) {
1354
+ this.#sendSingleKvRequest(requestId);
1355
+ processedCount++;
1356
+ }
1357
+ }
1358
+
1359
+ if (processedCount > 0) {
1360
+ //logger()?.log(`Processed ${processedCount} queued KV requests`);
1361
+ }
1362
+ }
1363
+
1364
+ __webSocketReady(): boolean {
1365
+ return this.#pegboardWebSocket
1366
+ ? this.#pegboardWebSocket.readyState === 1
1367
+ : false;
1368
+ }
1369
+
1370
+ __sendToServer(message: protocol.ToServer) {
1371
+ if (this.#shutdown) {
1372
+ logger()?.warn({
1373
+ msg: "Runner is shut down, cannot send message to server",
1374
+ runnerId: this.runnerId,
1375
+ });
1376
+ return;
1377
+ }
1378
+
1379
+ const encoded = protocol.encodeToServer(message);
1380
+ if (
1381
+ this.#pegboardWebSocket &&
1382
+ this.#pegboardWebSocket.readyState === 1
1383
+ ) {
1384
+ this.#pegboardWebSocket.send(encoded);
1385
+ } else {
1386
+ logger()?.error({
1387
+ msg: "WebSocket not available or not open for sending data",
1388
+ runnerId: this.runnerId,
1389
+ });
1390
+ }
1391
+ }
1392
+
1393
+ getServerlessInitPacket(): string | undefined {
1394
+ if (!this.runnerId) return undefined;
1395
+
1396
+ const data = protocol.encodeToServerlessServer({
1397
+ tag: "ToServerlessServerInit",
1398
+ val: {
1399
+ runnerId: this.runnerId,
1400
+ },
1401
+ });
1402
+
1403
+ // Embed version
1404
+ const buffer = Buffer.alloc(data.length + 2);
1405
+ buffer.writeUInt16LE(PROTOCOL_VERSION, 0);
1406
+ Buffer.from(data).copy(buffer, 2);
1407
+
1408
+ return buffer.toString("base64");
1409
+ }
1410
+
1411
+ #scheduleReconnect() {
1412
+ if (this.#shutdown) {
1413
+ logger()?.debug({
1414
+ msg: "Runner is shut down, not attempting reconnect",
1415
+ runnerId: this.runnerId,
1416
+ });
1417
+ return;
1418
+ }
1419
+
1420
+ const delay = calculateBackoff(this.#reconnectAttempt, {
1421
+ initialDelay: 1000,
1422
+ maxDelay: 30000,
1423
+ multiplier: 2,
1424
+ jitter: true,
1425
+ });
1426
+
1427
+ logger()?.debug({
1428
+ msg: `Scheduling reconnect attempt ${this.#reconnectAttempt + 1} in ${delay}ms`,
1429
+ runnerId: this.runnerId,
1430
+ });
1431
+
1432
+ this.#reconnectTimeout = setTimeout(async () => {
1433
+ if (!this.#shutdown) {
1434
+ this.#reconnectAttempt++;
1435
+ logger()?.debug({
1436
+ msg: `Attempting to reconnect (attempt ${this.#reconnectAttempt})...`,
1437
+ runnerId: this.runnerId,
1438
+ });
1439
+ await this.#openPegboardWebSocket();
1440
+ }
1441
+ }, delay);
1442
+ }
1443
+
1444
+ #resendUnacknowledgedEvents(lastEventIdx: bigint) {
1445
+ const eventsToResend = this.#eventHistory.filter(
1446
+ (event) => event.index > lastEventIdx,
1447
+ );
1448
+
1449
+ if (eventsToResend.length === 0) return;
1450
+
1451
+ //logger()?.log(
1452
+ // `Resending ${eventsToResend.length} unacknowledged events from index ${Number(lastEventIdx) + 1}`,
1453
+ //);
1454
+
1455
+ // Resend events in batches
1456
+ this.__sendToServer({
1457
+ tag: "ToServerEvents",
1458
+ val: eventsToResend,
1459
+ });
1460
+ }
1461
+
1462
+ #cleanupOldKvRequests() {
1463
+ const thirtySecondsAgo = Date.now() - KV_EXPIRE;
1464
+ const toDelete: number[] = [];
1465
+
1466
+ for (const [requestId, request] of this.#kvRequests.entries()) {
1467
+ if (request.timestamp < thirtySecondsAgo) {
1468
+ request.reject(
1469
+ new Error(
1470
+ "KV request timed out waiting for WebSocket connection",
1471
+ ),
1472
+ );
1473
+ toDelete.push(requestId);
1474
+ }
1475
+ }
1476
+
1477
+ for (const requestId of toDelete) {
1478
+ this.#kvRequests.delete(requestId);
1479
+ }
1480
+
1481
+ if (toDelete.length > 0) {
1482
+ //logger()?.log(`Cleaned up ${toDelete.length} expired KV requests`);
1483
+ }
1484
+ }
1485
+ }