convex
Version:
Client for the Convex Cloud
471 lines (436 loc) • 15.6 kB
text/typescript
import { v4 as uuidv4 } from "uuid";
import { GenericAPI } from "../../api/index.js";
import { version } from "../../index.js";
import { convexToJson, JSONValue, Value } from "../../values/index.js";
import { logFatalError } from "../logging.js";
import { ActionManager } from "./action_manager.js";
import { LocalSyncState } from "./local_state.js";
import { RequestManager } from "./request_manager.js";
import {
OptimisticLocalStore,
OptimisticUpdate,
} from "./optimistic_updates.js";
import {
OptimisticQueryResults,
QueryResultsMap,
} from "./optimistic_updates_impl.js";
import {
ActionRequest,
AuthError,
MutationRequest,
QueryId,
QueryJournal,
RequestId,
ServerMessage,
} from "./protocol.js";
import { QueryResult, RemoteQuerySet } from "./remote_query_set.js";
import { QueryToken, serializePathAndArgs } from "./udf_path_utils.js";
import { ReconnectMetadata, WebSocketManager } from "./web_socket_manager.js";
/**
* Options for {@link InternalConvexClient}.
*
* @public
*/
export interface ClientOptions {
/**
* Whether to prompt the user if they have unsaved changes pending
* when navigating away or closing a web page.
*
* This is only possible when the `window` object exists, i.e. in a browser.
*
* The default value is `true`.
*/
unsavedChangesWarning?: boolean;
/**
* Specifies an alternate
* [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket)
* constructor to use for client communication with the Convex cloud.
* The default behavior is to use `WebSocket` from the global environment.
*/
webSocketConstructor?: typeof WebSocket;
}
/**
* State describing the client's connection with the Convex backend.
*
* @public
*/
export type ConnectionState = {
hasInflightRequests: boolean;
isWebSocketConnected: boolean;
};
/**
* An async function returning the JWT-encoded OpenID Connect Identity Token
* if available.
* See {@link ConvexReactClient.setAuth}.
*
* @public
*/
export type AuthTokenFetcher = () => Promise<string | null | undefined>;
const DEFAULT_OPTIONS = {
unsavedChangesWarning: true,
};
/**
* Low-level client for directly integrating state management libraries
* with Convex.
*
* Most developers should use higher level clients, like
* the {@link ConvexHttpClient} or the React hook based {@link react.ConvexReactClient}.
*
* @public
*/
export class InternalConvexClient {
private readonly state: LocalSyncState;
private readonly requestManager: RequestManager;
private readonly actionManager: ActionManager;
private readonly webSocketManager: WebSocketManager;
private remoteQuerySet: RemoteQuerySet;
private readonly optimisticQueryResults: OptimisticQueryResults;
private readonly onTransition: (updatedQueries: QueryToken[]) => void;
private nextRequestId: RequestId;
private readonly sessionId: string;
private fetchToken: null | (() => Promise<string | null | undefined>) = null;
/**
* @param address - The url of your Convex deployment, often provided
* by an environment variable. E.g. `https://small-mouse-123.convex.cloud`.
* @param onTransition - A callback receiving an array of query tokens
* corresponding to query results that have changed.
* @param options - See {@link ClientOptions} for a full description.
*/
constructor(
address: string,
onTransition: (updatedQueries: QueryToken[]) => void,
options?: ClientOptions
) {
if (typeof address === "object") {
throw new Error(
"Passing a ClientConfig object is no longer supported. Pass the URL of the Convex deployment as a string directly."
);
}
options = { ...DEFAULT_OPTIONS, ...options };
let webSocketConstructor = options.webSocketConstructor;
if (!webSocketConstructor && typeof WebSocket === "undefined") {
throw new Error(
"No WebSocket global variable defined! To use Convex in an environment without WebSocket try the HTTP client: https://docs.convex.dev/api/classes/browser.ConvexHttpClient"
);
}
webSocketConstructor = webSocketConstructor || WebSocket;
// Substitute http(s) with ws(s)
const i = address.search("://");
if (i === -1) {
throw new Error("Provided address was not an absolute URL.");
}
const origin = address.substring(i + 3); // move past the double slash
const protocol = address.substring(0, i);
let wsProtocol;
if (protocol === "http") {
wsProtocol = "ws";
} else if (protocol === "https") {
wsProtocol = "wss";
} else {
throw new Error(`Unknown parent protocol ${protocol}`);
}
const wsUri = `${wsProtocol}://${origin}/api/${version}/sync`;
this.state = new LocalSyncState();
this.remoteQuerySet = new RemoteQuerySet(queryId =>
this.state.queryPath(queryId)
);
this.requestManager = new RequestManager();
this.actionManager = new ActionManager();
this.optimisticQueryResults = new OptimisticQueryResults();
this.onTransition = onTransition;
this.nextRequestId = 0;
this.sessionId = uuidv4();
const { unsavedChangesWarning } = options;
if (typeof window === "undefined" && unsavedChangesWarning) {
throw new Error(
"unsavedChangesWarning enabled, but no window object found! Navigating away from the page could cause in-flight mutations to be dropped. Pass {unsavedChangesWarning: false} in Convex client options to disable this feature."
);
}
// Listen for tab close events and notify the user on unsaved changes.
unsavedChangesWarning &&
window.addEventListener("beforeunload", e => {
if (
this.requestManager.hasIncompleteRequests() ||
this.actionManager.hasInflightActions()
) {
// There are 3 different ways to trigger this pop up so just try all of
// them.
e.preventDefault();
// This confirmation message doesn't actually appear in most modern
// browsers but we tried.
const confirmationMessage =
"Are you sure you want to leave? Your changes may not be saved.";
(e || window.event).returnValue = confirmationMessage;
return confirmationMessage;
}
});
this.webSocketManager = new WebSocketManager(
wsUri,
(reconnectMetadata: ReconnectMetadata) => {
// We have a new WebSocket!
this.webSocketManager.sendMessage({
...reconnectMetadata,
type: "Connect",
sessionId: this.sessionId,
});
// Throw out our remote query, reissue queries
// and outstanding mutations, and reauthenticate.
this.remoteQuerySet = new RemoteQuerySet(queryId =>
this.state.queryPath(queryId)
);
const [querySetModification, authModification] = this.state.restart();
if (authModification) {
this.webSocketManager.sendMessage(authModification);
}
this.webSocketManager.sendMessage(querySetModification);
this.actionManager.restart();
for (const message of this.requestManager.restart()) {
this.webSocketManager.sendMessage(message);
}
},
(serverMessage: ServerMessage) => {
switch (serverMessage.type) {
case "Transition": {
this.remoteQuerySet.transition(serverMessage);
this.state.saveQueryJournals(serverMessage);
// No optimistic updates for workflows.
const completedMutations = this.requestManager.removeCompleted(
this.remoteQuerySet.timestamp()
);
this.notifyOnQueryResultChanges(completedMutations);
break;
}
case "MutationResponse": {
const completedMutationId =
this.requestManager.onResponse(serverMessage);
if (completedMutationId) {
this.notifyOnQueryResultChanges(new Set([completedMutationId]));
}
break;
}
case "ActionResponse": {
this.actionManager.onResponse(serverMessage);
break;
}
case "AuthError": {
this.tryToReauthenticate(serverMessage)
.then()
.catch(error => {
logFatalError(error);
// TODO(CX-3070): This ignores a failed Promise
void this.webSocketManager.stop();
});
break;
}
case "FatalError": {
const error = logFatalError(serverMessage.error);
void this.webSocketManager.stop();
throw error;
}
case "Ping":
break; // do nothing
default: {
const _typeCheck: never = serverMessage;
}
}
},
webSocketConstructor
);
}
/**
* Compute the current query results based on the remoteQuerySet and the
* current optimistic updates and call `onTransition` for all the changed
* queries.
*
* @param completedMutations - A set of mutation IDs whose optimistic updates
* are no longer needed.
*/
private notifyOnQueryResultChanges(completedMutations: Set<RequestId>) {
const remoteQueryResults: Map<QueryId, QueryResult> =
this.remoteQuerySet.remoteQueryResults();
const queryTokenToValue: QueryResultsMap = new Map();
for (const [queryId, result] of remoteQueryResults) {
const queryToken = this.state.queryToken(queryId);
// It's possible that we've already unsubscribed to this query but
// the server hasn't learned about that yet. If so, ignore this one.
if (queryToken !== null) {
const query = {
result,
udfPath: this.state.queryPath(queryId)!,
args: this.state.queryArgs(queryId)!,
};
queryTokenToValue.set(queryToken, query);
}
}
this.onTransition(
this.optimisticQueryResults.ingestQueryResultsFromServer(
queryTokenToValue,
completedMutations
)
);
}
async setAuth(fetchToken: AuthTokenFetcher) {
this.fetchToken = fetchToken;
const token = await fetchToken();
if (token) {
this.authenticate(token);
} else if (this.state.hasAuth()) {
this.clearAuth();
}
}
// Stop the webSocket so that we don't retry with bad auth
private async tryToReauthenticate(serverMessage: AuthError) {
if (!this.fetchToken) {
throw new Error(serverMessage.error);
}
console.log("Attempting to reauthenticate");
await this.webSocketManager.pause();
const token = await this.fetchToken();
if (token && this.state.isNewAuth(token)) {
this.state.setAuth(token);
} else if (this.state.hasAuth()) {
console.log("Reauthentication failed, clearing auth");
this.state.clearAuth();
}
await this.webSocketManager.resume();
}
private authenticate(token: string) {
const message = this.state.setAuth(token);
this.webSocketManager.sendMessage(message);
}
/** @internal */
setAdminAuth(value: string) {
const message = this.state.setAdminAuth(value);
this.webSocketManager.sendMessage(message);
}
clearAuth() {
const message = this.state.clearAuth();
this.webSocketManager.sendMessage(message);
}
/**
* Subscribe to a query function.
*
* Whenever this query's result changes, the `onTransition` callback
* passed into the constructor will be called.
*
* @param name - The name of the query.
* @param args - An array of the arguments to the query.
* @param journal - An (optional) journal produced from a previous
* execution of this query function. Note that if this query function with
* these arguments has already been requested the journal will have no effect.
* @returns An object containing a {@link QueryToken} corresponding to this
* query and an `unsubscribe` callback.
*/
subscribe(
name: string,
args: any[],
journal?: QueryJournal
): { queryToken: QueryToken; unsubscribe: () => void } {
// `subscribe` used to collect the arguments with a rest operator
// (like `...args`). Double check that it's an array to make sure developers
// have updated their code.
if (!Array.isArray(args)) {
throw new Error(
`Query arguments to \`InternalConvexClient.subcribe\` must be an array. Received ${args}.`
);
}
const { modification, queryToken, unsubscribe } = this.state.subscribe(
name,
args,
journal
);
if (modification !== null) {
this.webSocketManager.sendMessage(modification);
}
// TODO: Use FinalizationRegistry?
return {
queryToken,
unsubscribe: () => {
const modification = unsubscribe();
if (modification) {
this.webSocketManager.sendMessage(modification);
}
},
};
}
/**
* A query result based only on the current, local state.
*
* The only way this will return a value is if we're already subscribed to the
* query or its value has been set optimistically.
*/
localQueryResult(udfPath: string, args: any[]): Value | undefined {
const queryToken = serializePathAndArgs(udfPath, args);
return this.optimisticQueryResults.queryResult(queryToken);
}
/**
* Retrieve the current {@link QueryJournal} for this query function.
*
* If we have not yet received a result for this query, this will be `undefined`.
*
* @param name - The name of the query.
* @param args - An array of arguments to this query.
* @returns The query's {@link QueryJournal} or `undefined`.
*/
queryJournal(name: string, args: any[]): QueryJournal | undefined {
const queryToken = serializePathAndArgs(name, args);
return this.state.queryJournal(queryToken);
}
/**
* Get the current {@link ConnectionState} between the client and the Convex
* backend.
*
* @returns The {@link ConnectionState} with the Convex backend.
*/
connectionState(): ConnectionState {
return {
hasInflightRequests:
this.requestManager.hasInflightRequests() ||
this.actionManager.hasInflightActions(),
isWebSocketConnected: this.webSocketManager.socketState() === "ready",
};
}
async mutate<Args extends any[]>(
udfPath: string,
args: Args,
optimisticUpdate: OptimisticUpdate<GenericAPI, Args> | null = null
): Promise<any> {
const requestId = this.nextRequestId;
this.nextRequestId++;
if (optimisticUpdate !== null) {
const wrappedUpdate = (localQueryStore: OptimisticLocalStore) => {
optimisticUpdate(localQueryStore, ...args);
};
const changedQueries = this.optimisticQueryResults.applyOptimisticUpdate(
wrappedUpdate,
requestId
);
this.onTransition(changedQueries);
}
const message: MutationRequest = {
type: "Mutation",
requestId,
udfPath,
args: convexToJson(args) as JSONValue[],
};
const result = this.requestManager.request(message);
this.webSocketManager.sendMessage(message);
return result;
}
async action<Args extends any[]>(udfPath: string, args: Args): Promise<any> {
const requestId = this.nextRequestId;
this.nextRequestId++;
const message: ActionRequest = {
type: "Action",
requestId,
udfPath,
args: convexToJson(args) as JSONValue[],
};
const result = this.actionManager.request(message);
this.webSocketManager.sendMessage(message);
return result;
}
async close(): Promise<void> {
return this.webSocketManager.stop();
}
}