@rxferrix/client 0.11.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/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
- /** The error for function args that can't be encoded as JSON (a cycle, a BigInt, ...), or null. */
170
- function unserializable(args: Record<string, unknown>): RxFerrixError | null {
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 { message, removed } = this.localQueries.flush(this.ws.canSend());
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
- if (message) this.ws.send(message);
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 would otherwise be retried on every socket and
419
- // block every later request behind it.
420
- const invalid = unserializable(args);
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 = unserializable(normalized);
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.reconnect(`FatalError: ${message.error}`);
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
+ }
@@ -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
- /** The message to send, or null when nothing changed (or nothing can be sent right now). */
16
- message: ModifyQuerySetMessage | null;
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 ModifyQuerySet that brings the server's query set
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 { message: null, removed };
127
+ if (!canSend) return { messages: [], removed };
99
128
 
100
- const modifications: QuerySetModification[] = [];
101
- for (const query of this.byToken.values()) {
102
- if (!query.onServer) {
103
- modifications.push({ type: "Add", queryId: query.queryId, udfPath: query.udfPath, args: query.args });
104
- query.onServer = true;
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
- if (modifications.length === 0) return { message: null, removed };
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 baseVersion = this.querySetVersion;
112
- this.querySetVersion += 1;
113
- return {
114
- message: { type: "ModifyQuerySet", baseVersion, newVersion: this.querySetVersion, modifications },
115
- removed,
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.11.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;