@rxferrix/client 0.2.0 → 0.4.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/auth.ts CHANGED
@@ -5,10 +5,14 @@
5
5
  * ```ts
6
6
  * const client = new RxFerrixClient("http://localhost:8090");
7
7
  * const auth = getAuth(client); // restores a stored session, if any
8
- * await auth.signIn({ email, password });
8
+ * const r = await auth.signIn({ email, password });
9
+ * if (r.mfaRequired) await auth.verifyMfa(r.mfaToken, codeFromAuthenticatorApp);
9
10
  * auth.subscribe(() => console.log(auth.state())); // { isLoading, isAuthenticated, userId, isAdmin }
10
11
  * ```
11
12
  *
13
+ * Two-factor authentication (an authenticator app, with recovery codes) is per account: `setupTotp()` +
14
+ * `confirmTotp(code)` turn it on, `getMfaStatus()`, `disableMfa(code)` and `regenerateRecoveryCodes(code)` manage it.
15
+ *
12
16
  * Tokens are kept in `localStorage` (or the `storage` you pass; an in-memory store when neither exists, e.g. SSR).
13
17
  * The access token is short-lived (15 minutes by default) and refreshed with the refresh token shortly before it
14
18
  * expires and whenever the server rejects it. Refresh tokens rotate on every use; refreshes are serialized across
@@ -76,9 +80,39 @@ export interface AuthTokens {
76
80
  /** Proof of email ownership: the token from an email link, or the email address plus the code from the email. */
77
81
  export type CodeProof = { token: string } | { email: string; code: string };
78
82
 
83
+ /**
84
+ * How a sign-in ended. With two-factor authentication enabled on the account, the first step (password, magic link,
85
+ * OAuth, password reset) returns `mfaRequired` with a short-lived `mfaToken` instead of signing in; finish with
86
+ * {@link RxFerrixAuth.verifyMfa}`(mfaToken, code)`, where `code` is from the authenticator app or a recovery code.
87
+ */
88
+ export type SignInResult = { mfaRequired: false } | { mfaRequired: true; mfaToken: string; methods: string[] };
89
+
90
+ /** The account's two-factor authentication (`GET /api/auth/mfa`). */
91
+ export interface MfaStatus {
92
+ /** Sign-ins need a code. */
93
+ enabled: boolean;
94
+ /** A setup was started but not confirmed with a first code yet. */
95
+ pending: boolean;
96
+ recoveryCodesLeft: number;
97
+ /** The enabled factors (`"totp"`). */
98
+ methods: string[];
99
+ }
100
+
101
+ /** A started authenticator (TOTP) setup: show the QR code (or the secret) and ask for the first code. */
102
+ export interface MfaSetup {
103
+ /** The secret in base32, for entering by hand. */
104
+ secret: string;
105
+ /** The `otpauth://totp/...` URI the QR code encodes. */
106
+ uri: string;
107
+ /** The QR code as an `<svg>` element (inline it; it scales to its container). */
108
+ qrSvg: string;
109
+ }
110
+
79
111
  /** What {@link RxFerrixAuth.handleRedirectCallback} found in the URL. */
80
112
  export type RedirectResult =
81
113
  | { type: "signedIn" }
114
+ /** The account has two-factor authentication: ask for a code, then `verifyMfa(mfaToken, code)`. */
115
+ | { type: "mfaRequired"; mfaToken: string }
82
116
  | { type: "emailVerified" }
83
117
  /** A password reset link: ask for a new password, then call `resetPassword({ token }, password)`. */
84
118
  | { type: "passwordReset"; token: string }
@@ -119,6 +153,13 @@ export const systemApi = {
119
153
  },
120
154
  } as const;
121
155
 
156
+ /** The server's answer to a sign-in that needs a second factor. */
157
+ interface MfaRequiredResponse {
158
+ mfaRequired: true;
159
+ mfaToken: string;
160
+ methods?: string[];
161
+ }
162
+
122
163
  interface StoredTokens {
123
164
  accessToken: string;
124
165
  refreshToken: string;
@@ -465,14 +506,92 @@ export class RxFerrixAuth {
465
506
  return { verificationRequired: true };
466
507
  }
467
508
 
468
- /** Sign in with email and password. Rejects with code `"invalid_credentials"` on a wrong email or password. */
469
- async signIn(args: { email: string; password: string }): Promise<void> {
470
- await this.signedIn(await this.post<AuthTokens>("signin", args));
509
+ /** Tokens: signed in. A second-factor token: the caller finishes with {@link verifyMfa}. */
510
+ private async signedInOrMfa(res: AuthTokens | MfaRequiredResponse): Promise<SignInResult> {
511
+ if ("mfaRequired" in res && res.mfaRequired) {
512
+ return { mfaRequired: true, mfaToken: res.mfaToken, methods: res.methods ?? ["totp"] };
513
+ }
514
+ await this.signedIn(res as AuthTokens);
515
+ return { mfaRequired: false };
516
+ }
517
+
518
+ /**
519
+ * Sign in with email and password. Rejects with code `"invalid_credentials"` on a wrong email or password. With
520
+ * two-factor authentication on the account, resolves `{ mfaRequired: true, mfaToken }` without signing in: finish
521
+ * with {@link verifyMfa}.
522
+ */
523
+ async signIn(args: { email: string; password: string }): Promise<SignInResult> {
524
+ return this.signedInOrMfa(await this.post("signin", args));
525
+ }
526
+
527
+ /**
528
+ * Sign in to an admin account (dashboard). Functions then see `ctx.auth.is_admin()`. Resolves `mfaRequired` like
529
+ * {@link signIn} when the administrator has two-factor authentication.
530
+ */
531
+ async signInAsAdmin(args: { email: string; password: string }): Promise<SignInResult> {
532
+ return this.signedInOrMfa(await this.post("admin/signin", args));
533
+ }
534
+
535
+ /**
536
+ * The second step of a sign-in: the `mfaToken` of a `mfaRequired` result plus a code from the authenticator app
537
+ * (or a recovery code) sign in. Rejects with `invalid_mfa_code` (wrong or already used), `mfa_locked` (too many
538
+ * wrong codes; wait) or `invalid_mfa_token` (the sign-in expired after 5 minutes: start again).
539
+ */
540
+ async verifyMfa(mfaToken: string, code: string): Promise<void> {
541
+ await this.signedIn(await this.post<AuthTokens>("mfa/verify", { mfaToken, code }));
542
+ }
543
+
544
+ // ---------------------------------------------------------------------------------------------------------------
545
+ // Two-factor authentication of the signed-in account
546
+
547
+ private async bearer(): Promise<string> {
548
+ const token = await this.getAccessToken();
549
+ if (!token) throw new RxFerrixError("Sign in first", { code: "unauthenticated" });
550
+ return token;
551
+ }
552
+
553
+ /** Whether the signed-in account has two-factor authentication, and how many recovery codes are left. */
554
+ async getMfaStatus(): Promise<MfaStatus> {
555
+ const token = await this.bearer();
556
+ let res: Response;
557
+ try {
558
+ res = await this.fetchImpl(`${this.baseUrl}/api/auth/mfa`, { headers: { Authorization: `Bearer ${token}` } });
559
+ } catch (e) {
560
+ throw new RxFerrixError("Could not reach the server", { code: "network_error", cause: e });
561
+ }
562
+ const json = (await res.json().catch(() => null)) as unknown;
563
+ if (!res.ok) throw errorFrom(res.status, json);
564
+ return json as MfaStatus;
565
+ }
566
+
567
+ /**
568
+ * Start setting up an authenticator app (TOTP) for the signed-in account: returns the QR code and secret to
569
+ * enroll with; nothing changes until {@link confirmTotp} gets the app's first code. Accounts with a password
570
+ * must give it (`password_required`, `invalid_current_password`); `mfa_already_enabled` when it is on already.
571
+ */
572
+ async setupTotp(args: { password?: string } = {}): Promise<MfaSetup> {
573
+ return this.post<MfaSetup>("mfa/totp/setup", args, await this.bearer());
574
+ }
575
+
576
+ /**
577
+ * Confirm the setup with the authenticator's first code. From then on, sign-ins need a code. Resolves the recovery
578
+ * codes: show them once and tell the user to keep them; each works once in place of a code. `invalid_mfa_code`.
579
+ */
580
+ async confirmTotp(code: string): Promise<{ recoveryCodes: string[] }> {
581
+ return this.post("mfa/totp/confirm", { code }, await this.bearer());
582
+ }
583
+
584
+ /**
585
+ * Turn two-factor authentication off for the signed-in account. Needs a current code (or a recovery code) once the
586
+ * factor is confirmed; a pending setup is dropped without one. `invalid_mfa_code`, `mfa_not_enabled`.
587
+ */
588
+ async disableMfa(code?: string): Promise<void> {
589
+ await this.post("mfa/disable", code ? { code } : {}, await this.bearer());
471
590
  }
472
591
 
473
- /** Sign in to an admin account (dashboard). Functions then see `ctx.auth.is_admin()`. */
474
- async signInAsAdmin(args: { email: string; password: string }): Promise<void> {
475
- await this.signedIn(await this.post<AuthTokens>("admin/signin", args));
592
+ /** Replace the recovery codes (a current code or recovery code proves it's the user); the old ones stop working. */
593
+ async regenerateRecoveryCodes(code: string): Promise<{ recoveryCodes: string[] }> {
594
+ return this.post("mfa/recovery-codes", { code }, await this.bearer());
476
595
  }
477
596
 
478
597
  /** Whether the server has no administrator yet (`GET /api/auth/admin/setup`). */
@@ -522,19 +641,20 @@ export class RxFerrixAuth {
522
641
  await this.post("reset/request", args);
523
642
  }
524
643
 
525
- /** Set a new password with the reset link token or code; ends other sessions and signs in. */
526
644
  /**
527
645
  * Change the signed-in user's password; `currentPassword` proves it's them. Their other sessions end, this one stays
528
646
  * signed in. Fails with an `RxFerrixError` code such as `invalid_current_password` or `weak_password`.
529
647
  */
530
648
  async changePassword(currentPassword: string, newPassword: string): Promise<void> {
531
- const token = await this.getAccessToken();
532
- if (!token) throw new RxFerrixError("Sign in first", { code: "unauthenticated" });
533
- await this.post("password/change", { currentPassword, newPassword }, token);
649
+ await this.post("password/change", { currentPassword, newPassword }, await this.bearer());
534
650
  }
535
651
 
536
- async resetPassword(proof: CodeProof, password: string): Promise<void> {
537
- await this.signedIn(await this.post<AuthTokens>("reset/confirm", { ...proof, password }));
652
+ /**
653
+ * Set a new password with the reset link token or code; ends other sessions and signs in (or resolves
654
+ * `mfaRequired` like {@link signIn}: a reset email doesn't bypass the second factor).
655
+ */
656
+ async resetPassword(proof: CodeProof, password: string): Promise<SignInResult> {
657
+ return this.signedInOrMfa(await this.post("reset/confirm", { ...proof, password }));
538
658
  }
539
659
 
540
660
  /** Email a magic sign-in link and a one-time code. Succeeds whether or not the account exists. */
@@ -542,14 +662,17 @@ export class RxFerrixAuth {
542
662
  await this.post("magic/request", args);
543
663
  }
544
664
 
545
- /** Sign in with the one-time code from a magic link email. */
546
- async verifyCode(args: { email: string; code: string }): Promise<void> {
547
- await this.signedIn(await this.post<AuthTokens>("magic/verify", args));
665
+ /** Sign in with the one-time code from a magic link email (or resolve `mfaRequired`, as {@link signIn}). */
666
+ async verifyCode(args: { email: string; code: string }): Promise<SignInResult> {
667
+ return this.signedInOrMfa(await this.post("magic/verify", args));
548
668
  }
549
669
 
550
- /** Sign in with the token of a magic link (usually done by {@link handleRedirectCallback}). */
551
- async verifyMagicLink(token: string): Promise<void> {
552
- await this.signedIn(await this.post<AuthTokens>("magic/verify", { token }));
670
+ /**
671
+ * Sign in with the token of a magic link (usually done by {@link handleRedirectCallback}); resolves `mfaRequired`
672
+ * as {@link signIn} does.
673
+ */
674
+ async verifyMagicLink(token: string): Promise<SignInResult> {
675
+ return this.signedInOrMfa(await this.post("magic/verify", { token }));
553
676
  }
554
677
 
555
678
  /** The URL that starts an OAuth sign-in with `provider`, returning to `redirectTo` (default: this page). */
@@ -573,9 +696,10 @@ export class RxFerrixAuth {
573
696
 
574
697
  /**
575
698
  * Handle the `#rxferrix_…` fragment that OAuth redirects and email links put on the URL: stores tokens (OAuth),
576
- * signs in (magic link), confirms the email (verification link) or returns the reset token (password reset). The
577
- * `rxferrix_` parameters are removed from the address bar first, so a reload can't replay them. Returns null when
578
- * the URL has none.
699
+ * signs in (magic link), confirms the email (verification link) or returns the reset token (password reset). With
700
+ * two-factor authentication on the account, OAuth and magic links end in `{ type: "mfaRequired", mfaToken }`:
701
+ * ask for a code and call {@link verifyMfa}. The `rxferrix_` parameters are removed from the address bar first,
702
+ * so a reload can't replay them. Returns null when the URL has none.
579
703
  */
580
704
  async handleRedirectCallback(url?: string): Promise<RedirectResult | null> {
581
705
  const g = globalThis as {
@@ -610,10 +734,12 @@ export class RxFerrixAuth {
610
734
  await this.signedIn({ accessToken: access, refreshToken: refresh, expiresIn, ...(userId ? { userId } : {}) });
611
735
  return { type: "signedIn" };
612
736
  }
737
+ const mfaToken = ours.get("rxferrix_mfa_token");
738
+ if (mfaToken) return { type: "mfaRequired", mfaToken };
613
739
  const magic = ours.get("rxferrix_magic_token");
614
740
  if (magic) {
615
- await this.verifyMagicLink(magic);
616
- return { type: "signedIn" };
741
+ const r = await this.verifyMagicLink(magic);
742
+ return r.mfaRequired ? { type: "mfaRequired", mfaToken: r.mfaToken } : { type: "signedIn" };
617
743
  }
618
744
  const verify = ours.get("rxferrix_verify_token");
619
745
  if (verify) {
package/src/client.ts CHANGED
@@ -136,7 +136,25 @@ function jwtExpiry(token: string): number | null {
136
136
 
137
137
  /** Refresh this long before a JWT's `exp`. */
138
138
  const TOKEN_REFRESH_LEEWAY_MS = 30_000;
139
+ /** Never refresh more often than this, even for a token that expires within the leeway (its session may be ending). */
140
+ const MIN_TOKEN_REFRESH_DELAY_MS = 5_000;
141
+ /** A token getter that threw (the server unreachable at page load, ...) is retried with this backoff. */
142
+ const TOKEN_FETCH_RETRY_BASE_MS = 2_000;
143
+ const TOKEN_FETCH_RETRY_MAX_MS = 60_000;
139
144
  const MAX_TIMEOUT_MS = 2_147_483_647;
145
+ /** The server's limit of live queries per socket; past it, a `ModifyQuerySet` is a fatal error (docs/PROTOCOL.md). */
146
+ export const MAX_LIVE_QUERIES = 1024;
147
+
148
+ /** The error for function args that can't be encoded as JSON (a cycle, a BigInt, ...), or null. */
149
+ function unserializable(args: Record<string, unknown>): RxFerrixError | null {
150
+ try {
151
+ JSON.stringify(args);
152
+ return null;
153
+ } catch (e) {
154
+ const detail = e instanceof Error ? e.message : String(e);
155
+ return new RxFerrixError(`Arguments can't be serialized as JSON: ${detail}`, { code: "invalid_args", cause: e });
156
+ }
157
+ }
140
158
 
141
159
  interface AuthState {
142
160
  fetchToken: AuthTokenFetcher;
@@ -192,6 +210,11 @@ export class RxFerrixClient {
192
210
  private isAuthenticated = false;
193
211
  private authGeneration = 0;
194
212
  private authRefreshTimer: ReturnType<typeof setTimeout> | null = null;
213
+ /** Token fetches in flight: the last one to finish lets held-back sends through again. */
214
+ private tokenFetchesInFlight = 0;
215
+ private tokenFetchFailures = 0;
216
+ /** Rejecters of `query()` calls still waiting for their first result (settled on `close()`). */
217
+ private readonly oneShotQueries = new Set<(e: RxFerrixError) => void>();
195
218
 
196
219
  private readonly removeUnloadListener: () => void;
197
220
 
@@ -256,15 +279,20 @@ export class RxFerrixClient {
256
279
  return new Promise<OutputOf<Q>>((resolve, reject) => {
257
280
  let unsubscribe: (() => void) | null = null;
258
281
  let done = false;
259
- const check = () => {
282
+ const finish = (settle: () => void) => {
260
283
  if (done) return;
261
- const state = watch.localQueryState();
262
- if (state.status === "loading") return;
263
284
  done = true;
285
+ this.oneShotQueries.delete(closed);
264
286
  if (unsubscribe) unsubscribe();
265
- if (state.status === "success") resolve(state.value);
266
- else reject(state.error);
287
+ settle();
267
288
  };
289
+ const closed = (e: RxFerrixError) => finish(() => reject(e));
290
+ const check = () => {
291
+ const state = watch.localQueryState();
292
+ if (state.status === "loading") return;
293
+ finish(() => (state.status === "success" ? resolve(state.value) : reject(state.error)));
294
+ };
295
+ this.oneShotQueries.add(closed);
268
296
  unsubscribe = watch.onUpdate(check);
269
297
  check();
270
298
  });
@@ -294,6 +322,13 @@ export class RxFerrixClient {
294
322
  callback: () => void,
295
323
  ): () => void {
296
324
  if (this.closed) return () => {};
325
+ if (!this.localQueries.get(token) && this.localQueries.size >= MAX_LIVE_QUERIES) {
326
+ // The server would answer the subscription with a fatal error and every reconnect would repeat it.
327
+ throw new RxFerrixError(
328
+ `Too many live queries: at most ${MAX_LIVE_QUERIES} distinct queries can be watched at once`,
329
+ { code: "too_many_queries" },
330
+ );
331
+ }
297
332
  let set = this.listeners.get(token);
298
333
  if (!set) {
299
334
  set = new Set();
@@ -349,6 +384,10 @@ export class RxFerrixClient {
349
384
  if (this.closed) return Promise.reject(new RxFerrixError("Client is closed", { code: CLIENT_CLOSED }));
350
385
  const args = normalizeArgs(argsAndOptions[0]);
351
386
  const options = argsAndOptions[1] as MutationOptions<ArgsOf<M>> | undefined;
387
+ // Checked before it is queued: a message that can't be encoded would otherwise be retried on every socket and
388
+ // block every later request behind it.
389
+ const invalid = unserializable(args);
390
+ if (invalid) return Promise.reject(invalid);
352
391
  const requestId = this.requests.allocateRequestId();
353
392
  const message: MutationMessage = { type: "Mutation", requestId, udfPath: ref._path, args };
354
393
  const promise = this.requests.trackMutation(message) as Promise<OutputOf<M>>;
@@ -366,8 +405,11 @@ export class RxFerrixClient {
366
405
  /** Runs an action. Rejects with a "connection lost" error if the socket drops while it is in flight. */
367
406
  action<A extends FunctionReference<"action">>(ref: A, ...args: OptionalArgs<A>): Promise<OutputOf<A>> {
368
407
  if (this.closed) return Promise.reject(new RxFerrixError("Client is closed", { code: CLIENT_CLOSED }));
408
+ const normalized = normalizeArgs(args[0]);
409
+ const invalid = unserializable(normalized);
410
+ if (invalid) return Promise.reject(invalid);
369
411
  const requestId = this.requests.allocateRequestId();
370
- const message: ActionMessage = { type: "Action", requestId, udfPath: ref._path, args: normalizeArgs(args[0]) };
412
+ const message: ActionMessage = { type: "Action", requestId, udfPath: ref._path, args: normalized };
371
413
  const promise = this.requests.trackAction(message) as Promise<OutputOf<A>>;
372
414
  this.sendUnsentRequests();
373
415
  this.notifyConnectionState();
@@ -414,18 +456,44 @@ export class RxFerrixClient {
414
456
  if (!auth) return;
415
457
  const generation = ++this.authGeneration;
416
458
  if (pause) this.ws.pause();
417
- let token: string | null = null;
459
+ this.tokenFetchesInFlight++;
418
460
  try {
419
- token = (await auth.fetchToken({ forceRefresh })) ?? null;
420
- } catch (e) {
421
- this.logger.error("fetchToken threw; continuing unauthenticated", e);
422
- token = null;
461
+ let token: string | null;
462
+ try {
463
+ token = (await auth.fetchToken({ forceRefresh })) ?? null;
464
+ } catch (e) {
465
+ if (generation !== this.authGeneration || this.auth !== auth || this.closed) return;
466
+ // The getter failed (the server unreachable at page load, a refresh that timed out): the session it would
467
+ // have returned may well be valid, so the token in hand (if any) is kept and the fetch is retried with
468
+ // backoff instead of treating the user as signed out.
469
+ this.tokenFetchFailures++;
470
+ const delay = Math.min(
471
+ TOKEN_FETCH_RETRY_MAX_MS,
472
+ TOKEN_FETCH_RETRY_BASE_MS * 2 ** Math.min(this.tokenFetchFailures - 1, 10),
473
+ );
474
+ this.logger.warn(`fetchToken failed; retrying in ${delay}ms`, e);
475
+ this.scheduleTokenFetch(delay, forceRefresh);
476
+ return;
477
+ }
478
+ if (generation !== this.authGeneration || this.auth !== auth || this.closed) return;
479
+ this.tokenFetchFailures = 0;
480
+ this.applyToken(token);
481
+ if (token === null) this.setAuthenticated(false, auth.onChange);
482
+ this.scheduleTokenRefresh(token);
483
+ } finally {
484
+ this.tokenFetchesInFlight--;
485
+ // Whichever fetch finishes last lets sends through again: a superseded fetch (a newer `setAuth` or
486
+ // `clearAuth`) must not leave the socket paused for good.
487
+ if (this.tokenFetchesInFlight === 0 && this.ws.isPaused && !this.closed) this.ws.resume();
423
488
  }
424
- if (generation !== this.authGeneration || this.auth !== auth || this.closed) return;
425
- this.applyToken(token);
426
- if (token === null) this.setAuthenticated(false, auth.onChange);
427
- this.scheduleTokenRefresh(token);
428
- if (pause) this.ws.resume();
489
+ }
490
+
491
+ private scheduleTokenFetch(delay: number, forceRefresh: boolean): void {
492
+ this.clearAuthRefreshTimer();
493
+ this.authRefreshTimer = setTimeout(() => {
494
+ this.authRefreshTimer = null;
495
+ void this.fetchAndApplyToken(forceRefresh, false);
496
+ }, delay);
429
497
  }
430
498
 
431
499
  /** Records the desired token and sends `Authenticate` when the server's token differs. */
@@ -449,11 +517,13 @@ export class RxFerrixClient {
449
517
  if (!token) return;
450
518
  const exp = jwtExpiry(token);
451
519
  if (exp === null) return;
452
- const delay = Math.min(MAX_TIMEOUT_MS, Math.max(0, exp * 1000 - Date.now() - TOKEN_REFRESH_LEEWAY_MS));
453
- this.authRefreshTimer = setTimeout(() => {
454
- this.authRefreshTimer = null;
455
- void this.fetchAndApplyToken(true, false);
456
- }, delay);
520
+ // Floored: a token that already expires within the leeway (its session is about to end) would otherwise be
521
+ // refreshed again the moment the refreshed one arrives, in a tight loop.
522
+ const delay = Math.min(
523
+ MAX_TIMEOUT_MS,
524
+ Math.max(MIN_TOKEN_REFRESH_DELAY_MS, exp * 1000 - Date.now() - TOKEN_REFRESH_LEEWAY_MS),
525
+ );
526
+ this.scheduleTokenFetch(delay, true);
457
527
  }
458
528
 
459
529
  private clearAuthRefreshTimer(): void {
@@ -706,7 +776,11 @@ export class RxFerrixClient {
706
776
  this.clearAuthRefreshTimer();
707
777
  this.ws.stop();
708
778
  this.removeUnloadListener();
709
- this.requests.rejectAll(new RxFerrixError("Client is closed", { code: CLIENT_CLOSED }));
779
+ const closed = new RxFerrixError("Client is closed", { code: CLIENT_CLOSED });
780
+ this.requests.rejectAll(closed);
781
+ // One-shot queries still waiting for a first result would never settle once the listeners are gone.
782
+ for (const reject of [...this.oneShotQueries]) reject(closed);
783
+ this.oneShotQueries.clear();
710
784
  this.listeners.clear();
711
785
  this.notifyConnectionState();
712
786
  }
package/src/index.ts CHANGED
@@ -36,6 +36,9 @@ export {
36
36
  type CodeProof,
37
37
  type RxFerrixAuthOptions,
38
38
  type RedirectResult,
39
+ type SignInResult,
40
+ type MfaStatus,
41
+ type MfaSetup,
39
42
  type User,
40
43
  } from "./auth.js";
41
44
  export type { OptimisticLocalStore, OptimisticUpdate } from "./optimistic_updates.js";
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.2.0";
9
+ export const CLIENT_VERSION = "0.4.0";
10
10
 
11
11
  export interface StateVersion {
12
12
  querySet: number;
@@ -5,6 +5,11 @@ 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
+ /**
9
+ * A socket that hasn't opened after this long is dropped and retried (a black-holed connect would otherwise hang
10
+ * forever). As long as the silence threshold: a server that takes longer than that to accept is as good as down.
11
+ */
12
+ export const CONNECT_TIMEOUT_MS = 30_000;
8
13
  /** The close code a server sends when it restarts or hands over to a new process (RFC 6455 "Service Restart"). */
9
14
  const SERVICE_RESTART = 1012;
10
15
  /** Spread of the immediate reconnects after a restart, so every client doesn't arrive in the same millisecond. */
@@ -62,6 +67,7 @@ export class WebSocketManager {
62
67
  private retries = 0;
63
68
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
64
69
  private watchdogTimer: ReturnType<typeof setTimeout> | null = null;
70
+ private connectTimer: ReturnType<typeof setTimeout> | null = null;
65
71
  private readonly removeEnvListeners: () => void;
66
72
 
67
73
  constructor(options: WebSocketManagerOptions, callbacks: WebSocketManagerCallbacks) {
@@ -157,9 +163,18 @@ export class WebSocketManager {
157
163
  private rawSend(message: ClientMessage): boolean {
158
164
  const ws = this.ws;
159
165
  if (!ws) return false;
166
+ let data: string;
167
+ try {
168
+ data = JSON.stringify(message);
169
+ } catch (e) {
170
+ // Not a transport failure: reconnecting wouldn't help, and queueing the message would block every later one
171
+ // behind it. (Mutation and action args are checked before they are queued; this is the last line of defense.)
172
+ this.logger.error("message can't be serialized and was dropped", e);
173
+ return true;
174
+ }
160
175
  try {
161
176
  this.logger.debug("->", message);
162
- ws.send(JSON.stringify(message));
177
+ ws.send(data);
163
178
  return true;
164
179
  } catch (e) {
165
180
  this.logger.warn("WebSocket send failed", e);
@@ -185,9 +200,15 @@ export class WebSocketManager {
185
200
  this.initialized = false;
186
201
  this._connectionCount += 1;
187
202
  this.callbacks.onStateChange();
203
+ this.clearConnectTimer();
204
+ this.connectTimer = setTimeout(() => {
205
+ this.connectTimer = null;
206
+ if (this.ws === ws && this._state === "connecting") this.reconnect("ConnectTimeout");
207
+ }, CONNECT_TIMEOUT_MS);
188
208
 
189
209
  ws.onopen = () => {
190
210
  if (this.ws !== ws) return;
211
+ this.clearConnectTimer();
191
212
  this._state = "ready";
192
213
  this._hasEverConnected = true;
193
214
  this.resetWatchdog();
@@ -208,6 +229,8 @@ export class WebSocketManager {
208
229
  ws.onmessage = (event: MessageEvent) => {
209
230
  if (this.ws !== ws) return;
210
231
  this.resetWatchdog();
232
+ // The server talks on this socket: it is up, so the next reconnect (if any) starts from a short delay.
233
+ this.retries = 0;
211
234
  let message: ServerMessage;
212
235
  try {
213
236
  message = JSON.parse(typeof event.data === "string" ? event.data : String(event.data)) as ServerMessage;
@@ -238,6 +261,7 @@ export class WebSocketManager {
238
261
  /** Detaches and closes the current socket (if any), reporting `onClose` once. */
239
262
  private closeCurrent(reason: string): void {
240
263
  this.clearWatchdog();
264
+ this.clearConnectTimer();
241
265
  const ws = this.ws;
242
266
  if (!ws) return;
243
267
  const wasOpen = this._state === "ready";
@@ -291,6 +315,13 @@ export class WebSocketManager {
291
315
  }, SERVER_INACTIVITY_TIMEOUT_MS);
292
316
  }
293
317
 
318
+ private clearConnectTimer(): void {
319
+ if (this.connectTimer !== null) {
320
+ clearTimeout(this.connectTimer);
321
+ this.connectTimer = null;
322
+ }
323
+ }
324
+
294
325
  private clearWatchdog(): void {
295
326
  if (this.watchdogTimer !== null) {
296
327
  clearTimeout(this.watchdogTimer);