@rxferrix/client 0.12.0 → 0.13.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/auth.d.ts +6 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +96 -28
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +2 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +47 -15
- package/dist/client.js.map +1 -1
- package/dist/limits.d.ts +28 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +58 -0
- package/dist/limits.js.map +1 -0
- package/dist/local_query_set.d.ts +20 -4
- package/dist/local_query_set.d.ts.map +1 -1
- package/dist/local_query_set.js +63 -18
- package/dist/local_query_set.js.map +1 -1
- package/dist/protocol.d.ts +1 -1
- package/dist/protocol.js +1 -1
- package/dist/web_socket_manager.d.ts +10 -0
- package/dist/web_socket_manager.d.ts.map +1 -1
- package/dist/web_socket_manager.js +22 -0
- package/dist/web_socket_manager.js.map +1 -1
- package/package.json +1 -1
- package/src/auth.ts +102 -25
- package/src/client.ts +45 -15
- package/src/limits.ts +62 -0
- package/src/local_query_set.ts +68 -18
- package/src/protocol.ts +1 -1
- package/src/web_socket_manager.ts +21 -0
package/src/client.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
} from "./api.js";
|
|
8
8
|
import { normalizeArgs, queryToken, type QueryToken } from "./canonical.js";
|
|
9
9
|
import { CLIENT_CLOSED, RxFerrixError } from "./errors.js";
|
|
10
|
+
import { MAX_QUERY_ARGS_BYTES, tooLarge, tooMuchWatched } from "./limits.js";
|
|
10
11
|
import { LocalQuerySet } from "./local_query_set.js";
|
|
11
12
|
import { createLogger, type Logger } from "./logging.js";
|
|
12
13
|
import {
|
|
@@ -166,15 +167,19 @@ const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
|
166
167
|
/** The server's limit of live queries per socket; past it, a `ModifyQuerySet` is a fatal error (docs/PROTOCOL.md). */
|
|
167
168
|
export const MAX_LIVE_QUERIES = 1024;
|
|
168
169
|
|
|
169
|
-
/**
|
|
170
|
-
function
|
|
170
|
+
/**
|
|
171
|
+
* The error for function args that can't be sent: not encodable as JSON (a cycle, a BigInt, ...), or too large for
|
|
172
|
+
* one message. Null if they can.
|
|
173
|
+
*/
|
|
174
|
+
function unsendable(udfPath: string, args: Record<string, unknown>): RxFerrixError | null {
|
|
175
|
+
let json: string;
|
|
171
176
|
try {
|
|
172
|
-
JSON.stringify(args);
|
|
173
|
-
return null;
|
|
177
|
+
json = JSON.stringify(args);
|
|
174
178
|
} catch (e) {
|
|
175
179
|
const detail = e instanceof Error ? e.message : String(e);
|
|
176
180
|
return new RxFerrixError(`Arguments can't be serialized as JSON: ${detail}`, { code: "invalid_args", cause: e });
|
|
177
181
|
}
|
|
182
|
+
return tooLarge(udfPath, json);
|
|
178
183
|
}
|
|
179
184
|
|
|
180
185
|
interface AuthState {
|
|
@@ -218,6 +223,8 @@ export class RxFerrixClient {
|
|
|
218
223
|
* still shows results from the old socket, so it is not "at" the new socket's ts; mutation completion waits.
|
|
219
224
|
*/
|
|
220
225
|
private resyncQuerySetVersion: number | null = null;
|
|
226
|
+
/** A new socket opened and its first query-set flush hasn't gone out yet ({@link flushQuerySet}). */
|
|
227
|
+
private awaitingFirstFlush = false;
|
|
221
228
|
|
|
222
229
|
// Auth
|
|
223
230
|
private auth: AuthState | null = null;
|
|
@@ -360,6 +367,14 @@ export class RxFerrixClient {
|
|
|
360
367
|
{ code: "too_many_queries" },
|
|
361
368
|
);
|
|
362
369
|
}
|
|
370
|
+
const cost = this.localQueries.costOf(token);
|
|
371
|
+
if (cost > 0) {
|
|
372
|
+
// The same goes for a query whose arguments no message can carry (the token holds their canonical JSON), and
|
|
373
|
+
// for the one that takes the set past what the server lets a socket watch.
|
|
374
|
+
const large = tooLarge(udfPath, token);
|
|
375
|
+
if (large) throw large;
|
|
376
|
+
if (this.localQueries.argsBytes + cost > MAX_QUERY_ARGS_BYTES) throw tooMuchWatched(udfPath);
|
|
377
|
+
}
|
|
363
378
|
let set = this.listeners.get(token);
|
|
364
379
|
if (!set) {
|
|
365
380
|
set = new Set();
|
|
@@ -393,12 +408,26 @@ export class RxFerrixClient {
|
|
|
393
408
|
}
|
|
394
409
|
|
|
395
410
|
private flushQuerySet(): void {
|
|
396
|
-
const
|
|
411
|
+
const canSend = this.ws.canSend();
|
|
412
|
+
const { messages, removed } = this.localQueries.flush(canSend);
|
|
397
413
|
if (removed.length > 0) {
|
|
398
414
|
for (const queryId of removed) this.remoteQueries.remove(queryId);
|
|
399
415
|
this.recomputeAndNotify();
|
|
400
416
|
}
|
|
401
|
-
|
|
417
|
+
for (const message of messages) this.ws.send(message);
|
|
418
|
+
if (canSend && this.awaitingFirstFlush) {
|
|
419
|
+
// The first flush a new socket lets through re-adds every query. Until their results arrive from this socket
|
|
420
|
+
// the view still shows the previous socket's: a mutation that completed there must not settle against them.
|
|
421
|
+
// (Decided here, not when the socket opens: its sends may be held back then, for a fresh token.)
|
|
422
|
+
this.awaitingFirstFlush = false;
|
|
423
|
+
const resent = this.localQueries.sentQuerySetVersion;
|
|
424
|
+
this.resyncQuerySetVersion = resent > 0 ? resent : null;
|
|
425
|
+
if (resent === 0) {
|
|
426
|
+
// Nothing is watched, so there is no view a mutation could be ahead of, and nothing on this socket will
|
|
427
|
+
// bring a Transition for the ones that completed on the last: they are done.
|
|
428
|
+
this.finishMutations(this.requests.removeCompleted(Number.MAX_SAFE_INTEGER));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
402
431
|
}
|
|
403
432
|
|
|
404
433
|
// ---------------------------------------------------------------------------------------------------------------
|
|
@@ -415,9 +444,9 @@ export class RxFerrixClient {
|
|
|
415
444
|
if (this.closed) return Promise.reject(new RxFerrixError("Client is closed", { code: CLIENT_CLOSED }));
|
|
416
445
|
const args = normalizeArgs(argsAndOptions[0]);
|
|
417
446
|
const options = argsAndOptions[1] as MutationOptions<ArgsOf<M>> | undefined;
|
|
418
|
-
// Checked before it is queued: a message that can't be encoded
|
|
419
|
-
// block every later request behind it.
|
|
420
|
-
const invalid =
|
|
447
|
+
// Checked before it is queued: a message that can't be encoded, or that the server refuses for its size, would
|
|
448
|
+
// otherwise be retried on every socket and block every later request behind it.
|
|
449
|
+
const invalid = unsendable(ref._path, args);
|
|
421
450
|
if (invalid) return Promise.reject(invalid);
|
|
422
451
|
const requestId = this.requests.allocateRequestId();
|
|
423
452
|
const message: MutationMessage = { type: "Mutation", requestId, udfPath: ref._path, args };
|
|
@@ -437,7 +466,7 @@ export class RxFerrixClient {
|
|
|
437
466
|
action<A extends FunctionReference<"action">>(ref: A, ...args: OptionalArgs<A>): Promise<OutputOf<A>> {
|
|
438
467
|
if (this.closed) return Promise.reject(new RxFerrixError("Client is closed", { code: CLIENT_CLOSED }));
|
|
439
468
|
const normalized = normalizeArgs(args[0]);
|
|
440
|
-
const invalid =
|
|
469
|
+
const invalid = unsendable(ref._path, normalized);
|
|
441
470
|
if (invalid) return Promise.reject(invalid);
|
|
442
471
|
const requestId = this.requests.allocateRequestId();
|
|
443
472
|
const message: ActionMessage = { type: "Action", requestId, udfPath: ref._path, args: normalized };
|
|
@@ -619,10 +648,11 @@ export class RxFerrixClient {
|
|
|
619
648
|
this.identityVersion = 0;
|
|
620
649
|
this.serverToken = null;
|
|
621
650
|
this.awaitingAuthConfirmation = null;
|
|
651
|
+
// The view still shows the previous socket's results until the re-added queries report from this socket
|
|
652
|
+
// (`flushQuerySet` says from which query set version on).
|
|
653
|
+
this.awaitingFirstFlush = true;
|
|
654
|
+
this.resyncQuerySetVersion = null;
|
|
622
655
|
this.syncToServer();
|
|
623
|
-
// The view still shows the previous socket's results until the re-added queries report from this socket.
|
|
624
|
-
const resent = this.localQueries.sentQuerySetVersion;
|
|
625
|
-
this.resyncQuerySetVersion = resent > 0 ? resent : null;
|
|
626
656
|
}
|
|
627
657
|
|
|
628
658
|
/** Sends everything the server does not know yet, in protocol order: Authenticate, ModifyQuerySet, requests. */
|
|
@@ -663,7 +693,7 @@ export class RxFerrixClient {
|
|
|
663
693
|
}
|
|
664
694
|
case "FatalError":
|
|
665
695
|
this.logger.error(`server reported a fatal error: ${message.error}`);
|
|
666
|
-
this.ws.
|
|
696
|
+
this.ws.reconnectAfterFatal(`FatalError: ${message.error}`);
|
|
667
697
|
return;
|
|
668
698
|
case "AuthError":
|
|
669
699
|
this.onAuthError(message);
|
|
@@ -677,7 +707,7 @@ export class RxFerrixClient {
|
|
|
677
707
|
|
|
678
708
|
/** The ts the local view is consistent with, or null while resyncing after a reconnect. */
|
|
679
709
|
private observedTs(): number | null {
|
|
680
|
-
return this.resyncQuerySetVersion === null ? this.remoteQueries.version.ts : null;
|
|
710
|
+
return this.resyncQuerySetVersion === null && !this.awaitingFirstFlush ? this.remoteQueries.version.ts : null;
|
|
681
711
|
}
|
|
682
712
|
|
|
683
713
|
private onTransition(message: TransitionMessage): void {
|
package/src/limits.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { RxFerrixError } from "./errors.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The server's limit on one message, in UTF-8 bytes (docs/PROTOCOL.md, Limits). A larger one is answered with a
|
|
5
|
+
* `FatalError` and the socket is closed, and since the client sends again what the server never answered, the same
|
|
6
|
+
* message would end every socket after it. So nothing that large is ever queued.
|
|
7
|
+
*/
|
|
8
|
+
export const MAX_MESSAGE_BYTES = 2 * 1024 * 1024;
|
|
9
|
+
/**
|
|
10
|
+
* The server's limit on the arguments of all the queries one socket watches, as JSON (docs/PROTOCOL.md, Limits). The
|
|
11
|
+
* query that crosses it is answered with a `FatalError`, and every new socket would re-add the same set and get the
|
|
12
|
+
* same answer: the query is refused here instead.
|
|
13
|
+
*/
|
|
14
|
+
export const MAX_QUERY_ARGS_BYTES = 8 * 1024 * 1024;
|
|
15
|
+
/** Room kept in a message for what goes around the arguments: its type, ids and the function's path. */
|
|
16
|
+
const ENVELOPE_BYTES = 1024;
|
|
17
|
+
/**
|
|
18
|
+
* How much of a `ModifyQuerySet` its queries' paths and arguments may take, in characters of their canonical JSON.
|
|
19
|
+
* A query set is re-sent whole on every new socket; this splits it into messages well under the limit (a character
|
|
20
|
+
* is at most three bytes, and escaping can add a little).
|
|
21
|
+
*/
|
|
22
|
+
export const MODIFY_QUERY_SET_CHARS = 400_000;
|
|
23
|
+
|
|
24
|
+
/** The length of `s` as UTF-8. */
|
|
25
|
+
export function utf8Length(s: string): number {
|
|
26
|
+
let bytes = 0;
|
|
27
|
+
for (let i = 0; i < s.length; i++) {
|
|
28
|
+
const c = s.charCodeAt(i);
|
|
29
|
+
if (c < 0x80) bytes += 1;
|
|
30
|
+
else if (c < 0x800) bytes += 2;
|
|
31
|
+
else if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) {
|
|
32
|
+
// A surrogate pair: four bytes for the two code units.
|
|
33
|
+
bytes += 4;
|
|
34
|
+
i++;
|
|
35
|
+
} else bytes += 3;
|
|
36
|
+
}
|
|
37
|
+
return bytes;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** About what a `Remove` takes in a `ModifyQuerySet`, in characters. */
|
|
41
|
+
export const REMOVE_CHARS = 40;
|
|
42
|
+
|
|
43
|
+
/** The error for one query too many, by the size of their arguments ({@link MAX_QUERY_ARGS_BYTES}). */
|
|
44
|
+
export function tooMuchWatched(udfPath: string): RxFerrixError {
|
|
45
|
+
return new RxFerrixError(
|
|
46
|
+
`Can't watch "${udfPath}" as well: the queries watched at once may hold at most ` +
|
|
47
|
+
`${MAX_QUERY_ARGS_BYTES >> 20} MiB of arguments`,
|
|
48
|
+
{ code: "args_too_large" },
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The error for a call whose arguments (`json`: their JSON) don't fit in one message, or null. */
|
|
53
|
+
export function tooLarge(udfPath: string, json: string): RxFerrixError | null {
|
|
54
|
+
const room = MAX_MESSAGE_BYTES - ENVELOPE_BYTES - udfPath.length;
|
|
55
|
+
// Each character is one to three bytes: only what is in between needs counting.
|
|
56
|
+
if (json.length * 3 <= room) return null;
|
|
57
|
+
if (json.length <= room && utf8Length(json) <= room) return null;
|
|
58
|
+
return new RxFerrixError(
|
|
59
|
+
`The arguments of "${udfPath}" are too large: a message to the server holds at most ${MAX_MESSAGE_BYTES >> 20} MiB`,
|
|
60
|
+
{ code: "args_too_large" },
|
|
61
|
+
);
|
|
62
|
+
}
|
package/src/local_query_set.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { queryToken, type QueryToken } from "./canonical.js";
|
|
2
|
+
import { MODIFY_QUERY_SET_CHARS, REMOVE_CHARS, utf8Length } from "./limits.js";
|
|
2
3
|
import type { ModifyQuerySetMessage, QueryId, QuerySetModification } from "./protocol.js";
|
|
3
4
|
|
|
4
5
|
export interface LocalQuery {
|
|
@@ -9,11 +10,17 @@ export interface LocalQuery {
|
|
|
9
10
|
refCount: number;
|
|
10
11
|
/** Whether an `Add` for this query is part of the server's query set on the current socket. */
|
|
11
12
|
onServer: boolean;
|
|
13
|
+
/** About what its arguments count for on the server ({@link LocalQuerySet.argsBytes}). */
|
|
14
|
+
readonly bytes: number;
|
|
12
15
|
}
|
|
13
16
|
|
|
14
17
|
export interface FlushResult {
|
|
15
|
-
/**
|
|
16
|
-
|
|
18
|
+
/**
|
|
19
|
+
* The messages to send, in order; none when nothing changed (or nothing can be sent right now). Usually one. A
|
|
20
|
+
* query set too large for one message (it is sent whole on every new socket) is split into several, each one
|
|
21
|
+
* version after the last.
|
|
22
|
+
*/
|
|
23
|
+
messages: ModifyQuerySetMessage[];
|
|
17
24
|
/** Queries dropped locally by this flush (their last watcher left). Their results should be discarded. */
|
|
18
25
|
removed: QueryId[];
|
|
19
26
|
}
|
|
@@ -34,12 +41,31 @@ export class LocalQuerySet {
|
|
|
34
41
|
private readonly pendingRemoves = new Set<QueryId>();
|
|
35
42
|
/** The `newVersion` of the last ModifyQuerySet sent on this socket. */
|
|
36
43
|
private querySetVersion = 0;
|
|
44
|
+
/** The sum of {@link LocalQuery.bytes} over the queries that have watchers. */
|
|
45
|
+
private bytes = 0;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* About what the server counts for the arguments of the watched queries (it caps their JSON at 8 MiB a socket).
|
|
49
|
+
* An upper bound: each query counts with its token, which holds its path as well. A query whose last watcher
|
|
50
|
+
* left no longer counts, though it is tracked until the next flush: that flush removes it before it adds anything.
|
|
51
|
+
*/
|
|
52
|
+
get argsBytes(): number {
|
|
53
|
+
return this.bytes;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What watching the query `token` would add to {@link argsBytes}; nothing if it has watchers already. */
|
|
57
|
+
costOf(token: QueryToken): number {
|
|
58
|
+
const tracked = this.byToken.get(token);
|
|
59
|
+
if (!tracked) return utf8Length(token);
|
|
60
|
+
return tracked.refCount > 0 ? 0 : tracked.bytes;
|
|
61
|
+
}
|
|
37
62
|
|
|
38
63
|
/** Adds a watcher; returns the query (created if needed) and whether a flush is needed. */
|
|
39
64
|
addRef(udfPath: string, args: Record<string, unknown>): { query: LocalQuery; needsFlush: boolean } {
|
|
40
65
|
const token = queryToken(udfPath, args);
|
|
41
66
|
const existing = this.byToken.get(token);
|
|
42
67
|
if (existing) {
|
|
68
|
+
if (existing.refCount === 0) this.bytes += existing.bytes;
|
|
43
69
|
existing.refCount += 1;
|
|
44
70
|
// refCount went 0 -> 1 before the flush: the query is revived without any server traffic.
|
|
45
71
|
return { query: existing, needsFlush: false };
|
|
@@ -51,7 +77,9 @@ export class LocalQuerySet {
|
|
|
51
77
|
args,
|
|
52
78
|
refCount: 1,
|
|
53
79
|
onServer: false,
|
|
80
|
+
bytes: utf8Length(token),
|
|
54
81
|
};
|
|
82
|
+
this.bytes += query.bytes;
|
|
55
83
|
this.byToken.set(token, query);
|
|
56
84
|
this.byId.set(query.queryId, query);
|
|
57
85
|
return { query, needsFlush: true };
|
|
@@ -61,6 +89,7 @@ export class LocalQuerySet {
|
|
|
61
89
|
removeRef(token: QueryToken): boolean {
|
|
62
90
|
const query = this.byToken.get(token);
|
|
63
91
|
if (!query) return false;
|
|
92
|
+
if (query.refCount === 1) this.bytes -= query.bytes;
|
|
64
93
|
query.refCount = Math.max(0, query.refCount - 1);
|
|
65
94
|
return query.refCount === 0;
|
|
66
95
|
}
|
|
@@ -83,8 +112,8 @@ export class LocalQuerySet {
|
|
|
83
112
|
}
|
|
84
113
|
|
|
85
114
|
/**
|
|
86
|
-
* Drops queries without watchers and, if `canSend`, builds the
|
|
87
|
-
* in line with the local one (bumping the query set version).
|
|
115
|
+
* Drops queries without watchers and, if `canSend`, builds the ModifyQuerySets that bring the server's query set
|
|
116
|
+
* in line with the local one (bumping the query set version once per message).
|
|
88
117
|
*/
|
|
89
118
|
flush(canSend: boolean): FlushResult {
|
|
90
119
|
const removed: QueryId[] = [];
|
|
@@ -95,25 +124,46 @@ export class LocalQuerySet {
|
|
|
95
124
|
removed.push(query.queryId);
|
|
96
125
|
if (query.onServer) this.pendingRemoves.add(query.queryId);
|
|
97
126
|
}
|
|
98
|
-
if (!canSend) return {
|
|
127
|
+
if (!canSend) return { messages: [], removed };
|
|
99
128
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
129
|
+
// Split so that no message outgrows the server's limit. The removes go first: the server applies a message in
|
|
130
|
+
// order and caps what is live after each step, so a set near the cap is swapped by making room before using it.
|
|
131
|
+
const batches: QuerySetModification[][] = [[]];
|
|
132
|
+
let chars = 0;
|
|
133
|
+
for (const queryId of this.pendingRemoves) {
|
|
134
|
+
let batch = batches[batches.length - 1]!;
|
|
135
|
+
if (chars + REMOVE_CHARS > MODIFY_QUERY_SET_CHARS) {
|
|
136
|
+
batch = [];
|
|
137
|
+
batches.push(batch);
|
|
138
|
+
chars = 0;
|
|
105
139
|
}
|
|
140
|
+
batch.push({ type: "Remove", queryId });
|
|
141
|
+
chars += REMOVE_CHARS;
|
|
106
142
|
}
|
|
107
|
-
for (const queryId of this.pendingRemoves) modifications.push({ type: "Remove", queryId });
|
|
108
143
|
this.pendingRemoves.clear();
|
|
109
|
-
|
|
144
|
+
for (const query of this.byToken.values()) {
|
|
145
|
+
if (query.onServer) continue;
|
|
146
|
+
// The token is the path and the canonical JSON of the arguments: about what the Add takes on the wire.
|
|
147
|
+
const size = query.token.length;
|
|
148
|
+
let batch = batches[batches.length - 1]!;
|
|
149
|
+
if (batch.length > 0 && chars + size > MODIFY_QUERY_SET_CHARS) {
|
|
150
|
+
batch = [];
|
|
151
|
+
batches.push(batch);
|
|
152
|
+
chars = 0;
|
|
153
|
+
}
|
|
154
|
+
batch.push({ type: "Add", queryId: query.queryId, udfPath: query.udfPath, args: query.args });
|
|
155
|
+
chars += size;
|
|
156
|
+
query.onServer = true;
|
|
157
|
+
}
|
|
110
158
|
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
159
|
+
const messages: ModifyQuerySetMessage[] = [];
|
|
160
|
+
for (const modifications of batches) {
|
|
161
|
+
if (modifications.length === 0) continue;
|
|
162
|
+
const baseVersion = this.querySetVersion;
|
|
163
|
+
this.querySetVersion += 1;
|
|
164
|
+
messages.push({ type: "ModifyQuerySet", baseVersion, newVersion: this.querySetVersion, modifications });
|
|
165
|
+
}
|
|
166
|
+
return { messages, removed };
|
|
117
167
|
}
|
|
118
168
|
|
|
119
169
|
/** The server state is fresh on a new socket: nothing is subscribed and the query set version is 0. */
|
package/src/protocol.ts
CHANGED
|
@@ -6,7 +6,7 @@ export type QueryId = number;
|
|
|
6
6
|
export type RequestId = number;
|
|
7
7
|
export type JSONValue = null | boolean | number | string | JSONValue[] | { [key: string]: JSONValue };
|
|
8
8
|
|
|
9
|
-
export const CLIENT_VERSION = "0.
|
|
9
|
+
export const CLIENT_VERSION = "0.13.0";
|
|
10
10
|
|
|
11
11
|
export interface StateVersion {
|
|
12
12
|
querySet: number;
|
|
@@ -5,6 +5,8 @@ import type { Logger } from "./logging.js";
|
|
|
5
5
|
export const SERVER_INACTIVITY_TIMEOUT_MS = 30_000;
|
|
6
6
|
const INITIAL_BACKOFF_MS = 250;
|
|
7
7
|
const MAX_BACKOFF_MS = 16_000;
|
|
8
|
+
/** `FatalError`s closer together than this are one streak ({@link WebSocketManager.reconnectAfterFatal}). */
|
|
9
|
+
const FATAL_STREAK_MS = 60_000;
|
|
8
10
|
/**
|
|
9
11
|
* A socket that hasn't opened after this long is dropped and retried (a black-holed connect would otherwise hang
|
|
10
12
|
* forever). As long as the silence threshold: a server that takes longer than that to accept is as good as down.
|
|
@@ -65,6 +67,9 @@ export class WebSocketManager {
|
|
|
65
67
|
private _hasEverConnected = false;
|
|
66
68
|
private lastCloseReason: string | null = null;
|
|
67
69
|
private retries = 0;
|
|
70
|
+
/** `FatalError`s in the current streak, and when the last one came. */
|
|
71
|
+
private fatalErrors = 0;
|
|
72
|
+
private lastFatalAt = 0;
|
|
68
73
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
69
74
|
private watchdogTimer: ReturnType<typeof setTimeout> | null = null;
|
|
70
75
|
private connectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -148,6 +153,22 @@ export class WebSocketManager {
|
|
|
148
153
|
this.scheduleReconnect(reason);
|
|
149
154
|
}
|
|
150
155
|
|
|
156
|
+
/**
|
|
157
|
+
* The server refused something this client sent and ended the socket (`FatalError`). The client will send it
|
|
158
|
+
* again, or something like it, on the next socket, so each refusal that follows the last within a minute waits
|
|
159
|
+
* longer than the one before: nothing a healthy-looking socket says in between (its first Transition arrives
|
|
160
|
+
* before the refusal) starts the delays over.
|
|
161
|
+
*/
|
|
162
|
+
reconnectAfterFatal(reason: string): void {
|
|
163
|
+
if (this._state === "stopped") return;
|
|
164
|
+
const now = Date.now();
|
|
165
|
+
if (now - this.lastFatalAt > FATAL_STREAK_MS) this.fatalErrors = 0;
|
|
166
|
+
this.lastFatalAt = now;
|
|
167
|
+
this.retries = Math.max(this.retries, this.fatalErrors);
|
|
168
|
+
this.fatalErrors += 1;
|
|
169
|
+
this.reconnect(reason);
|
|
170
|
+
}
|
|
171
|
+
|
|
151
172
|
/** Permanently closes the socket. */
|
|
152
173
|
stop(): void {
|
|
153
174
|
if (this._state === "stopped") return;
|