@rxferrix/client 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/dist/api.d.ts +60 -0
- package/dist/api.d.ts.map +1 -0
- package/dist/api.js +22 -0
- package/dist/api.js.map +1 -0
- package/dist/auth.d.ts +251 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +531 -0
- package/dist/auth.js.map +1 -0
- package/dist/bytes.d.ts +21 -0
- package/dist/bytes.d.ts.map +1 -0
- package/dist/bytes.js +51 -0
- package/dist/bytes.js.map +1 -0
- package/dist/canonical.d.ts +13 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +66 -0
- package/dist/canonical.js.map +1 -0
- package/dist/client.d.ts +170 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +638 -0
- package/dist/client.js.map +1 -0
- package/dist/diff.d.ts +20 -0
- package/dist/diff.d.ts.map +1 -0
- package/dist/diff.js +100 -0
- package/dist/diff.js.map +1 -0
- package/dist/errors.d.ts +24 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +34 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/local_query_set.d.ts +56 -0
- package/dist/local_query_set.d.ts.map +1 -0
- package/dist/local_query_set.js +108 -0
- package/dist/local_query_set.js.map +1 -0
- package/dist/logging.d.ts +15 -0
- package/dist/logging.d.ts.map +1 -0
- package/dist/logging.js +17 -0
- package/dist/logging.js.map +1 -0
- package/dist/optimistic_updates.d.ts +48 -0
- package/dist/optimistic_updates.d.ts.map +1 -0
- package/dist/optimistic_updates.js +113 -0
- package/dist/optimistic_updates.js.map +1 -0
- package/dist/paginated_query.d.ts +118 -0
- package/dist/paginated_query.d.ts.map +1 -0
- package/dist/paginated_query.js +199 -0
- package/dist/paginated_query.js.map +1 -0
- package/dist/protocol.d.ts +136 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +11 -0
- package/dist/protocol.js.map +1 -0
- package/dist/query_state.d.ts +16 -0
- package/dist/query_state.d.ts.map +1 -0
- package/dist/query_state.js +2 -0
- package/dist/query_state.js.map +1 -0
- package/dist/remote_query_set.d.ts +37 -0
- package/dist/remote_query_set.d.ts.map +1 -0
- package/dist/remote_query_set.js +87 -0
- package/dist/remote_query_set.js.map +1 -0
- package/dist/request_manager.d.ts +57 -0
- package/dist/request_manager.d.ts.map +1 -0
- package/dist/request_manager.js +163 -0
- package/dist/request_manager.js.map +1 -0
- package/dist/storage.d.ts +45 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +120 -0
- package/dist/storage.js.map +1 -0
- package/dist/testing.d.ts +68 -0
- package/dist/testing.d.ts.map +1 -0
- package/dist/testing.js +97 -0
- package/dist/testing.js.map +1 -0
- package/dist/web_socket_manager.d.ts +88 -0
- package/dist/web_socket_manager.d.ts.map +1 -0
- package/dist/web_socket_manager.js +291 -0
- package/dist/web_socket_manager.js.map +1 -0
- package/package.json +50 -4
- package/src/api.ts +86 -0
- package/src/auth.ts +636 -0
- package/src/bytes.ts +57 -0
- package/src/canonical.ts +61 -0
- package/src/client.ts +740 -0
- package/src/diff.ts +87 -0
- package/src/errors.ts +36 -0
- package/src/index.ts +60 -0
- package/src/local_query_set.ts +130 -0
- package/src/logging.ts +26 -0
- package/src/optimistic_updates.ts +137 -0
- package/src/paginated_query.ts +299 -0
- package/src/protocol.ts +152 -0
- package/src/query_state.ts +12 -0
- package/src/remote_query_set.ts +105 -0
- package/src/request_manager.ts +199 -0
- package/src/storage.ts +171 -0
- package/src/testing.ts +120 -0
- package/src/web_socket_manager.ts +322 -0
- package/README.md +0 -3
package/src/client.ts
ADDED
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ArgsAndOptions,
|
|
3
|
+
ArgsOf,
|
|
4
|
+
FunctionReference,
|
|
5
|
+
OptionalArgs,
|
|
6
|
+
OutputOf,
|
|
7
|
+
} from "./api.js";
|
|
8
|
+
import { normalizeArgs, queryToken, type QueryToken } from "./canonical.js";
|
|
9
|
+
import { CLIENT_CLOSED, RxFerrixError } from "./errors.js";
|
|
10
|
+
import { LocalQuerySet } from "./local_query_set.js";
|
|
11
|
+
import { createLogger, type Logger } from "./logging.js";
|
|
12
|
+
import {
|
|
13
|
+
OptimisticQueryResults,
|
|
14
|
+
type OptimisticLocalStore,
|
|
15
|
+
type OptimisticUpdate,
|
|
16
|
+
type ViewMap,
|
|
17
|
+
} from "./optimistic_updates.js";
|
|
18
|
+
import type {
|
|
19
|
+
ActionMessage,
|
|
20
|
+
AuthErrorMessage,
|
|
21
|
+
MutationMessage,
|
|
22
|
+
QueryId,
|
|
23
|
+
ServerMessage,
|
|
24
|
+
TransitionMessage,
|
|
25
|
+
} from "./protocol.js";
|
|
26
|
+
import type { QueryState } from "./query_state.js";
|
|
27
|
+
import {
|
|
28
|
+
PaginatedQueryImpl,
|
|
29
|
+
type PaginatedQuery,
|
|
30
|
+
type PaginatedQueryArgs,
|
|
31
|
+
type PaginatedQueryItem,
|
|
32
|
+
type PaginatedQueryOptions,
|
|
33
|
+
type PaginatedQueryReference,
|
|
34
|
+
} from "./paginated_query.js";
|
|
35
|
+
import { DiffMismatch } from "./diff.js";
|
|
36
|
+
import { RemoteQuerySet, TransitionVersionMismatch } from "./remote_query_set.js";
|
|
37
|
+
import { RequestManager, type SettledMutation } from "./request_manager.js";
|
|
38
|
+
import { WebSocketManager } from "./web_socket_manager.js";
|
|
39
|
+
|
|
40
|
+
export interface RxFerrixClientOptions {
|
|
41
|
+
/** WebSocket implementation to use (defaults to `globalThis.WebSocket`). Needed on Node < 22 or for tests. */
|
|
42
|
+
webSocketConstructor?: typeof WebSocket;
|
|
43
|
+
/** Log every protocol message to `console.debug`. */
|
|
44
|
+
verbose?: boolean;
|
|
45
|
+
/** Log connection diagnostics (reconnects and their reasons) to `console.info`. */
|
|
46
|
+
reportDebugInfoToConsole?: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* Show the browser's "unsaved changes" prompt when the page is closed while mutations are in flight.
|
|
49
|
+
* Defaults to true in browsers.
|
|
50
|
+
*/
|
|
51
|
+
unsavedChangesWarning?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface MutationOptions<Args> {
|
|
55
|
+
optimisticUpdate?: OptimisticUpdate<Args>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Fetches a JWT (or other bearer token). `forceRefresh` is true when the cached token was rejected or expires. */
|
|
59
|
+
export type AuthTokenFetcher = (options: { forceRefresh: boolean }) => Promise<string | null | undefined>;
|
|
60
|
+
|
|
61
|
+
export interface ConnectionState {
|
|
62
|
+
/** The socket is open and the session handshake has been sent. */
|
|
63
|
+
readonly isWebSocketConnected: boolean;
|
|
64
|
+
/** At least one mutation or action has not settled yet. */
|
|
65
|
+
readonly hasInflightRequests: boolean;
|
|
66
|
+
readonly hasEverConnected: boolean;
|
|
67
|
+
/** Number of sockets opened so far, minus one (the `connectionCount` of the current `Connect`). */
|
|
68
|
+
readonly connectionCount: number;
|
|
69
|
+
/** Consecutive failed connection attempts since the last healthy socket. */
|
|
70
|
+
readonly connectionRetries: number;
|
|
71
|
+
readonly inflightMutations: number;
|
|
72
|
+
readonly inflightActions: number;
|
|
73
|
+
readonly timeOfOldestInflightRequest: Date | null;
|
|
74
|
+
/** Whether the server has confirmed the current auth token. */
|
|
75
|
+
readonly isAuthenticated: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface QueryWatch<T> {
|
|
79
|
+
/** Subscribes to changes of this query. The server subscription lives while at least one callback is registered. */
|
|
80
|
+
onUpdate(callback: () => void): () => void;
|
|
81
|
+
/** The current value, `undefined` while loading. Throws the query's {@link RxFerrixError} if it failed. */
|
|
82
|
+
localQueryResult(): T | undefined;
|
|
83
|
+
/** The current state. The returned object is referentially stable while the visible value does not change. */
|
|
84
|
+
localQueryState(): QueryState<T>;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function deriveSyncUrl(url: string): string {
|
|
88
|
+
let parsed: URL;
|
|
89
|
+
try {
|
|
90
|
+
parsed = new URL(url);
|
|
91
|
+
} catch {
|
|
92
|
+
throw new Error(`RxFerrixClient: invalid deployment URL ${JSON.stringify(url)}`);
|
|
93
|
+
}
|
|
94
|
+
switch (parsed.protocol) {
|
|
95
|
+
case "http:":
|
|
96
|
+
case "ws:":
|
|
97
|
+
parsed.protocol = "ws:";
|
|
98
|
+
break;
|
|
99
|
+
case "https:":
|
|
100
|
+
case "wss:":
|
|
101
|
+
parsed.protocol = "wss:";
|
|
102
|
+
break;
|
|
103
|
+
default:
|
|
104
|
+
throw new Error(`RxFerrixClient: unsupported URL protocol ${parsed.protocol}`);
|
|
105
|
+
}
|
|
106
|
+
parsed.pathname = parsed.pathname.replace(/\/+$/, "") + "/api/sync";
|
|
107
|
+
parsed.search = "";
|
|
108
|
+
parsed.hash = "";
|
|
109
|
+
return parsed.toString();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function randomSessionId(): string {
|
|
113
|
+
const c = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
114
|
+
if (c?.randomUUID) return c.randomUUID();
|
|
115
|
+
// RFC 4122 v4 fallback.
|
|
116
|
+
return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (ch) => {
|
|
117
|
+
const r = (Math.random() * 16) | 0;
|
|
118
|
+
return (ch === "x" ? r : (r & 0x3) | 0x8).toString(16);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Seconds-since-epoch `exp` claim of a JWT, if the token is a decodable JWT. */
|
|
123
|
+
function jwtExpiry(token: string): number | null {
|
|
124
|
+
const parts = token.split(".");
|
|
125
|
+
if (parts.length !== 3 || !parts[1]) return null;
|
|
126
|
+
try {
|
|
127
|
+
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
128
|
+
const padded = b64 + "=".repeat((4 - (b64.length % 4)) % 4);
|
|
129
|
+
const json = typeof atob === "function" ? atob(padded) : "";
|
|
130
|
+
const payload = JSON.parse(json) as { exp?: unknown };
|
|
131
|
+
return typeof payload.exp === "number" ? payload.exp : null;
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Refresh this long before a JWT's `exp`. */
|
|
138
|
+
const TOKEN_REFRESH_LEEWAY_MS = 30_000;
|
|
139
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
140
|
+
|
|
141
|
+
interface AuthState {
|
|
142
|
+
fetchToken: AuthTokenFetcher;
|
|
143
|
+
onChange: ((isAuthenticated: boolean) => void) | undefined;
|
|
144
|
+
/** True after one forced refresh in response to an AuthError; reset once a token is confirmed. */
|
|
145
|
+
retriedAfterAuthError: boolean;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The RxFerrix sync client: subscribes to queries over a WebSocket and runs mutations and actions.
|
|
150
|
+
*
|
|
151
|
+
* ```ts
|
|
152
|
+
* const client = new RxFerrixClient("http://localhost:3210");
|
|
153
|
+
* const watch = client.watchQuery(api.todos.list, {});
|
|
154
|
+
* watch.onUpdate(() => console.log(watch.localQueryResult()));
|
|
155
|
+
* await client.mutation(api.todos.add, { text: "hi" });
|
|
156
|
+
* ```
|
|
157
|
+
*/
|
|
158
|
+
export class RxFerrixClient {
|
|
159
|
+
/** The http(s) base URL this client was created with. */
|
|
160
|
+
readonly url: string;
|
|
161
|
+
/** The WebSocket URL (`ws(s)://host/api/sync`). */
|
|
162
|
+
readonly syncUrl: string;
|
|
163
|
+
readonly sessionId: string;
|
|
164
|
+
|
|
165
|
+
private readonly logger: Logger;
|
|
166
|
+
private readonly ws: WebSocketManager;
|
|
167
|
+
private readonly localQueries = new LocalQuerySet();
|
|
168
|
+
private readonly remoteQueries = new RemoteQuerySet();
|
|
169
|
+
private readonly requests = new RequestManager();
|
|
170
|
+
private readonly optimistic: OptimisticQueryResults;
|
|
171
|
+
private readonly listeners = new Map<QueryToken, Set<() => void>>();
|
|
172
|
+
private readonly connectionListeners = new Set<(state: ConnectionState) => void>();
|
|
173
|
+
private cachedConnectionState: ConnectionState;
|
|
174
|
+
private flushScheduled = false;
|
|
175
|
+
private closed = false;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Set after a reconnect while live queries have not yet received results from the new socket. The local view then
|
|
179
|
+
* still shows results from the old socket, so it is not "at" the new socket's ts; mutation completion waits.
|
|
180
|
+
*/
|
|
181
|
+
private resyncQuerySetVersion: number | null = null;
|
|
182
|
+
|
|
183
|
+
// Auth
|
|
184
|
+
private auth: AuthState | null = null;
|
|
185
|
+
private authToken: string | null = null;
|
|
186
|
+
/** The token held by the server on this socket (null = none sent / cleared). */
|
|
187
|
+
private serverToken: string | null = null;
|
|
188
|
+
/** Identity version sent on this socket (the server's identity version once processed). */
|
|
189
|
+
private identityVersion = 0;
|
|
190
|
+
/** Identity version whose confirmation (a Transition reaching it) turns `isAuthenticated` on. */
|
|
191
|
+
private awaitingAuthConfirmation: number | null = null;
|
|
192
|
+
private isAuthenticated = false;
|
|
193
|
+
private authGeneration = 0;
|
|
194
|
+
private authRefreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
195
|
+
|
|
196
|
+
private readonly removeUnloadListener: () => void;
|
|
197
|
+
|
|
198
|
+
constructor(url: string, options: RxFerrixClientOptions = {}) {
|
|
199
|
+
if (typeof url !== "string") throw new Error("RxFerrixClient: url must be a string");
|
|
200
|
+
this.url = url;
|
|
201
|
+
this.syncUrl = deriveSyncUrl(url);
|
|
202
|
+
this.sessionId = randomSessionId();
|
|
203
|
+
this.logger = createLogger({
|
|
204
|
+
verbose: options.verbose ?? false,
|
|
205
|
+
reportDebugInfoToConsole: options.reportDebugInfoToConsole ?? false,
|
|
206
|
+
});
|
|
207
|
+
this.optimistic = new OptimisticQueryResults(this.logger);
|
|
208
|
+
|
|
209
|
+
const WS = options.webSocketConstructor ?? (globalThis as { WebSocket?: typeof WebSocket }).WebSocket;
|
|
210
|
+
if (!WS) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
"RxFerrixClient: no global WebSocket available; pass `webSocketConstructor` (e.g. from the `ws` package)",
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
this.ws = new WebSocketManager(
|
|
216
|
+
{ url: this.syncUrl, sessionId: this.sessionId, webSocketConstructor: WS, logger: this.logger },
|
|
217
|
+
{
|
|
218
|
+
onOpen: () => this.onSocketOpen(),
|
|
219
|
+
onResume: () => this.syncToServer(),
|
|
220
|
+
onMessage: (message) => this.onMessage(message),
|
|
221
|
+
onClose: () => this.onSocketClosed(),
|
|
222
|
+
onStateChange: () => this.notifyConnectionState(),
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
this.cachedConnectionState = this.computeConnectionState();
|
|
226
|
+
this.removeUnloadListener = this.installUnloadListener(options.unsavedChangesWarning);
|
|
227
|
+
this.ws.start();
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
231
|
+
// Queries
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Creates a watch on a query. Nothing is sent until `onUpdate` registers the first callback; all watches on the
|
|
235
|
+
* same `(udfPath, canonicalJSON(args))` share one server subscription.
|
|
236
|
+
*/
|
|
237
|
+
watchQuery<Q extends FunctionReference<"query">>(ref: Q, ...args: OptionalArgs<Q>): QueryWatch<OutputOf<Q>> {
|
|
238
|
+
const udfPath = ref._path;
|
|
239
|
+
const normalized = normalizeArgs(args[0]);
|
|
240
|
+
const token = queryToken(udfPath, normalized);
|
|
241
|
+
return {
|
|
242
|
+
onUpdate: (callback) => this.subscribe(token, udfPath, normalized, callback),
|
|
243
|
+
localQueryResult: () => {
|
|
244
|
+
const state = this.optimistic.get(token) as QueryState<OutputOf<Q>>;
|
|
245
|
+
if (state.status === "error") throw state.error;
|
|
246
|
+
return state.status === "success" ? state.value : undefined;
|
|
247
|
+
},
|
|
248
|
+
localQueryState: () => this.optimistic.get(token) as QueryState<OutputOf<Q>>,
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Runs a query once: subscribes, waits for the first result, then unsubscribes. */
|
|
253
|
+
query<Q extends FunctionReference<"query">>(ref: Q, ...args: OptionalArgs<Q>): Promise<OutputOf<Q>> {
|
|
254
|
+
if (this.closed) return Promise.reject(new RxFerrixError("Client is closed", { code: CLIENT_CLOSED }));
|
|
255
|
+
const watch = this.watchQuery(ref, ...args);
|
|
256
|
+
return new Promise<OutputOf<Q>>((resolve, reject) => {
|
|
257
|
+
let unsubscribe: (() => void) | null = null;
|
|
258
|
+
let done = false;
|
|
259
|
+
const check = () => {
|
|
260
|
+
if (done) return;
|
|
261
|
+
const state = watch.localQueryState();
|
|
262
|
+
if (state.status === "loading") return;
|
|
263
|
+
done = true;
|
|
264
|
+
if (unsubscribe) unsubscribe();
|
|
265
|
+
if (state.status === "success") resolve(state.value);
|
|
266
|
+
else reject(state.error);
|
|
267
|
+
};
|
|
268
|
+
unsubscribe = watch.onUpdate(check);
|
|
269
|
+
check();
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Creates a reactive paginated query over a query that takes `paginationOpts` and returns a `PaginationResult`
|
|
275
|
+
* (see `paginated_query.ts`). Pages are subscribed while the returned object has subscribers.
|
|
276
|
+
*
|
|
277
|
+
* ```ts
|
|
278
|
+
* const messages = client.paginatedQuery(api.messages.list, { channel }, { initialNumItems: 20 });
|
|
279
|
+
* messages.subscribe(() => render(messages.getCurrent())); // { results, status, loadMore, ... }
|
|
280
|
+
* ```
|
|
281
|
+
*/
|
|
282
|
+
paginatedQuery<Q extends PaginatedQueryReference>(
|
|
283
|
+
ref: Q,
|
|
284
|
+
args: PaginatedQueryArgs<Q>,
|
|
285
|
+
options: PaginatedQueryOptions,
|
|
286
|
+
): PaginatedQuery<PaginatedQueryItem<Q>> {
|
|
287
|
+
return new PaginatedQueryImpl<PaginatedQueryItem<Q>>(this, ref, args as Record<string, unknown>, options);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private subscribe(
|
|
291
|
+
token: QueryToken,
|
|
292
|
+
udfPath: string,
|
|
293
|
+
args: Record<string, unknown>,
|
|
294
|
+
callback: () => void,
|
|
295
|
+
): () => void {
|
|
296
|
+
if (this.closed) return () => {};
|
|
297
|
+
let set = this.listeners.get(token);
|
|
298
|
+
if (!set) {
|
|
299
|
+
set = new Set();
|
|
300
|
+
this.listeners.set(token, set);
|
|
301
|
+
}
|
|
302
|
+
// Wrap so the same callback can be registered twice and removed independently.
|
|
303
|
+
const entry = () => callback();
|
|
304
|
+
set.add(entry);
|
|
305
|
+
const { needsFlush } = this.localQueries.addRef(udfPath, args);
|
|
306
|
+
if (needsFlush) this.scheduleFlush();
|
|
307
|
+
let active = true;
|
|
308
|
+
return () => {
|
|
309
|
+
if (!active) return;
|
|
310
|
+
active = false;
|
|
311
|
+
const current = this.listeners.get(token);
|
|
312
|
+
current?.delete(entry);
|
|
313
|
+
if (current && current.size === 0) this.listeners.delete(token);
|
|
314
|
+
if (this.localQueries.removeRef(token)) this.scheduleFlush();
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Batches query-set changes made in the same tick into one ModifyQuerySet. */
|
|
319
|
+
private scheduleFlush(): void {
|
|
320
|
+
if (this.flushScheduled || this.closed) return;
|
|
321
|
+
this.flushScheduled = true;
|
|
322
|
+
queueMicrotask(() => {
|
|
323
|
+
this.flushScheduled = false;
|
|
324
|
+
if (this.closed) return;
|
|
325
|
+
this.flushQuerySet();
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private flushQuerySet(): void {
|
|
330
|
+
const { message, removed } = this.localQueries.flush(this.ws.canSend());
|
|
331
|
+
if (removed.length > 0) {
|
|
332
|
+
for (const queryId of removed) this.remoteQueries.remove(queryId);
|
|
333
|
+
this.recomputeAndNotify();
|
|
334
|
+
}
|
|
335
|
+
if (message) this.ws.send(message);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
339
|
+
// Mutations and actions
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Runs a mutation. The returned promise resolves with the mutation's result once the client's queries reflect the
|
|
343
|
+
* mutation (read-your-writes); it rejects with a {@link RxFerrixError} if the mutation fails.
|
|
344
|
+
*/
|
|
345
|
+
mutation<M extends FunctionReference<"mutation">>(
|
|
346
|
+
ref: M,
|
|
347
|
+
...argsAndOptions: ArgsAndOptions<M, MutationOptions<ArgsOf<M>>>
|
|
348
|
+
): Promise<OutputOf<M>> {
|
|
349
|
+
if (this.closed) return Promise.reject(new RxFerrixError("Client is closed", { code: CLIENT_CLOSED }));
|
|
350
|
+
const args = normalizeArgs(argsAndOptions[0]);
|
|
351
|
+
const options = argsAndOptions[1] as MutationOptions<ArgsOf<M>> | undefined;
|
|
352
|
+
const requestId = this.requests.allocateRequestId();
|
|
353
|
+
const message: MutationMessage = { type: "Mutation", requestId, udfPath: ref._path, args };
|
|
354
|
+
const promise = this.requests.trackMutation(message) as Promise<OutputOf<M>>;
|
|
355
|
+
if (options?.optimisticUpdate) {
|
|
356
|
+
const update = options.optimisticUpdate;
|
|
357
|
+
const typedArgs = args as ArgsOf<M>;
|
|
358
|
+
this.optimistic.addUpdate(requestId, (store: OptimisticLocalStore) => update(store, typedArgs));
|
|
359
|
+
this.recomputeAndNotify();
|
|
360
|
+
}
|
|
361
|
+
this.sendUnsentRequests();
|
|
362
|
+
this.notifyConnectionState();
|
|
363
|
+
return promise;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Runs an action. Rejects with a "connection lost" error if the socket drops while it is in flight. */
|
|
367
|
+
action<A extends FunctionReference<"action">>(ref: A, ...args: OptionalArgs<A>): Promise<OutputOf<A>> {
|
|
368
|
+
if (this.closed) return Promise.reject(new RxFerrixError("Client is closed", { code: CLIENT_CLOSED }));
|
|
369
|
+
const requestId = this.requests.allocateRequestId();
|
|
370
|
+
const message: ActionMessage = { type: "Action", requestId, udfPath: ref._path, args: normalizeArgs(args[0]) };
|
|
371
|
+
const promise = this.requests.trackAction(message) as Promise<OutputOf<A>>;
|
|
372
|
+
this.sendUnsentRequests();
|
|
373
|
+
this.notifyConnectionState();
|
|
374
|
+
return promise;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private sendUnsentRequests(): void {
|
|
378
|
+
if (!this.ws.canSend()) return;
|
|
379
|
+
for (const message of this.requests.unsent()) {
|
|
380
|
+
if (!this.ws.send(message)) break;
|
|
381
|
+
this.requests.markSent(message.requestId);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
386
|
+
// Auth
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Sets the auth token source. The client fetches a token right away (holding back query subscriptions until it
|
|
390
|
+
* has one), sends it with `Authenticate`, re-sends it on every reconnect, refreshes JWTs shortly before they expire,
|
|
391
|
+
* and retries once with `forceRefresh: true` when the server rejects the token.
|
|
392
|
+
*
|
|
393
|
+
* `onChange(true)` fires once the server has confirmed the token; `onChange(false)` when auth is lost or cleared.
|
|
394
|
+
*/
|
|
395
|
+
setAuth(fetchToken: AuthTokenFetcher, onChange?: (isAuthenticated: boolean) => void): void {
|
|
396
|
+
if (this.closed) return;
|
|
397
|
+
this.auth = { fetchToken, onChange, retriedAfterAuthError: false };
|
|
398
|
+
void this.fetchAndApplyToken(false, true);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** Removes the token: the server falls back to an anonymous identity. */
|
|
402
|
+
clearAuth(): void {
|
|
403
|
+
const previous = this.auth;
|
|
404
|
+
this.auth = null;
|
|
405
|
+
this.authGeneration++;
|
|
406
|
+
this.clearAuthRefreshTimer();
|
|
407
|
+
this.applyToken(null);
|
|
408
|
+
if (this.ws.isPaused) this.ws.resume();
|
|
409
|
+
this.setAuthenticated(false, previous?.onChange);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private async fetchAndApplyToken(forceRefresh: boolean, pause: boolean): Promise<void> {
|
|
413
|
+
const auth = this.auth;
|
|
414
|
+
if (!auth) return;
|
|
415
|
+
const generation = ++this.authGeneration;
|
|
416
|
+
if (pause) this.ws.pause();
|
|
417
|
+
let token: string | null = null;
|
|
418
|
+
try {
|
|
419
|
+
token = (await auth.fetchToken({ forceRefresh })) ?? null;
|
|
420
|
+
} catch (e) {
|
|
421
|
+
this.logger.error("fetchToken threw; continuing unauthenticated", e);
|
|
422
|
+
token = null;
|
|
423
|
+
}
|
|
424
|
+
if (generation !== this.authGeneration || this.auth !== auth || this.closed) return;
|
|
425
|
+
this.applyToken(token);
|
|
426
|
+
if (token === null) this.setAuthenticated(false, auth.onChange);
|
|
427
|
+
this.scheduleTokenRefresh(token);
|
|
428
|
+
if (pause) this.ws.resume();
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/** Records the desired token and sends `Authenticate` when the server's token differs. */
|
|
432
|
+
private applyToken(token: string | null): void {
|
|
433
|
+
this.authToken = token;
|
|
434
|
+
this.syncToServer();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
private sendAuthenticateIfNeeded(): void {
|
|
438
|
+
if (this.authToken === this.serverToken) return;
|
|
439
|
+
const baseVersion = this.identityVersion;
|
|
440
|
+
const sent = this.ws.send({ type: "Authenticate", baseVersion, token: this.authToken });
|
|
441
|
+
if (!sent) return;
|
|
442
|
+
this.identityVersion = baseVersion + 1;
|
|
443
|
+
this.serverToken = this.authToken;
|
|
444
|
+
this.awaitingAuthConfirmation = this.authToken !== null ? this.identityVersion : null;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
private scheduleTokenRefresh(token: string | null): void {
|
|
448
|
+
this.clearAuthRefreshTimer();
|
|
449
|
+
if (!token) return;
|
|
450
|
+
const exp = jwtExpiry(token);
|
|
451
|
+
if (exp === null) return;
|
|
452
|
+
const delay = Math.min(MAX_TIMEOUT_MS, Math.max(0, exp * 1000 - Date.now() - TOKEN_REFRESH_LEEWAY_MS));
|
|
453
|
+
this.authRefreshTimer = setTimeout(() => {
|
|
454
|
+
this.authRefreshTimer = null;
|
|
455
|
+
void this.fetchAndApplyToken(true, false);
|
|
456
|
+
}, delay);
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private clearAuthRefreshTimer(): void {
|
|
460
|
+
if (this.authRefreshTimer !== null) {
|
|
461
|
+
clearTimeout(this.authRefreshTimer);
|
|
462
|
+
this.authRefreshTimer = null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
private onAuthError(message: AuthErrorMessage): void {
|
|
467
|
+
this.logger.warn(`authentication rejected: ${message.error}`);
|
|
468
|
+
const auth = this.auth;
|
|
469
|
+
// The server closes the socket after AuthError; the next socket must not resend the rejected token blindly.
|
|
470
|
+
this.ws.reconnect("AuthError");
|
|
471
|
+
if (auth && !auth.retriedAfterAuthError) {
|
|
472
|
+
auth.retriedAfterAuthError = true;
|
|
473
|
+
void this.fetchAndApplyToken(true, true);
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
this.authToken = null;
|
|
477
|
+
this.clearAuthRefreshTimer();
|
|
478
|
+
this.setAuthenticated(false, auth?.onChange);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
private setAuthenticated(value: boolean, onChange: ((v: boolean) => void) | undefined): void {
|
|
482
|
+
const changed = this.isAuthenticated !== value;
|
|
483
|
+
this.isAuthenticated = value;
|
|
484
|
+
if (changed) this.notifyConnectionState();
|
|
485
|
+
if (onChange && (changed || !value)) {
|
|
486
|
+
try {
|
|
487
|
+
onChange(value);
|
|
488
|
+
} catch (e) {
|
|
489
|
+
this.logger.error("auth onChange threw", e);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
495
|
+
// Socket lifecycle
|
|
496
|
+
|
|
497
|
+
/** A new socket is open and `Connect` was sent: rebuild the server's state from scratch. */
|
|
498
|
+
private onSocketOpen(): void {
|
|
499
|
+
this.remoteQueries.resetVersion();
|
|
500
|
+
this.localQueries.resetForNewSocket();
|
|
501
|
+
this.identityVersion = 0;
|
|
502
|
+
this.serverToken = null;
|
|
503
|
+
this.awaitingAuthConfirmation = null;
|
|
504
|
+
this.syncToServer();
|
|
505
|
+
// The view still shows the previous socket's results until the re-added queries report from this socket.
|
|
506
|
+
const resent = this.localQueries.sentQuerySetVersion;
|
|
507
|
+
this.resyncQuerySetVersion = resent > 0 ? resent : null;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Sends everything the server does not know yet, in protocol order: Authenticate, ModifyQuerySet, requests. */
|
|
511
|
+
private syncToServer(): void {
|
|
512
|
+
if (!this.ws.canSend()) return;
|
|
513
|
+
this.sendAuthenticateIfNeeded();
|
|
514
|
+
this.flushQuerySet();
|
|
515
|
+
this.sendUnsentRequests();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
private onSocketClosed(): void {
|
|
519
|
+
const rejected = this.requests.onSocketClosed();
|
|
520
|
+
for (const r of rejected) r.settle();
|
|
521
|
+
if (rejected.length > 0) this.notifyConnectionState();
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
private onMessage(message: ServerMessage): void {
|
|
525
|
+
switch (message.type) {
|
|
526
|
+
case "Transition":
|
|
527
|
+
this.onTransition(message);
|
|
528
|
+
return;
|
|
529
|
+
case "MutationResponse": {
|
|
530
|
+
const udfPath = this.requests.udfPathOf(message.requestId) ?? "?";
|
|
531
|
+
this.logger.logLines("mutation", udfPath, message.logLines);
|
|
532
|
+
const settled = this.requests.onMutationResponse(message, this.observedTs());
|
|
533
|
+
if (settled) this.finishMutations([settled]);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
case "ActionResponse": {
|
|
537
|
+
const udfPath = this.requests.udfPathOf(message.requestId) ?? "?";
|
|
538
|
+
this.logger.logLines("action", udfPath, message.logLines);
|
|
539
|
+
const settle = this.requests.onActionResponse(message);
|
|
540
|
+
if (settle) {
|
|
541
|
+
settle();
|
|
542
|
+
this.notifyConnectionState();
|
|
543
|
+
}
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
case "FatalError":
|
|
547
|
+
this.logger.error(`server reported a fatal error: ${message.error}`);
|
|
548
|
+
this.ws.reconnect(`FatalError: ${message.error}`);
|
|
549
|
+
return;
|
|
550
|
+
case "AuthError":
|
|
551
|
+
this.onAuthError(message);
|
|
552
|
+
return;
|
|
553
|
+
case "Ping":
|
|
554
|
+
return; // Pong is sent by the socket manager.
|
|
555
|
+
default:
|
|
556
|
+
this.logger.warn("unknown server message", message);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** The ts the local view is consistent with, or null while resyncing after a reconnect. */
|
|
561
|
+
private observedTs(): number | null {
|
|
562
|
+
return this.resyncQuerySetVersion === null ? this.remoteQueries.version.ts : null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
private onTransition(message: TransitionMessage): void {
|
|
566
|
+
let result;
|
|
567
|
+
try {
|
|
568
|
+
result = this.remoteQueries.transition(message, (queryId) => this.localQueries.getById(queryId) !== undefined);
|
|
569
|
+
} catch (e) {
|
|
570
|
+
if (e instanceof TransitionVersionMismatch) {
|
|
571
|
+
this.logger.warn(e.message);
|
|
572
|
+
this.ws.reconnect("TransitionVersionMismatch");
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (e instanceof DiffMismatch) {
|
|
576
|
+
// A new connection starts from full results.
|
|
577
|
+
this.logger.warn(e.message);
|
|
578
|
+
this.ws.reconnect("DiffMismatch");
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
throw e;
|
|
582
|
+
}
|
|
583
|
+
this.ws.markHealthy();
|
|
584
|
+
for (const { queryId, lines } of result.logLines) {
|
|
585
|
+
this.logger.logLines("query", this.localQueries.getById(queryId)?.udfPath ?? "?", lines);
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const end = message.endVersion;
|
|
589
|
+
if (this.resyncQuerySetVersion !== null && end.querySet >= this.resyncQuerySetVersion) {
|
|
590
|
+
this.resyncQuerySetVersion = null;
|
|
591
|
+
}
|
|
592
|
+
if (this.awaitingAuthConfirmation !== null && end.identity >= this.awaitingAuthConfirmation) {
|
|
593
|
+
this.awaitingAuthConfirmation = null;
|
|
594
|
+
if (this.auth) this.auth.retriedAfterAuthError = false;
|
|
595
|
+
this.setAuthenticated(true, this.auth?.onChange);
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const ts = this.observedTs();
|
|
599
|
+
const settled = ts === null ? [] : this.requests.removeCompleted(ts);
|
|
600
|
+
this.finishMutations(settled, result.changed.length > 0);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
/** Drops the settled mutations' optimistic updates, updates the view and notifies watchers, then settles. */
|
|
604
|
+
private finishMutations(settled: SettledMutation[], forceRecompute = false): void {
|
|
605
|
+
this.optimistic.dropUpdates(settled.map((s) => s.requestId));
|
|
606
|
+
if (forceRecompute || settled.length > 0) this.recomputeAndNotify();
|
|
607
|
+
for (const s of settled) s.settle();
|
|
608
|
+
if (settled.length > 0) this.notifyConnectionState();
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
612
|
+
// View
|
|
613
|
+
|
|
614
|
+
private serverView(): ViewMap {
|
|
615
|
+
const view: ViewMap = new Map();
|
|
616
|
+
for (const query of this.localQueries.queries()) {
|
|
617
|
+
const state = this.remoteQueries.result(query.queryId);
|
|
618
|
+
if (state) view.set(query.token, { udfPath: query.udfPath, args: query.args, state });
|
|
619
|
+
}
|
|
620
|
+
return view;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/** Rebuilds the visible view; after the whole update is applied, notifies the watchers of changed queries. */
|
|
624
|
+
private recomputeAndNotify(): void {
|
|
625
|
+
const changed = this.optimistic.recompute(this.serverView());
|
|
626
|
+
const callbacks: (() => void)[] = [];
|
|
627
|
+
for (const token of changed) {
|
|
628
|
+
const set = this.listeners.get(token);
|
|
629
|
+
if (set) callbacks.push(...set);
|
|
630
|
+
}
|
|
631
|
+
for (const callback of callbacks) {
|
|
632
|
+
try {
|
|
633
|
+
callback();
|
|
634
|
+
} catch (e) {
|
|
635
|
+
this.logger.error("query watcher threw", e);
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
641
|
+
// Connection state
|
|
642
|
+
|
|
643
|
+
/** The current connection state. Referentially stable until something changes. */
|
|
644
|
+
connectionState(): ConnectionState {
|
|
645
|
+
return this.cachedConnectionState;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
subscribeToConnectionState(callback: (state: ConnectionState) => void): () => void {
|
|
649
|
+
this.connectionListeners.add(callback);
|
|
650
|
+
return () => {
|
|
651
|
+
this.connectionListeners.delete(callback);
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private computeConnectionState(): ConnectionState {
|
|
656
|
+
const counts = this.requests.counts();
|
|
657
|
+
return {
|
|
658
|
+
isWebSocketConnected: this.ws.state === "ready",
|
|
659
|
+
hasInflightRequests: counts.mutations + counts.actions > 0,
|
|
660
|
+
hasEverConnected: this.ws.hasEverConnected,
|
|
661
|
+
connectionCount: this.ws.connectionCount,
|
|
662
|
+
connectionRetries: this.ws.connectionRetries,
|
|
663
|
+
inflightMutations: counts.mutations,
|
|
664
|
+
inflightActions: counts.actions,
|
|
665
|
+
timeOfOldestInflightRequest: counts.oldestStartedAt === null ? null : new Date(counts.oldestStartedAt),
|
|
666
|
+
isAuthenticated: this.isAuthenticated,
|
|
667
|
+
};
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
private notifyConnectionState(): void {
|
|
671
|
+
// Called from the socket manager's constructor path before fields are ready.
|
|
672
|
+
if (!this.connectionListeners) return;
|
|
673
|
+
const next = this.computeConnectionState();
|
|
674
|
+
const prev = this.cachedConnectionState;
|
|
675
|
+
if (
|
|
676
|
+
prev &&
|
|
677
|
+
prev.isWebSocketConnected === next.isWebSocketConnected &&
|
|
678
|
+
prev.hasInflightRequests === next.hasInflightRequests &&
|
|
679
|
+
prev.hasEverConnected === next.hasEverConnected &&
|
|
680
|
+
prev.connectionCount === next.connectionCount &&
|
|
681
|
+
prev.connectionRetries === next.connectionRetries &&
|
|
682
|
+
prev.inflightMutations === next.inflightMutations &&
|
|
683
|
+
prev.inflightActions === next.inflightActions &&
|
|
684
|
+
prev.timeOfOldestInflightRequest?.getTime() === next.timeOfOldestInflightRequest?.getTime() &&
|
|
685
|
+
prev.isAuthenticated === next.isAuthenticated
|
|
686
|
+
) {
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
this.cachedConnectionState = next;
|
|
690
|
+
for (const listener of [...this.connectionListeners]) {
|
|
691
|
+
try {
|
|
692
|
+
listener(next);
|
|
693
|
+
} catch (e) {
|
|
694
|
+
this.logger.error("connection state listener threw", e);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
700
|
+
|
|
701
|
+
/** Closes the socket for good. Pending mutations and actions are rejected. */
|
|
702
|
+
close(): void {
|
|
703
|
+
if (this.closed) return;
|
|
704
|
+
this.closed = true;
|
|
705
|
+
this.authGeneration++;
|
|
706
|
+
this.clearAuthRefreshTimer();
|
|
707
|
+
this.ws.stop();
|
|
708
|
+
this.removeUnloadListener();
|
|
709
|
+
this.requests.rejectAll(new RxFerrixError("Client is closed", { code: CLIENT_CLOSED }));
|
|
710
|
+
this.listeners.clear();
|
|
711
|
+
this.notifyConnectionState();
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
private installUnloadListener(enabled: boolean | undefined): () => void {
|
|
715
|
+
const w = globalThis as {
|
|
716
|
+
window?: unknown;
|
|
717
|
+
addEventListener?: (t: string, l: (e: { preventDefault(): void; returnValue?: unknown }) => void) => void;
|
|
718
|
+
removeEventListener?: (t: string, l: (e: { preventDefault(): void; returnValue?: unknown }) => void) => void;
|
|
719
|
+
};
|
|
720
|
+
if (enabled === false || typeof w.window === "undefined" || typeof w.addEventListener !== "function") {
|
|
721
|
+
return () => {};
|
|
722
|
+
}
|
|
723
|
+
const handler = (e: { preventDefault(): void; returnValue?: unknown }) => {
|
|
724
|
+
if (!this.requests.hasInflightMutations()) return;
|
|
725
|
+
e.preventDefault();
|
|
726
|
+
const message = "Changes you made may not be saved. Are you sure you want to leave?";
|
|
727
|
+
e.returnValue = message;
|
|
728
|
+
return message;
|
|
729
|
+
};
|
|
730
|
+
w.addEventListener("beforeunload", handler);
|
|
731
|
+
return () => w.removeEventListener?.("beforeunload", handler);
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/** @internal Exposed for tests and devtools. */
|
|
735
|
+
get _debug(): { queryIds: Map<QueryToken, QueryId> } {
|
|
736
|
+
const queryIds = new Map<QueryToken, QueryId>();
|
|
737
|
+
for (const q of this.localQueries.queries()) queryIds.set(q.token, q.queryId);
|
|
738
|
+
return { queryIds };
|
|
739
|
+
}
|
|
740
|
+
}
|