@reatomized/convex 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +110 -2
- package/dist/browser/logging.d.ts +39 -0
- package/dist/browser/logging.js +117 -0
- package/dist/browser/reatom/auth.d.ts +75 -0
- package/dist/browser/reatom/auth.js +329 -0
- package/dist/browser/reatom/client.d.ts +348 -0
- package/dist/browser/reatom/client.js +1138 -0
- package/dist/browser/reatom/connection_hooks.d.ts +11 -0
- package/dist/browser/reatom/connection_hooks.js +15 -0
- package/dist/browser/reatom/errors.d.ts +3 -0
- package/dist/browser/reatom/errors.js +8 -0
- package/dist/browser/reatom/index.d.ts +6 -0
- package/dist/browser/reatom/index.js +2 -0
- package/dist/browser/reatom/optimistic.d.ts +44 -0
- package/dist/browser/reatom/optimistic.js +70 -0
- package/dist/browser/reatom/paginated.d.ts +56 -0
- package/dist/browser/reatom/paginated.js +162 -0
- package/dist/browser/reatom/socket.d.ts +52 -0
- package/dist/browser/reatom/socket.js +523 -0
- package/dist/browser/sync/function_result.d.ts +21 -0
- package/dist/browser/sync/function_result.js +1 -0
- package/dist/browser/sync/metrics.d.ts +9 -0
- package/dist/browser/sync/metrics.js +43 -0
- package/dist/browser/sync/pagination.d.ts +22 -0
- package/dist/browser/sync/pagination.js +20 -0
- package/dist/browser/sync/protocol.d.ts +206 -0
- package/dist/browser/sync/protocol.js +75 -0
- package/dist/browser/sync/session.d.ts +1 -0
- package/dist/browser/sync/session.js +10 -0
- package/dist/browser/sync/udf_path_utils.d.ts +30 -0
- package/dist/browser/sync/udf_path_utils.js +38 -0
- package/dist/common/index.d.ts +11 -0
- package/dist/common/index.js +52 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/vendor/jwt-decode/LICENSE +21 -0
- package/dist/vendor/jwt-decode/index.d.ts +23 -0
- package/dist/vendor/jwt-decode/index.js +57 -0
- package/dist/vendor/long.d.ts +16 -0
- package/dist/vendor/long.js +297 -0
- package/package.json +59 -4
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token fetch, server confirmation and refresh, with the state machine in an
|
|
3
|
+
* atom. By default the client takes 3 steps:
|
|
4
|
+
* 1. Fetch a possibly cached token and send it to the server.
|
|
5
|
+
* 2. Once the server confirms it, fetch a fresh token without the cache.
|
|
6
|
+
* 3. Refetch before each fresh token expires.
|
|
7
|
+
*
|
|
8
|
+
* With `initialAuthTokenReuse` step 2 is skipped: the cached token stays and
|
|
9
|
+
* its refetch is scheduled from its remaining lifetime, estimated with the
|
|
10
|
+
* server's clock skew. When the server rejects a token, the socket stops, a
|
|
11
|
+
* fresh token is fetched and sent, and the socket restarts.
|
|
12
|
+
*/
|
|
13
|
+
import { atom, context, peek, wrap } from "@reatom/core";
|
|
14
|
+
import { jwtDecode } from "../../vendor/jwt-decode/index.js";
|
|
15
|
+
// setTimeout takes a 32-bit delay, about 24 days.
|
|
16
|
+
const MAXIMUM_REFRESH_DELAY = 20 * 24 * 60 * 60 * 1000; // 20 days
|
|
17
|
+
const MAX_TOKEN_CONFIRMATION_ATTEMPTS = 2;
|
|
18
|
+
export function reatomAuth(sync, options, name) {
|
|
19
|
+
const { logger, refreshTokenLeewaySeconds, initialAuthTokenReuse } = options;
|
|
20
|
+
const state = atom({ state: "noAuth" }, `${name}.state`);
|
|
21
|
+
const runtime = atom(() => ({
|
|
22
|
+
root: context(),
|
|
23
|
+
configVersion: 0,
|
|
24
|
+
lastRefreshChange: false,
|
|
25
|
+
tokenConfirmationAttempts: 0,
|
|
26
|
+
}), `${name}._runtime`);
|
|
27
|
+
const logVerbose = (message) => logger.logVerbose(`${message} [v${peek(runtime).configVersion}]`);
|
|
28
|
+
const setAuthState = (next) => {
|
|
29
|
+
const authStateForLog = next.state === "waitingForServerConfirmationOfFreshToken"
|
|
30
|
+
? {
|
|
31
|
+
hadAuth: next.hadAuth,
|
|
32
|
+
state: next.state,
|
|
33
|
+
token: `...${next.token.slice(-7)}`,
|
|
34
|
+
}
|
|
35
|
+
: { state: next.state };
|
|
36
|
+
logVerbose(`setting auth state to ${JSON.stringify(authStateForLog)}`);
|
|
37
|
+
if (next.state === "waitingForScheduledRefetch" ||
|
|
38
|
+
next.state === "notRefetching" ||
|
|
39
|
+
next.state === "noAuth") {
|
|
40
|
+
peek(runtime).tokenConfirmationAttempts = 0;
|
|
41
|
+
}
|
|
42
|
+
const current = state();
|
|
43
|
+
if (current.state === "waitingForScheduledRefetch") {
|
|
44
|
+
clearTimeout(current.refetchTokenTimeoutId);
|
|
45
|
+
}
|
|
46
|
+
state.set(next);
|
|
47
|
+
};
|
|
48
|
+
const notifyRefreshChange = (isRefreshing) => {
|
|
49
|
+
const current = state();
|
|
50
|
+
const rt = peek(runtime);
|
|
51
|
+
if (current.state !== "noAuth" &&
|
|
52
|
+
current.state !== "initialRefetch" &&
|
|
53
|
+
current.config.onRefreshChange &&
|
|
54
|
+
rt.lastRefreshChange !== isRefreshing) {
|
|
55
|
+
rt.lastRefreshChange = isRefreshing;
|
|
56
|
+
current.config.onRefreshChange(isRefreshing);
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
// The only way into `noAuth`; pairs any pending `onRefreshChange(true)`.
|
|
60
|
+
const resetAuthState = () => {
|
|
61
|
+
notifyRefreshChange(false);
|
|
62
|
+
setAuthState({ state: "noAuth" });
|
|
63
|
+
};
|
|
64
|
+
const setAndReportAuthFailed = (onAuthChange) => {
|
|
65
|
+
onAuthChange(false);
|
|
66
|
+
resetAuthState();
|
|
67
|
+
};
|
|
68
|
+
/** `isFromOutdatedConfig` when `setConfig` or `stop` ran during the fetch. */
|
|
69
|
+
const fetchTokenAndGuardAgainstRace = async (fetchToken, fetchArgs) => {
|
|
70
|
+
const rt = peek(runtime);
|
|
71
|
+
const originalConfigVersion = ++rt.configVersion;
|
|
72
|
+
logVerbose(`fetching token with config version ${originalConfigVersion}`);
|
|
73
|
+
const token = await wrap(fetchToken(fetchArgs));
|
|
74
|
+
if (rt.configVersion !== originalConfigVersion) {
|
|
75
|
+
logVerbose(`stale config version, expected ${originalConfigVersion}, got ${rt.configVersion}`);
|
|
76
|
+
return { isFromOutdatedConfig: true };
|
|
77
|
+
}
|
|
78
|
+
return { isFromOutdatedConfig: false, value: token };
|
|
79
|
+
};
|
|
80
|
+
const decodeToken = (token) => {
|
|
81
|
+
try {
|
|
82
|
+
return jwtDecode(token);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
logVerbose(`Error decoding token: ${e instanceof Error ? e.message : "Unknown error"}`);
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
const scheduleTokenRefetch = (token, clientClockSkewMs) => {
|
|
90
|
+
const current = state();
|
|
91
|
+
if (current.state === "noAuth")
|
|
92
|
+
return;
|
|
93
|
+
const decodedToken = decodeToken(token);
|
|
94
|
+
if (!decodedToken) {
|
|
95
|
+
// The server only confirms JWTs, so this should not happen.
|
|
96
|
+
logger.error("Auth token is not a valid JWT, cannot refetch the token");
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// iat: issued at, exp: expires at, both UTC seconds.
|
|
100
|
+
const { iat, exp } = decodedToken;
|
|
101
|
+
if (!iat || !exp) {
|
|
102
|
+
logger.error("Auth token does not have required fields, cannot refetch the token");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
const fullLifetimeSeconds = exp - iat;
|
|
106
|
+
if (fullLifetimeSeconds <= 2) {
|
|
107
|
+
logger.error("Auth token does not live long enough, cannot refetch the token");
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
let tokenValiditySeconds;
|
|
111
|
+
if (clientClockSkewMs !== undefined) {
|
|
112
|
+
// A cached token's remaining lifetime, on the server's clock. The server
|
|
113
|
+
// computes clientClockSkew = clientTs - serverReceiveTs. Connect latency
|
|
114
|
+
// makes the client look further behind, so the refetch runs early.
|
|
115
|
+
const estimatedServerNowSeconds = (Date.now() - clientClockSkewMs) / 1000;
|
|
116
|
+
tokenValiditySeconds = Math.max(0, exp - estimatedServerNowSeconds);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
// A fresh token lives `exp - iat` from now.
|
|
120
|
+
tokenValiditySeconds = fullLifetimeSeconds;
|
|
121
|
+
}
|
|
122
|
+
let delay = Math.min(MAXIMUM_REFRESH_DELAY, (tokenValiditySeconds - refreshTokenLeewaySeconds) * 1000);
|
|
123
|
+
if (delay <= 0) {
|
|
124
|
+
logger.warn(`Refetching auth token immediately, configured leeway ${refreshTokenLeewaySeconds}s is larger than the token's lifetime ${tokenValiditySeconds}s`);
|
|
125
|
+
delay = 0;
|
|
126
|
+
}
|
|
127
|
+
const refetchTokenTimeoutId = setTimeout(wrap(() => {
|
|
128
|
+
logVerbose("running scheduled token refetch");
|
|
129
|
+
void refetchToken();
|
|
130
|
+
}, peek(runtime).root), delay);
|
|
131
|
+
setAuthState({
|
|
132
|
+
state: "waitingForScheduledRefetch",
|
|
133
|
+
refetchTokenTimeoutId,
|
|
134
|
+
config: current.config,
|
|
135
|
+
});
|
|
136
|
+
logVerbose(`scheduled preemptive auth token refetching in ${delay}ms`);
|
|
137
|
+
};
|
|
138
|
+
// Force-fetches a token and schedules the next refetch once the server
|
|
139
|
+
// confirms it, so an active client never has to reauthenticate.
|
|
140
|
+
const refetchToken = async () => {
|
|
141
|
+
const current = state();
|
|
142
|
+
if (current.state === "noAuth")
|
|
143
|
+
return;
|
|
144
|
+
logVerbose("refetching auth token");
|
|
145
|
+
const token = await wrap(fetchTokenAndGuardAgainstRace(current.config.fetchToken, {
|
|
146
|
+
forceRefreshToken: true,
|
|
147
|
+
}));
|
|
148
|
+
if (token.isFromOutdatedConfig)
|
|
149
|
+
return;
|
|
150
|
+
if (token.value) {
|
|
151
|
+
if (sync.token() !== token.value) {
|
|
152
|
+
setAuthState({
|
|
153
|
+
state: "waitingForServerConfirmationOfFreshToken",
|
|
154
|
+
hadAuth: sync.hasAuth(),
|
|
155
|
+
token: token.value,
|
|
156
|
+
config: current.config,
|
|
157
|
+
});
|
|
158
|
+
sync.authenticate(token.value);
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
setAuthState({ state: "notRefetching", config: current.config });
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
logVerbose("refetching token failed");
|
|
166
|
+
if (sync.hasAuth()) {
|
|
167
|
+
sync.clearAuth();
|
|
168
|
+
}
|
|
169
|
+
setAndReportAuthFailed(current.config.onAuthChange);
|
|
170
|
+
}
|
|
171
|
+
// In case a scheduled refetch ran during a reauthentication.
|
|
172
|
+
logVerbose("restarting WS after auth token fetch (if currently stopped)");
|
|
173
|
+
sync.tryRestartSocket();
|
|
174
|
+
};
|
|
175
|
+
// Like `refetchToken`, but with the socket stopped so requests don't retry
|
|
176
|
+
// with the rejected token.
|
|
177
|
+
const tryToReauthenticate = async (serverMessage) => {
|
|
178
|
+
logVerbose(`attempting to reauthenticate: ${serverMessage.error}`);
|
|
179
|
+
const current = state();
|
|
180
|
+
const rt = peek(runtime);
|
|
181
|
+
if (
|
|
182
|
+
// No way to fetch another token.
|
|
183
|
+
current.state === "noAuth" ||
|
|
184
|
+
// A fresh token failed too often; give up instead of retrying forever.
|
|
185
|
+
(current.state === "waitingForServerConfirmationOfFreshToken" &&
|
|
186
|
+
rt.tokenConfirmationAttempts >= MAX_TOKEN_CONFIRMATION_ATTEMPTS)) {
|
|
187
|
+
logger.error(`Failed to authenticate: "${serverMessage.error}", check your server auth config`);
|
|
188
|
+
if (sync.hasAuth()) {
|
|
189
|
+
sync.dropAuth();
|
|
190
|
+
}
|
|
191
|
+
if (current.state !== "noAuth") {
|
|
192
|
+
setAndReportAuthFailed(current.config.onAuthChange);
|
|
193
|
+
}
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (current.state === "waitingForServerConfirmationOfFreshToken") {
|
|
197
|
+
rt.tokenConfirmationAttempts++;
|
|
198
|
+
logVerbose(`retrying reauthentication, ${MAX_TOKEN_CONFIRMATION_ATTEMPTS - rt.tokenConfirmationAttempts} attempts remaining`);
|
|
199
|
+
}
|
|
200
|
+
notifyRefreshChange(true);
|
|
201
|
+
await wrap(sync.stopSocket());
|
|
202
|
+
const afterStop = state();
|
|
203
|
+
// `setConfig` or `stop` ran during the await.
|
|
204
|
+
if (afterStop.state === "noAuth")
|
|
205
|
+
return;
|
|
206
|
+
const token = await wrap(fetchTokenAndGuardAgainstRace(afterStop.config.fetchToken, {
|
|
207
|
+
forceRefreshToken: true,
|
|
208
|
+
}));
|
|
209
|
+
if (token.isFromOutdatedConfig)
|
|
210
|
+
return;
|
|
211
|
+
const latest = state();
|
|
212
|
+
// Another auth error gave up meanwhile.
|
|
213
|
+
if (latest.state === "noAuth")
|
|
214
|
+
return;
|
|
215
|
+
if (token.value && sync.token() !== token.value) {
|
|
216
|
+
sync.authenticate(token.value);
|
|
217
|
+
setAuthState({
|
|
218
|
+
state: "waitingForServerConfirmationOfFreshToken",
|
|
219
|
+
config: latest.config,
|
|
220
|
+
token: token.value,
|
|
221
|
+
hadAuth: latest.state === "notRefetching" ||
|
|
222
|
+
latest.state === "waitingForScheduledRefetch",
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
logVerbose("reauthentication failed, could not fetch a new token");
|
|
227
|
+
if (sync.hasAuth()) {
|
|
228
|
+
sync.dropAuth();
|
|
229
|
+
}
|
|
230
|
+
setAndReportAuthFailed(latest.config.onAuthChange);
|
|
231
|
+
}
|
|
232
|
+
sync.tryRestartSocket();
|
|
233
|
+
};
|
|
234
|
+
return {
|
|
235
|
+
/** The auth state machine. */
|
|
236
|
+
state,
|
|
237
|
+
async setConfig(fetchToken, onChange, onRefreshChange) {
|
|
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
|
+
},
|
|
267
|
+
onTransition(serverMessage) {
|
|
268
|
+
// Stale: the client has moved on to a newer identity.
|
|
269
|
+
if (!sync.isCurrentOrNewerAuthVersion(serverMessage.endVersion.identity)) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
// Not an answer to `Authenticate`.
|
|
273
|
+
if (serverMessage.endVersion.identity <= serverMessage.startVersion.identity) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
const current = state();
|
|
277
|
+
logVerbose(`auth state is ${current.state} when handling transition`);
|
|
278
|
+
// The server accepted the token: client and server identities agree.
|
|
279
|
+
sync.markAuthCompletion();
|
|
280
|
+
if (current.state === "waitingForServerConfirmationOfCachedToken") {
|
|
281
|
+
logVerbose("server confirmed auth token is valid");
|
|
282
|
+
const cachedToken = sync.token();
|
|
283
|
+
if (initialAuthTokenReuse && cachedToken) {
|
|
284
|
+
scheduleTokenRefetch(cachedToken, serverMessage.clientClockSkew);
|
|
285
|
+
}
|
|
286
|
+
else {
|
|
287
|
+
void refetchToken();
|
|
288
|
+
}
|
|
289
|
+
current.config.onAuthChange(true);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
if (current.state === "waitingForServerConfirmationOfFreshToken") {
|
|
293
|
+
logVerbose("server confirmed new auth token is valid");
|
|
294
|
+
notifyRefreshChange(false);
|
|
295
|
+
scheduleTokenRefetch(current.token);
|
|
296
|
+
peek(runtime).tokenConfirmationAttempts = 0;
|
|
297
|
+
// Read after scheduling, as convex@1.48.0 does: once the refetch is
|
|
298
|
+
// scheduled the state has no `hadAuth`, so every confirmed fresh
|
|
299
|
+
// token reports `true`.
|
|
300
|
+
const after = state();
|
|
301
|
+
if (!("hadAuth" in after && after.hadAuth)) {
|
|
302
|
+
current.config.onAuthChange(true);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
},
|
|
306
|
+
onAuthError(serverMessage) {
|
|
307
|
+
const current = state().state;
|
|
308
|
+
// An expired-token error while a new token is already on its way.
|
|
309
|
+
if (serverMessage.authUpdateAttempted === false &&
|
|
310
|
+
(current === "waitingForServerConfirmationOfFreshToken" ||
|
|
311
|
+
current === "waitingForServerConfirmationOfCachedToken")) {
|
|
312
|
+
logVerbose("ignoring non-auth token expired error");
|
|
313
|
+
return;
|
|
314
|
+
}
|
|
315
|
+
// The error reports the version the server stayed at, hence `+ 1`.
|
|
316
|
+
if (!sync.isCurrentOrNewerAuthVersion(serverMessage.baseVersion + 1)) {
|
|
317
|
+
logVerbose("ignoring auth error for previous auth attempt");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
void tryToReauthenticate(serverMessage);
|
|
321
|
+
},
|
|
322
|
+
stop() {
|
|
323
|
+
resetAuthState();
|
|
324
|
+
// Drops a token fetch in progress.
|
|
325
|
+
peek(runtime).configVersion++;
|
|
326
|
+
logVerbose(`config version bumped to ${peek(runtime).configVersion}`);
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A Convex sync client whose state lives in Reatom atoms.
|
|
3
|
+
*
|
|
4
|
+
* The query set is the set of connected query atoms: reading a query's
|
|
5
|
+
* `result` in a subscribed graph subscribes it on the server, and losing the
|
|
6
|
+
* last reader unsubscribes it. Query set changes made in one microtask go out
|
|
7
|
+
* as one `ModifyQuerySet`. A server transition updates only the queries it
|
|
8
|
+
* names; optimistic updates are replayed only while some are pending.
|
|
9
|
+
*
|
|
10
|
+
* State is per Reatom context: call the client's methods inside a frame of
|
|
11
|
+
* the context that owns it.
|
|
12
|
+
*/
|
|
13
|
+
import { type Action, type AtomLike, type Computed } from "@reatom/core";
|
|
14
|
+
import { FunctionArgs, FunctionReference, FunctionReference_future, FunctionReturnType } from "convex/server";
|
|
15
|
+
import { Value } from "convex/values";
|
|
16
|
+
import { Logger } from "../logging.js";
|
|
17
|
+
import { FunctionResult } from "../sync/function_result.js";
|
|
18
|
+
import { OptimisticUpdate } from "convex/browser";
|
|
19
|
+
import { QueryJournal, RequestId, TS } from "../sync/protocol.js";
|
|
20
|
+
import { QueryToken } from "../sync/udf_path_utils.js";
|
|
21
|
+
import { AuthTokenFetcher } from "./auth.js";
|
|
22
|
+
import { ReatomPaginatedQuery } from "./paginated.js";
|
|
23
|
+
import { SocketState } from "./socket.js";
|
|
24
|
+
/**
|
|
25
|
+
* Options for {@link BaseConvexClient}.
|
|
26
|
+
*
|
|
27
|
+
* @public
|
|
28
|
+
*/
|
|
29
|
+
export interface BaseConvexClientOptions {
|
|
30
|
+
/**
|
|
31
|
+
* Whether to prompt the user if they have unsaved changes pending
|
|
32
|
+
* when navigating away or closing a web page.
|
|
33
|
+
*
|
|
34
|
+
* This is only possible when the `window` object exists, i.e. in a browser.
|
|
35
|
+
*
|
|
36
|
+
* The default value is `true` in browsers.
|
|
37
|
+
*/
|
|
38
|
+
unsavedChangesWarning?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Specifies an alternate
|
|
41
|
+
* [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
|
|
42
|
+
* constructor to use for client communication with the Convex cloud.
|
|
43
|
+
* The default behavior is to use `WebSocket` from the global environment.
|
|
44
|
+
*/
|
|
45
|
+
webSocketConstructor?: typeof WebSocket;
|
|
46
|
+
/**
|
|
47
|
+
* Adds additional logging for debugging purposes.
|
|
48
|
+
*
|
|
49
|
+
* The default value is `false`.
|
|
50
|
+
*/
|
|
51
|
+
verbose?: boolean;
|
|
52
|
+
/**
|
|
53
|
+
* A logger, `true`, or `false`. If not provided or `true`, logs to the console.
|
|
54
|
+
* If `false`, logs are not printed anywhere.
|
|
55
|
+
*
|
|
56
|
+
* You can construct your own logger to customize logging to log elsewhere.
|
|
57
|
+
* A logger is an object with 4 methods: log(), warn(), error(), and logVerbose().
|
|
58
|
+
* These methods can receive multiple arguments of any types, like console.log().
|
|
59
|
+
*/
|
|
60
|
+
logger?: Logger | boolean;
|
|
61
|
+
/**
|
|
62
|
+
* Sends additional metrics to Convex for debugging purposes.
|
|
63
|
+
*
|
|
64
|
+
* The default value is `false`.
|
|
65
|
+
*/
|
|
66
|
+
reportDebugInfoToConvex?: boolean;
|
|
67
|
+
/**
|
|
68
|
+
* This API is experimental: it may change or disappear.
|
|
69
|
+
*
|
|
70
|
+
* A function to call on receiving abnormal WebSocket close messages from the
|
|
71
|
+
* connected Convex deployment. The content of these messages is not stable,
|
|
72
|
+
* it is an implementation detail that may change.
|
|
73
|
+
*
|
|
74
|
+
* Consider this API an observability stopgap until higher level codes with
|
|
75
|
+
* recommendations on what to do are available, which could be a more stable
|
|
76
|
+
* interface instead of `string`.
|
|
77
|
+
*
|
|
78
|
+
* Check `connectionState` for more quantitative metrics about connection status.
|
|
79
|
+
*/
|
|
80
|
+
onServerDisconnectError?: (message: string) => void;
|
|
81
|
+
/**
|
|
82
|
+
* Skip validating that the Convex deployment URL looks like
|
|
83
|
+
* `https://happy-animal-123.convex.cloud` or localhost.
|
|
84
|
+
*
|
|
85
|
+
* This can be useful if running a self-hosted Convex backend that uses a different
|
|
86
|
+
* URL.
|
|
87
|
+
*
|
|
88
|
+
* The default value is `false`
|
|
89
|
+
*/
|
|
90
|
+
skipConvexDeploymentUrlCheck?: boolean;
|
|
91
|
+
/**
|
|
92
|
+
* If using auth, the number of seconds before a token expires that we should refresh it.
|
|
93
|
+
*
|
|
94
|
+
* The default value is `10`.
|
|
95
|
+
*/
|
|
96
|
+
authRefreshTokenLeewaySeconds?: number;
|
|
97
|
+
/**
|
|
98
|
+
* This API is experimental: it may change or disappear.
|
|
99
|
+
*
|
|
100
|
+
* Whether query, mutation, and action requests should be held back
|
|
101
|
+
* until the first auth token can be sent.
|
|
102
|
+
*
|
|
103
|
+
* Opting into this behavior works well for pages that should
|
|
104
|
+
* only be viewed by authenticated clients.
|
|
105
|
+
*
|
|
106
|
+
* Defaults to false, not waiting for an auth token.
|
|
107
|
+
*/
|
|
108
|
+
expectAuth?: boolean;
|
|
109
|
+
/**
|
|
110
|
+
* This API is experimental: it may change or disappear.
|
|
111
|
+
*
|
|
112
|
+
* When true, the client reuses the initial cached auth token instead
|
|
113
|
+
* of immediately fetching a fresh one. This avoids a second
|
|
114
|
+
* Authenticate message that causes the server to re-execute all
|
|
115
|
+
* authenticated queries.
|
|
116
|
+
*
|
|
117
|
+
* The cached token's remaining lifetime is estimated using the
|
|
118
|
+
* server's clock skew measurement, and a refresh is scheduled
|
|
119
|
+
* before it expires.
|
|
120
|
+
*
|
|
121
|
+
* Defaults to false, preserving the original behavior of immediately
|
|
122
|
+
* fetching a fresh token after the cached token is confirmed.
|
|
123
|
+
*/
|
|
124
|
+
initialAuthTokenReuse?: boolean;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* State describing the client's connection with the Convex backend.
|
|
128
|
+
*
|
|
129
|
+
* @public
|
|
130
|
+
*/
|
|
131
|
+
export type ConnectionState = {
|
|
132
|
+
hasInflightRequests: boolean;
|
|
133
|
+
isWebSocketConnected: boolean;
|
|
134
|
+
timeOfOldestInflightRequest: Date | null;
|
|
135
|
+
/**
|
|
136
|
+
* True if the client has ever opened a WebSocket to the "ready" state.
|
|
137
|
+
*/
|
|
138
|
+
hasEverConnected: boolean;
|
|
139
|
+
/**
|
|
140
|
+
* The number of times this client has connected to the Convex backend.
|
|
141
|
+
*
|
|
142
|
+
* A number of things can cause the client to reconnect -- server errors,
|
|
143
|
+
* bad internet, auth expiring. But this number being high is an indication
|
|
144
|
+
* that the client is having trouble keeping a stable connection.
|
|
145
|
+
*/
|
|
146
|
+
connectionCount: number;
|
|
147
|
+
/**
|
|
148
|
+
* The number of times this client has tried (and failed) to connect to the Convex backend.
|
|
149
|
+
*/
|
|
150
|
+
connectionRetries: number;
|
|
151
|
+
/**
|
|
152
|
+
* The number of mutations currently in flight.
|
|
153
|
+
*/
|
|
154
|
+
inflightMutations: number;
|
|
155
|
+
/**
|
|
156
|
+
* The number of actions currently in flight.
|
|
157
|
+
*/
|
|
158
|
+
inflightActions: number;
|
|
159
|
+
};
|
|
160
|
+
/**
|
|
161
|
+
* Options for {@link BaseConvexClient.subscribe}.
|
|
162
|
+
*
|
|
163
|
+
* @public
|
|
164
|
+
*/
|
|
165
|
+
export interface SubscribeOptions {
|
|
166
|
+
/**
|
|
167
|
+
* An (optional) journal produced from a previous execution of this query
|
|
168
|
+
* function.
|
|
169
|
+
*
|
|
170
|
+
* If there is an existing subscription to a query function with the same
|
|
171
|
+
* name and arguments, this journal will have no effect.
|
|
172
|
+
*/
|
|
173
|
+
journal?: QueryJournal;
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Options for {@link BaseConvexClient.mutation}.
|
|
177
|
+
*
|
|
178
|
+
* @public
|
|
179
|
+
*/
|
|
180
|
+
export interface MutationOptions {
|
|
181
|
+
/**
|
|
182
|
+
* An optimistic update to apply along with this mutation.
|
|
183
|
+
*
|
|
184
|
+
* An optimistic update locally updates queries while a mutation is pending.
|
|
185
|
+
* Once the mutation completes, the update will be rolled back.
|
|
186
|
+
*/
|
|
187
|
+
optimisticUpdate?: OptimisticUpdate<any> | undefined;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Type describing updates to a query within a `Transition`.
|
|
191
|
+
*
|
|
192
|
+
* @public
|
|
193
|
+
*/
|
|
194
|
+
export type QueryModification = {
|
|
195
|
+
kind: "Updated";
|
|
196
|
+
result: FunctionResult | undefined;
|
|
197
|
+
} | {
|
|
198
|
+
kind: "Removed";
|
|
199
|
+
};
|
|
200
|
+
/**
|
|
201
|
+
* Object describing a transition passed into the `onTransition` handler.
|
|
202
|
+
*
|
|
203
|
+
* These can be from receiving a transition from the server, or from applying an
|
|
204
|
+
* optimistic update locally.
|
|
205
|
+
*
|
|
206
|
+
* @public
|
|
207
|
+
*/
|
|
208
|
+
export type Transition = {
|
|
209
|
+
queries: Array<{
|
|
210
|
+
token: QueryToken;
|
|
211
|
+
modification: QueryModification;
|
|
212
|
+
}>;
|
|
213
|
+
reflectedMutations: Array<{
|
|
214
|
+
requestId: RequestId;
|
|
215
|
+
result: FunctionResult;
|
|
216
|
+
}>;
|
|
217
|
+
timestamp: TS;
|
|
218
|
+
};
|
|
219
|
+
/**
|
|
220
|
+
* The status of a {@link ReatomQuery}.
|
|
221
|
+
*
|
|
222
|
+
* @public
|
|
223
|
+
*/
|
|
224
|
+
export type QueryStatus = "loading" | "success" | "error";
|
|
225
|
+
/**
|
|
226
|
+
* One query function and arguments on a {@link ReatomConvexClient}.
|
|
227
|
+
*
|
|
228
|
+
* Reading any of its atoms inside a subscribed Reatom graph subscribes the
|
|
229
|
+
* query on the server; it is unsubscribed once nothing reads it. Equal
|
|
230
|
+
* queries share one entry and one server subscription.
|
|
231
|
+
*
|
|
232
|
+
* @public
|
|
233
|
+
*/
|
|
234
|
+
export interface ReatomQuery<T = Value> {
|
|
235
|
+
readonly token: QueryToken;
|
|
236
|
+
/** The canonical function path. */
|
|
237
|
+
readonly udfPath: string;
|
|
238
|
+
readonly args: Record<string, Value>;
|
|
239
|
+
/**
|
|
240
|
+
* The function result with optimistic updates applied, `undefined` while
|
|
241
|
+
* loading.
|
|
242
|
+
*/
|
|
243
|
+
readonly result: AtomLike<FunctionResult | undefined, []>;
|
|
244
|
+
/** The value, `undefined` while loading or after an error. */
|
|
245
|
+
readonly data: Computed<T | undefined>;
|
|
246
|
+
/** The error the query failed with, a `ConvexError` when it carries data. */
|
|
247
|
+
readonly error: Computed<Error | undefined>;
|
|
248
|
+
readonly status: Computed<QueryStatus>;
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Options for {@link ReatomConvexClient.query}.
|
|
252
|
+
*
|
|
253
|
+
* @public
|
|
254
|
+
*/
|
|
255
|
+
export type ReatomQueryOptions = SubscribeOptions;
|
|
256
|
+
type QueryRef = FunctionReference<"query"> | FunctionReference_future<"query"> | string;
|
|
257
|
+
type MutationRef = FunctionReference<"mutation"> | FunctionReference_future<"mutation"> | string;
|
|
258
|
+
type ActionRef = FunctionReference<"action"> | FunctionReference_future<"action"> | string;
|
|
259
|
+
type AnyRef = FunctionReference<any, any> | FunctionReference_future<any, any>;
|
|
260
|
+
type ReturnOf<Ref> = Ref extends AnyRef ? FunctionReturnType<Ref> : any;
|
|
261
|
+
type PaginatedItemOf<Ref> = ReturnOf<Ref> extends {
|
|
262
|
+
page: Array<infer Item>;
|
|
263
|
+
} ? Item : any;
|
|
264
|
+
type ArgsOf<Ref> = Ref extends AnyRef ? FunctionArgs<Ref> : Record<string, Value>;
|
|
265
|
+
/**
|
|
266
|
+
* Create a Convex client whose state lives in Reatom atoms.
|
|
267
|
+
*
|
|
268
|
+
* The client connects on first use in a context: a query read, a request,
|
|
269
|
+
* `setAuth` or a `connectionState` subscriber. Call its methods inside a
|
|
270
|
+
* Reatom frame; each context gets its own connection.
|
|
271
|
+
*
|
|
272
|
+
* ```ts
|
|
273
|
+
* const convex = reatomConvexClient(import.meta.env.VITE_CONVEX_URL);
|
|
274
|
+
* const notes = computed(() => convex.query(api.notes.list, {}).data());
|
|
275
|
+
* ```
|
|
276
|
+
*
|
|
277
|
+
* @param address - The url of your Convex deployment, e.g.
|
|
278
|
+
* `https://small-mouse-123.convex.cloud`.
|
|
279
|
+
* @param options - See {@link BaseConvexClientOptions}.
|
|
280
|
+
* @param name - A prefix for the client's atom names.
|
|
281
|
+
*
|
|
282
|
+
* @public
|
|
283
|
+
*/
|
|
284
|
+
export declare function reatomConvexClient(address: string, options?: BaseConvexClientOptions, name?: string): ReatomConvexClient;
|
|
285
|
+
/**
|
|
286
|
+
* A Convex client whose state lives in Reatom atoms, created with
|
|
287
|
+
* {@link reatomConvexClient}. Use it inside a Reatom frame.
|
|
288
|
+
*
|
|
289
|
+
* @public
|
|
290
|
+
*/
|
|
291
|
+
export interface ReatomConvexClient {
|
|
292
|
+
/** The prefix of the client's atom names. */
|
|
293
|
+
readonly name: string;
|
|
294
|
+
/** The deployment address. */
|
|
295
|
+
readonly url: string;
|
|
296
|
+
/** The connection with the backend; subscribing connects. */
|
|
297
|
+
readonly connectionState: Computed<ConnectionState>;
|
|
298
|
+
/**
|
|
299
|
+
* Whether the server accepted the current token: `null` while a token waits
|
|
300
|
+
* for the server, `false` without auth.
|
|
301
|
+
*/
|
|
302
|
+
readonly isAuthenticated: AtomLike<boolean | null, []>;
|
|
303
|
+
/** The server rejected a confirmed token and a replacement is being fetched. */
|
|
304
|
+
readonly isRefreshingAuth: AtomLike<boolean, []>;
|
|
305
|
+
/** The current user JWT and its decoded claims. */
|
|
306
|
+
readonly authClaims: Computed<{
|
|
307
|
+
token: string;
|
|
308
|
+
decoded: Record<string, any>;
|
|
309
|
+
} | undefined>;
|
|
310
|
+
/** The socket state machine. */
|
|
311
|
+
readonly socketState: AtomLike<SocketState, []>;
|
|
312
|
+
/**
|
|
313
|
+
* A query of this client. Equal queries return the same entry.
|
|
314
|
+
*
|
|
315
|
+
* @param query - A query reference, or a function path.
|
|
316
|
+
* @param args - The query arguments, `{}` by default.
|
|
317
|
+
*/
|
|
318
|
+
query<Query extends QueryRef>(query: Query, args?: ArgsOf<Query>, options?: ReatomQueryOptions): ReatomQuery<ReturnOf<Query>>;
|
|
319
|
+
/**
|
|
320
|
+
* A paginated query of this client. Equal queries with the same `id` share
|
|
321
|
+
* their pages; `id` is `-1` by default.
|
|
322
|
+
*
|
|
323
|
+
* @param query - A paginated query reference, or a function path.
|
|
324
|
+
* @param args - The query arguments without `paginationOpts`.
|
|
325
|
+
*/
|
|
326
|
+
paginatedQuery<Query extends QueryRef>(query: Query, args: Omit<ArgsOf<Query>, "paginationOpts">, options: {
|
|
327
|
+
initialNumItems: number;
|
|
328
|
+
id?: number;
|
|
329
|
+
}): ReatomPaginatedQuery<PaginatedItemOf<Query>>;
|
|
330
|
+
/** Run a mutation; `optimisticUpdate` edits local results until it is reflected. */
|
|
331
|
+
mutation<Mutation extends MutationRef>(mutation: Mutation, args?: ArgsOf<Mutation>, options?: MutationOptions): Promise<ReturnOf<Mutation>>;
|
|
332
|
+
action<Action extends ActionRef>(action: Action, args?: ArgsOf<Action>): Promise<ReturnOf<Action>>;
|
|
333
|
+
/**
|
|
334
|
+
* Fetch a token, send it, and refetch it before it expires or when the
|
|
335
|
+
* server rejects it. `fetchToken` should return `null` when no token can be
|
|
336
|
+
* had, for example after the user's rights were revoked.
|
|
337
|
+
*/
|
|
338
|
+
readonly setAuth: Action<[
|
|
339
|
+
fetchToken: AuthTokenFetcher,
|
|
340
|
+
onChange?: ((isAuthenticated: boolean) => void) | undefined,
|
|
341
|
+
onRefreshChange?: ((isRefreshing: boolean) => void) | undefined
|
|
342
|
+
], void>;
|
|
343
|
+
/** Stop the auth flow and continue without an identity. */
|
|
344
|
+
readonly clearAuth: Action<[], void>;
|
|
345
|
+
/** Close the connection for good. */
|
|
346
|
+
readonly close: Action<[], Promise<void>>;
|
|
347
|
+
}
|
|
348
|
+
export {};
|