@evolu/common 8.10.0 → 8.12.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.
- package/dist/src/Bytes.d.ts +39 -2
- package/dist/src/Bytes.d.ts.map +1 -1
- package/dist/src/Bytes.js +50 -2
- package/dist/src/Config.d.ts +22 -22
- package/dist/src/Config.d.ts.map +1 -1
- package/dist/src/Console.d.ts +62 -7
- package/dist/src/Console.d.ts.map +1 -1
- package/dist/src/Console.js +20 -4
- package/dist/src/Crypto.d.ts +76 -4
- package/dist/src/Crypto.d.ts.map +1 -1
- package/dist/src/Crypto.js +55 -4
- package/dist/src/Error.d.ts +45 -0
- package/dist/src/Error.d.ts.map +1 -1
- package/dist/src/Error.js +69 -0
- package/dist/src/Fs.d.ts +92 -18
- package/dist/src/Fs.d.ts.map +1 -1
- package/dist/src/Fs.js +2 -0
- package/dist/src/Identicon.d.ts +2 -2
- package/dist/src/Identicon.js +2 -2
- package/dist/src/LeakDetector.d.ts +22 -3
- package/dist/src/LeakDetector.d.ts.map +1 -1
- package/dist/src/LeakDetector.js +12 -2
- package/dist/src/LockManager.d.ts +8 -0
- package/dist/src/LockManager.d.ts.map +1 -1
- package/dist/src/LockManager.js +6 -0
- package/dist/src/Object.d.ts.map +1 -1
- package/dist/src/Object.js +5 -0
- package/dist/src/Platform.d.ts +47 -7
- package/dist/src/Platform.d.ts.map +1 -1
- package/dist/src/Platform.js +24 -5
- package/dist/src/Random.d.ts +25 -2
- package/dist/src/Random.d.ts.map +1 -1
- package/dist/src/Random.js +14 -2
- package/dist/src/Resource.d.ts +156 -1
- package/dist/src/Resource.d.ts.map +1 -1
- package/dist/src/Resource.js +201 -72
- package/dist/src/Schedule.d.ts +11 -10
- package/dist/src/Schedule.d.ts.map +1 -1
- package/dist/src/Schedule.js +1 -1
- package/dist/src/Sqlite.d.ts +132 -16
- package/dist/src/Sqlite.d.ts.map +1 -1
- package/dist/src/Sqlite.js +63 -9
- package/dist/src/Task.d.ts +15 -4
- package/dist/src/Task.d.ts.map +1 -1
- package/dist/src/Task.js +41 -15
- package/dist/src/Test.d.ts +9 -0
- package/dist/src/Test.d.ts.map +1 -1
- package/dist/src/Test.js +4 -0
- package/dist/src/Time.d.ts +106 -9
- package/dist/src/Time.d.ts.map +1 -1
- package/dist/src/Time.js +55 -4
- package/dist/src/Type.d.ts +1455 -1310
- package/dist/src/Type.d.ts.map +1 -1
- package/dist/src/Type.js +1274 -517
- package/dist/src/WebSocket.d.ts +164 -13
- package/dist/src/WebSocket.d.ts.map +1 -1
- package/dist/src/WebSocket.js +133 -24
- package/dist/src/Worker.d.ts +90 -8
- package/dist/src/Worker.d.ts.map +1 -1
- package/dist/src/Worker.js +28 -2
- package/dist/src/index.d.ts +6 -7
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +2 -3
- package/dist/src/local-first/Db.d.ts +52 -3
- package/dist/src/local-first/Db.d.ts.map +1 -1
- package/dist/src/local-first/Db.js +412 -137
- package/dist/src/local-first/Evolu.d.ts +412 -213
- package/dist/src/local-first/Evolu.d.ts.map +1 -1
- package/dist/src/local-first/Evolu.js +181 -18
- package/dist/src/local-first/Owner.d.ts +13 -30
- package/dist/src/local-first/Owner.d.ts.map +1 -1
- package/dist/src/local-first/Owner.js +13 -30
- package/dist/src/local-first/Protocol.d.ts +106 -19
- package/dist/src/local-first/Protocol.d.ts.map +1 -1
- package/dist/src/local-first/Protocol.js +162 -60
- package/dist/src/local-first/Query.d.ts +8 -15
- package/dist/src/local-first/Query.d.ts.map +1 -1
- package/dist/src/local-first/Relay.d.ts.map +1 -1
- package/dist/src/local-first/Relay.js +4 -2
- package/dist/src/local-first/Schema.d.ts +346 -23
- package/dist/src/local-first/Schema.d.ts.map +1 -1
- package/dist/src/local-first/Schema.js +214 -17
- package/dist/src/local-first/Shared.d.ts +537 -22
- package/dist/src/local-first/Shared.d.ts.map +1 -1
- package/dist/src/local-first/Shared.js +1437 -234
- package/dist/src/local-first/Storage.d.ts +195 -17
- package/dist/src/local-first/Storage.d.ts.map +1 -1
- package/dist/src/local-first/Storage.js +85 -22
- package/dist/src/local-first/Timestamp.d.ts +392 -41
- package/dist/src/local-first/Timestamp.d.ts.map +1 -1
- package/dist/src/local-first/Timestamp.js +403 -81
- package/dist/src/local-first/index.d.ts +0 -1
- package/dist/src/local-first/index.d.ts.map +1 -1
- package/dist/src/local-first/index.js +0 -1
- package/package.json +1 -1
- package/src/Assert.test.ts +2 -5
- package/src/Bytes.test.ts +27 -0
- package/src/Bytes.ts +58 -2
- package/src/Config.test.ts +2 -6
- package/src/Config.ts +133 -133
- package/src/Console.ts +62 -7
- package/src/Crypto.ts +76 -4
- package/src/Eq.test.ts +2 -3
- package/src/Error.test.ts +76 -3
- package/src/Error.ts +71 -0
- package/src/Fs.ts +92 -18
- package/src/Identicon.ts +2 -2
- package/src/LeakDetector.ts +22 -3
- package/src/LockManager.ts +8 -0
- package/src/Object.test.ts +27 -12
- package/src/Object.ts +5 -0
- package/src/Platform.ts +50 -8
- package/src/Random.ts +25 -2
- package/src/Resource.test.ts +837 -0
- package/src/Resource.ts +235 -15
- package/src/Schedule.test.ts +50 -12
- package/src/Schedule.ts +24 -14
- package/src/Sqlite.ts +137 -17
- package/src/Task.test.ts +189 -8
- package/src/Task.ts +56 -17
- package/src/Test.ts +9 -0
- package/src/Time.ts +106 -9
- package/src/Type.test.ts +946 -1028
- package/src/Type.ts +4195 -3136
- package/src/Types.test.ts +4 -14
- package/src/WebSocket.ts +313 -40
- package/src/Worker.ts +90 -8
- package/src/index.ts +20 -6
- package/src/local-first/Db.ts +644 -339
- package/src/local-first/Evolu.test.ts +994 -22
- package/src/local-first/Evolu.ts +625 -232
- package/src/local-first/Owner.ts +13 -30
- package/src/local-first/Protocol.test.ts +634 -10
- package/src/local-first/Protocol.ts +255 -109
- package/src/local-first/Query.ts +8 -15
- package/src/local-first/Relay.ts +4 -2
- package/src/local-first/Schema.test.ts +143 -0
- package/src/local-first/Schema.ts +376 -26
- package/src/local-first/Shared.test.ts +7731 -559
- package/src/local-first/Shared.ts +2036 -267
- package/src/local-first/Storage.ts +224 -36
- package/src/local-first/Timestamp.test.ts +344 -70
- package/src/local-first/Timestamp.ts +434 -118
- package/src/local-first/index.ts +0 -1
- package/dist/src/local-first/Error.d.ts +0 -12
- package/dist/src/local-first/Error.d.ts.map +0 -1
- package/dist/src/local-first/Error.js +0 -6
- package/dist/src/local-first/LocalAuth.d.ts +0 -150
- package/dist/src/local-first/LocalAuth.d.ts.map +0 -1
- package/dist/src/local-first/LocalAuth.js +0 -179
- package/src/local-first/Error.ts +0 -17
- 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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
144
|
+
/**
|
|
145
|
+
* WebSocket connection states.
|
|
146
|
+
*
|
|
147
|
+
* @group Core
|
|
148
|
+
*/
|
|
119
149
|
export type WebSocketReadyState = "connecting" | "open" | "closing" | "closed";
|
|
120
150
|
|
|
121
|
-
/**
|
|
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
|
-
/**
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
/**
|
|
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:
|
|
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
|
-
/**
|
|
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
|
|
460
|
+
return () => {
|
|
461
|
+
resolveConnect = null;
|
|
462
|
+
closeSocket();
|
|
463
|
+
};
|
|
310
464
|
});
|
|
311
465
|
|
|
312
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
434
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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.
|
|
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)
|
|
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.
|
|
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
|
-
/**
|
|
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> => {
|