@reatomized/convex 0.0.0-stage → 0.1.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/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,523 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The WebSocket half of the client: connect, reconnect with backoff, pause for
|
|
3
|
+
* auth, server inactivity and chunked transitions. The state machine is the
|
|
4
|
+
* one documented in `../sync/web_socket_manager.ts` of convex@1.48.0:
|
|
5
|
+
*
|
|
6
|
+
* disconnected -> connecting -> ready -> disconnected (on close)
|
|
7
|
+
* ready -> stopped (stop()) -> connecting (tryRestart())
|
|
8
|
+
* any -> terminated (terminate())
|
|
9
|
+
*
|
|
10
|
+
* `connecting` and `ready` carry a pause flag for auth token fetches. Socket
|
|
11
|
+
* state, counters and retries are atoms; the socket handle, timers and the
|
|
12
|
+
* chunk buffer live in a per-context runtime object nothing subscribes to.
|
|
13
|
+
*/
|
|
14
|
+
import { atom, context, peek, wrap } from "@reatom/core";
|
|
15
|
+
import { encodeClientMessage, parseServerMessage, } from "../sync/protocol.js";
|
|
16
|
+
const CLOSE_NORMAL = 1000;
|
|
17
|
+
const CLOSE_GOING_AWAY = 1001;
|
|
18
|
+
const CLOSE_NO_STATUS = 1005;
|
|
19
|
+
/** Convex's "404 Not Found": retried, since it can be transient during a push. */
|
|
20
|
+
const CLOSE_NOT_FOUND = 4040;
|
|
21
|
+
const serverDisconnectErrors = {
|
|
22
|
+
// A known error, e.g. during a restart or push
|
|
23
|
+
InternalServerError: { timeout: 1000 },
|
|
24
|
+
// ErrorMetadata::overloaded() messages that we really should back off
|
|
25
|
+
SubscriptionsWorkerFullError: { timeout: 3000 },
|
|
26
|
+
TooManyConcurrentRequests: { timeout: 3000 },
|
|
27
|
+
CommitterFullError: { timeout: 3000 },
|
|
28
|
+
AwsTooManyRequestsException: { timeout: 3000 },
|
|
29
|
+
ExecuteFullError: { timeout: 3000 },
|
|
30
|
+
SystemTimeoutError: { timeout: 3000 },
|
|
31
|
+
ExpiredInQueue: { timeout: 3000 },
|
|
32
|
+
// ErrorMetadata::feature_temporarily_unavailable() that typically indicate a deploy just happened
|
|
33
|
+
VectorIndexesUnavailable: { timeout: 1000 },
|
|
34
|
+
SearchIndexesUnavailable: { timeout: 1000 },
|
|
35
|
+
TableSummariesUnavailable: { timeout: 1000 },
|
|
36
|
+
// More ErrorMetadata::overloaded()
|
|
37
|
+
VectorIndexTooLarge: { timeout: 3000 },
|
|
38
|
+
SearchIndexTooLarge: { timeout: 3000 },
|
|
39
|
+
TooManyWritesInTimePeriod: { timeout: 3000 },
|
|
40
|
+
};
|
|
41
|
+
function classifyDisconnectError(s) {
|
|
42
|
+
if (s === undefined)
|
|
43
|
+
return "Unknown";
|
|
44
|
+
// startsWith so more info could be at the end
|
|
45
|
+
for (const prefix of Object.keys(serverDisconnectErrors)) {
|
|
46
|
+
if (s.startsWith(prefix)) {
|
|
47
|
+
return prefix;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return "Unknown";
|
|
51
|
+
}
|
|
52
|
+
let firstTime;
|
|
53
|
+
export function monotonicMillis() {
|
|
54
|
+
if (firstTime === undefined) {
|
|
55
|
+
firstTime = Date.now();
|
|
56
|
+
}
|
|
57
|
+
if (typeof performance === "undefined" || !performance.now) {
|
|
58
|
+
return Date.now();
|
|
59
|
+
}
|
|
60
|
+
return Math.round(firstTime + performance.now());
|
|
61
|
+
}
|
|
62
|
+
function prettyNow() {
|
|
63
|
+
return `t=${Math.round((monotonicMillis() - firstTime) / 100) / 10}s`;
|
|
64
|
+
}
|
|
65
|
+
const DEFAULT_INITIAL_BACKOFF = 1000;
|
|
66
|
+
const MAX_BACKOFF = 16000;
|
|
67
|
+
// Pings arrive every 15s without other traffic, but one large message can hog
|
|
68
|
+
// the line, so the threshold is set higher.
|
|
69
|
+
const SERVER_INACTIVITY_THRESHOLD = 60000;
|
|
70
|
+
export function reatomSocket(uri, options, callbacks, name) {
|
|
71
|
+
const { webSocketConstructor, logger, debug } = options;
|
|
72
|
+
const state = atom({ state: "disconnected" }, `${name}.state`);
|
|
73
|
+
const connectionCount = atom(0, `${name}.connectionCount`);
|
|
74
|
+
/** Consecutive failed connections, reset once the client syncs past a reconnect. */
|
|
75
|
+
const retries = atom(0, `${name}.retries`);
|
|
76
|
+
const hasEverConnected = atom(false, `${name}.hasEverConnected`);
|
|
77
|
+
const runtime = atom(() => ({
|
|
78
|
+
root: context(),
|
|
79
|
+
ws: null,
|
|
80
|
+
lastCloseReason: "InitialConnect",
|
|
81
|
+
transitionChunkBuffer: null,
|
|
82
|
+
inactivityTimeout: null,
|
|
83
|
+
scheduledReconnect: null,
|
|
84
|
+
networkOnlineHandler: null,
|
|
85
|
+
pendingNetworkRecoveryInfo: null,
|
|
86
|
+
}), `${name}._runtime`);
|
|
87
|
+
// Socket events and timers arrive outside any frame.
|
|
88
|
+
const inRoot = (fn) => wrap(fn, peek(runtime).root);
|
|
89
|
+
const logVerbose = (message) => logger.logVerbose(message);
|
|
90
|
+
const setState = (next) => {
|
|
91
|
+
state.set(next);
|
|
92
|
+
logVerbose(`socket state changed: ${next.state}, paused: ${"paused" in next ? next.paused : undefined}`);
|
|
93
|
+
};
|
|
94
|
+
const setupNetworkListener = () => {
|
|
95
|
+
// React Native has window but not addEventListener.
|
|
96
|
+
if (typeof window === "undefined" ||
|
|
97
|
+
typeof window.addEventListener !== "function") {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const rt = peek(runtime);
|
|
101
|
+
if (rt.networkOnlineHandler !== null)
|
|
102
|
+
return;
|
|
103
|
+
rt.networkOnlineHandler = inRoot(() => {
|
|
104
|
+
logVerbose("network online event detected");
|
|
105
|
+
tryReconnectImmediately();
|
|
106
|
+
});
|
|
107
|
+
window.addEventListener("online", rt.networkOnlineHandler);
|
|
108
|
+
logVerbose("network online event listener registered");
|
|
109
|
+
};
|
|
110
|
+
const cleanupNetworkListener = () => {
|
|
111
|
+
const rt = peek(runtime);
|
|
112
|
+
if (rt.networkOnlineHandler &&
|
|
113
|
+
typeof window !== "undefined" &&
|
|
114
|
+
typeof window.removeEventListener === "function") {
|
|
115
|
+
window.removeEventListener("online", rt.networkOnlineHandler);
|
|
116
|
+
rt.networkOnlineHandler = null;
|
|
117
|
+
logVerbose("network online event listener removed");
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const assembleTransition = (chunk) => {
|
|
121
|
+
const rt = peek(runtime);
|
|
122
|
+
const buffer = rt.transitionChunkBuffer;
|
|
123
|
+
if (chunk.partNumber < 0 ||
|
|
124
|
+
chunk.partNumber >= chunk.totalParts ||
|
|
125
|
+
chunk.totalParts === 0 ||
|
|
126
|
+
(buffer &&
|
|
127
|
+
(buffer.totalParts !== chunk.totalParts ||
|
|
128
|
+
buffer.transitionId !== chunk.transitionId))) {
|
|
129
|
+
// Throwing doesn't crash the client, so clear the buffer.
|
|
130
|
+
rt.transitionChunkBuffer = null;
|
|
131
|
+
throw new Error("Invalid TransitionChunk");
|
|
132
|
+
}
|
|
133
|
+
const current = (rt.transitionChunkBuffer = buffer ?? {
|
|
134
|
+
chunks: [],
|
|
135
|
+
totalParts: chunk.totalParts,
|
|
136
|
+
transitionId: chunk.transitionId,
|
|
137
|
+
});
|
|
138
|
+
if (chunk.partNumber !== current.chunks.length) {
|
|
139
|
+
const expectedLength = current.chunks.length;
|
|
140
|
+
rt.transitionChunkBuffer = null;
|
|
141
|
+
throw new Error(`TransitionChunk received out of order: expected part ${expectedLength}, got ${chunk.partNumber}`);
|
|
142
|
+
}
|
|
143
|
+
current.chunks.push(chunk.chunk);
|
|
144
|
+
if (current.chunks.length < chunk.totalParts)
|
|
145
|
+
return null;
|
|
146
|
+
rt.transitionChunkBuffer = null;
|
|
147
|
+
const transition = parseServerMessage(JSON.parse(current.chunks.join("")));
|
|
148
|
+
if (transition.type !== "Transition") {
|
|
149
|
+
throw new Error(`Expected Transition, got ${transition.type} after assembling chunks`);
|
|
150
|
+
}
|
|
151
|
+
return transition;
|
|
152
|
+
};
|
|
153
|
+
const resetServerInactivityTimeout = () => {
|
|
154
|
+
if (state().state === "terminated")
|
|
155
|
+
return;
|
|
156
|
+
const rt = peek(runtime);
|
|
157
|
+
if (rt.inactivityTimeout !== null) {
|
|
158
|
+
clearTimeout(rt.inactivityTimeout);
|
|
159
|
+
rt.inactivityTimeout = null;
|
|
160
|
+
}
|
|
161
|
+
rt.inactivityTimeout = setTimeout(inRoot(() => closeAndReconnect("InactiveServer")), SERVER_INACTIVITY_THRESHOLD);
|
|
162
|
+
};
|
|
163
|
+
const reportLargeTransition = (transition, messageLength) => {
|
|
164
|
+
if (transition.clientClockSkew === undefined ||
|
|
165
|
+
transition.serverTs === undefined) {
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
// clientClockSkew is how far behind the client clock is, inflated by the
|
|
169
|
+
// latency of the Connect message.
|
|
170
|
+
const transitionTransitTime = monotonicMillis() -
|
|
171
|
+
transition.clientClockSkew -
|
|
172
|
+
transition.serverTs / 1000000;
|
|
173
|
+
const prettyTransitionTime = `${Math.round(transitionTransitTime)}ms`;
|
|
174
|
+
const prettyMessageMB = `${Math.round(messageLength / 10000) / 100}MB`;
|
|
175
|
+
const bytesPerSecond = messageLength / (transitionTransitTime / 1000);
|
|
176
|
+
const prettyBytesPerSecond = `${Math.round(bytesPerSecond / 10000) / 100}MB per second`;
|
|
177
|
+
logVerbose(`received ${prettyMessageMB} transition in ${prettyTransitionTime} at ${prettyBytesPerSecond}`);
|
|
178
|
+
// Shown to all users, so not too aggressive. `logger: false` silences them.
|
|
179
|
+
if (messageLength > 20000000) {
|
|
180
|
+
logger.log(`received query results totaling more that 20MB (${prettyMessageMB}) which will take a long time to download on slower connections`);
|
|
181
|
+
}
|
|
182
|
+
else if (transitionTransitTime > 20000) {
|
|
183
|
+
logger.log(`received query results totaling ${prettyMessageMB} which took more than 20s to arrive (${prettyTransitionTime})`);
|
|
184
|
+
}
|
|
185
|
+
if (debug) {
|
|
186
|
+
send({
|
|
187
|
+
type: "Event",
|
|
188
|
+
eventType: "ClientReceivedTransition",
|
|
189
|
+
event: { transitionTransitTime, messageLength },
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
const onOpen = (ws) => {
|
|
194
|
+
logger.logVerbose("begin ws.onopen");
|
|
195
|
+
const rt = peek(runtime);
|
|
196
|
+
const current = state();
|
|
197
|
+
if (current.state !== "connecting") {
|
|
198
|
+
throw new Error("onopen called with socket not in connecting state");
|
|
199
|
+
}
|
|
200
|
+
const paused = current.paused === "yes" ? "uninitialized" : "no";
|
|
201
|
+
rt.ws = ws;
|
|
202
|
+
setState({ state: "ready", paused });
|
|
203
|
+
resetServerInactivityTimeout();
|
|
204
|
+
if (paused === "no") {
|
|
205
|
+
hasEverConnected.set(true);
|
|
206
|
+
callbacks.onOpen({
|
|
207
|
+
connectionCount: connectionCount(),
|
|
208
|
+
lastCloseReason: rt.lastCloseReason,
|
|
209
|
+
clientTs: monotonicMillis(),
|
|
210
|
+
});
|
|
211
|
+
}
|
|
212
|
+
if (rt.lastCloseReason !== "InitialConnect") {
|
|
213
|
+
if (rt.lastCloseReason) {
|
|
214
|
+
logger.log("WebSocket reconnected at", prettyNow(), "after disconnect due to", rt.lastCloseReason);
|
|
215
|
+
}
|
|
216
|
+
else {
|
|
217
|
+
logger.log("WebSocket reconnected at", prettyNow());
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
connectionCount.set((count) => count + 1);
|
|
221
|
+
rt.lastCloseReason = null;
|
|
222
|
+
if (rt.pendingNetworkRecoveryInfo !== null) {
|
|
223
|
+
const { timeSavedMs } = rt.pendingNetworkRecoveryInfo;
|
|
224
|
+
rt.pendingNetworkRecoveryInfo = null;
|
|
225
|
+
send({
|
|
226
|
+
type: "Event",
|
|
227
|
+
eventType: "NetworkRecoveryReconnect",
|
|
228
|
+
event: { timeSavedMs },
|
|
229
|
+
});
|
|
230
|
+
logger.log(`Network recovery reconnect saved ~${Math.round(timeSavedMs / 1000)}s of waiting`);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
const onMessage = (data) => {
|
|
234
|
+
resetServerInactivityTimeout();
|
|
235
|
+
const rt = peek(runtime);
|
|
236
|
+
const messageLength = data.length;
|
|
237
|
+
let serverMessage = parseServerMessage(JSON.parse(data));
|
|
238
|
+
logVerbose(`received ws message with type ${serverMessage.type}`);
|
|
239
|
+
// A Ping only resets the inactivity timer.
|
|
240
|
+
if (serverMessage.type === "Ping")
|
|
241
|
+
return;
|
|
242
|
+
if (serverMessage.type === "TransitionChunk") {
|
|
243
|
+
const transition = assembleTransition(serverMessage);
|
|
244
|
+
if (!transition)
|
|
245
|
+
return;
|
|
246
|
+
serverMessage = transition;
|
|
247
|
+
logVerbose(`assembled full ws message of type ${serverMessage.type}`);
|
|
248
|
+
}
|
|
249
|
+
if (rt.transitionChunkBuffer !== null) {
|
|
250
|
+
rt.transitionChunkBuffer = null;
|
|
251
|
+
logger.log(`Received unexpected ${serverMessage.type} while buffering TransitionChunks`);
|
|
252
|
+
}
|
|
253
|
+
if (serverMessage.type === "Transition") {
|
|
254
|
+
reportLargeTransition(serverMessage, messageLength);
|
|
255
|
+
}
|
|
256
|
+
if (callbacks.onMessage(serverMessage)) {
|
|
257
|
+
// Reset backoff once all outstanding work is complete.
|
|
258
|
+
retries.set(0);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
const onClose = (event) => {
|
|
262
|
+
logVerbose("begin ws.onclose");
|
|
263
|
+
const rt = peek(runtime);
|
|
264
|
+
rt.transitionChunkBuffer = null;
|
|
265
|
+
if (rt.lastCloseReason === null) {
|
|
266
|
+
// event.reason is often an empty string
|
|
267
|
+
rt.lastCloseReason = event.reason || `closed with code ${event.code}`;
|
|
268
|
+
}
|
|
269
|
+
if (event.code !== CLOSE_NORMAL &&
|
|
270
|
+
event.code !== CLOSE_GOING_AWAY && // common when a mobile app is backgrounded
|
|
271
|
+
event.code !== CLOSE_NO_STATUS &&
|
|
272
|
+
event.code !== CLOSE_NOT_FOUND) {
|
|
273
|
+
let msg = `WebSocket closed with code ${event.code}`;
|
|
274
|
+
if (event.reason) {
|
|
275
|
+
msg += `: ${event.reason}`;
|
|
276
|
+
}
|
|
277
|
+
logger.log(msg);
|
|
278
|
+
if (callbacks.onServerDisconnectError && event.reason) {
|
|
279
|
+
callbacks.onServerDisconnectError(msg);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
scheduleReconnect(classifyDisconnectError(event.reason));
|
|
283
|
+
};
|
|
284
|
+
const connect = () => {
|
|
285
|
+
const current = state().state;
|
|
286
|
+
if (current === "terminated")
|
|
287
|
+
return;
|
|
288
|
+
if (current !== "disconnected" && current !== "stopped") {
|
|
289
|
+
throw new Error("Didn't start connection from disconnected state: " + current);
|
|
290
|
+
}
|
|
291
|
+
const rt = peek(runtime);
|
|
292
|
+
const ws = new webSocketConstructor(uri);
|
|
293
|
+
logVerbose("constructed WebSocket");
|
|
294
|
+
rt.ws = ws;
|
|
295
|
+
setState({ state: "connecting", paused: "no" });
|
|
296
|
+
// Started before onopen, so a failed handshake times out too.
|
|
297
|
+
resetServerInactivityTimeout();
|
|
298
|
+
ws.onopen = inRoot(() => onOpen(ws));
|
|
299
|
+
// The WebSocket API calls `onclose` even if connecting fails, so every
|
|
300
|
+
// error path goes through `onclose`.
|
|
301
|
+
ws.onerror = inRoot((error) => {
|
|
302
|
+
rt.transitionChunkBuffer = null;
|
|
303
|
+
const message = error.message;
|
|
304
|
+
if (message) {
|
|
305
|
+
logger.log(`WebSocket error message: ${message}`);
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
ws.onmessage = inRoot((message) => onMessage(message.data));
|
|
309
|
+
ws.onclose = inRoot(onClose);
|
|
310
|
+
};
|
|
311
|
+
const nextBackoff = (reason) => {
|
|
312
|
+
const initialBackoff = reason === "client"
|
|
313
|
+
? 100 // No evidence of a server problem, retry quickly
|
|
314
|
+
: reason === "Unknown"
|
|
315
|
+
? DEFAULT_INITIAL_BACKOFF
|
|
316
|
+
: serverDisconnectErrors[reason].timeout;
|
|
317
|
+
const baseBackoff = initialBackoff * Math.pow(2, retries());
|
|
318
|
+
retries.set((count) => count + 1);
|
|
319
|
+
const actualBackoff = Math.min(baseBackoff, MAX_BACKOFF);
|
|
320
|
+
const jitter = actualBackoff * (Math.random() - 0.5);
|
|
321
|
+
return actualBackoff + jitter;
|
|
322
|
+
};
|
|
323
|
+
const scheduleReconnect = (reason) => {
|
|
324
|
+
const rt = peek(runtime);
|
|
325
|
+
if (rt.scheduledReconnect) {
|
|
326
|
+
clearTimeout(rt.scheduledReconnect.timeout);
|
|
327
|
+
rt.scheduledReconnect = null;
|
|
328
|
+
}
|
|
329
|
+
rt.ws = null;
|
|
330
|
+
state.set({ state: "disconnected" });
|
|
331
|
+
const backoff = nextBackoff(reason);
|
|
332
|
+
logger.log(`Attempting reconnect in ${Math.round(backoff)}ms`);
|
|
333
|
+
const scheduledAt = monotonicMillis();
|
|
334
|
+
const timeout = setTimeout(inRoot(() => {
|
|
335
|
+
if (rt.scheduledReconnect?.timeout === timeout) {
|
|
336
|
+
rt.scheduledReconnect = null;
|
|
337
|
+
connect();
|
|
338
|
+
}
|
|
339
|
+
}), backoff);
|
|
340
|
+
rt.scheduledReconnect = { timeout, scheduledAt, backoffMs: backoff };
|
|
341
|
+
};
|
|
342
|
+
/**
|
|
343
|
+
* Detaches the current socket and closes it, clearing its handlers so a
|
|
344
|
+
* late `onclose` does not reconnect. The caller sets the next state.
|
|
345
|
+
*/
|
|
346
|
+
const close = () => {
|
|
347
|
+
const rt = peek(runtime);
|
|
348
|
+
rt.transitionChunkBuffer = null;
|
|
349
|
+
const current = state().state;
|
|
350
|
+
const ws = rt.ws;
|
|
351
|
+
if (ws === null || (current !== "connecting" && current !== "ready")) {
|
|
352
|
+
return Promise.resolve();
|
|
353
|
+
}
|
|
354
|
+
rt.ws = null;
|
|
355
|
+
// Messages can still arrive after close but nothing wants them.
|
|
356
|
+
ws.onmessage = () => {
|
|
357
|
+
logVerbose("Ignoring message received after close");
|
|
358
|
+
};
|
|
359
|
+
if (current === "connecting") {
|
|
360
|
+
return new Promise((resolve) => {
|
|
361
|
+
ws.onclose = () => {
|
|
362
|
+
logVerbose("Closed after connecting");
|
|
363
|
+
resolve();
|
|
364
|
+
};
|
|
365
|
+
ws.onopen = () => {
|
|
366
|
+
logVerbose("Opened after connecting");
|
|
367
|
+
ws.close();
|
|
368
|
+
};
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
logVerbose("ws.close called");
|
|
372
|
+
const result = new Promise((resolve) => {
|
|
373
|
+
ws.onclose = () => resolve();
|
|
374
|
+
});
|
|
375
|
+
ws.close();
|
|
376
|
+
return result;
|
|
377
|
+
};
|
|
378
|
+
/** Closes the socket after an error and schedules a reconnect. */
|
|
379
|
+
const closeAndReconnect = (closeReason) => {
|
|
380
|
+
logVerbose(`begin closeAndReconnect with reason ${closeReason}`);
|
|
381
|
+
const current = state().state;
|
|
382
|
+
if (current !== "connecting" && current !== "ready")
|
|
383
|
+
return;
|
|
384
|
+
peek(runtime).lastCloseReason = closeReason;
|
|
385
|
+
void close();
|
|
386
|
+
scheduleReconnect("client");
|
|
387
|
+
};
|
|
388
|
+
const tryReconnectImmediately = () => {
|
|
389
|
+
logVerbose("tryReconnectImmediately called");
|
|
390
|
+
const current = state().state;
|
|
391
|
+
// Only while waiting to reconnect.
|
|
392
|
+
if (current !== "disconnected") {
|
|
393
|
+
logVerbose(`tryReconnectImmediately called but socket state is ${current}, no action taken`);
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
const rt = peek(runtime);
|
|
397
|
+
let timeSavedMs = null;
|
|
398
|
+
if (rt.scheduledReconnect) {
|
|
399
|
+
const elapsed = monotonicMillis() - rt.scheduledReconnect.scheduledAt;
|
|
400
|
+
timeSavedMs = Math.max(0, rt.scheduledReconnect.backoffMs - elapsed);
|
|
401
|
+
logVerbose(`would have waited ${Math.round(timeSavedMs)}ms more (backoff was ${Math.round(rt.scheduledReconnect.backoffMs)}ms, elapsed ${Math.round(elapsed)}ms)`);
|
|
402
|
+
clearTimeout(rt.scheduledReconnect.timeout);
|
|
403
|
+
rt.scheduledReconnect = null;
|
|
404
|
+
logVerbose("canceled scheduled reconnect");
|
|
405
|
+
}
|
|
406
|
+
logger.log("Network recovery detected, reconnecting immediately");
|
|
407
|
+
rt.pendingNetworkRecoveryInfo =
|
|
408
|
+
timeSavedMs !== null ? { timeSavedMs } : null;
|
|
409
|
+
connect();
|
|
410
|
+
};
|
|
411
|
+
/** Sends when open and not paused; returns whether the message might have been sent. */
|
|
412
|
+
const send = (message) => {
|
|
413
|
+
const messageForLog = {
|
|
414
|
+
type: message.type,
|
|
415
|
+
...(message.type === "Authenticate" && message.tokenType === "User"
|
|
416
|
+
? { value: `...${message.value.slice(-7)}` }
|
|
417
|
+
: {}),
|
|
418
|
+
};
|
|
419
|
+
const current = state();
|
|
420
|
+
const ws = peek(runtime).ws;
|
|
421
|
+
if (current.state === "ready" && current.paused === "no" && ws) {
|
|
422
|
+
const request = JSON.stringify(encodeClientMessage(message));
|
|
423
|
+
let sent = false;
|
|
424
|
+
try {
|
|
425
|
+
ws.send(request);
|
|
426
|
+
sent = true;
|
|
427
|
+
}
|
|
428
|
+
catch (error) {
|
|
429
|
+
logger.log(`Failed to send message on WebSocket, reconnecting: ${error}`);
|
|
430
|
+
closeAndReconnect("FailedToSendMessage");
|
|
431
|
+
}
|
|
432
|
+
logVerbose(`${sent ? "sent" : "failed to send"} message with type ${message.type}: ${JSON.stringify(messageForLog)}`);
|
|
433
|
+
return true;
|
|
434
|
+
}
|
|
435
|
+
logVerbose(`message not sent (socket state: ${current.state}, paused: ${"paused" in current ? current.paused : undefined}): ${JSON.stringify(messageForLog)}`);
|
|
436
|
+
return false;
|
|
437
|
+
};
|
|
438
|
+
const clearTimers = () => {
|
|
439
|
+
const rt = peek(runtime);
|
|
440
|
+
if (rt.inactivityTimeout !== null) {
|
|
441
|
+
clearTimeout(rt.inactivityTimeout);
|
|
442
|
+
rt.inactivityTimeout = null;
|
|
443
|
+
}
|
|
444
|
+
if (rt.scheduledReconnect) {
|
|
445
|
+
clearTimeout(rt.scheduledReconnect.timeout);
|
|
446
|
+
rt.scheduledReconnect = null;
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
return {
|
|
450
|
+
/** The state machine position, without the socket handle. */
|
|
451
|
+
state,
|
|
452
|
+
/** Times a socket reached `ready`. */
|
|
453
|
+
connectionCount,
|
|
454
|
+
retries,
|
|
455
|
+
hasEverConnected,
|
|
456
|
+
send,
|
|
457
|
+
/** Opens the first socket. */
|
|
458
|
+
start() {
|
|
459
|
+
setupNetworkListener();
|
|
460
|
+
connect();
|
|
461
|
+
},
|
|
462
|
+
/** Closes the socket until `tryRestart`. */
|
|
463
|
+
stop() {
|
|
464
|
+
if (state().state === "terminated")
|
|
465
|
+
return Promise.resolve();
|
|
466
|
+
cleanupNetworkListener();
|
|
467
|
+
const result = close();
|
|
468
|
+
clearTimers();
|
|
469
|
+
state.set({ state: "stopped" });
|
|
470
|
+
return result;
|
|
471
|
+
},
|
|
472
|
+
/** Reconnects after `stop`, unless terminated meanwhile. */
|
|
473
|
+
tryRestart() {
|
|
474
|
+
if (state().state !== "stopped") {
|
|
475
|
+
logger.logVerbose("Restart called without stopping first");
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
setupNetworkListener();
|
|
479
|
+
connect();
|
|
480
|
+
},
|
|
481
|
+
pause() {
|
|
482
|
+
const current = state();
|
|
483
|
+
// Disconnected or stopped sockets have nothing to pause. A ready socket
|
|
484
|
+
// that hasn't sent its handshake still needs `onOpen` on resume.
|
|
485
|
+
if (current.state === "connecting") {
|
|
486
|
+
state.set({ ...current, paused: "yes" });
|
|
487
|
+
}
|
|
488
|
+
else if (current.state === "ready" &&
|
|
489
|
+
current.paused !== "uninitialized") {
|
|
490
|
+
state.set({ ...current, paused: "yes" });
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
resume() {
|
|
494
|
+
const current = state();
|
|
495
|
+
if (current.state === "connecting") {
|
|
496
|
+
state.set({ state: "connecting", paused: "no" });
|
|
497
|
+
}
|
|
498
|
+
else if (current.state === "ready") {
|
|
499
|
+
if (current.paused === "uninitialized") {
|
|
500
|
+
state.set({ state: "ready", paused: "no" });
|
|
501
|
+
hasEverConnected.set(true);
|
|
502
|
+
callbacks.onOpen({
|
|
503
|
+
connectionCount: connectionCount(),
|
|
504
|
+
lastCloseReason: peek(runtime).lastCloseReason,
|
|
505
|
+
clientTs: monotonicMillis(),
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
else if (current.paused === "yes") {
|
|
509
|
+
state.set({ state: "ready", paused: "no" });
|
|
510
|
+
callbacks.onResume();
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
/** Closes the socket for good. */
|
|
515
|
+
terminate() {
|
|
516
|
+
clearTimers();
|
|
517
|
+
cleanupNetworkListener();
|
|
518
|
+
const result = close();
|
|
519
|
+
setState({ state: "terminated" });
|
|
520
|
+
return result;
|
|
521
|
+
},
|
|
522
|
+
};
|
|
523
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { Value } from "convex/values";
|
|
2
|
+
/**
|
|
3
|
+
* The result of running a function on the server.
|
|
4
|
+
*
|
|
5
|
+
* If the function hit an exception it will have an `errorMessage`. Otherwise
|
|
6
|
+
* it will produce a `Value`.
|
|
7
|
+
*
|
|
8
|
+
* @public
|
|
9
|
+
*/
|
|
10
|
+
export type FunctionResult = FunctionSuccess | FunctionFailure;
|
|
11
|
+
export type FunctionSuccess = {
|
|
12
|
+
success: true;
|
|
13
|
+
value: Value;
|
|
14
|
+
logLines: string[];
|
|
15
|
+
};
|
|
16
|
+
export type FunctionFailure = {
|
|
17
|
+
success: false;
|
|
18
|
+
errorMessage: string;
|
|
19
|
+
errorData?: Value | undefined;
|
|
20
|
+
logLines: string[];
|
|
21
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
declare const markNames: readonly ["convexClientConstructed", "convexWebSocketOpen", "convexFirstMessageReceived"];
|
|
2
|
+
export type MarkName = (typeof markNames)[number];
|
|
3
|
+
export declare function mark(name: MarkName, sessionId: string): void;
|
|
4
|
+
export type MarkJson = {
|
|
5
|
+
name: string;
|
|
6
|
+
startTime: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function getMarksReport(sessionId: string): MarkJson[];
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Marks share a global namespace with other developer code.
|
|
2
|
+
const markNames = [
|
|
3
|
+
"convexClientConstructed",
|
|
4
|
+
"convexWebSocketOpen",
|
|
5
|
+
"convexFirstMessageReceived",
|
|
6
|
+
];
|
|
7
|
+
// `PerformanceMark`s are efficient and show up in browser's performance
|
|
8
|
+
// timeline. They can be cleared with `performance.clearMarks()`.
|
|
9
|
+
// This is a memory leak, but a worthwhile one: automatic
|
|
10
|
+
// cleanup would make in-browser debugging more difficult.
|
|
11
|
+
export function mark(name, sessionId) {
|
|
12
|
+
const detail = { sessionId };
|
|
13
|
+
// `performance` APIs exists in browsers, Node.js, Deno, and more but it
|
|
14
|
+
// is not required by the Convex client.
|
|
15
|
+
if (typeof performance === "undefined" || !performance.mark)
|
|
16
|
+
return;
|
|
17
|
+
performance.mark(name, { detail });
|
|
18
|
+
}
|
|
19
|
+
// `PerfomanceMark` has a built-in toJSON() but the return type varies
|
|
20
|
+
// between implementations, e.g. Node.js returns details but Chrome does not.
|
|
21
|
+
function performanceMarkToJson(mark) {
|
|
22
|
+
// Remove "convex" prefix
|
|
23
|
+
let name = mark.name.slice("convex".length);
|
|
24
|
+
// lowercase the first letter
|
|
25
|
+
name = name.charAt(0).toLowerCase() + name.slice(1);
|
|
26
|
+
return {
|
|
27
|
+
name,
|
|
28
|
+
startTime: mark.startTime,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function getMarksReport(sessionId) {
|
|
32
|
+
if (typeof performance === "undefined" || !performance.getEntriesByName) {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const allMarks = [];
|
|
36
|
+
for (const name of markNames) {
|
|
37
|
+
const marks = performance
|
|
38
|
+
.getEntriesByName(name)
|
|
39
|
+
.filter((entry) => entry.entryType === "mark").filter((mark) => mark.detail.sessionId === sessionId);
|
|
40
|
+
allMarks.push(...marks);
|
|
41
|
+
}
|
|
42
|
+
return allMarks.map(performanceMarkToJson);
|
|
43
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { PaginationResult } from "convex/server";
|
|
2
|
+
import type { Infer, Value } from "convex/values";
|
|
3
|
+
import type { paginationOptsValidator } from "convex/server";
|
|
4
|
+
export type PaginationStatus = "LoadingFirstPage" | "CanLoadMore" | "LoadingMore" | "Exhausted";
|
|
5
|
+
export type PaginatedQueryResult<T> = {
|
|
6
|
+
results: T[];
|
|
7
|
+
status: PaginationStatus;
|
|
8
|
+
loadMore: LoadMoreOfPaginatedQuery;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* Returns whether loading more was actually initiated; in cases where
|
|
12
|
+
* a paginated query is already loading more items or there are no more
|
|
13
|
+
* items available, calling loadMore() may do nothing.
|
|
14
|
+
*/
|
|
15
|
+
export type LoadMoreOfPaginatedQuery = (numItems: number) => boolean;
|
|
16
|
+
export declare function asPaginationArgs(value: Value): Record<string, Value> & {
|
|
17
|
+
paginationOpts: Infer<typeof paginationOptsValidator>;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Validates that a Value is a valid pagination result and returns it cast to PaginationResult.
|
|
21
|
+
*/
|
|
22
|
+
export declare function asPaginationResult(value: Value): PaginationResult<Value>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// The arguments for each page query.
|
|
2
|
+
export function asPaginationArgs(value) {
|
|
3
|
+
if (typeof value.paginationOpts.numItems !== "number") {
|
|
4
|
+
throw new Error(`Not valid paginated query args: ${JSON.stringify(value)}`);
|
|
5
|
+
}
|
|
6
|
+
return value;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Validates that a Value is a valid pagination result and returns it cast to PaginationResult.
|
|
10
|
+
*/
|
|
11
|
+
export function asPaginationResult(value) {
|
|
12
|
+
if (typeof value !== "object" ||
|
|
13
|
+
value === null ||
|
|
14
|
+
!Array.isArray(value.page) ||
|
|
15
|
+
typeof value.isDone !== "boolean" ||
|
|
16
|
+
typeof value.continueCursor !== "string") {
|
|
17
|
+
throw new Error(`Not a valid paginated query result: ${value?.toString()}`);
|
|
18
|
+
}
|
|
19
|
+
return value;
|
|
20
|
+
}
|