rivetkit 2.0.30 → 2.0.32

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.
Files changed (131) hide show
  1. package/dist/schemas/actor-persist/v1.ts +2 -2
  2. package/dist/schemas/actor-persist/v2.ts +2 -2
  3. package/dist/schemas/actor-persist/v3.ts +2 -2
  4. package/dist/schemas/client-protocol/v1.ts +2 -2
  5. package/dist/schemas/client-protocol/v2.ts +2 -2
  6. package/dist/schemas/file-system-driver/v1.ts +2 -2
  7. package/dist/schemas/file-system-driver/v2.ts +2 -2
  8. package/dist/schemas/file-system-driver/v3.ts +2 -2
  9. package/dist/tsup/actor/errors.cjs +4 -2
  10. package/dist/tsup/actor/errors.cjs.map +1 -1
  11. package/dist/tsup/actor/errors.d.cts +5 -2
  12. package/dist/tsup/actor/errors.d.ts +5 -2
  13. package/dist/tsup/actor/errors.js +5 -3
  14. package/dist/tsup/{chunk-46DWBVYE.cjs → chunk-24MVWG2B.cjs} +15 -15
  15. package/dist/tsup/{chunk-46DWBVYE.cjs.map → chunk-24MVWG2B.cjs.map} +1 -1
  16. package/dist/tsup/{chunk-DGSYEC34.js → chunk-2E6QYC2Y.js} +18 -18
  17. package/dist/tsup/chunk-2E6QYC2Y.js.map +1 -0
  18. package/dist/tsup/{chunk-23EJLAOV.cjs → chunk-3YT2J462.cjs} +8 -8
  19. package/dist/tsup/{chunk-23EJLAOV.cjs.map → chunk-3YT2J462.cjs.map} +1 -1
  20. package/dist/tsup/{chunk-GOC4GSPT.js → chunk-46RC64TZ.js} +119 -32
  21. package/dist/tsup/chunk-46RC64TZ.js.map +1 -0
  22. package/dist/tsup/{chunk-REMOXAIW.cjs → chunk-4WDHG57J.cjs} +21 -10
  23. package/dist/tsup/chunk-4WDHG57J.cjs.map +1 -0
  24. package/dist/tsup/{chunk-YBOQOQZB.js → chunk-5MD6ZIUI.js} +172 -115
  25. package/dist/tsup/chunk-5MD6ZIUI.js.map +1 -0
  26. package/dist/tsup/{chunk-ZUJRXXQC.cjs → chunk-GDXRMMOY.cjs} +372 -315
  27. package/dist/tsup/chunk-GDXRMMOY.cjs.map +1 -0
  28. package/dist/tsup/{chunk-MAQSR26X.cjs → chunk-I2OKFOBV.cjs} +16 -5
  29. package/dist/tsup/chunk-I2OKFOBV.cjs.map +1 -0
  30. package/dist/tsup/{chunk-3WG6PXWE.cjs → chunk-JFUFZNBY.cjs} +3 -3
  31. package/dist/tsup/{chunk-3WG6PXWE.cjs.map → chunk-JFUFZNBY.cjs.map} +1 -1
  32. package/dist/tsup/{chunk-DI7LJEYL.cjs → chunk-JXZMBI2W.cjs} +6 -6
  33. package/dist/tsup/{chunk-DI7LJEYL.cjs.map → chunk-JXZMBI2W.cjs.map} +1 -1
  34. package/dist/tsup/{chunk-VROCBPWT.cjs → chunk-KCP36F62.cjs} +10 -10
  35. package/dist/tsup/{chunk-VROCBPWT.cjs.map → chunk-KCP36F62.cjs.map} +1 -1
  36. package/dist/tsup/{chunk-Z33UBLLH.js → chunk-KSKIKS4Q.js} +9 -9
  37. package/dist/tsup/{chunk-Z33UBLLH.js.map → chunk-KSKIKS4Q.js.map} +1 -1
  38. package/dist/tsup/{chunk-NYQJHQHK.cjs → chunk-MJX33BZM.cjs} +283 -196
  39. package/dist/tsup/chunk-MJX33BZM.cjs.map +1 -0
  40. package/dist/tsup/{chunk-FVSTM7QK.cjs → chunk-MR3O2TFJ.cjs} +3 -3
  41. package/dist/tsup/{chunk-FVSTM7QK.cjs.map → chunk-MR3O2TFJ.cjs.map} +1 -1
  42. package/dist/tsup/{chunk-EOXUA7SX.js → chunk-OKZQC52X.js} +2 -2
  43. package/dist/tsup/{chunk-K2UD42XA.js → chunk-QCVN5ZWE.js} +2 -2
  44. package/dist/tsup/{chunk-DQH5K5TL.js → chunk-RQEUDCBR.js} +3 -3
  45. package/dist/tsup/{chunk-DQH5K5TL.js.map → chunk-RQEUDCBR.js.map} +1 -1
  46. package/dist/tsup/{chunk-HPIRVETT.js → chunk-RSSAT5PN.js} +3 -3
  47. package/dist/tsup/{chunk-HPIRVETT.js.map → chunk-RSSAT5PN.js.map} +1 -1
  48. package/dist/tsup/{chunk-F4CRQFYG.cjs → chunk-TEDQEGUV.cjs} +30 -30
  49. package/dist/tsup/chunk-TEDQEGUV.cjs.map +1 -0
  50. package/dist/tsup/{chunk-OI6FEIRD.js → chunk-TK7XXGVD.js} +15 -4
  51. package/dist/tsup/chunk-TK7XXGVD.js.map +1 -0
  52. package/dist/tsup/{chunk-SLAUR4QB.js → chunk-UMVOVPLU.js} +2 -2
  53. package/dist/tsup/{chunk-P2RZJPYI.js → chunk-V35I3JSW.js} +16 -5
  54. package/dist/tsup/chunk-V35I3JSW.js.map +1 -0
  55. package/dist/tsup/client/mod.cjs +11 -9
  56. package/dist/tsup/client/mod.cjs.map +1 -1
  57. package/dist/tsup/client/mod.d.cts +2 -2
  58. package/dist/tsup/client/mod.d.ts +2 -2
  59. package/dist/tsup/client/mod.js +10 -8
  60. package/dist/tsup/common/log.cjs +3 -3
  61. package/dist/tsup/common/log.js +2 -2
  62. package/dist/tsup/common/websocket.cjs +4 -4
  63. package/dist/tsup/common/websocket.js +3 -3
  64. package/dist/tsup/{config-Dj5nTCrh.d.cts → config-D6nMVDna.d.cts} +36 -1
  65. package/dist/tsup/{config-Cs3B9xN9.d.ts → config-DN0AurPi.d.ts} +36 -1
  66. package/dist/tsup/driver-helpers/mod.cjs +5 -5
  67. package/dist/tsup/driver-helpers/mod.d.cts +1 -1
  68. package/dist/tsup/driver-helpers/mod.d.ts +1 -1
  69. package/dist/tsup/driver-helpers/mod.js +4 -4
  70. package/dist/tsup/driver-test-suite/mod.cjs +172 -74
  71. package/dist/tsup/driver-test-suite/mod.cjs.map +1 -1
  72. package/dist/tsup/driver-test-suite/mod.d.cts +1 -1
  73. package/dist/tsup/driver-test-suite/mod.d.ts +1 -1
  74. package/dist/tsup/driver-test-suite/mod.js +113 -15
  75. package/dist/tsup/driver-test-suite/mod.js.map +1 -1
  76. package/dist/tsup/inspector/mod.cjs +6 -6
  77. package/dist/tsup/inspector/mod.d.cts +2 -2
  78. package/dist/tsup/inspector/mod.d.ts +2 -2
  79. package/dist/tsup/inspector/mod.js +5 -5
  80. package/dist/tsup/mod.cjs +10 -10
  81. package/dist/tsup/mod.cjs.map +1 -1
  82. package/dist/tsup/mod.d.cts +2 -2
  83. package/dist/tsup/mod.d.ts +2 -2
  84. package/dist/tsup/mod.js +9 -9
  85. package/dist/tsup/test/mod.cjs +12 -12
  86. package/dist/tsup/test/mod.d.cts +1 -1
  87. package/dist/tsup/test/mod.d.ts +1 -1
  88. package/dist/tsup/test/mod.js +11 -11
  89. package/dist/tsup/utils.cjs +5 -3
  90. package/dist/tsup/utils.cjs.map +1 -1
  91. package/dist/tsup/utils.d.cts +3 -1
  92. package/dist/tsup/utils.d.ts +3 -1
  93. package/dist/tsup/utils.js +4 -2
  94. package/package.json +3 -3
  95. package/src/actor/config.ts +3 -2
  96. package/src/actor/conn/drivers/websocket.ts +15 -0
  97. package/src/actor/errors.ts +14 -3
  98. package/src/actor/instance/event-manager.ts +7 -0
  99. package/src/actor/instance/state-manager.ts +1 -1
  100. package/src/actor/protocol/old.ts +1 -1
  101. package/src/actor/protocol/serde.ts +1 -1
  102. package/src/actor/router-endpoints.ts +13 -1
  103. package/src/actor/router-websocket-endpoints.ts +5 -0
  104. package/src/client/actor-conn.ts +196 -133
  105. package/src/client/config.ts +4 -1
  106. package/src/client/mod.ts +3 -1
  107. package/src/client/utils.ts +1 -1
  108. package/src/driver-test-suite/tests/action-features.ts +63 -0
  109. package/src/driver-test-suite/tests/actor-conn.ts +91 -0
  110. package/src/drivers/file-system/global-state.ts +4 -5
  111. package/src/drivers/file-system/manager.ts +12 -4
  112. package/src/engine-process/mod.ts +16 -10
  113. package/src/registry/run-config.ts +3 -0
  114. package/src/registry/serve.ts +96 -7
  115. package/src/schemas/actor-persist/versioned.ts +3 -4
  116. package/src/serde.ts +1 -1
  117. package/src/test/mod.ts +1 -1
  118. package/src/utils.ts +14 -0
  119. package/dist/tsup/chunk-DGSYEC34.js.map +0 -1
  120. package/dist/tsup/chunk-F4CRQFYG.cjs.map +0 -1
  121. package/dist/tsup/chunk-GOC4GSPT.js.map +0 -1
  122. package/dist/tsup/chunk-MAQSR26X.cjs.map +0 -1
  123. package/dist/tsup/chunk-NYQJHQHK.cjs.map +0 -1
  124. package/dist/tsup/chunk-OI6FEIRD.js.map +0 -1
  125. package/dist/tsup/chunk-P2RZJPYI.js.map +0 -1
  126. package/dist/tsup/chunk-REMOXAIW.cjs.map +0 -1
  127. package/dist/tsup/chunk-YBOQOQZB.js.map +0 -1
  128. package/dist/tsup/chunk-ZUJRXXQC.cjs.map +0 -1
  129. /package/dist/tsup/{chunk-EOXUA7SX.js.map → chunk-OKZQC52X.js.map} +0 -0
  130. /package/dist/tsup/{chunk-K2UD42XA.js.map → chunk-QCVN5ZWE.js.map} +0 -0
  131. /package/dist/tsup/{chunk-SLAUR4QB.js.map → chunk-UMVOVPLU.js.map} +0 -0
@@ -54,6 +54,16 @@ import {
54
54
  sendHttpRequest,
55
55
  } from "./utils";
56
56
 
57
+ /**
58
+ * Connection status for an actor connection.
59
+ *
60
+ * - `"idle"`: Not connected, no auto-reconnect (initial state, after dispose, or disabled)
61
+ * - `"connecting"`: Attempting to establish connection
62
+ * - `"connected"`: Connection is active
63
+ * - `"disconnected"`: Connection was lost, will auto-reconnect
64
+ */
65
+ export type ActorConnStatus = "idle" | "connecting" | "connected" | "disconnected";
66
+
57
67
  interface ActionInFlight {
58
68
  name: string;
59
69
  resolve: (response: { id: bigint; output: unknown }) => void;
@@ -86,6 +96,13 @@ export type ActorErrorCallback = (error: errors.ActorError) => void;
86
96
  */
87
97
  export type ConnectionStateCallback = () => void;
88
98
 
99
+ /**
100
+ * A callback for connection status changes.
101
+ *
102
+ * @typedef {Function} StatusChangeCallback
103
+ */
104
+ export type StatusChangeCallback = (status: ActorConnStatus) => void;
105
+
89
106
  export interface SendHttpMessageOpts {
90
107
  ephemeral: boolean;
91
108
  signal?: AbortSignal;
@@ -104,10 +121,10 @@ export class ActorConnRaw {
104
121
  /* Will be aborted on dispose. */
105
122
  #abortController = new AbortController();
106
123
 
107
- #connecting = false;
124
+ #connStatus: ActorConnStatus = "idle";
108
125
 
109
126
  #actorId?: string;
110
- #connectionId?: string;
127
+ #connId?: string;
111
128
 
112
129
  #messageQueue: Array<{
113
130
  body:
@@ -128,8 +145,7 @@ export class ActorConnRaw {
128
145
  #errorHandlers = new Set<ActorErrorCallback>();
129
146
  #openHandlers = new Set<ConnectionStateCallback>();
130
147
  #closeHandlers = new Set<ConnectionStateCallback>();
131
-
132
- #isConnected = false;
148
+ #statusChangeHandlers = new Set<StatusChangeCallback>();
133
149
 
134
150
  #actionIdCounter = 0;
135
151
 
@@ -249,39 +265,86 @@ enc
249
265
  this.#connectWithRetry();
250
266
  }
251
267
 
252
- async #connectWithRetry() {
253
- this.#connecting = true;
268
+ #setConnStatus(status: ActorConnStatus) {
269
+ const prevStatus = this.#connStatus;
270
+ if (prevStatus === status) return;
271
+ this.#connStatus = status;
254
272
 
255
- // Attempt to reconnect indefinitely
256
- try {
257
- await pRetry(this.#connectAndWait.bind(this), {
258
- forever: true,
259
- minTimeout: 250,
260
- maxTimeout: 30_000,
273
+ // Notify status change handlers
274
+ for (const handler of [...this.#statusChangeHandlers]) {
275
+ try {
276
+ handler(status);
277
+ } catch (err) {
278
+ logger().error({
279
+ msg: "error in status change handler",
280
+ error: stringifyError(err),
281
+ });
282
+ }
283
+ }
261
284
 
262
- onFailedAttempt: (error) => {
263
- logger().warn({
264
- msg: "failed to reconnect",
265
- attempt: error.attemptNumber,
266
- error: stringifyError(error),
285
+ // Notify open handlers
286
+ if (status === "connected") {
287
+ for (const handler of [...this.#openHandlers]) {
288
+ try {
289
+ handler();
290
+ } catch (err) {
291
+ logger().error({
292
+ msg: "error in open handler",
293
+ error: stringifyError(err),
267
294
  });
268
- },
295
+ }
296
+ }
297
+ }
269
298
 
270
- // Cancel retry if aborted
271
- signal: this.#abortController.signal,
272
- });
273
- } catch (err) {
299
+ // Notify close handlers (only if transitioning from Connected to Disconnected or Idle)
300
+ if (
301
+ (status === "disconnected" ||
302
+ status === "idle") &&
303
+ prevStatus === "connected"
304
+ ) {
305
+ for (const handler of [...this.#closeHandlers]) {
306
+ try {
307
+ handler();
308
+ } catch (err) {
309
+ logger().error({
310
+ msg: "error in close handler",
311
+ error: stringifyError(err),
312
+ });
313
+ }
314
+ }
315
+ }
316
+ }
317
+
318
+ #connectWithRetry() {
319
+ this.#setConnStatus("connecting");
320
+
321
+ // Attempt to reconnect indefinitely
322
+ // This is intentionally not awaited - connection happens in background
323
+ pRetry(this.#connectAndWait.bind(this), {
324
+ forever: true,
325
+ minTimeout: 250,
326
+ maxTimeout: 30_000,
327
+
328
+ onFailedAttempt: (error) => {
329
+ logger().warn({
330
+ msg: "failed to reconnect",
331
+ attempt: error.attemptNumber,
332
+ error: stringifyError(error),
333
+ });
334
+ },
335
+
336
+ // Cancel retry if aborted
337
+ signal: this.#abortController.signal,
338
+ }).catch((err) => {
274
339
  if ((err as Error).name === "AbortError") {
275
- // Ignore abortions
276
340
  logger().info({ msg: "connection retry aborted" });
277
- return;
278
341
  } else {
279
- // Unknown error
280
- throw err;
342
+ logger().error({
343
+ msg: "unexpected error in connection retry",
344
+ error: stringifyError(err),
345
+ });
281
346
  }
282
- }
283
-
284
- this.#connecting = false;
347
+ });
285
348
  }
286
349
 
287
350
  async #connectAndWait() {
@@ -315,7 +378,7 @@ enc
315
378
  );
316
379
  logger().debug({
317
380
  msg: "opened websocket",
318
- connectionId: this.#connectionId,
381
+ connId: this.#connId,
319
382
  readyState: ws.readyState,
320
383
  messageQueueLength: this.#messageQueue.length,
321
384
  });
@@ -323,7 +386,7 @@ enc
323
386
  ws.addEventListener("open", () => {
324
387
  logger().debug({
325
388
  msg: "client websocket open",
326
- connectionId: this.#connectionId,
389
+ connId: this.#connId,
327
390
  });
328
391
  });
329
392
  ws.addEventListener("message", async (ev) => {
@@ -360,14 +423,24 @@ enc
360
423
 
361
424
  /** Called by the onopen event from drivers. */
362
425
  #handleOnOpen() {
426
+ // Connection was disposed before Init message arrived - close the websocket to avoid leak
427
+ if (this.#disposed) {
428
+ logger().debug({ msg: "handleOnOpen called after dispose, closing websocket" });
429
+ if (this.#websocket) {
430
+ this.#websocket.close(1000, "Disposed");
431
+ this.#websocket = undefined;
432
+ }
433
+ return;
434
+ }
435
+
363
436
  logger().debug({
364
437
  msg: "socket open",
365
438
  messageQueueLength: this.#messageQueue.length,
366
- connectionId: this.#connectionId,
439
+ connId: this.#connId,
367
440
  });
368
441
 
369
- // Update connection state
370
- this.#isConnected = true;
442
+ // Update connection state (this also notifies handlers)
443
+ this.#setConnStatus("connected");
371
444
 
372
445
  // Resolve open promise
373
446
  if (this.#onOpenPromise) {
@@ -376,18 +449,6 @@ enc
376
449
  logger().warn({ msg: "#onOpenPromise is undefined" });
377
450
  }
378
451
 
379
- // Notify open handlers
380
- for (const handler of [...this.#openHandlers]) {
381
- try {
382
- handler();
383
- } catch (err) {
384
- logger().error({
385
- msg: "error in open handler",
386
- error: stringifyError(err),
387
- });
388
- }
389
- }
390
-
391
452
  // Resubscribe to all active events
392
453
  for (const eventName of this.#eventSubscriptions.keys()) {
393
454
  this.#sendSubscription(eventName, true);
@@ -431,11 +492,11 @@ enc
431
492
  if (response.body.tag === "Init") {
432
493
  // Store connection info
433
494
  this.#actorId = response.body.val.actorId;
434
- this.#connectionId = response.body.val.connectionId;
495
+ this.#connId = response.body.val.connectionId;
435
496
  logger().trace({
436
497
  msg: "received init message",
437
498
  actorId: this.#actorId,
438
- connectionId: this.#connectionId,
499
+ connId: this.#connId,
439
500
  });
440
501
  this.#handleOnOpen();
441
502
  } else if (response.body.tag === "Error") {
@@ -520,86 +581,54 @@ enc
520
581
 
521
582
  /** Called by the onclose event from drivers. */
522
583
  #handleOnClose(event: Event | CloseEvent) {
523
- // TODO: Handle queue
524
- // TODO: Reconnect with backoff
525
-
526
584
  // We can't use `event instanceof CloseEvent` because it's not defined in NodeJS
527
- //
528
- // These properties will be undefined
529
585
  const closeEvent = event as CloseEvent;
530
586
  const wasClean = closeEvent.wasClean;
531
-
532
- // Update connection state and notify handlers
533
- const wasConnected = this.#isConnected;
534
- this.#isConnected = false;
535
-
536
- if (wasConnected) {
537
- for (const handler of [...this.#closeHandlers]) {
538
- try {
539
- handler();
540
- } catch (err) {
541
- logger().error({
542
- msg: "error in close handler",
543
- error: stringifyError(err),
544
- });
545
- }
546
- }
547
- }
548
-
549
- // Reject open promise
550
- if (this.#onOpenPromise) {
551
- this.#onOpenPromise.reject(
552
- new Error(
553
- `websocket closed with code ${closeEvent.code}: ${closeEvent.reason}`,
554
- ),
555
- );
556
- }
587
+ const wasConnected = this.#connStatus === "connected";
557
588
 
558
589
  logger().info({
559
590
  msg: "socket closed",
560
591
  code: closeEvent.code,
561
592
  reason: closeEvent.reason,
562
- wasClean: wasClean,
563
- connectionId: this.#connectionId,
564
- messageQueueLength: this.#messageQueue.length,
565
- actionsInFlight: this.#actionsInFlight.size,
593
+ wasClean,
594
+ disposed: this.#disposed,
595
+ connId: this.#connId,
566
596
  });
567
597
 
568
- // Reject all in-flight actions
569
- if (this.#actionsInFlight.size > 0) {
570
- logger().debug({
571
- msg: "rejecting in-flight actions after disconnect",
572
- count: this.#actionsInFlight.size,
573
- connectionId: this.#connectionId,
574
- wasClean,
575
- });
598
+ this.#websocket = undefined;
576
599
 
577
- const disconnectError = new Error(
578
- `${wasClean ? "Connection closed" : "Connection lost"} (code: ${closeEvent.code}, reason: ${closeEvent.reason})`,
600
+ if (this.#disposed) {
601
+ // Use ActorConnDisposed error and prevent unhandled rejection
602
+ this.#rejectPendingPromises(new errors.ActorConnDisposed(), true);
603
+ } else {
604
+ this.#setConnStatus("disconnected");
605
+ this.#rejectPendingPromises(
606
+ new Error(
607
+ `${wasClean ? "Connection closed" : "Connection lost"} (code: ${closeEvent.code}, reason: ${closeEvent.reason})`,
608
+ ),
609
+ false,
579
610
  );
580
611
 
581
- for (const actionInfo of this.#actionsInFlight.values()) {
582
- actionInfo.reject(disconnectError);
612
+ // Automatically reconnect if we were connected
613
+ if (wasConnected) {
614
+ logger().debug({ msg: "triggering reconnect", connId: this.#connId });
615
+ this.#connectWithRetry();
583
616
  }
584
- this.#actionsInFlight.clear();
585
617
  }
618
+ }
586
619
 
587
- this.#websocket = undefined;
588
-
589
- // Automatically reconnect. Skip if already attempting to connect.
590
- if (!this.#disposed && !this.#connecting) {
591
- logger().debug({
592
- msg: "triggering reconnect",
593
- connectionId: this.#connectionId,
594
- messageQueueLength: this.#messageQueue.length,
595
- });
596
- // TODO: Fetch actor to check if it's destroyed
597
- // TODO: Add backoff for reconnect
598
- // TODO: Add a way of preserving connection ID for connection state
620
+ #rejectPendingPromises(error: Error, suppressUnhandled: boolean) {
621
+ if (this.#onOpenPromise) {
622
+ if (suppressUnhandled) {
623
+ this.#onOpenPromise.promise.catch(() => {});
624
+ }
625
+ this.#onOpenPromise.reject(error);
626
+ }
599
627
 
600
- // Attempt to connect again
601
- this.#connectWithRetry();
628
+ for (const actionInfo of this.#actionsInFlight.values()) {
629
+ actionInfo.reject(error);
602
630
  }
631
+ this.#actionsInFlight.clear();
603
632
  }
604
633
 
605
634
  /** Called by the onerror event from drivers. */
@@ -751,13 +780,23 @@ enc
751
780
  };
752
781
  }
753
782
 
783
+ /**
784
+ * Returns the current connection status.
785
+ *
786
+ * @returns {ActorConnStatus} - The current connection status.
787
+ */
788
+ get connStatus(): ActorConnStatus {
789
+ return this.#connStatus;
790
+ }
791
+
754
792
  /**
755
793
  * Returns whether the connection is currently open.
756
794
  *
795
+ * @deprecated Use `connStatus` instead.
757
796
  * @returns {boolean} - True if the connection is open, false otherwise.
758
797
  */
759
798
  get isConnected(): boolean {
760
- return this.#isConnected;
799
+ return this.#connStatus === "connected";
761
800
  }
762
801
 
763
802
  /**
@@ -795,6 +834,23 @@ enc
795
834
  };
796
835
  }
797
836
 
837
+ /**
838
+ * Subscribes to connection status changes.
839
+ *
840
+ * This is called whenever the connection status changes between Disconnected, Connecting, and Connected.
841
+ *
842
+ * @param {StatusChangeCallback} callback - The callback function to execute when the status changes.
843
+ * @returns {() => void} - A function to unsubscribe from the status change handler.
844
+ */
845
+ onStatusChange(callback: StatusChangeCallback): () => void {
846
+ this.#statusChangeHandlers.add(callback);
847
+
848
+ // Return unsubscribe function
849
+ return () => {
850
+ this.#statusChangeHandlers.delete(callback);
851
+ };
852
+ }
853
+
798
854
  #sendMessage(
799
855
  message: {
800
856
  body:
@@ -810,7 +866,11 @@ enc
810
866
  opts?: SendHttpMessageOpts,
811
867
  ) {
812
868
  if (this.#disposed) {
813
- throw new errors.ActorConnDisposed();
869
+ if (opts?.ephemeral) {
870
+ return;
871
+ } else {
872
+ throw new errors.ActorConnDisposed();
873
+ }
814
874
  }
815
875
 
816
876
  let queueMessage = false;
@@ -827,7 +887,7 @@ enc
827
887
  : readyState === 2
828
888
  ? "CLOSING"
829
889
  : "CLOSED",
830
- connectionId: this.#connectionId,
890
+ connId: this.#connId,
831
891
  messageType: (message.body as any).tag,
832
892
  actionName: (message.body as any).val?.name,
833
893
  });
@@ -837,7 +897,7 @@ enc
837
897
  this.#encoding,
838
898
  message,
839
899
  TO_SERVER_VERSIONED,
840
- CLIENT_PROTOCOL_CURRENT_VERSION,
900
+ CLIENT_PROTOCOL_CURRENT_VERSION,
841
901
  ToServerSchema,
842
902
  // JSON: args is the raw value
843
903
  (msg): ToServerJson => msg as ToServerJson,
@@ -870,7 +930,7 @@ enc
870
930
  logger().warn({
871
931
  msg: "failed to send message, added to queue",
872
932
  error,
873
- connectionId: this.#connectionId,
933
+ connId: this.#connId,
874
934
  });
875
935
 
876
936
  // Assuming the socket is disconnected and will be reconnected soon
@@ -894,7 +954,7 @@ enc
894
954
  logger().debug({
895
955
  msg: "queued connection message",
896
956
  queueLength: this.#messageQueue.length,
897
- connectionId: this.#connectionId,
957
+ connId: this.#connId,
898
958
  messageType: (message.body as any).tag,
899
959
  actionName: (message.body as any).val?.name,
900
960
  });
@@ -993,8 +1053,17 @@ enc
993
1053
  * Get the connection ID (for testing purposes).
994
1054
  * @internal
995
1055
  */
1056
+ get connId(): string | undefined {
1057
+ return this.#connId;
1058
+ }
1059
+
1060
+ /**
1061
+ * Get the connection ID (for testing purposes).
1062
+ * @internal
1063
+ * @deprecated Use `connId` instead.
1064
+ */
996
1065
  get connectionId(): string | undefined {
997
- return this.#connectionId;
1066
+ return this.#connId;
998
1067
  }
999
1068
 
1000
1069
  /**
@@ -1013,35 +1082,29 @@ enc
1013
1082
 
1014
1083
  logger().debug({ msg: "disposing actor conn" });
1015
1084
 
1085
+ // Set status to Idle (intentionally closed, no auto-reconnect)
1086
+ this.#setConnStatus("idle");
1087
+
1016
1088
  // Clear interval so NodeJS process can exit
1017
1089
  clearInterval(this.#keepNodeAliveInterval);
1018
1090
 
1019
- // Abort
1091
+ // Abort retry loop
1020
1092
  this.#abortController.abort();
1021
1093
 
1022
1094
  // Remove from registry
1023
1095
  this.#client[ACTOR_CONNS_SYMBOL].delete(this);
1024
1096
 
1025
- // Disconnect websocket cleanly
1097
+ // Close websocket (#handleOnClose will reject pending promises)
1026
1098
  if (this.#websocket) {
1027
- logger().debug("closing ws");
1028
-
1029
1099
  const ws = this.#websocket;
1030
- // Check if WebSocket is already closed or closing
1031
- if (
1032
- ws.readyState === 2 /* CLOSING */ ||
1033
- ws.readyState === 3 /* CLOSED */
1034
- ) {
1035
- logger().debug({ msg: "ws already closed or closing" });
1036
- } else {
1100
+ if (ws.readyState !== 2 /* CLOSING */ && ws.readyState !== 3 /* CLOSED */) {
1037
1101
  const { promise, resolve } = promiseWithResolvers();
1038
- ws.addEventListener("close", () => {
1039
- logger().debug({ msg: "ws closed" });
1040
- resolve(undefined);
1041
- });
1042
- ws.close(1000, "Normal closure");
1102
+ ws.addEventListener("close", () => resolve(undefined));
1103
+ ws.close(1000, "Disposed");
1043
1104
  await promise;
1044
1105
  }
1106
+ } else {
1107
+ this.#rejectPendingPromises(new errors.ActorConnDisposed(), true);
1045
1108
  }
1046
1109
  this.#websocket = undefined;
1047
1110
  }
@@ -32,7 +32,10 @@ export const ClientConfigSchema = z.object({
32
32
 
33
33
  encoding: EncodingSchema.default("bare"),
34
34
 
35
- headers: z.record(z.string(), z.string()).optional().default(() => ({})),
35
+ headers: z
36
+ .record(z.string(), z.string())
37
+ .optional()
38
+ .default(() => ({})),
36
39
 
37
40
  // See RunConfig.getUpgradeWebSocket
38
41
  getUpgradeWebSocket: z.custom<GetUpgradeWebSocket>().optional(),
package/src/client/mod.ts CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  export type { Encoding } from "@/actor/protocol/serde";
15
15
  export {
16
16
  ActorClientError,
17
+ ActorConnDisposed,
17
18
  ActorError,
18
19
  InternalError,
19
20
  MalformedResponseMessage,
@@ -25,8 +26,9 @@ export type {
25
26
  ActorConn,
26
27
  ConnectionStateCallback,
27
28
  EventUnsubscribe,
29
+ StatusChangeCallback,
28
30
  } from "./actor-conn";
29
- export { ActorConnRaw } from "./actor-conn";
31
+ export { ActorConnRaw, ActorConnStatus } from "./actor-conn";
30
32
  export type { ActorHandle } from "./actor-handle";
31
33
  export { ActorHandleRaw } from "./actor-handle";
32
34
  export type {
@@ -1,9 +1,9 @@
1
1
  import * as cbor from "cbor-x";
2
2
  import invariant from "invariant";
3
+ import type { VersionedDataHandler } from "vbare";
3
4
  import type { z } from "zod";
4
5
  import type { Encoding } from "@/actor/protocol/serde";
5
6
  import { assertUnreachable } from "@/common/utils";
6
- import type { VersionedDataHandler } from "vbare";
7
7
  import type { HttpResponseError } from "@/schemas/client-protocol/mod";
8
8
  import { HTTP_RESPONSE_ERROR_VERSIONED } from "@/schemas/client-protocol/versioned";
9
9
  import {
@@ -136,5 +136,68 @@ export function runActionFeaturesTests(driverTestConfig: DriverTestConfig) {
136
136
  expect(results).toContain("delayed");
137
137
  });
138
138
  });
139
+
140
+ describe("Large Payloads", () => {
141
+ test("should handle large request within size limit", async (c) => {
142
+ const { client } = await setupDriverTest(c, driverTestConfig);
143
+
144
+ const instance = client.largePayloadActor.getOrCreate();
145
+
146
+ // Create a large payload that's under the default 64KB limit
147
+ // Each item is roughly 60 bytes, so 800 items ≈ 48KB
148
+ const items: string[] = [];
149
+ for (let i = 0; i < 800; i++) {
150
+ items.push(`Item ${i} with some additional text to increase size`);
151
+ }
152
+
153
+ const result = await instance.processLargeRequest({ items });
154
+
155
+ expect(result.itemCount).toBe(800);
156
+ expect(result.firstItem).toBe("Item 0 with some additional text to increase size");
157
+ expect(result.lastItem).toBe("Item 799 with some additional text to increase size");
158
+ });
159
+
160
+ test("should reject request exceeding maxIncomingMessageSize", async (c) => {
161
+ const { client } = await setupDriverTest(c, driverTestConfig);
162
+
163
+ const instance = client.largePayloadActor.getOrCreate();
164
+
165
+ // Create a payload that exceeds the default 64KB limit
166
+ // Each item is roughly 60 bytes, so 1500 items ≈ 90KB
167
+ const items: string[] = [];
168
+ for (let i = 0; i < 1500; i++) {
169
+ items.push(`Item ${i} with some additional text to increase size`);
170
+ }
171
+
172
+ await expect(
173
+ instance.processLargeRequest({ items })
174
+ ).rejects.toThrow();
175
+ });
176
+
177
+ test("should handle large response", async (c) => {
178
+ const { client } = await setupDriverTest(c, driverTestConfig);
179
+
180
+ const instance = client.largePayloadActor.getOrCreate();
181
+
182
+ // Request a large response (800 items ≈ 48KB)
183
+ const result = await instance.getLargeResponse(800);
184
+
185
+ expect(result.items).toHaveLength(800);
186
+ expect(result.items[0]).toBe("Item 0 with some additional text to increase size");
187
+ expect(result.items[799]).toBe("Item 799 with some additional text to increase size");
188
+ });
189
+
190
+ test("should reject response exceeding maxOutgoingMessageSize", async (c) => {
191
+ const { client } = await setupDriverTest(c, driverTestConfig);
192
+
193
+ const instance = client.largePayloadActor.getOrCreate();
194
+
195
+ // Request a response that exceeds the default 1MB limit
196
+ // Each item is roughly 60 bytes, so 20000 items ≈ 1.2MB
197
+ await expect(
198
+ instance.getLargeResponse(20000)
199
+ ).rejects.toThrow();
200
+ });
201
+ });
139
202
  });
140
203
  }