@reatomized/convex 0.2.0 → 0.3.1
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/README.md +10 -5
- package/dist/browser/reatom/auth.d.ts +1 -1
- package/dist/browser/reatom/auth.js +38 -35
- package/dist/browser/reatom/better_auth.d.ts +25 -8
- package/dist/browser/reatom/better_auth.js +37 -15
- package/dist/browser/reatom/client.js +13 -10
- package/dist/browser/reatom/connection_hooks.d.ts +4 -8
- package/dist/browser/reatom/connection_hooks.js +8 -13
- package/dist/browser/reatom/convex_auth.d.ts +8 -1
- package/dist/browser/reatom/convex_auth.js +21 -20
- package/dist/browser/reatom/socket.js +3 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -24,11 +24,11 @@ import type { Id } from "../convex/_generated/dataModel";
|
|
|
24
24
|
|
|
25
25
|
const convex = reatomConvexClient(import.meta.env.VITE_CONVEX_URL);
|
|
26
26
|
|
|
27
|
-
const noteId = atom<Id<"notes"> | null>(null);
|
|
27
|
+
const noteId = atom<Id<"notes"> | null>(null, "noteId");
|
|
28
28
|
const note = computed(() => {
|
|
29
29
|
const id = noteId();
|
|
30
30
|
return id ? convex.query(api.notes.get, { id }).data() : undefined;
|
|
31
|
-
});
|
|
31
|
+
}, "note");
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
- `convex.query(ref, args)` returns the entry for that function and arguments,
|
|
@@ -83,6 +83,8 @@ await convexAuth.signOut();
|
|
|
83
83
|
whose own client is a React provider. It uses the same storage keys, so sessions
|
|
84
84
|
carry over from `@convex-dev/auth/react`. On start it signs in with a `code`
|
|
85
85
|
from the URL, and it refreshes the token once across tabs.
|
|
86
|
+
`completeSignIn(code)` runs that exchange for an app that reads the URL itself;
|
|
87
|
+
its `error` atom holds a failed exchange either way.
|
|
86
88
|
|
|
87
89
|
```ts
|
|
88
90
|
// Better Auth with @convex-dev/better-auth
|
|
@@ -104,7 +106,9 @@ await authClient.signIn.email({ email, password });
|
|
|
104
106
|
Better Auth's client works without a framework, so you sign in through it;
|
|
105
107
|
`reatomBetterAuthProvider` follows its session store, fetches Convex tokens, and
|
|
106
108
|
trades the `ott` one-time token from a cross-domain OAuth redirect for the
|
|
107
|
-
session.
|
|
109
|
+
session. Its `data` atom is the store's session and user, typed by your client,
|
|
110
|
+
and `completeSignIn(ott)` is the one-time token trade with an `error` atom.
|
|
111
|
+
Neither entry point adds dependencies.
|
|
108
112
|
|
|
109
113
|
For another library, pass any object with `isLoading` and `session` atoms and a
|
|
110
114
|
`fetchToken` function. `session` should change when the identity does, such as a
|
|
@@ -149,8 +153,9 @@ Node 22 and later have a global `WebSocket`. On Node 20, pass one, such as the
|
|
|
149
153
|
convex/browser's `BaseConvexClient`. The Reatom functions it uses add 4.6 KiB,
|
|
150
154
|
shared with the rest of the app.
|
|
151
155
|
|
|
152
|
-
With 1000 components each running its own query in Chromium
|
|
153
|
-
ms against
|
|
156
|
+
With 1000 components each running its own query in Chromium and @reatom/jsx
|
|
157
|
+
rendering, mounting takes 6.28 ms against 54.2 ms for convex/react, unmounting
|
|
158
|
+
0.75 ms against 7.93 ms.
|
|
154
159
|
[CONTRIBUTING.md](https://github.com/reatomized/convex/blob/reatom/CONTRIBUTING.md#performance)
|
|
155
160
|
has the full table and how it was measured.
|
|
156
161
|
|
|
@@ -67,7 +67,7 @@ export declare function reatomAuth(sync: AuthSync, options: {
|
|
|
67
67
|
}, name: string): {
|
|
68
68
|
/** The auth state machine. */
|
|
69
69
|
state: import("@reatom/core").Atom<AuthState, [newState: AuthState]>;
|
|
70
|
-
setConfig(fetchToken: AuthTokenFetcher, onChange: (isAuthenticated: boolean) => void, onRefreshChange?: ((isRefreshing: boolean) => void) | undefined)
|
|
70
|
+
setConfig: import("@reatom/core").GAction<(fetchToken: AuthTokenFetcher, onChange: (isAuthenticated: boolean) => void, onRefreshChange?: ((isRefreshing: boolean) => void) | undefined) => Promise<void>>;
|
|
71
71
|
onTransition(serverMessage: Transition): void;
|
|
72
72
|
onAuthError(serverMessage: AuthError): void;
|
|
73
73
|
stop(): void;
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* server's clock skew. When the server rejects a token, the socket stops, a
|
|
11
11
|
* fresh token is fetched and sent, and the socket restarts.
|
|
12
12
|
*/
|
|
13
|
-
import { atom, context, peek, wrap } from "@reatom/core";
|
|
13
|
+
import { action, atom, context, peek, wrap } from "@reatom/core";
|
|
14
14
|
import { jwtDecode } from "../../vendor/jwt-decode/index.js";
|
|
15
15
|
// setTimeout takes a 32-bit delay, about 24 days.
|
|
16
16
|
const MAXIMUM_REFRESH_DELAY = 20 * 24 * 60 * 60 * 1000; // 20 days
|
|
@@ -124,6 +124,8 @@ export function reatomAuth(sync, options, name) {
|
|
|
124
124
|
logger.warn(`Refetching auth token immediately, configured leeway ${refreshTokenLeewaySeconds}s is larger than the token's lifetime ${tokenValiditySeconds}s`);
|
|
125
125
|
delay = 0;
|
|
126
126
|
}
|
|
127
|
+
// Owned by the auth state machine, not a query subscription. Changing
|
|
128
|
+
// config or stopping auth cancels this in resetAuthState.
|
|
127
129
|
const refetchTokenTimeoutId = setTimeout(wrap(() => {
|
|
128
130
|
logVerbose("running scheduled token refetch");
|
|
129
131
|
void refetchToken();
|
|
@@ -137,7 +139,7 @@ export function reatomAuth(sync, options, name) {
|
|
|
137
139
|
};
|
|
138
140
|
// Force-fetches a token and schedules the next refetch once the server
|
|
139
141
|
// confirms it, so an active client never has to reauthenticate.
|
|
140
|
-
const refetchToken = async () => {
|
|
142
|
+
const refetchToken = action(async () => {
|
|
141
143
|
const current = state();
|
|
142
144
|
if (current.state === "noAuth")
|
|
143
145
|
return;
|
|
@@ -171,10 +173,10 @@ export function reatomAuth(sync, options, name) {
|
|
|
171
173
|
// In case a scheduled refetch ran during a reauthentication.
|
|
172
174
|
logVerbose("restarting WS after auth token fetch (if currently stopped)");
|
|
173
175
|
sync.tryRestartSocket();
|
|
174
|
-
};
|
|
176
|
+
}, `${name}._refetchToken`);
|
|
175
177
|
// Like `refetchToken`, but with the socket stopped so requests don't retry
|
|
176
178
|
// with the rejected token.
|
|
177
|
-
const tryToReauthenticate = async (serverMessage) => {
|
|
179
|
+
const tryToReauthenticate = action(async (serverMessage) => {
|
|
178
180
|
logVerbose(`attempting to reauthenticate: ${serverMessage.error}`);
|
|
179
181
|
const current = state();
|
|
180
182
|
const rt = peek(runtime);
|
|
@@ -230,40 +232,41 @@ export function reatomAuth(sync, options, name) {
|
|
|
230
232
|
setAndReportAuthFailed(latest.config.onAuthChange);
|
|
231
233
|
}
|
|
232
234
|
sync.tryRestartSocket();
|
|
233
|
-
};
|
|
235
|
+
}, `${name}._reauthenticate`);
|
|
236
|
+
const setConfig = action(async (fetchToken, onChange, onRefreshChange) => {
|
|
237
|
+
resetAuthState();
|
|
238
|
+
logVerbose("pausing WS for auth token fetch");
|
|
239
|
+
sync.pauseSocket();
|
|
240
|
+
const token = await wrap(fetchTokenAndGuardAgainstRace(fetchToken, {
|
|
241
|
+
forceRefreshToken: false,
|
|
242
|
+
}));
|
|
243
|
+
if (token.isFromOutdatedConfig)
|
|
244
|
+
return;
|
|
245
|
+
const config = {
|
|
246
|
+
fetchToken,
|
|
247
|
+
onAuthChange: onChange,
|
|
248
|
+
onRefreshChange,
|
|
249
|
+
};
|
|
250
|
+
if (token.value) {
|
|
251
|
+
setAuthState({
|
|
252
|
+
state: "waitingForServerConfirmationOfCachedToken",
|
|
253
|
+
config,
|
|
254
|
+
hasRetried: false,
|
|
255
|
+
});
|
|
256
|
+
sync.authenticate(token.value);
|
|
257
|
+
}
|
|
258
|
+
else {
|
|
259
|
+
setAuthState({ state: "initialRefetch", config });
|
|
260
|
+
// Try again with `forceRefreshToken: true`.
|
|
261
|
+
await wrap(refetchToken());
|
|
262
|
+
}
|
|
263
|
+
logVerbose("resuming WS after auth token fetch");
|
|
264
|
+
sync.resumeSocket();
|
|
265
|
+
}, `${name}.setConfig`);
|
|
234
266
|
return {
|
|
235
267
|
/** The auth state machine. */
|
|
236
268
|
state,
|
|
237
|
-
|
|
238
|
-
resetAuthState();
|
|
239
|
-
logVerbose("pausing WS for auth token fetch");
|
|
240
|
-
sync.pauseSocket();
|
|
241
|
-
const token = await wrap(fetchTokenAndGuardAgainstRace(fetchToken, {
|
|
242
|
-
forceRefreshToken: false,
|
|
243
|
-
}));
|
|
244
|
-
if (token.isFromOutdatedConfig)
|
|
245
|
-
return;
|
|
246
|
-
const config = {
|
|
247
|
-
fetchToken,
|
|
248
|
-
onAuthChange: onChange,
|
|
249
|
-
onRefreshChange,
|
|
250
|
-
};
|
|
251
|
-
if (token.value) {
|
|
252
|
-
setAuthState({
|
|
253
|
-
state: "waitingForServerConfirmationOfCachedToken",
|
|
254
|
-
config,
|
|
255
|
-
hasRetried: false,
|
|
256
|
-
});
|
|
257
|
-
sync.authenticate(token.value);
|
|
258
|
-
}
|
|
259
|
-
else {
|
|
260
|
-
setAuthState({ state: "initialRefetch", config });
|
|
261
|
-
// Try again with `forceRefreshToken: true`.
|
|
262
|
-
await wrap(refetchToken());
|
|
263
|
-
}
|
|
264
|
-
logVerbose("resuming WS after auth token fetch");
|
|
265
|
-
sync.resumeSocket();
|
|
266
|
-
},
|
|
269
|
+
setConfig,
|
|
267
270
|
onTransition(serverMessage) {
|
|
268
271
|
// Stale: the client has moved on to a newer identity.
|
|
269
272
|
if (!sync.isCurrentOrNewerAuthVersion(serverMessage.endVersion.identity)) {
|
|
@@ -4,17 +4,18 @@
|
|
|
4
4
|
* `ConvexBetterAuthProvider`. Sign in and out through the better-auth client
|
|
5
5
|
* itself; its session store tells this provider.
|
|
6
6
|
*/
|
|
7
|
-
import { type AtomLike } from "@reatom/core";
|
|
7
|
+
import { type Action, type AsyncExt, type AtomLike, type Computed } from "@reatom/core";
|
|
8
8
|
import type { AuthProvider } from "./auth_state.js";
|
|
9
9
|
export type { AuthProvider } from "./auth_state.js";
|
|
10
10
|
type SessionState = {
|
|
11
11
|
data: {
|
|
12
12
|
session?: {
|
|
13
13
|
id: string;
|
|
14
|
-
} | null;
|
|
14
|
+
} | null | undefined;
|
|
15
15
|
} | null;
|
|
16
16
|
isPending: boolean;
|
|
17
17
|
};
|
|
18
|
+
type SessionData<Client extends BetterAuthClient> = ReturnType<Client["useSession"]["get"]>["data"];
|
|
18
19
|
/**
|
|
19
20
|
* What the provider uses of a client from better-auth's `createAuthClient`
|
|
20
21
|
* (`better-auth/client`) with `@convex-dev/better-auth`'s `convexClient()`
|
|
@@ -35,8 +36,8 @@ export type BetterAuthClient = {
|
|
|
35
36
|
};
|
|
36
37
|
}): Promise<{
|
|
37
38
|
data?: {
|
|
38
|
-
token?: string | null;
|
|
39
|
-
} | null;
|
|
39
|
+
token?: string | null | undefined;
|
|
40
|
+
} | null | undefined;
|
|
40
41
|
}>;
|
|
41
42
|
};
|
|
42
43
|
getSession(options?: {
|
|
@@ -52,8 +53,11 @@ export type BetterAuthClient = {
|
|
|
52
53
|
data?: {
|
|
53
54
|
session?: {
|
|
54
55
|
token: string;
|
|
55
|
-
} | null;
|
|
56
|
-
} | null;
|
|
56
|
+
} | null | undefined;
|
|
57
|
+
} | null | undefined;
|
|
58
|
+
error?: {
|
|
59
|
+
message?: string | undefined;
|
|
60
|
+
} | null | undefined;
|
|
57
61
|
}>;
|
|
58
62
|
};
|
|
59
63
|
};
|
|
@@ -63,13 +67,26 @@ export type BetterAuthClient = {
|
|
|
63
67
|
export type BetterAuthOptions = {
|
|
64
68
|
/** A Convex token from server rendering, used until the session loads. */
|
|
65
69
|
initialToken?: string | null;
|
|
70
|
+
/**
|
|
71
|
+
* Whether an `ott` query param on start is a one-time token to sign in with;
|
|
72
|
+
* `true` by default. Only `crossDomainClient()` sends one.
|
|
73
|
+
*/
|
|
74
|
+
shouldHandleOneTimeToken?: boolean;
|
|
66
75
|
/** Drops `ott` from the address; `history.replaceState` by default. */
|
|
67
76
|
replaceURL?: (url: URL) => void | Promise<void>;
|
|
68
77
|
};
|
|
69
78
|
/** @public */
|
|
70
|
-
export type ReatomBetterAuth = AuthProvider & {
|
|
79
|
+
export type ReatomBetterAuth<Data = SessionState["data"]> = AuthProvider & {
|
|
80
|
+
/** The session store's data: the session and user, `null` when signed out. */
|
|
81
|
+
readonly data: Computed<Data>;
|
|
71
82
|
/** The last Convex token, `null` without one. */
|
|
72
83
|
readonly token: AtomLike<string | null, []>;
|
|
84
|
+
/**
|
|
85
|
+
* Trades the one-time token a cross-domain OAuth redirect comes back with
|
|
86
|
+
* for the session. Runs on start for an `ott` in the URL; call it when the
|
|
87
|
+
* app takes the token from the URL itself. Its `error` holds a failed trade.
|
|
88
|
+
*/
|
|
89
|
+
readonly completeSignIn: Action<[oneTimeToken: string], Promise<void>> & AsyncExt<[oneTimeToken: string], void>;
|
|
73
90
|
};
|
|
74
91
|
/**
|
|
75
92
|
* With `crossDomainClient()`, an OAuth sign-in comes back with a one-time
|
|
@@ -86,4 +103,4 @@ export type ReatomBetterAuth = AuthProvider & {
|
|
|
86
103
|
*
|
|
87
104
|
* @public
|
|
88
105
|
*/
|
|
89
|
-
export declare function reatomBetterAuthProvider(authClient:
|
|
106
|
+
export declare function reatomBetterAuthProvider<Client extends BetterAuthClient>(authClient: Client, options?: BetterAuthOptions, name?: string): ReatomBetterAuth<SessionData<Client>>;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `ConvexBetterAuthProvider`. Sign in and out through the better-auth client
|
|
5
5
|
* itself; its session store tells this provider.
|
|
6
6
|
*/
|
|
7
|
-
import { action, atom, computed, peek, withConnectHook, wrap, } from "@reatom/core";
|
|
7
|
+
import { abortVar, action, atom, computed, peek, withAsync, withConnectHook, wrap, } from "@reatom/core";
|
|
8
8
|
/**
|
|
9
9
|
* With `crossDomainClient()`, an OAuth sign-in comes back with a one-time
|
|
10
10
|
* token in the `ott` query param; the provider trades it for the session.
|
|
@@ -25,15 +25,37 @@ export function reatomBetterAuthProvider(authClient, options = {}, name = "bette
|
|
|
25
25
|
// Boxed, so the atom holds the request rather than its result.
|
|
26
26
|
const pending = atom(null, `${name}._pending`);
|
|
27
27
|
const state = atom(() => authClient.useSession.get(), `${name}.sessionState`).extend(withConnectHook((target) => {
|
|
28
|
-
void
|
|
28
|
+
void abortVar.spawn(start);
|
|
29
29
|
return authClient.useSession.subscribe(wrap((value) => {
|
|
30
|
+
const previous = peek(target).data?.session?.id;
|
|
30
31
|
target.set(value);
|
|
31
|
-
if (
|
|
32
|
+
if (value.isPending)
|
|
33
|
+
return;
|
|
34
|
+
// The cached token belongs to the previous session, or to none.
|
|
35
|
+
const id = value.data?.session?.id;
|
|
36
|
+
if (!id || (previous && id !== previous))
|
|
32
37
|
token.set(null);
|
|
33
38
|
}));
|
|
34
39
|
}));
|
|
35
|
-
const
|
|
36
|
-
|
|
40
|
+
const completeSignIn = action(async (oneTimeToken) => {
|
|
41
|
+
// better-auth's client answers every property, so a missing plugin shows
|
|
42
|
+
// up as a failed request rather than an undefined `crossDomain`.
|
|
43
|
+
const result = await wrap(authClient.crossDomain.oneTimeToken.verify({ token: oneTimeToken }));
|
|
44
|
+
const session = result.data?.session;
|
|
45
|
+
if (!session)
|
|
46
|
+
throw new Error(result.error?.message ?? "Invalid one-time token");
|
|
47
|
+
await wrap(authClient.getSession({
|
|
48
|
+
fetchOptions: { headers: { Authorization: `Bearer ${session.token}` } },
|
|
49
|
+
}));
|
|
50
|
+
authClient.updateSession?.();
|
|
51
|
+
}, `${name}.completeSignIn`).extend(withAsync());
|
|
52
|
+
const started = atom(false, `${name}._started`);
|
|
53
|
+
const start = action(async () => {
|
|
54
|
+
if (peek(started))
|
|
55
|
+
return;
|
|
56
|
+
started.set(true);
|
|
57
|
+
if (options.shouldHandleOneTimeToken === false ||
|
|
58
|
+
typeof location === "undefined")
|
|
37
59
|
return;
|
|
38
60
|
const url = new URL(location.href);
|
|
39
61
|
const ott = url.searchParams.get("ott");
|
|
@@ -42,15 +64,13 @@ export function reatomBetterAuthProvider(authClient, options = {}, name = "bette
|
|
|
42
64
|
url.searchParams.delete("ott");
|
|
43
65
|
await wrap((options.replaceURL ??
|
|
44
66
|
((next) => history.replaceState({}, "", next)))(url));
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
authClient.updateSession?.();
|
|
53
|
-
}, `${name}._verifyOneTimeToken`);
|
|
67
|
+
try {
|
|
68
|
+
await wrap(completeSignIn(ott));
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Kept in `completeSignIn.error()`.
|
|
72
|
+
}
|
|
73
|
+
}, `${name}._start`);
|
|
54
74
|
const fetchToken = action(async ({ forceRefreshToken }) => {
|
|
55
75
|
const cached = token();
|
|
56
76
|
if (cached && !forceRefreshToken)
|
|
@@ -74,7 +94,8 @@ export function reatomBetterAuthProvider(authClient, options = {}, name = "bette
|
|
|
74
94
|
}
|
|
75
95
|
}, `${name}.fetchToken`);
|
|
76
96
|
return {
|
|
77
|
-
isLoading: computed(() => state().isPending && token() === null, `${name}.isLoading`),
|
|
97
|
+
isLoading: computed(() => (state().isPending && token() === null) || !completeSignIn.ready(), `${name}.isLoading`),
|
|
98
|
+
data: computed(() => state().data, `${name}.data`),
|
|
78
99
|
// A token from server rendering stands in until the session loads.
|
|
79
100
|
session: computed(() => {
|
|
80
101
|
const id = state().data?.session?.id;
|
|
@@ -84,5 +105,6 @@ export function reatomBetterAuthProvider(authClient, options = {}, name = "bette
|
|
|
84
105
|
}, `${name}.session`),
|
|
85
106
|
fetchToken,
|
|
86
107
|
token,
|
|
108
|
+
completeSignIn,
|
|
87
109
|
};
|
|
88
110
|
}
|
|
@@ -121,11 +121,11 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
|
|
|
121
121
|
if (!hasWindow && options.unsavedChangesWarning === true) {
|
|
122
122
|
throw new Error("unsavedChangesWarning requested, but window.addEventListener not found! Remove {unsavedChangesWarning: true} from Convex client options.");
|
|
123
123
|
}
|
|
124
|
-
const querySetVersion = atom(0, `${name}.
|
|
125
|
-
const identityVersion = atom(0, `${name}.
|
|
124
|
+
const querySetVersion = atom(0, `${name}._querySetVersion`);
|
|
125
|
+
const identityVersion = atom(0, `${name}._identityVersion`);
|
|
126
126
|
/** The version of the query results received on the current connection. */
|
|
127
|
-
const remoteVersion = atom(INITIAL_VERSION, `${name}.
|
|
128
|
-
const maxObservedTimestamp = atom(undefined, `${name}.
|
|
127
|
+
const remoteVersion = atom(INITIAL_VERSION, `${name}._remoteVersion`);
|
|
128
|
+
const maxObservedTimestamp = atom(undefined, `${name}._maxObservedTimestamp`);
|
|
129
129
|
/** The token the server is told about. */
|
|
130
130
|
const authToken = atom(undefined, `${name}.authToken`);
|
|
131
131
|
/**
|
|
@@ -139,6 +139,9 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
|
|
|
139
139
|
const requests = atom(new Map(), `${name}.requests`);
|
|
140
140
|
/** Optimistic updates of mutations the client has not seen reflected yet. */
|
|
141
141
|
const optimisticUpdates = atom([], `${name}.optimisticUpdates`);
|
|
142
|
+
let nextTraceId = 0;
|
|
143
|
+
// Transport handles and query indexes are mutable per-context bookkeeping.
|
|
144
|
+
// Public results are atoms; these indexes deliberately are not rollback state.
|
|
142
145
|
const runtime = atom(() => ({
|
|
143
146
|
root: context(),
|
|
144
147
|
started: false,
|
|
@@ -317,7 +320,7 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
|
|
|
317
320
|
rt.pending.clear();
|
|
318
321
|
return modifications;
|
|
319
322
|
};
|
|
320
|
-
const flush = () => {
|
|
323
|
+
const flush = action(() => {
|
|
321
324
|
const rt = peek(runtime);
|
|
322
325
|
rt.flushScheduled = false;
|
|
323
326
|
if (rt.pending.size === 0 || rt.paused || rt.closed)
|
|
@@ -331,7 +334,7 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
|
|
|
331
334
|
};
|
|
332
335
|
querySetVersion.set(baseVersion + 1);
|
|
333
336
|
socket.send(modification);
|
|
334
|
-
};
|
|
337
|
+
}, `${name}._flush`);
|
|
335
338
|
const scheduleFlush = (rt) => {
|
|
336
339
|
if (rt.flushScheduled)
|
|
337
340
|
return;
|
|
@@ -376,7 +379,7 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
|
|
|
376
379
|
};
|
|
377
380
|
const createEntry = (rt, token, udfPath, args, queryOptions) => {
|
|
378
381
|
const visible = rt.overlay.get(token)?.result;
|
|
379
|
-
const result = atom(visible, `${name}.query#${udfPath}`);
|
|
382
|
+
const result = atom(visible, `${name}.query#${udfPath}#${nextTraceId++}`);
|
|
380
383
|
let data;
|
|
381
384
|
let error;
|
|
382
385
|
let status;
|
|
@@ -827,7 +830,7 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
|
|
|
827
830
|
remoteVersion.set(message.endVersion);
|
|
828
831
|
ingest(changed, removeCompleted(message.endVersion.ts), removed);
|
|
829
832
|
};
|
|
830
|
-
const onMessage = (serverMessage) => {
|
|
833
|
+
const onMessage = action((serverMessage) => {
|
|
831
834
|
const rt = peek(runtime);
|
|
832
835
|
// Once per client: metrics grow with reconnects.
|
|
833
836
|
if (!rt.firstMessageReceived) {
|
|
@@ -874,7 +877,7 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
|
|
|
874
877
|
}
|
|
875
878
|
}
|
|
876
879
|
return hasSyncedPastLastReconnect();
|
|
877
|
-
};
|
|
880
|
+
}, `${name}._onMessage`);
|
|
878
881
|
const socket = reatomSocket(wsUri, { webSocketConstructor, logger, debug }, {
|
|
879
882
|
onOpen,
|
|
880
883
|
onResume,
|
|
@@ -1044,7 +1047,7 @@ export function reatomConvexClient(address, options = {}, name = "convex") {
|
|
|
1044
1047
|
const existing = paginated.get(token);
|
|
1045
1048
|
if (existing !== undefined)
|
|
1046
1049
|
return existing;
|
|
1047
|
-
const created = reatomPaginatedQuery((pageUdfPath, pageArgs, paginationOpts) => queryEntry(pageUdfPath, { ...pageArgs, paginationOpts }), canonical, args, paginatedOptions, `${name}.paginatedQuery#${canonical}`);
|
|
1050
|
+
const created = reatomPaginatedQuery((pageUdfPath, pageArgs, paginationOpts) => queryEntry(pageUdfPath, { ...pageArgs, paginationOpts }), canonical, args, paginatedOptions, `${name}.paginatedQuery#${canonical}#${nextTraceId++}`);
|
|
1048
1051
|
paginated.set(created);
|
|
1049
1052
|
return created;
|
|
1050
1053
|
};
|
|
@@ -1,11 +1,7 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type AtomLike, type Ext } from "@reatom/core";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* Sets Reatom's internal `onConnect` slot to plain functions: Reatom's queue
|
|
7
|
-
* calls them in the context's root frame. Unlike `withConnectHook` this makes
|
|
8
|
-
* no actions, AbortController or AbortError per connection, which matters
|
|
9
|
-
* with thousands of queries.
|
|
3
|
+
* Composes with other public Reatom connection hooks. Client work belongs to
|
|
4
|
+
* the context, not to a subscriber's abort scope; losing a subscription must
|
|
5
|
+
* not cancel a socket operation or a scheduled query-set flush.
|
|
10
6
|
*/
|
|
11
7
|
export declare const withConnectionHooks: <Target extends AtomLike<any, any[], any>>(connect: () => void, disconnect: () => void) => Ext<Target, Target>;
|
|
@@ -1,15 +1,10 @@
|
|
|
1
|
+
import { abortVar, withConnectHook, } from "@reatom/core";
|
|
1
2
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Sets Reatom's internal `onConnect` slot to plain functions: Reatom's queue
|
|
6
|
-
* calls them in the context's root frame. Unlike `withConnectHook` this makes
|
|
7
|
-
* no actions, AbortController or AbortError per connection, which matters
|
|
8
|
-
* with thousands of queries.
|
|
3
|
+
* Composes with other public Reatom connection hooks. Client work belongs to
|
|
4
|
+
* the context, not to a subscriber's abort scope; losing a subscription must
|
|
5
|
+
* not cancel a socket operation or a scheduled query-set flush.
|
|
9
6
|
*/
|
|
10
|
-
export const withConnectionHooks = (connect, disconnect) => (
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
return target;
|
|
15
|
-
};
|
|
7
|
+
export const withConnectionHooks = (connect, disconnect) => withConnectHook(() => {
|
|
8
|
+
abortVar.spawn(connect);
|
|
9
|
+
return disconnect;
|
|
10
|
+
});
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* verifier, signs in. `auth:signOut` ends the server session. Storage keys and
|
|
9
9
|
* formats are `@convex-dev/auth/react`'s, so its sessions carry over.
|
|
10
10
|
*/
|
|
11
|
-
import { type Action, type AtomLike } from "@reatom/core";
|
|
11
|
+
import { type Action, type AsyncExt, type AtomLike } from "@reatom/core";
|
|
12
12
|
import type { Value } from "convex/values";
|
|
13
13
|
import type { AuthProvider } from "./auth_state.js";
|
|
14
14
|
import type { ReatomConvexClient } from "./client.js";
|
|
@@ -62,6 +62,13 @@ export type ReatomConvexAuth = AuthProvider & {
|
|
|
62
62
|
provider?: string | undefined,
|
|
63
63
|
params?: FormData | Record<string, Value> | undefined
|
|
64
64
|
], Promise<ConvexAuthSignInResult>>;
|
|
65
|
+
/**
|
|
66
|
+
* Signs in with the `code` the backend sends the browser back with after
|
|
67
|
+
* OAuth; `true` when tokens were issued. Runs on start for a `code` in the
|
|
68
|
+
* URL; call it when the app takes the code from the URL itself. Its `error`
|
|
69
|
+
* holds a failed exchange.
|
|
70
|
+
*/
|
|
71
|
+
readonly completeSignIn: Action<[code: string], Promise<boolean>> & AsyncExt<[code: string], boolean>;
|
|
65
72
|
/** Ends the session locally, even when `auth:signOut` fails. */
|
|
66
73
|
readonly signOut: Action<[], Promise<void>>;
|
|
67
74
|
};
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* verifier, signs in. `auth:signOut` ends the server session. Storage keys and
|
|
9
9
|
* formats are `@convex-dev/auth/react`'s, so its sessions carry over.
|
|
10
10
|
*/
|
|
11
|
-
import { action, atom, computed, peek, withConnectHook, wrap, } from "@reatom/core";
|
|
11
|
+
import { abortVar, action, atom, computed, peek, onEvent, sleep, withAsync, withConnectHook, wrap, } from "@reatom/core";
|
|
12
12
|
import { ConvexHttpClient } from "convex/browser";
|
|
13
13
|
import { makeFunctionReference } from "convex/server";
|
|
14
14
|
import { jwtDecode } from "../../vendor/jwt-decode/index.js";
|
|
@@ -48,16 +48,12 @@ export function reatomConvexAuthProvider(convex, options = {}, name = `${convex.
|
|
|
48
48
|
? storage().removeItem(key(base))
|
|
49
49
|
: storage().setItem(key(base), value));
|
|
50
50
|
const token = atom(null, `${name}.token`).extend(withConnectHook(() => {
|
|
51
|
-
void start
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
return () => {
|
|
55
|
-
stopFollowing?.();
|
|
56
|
-
stopWarning?.();
|
|
57
|
-
};
|
|
51
|
+
void abortVar.spawn(start);
|
|
52
|
+
followStorage();
|
|
53
|
+
warnOnUnload();
|
|
58
54
|
}));
|
|
59
|
-
const
|
|
60
|
-
void start
|
|
55
|
+
const restoring = atom(true, `${name}._restoring`).extend(withConnectHook(() => {
|
|
56
|
+
void abortVar.spawn(start);
|
|
61
57
|
}));
|
|
62
58
|
const isRefreshingToken = atom(false, `${name}.isRefreshingToken`);
|
|
63
59
|
const started = atom(false, `${name}._started`);
|
|
@@ -76,7 +72,7 @@ export function reatomConvexAuthProvider(convex, options = {}, name = `${convex.
|
|
|
76
72
|
}, `${name}.session`);
|
|
77
73
|
const apply = (value) => {
|
|
78
74
|
token.set(value);
|
|
79
|
-
|
|
75
|
+
restoring.set(false);
|
|
80
76
|
};
|
|
81
77
|
const store = async (tokens) => {
|
|
82
78
|
await write(JWT_KEY, tokens?.token ?? null);
|
|
@@ -95,7 +91,7 @@ export function reatomConvexAuthProvider(convex, options = {}, name = `${convex.
|
|
|
95
91
|
if (!(error instanceof TypeError) || retry >= RETRY_BACKOFF.length)
|
|
96
92
|
throw error;
|
|
97
93
|
const delay = RETRY_BACKOFF[retry] + RETRY_JITTER * Math.random();
|
|
98
|
-
await wrap(
|
|
94
|
+
await wrap(sleep(delay));
|
|
99
95
|
}
|
|
100
96
|
}
|
|
101
97
|
};
|
|
@@ -121,6 +117,11 @@ export function reatomConvexAuthProvider(convex, options = {}, name = `${convex.
|
|
|
121
117
|
}
|
|
122
118
|
return { signingIn: false };
|
|
123
119
|
}, `${name}.signIn`);
|
|
120
|
+
const completeSignIn = action(async (code) => {
|
|
121
|
+
const { signingIn } = await wrap(signIn(undefined, { code }));
|
|
122
|
+
return signingIn;
|
|
123
|
+
}, `${name}.completeSignIn`).extend(withAsync());
|
|
124
|
+
const isLoading = computed(() => restoring() || !completeSignIn.ready(), `${name}.isLoading`);
|
|
124
125
|
const signOut = action(async () => {
|
|
125
126
|
try {
|
|
126
127
|
await wrap(convex.action(signOutRef, {}));
|
|
@@ -167,10 +168,13 @@ export function reatomConvexAuthProvider(convex, options = {}, name = `${convex.
|
|
|
167
168
|
await wrap((options.replaceURL ??
|
|
168
169
|
((relative) => history.replaceState({}, "", relative)))(url.pathname + url.search + url.hash));
|
|
169
170
|
try {
|
|
170
|
-
await wrap(
|
|
171
|
+
await wrap(completeSignIn(code));
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// Kept in `completeSignIn.error()`.
|
|
171
175
|
}
|
|
172
176
|
finally {
|
|
173
|
-
|
|
177
|
+
restoring.set(false);
|
|
174
178
|
}
|
|
175
179
|
return;
|
|
176
180
|
}
|
|
@@ -180,27 +184,23 @@ export function reatomConvexAuthProvider(convex, options = {}, name = `${convex.
|
|
|
180
184
|
const followStorage = () => {
|
|
181
185
|
if (typeof window === "undefined")
|
|
182
186
|
return undefined;
|
|
183
|
-
|
|
187
|
+
return onEvent(window, "storage", (event) => {
|
|
184
188
|
if (event.storageArea !== storage() || event.key !== key(JWT_KEY))
|
|
185
189
|
return;
|
|
186
190
|
// Each key comes as its own event; the refresh token stays in storage.
|
|
187
191
|
apply(event.newValue);
|
|
188
192
|
});
|
|
189
|
-
window.addEventListener("storage", listener);
|
|
190
|
-
return () => window.removeEventListener("storage", listener);
|
|
191
193
|
};
|
|
192
194
|
// Leaving during a refresh token exchange can lose the new pair.
|
|
193
195
|
const warnOnUnload = () => {
|
|
194
196
|
if (typeof window === "undefined")
|
|
195
197
|
return undefined;
|
|
196
|
-
|
|
198
|
+
return onEvent(window, "beforeunload", (event) => {
|
|
197
199
|
if (!peek(isRefreshingToken))
|
|
198
200
|
return;
|
|
199
201
|
event.preventDefault();
|
|
200
202
|
event.returnValue = true;
|
|
201
203
|
});
|
|
202
|
-
window.addEventListener("beforeunload", listener);
|
|
203
|
-
return () => window.removeEventListener("beforeunload", listener);
|
|
204
204
|
};
|
|
205
205
|
return {
|
|
206
206
|
isLoading,
|
|
@@ -209,6 +209,7 @@ export function reatomConvexAuthProvider(convex, options = {}, name = `${convex.
|
|
|
209
209
|
token,
|
|
210
210
|
isRefreshingToken,
|
|
211
211
|
signIn,
|
|
212
|
+
completeSignIn,
|
|
212
213
|
signOut,
|
|
213
214
|
};
|
|
214
215
|
}
|
|
@@ -72,7 +72,7 @@ export function reatomSocket(uri, options, callbacks, name) {
|
|
|
72
72
|
const state = atom({ state: "disconnected" }, `${name}.state`);
|
|
73
73
|
const connectionCount = atom(0, `${name}.connectionCount`);
|
|
74
74
|
/** Consecutive failed connections, reset once the client syncs past a reconnect. */
|
|
75
|
-
const retries = atom(0, `${name}.
|
|
75
|
+
const retries = atom(0, `${name}._retries`);
|
|
76
76
|
const hasEverConnected = atom(false, `${name}.hasEverConnected`);
|
|
77
77
|
const runtime = atom(() => ({
|
|
78
78
|
root: context(),
|
|
@@ -150,6 +150,8 @@ export function reatomSocket(uri, options, callbacks, name) {
|
|
|
150
150
|
}
|
|
151
151
|
return transition;
|
|
152
152
|
};
|
|
153
|
+
// Socket timers outlive individual subscribers. terminate clears both
|
|
154
|
+
// inactivity and reconnect timers; each reschedule cancels its predecessor.
|
|
153
155
|
const resetServerInactivityTimeout = () => {
|
|
154
156
|
if (state().state === "terminated")
|
|
155
157
|
return;
|