@evolu/common 7.4.1 → 8.0.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/README.md +4 -61
  2. package/dist/src/Array.d.ts +343 -102
  3. package/dist/src/Array.d.ts.map +1 -1
  4. package/dist/src/Array.js +181 -85
  5. package/dist/src/Assert.d.ts +57 -11
  6. package/dist/src/Assert.d.ts.map +1 -1
  7. package/dist/src/Assert.js +47 -11
  8. package/dist/src/BigInt.d.ts +6 -1
  9. package/dist/src/BigInt.d.ts.map +1 -1
  10. package/dist/src/BigInt.js +5 -0
  11. package/dist/src/Brand.d.ts +40 -12
  12. package/dist/src/Brand.d.ts.map +1 -1
  13. package/dist/src/Brand.js +5 -0
  14. package/dist/src/Buffer.d.ts +6 -1
  15. package/dist/src/Buffer.d.ts.map +1 -1
  16. package/dist/src/Buffer.js +7 -2
  17. package/dist/src/Cache.d.ts +10 -2
  18. package/dist/src/Cache.d.ts.map +1 -1
  19. package/dist/src/Cache.js +6 -0
  20. package/dist/src/Callbacks.d.ts +10 -7
  21. package/dist/src/Callbacks.d.ts.map +1 -1
  22. package/dist/src/Callbacks.js +23 -13
  23. package/dist/src/Console.d.ts +361 -69
  24. package/dist/src/Console.d.ts.map +1 -1
  25. package/dist/src/Console.js +217 -117
  26. package/dist/src/Crypto.d.ts +70 -21
  27. package/dist/src/Crypto.d.ts.map +1 -1
  28. package/dist/src/Crypto.js +72 -27
  29. package/dist/src/Eq.d.ts +22 -2
  30. package/dist/src/Eq.d.ts.map +1 -1
  31. package/dist/src/Eq.js +21 -1
  32. package/dist/src/Error.d.ts +23 -12
  33. package/dist/src/Error.d.ts.map +1 -1
  34. package/dist/src/Error.js +27 -11
  35. package/dist/src/Function.d.ts +128 -56
  36. package/dist/src/Function.d.ts.map +1 -1
  37. package/dist/src/Function.js +118 -14
  38. package/dist/src/Identicon.d.ts +5 -0
  39. package/dist/src/Identicon.d.ts.map +1 -1
  40. package/dist/src/Identicon.js +6 -1
  41. package/dist/src/Lookup.d.ts +162 -0
  42. package/dist/src/Lookup.d.ts.map +1 -0
  43. package/dist/src/Lookup.js +200 -0
  44. package/dist/src/Microtask.d.ts +21 -0
  45. package/dist/src/Microtask.d.ts.map +1 -0
  46. package/dist/src/Microtask.js +37 -0
  47. package/dist/src/Number.d.ts +18 -4
  48. package/dist/src/Number.d.ts.map +1 -1
  49. package/dist/src/Number.js +23 -1
  50. package/dist/src/Object.d.ts +110 -5
  51. package/dist/src/Object.d.ts.map +1 -1
  52. package/dist/src/Object.js +108 -5
  53. package/dist/src/Option.d.ts +58 -0
  54. package/dist/src/Option.d.ts.map +1 -0
  55. package/dist/src/Option.js +43 -0
  56. package/dist/src/Order.d.ts +5 -0
  57. package/dist/src/Order.d.ts.map +1 -1
  58. package/dist/src/Order.js +8 -3
  59. package/dist/src/Platform.d.ts +36 -2
  60. package/dist/src/Platform.d.ts.map +1 -1
  61. package/dist/src/Platform.js +10 -6
  62. package/dist/src/Polyfills.d.ts +29 -0
  63. package/dist/src/Polyfills.d.ts.map +1 -0
  64. package/dist/src/Polyfills.js +329 -0
  65. package/dist/src/Random.d.ts +18 -7
  66. package/dist/src/Random.d.ts.map +1 -1
  67. package/dist/src/Random.js +9 -7
  68. package/dist/src/Redacted.d.ts +7 -4
  69. package/dist/src/Redacted.d.ts.map +1 -1
  70. package/dist/src/Redacted.js +5 -0
  71. package/dist/src/Ref.d.ts +31 -16
  72. package/dist/src/Ref.d.ts.map +1 -1
  73. package/dist/src/Ref.js +35 -7
  74. package/dist/src/RefCount.d.ts +62 -0
  75. package/dist/src/RefCount.d.ts.map +1 -0
  76. package/dist/src/RefCount.js +83 -0
  77. package/dist/src/Relation.d.ts +84 -29
  78. package/dist/src/Relation.d.ts.map +1 -1
  79. package/dist/src/Relation.js +83 -105
  80. package/dist/src/Resource.d.ts +263 -0
  81. package/dist/src/Resource.d.ts.map +1 -0
  82. package/dist/src/Resource.js +389 -0
  83. package/dist/src/Result.d.ts +390 -374
  84. package/dist/src/Result.d.ts.map +1 -1
  85. package/dist/src/Result.js +142 -70
  86. package/dist/src/Schedule.d.ts +953 -0
  87. package/dist/src/Schedule.d.ts.map +1 -0
  88. package/dist/src/Schedule.js +1199 -0
  89. package/dist/src/Set.d.ts +181 -0
  90. package/dist/src/Set.d.ts.map +1 -0
  91. package/dist/src/Set.js +137 -0
  92. package/dist/src/Skiplist.d.ts +6 -1
  93. package/dist/src/Skiplist.d.ts.map +1 -1
  94. package/dist/src/Skiplist.js +5 -0
  95. package/dist/src/Sqlite.d.ts +160 -60
  96. package/dist/src/Sqlite.d.ts.map +1 -1
  97. package/dist/src/Sqlite.js +290 -190
  98. package/dist/src/Store.d.ts +36 -28
  99. package/dist/src/Store.d.ts.map +1 -1
  100. package/dist/src/Store.js +55 -15
  101. package/dist/src/String.d.ts +5 -0
  102. package/dist/src/String.d.ts.map +1 -1
  103. package/dist/src/String.js +5 -0
  104. package/dist/src/Task.d.ts +2274 -423
  105. package/dist/src/Task.d.ts.map +1 -1
  106. package/dist/src/Task.js +1499 -348
  107. package/dist/src/Test.d.ts +150 -0
  108. package/dist/src/Test.d.ts.map +1 -0
  109. package/dist/src/Test.js +97 -0
  110. package/dist/src/Time.d.ts +202 -48
  111. package/dist/src/Time.d.ts.map +1 -1
  112. package/dist/src/Time.js +199 -88
  113. package/dist/src/Tracer.d.ts +48 -0
  114. package/dist/src/Tracer.d.ts.map +1 -0
  115. package/dist/src/Tracer.js +6 -0
  116. package/dist/src/Type.d.ts +587 -209
  117. package/dist/src/Type.d.ts.map +1 -1
  118. package/dist/src/Type.js +370 -240
  119. package/dist/src/Types.d.ts +160 -6
  120. package/dist/src/Types.d.ts.map +1 -1
  121. package/dist/src/Types.js +22 -2
  122. package/dist/src/WebSocket.d.ts +130 -58
  123. package/dist/src/WebSocket.d.ts.map +1 -1
  124. package/dist/src/WebSocket.js +274 -113
  125. package/dist/src/Worker.d.ts +245 -60
  126. package/dist/src/Worker.d.ts.map +1 -1
  127. package/dist/src/Worker.js +220 -64
  128. package/dist/src/index.d.ts +25 -3
  129. package/dist/src/index.d.ts.map +1 -1
  130. package/dist/src/index.js +19 -3
  131. package/dist/src/local-first/Db.d.ts +24 -213
  132. package/dist/src/local-first/Db.d.ts.map +1 -1
  133. package/dist/src/local-first/Db.js +621 -293
  134. package/dist/src/local-first/Error.d.ts +12 -0
  135. package/dist/src/local-first/Error.d.ts.map +1 -0
  136. package/dist/src/local-first/Error.js +6 -0
  137. package/dist/src/local-first/Evolu.d.ts +330 -285
  138. package/dist/src/local-first/Evolu.d.ts.map +1 -1
  139. package/dist/src/local-first/Evolu.js +472 -402
  140. package/dist/src/local-first/LocalAuth.d.ts +10 -10
  141. package/dist/src/local-first/LocalAuth.d.ts.map +1 -1
  142. package/dist/src/local-first/LocalAuth.js +6 -1
  143. package/dist/src/local-first/Owner.d.ts +70 -27
  144. package/dist/src/local-first/Owner.d.ts.map +1 -1
  145. package/dist/src/local-first/Owner.js +27 -14
  146. package/dist/src/local-first/Protocol.d.ts +58 -50
  147. package/dist/src/local-first/Protocol.d.ts.map +1 -1
  148. package/dist/src/local-first/Protocol.js +253 -155
  149. package/dist/src/local-first/Query.d.ts +133 -43
  150. package/dist/src/local-first/Query.d.ts.map +1 -1
  151. package/dist/src/local-first/Query.js +139 -90
  152. package/dist/src/local-first/Relay.d.ts +34 -49
  153. package/dist/src/local-first/Relay.d.ts.map +1 -1
  154. package/dist/src/local-first/Relay.js +59 -177
  155. package/dist/src/local-first/Schema.d.ts +139 -144
  156. package/dist/src/local-first/Schema.d.ts.map +1 -1
  157. package/dist/src/local-first/Schema.js +78 -163
  158. package/dist/src/local-first/Shared.d.ts +125 -0
  159. package/dist/src/local-first/Shared.d.ts.map +1 -0
  160. package/dist/src/local-first/Shared.js +569 -0
  161. package/dist/src/local-first/Storage.d.ts +54 -58
  162. package/dist/src/local-first/Storage.d.ts.map +1 -1
  163. package/dist/src/local-first/Storage.js +127 -179
  164. package/dist/src/local-first/Timestamp.d.ts +28 -35
  165. package/dist/src/local-first/Timestamp.d.ts.map +1 -1
  166. package/dist/src/local-first/Timestamp.js +24 -25
  167. package/dist/src/local-first/index.d.ts +2 -15
  168. package/dist/src/local-first/index.d.ts.map +1 -1
  169. package/dist/src/local-first/index.js +2 -15
  170. package/package.json +37 -24
  171. package/src/Array.ts +467 -106
  172. package/src/Assert.ts +87 -12
  173. package/src/BigInt.ts +7 -1
  174. package/src/Brand.ts +41 -12
  175. package/src/Buffer.ts +10 -4
  176. package/src/Cache.ts +11 -2
  177. package/src/Callbacks.ts +33 -23
  178. package/src/Console.ts +567 -178
  179. package/src/Crypto.ts +111 -62
  180. package/src/Eq.ts +30 -3
  181. package/src/Error.ts +50 -34
  182. package/src/Function.ts +140 -68
  183. package/src/Identicon.ts +7 -1
  184. package/src/Lookup.ts +429 -0
  185. package/src/Microtask.ts +58 -0
  186. package/src/Number.ts +40 -7
  187. package/src/Object.ts +155 -16
  188. package/src/Option.ts +74 -0
  189. package/src/Order.ts +11 -3
  190. package/src/Platform.ts +43 -7
  191. package/src/Polyfills.ts +520 -0
  192. package/src/Random.ts +25 -11
  193. package/src/Redacted.ts +8 -4
  194. package/src/Ref.ts +72 -21
  195. package/src/RefCount.ts +170 -0
  196. package/src/Relation.ts +188 -136
  197. package/src/Resource.ts +864 -0
  198. package/src/Result.ts +548 -381
  199. package/src/Schedule.ts +1471 -0
  200. package/src/Set.ts +247 -0
  201. package/src/Skiplist.ts +7 -1
  202. package/src/Sqlite.ts +409 -304
  203. package/src/Store.ts +94 -48
  204. package/src/String.ts +6 -0
  205. package/src/Task.ts +3696 -740
  206. package/src/Test.ts +202 -0
  207. package/src/Time.ts +357 -147
  208. package/src/Tracer.ts +54 -0
  209. package/src/Type.ts +866 -430
  210. package/src/Types.ts +202 -7
  211. package/src/WebSocket.ts +404 -167
  212. package/src/Worker.ts +527 -147
  213. package/src/index.ts +74 -3
  214. package/src/local-first/Db.ts +867 -597
  215. package/src/local-first/Error.ts +17 -0
  216. package/src/local-first/Evolu.ts +884 -824
  217. package/src/local-first/LocalAuth.ts +10 -10
  218. package/src/local-first/Owner.ts +94 -42
  219. package/src/local-first/Protocol.ts +441 -307
  220. package/src/local-first/Query.ts +243 -194
  221. package/src/local-first/Relay.ts +119 -273
  222. package/src/local-first/Schema.ts +284 -394
  223. package/src/local-first/Shared.ts +932 -0
  224. package/src/local-first/Storage.ts +211 -283
  225. package/src/local-first/Timestamp.ts +38 -45
  226. package/src/local-first/index.ts +2 -16
  227. package/dist/src/Instances.d.ts +0 -34
  228. package/dist/src/Instances.d.ts.map +0 -1
  229. package/dist/src/Instances.js +0 -44
  230. package/dist/src/Resources.d.ts +0 -118
  231. package/dist/src/Resources.d.ts.map +0 -1
  232. package/dist/src/Resources.js +0 -197
  233. package/dist/src/local-first/Platform.d.ts +0 -25
  234. package/dist/src/local-first/Platform.d.ts.map +0 -1
  235. package/dist/src/local-first/Platform.js +0 -1
  236. package/dist/src/local-first/Public.d.ts +0 -22
  237. package/dist/src/local-first/Public.d.ts.map +0 -1
  238. package/dist/src/local-first/Public.js +0 -15
  239. package/dist/src/local-first/PublicKysely.d.ts +0 -148
  240. package/dist/src/local-first/PublicKysely.d.ts.map +0 -1
  241. package/dist/src/local-first/PublicKysely.js +0 -184
  242. package/dist/src/local-first/Sync.d.ts +0 -112
  243. package/dist/src/local-first/Sync.d.ts.map +0 -1
  244. package/dist/src/local-first/Sync.js +0 -526
  245. package/src/Instances.ts +0 -92
  246. package/src/Resources.ts +0 -367
  247. package/src/local-first/Platform.ts +0 -27
  248. package/src/local-first/Public.ts +0 -42
  249. package/src/local-first/PublicKysely.ts +0 -239
  250. package/src/local-first/Sync.ts +0 -949
package/src/WebSocket.ts CHANGED
@@ -1,16 +1,77 @@
1
- import { constVoid } from "./Function.js";
2
- import { err, ok, Result } from "./Result.js";
3
- import { retry, RetryError, RetryOptions } from "./Task.js";
4
- import { maxPositiveInt } from "./Type.js";
1
+ /**
2
+ * WebSocket with auto-reconnect.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ import { assert } from "./Assert.js";
8
+ import { lazyTrue } from "./Function.js";
9
+ import type { Result } from "./Result.js";
10
+ import { err, ok } from "./Result.js";
11
+ import type { Schedule } from "./Schedule.js";
12
+ import { exponential, jitter, maxDelay } from "./Schedule.js";
13
+ import type { RetryError, Task } from "./Task.js";
14
+ import { callback, retry } from "./Task.js";
15
+ import type { Millis } from "./Time.js";
16
+ import { ArrayBuffer, String, Uint8Array, type Typed } from "./Type.js";
5
17
 
6
- /** WebSocket with auto-reconnect and offline support. */
7
- export interface WebSocket extends Disposable {
18
+ /**
19
+ * WebSocket with auto-reconnect.
20
+ *
21
+ * The API mirrors native
22
+ * {@link https://developer.mozilla.org/en-US/docs/Web/API/WebSocket | WebSocket}
23
+ * but retries connections indefinitely by default. This design accounts for the
24
+ * fact that browser and React Native online/offline detection APIs are
25
+ * unreliable — they may report online status incorrectly, so the only reliable
26
+ * approach is to keep attempting reconnection.
27
+ *
28
+ * Created via {@link createWebSocket} which returns a {@link Task}.
29
+ *
30
+ * Disposing starts closing the connection without waiting for the close event
31
+ * so disposal stays immediate. This wrapper treats disposal as local teardown,
32
+ * not as waiting for the full WebSocket close handshake to finish.
33
+ *
34
+ * ## How Binary Messages Work
35
+ *
36
+ * The Server Chooses the Message Type:
37
+ *
38
+ * - Text (0x1) → Sent as UTF-8 encoded text (always received as a string in the
39
+ * browser).
40
+ * - Binary (0x2) → Sent as raw binary data (received as a Blob or ArrayBuffer,
41
+ * depending on binaryType).
42
+ *
43
+ * The Client's binaryType Controls How Binary Data is Processed:
44
+ *
45
+ * - If the server sends a text frame (0x1), the browser always delivers
46
+ * event.data as a string, regardless of binaryType.
47
+ * - If the server sends a binary frame (0x2), the browser delivers event.data as:
48
+ *
49
+ * - A Blob (default: "blob")
50
+ * - An ArrayBuffer ("arraybuffer")
51
+ *
52
+ * ### Example
53
+ *
54
+ * ```ts
55
+ * const ws = await run(
56
+ * createWebSocket("wss://example.com", {
57
+ * onMessage: (data) => console.log("Received:", data),
58
+ * onOpen: () => console.log("Connected"),
59
+ * onClose: () => console.log("Disconnected"),
60
+ * }),
61
+ * );
62
+ * if (ws.ok) {
63
+ * ws.value.send("Hello");
64
+ * // Later: await ws.value[Symbol.asyncDispose]();
65
+ * }
66
+ * ```
67
+ */
68
+ export interface WebSocket extends AsyncDisposable {
8
69
  /**
9
70
  * Send data through the WebSocket connection. Returns {@link Result} with an
10
71
  * error if the data couldn't be sent.
11
72
  */
12
73
  send: (
13
- data: string | ArrayBufferLike | Blob | ArrayBufferView,
74
+ data: BufferSource | Blob | string | globalThis.Uint8Array,
14
75
  ) => Result<void, WebSocketSendError>;
15
76
 
16
77
  readonly getReadyState: () => WebSocketReadyState;
@@ -25,53 +86,66 @@ export interface WebSocket extends Disposable {
25
86
  *
26
87
  * https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send
27
88
  */
28
- export interface WebSocketSendError {
29
- readonly type: "WebSocketSendError";
30
- }
89
+ export interface WebSocketSendError extends Typed<"WebSocketSendError"> {}
31
90
 
32
91
  /** WebSocket connection states. */
33
92
  export type WebSocketReadyState = "connecting" | "open" | "closing" | "closed";
34
93
 
94
+ /** {@link Task} that creates a {@link WebSocket}. */
35
95
  export type CreateWebSocket = (
36
96
  url: string,
37
97
  options?: WebSocketOptions,
38
- ) => WebSocket;
98
+ ) => Task<WebSocket>;
39
99
 
40
100
  export interface CreateWebSocketDep {
41
101
  readonly createWebSocket: CreateWebSocket;
42
102
  }
43
103
 
44
- /** Options for creating {@link WebSocket} */
104
+ /** Options for creating {@link WebSocket}. */
45
105
  export interface WebSocketOptions {
46
106
  /** Protocol(s) to use with the WebSocket connection. */
47
- protocols?: string | Array<string>;
107
+ readonly protocols?: string | ReadonlyArray<string>;
48
108
 
49
109
  /** Sets the binary type for the data being received. */
50
- binaryType?: "blob" | "arraybuffer";
110
+ readonly binaryType?: "blob" | "arraybuffer";
51
111
 
52
112
  /** Callback when the connection is established. */
53
- onOpen?: () => void;
113
+ readonly onOpen?: () => void;
54
114
 
55
115
  /** Callback when an error occurs. */
56
- onError?: (error: WebSocketError) => void;
116
+ readonly onError?: (error: WebSocketError) => void;
57
117
 
58
118
  /** Callback when the connection is closed. */
59
- onClose?: (event: CloseEvent) => void;
119
+ readonly onClose?: (event: CloseEvent) => void;
120
+
121
+ /**
122
+ * Determines whether a closed connection should trigger a retry.
123
+ *
124
+ * Return false to stop retrying, for example on auth errors or maintenance.
125
+ */
126
+ readonly shouldRetryOnClose?: (event: CloseEvent) => boolean;
60
127
 
61
128
  /** Callback when message data is received. */
62
- onMessage?: (data: string | ArrayBuffer | Blob) => void;
129
+ readonly onMessage?: (data: string | ArrayBuffer | Blob) => void;
63
130
 
64
- /** Options for retry behavior. */
65
- retryOptions?: Omit<RetryOptions<WebSocketRetryError>, "signal">;
131
+ /**
132
+ * Retry schedule for reconnection. Defaults to:
133
+ *
134
+ * ```ts
135
+ * // A jittered, capped, unlimited exponential backoff.
136
+ * jitter(1)(maxDelay("30s")(exponential("100ms")));
137
+ * ```
138
+ */
139
+ readonly schedule?: Schedule<Millis, WebSocketRetryError>;
66
140
 
67
141
  /**
68
142
  * For custom WebSocket implementations.
69
143
  *
70
- * This suppors blob:
144
+ * This supports blob:
71
145
  *
72
146
  * https://github.com/callstackincubator/react-native-fast-io
73
147
  */
74
- WebSocketConstructor?: typeof globalThis.WebSocket;
148
+ readonly WebSocketConstructor?: typeof globalThis.WebSocket;
75
149
  }
76
150
 
77
151
  export type WebSocketError =
@@ -81,21 +155,22 @@ export type WebSocketError =
81
155
 
82
156
  /**
83
157
  * An error that occurs when a connection cannot be established due to a network
84
- * error.
158
+ * error. Fires before `onclose`.
85
159
  */
86
- export interface WebSocketConnectError {
87
- readonly type: "WebSocketConnectError";
160
+ export interface WebSocketConnectError extends Typed<"WebSocketConnectError"> {
88
161
  readonly event: Event;
89
162
  }
90
163
 
91
164
  /**
92
- * An error that occurs when a connection is closed due to an issue (e.g.,
93
- * failure to send some data).
165
+ * An error that occurs when an established connection encounters an issue
166
+ * (e.g., failure to send data). Fires before `onclose`.
167
+ *
168
+ * Note: Only Node.js and WebKit fire this error on abrupt server termination.
169
+ * Chromium and Firefox only fire `onclose` without a preceding error event.
94
170
  *
95
171
  * https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/error_event
96
172
  */
97
- export interface WebSocketConnectionError {
98
- readonly type: "WebSocketConnectionError";
173
+ export interface WebSocketConnectionError extends Typed<"WebSocketConnectionError"> {
99
174
  readonly event: Event;
100
175
  }
101
176
 
@@ -103,172 +178,334 @@ export type WebSocketRetryError =
103
178
  | WebSocketConnectError
104
179
  | WebSocketConnectionCloseError;
105
180
 
106
- export interface WebSocketConnectionCloseError {
107
- readonly type: "WebSocketConnectionCloseError";
181
+ /** An error that occurs when the connection is closed by the server. */
182
+ export interface WebSocketConnectionCloseError extends Typed<"WebSocketConnectionCloseError"> {
108
183
  readonly event: CloseEvent;
109
184
  }
110
185
 
111
- /**
112
- * Create a new {@link WebSocket}.
113
- *
114
- * The default behavior is that WebSocket tries to reconnect repeatedly in case
115
- * the application is offline, because online events (both web and native) are
116
- * not reliable. Once it connects and the connection is closed, it tries to
117
- * reconnect again. Retrying the connection can be controlled using the
118
- * retryOptions retryable predicate.
119
- *
120
- * ### How Binary Messages Work in WebSockets
121
- *
122
- * The Server Chooses the Message Type:
123
- *
124
- * - Text (0x1) → Sent as UTF-8 encoded text (always received as a string in the
125
- * browser).
126
- * - Binary (0x2) → Sent as raw binary data (received as a Blob or ArrayBuffer,
127
- * depending on binaryType).
128
- *
129
- * The Client's binaryType Controls How Binary Data is Processed:
130
- *
131
- * - If the server sends a text frame (0x1), the browser always delivers
132
- * event.data as a string, regardless of binaryType.
133
- * - If the server sends a binary frame (0x2), the browser delivers event.data as:
134
- *
135
- * - A Blob (default: "blob")
136
- * - An ArrayBuffer ("arraybuffer")
137
- *
138
- * ### Example
139
- *
140
- * TODO:
141
- */
142
- export const createWebSocket: CreateWebSocket = (
143
- url,
144
- {
145
- protocols,
146
- binaryType,
147
- onOpen,
148
- onClose,
149
- onMessage,
150
- onError,
151
- retryOptions,
152
- WebSocketConstructor = globalThis.WebSocket,
153
- } = {},
154
- ) => {
155
- let isDisposed = false;
156
-
157
- const reconnectController = new AbortController();
186
+ /** Create a new {@link WebSocket}. */
187
+ export const createWebSocket: CreateWebSocket =
188
+ (
189
+ url,
190
+ {
191
+ protocols,
192
+ binaryType,
193
+ onOpen,
194
+ onClose,
195
+ shouldRetryOnClose = lazyTrue,
196
+ onMessage,
197
+ onError,
198
+ schedule = jitter(1)(maxDelay("30s")(exponential("100ms"))),
199
+ WebSocketConstructor = globalThis.WebSocket,
200
+ } = {},
201
+ ) =>
202
+ async (run) => {
203
+ await using stack = new AsyncDisposableStack();
204
+
205
+ let socket: globalThis.WebSocket | null = null;
206
+
207
+ const closeSocket = () => {
208
+ if (!socket) return;
209
+
210
+ socket.onopen = null;
211
+ socket.onclose = null;
212
+ socket.onmessage = null;
213
+ socket.onerror = null;
214
+
215
+ if (
216
+ socket.readyState !== socket.CLOSING &&
217
+ socket.readyState !== socket.CLOSED
218
+ ) {
219
+ socket.close();
220
+ }
158
221
 
159
- const defaultRetryOptions: RetryOptions<WebSocketRetryError> = {
160
- retries: maxPositiveInt, // Practically infinite retries
222
+ socket = null;
223
+ };
224
+
225
+ /**
226
+ * A task that connects and stays connected until the connection closes or
227
+ * errors. Returns error to trigger retry.
228
+ */
229
+ const connect: Task<void, WebSocketRetryError> = callback(({ err, ok }) => {
230
+ closeSocket();
231
+
232
+ socket = new WebSocketConstructor(
233
+ url,
234
+ String.is(protocols) ? protocols : protocols && [...protocols],
235
+ );
236
+
237
+ if (binaryType) socket.binaryType = binaryType;
238
+
239
+ let isOpen = false;
240
+
241
+ socket.onopen = () => {
242
+ isOpen = true;
243
+ onOpen?.();
244
+ };
245
+
246
+ socket.onclose = (event) => {
247
+ onClose?.(event);
248
+ if (shouldRetryOnClose(event)) {
249
+ err({ type: "WebSocketConnectionCloseError", event });
250
+ } else {
251
+ ok();
252
+ }
253
+ };
254
+
255
+ socket.onmessage = (event: MessageEvent<string | ArrayBuffer | Blob>) => {
256
+ onMessage?.(event.data);
257
+ };
258
+
259
+ socket.onerror = (event) => {
260
+ const error: WebSocketConnectionError | WebSocketConnectError = isOpen
261
+ ? { type: "WebSocketConnectionError", event }
262
+ : { type: "WebSocketConnectError", event };
263
+ onError?.(error);
264
+
265
+ if (error.type === "WebSocketConnectError") err(error);
266
+ };
267
+
268
+ return closeSocket;
269
+ });
270
+
271
+ const retryFiber = stack.use(run.daemon(retry(connect, schedule)));
272
+
273
+ // Report RetryError (schedule exhausted) via onError callback
274
+ void retryFiber.then((result) => {
275
+ if (!result.ok && result.error.type === "RetryError") {
276
+ onError?.(result.error);
277
+ }
278
+ });
279
+
280
+ const moved = stack.move();
281
+
282
+ return ok<WebSocket>({
283
+ send: (data) => {
284
+ // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send
285
+ if (!socket || socket.readyState === socket.CONNECTING) {
286
+ return err({ type: "WebSocketSendError" });
287
+ }
288
+ socket.send(ensureSendableData(data));
289
+ return ok();
290
+ },
291
+
292
+ getReadyState: () => {
293
+ if (moved.disposed) return "closed";
294
+ return socket ? nativeToStringState[socket.readyState] : "connecting";
295
+ },
296
+
297
+ isOpen: () =>
298
+ !moved.disposed && socket?.readyState === globalThis.WebSocket.OPEN,
299
+
300
+ [Symbol.asyncDispose]: () => moved.disposeAsync(),
301
+ });
161
302
  };
162
303
 
163
- let socket: globalThis.WebSocket | null = null;
304
+ /** Clones SharedArrayBuffer-backed Uint8Array values before WebSocket.send. */
305
+ const ensureSendableData = (
306
+ data: BufferSource | Blob | string | globalThis.Uint8Array,
307
+ ): BufferSource | Blob | string => {
308
+ if (!Uint8Array.is(data)) return data;
309
+ return ArrayBuffer.is(data.buffer)
310
+ ? (data as globalThis.Uint8Array<ArrayBuffer>)
311
+ : new globalThis.Uint8Array(data);
312
+ };
164
313
 
165
- const disposeSocket = () => {
166
- if (!socket) return;
314
+ const nativeToStringState: Record<number, WebSocketReadyState> = {
315
+ [globalThis.WebSocket.CONNECTING]: "connecting",
316
+ [globalThis.WebSocket.OPEN]: "open",
317
+ [globalThis.WebSocket.CLOSING]: "closing",
318
+ [globalThis.WebSocket.CLOSED]: "closed",
319
+ };
320
+
321
+ /**
322
+ * An inspectable in-memory {@link CreateWebSocket} for testing by
323
+ * {@link testCreateWebSocket}.
324
+ */
325
+ export interface TestCreateWebSocket extends CreateWebSocket {
326
+ readonly createdUrls: Array<string>;
327
+ readonly sentMessages: Array<{
328
+ readonly url: string;
329
+ readonly data: BufferSource | Blob | string | globalThis.Uint8Array;
330
+ }>;
331
+ readonly message: (url: string, data: string | ArrayBuffer | Blob) => void;
332
+ readonly open: (url: string) => void;
333
+ }
167
334
 
168
- // Remove all listeners before closing
169
- socket.onopen = null;
170
- socket.onclose = null;
171
- socket.onmessage = null;
172
- socket.onerror = null;
335
+ /** Creates {@link TestCreateWebSocket}. */
336
+ export const testCreateWebSocket = (
337
+ options: {
338
+ /** Throw immediately when a socket is created. */
339
+ readonly throwOnCreate?: boolean;
173
340
 
174
- if (
175
- socket.readyState !== socket.CLOSING &&
176
- socket.readyState !== socket.CLOSED
177
- ) {
178
- socket.close();
341
+ /** Initial open state of created sockets. Defaults to true. */
342
+ readonly isOpen?: boolean;
343
+ } = {},
344
+ ): TestCreateWebSocket => {
345
+ const createdUrls: Array<string> = [];
346
+ const sentMessages: Array<{
347
+ readonly url: string;
348
+ readonly data: BufferSource | Blob | string | globalThis.Uint8Array;
349
+ }> = [];
350
+ const stateByUrl = new Map<
351
+ string,
352
+ {
353
+ options: WebSocketOptions | undefined;
354
+ isOpen: boolean;
355
+ isDisposed: boolean;
179
356
  }
180
- socket = null;
181
- };
357
+ >();
182
358
 
183
- // To prevent a memory leak from pending connection promise.
184
- let disposePromise: null | typeof constVoid = null;
359
+ const getState = (url: string) => {
360
+ const state = stateByUrl.get(url);
361
+ assert(state, `Test WebSocket for ${url} does not exist.`);
362
+ return state;
363
+ };
185
364
 
186
- /**
187
- * This promise represents continuous connection which:
188
- *
189
- * - Is rejected when a connection cannot be established.
190
- * - Is rejected when a connection is closed.
191
- * - Is resolved when WebSocket is disposed().
192
- */
193
- void retry(
194
- {
195
- ...defaultRetryOptions,
196
- ...retryOptions,
197
- },
198
- (): Promise<Result<void, WebSocketRetryError>> =>
199
- new Promise((resolve) => {
200
- disposePromise = () => {
201
- resolve(ok());
202
- };
365
+ const createWebSocket: CreateWebSocket = (url, socketOptions) => () => {
366
+ if (options.throwOnCreate) {
367
+ throw new Error("testCreateWebSocket is configured to throw on create");
368
+ }
203
369
 
204
- if (isDisposed) disposePromise();
370
+ createdUrls.push(url);
371
+ stateByUrl.set(url, {
372
+ options: socketOptions,
373
+ isOpen: options.isOpen ?? true,
374
+ isDisposed: false,
375
+ });
376
+
377
+ return ok({
378
+ send: (data) => {
379
+ const state = getState(url);
380
+ if (state.isDisposed || !state.isOpen) {
381
+ return err({ type: "WebSocketSendError" });
382
+ }
383
+ sentMessages.push({
384
+ url,
385
+ data: ensureSendableData(data),
386
+ });
387
+ return ok();
388
+ },
389
+
390
+ getReadyState: () => {
391
+ const state = getState(url);
392
+ if (state.isDisposed) return "closed";
393
+ return state.isOpen ? "open" : "closed";
394
+ },
395
+
396
+ isOpen: () => {
397
+ const state = getState(url);
398
+ return !state.isDisposed && state.isOpen;
399
+ },
400
+
401
+ [Symbol.asyncDispose]: () => {
402
+ const state = getState(url);
403
+ state.isDisposed = true;
404
+ state.isOpen = false;
405
+ return Promise.resolve();
406
+ },
407
+ });
408
+ };
205
409
 
206
- disposeSocket();
410
+ return Object.assign(createWebSocket, {
411
+ createdUrls,
412
+ sentMessages,
413
+ message: (url: string, data: string | ArrayBuffer | Blob) => {
414
+ getState(url).options?.onMessage?.(data);
415
+ },
416
+ open: (url: string) => {
417
+ const state = getState(url);
418
+ state.isOpen = true;
419
+ state.options?.onOpen?.();
420
+ },
421
+ });
422
+ };
207
423
 
208
- socket = new WebSocketConstructor(url, protocols);
209
- if (binaryType) socket.binaryType = binaryType;
424
+ /**
425
+ * A native {@link WebSocket} prepared for integration tests by
426
+ * {@link testSetupWebSocket}.
427
+ */
428
+ export interface TestSetupWebSocket extends AsyncDisposable {
429
+ readonly socket: globalThis.WebSocket;
430
+ readonly send: (
431
+ data: BufferSource | Blob | string | globalThis.Uint8Array,
432
+ ) => void;
433
+ readonly waitForMessage: () => Promise<string | globalThis.Uint8Array>;
434
+ }
210
435
 
211
- let isOpen = false;
436
+ /** Opens a native {@link WebSocket} and returns {@link TestSetupWebSocket}. */
437
+ export const testSetupWebSocket = async (
438
+ url: string,
439
+ ): Promise<TestSetupWebSocket> => {
440
+ const socket = new globalThis.WebSocket(url);
441
+ socket.binaryType = "arraybuffer";
442
+
443
+ await new Promise<void>((resolve, reject) => {
444
+ const onOpen = () => {
445
+ cleanup();
446
+ resolve();
447
+ };
448
+
449
+ const onError = () => {
450
+ cleanup();
451
+ socket.close();
452
+ reject(new Error("WebSocket connection failed"));
453
+ };
212
454
 
213
- socket.onopen = () => {
214
- isOpen = true;
215
- onOpen?.();
216
- };
455
+ const cleanup = () => {
456
+ socket.removeEventListener("open", onOpen);
457
+ socket.removeEventListener("error", onError);
458
+ };
217
459
 
218
- socket.onerror = (event) => {
219
- const error: WebSocketConnectionError | WebSocketConnectError = isOpen
220
- ? { type: "WebSocketConnectionError", event }
221
- : { type: "WebSocketConnectError", event };
222
- onError?.(error);
460
+ socket.addEventListener("open", onOpen, { once: true });
461
+ socket.addEventListener("error", onError, { once: true });
462
+ });
223
463
 
224
- // Trigger reconnect only on WebSocketConnectError.
225
- if (error.type === "WebSocketConnectError") {
226
- resolve(err(error));
464
+ return {
465
+ socket,
466
+ send: (data) => {
467
+ socket.send(ensureSendableData(data));
468
+ },
469
+ waitForMessage: () =>
470
+ new Promise((resolve, reject) => {
471
+ if (socket.readyState === globalThis.WebSocket.CLOSED) {
472
+ reject(new Error("WebSocket closed before message"));
473
+ return;
474
+ }
475
+
476
+ const onMessage = (event: MessageEvent) => {
477
+ cleanup();
478
+
479
+ if (typeof event.data === "string") {
480
+ resolve(event.data);
481
+ return;
227
482
  }
228
- };
229
483
 
230
- socket.onclose = (event) => {
231
- onClose?.(event);
232
- resolve(err({ type: "WebSocketConnectionCloseError", event }));
484
+ resolve(new globalThis.Uint8Array(event.data as ArrayBuffer));
233
485
  };
234
486
 
235
- socket.onmessage = (event) => {
236
- onMessage?.(event.data as string | ArrayBuffer | Blob);
487
+ const onClose = () => {
488
+ cleanup();
489
+ reject(new Error("WebSocket closed before message"));
237
490
  };
238
- }),
239
- )(reconnectController).then((result) => {
240
- if (result.ok || result.error.type === "AbortError") return;
241
- onError?.(result.error as WebSocketError);
242
- });
243
491
 
244
- return {
245
- send: (data) => {
246
- // https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/send
247
- if (!socket || socket.readyState === socket.CONNECTING) {
248
- return err({ type: "WebSocketSendError" });
249
- }
250
- socket.send(data);
251
- return ok();
252
- },
492
+ const cleanup = () => {
493
+ socket.removeEventListener("message", onMessage);
494
+ socket.removeEventListener("close", onClose);
495
+ };
253
496
 
254
- getReadyState: () =>
255
- socket ? nativeToStringState[socket.readyState] : "connecting",
497
+ socket.addEventListener("message", onMessage, { once: true });
498
+ socket.addEventListener("close", onClose, { once: true });
499
+ }),
500
+ [Symbol.asyncDispose]: async () => {
501
+ if (socket.readyState === globalThis.WebSocket.CLOSED) return;
256
502
 
257
- isOpen: () => (socket ? socket.readyState === socket.OPEN : false),
503
+ const closed = new Promise<void>((resolve) => {
504
+ socket.addEventListener("close", () => resolve(), { once: true });
505
+ });
258
506
 
259
- [Symbol.dispose]() {
260
- if (isDisposed) return;
261
- isDisposed = true;
262
- reconnectController.abort();
263
- disposeSocket();
264
- disposePromise?.();
507
+ socket.close();
508
+ await closed;
265
509
  },
266
510
  };
267
511
  };
268
-
269
- const nativeToStringState: Record<number, WebSocketReadyState> = {
270
- [WebSocket.CONNECTING]: "connecting",
271
- [WebSocket.OPEN]: "open",
272
- [WebSocket.CLOSING]: "closing",
273
- [WebSocket.CLOSED]: "closed",
274
- };