@rxferrix/client 0.2.0 → 0.3.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 +103 -14
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +103 -20
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +8 -0
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +95 -28
- package/dist/client.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/protocol.d.ts +1 -1
- package/dist/protocol.js +1 -1
- package/dist/web_socket_manager.d.ts +7 -0
- package/dist/web_socket_manager.d.ts.map +1 -1
- package/dist/web_socket_manager.js +33 -1
- package/dist/web_socket_manager.js.map +1 -1
- package/package.json +1 -1
- package/src/auth.ts +150 -24
- package/src/client.ts +96 -22
- package/src/index.ts +3 -0
- package/src/protocol.ts +1 -1
- package/src/web_socket_manager.ts +32 -1
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
|
-
/**
|
|
469
|
-
async
|
|
470
|
-
|
|
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
|
-
/**
|
|
474
|
-
async
|
|
475
|
-
|
|
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
|
-
|
|
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
|
-
|
|
537
|
-
|
|
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<
|
|
547
|
-
|
|
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
|
-
/**
|
|
551
|
-
|
|
552
|
-
|
|
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).
|
|
577
|
-
*
|
|
578
|
-
* the
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
459
|
+
this.tokenFetchesInFlight++;
|
|
418
460
|
try {
|
|
419
|
-
token
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
this.
|
|
428
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
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.
|
|
9
|
+
export const CLIENT_VERSION = "0.3.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(
|
|
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);
|