convex
Version:
Client for the Convex Cloud
307 lines (282 loc) • 10.6 kB
text/typescript
import { version } from "../../index.js";
import { Value } from "@convex-dev/common";
import { LocalSyncState } from "./local_state.js";
import { MutationManager } from "./mutation_manager.js";
import {
OptimisticQueryResults,
OptimisticLocalStore,
OptimisticUpdate,
QueryResultsMap,
} from "./optimistic_query_set.js";
import { ServerMessage, QueryId, MutationId } from "./protocol.js";
import { QueryResult, RemoteQuerySet } from "./remote_query_set.js";
import { QueryToken, serializePathAndArgs } from "./udf_path_utils.js";
import { WebSocketManager } from "./web_socket_manager.js";
import { v4 as uuidv4 } from "uuid";
import { GenericAPI } from "../api.js";
import { logFatalError } from "../logging.js";
import { ClientConfiguration } from "../client_config.js";
/**
* Options for {@link InternalConvexClient}.
*
* @public
*/
export type ClientOptions = {
/**
* Whether to prompt the user that have unsaved changes pending
* when navigating away or closing a web page with pending Convex mutations.
* 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;
};
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 mutationManager: MutationManager;
private readonly webSocketManager: WebSocketManager;
private remoteQuerySet: RemoteQuerySet;
private readonly optimisticQueryResults: OptimisticQueryResults;
private readonly onTransition: (updatedQueries: QueryToken[]) => void;
private nextMutationId: MutationId;
private readonly sessionId: string;
private connectionCount: number;
/**
* @param clientConfig - The generated client configuration for your project.
* You can find this at `convex/_generated/clientConfig.js`.
* @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(
clientConfig: ClientConfiguration,
onTransition: (updatedQueries: QueryToken[]) => void,
options?: ClientOptions
) {
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 address = clientConfig.address;
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.mutationManager = new MutationManager();
this.optimisticQueryResults = new OptimisticQueryResults();
this.onTransition = onTransition;
this.nextMutationId = 0;
this.sessionId = uuidv4();
this.connectionCount = 0;
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.mutationManager.hasUncommittedMutations()) {
// 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,
() => {
// We have a new WebSocket!
this.webSocketManager.sendMessage({
type: "Connect",
sessionId: this.sessionId,
connectionCount: this.connectionCount,
});
this.connectionCount += 1;
// 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);
for (const message of this.mutationManager.restart()) {
this.webSocketManager.sendMessage(message);
}
},
(serverMessage: ServerMessage) => {
if (serverMessage.type == "Transition") {
this.remoteQuerySet.transition(serverMessage);
const completedMutations =
this.mutationManager.removeCompletedMutations(
this.remoteQuerySet.timestamp()
);
this.notifyOnQueryResultChanges(completedMutations);
} else if (serverMessage.type == "MutationResponse") {
const completedMutationId =
this.mutationManager.onResponse(serverMessage);
if (completedMutationId) {
this.notifyOnQueryResultChanges(new Set([completedMutationId]));
}
} else if (serverMessage.type == "FatalError") {
const error = logFatalError(serverMessage.error);
this.webSocketManager.stop();
throw error;
}
},
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<MutationId>) {
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
)
);
}
setAuth(value: string) {
const message = this.state.setAuth(value);
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(
udfPath: string,
args: any[]
): { queryToken: QueryToken; unsubscribe: () => void } {
const { modification, queryToken, unsubscribe } = this.state.subscribe(
udfPath,
args
);
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);
}
async mutate<Args extends any[]>(
udfPath: string,
args: Args,
optimisticUpdate: OptimisticUpdate<GenericAPI, Args> | null = null
): Promise<any> {
const mutationId = this.nextMutationId;
this.nextMutationId++;
if (optimisticUpdate !== null) {
const wrappedUpdate = (localQueryStore: OptimisticLocalStore) => {
optimisticUpdate(localQueryStore, ...args);
};
const changedQueries = this.optimisticQueryResults.applyOptimisticUpdate(
wrappedUpdate,
mutationId
);
this.onTransition(changedQueries);
}
const { message, result } = this.mutationManager.request(
udfPath,
args,
mutationId
);
this.webSocketManager.sendMessage(message);
return result;
}
async close(): Promise<void> {
return this.webSocketManager.stop();
}
}