@evolu/common 8.10.0 → 8.11.0

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 (144) hide show
  1. package/dist/src/Config.d.ts +22 -22
  2. package/dist/src/Config.d.ts.map +1 -1
  3. package/dist/src/Console.d.ts +62 -7
  4. package/dist/src/Console.d.ts.map +1 -1
  5. package/dist/src/Console.js +20 -4
  6. package/dist/src/Crypto.d.ts +76 -4
  7. package/dist/src/Crypto.d.ts.map +1 -1
  8. package/dist/src/Crypto.js +55 -4
  9. package/dist/src/Error.d.ts +45 -0
  10. package/dist/src/Error.d.ts.map +1 -1
  11. package/dist/src/Error.js +69 -0
  12. package/dist/src/Fs.d.ts +92 -18
  13. package/dist/src/Fs.d.ts.map +1 -1
  14. package/dist/src/Fs.js +2 -0
  15. package/dist/src/Identicon.d.ts +2 -2
  16. package/dist/src/Identicon.js +2 -2
  17. package/dist/src/LeakDetector.d.ts +22 -3
  18. package/dist/src/LeakDetector.d.ts.map +1 -1
  19. package/dist/src/LeakDetector.js +12 -2
  20. package/dist/src/LockManager.d.ts +8 -0
  21. package/dist/src/LockManager.d.ts.map +1 -1
  22. package/dist/src/LockManager.js +6 -0
  23. package/dist/src/Object.d.ts.map +1 -1
  24. package/dist/src/Object.js +5 -0
  25. package/dist/src/Platform.d.ts +47 -7
  26. package/dist/src/Platform.d.ts.map +1 -1
  27. package/dist/src/Platform.js +24 -5
  28. package/dist/src/Random.d.ts +25 -2
  29. package/dist/src/Random.d.ts.map +1 -1
  30. package/dist/src/Random.js +14 -2
  31. package/dist/src/Resource.d.ts +156 -1
  32. package/dist/src/Resource.d.ts.map +1 -1
  33. package/dist/src/Resource.js +201 -72
  34. package/dist/src/Schedule.d.ts +11 -10
  35. package/dist/src/Schedule.d.ts.map +1 -1
  36. package/dist/src/Schedule.js +1 -1
  37. package/dist/src/Sqlite.d.ts +132 -16
  38. package/dist/src/Sqlite.d.ts.map +1 -1
  39. package/dist/src/Sqlite.js +63 -9
  40. package/dist/src/Task.d.ts +15 -4
  41. package/dist/src/Task.d.ts.map +1 -1
  42. package/dist/src/Task.js +41 -15
  43. package/dist/src/Test.d.ts +9 -0
  44. package/dist/src/Test.d.ts.map +1 -1
  45. package/dist/src/Test.js +4 -0
  46. package/dist/src/Time.d.ts +106 -9
  47. package/dist/src/Time.d.ts.map +1 -1
  48. package/dist/src/Time.js +55 -4
  49. package/dist/src/Type.d.ts +1455 -1310
  50. package/dist/src/Type.d.ts.map +1 -1
  51. package/dist/src/Type.js +1274 -517
  52. package/dist/src/WebSocket.d.ts +164 -13
  53. package/dist/src/WebSocket.d.ts.map +1 -1
  54. package/dist/src/WebSocket.js +133 -24
  55. package/dist/src/Worker.d.ts +90 -8
  56. package/dist/src/Worker.d.ts.map +1 -1
  57. package/dist/src/Worker.js +28 -2
  58. package/dist/src/index.d.ts +6 -7
  59. package/dist/src/index.d.ts.map +1 -1
  60. package/dist/src/index.js +2 -3
  61. package/dist/src/local-first/Db.d.ts +52 -3
  62. package/dist/src/local-first/Db.d.ts.map +1 -1
  63. package/dist/src/local-first/Db.js +412 -137
  64. package/dist/src/local-first/Evolu.d.ts +336 -211
  65. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  66. package/dist/src/local-first/Evolu.js +102 -15
  67. package/dist/src/local-first/Owner.d.ts +13 -30
  68. package/dist/src/local-first/Owner.d.ts.map +1 -1
  69. package/dist/src/local-first/Owner.js +13 -30
  70. package/dist/src/local-first/Protocol.d.ts +94 -16
  71. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  72. package/dist/src/local-first/Protocol.js +118 -38
  73. package/dist/src/local-first/Query.d.ts +8 -15
  74. package/dist/src/local-first/Query.d.ts.map +1 -1
  75. package/dist/src/local-first/Schema.d.ts +335 -21
  76. package/dist/src/local-first/Schema.d.ts.map +1 -1
  77. package/dist/src/local-first/Schema.js +214 -17
  78. package/dist/src/local-first/Shared.d.ts +537 -22
  79. package/dist/src/local-first/Shared.d.ts.map +1 -1
  80. package/dist/src/local-first/Shared.js +1437 -234
  81. package/dist/src/local-first/Storage.d.ts +192 -14
  82. package/dist/src/local-first/Storage.d.ts.map +1 -1
  83. package/dist/src/local-first/Storage.js +81 -20
  84. package/dist/src/local-first/Timestamp.d.ts +392 -41
  85. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  86. package/dist/src/local-first/Timestamp.js +403 -81
  87. package/dist/src/local-first/index.d.ts +0 -1
  88. package/dist/src/local-first/index.d.ts.map +1 -1
  89. package/dist/src/local-first/index.js +0 -1
  90. package/package.json +1 -1
  91. package/src/Assert.test.ts +2 -5
  92. package/src/Config.test.ts +2 -6
  93. package/src/Config.ts +133 -133
  94. package/src/Console.ts +62 -7
  95. package/src/Crypto.ts +76 -4
  96. package/src/Eq.test.ts +2 -3
  97. package/src/Error.test.ts +76 -3
  98. package/src/Error.ts +71 -0
  99. package/src/Fs.ts +92 -18
  100. package/src/Identicon.ts +2 -2
  101. package/src/LeakDetector.ts +22 -3
  102. package/src/LockManager.ts +8 -0
  103. package/src/Object.test.ts +27 -12
  104. package/src/Object.ts +5 -0
  105. package/src/Platform.ts +50 -8
  106. package/src/Random.ts +25 -2
  107. package/src/Resource.test.ts +837 -0
  108. package/src/Resource.ts +235 -15
  109. package/src/Schedule.test.ts +50 -12
  110. package/src/Schedule.ts +24 -14
  111. package/src/Sqlite.ts +137 -17
  112. package/src/Task.test.ts +189 -8
  113. package/src/Task.ts +56 -17
  114. package/src/Test.ts +9 -0
  115. package/src/Time.ts +106 -9
  116. package/src/Type.test.ts +946 -1028
  117. package/src/Type.ts +4195 -3136
  118. package/src/Types.test.ts +4 -14
  119. package/src/WebSocket.ts +313 -40
  120. package/src/Worker.ts +90 -8
  121. package/src/index.ts +15 -6
  122. package/src/local-first/Db.ts +644 -339
  123. package/src/local-first/Evolu.test.ts +686 -21
  124. package/src/local-first/Evolu.ts +450 -228
  125. package/src/local-first/Owner.ts +13 -30
  126. package/src/local-first/Protocol.test.ts +617 -10
  127. package/src/local-first/Protocol.ts +196 -72
  128. package/src/local-first/Query.ts +8 -15
  129. package/src/local-first/Schema.test.ts +143 -0
  130. package/src/local-first/Schema.ts +363 -24
  131. package/src/local-first/Shared.test.ts +7731 -559
  132. package/src/local-first/Shared.ts +2036 -267
  133. package/src/local-first/Storage.ts +218 -32
  134. package/src/local-first/Timestamp.test.ts +344 -70
  135. package/src/local-first/Timestamp.ts +434 -118
  136. package/src/local-first/index.ts +0 -1
  137. package/dist/src/local-first/Error.d.ts +0 -12
  138. package/dist/src/local-first/Error.d.ts.map +0 -1
  139. package/dist/src/local-first/Error.js +0 -6
  140. package/dist/src/local-first/LocalAuth.d.ts +0 -150
  141. package/dist/src/local-first/LocalAuth.d.ts.map +0 -1
  142. package/dist/src/local-first/LocalAuth.js +0 -179
  143. package/src/local-first/Error.ts +0 -17
  144. package/src/local-first/LocalAuth.ts +0 -457
package/src/Types.test.ts CHANGED
@@ -97,16 +97,10 @@ test("Instance", () => {
97
97
  const value: unknown = foo;
98
98
  if (isFoo(value)) assertType<typeof value, Foo>();
99
99
 
100
- const compileTimeAssertions = () => {
100
+ void (() => {
101
101
  // @ts-expect-error The runtime name must match the interface name.
102
102
  isInstance<Foo>("Bar");
103
- };
104
- assertType<
105
- typeof compileTimeAssertions extends (...args: Array<never>) => unknown
106
- ? true
107
- : false,
108
- true
109
- >();
103
+ });
110
104
  });
111
105
 
112
106
  test("isInstance checks its own marker", () => {
@@ -205,17 +199,13 @@ test("isPromiseLike", () => {
205
199
  assertFalse(isPromiseLike(undefined));
206
200
  assertFalse(isPromiseLike("value"));
207
201
 
208
- const narrow = (value: Awaitable<string>) => {
202
+ void ((value: Awaitable<string>) => {
209
203
  if (isPromiseLike(value)) {
210
204
  assertType<typeof value, PromiseLike<string>>();
211
205
  } else {
212
206
  assertType<typeof value, string>();
213
207
  }
214
- };
215
- assertType<
216
- typeof narrow extends (...args: Array<never>) => unknown ? true : false,
217
- true
218
- >();
208
+ });
219
209
  });
220
210
 
221
211
  test("CompileTimeError", () => {
package/src/WebSocket.ts CHANGED
@@ -12,7 +12,8 @@ import type { Schedule } from "./Schedule.ts";
12
12
  import { exponential, jitter, maxDelay } from "./Schedule.ts";
13
13
  import type { RetryError, Task } from "./Task.ts";
14
14
  import { callback, retry } from "./Task.ts";
15
- import type { Millis } from "./Time.ts";
15
+ import type { Duration, Millis, PerformanceTime } from "./Time.ts";
16
+ import { durationToMillis, performanceDurationBetween } from "./Time.ts";
16
17
  import { ArrayBuffer, String, Uint8Array, type Typed } from "./Type.ts";
17
18
 
18
19
  /**
@@ -91,6 +92,8 @@ import { ArrayBuffer, String, Uint8Array, type Typed } from "./Type.ts";
91
92
  * { url: "wss://example.com", data: "Hello" },
92
93
  * );
93
94
  * ```
95
+ *
96
+ * @group Core
94
97
  */
95
98
  export interface WebSocket extends AsyncDisposable {
96
99
  /**
@@ -105,6 +108,27 @@ export interface WebSocket extends AsyncDisposable {
105
108
 
106
109
  /** Returns true if the WebSocket is open and ready to send data. */
107
110
  readonly isOpen: () => boolean;
111
+
112
+ /**
113
+ * Abandons the current connection and connects again.
114
+ *
115
+ * Use it when the connection is dead although it never closed, for example
116
+ * when a request stays unanswered. The wrapper cannot detect that by itself:
117
+ * no close or error event arrives, so its own reconnect never runs.
118
+ *
119
+ * The connection is dropped without waiting for a close handshake, and
120
+ * neither {@link WebSocketOptions.onClose} nor
121
+ * {@link WebSocketOptions.shouldRetryOnClose} is consulted, because the close
122
+ * is this call rather than something to learn about or veto. A connection
123
+ * that was open restarts the {@link WebSocketOptions.schedule}, as a close
124
+ * after {@link WebSocketOptions.healthyConnectionDuration} does; one that was
125
+ * still connecting keeps its backoff, having proved nothing.
126
+ *
127
+ * A connection already closing or closed is left to settle on its own, and
128
+ * after disposal this does nothing, so a timer or handler that outlives the
129
+ * connection is safe to call it from.
130
+ */
131
+ readonly reconnect: () => void;
108
132
  }
109
133
 
110
134
  /**
@@ -112,23 +136,65 @@ export interface WebSocket extends AsyncDisposable {
112
136
  * or is in the CONNECTING state.
113
137
  *
114
138
  * https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send
139
+ *
140
+ * @group Errors
115
141
  */
116
142
  export interface WebSocketSendError extends Typed<"WebSocketSendError"> {}
117
143
 
118
- /** WebSocket connection states. */
144
+ /**
145
+ * WebSocket connection states.
146
+ *
147
+ * @group Core
148
+ */
119
149
  export type WebSocketReadyState = "connecting" | "open" | "closing" | "closed";
120
150
 
121
- /** {@link Task} that creates a {@link WebSocket}. */
151
+ /**
152
+ * What a {@link WebSocket} reports when a connection closes.
153
+ *
154
+ * These are the fields every platform provides. Evolu does not use the DOM
155
+ * `CloseEvent` type: React Native delivers its own close event and exposes no
156
+ * `CloseEvent` global, so promising the DOM type would promise an inheritance
157
+ * chain and an `instanceof` that do not hold there. A platform's own close
158
+ * event is structurally assignable to this, and {@link createWebSocket} passes
159
+ * it through unchanged, so a caller that knows its platform can narrow it.
160
+ *
161
+ * @group Core
162
+ */
163
+ export interface WebSocketCloseEvent {
164
+ /** https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code */
165
+ readonly code: number;
166
+
167
+ /** https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/reason */
168
+ readonly reason: string;
169
+
170
+ /** Whether the connection closed after a completed closing handshake. */
171
+ readonly wasClean: boolean;
172
+ }
173
+
174
+ /**
175
+ * {@link Task} that creates a {@link WebSocket}.
176
+ *
177
+ * @group Core
178
+ */
122
179
  export type CreateWebSocket = (
123
180
  url: string,
124
181
  options?: WebSocketOptions,
125
182
  ) => Task<WebSocket>;
126
183
 
184
+ /**
185
+ * Dependency wrapper for {@link CreateWebSocket}.
186
+ *
187
+ * @group Core
188
+ */
127
189
  export interface CreateWebSocketDep {
128
190
  readonly createWebSocket: CreateWebSocket;
129
191
  }
130
192
 
131
- /** Options for creating {@link WebSocket}. */
193
+ /**
194
+ * Options for creating {@link WebSocket}.
195
+ *
196
+ * @group Core
197
+ */
132
198
  export interface WebSocketOptions {
133
199
  /** Protocol(s) to use with the WebSocket connection. */
134
200
  readonly protocols?: string | ReadonlyArray<string>;
@@ -143,14 +209,14 @@ export interface WebSocketOptions {
143
209
  readonly onError?: (error: WebSocketError) => void;
144
210
 
145
211
  /** Callback when the connection is closed. */
146
- readonly onClose?: (event: CloseEvent) => void;
212
+ readonly onClose?: (event: WebSocketCloseEvent) => void;
147
213
 
148
214
  /**
149
215
  * Determines whether a closed connection should trigger a retry.
150
216
  *
151
217
  * Return false to stop retrying, for example on auth errors or maintenance.
152
218
  */
153
- readonly shouldRetryOnClose?: (event: CloseEvent) => boolean;
219
+ readonly shouldRetryOnClose?: (event: WebSocketCloseEvent) => boolean;
154
220
 
155
221
  /** Callback when message data is received. */
156
222
  readonly onMessage?: (data: string | ArrayBuffer | Blob) => void;
@@ -161,6 +227,28 @@ export interface WebSocketOptions {
161
227
  */
162
228
  readonly schedule?: Schedule<Millis, WebSocketRetryError>;
163
229
 
230
+ /**
231
+ * How long a connection must stay open for its close to start
232
+ * {@link WebSocketOptions.schedule} over. Defaults to 30 seconds, the delay
233
+ * cap of {@link webSocketReconnectSchedule}.
234
+ *
235
+ * The schedule is stateful and spans every reconnect, so without this a
236
+ * client that has disconnected often keeps waiting the longest backoff delay
237
+ * forever, even after hours of healthy connection. A connection that outlasts
238
+ * the longest delay the schedule can produce shows the endpoint works, so the
239
+ * next disconnect starts from the base delay again. Shorter connections reset
240
+ * nothing, so an endpoint that accepts and immediately drops connections
241
+ * still backs off.
242
+ *
243
+ * Choose it with the schedule rather than on its own: it is the schedule's
244
+ * delay cap, which only the schedule knows. A schedule cannot be asked for
245
+ * that cap, which is why this is an option rather than something derived. The
246
+ * delays a schedule has already produced are no substitute, because a
247
+ * jittered one produces delays near zero early on, and a threshold that low
248
+ * would reset the backoff for exactly the endpoint it protects against.
249
+ */
250
+ readonly healthyConnectionDuration?: Duration;
251
+
164
252
  /**
165
253
  * For custom WebSocket implementations.
166
254
  *
@@ -171,6 +259,12 @@ export interface WebSocketOptions {
171
259
  readonly WebSocketConstructor?: typeof globalThis.WebSocket;
172
260
  }
173
261
 
262
+ /**
263
+ * Any error reported by a {@link WebSocket}, including exhausted reconnect
264
+ * retries.
265
+ *
266
+ * @group Errors
267
+ */
174
268
  export type WebSocketError =
175
269
  | WebSocketConnectError
176
270
  | WebSocketConnectionError
@@ -179,6 +273,8 @@ export type WebSocketError =
179
273
  /**
180
274
  * An error that occurs when a connection cannot be established due to a network
181
275
  * error. Fires before `onclose`.
276
+ *
277
+ * @group Errors
182
278
  */
183
279
  export interface WebSocketConnectError extends Typed<"WebSocketConnectError"> {
184
280
  readonly event: Event;
@@ -192,17 +288,41 @@ export interface WebSocketConnectError extends Typed<"WebSocketConnectError"> {
192
288
  * Chromium and Firefox only fire `onclose` without a preceding error event.
193
289
  *
194
290
  * https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/error_event
291
+ *
292
+ * @group Errors
195
293
  */
196
294
  export interface WebSocketConnectionError extends Typed<"WebSocketConnectionError"> {
197
295
  readonly event: Event;
198
296
  }
199
297
 
298
+ /**
299
+ * Errors that trigger a reconnect attempt under
300
+ * {@link webSocketReconnectSchedule}.
301
+ *
302
+ * @group Errors
303
+ */
200
304
  export type WebSocketRetryError =
201
- WebSocketConnectError | WebSocketConnectionCloseError;
305
+ | WebSocketConnectError
306
+ | WebSocketConnectionCloseError
307
+ | WebSocketReconnectError;
308
+
309
+ /**
310
+ * The error {@link WebSocket.reconnect} settles the current connection with.
311
+ *
312
+ * Reconnecting is not a close, so it carries no close event: nothing observed
313
+ * one to report.
314
+ *
315
+ * @group Errors
316
+ */
317
+ export interface WebSocketReconnectError extends Typed<"WebSocketReconnectError"> {}
202
318
 
203
- /** An error that occurs when the connection is closed by the server. */
319
+ /**
320
+ * An error that occurs when the connection is closed by the server.
321
+ *
322
+ * @group Errors
323
+ */
204
324
  export interface WebSocketConnectionCloseError extends Typed<"WebSocketConnectionCloseError"> {
205
- readonly event: CloseEvent;
325
+ readonly event: WebSocketCloseEvent;
206
326
  }
207
327
 
208
328
  /**
@@ -210,13 +330,22 @@ export interface WebSocketConnectionCloseError extends Typed<"WebSocketConnectio
210
330
  *
211
331
  * Uses unlimited exponential backoff with a 100ms base, 30s cap, and full
212
332
  * jitter.
333
+ *
334
+ * @group Core
213
335
  */
214
336
  export const webSocketReconnectSchedule: Schedule<Millis, WebSocketRetryError> =
215
337
  /*#__PURE__*/ jitter("100%")(
216
338
  /*#__PURE__*/ maxDelay("30s")(/*#__PURE__*/ exponential("100ms")),
217
339
  );
218
340
 
219
- /** Create a new {@link WebSocket}. */
341
+ /** The delay cap of {@link webSocketReconnectSchedule}. */
342
+ const defaultHealthyConnectionDuration = /*#__PURE__*/ durationToMillis("30s");
343
+
344
+ /**
345
+ * Create a new {@link WebSocket}.
346
+ *
347
+ * @group Core
348
+ */
220
349
  export const createWebSocket: CreateWebSocket =
221
350
  (
222
351
  url,
@@ -229,13 +358,20 @@ export const createWebSocket: CreateWebSocket =
229
358
  onMessage,
230
359
  onError,
231
360
  schedule = webSocketReconnectSchedule,
361
+ healthyConnectionDuration = defaultHealthyConnectionDuration,
232
362
  WebSocketConstructor = globalThis.WebSocket,
233
363
  } = {},
234
364
  ) =>
235
365
  async (run) => {
236
366
  await using disposer = new AsyncDisposableStack();
237
367
 
368
+ const healthyConnectionMillis = durationToMillis(healthyConnectionDuration);
369
+
238
370
  let socket: globalThis.WebSocket | null = null;
371
+ // Set by a close that proves the endpoint works and by `reconnect`, and
372
+ // consumed by the next schedule step.
373
+ let shouldResetSchedule = false;
374
+ let resolveConnect: ConnectResolve | null = null;
239
375
 
240
376
  const closeSocket = () => {
241
377
  if (!socket) return;
@@ -265,6 +401,7 @@ export const createWebSocket: CreateWebSocket =
265
401
  */
266
402
  const connect: Task<void, WebSocketRetryError> = callback(({ resolve }) => {
267
403
  closeSocket();
404
+ resolveConnect = resolve;
268
405
 
269
406
  socket = new WebSocketConstructor(
270
407
  url,
@@ -274,15 +411,29 @@ export const createWebSocket: CreateWebSocket =
274
411
  if (binaryType) socket.binaryType = binaryType;
275
412
 
276
413
  let isOpen = false;
414
+ // Monotonic: how long the connection lasted must not follow a system
415
+ // clock adjustment, which would reset the schedule for a connection that
416
+ // proved nothing, or withhold the reset from one that proved the
417
+ // endpoint works.
418
+ let openedAt: PerformanceTime | null = null;
277
419
 
278
420
  // oxlint-disable-next-line unicorn/prefer-add-event-listener -- This adapter owns and clears one handler.
279
421
  socket.onopen = () => {
280
422
  isOpen = true;
423
+ openedAt = run.deps.time.performance.now();
281
424
  onOpen?.();
282
425
  };
283
426
 
284
427
  // oxlint-disable-next-line unicorn/prefer-add-event-listener -- This adapter owns and clears one handler.
285
428
  socket.onclose = (event) => {
429
+ if (
430
+ openedAt !== null &&
431
+ performanceDurationBetween(
432
+ openedAt,
433
+ run.deps.time.performance.now(),
434
+ ) >= healthyConnectionMillis
435
+ )
436
+ shouldResetSchedule = true;
286
437
  onClose?.(event);
287
438
  if (shouldRetryOnClose(event)) {
288
439
  resolve(err({ type: "WebSocketConnectionCloseError", event }));
@@ -306,10 +457,33 @@ export const createWebSocket: CreateWebSocket =
306
457
  if (error.type === "WebSocketConnectError") resolve(err(error));
307
458
  };
308
459
 
309
- return closeSocket;
460
+ return () => {
461
+ resolveConnect = null;
462
+ closeSocket();
463
+ };
310
464
  });
311
465
 
312
- const retryFiber = disposer.use(run.daemon(retry(connect, schedule)));
466
+ /**
467
+ * Wraps `schedule` so a healthy connection starts its backoff over.
468
+ *
469
+ * `retry` builds one schedule step per call and `connect` settles only when
470
+ * a connection closes, so a single step would otherwise accumulate backoff
471
+ * across every close for this wrapper's lifetime.
472
+ */
473
+ const reconnectSchedule: Schedule<Millis, WebSocketRetryError> = (deps) => {
474
+ let step = schedule(deps);
475
+ return (error) => {
476
+ if (shouldResetSchedule) {
477
+ shouldResetSchedule = false;
478
+ step = schedule(deps);
479
+ }
480
+ return step(error);
481
+ };
482
+ };
483
+
484
+ const retryFiber = disposer.use(
485
+ run.daemon(retry(connect, reconnectSchedule)),
486
+ );
313
487
 
314
488
  // Report RetryError (schedule exhausted) via onError callback
315
489
  void retryFiber.then((result) => {
@@ -339,10 +513,34 @@ export const createWebSocket: CreateWebSocket =
339
513
  !disposables.disposed &&
340
514
  socket?.readyState === globalThis.WebSocket.OPEN,
341
515
 
516
+ reconnect: () => {
517
+ const resolve = resolveConnect;
518
+ if (disposables.disposed || !resolve || !socket) return;
519
+ // A closing or closed socket settles the connection on its own.
520
+ // Reconnecting it would discard `shouldRetryOnClose` and restart the
521
+ // schedule for a close that proved nothing. `onClose` and `onError`
522
+ // run before that settlement, so a handler can reach this.
523
+ if (
524
+ socket.readyState !== socket.CONNECTING &&
525
+ socket.readyState !== socket.OPEN
526
+ )
527
+ return;
528
+ // Only a connection that was open makes the backoff before it stale.
529
+ if (socket.readyState === socket.OPEN) shouldResetSchedule = true;
530
+ resolveConnect = null;
531
+ // Handlers are cleared before settling, so the abandoned connection
532
+ // reports nothing while `retry` waits out its delay.
533
+ closeSocket();
534
+ resolve(err({ type: "WebSocketReconnectError" }));
535
+ },
536
+
342
537
  [Symbol.asyncDispose]: () => disposables.disposeAsync(),
343
538
  });
344
539
  };
345
540
 
541
+ /** The `resolve` a {@link callback} hands to the connect Task. */
542
+ type ConnectResolve = (result: Result<void, WebSocketRetryError>) => void;
543
+
346
544
  /** Clones SharedArrayBuffer-backed Uint8Array values before WebSocket.send. */
347
545
  const ensureSendableData = (
348
546
  data: BufferSource | Blob | string | globalThis.Uint8Array,
@@ -363,40 +561,61 @@ const nativeToStringState: Record<number, WebSocketReadyState> = {
363
561
  /**
364
562
  * An inspectable in-memory {@link CreateWebSocket} for testing by
365
563
  * {@link testCreateWebSocket}.
564
+ *
565
+ * Sockets report the states {@link createWebSocket} reports, including the
566
+ * `connecting` a socket is in before it opens and while the wrapper retries
567
+ * after a close or a {@link WebSocket.reconnect}. While it retries,
568
+ * {@link WebSocket.reconnect} does nothing, as the wrapper has no socket until
569
+ * {@link TestCreateWebSocket.open}. Only disposal ends in `closed`. The
570
+ * `closing` state is not modeled: no helper starts a close handshake. The event
571
+ * helpers throw for a disposed socket, which cannot receive events:
572
+ * {@link createWebSocket} detaches its handlers on disposal.
573
+ *
574
+ * @group Testing
366
575
  */
367
576
  export interface TestCreateWebSocket extends CreateWebSocket {
368
577
  readonly createdUrls: Array<string>;
578
+ /** URLs whose newest socket was reconnected, in call order. */
579
+ readonly reconnectedUrls: Array<string>;
369
580
  readonly sentMessages: Array<{
370
581
  readonly url: string;
371
582
  readonly data: BufferSource | Blob | string | globalThis.Uint8Array;
372
583
  }>;
373
584
  readonly message: (url: string, data: string | ArrayBuffer | Blob) => void;
374
585
  readonly open: (url: string) => void;
586
+ /**
587
+ * Reports the close event, with `code` defaulting to 1006, and leaves the
588
+ * socket connecting.
589
+ */
590
+ readonly close: (url: string, event?: Partial<WebSocketCloseEvent>) => void;
591
+ readonly error: (url: string, error: WebSocketError) => void;
375
592
  }
376
593
 
377
- /** Creates {@link TestCreateWebSocket}. */
594
+ /**
595
+ * Creates {@link TestCreateWebSocket}.
596
+ *
597
+ * @group Testing
598
+ */
378
599
  export const testCreateWebSocket = (
379
600
  options: {
380
601
  /** Throw immediately when a socket is created. */
381
602
  readonly throwOnCreate?: boolean;
382
603
 
383
- /** Initial open state of created sockets. Defaults to true. */
604
+ /**
605
+ * Whether created sockets start open, skipping
606
+ * {@link TestCreateWebSocket.open}. Defaults to true. A socket that does not
607
+ * start open is connecting.
608
+ */
384
609
  readonly isOpen?: boolean;
385
610
  } = {},
386
611
  ): TestCreateWebSocket => {
387
612
  const createdUrls: Array<string> = [];
613
+ const reconnectedUrls: Array<string> = [];
388
614
  const sentMessages: Array<{
389
615
  readonly url: string;
390
616
  readonly data: BufferSource | Blob | string | globalThis.Uint8Array;
391
617
  }> = [];
392
- const stateByUrl = new Map<
393
- string,
394
- {
395
- options: WebSocketOptions | undefined;
396
- isOpen: boolean;
397
- isDisposed: boolean;
398
- }
399
- >();
618
+ const stateByUrl = new Map<string, TestWebSocketState>();
400
619
 
401
620
  const getState = (url: string) => {
402
621
  const state = stateByUrl.get(url);
@@ -410,16 +629,19 @@ export const testCreateWebSocket = (
410
629
  }
411
630
 
412
631
  createdUrls.push(url);
413
- stateByUrl.set(url, {
632
+ // A URL can be created again after its socket was disposed. Each socket
633
+ // keeps its own state; the helpers address the newest socket for a URL.
634
+ const state: TestWebSocketState = {
414
635
  options: socketOptions,
415
- isOpen: options.isOpen ?? true,
636
+ readyState: (options.isOpen ?? true) ? "open" : "connecting",
416
637
  isDisposed: false,
417
- });
638
+ isWaitingToRetry: false,
639
+ };
640
+ stateByUrl.set(url, state);
418
641
 
419
642
  return ok({
420
643
  send: (data) => {
421
- const state = getState(url);
422
- if (state.isDisposed || !state.isOpen) {
644
+ if (state.isDisposed || state.readyState !== "open") {
423
645
  return err({ type: "WebSocketSendError" });
424
646
  }
425
647
  sentMessages.push({
@@ -429,21 +651,27 @@ export const testCreateWebSocket = (
429
651
  return ok();
430
652
  },
431
653
 
432
- getReadyState: () => {
433
- const state = getState(url);
434
- if (state.isDisposed) return "closed";
435
- return state.isOpen ? "open" : "closed";
436
- },
654
+ getReadyState: () => (state.isDisposed ? "closed" : state.readyState),
655
+
656
+ isOpen: () => !state.isDisposed && state.readyState === "open",
437
657
 
438
- isOpen: () => {
439
- const state = getState(url);
440
- return !state.isDisposed && state.isOpen;
658
+ reconnect: () => {
659
+ // A closed socket settles on its own, and a retrying wrapper has no
660
+ // socket, as in `createWebSocket`.
661
+ if (
662
+ state.isDisposed ||
663
+ state.readyState === "closed" ||
664
+ state.isWaitingToRetry
665
+ )
666
+ return;
667
+ state.readyState = "connecting";
668
+ state.isWaitingToRetry = true;
669
+ reconnectedUrls.push(url);
441
670
  },
442
671
 
443
672
  [Symbol.asyncDispose]: () => {
444
- const state = getState(url);
445
673
  state.isDisposed = true;
446
- state.isOpen = false;
674
+ state.readyState = "closed";
447
675
  return Promise.resolve();
448
676
  },
449
677
  });
@@ -451,21 +679,62 @@ export const testCreateWebSocket = (
451
679
 
452
680
  return Object.assign(createWebSocket, {
453
681
  createdUrls,
682
+ reconnectedUrls,
454
683
  sentMessages,
455
684
  message: (url: string, data: string | ArrayBuffer | Blob) => {
456
- getState(url).options?.onMessage?.(data);
685
+ const state = getState(url);
686
+ assert(!state.isDisposed, `Test WebSocket for ${url} is disposed.`);
687
+ state.options?.onMessage?.(data);
457
688
  },
458
689
  open: (url: string) => {
459
690
  const state = getState(url);
460
- state.isOpen = true;
691
+ assert(!state.isDisposed, `Test WebSocket for ${url} is disposed.`);
692
+ state.readyState = "open";
693
+ state.isWaitingToRetry = false;
461
694
  state.options?.onOpen?.();
462
695
  },
696
+ close: (url: string, event: Partial<WebSocketCloseEvent> = {}) => {
697
+ const state = getState(url);
698
+ assert(!state.isDisposed, `Test WebSocket for ${url} is disposed.`);
699
+ state.readyState = "closed";
700
+ state.options?.onClose?.({
701
+ code: 1006,
702
+ reason: "",
703
+ wasClean: false,
704
+ ...event,
705
+ });
706
+ // `createWebSocket` still holds the closed socket while it reports the
707
+ // close, and drops it to retry once that settles. Anything the handler
708
+ // schedules therefore still reads `closed`; anything later reads
709
+ // `connecting`.
710
+ queueMicrotask(() => {
711
+ if (state.readyState === "closed" && !state.isDisposed) {
712
+ state.readyState = "connecting";
713
+ state.isWaitingToRetry = true;
714
+ }
715
+ });
716
+ },
717
+ error: (url: string, error: WebSocketError) => {
718
+ const state = getState(url);
719
+ assert(!state.isDisposed, `Test WebSocket for ${url} is disposed.`);
720
+ state.options?.onError?.(error);
721
+ },
463
722
  });
464
723
  };
465
724
 
725
+ /** Mutable state of one socket created by {@link testCreateWebSocket}. */
726
+ interface TestWebSocketState {
727
+ options: WebSocketOptions | undefined;
728
+ readyState: WebSocketReadyState;
729
+ isDisposed: boolean;
730
+ isWaitingToRetry: boolean;
731
+ }
732
+
466
733
  /**
467
734
  * A native {@link WebSocket} prepared for integration tests by
468
735
  * {@link testSetupWebSocket}.
736
+ *
737
+ * @group Testing
469
738
  */
470
739
  export interface TestSetupWebSocket extends AsyncDisposable {
471
740
  readonly socket: globalThis.WebSocket;
@@ -475,7 +744,11 @@ export interface TestSetupWebSocket extends AsyncDisposable {
475
744
  readonly waitForMessage: () => Promise<string | globalThis.Uint8Array>;
476
745
  }
477
746
 
478
- /** Opens a native {@link WebSocket} and returns {@link TestSetupWebSocket}. */
747
+ /**
748
+ * Opens a native {@link WebSocket} and returns {@link TestSetupWebSocket}.
749
+ *
750
+ * @group Testing
751
+ */
479
752
  export const testSetupWebSocket = async (
480
753
  url: string,
481
754
  ): Promise<TestSetupWebSocket> => {