convex
Version:
Client for the Convex Cloud
795 lines (744 loc) • 25.1 kB
text/typescript
import { version } from "../../index.js";
import { convexToJson, Value } from "../../values/index.js";
import {
createHybridErrorStacktrace,
forwardData,
logFatalError,
} from "../logging.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,
MutationRequest,
QueryId,
QueryJournal,
RequestId,
ServerMessage,
TS,
UserIdentityAttributes,
} from "./protocol.js";
import { RemoteQuerySet } from "./remote_query_set.js";
import { QueryToken, serializePathAndArgs } from "./udf_path_utils.js";
import { ReconnectMetadata, WebSocketManager } from "./web_socket_manager.js";
import { newSessionId } from "./session.js";
import { FunctionResult } from "./function_result.js";
import {
AuthenticationManager,
AuthTokenFetcher,
} from "./authentication_manager.js";
export { type AuthTokenFetcher } from "./authentication_manager.js";
import { getMarksReport, mark, MarkName } from "./metrics.js";
import { parseArgs, validateDeploymentUrl } from "../../common/index.js";
import { ConvexError } from "../../values/errors.js";
/**
* Options for {@link BaseConvexClient}.
*
* @public
*/
export interface BaseConvexClientOptions {
/**
* 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` in browsers.
*/
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;
/**
* Adds additional logging for debugging purposes.
*
* The default value is `false`.
*/
verbose?: boolean;
/**
* Sends additional metrics to Convex for debugging purposes.
*
* The default value is `false`.
*/
reportDebugInfoToConvex?: boolean;
/**
* Skip validating that the Convex deployment URL looks like
* `https://happy-animal-123.convex.cloud` or localhost.
*
* This can be useful if running a self-hosted Convex backend that uses a different
* URL.
*
* The default value is `false`
*/
skipConvexDeploymentUrlCheck?: boolean;
}
/**
* State describing the client's connection with the Convex backend.
*
* @public
*/
export type ConnectionState = {
hasInflightRequests: boolean;
isWebSocketConnected: boolean;
timeOfOldestInflightRequest: Date | null;
};
/**
* Options for {@link BaseConvexClient.subscribe}.
*
* @public
*/
export interface SubscribeOptions {
/**
* An (optional) journal produced from a previous execution of this query
* function.
*
* If there is an existing subscription to a query function with the same
* name and arguments, this journal will have no effect.
*/
journal?: QueryJournal;
/**
* @internal
*/
componentPath?: string;
}
/**
* Options for {@link BaseConvexClient.mutation}.
*
* @public
*/
export interface MutationOptions {
/**
* An optimistic update to apply along with this mutation.
*
* An optimistic update locally updates queries while a mutation is pending.
* Once the mutation completes, the update will be rolled back.
*/
optimisticUpdate?: OptimisticUpdate<any>;
}
/**
* 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 BaseConvexClient {
private readonly address: string;
private readonly state: LocalSyncState;
private readonly requestManager: RequestManager;
private readonly webSocketManager: WebSocketManager;
private readonly authenticationManager: AuthenticationManager;
private remoteQuerySet: RemoteQuerySet;
private readonly optimisticQueryResults: OptimisticQueryResults;
private readonly onTransition: (updatedQueries: QueryToken[]) => void;
private _nextRequestId: RequestId;
private readonly _sessionId: string;
private firstMessageReceived = false;
private readonly verbose: boolean;
private readonly debug: boolean;
private maxObservedTimestamp: TS | undefined;
/**
* @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 BaseConvexClientOptions} for a full description.
*/
constructor(
address: string,
onTransition: (updatedQueries: QueryToken[]) => void,
options?: BaseConvexClientOptions,
) {
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.",
);
}
if (options?.skipConvexDeploymentUrlCheck !== true) {
validateDeploymentUrl(address);
}
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;
this.verbose = options.verbose ?? false;
this.debug = options.reportDebugInfoToConvex ?? false;
this.address = address;
// 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.authenticationManager = new AuthenticationManager(this.state, {
authenticate: (token) => {
const message = this.state.setAuth(token);
this.webSocketManager.sendMessage(message);
},
stopSocket: () => this.webSocketManager.stop(),
restartSocket: () => this.webSocketManager.restart(),
pauseSocket: () => {
this.webSocketManager.pause();
this.state.pause();
},
resumeSocket: () => this.webSocketManager.resume(),
clearAuth: () => {
this.clearAuth();
},
verbose: this.verbose,
});
this.optimisticQueryResults = new OptimisticQueryResults();
this.onTransition = onTransition;
this._nextRequestId = 0;
this._sessionId = newSessionId();
const { unsavedChangesWarning } = options;
if (
typeof window === "undefined" ||
typeof window.addEventListener === "undefined"
) {
if (unsavedChangesWarning === true) {
throw new Error(
"unsavedChangesWarning requested, but window.addEventListener not found! Remove {unsavedChangesWarning: true} from Convex client options.",
);
}
} else if (unsavedChangesWarning !== false) {
// Listen for tab close events and notify the user on unsaved changes.
window.addEventListener("beforeunload", (e) => {
if (this.requestManager.hasIncompleteRequests()) {
// 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,
{
onOpen: (reconnectMetadata: ReconnectMetadata) => {
// We have a new WebSocket!
this.mark("convexWebSocketOpen");
this.webSocketManager.sendMessage({
...reconnectMetadata,
type: "Connect",
sessionId: this._sessionId,
maxObservedTimestamp: this.maxObservedTimestamp,
});
// Throw out our remote query, reissue queries
// and outstanding mutations, and reauthenticate.
const oldRemoteQueryResults = new Set(
this.remoteQuerySet.remoteQueryResults().keys(),
);
this.remoteQuerySet = new RemoteQuerySet((queryId) =>
this.state.queryPath(queryId),
);
const [querySetModification, authModification] = this.state.restart(
oldRemoteQueryResults,
);
if (authModification) {
this.webSocketManager.sendMessage(authModification);
}
this.webSocketManager.sendMessage(querySetModification);
for (const message of this.requestManager.restart()) {
this.webSocketManager.sendMessage(message);
}
},
onResume: () => {
const [querySetModification, authModification] = this.state.resume();
if (authModification) {
this.webSocketManager.sendMessage(authModification);
}
if (querySetModification) {
this.webSocketManager.sendMessage(querySetModification);
}
for (const message of this.requestManager.resume()) {
this.webSocketManager.sendMessage(message);
}
},
onMessage: (serverMessage: ServerMessage) => {
// Metrics events grow linearly with reconnection attempts so this
// conditional prevents n^2 metrics reporting.
if (!this.firstMessageReceived) {
this.firstMessageReceived = true;
this.mark("convexFirstMessageReceived");
this.reportMarks();
}
switch (serverMessage.type) {
case "Transition": {
this.observedTimestamp(serverMessage.endVersion.ts);
this.authenticationManager.onTransition(serverMessage);
this.remoteQuerySet.transition(serverMessage);
this.state.transition(serverMessage);
const completedRequests = this.requestManager.removeCompleted(
this.remoteQuerySet.timestamp(),
);
this.notifyOnQueryResultChanges(completedRequests);
break;
}
case "MutationResponse": {
if (serverMessage.success) {
this.observedTimestamp(serverMessage.ts);
}
const completedMutationId =
this.requestManager.onResponse(serverMessage);
if (completedMutationId !== null) {
this.notifyOnQueryResultChanges(new Set([completedMutationId]));
}
break;
}
case "ActionResponse": {
this.requestManager.onResponse(serverMessage);
break;
}
case "AuthError": {
this.authenticationManager.onAuthError(serverMessage);
break;
}
case "FatalError": {
const error = logFatalError(serverMessage.error);
void this.webSocketManager.terminate();
throw error;
}
case "Ping":
break; // do nothing
default: {
const _typeCheck: never = serverMessage;
}
}
return {
hasSyncedPastLastReconnect: this.hasSyncedPastLastReconnect(),
};
},
},
webSocketConstructor,
this.verbose,
);
this.mark("convexClientConstructed");
}
/**
* Return true if there is outstanding work from prior to the time of the most recent restart.
* This indicates that the client has not proven itself to have gotten past the issue that
* potentially led to the restart. Use this to influence when to reset backoff after a failure.
*/
private hasSyncedPastLastReconnect() {
const hasSyncedPastLastReconnect =
this.requestManager.hasSyncedPastLastReconnect() ||
this.state.hasSyncedPastLastReconnect();
return hasSyncedPastLastReconnect;
}
private observedTimestamp(observedTs: TS) {
if (
this.maxObservedTimestamp === undefined ||
this.maxObservedTimestamp.lessThanOrEqual(observedTs)
) {
this.maxObservedTimestamp = observedTs;
}
}
getMaxObservedTimestamp() {
return this.maxObservedTimestamp;
}
/**
* 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(completedRequest: Set<RequestId>) {
const remoteQueryResults: Map<QueryId, FunctionResult> =
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,
completedRequest,
),
);
}
/**
* Set the authentication token to be used for subsequent queries and mutations.
* `fetchToken` will be called automatically again if a token expires.
* `fetchToken` should return `null` if the token cannot be retrieved, for example
* when the user's rights were permanently revoked.
* @param fetchToken - an async function returning the JWT-encoded OpenID Connect Identity Token
* @param onChange - a callback that will be called when the authentication status changes
*/
setAuth(
fetchToken: AuthTokenFetcher,
onChange: (isAuthenticated: boolean) => void,
) {
void this.authenticationManager.setConfig(fetchToken, onChange);
}
hasAuth() {
return this.state.hasAuth();
}
/** @internal */
setAdminAuth(value: string, fakeUserIdentity?: UserIdentityAttributes) {
const message = this.state.setAdminAuth(value, fakeUserIdentity);
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 arguments object for the query. If this is omitted, the
* arguments will be `{}`.
* @param options - A {@link SubscribeOptions} options object for this query.
* @returns An object containing a {@link QueryToken} corresponding to this
* query and an `unsubscribe` callback.
*/
subscribe(
name: string,
args?: Record<string, Value>,
options?: SubscribeOptions,
): { queryToken: QueryToken; unsubscribe: () => void } {
const argsObject = parseArgs(args);
const { modification, queryToken, unsubscribe } = this.state.subscribe(
name,
argsObject,
options?.journal,
options?.componentPath,
);
if (modification !== null) {
this.webSocketManager.sendMessage(modification);
}
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?: Record<string, Value>,
): Value | undefined {
const argsObject = parseArgs(args);
const queryToken = serializePathAndArgs(udfPath, argsObject);
return this.optimisticQueryResults.queryResult(queryToken);
}
/**
* Get query result by query token based on 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.
*
* @internal
*/
localQueryResultByToken(queryToken: QueryToken): Value | undefined {
return this.optimisticQueryResults.queryResult(queryToken);
}
/**
* Whether local query result is available for a toke.
*
* This method does not throw if the result is an error.
*
* @internal
*/
hasLocalQueryResultByToken(queryToken: QueryToken): boolean {
return this.optimisticQueryResults.hasQueryResult(queryToken);
}
/**
* @internal
*/
localQueryLogs(
udfPath: string,
args?: Record<string, Value>,
): string[] | undefined {
const argsObject = parseArgs(args);
const queryToken = serializePathAndArgs(udfPath, argsObject);
return this.optimisticQueryResults.queryLogs(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 - The arguments object for this query.
* @returns The query's {@link QueryJournal} or `undefined`.
*/
queryJournal(
name: string,
args?: Record<string, Value>,
): QueryJournal | undefined {
const argsObject = parseArgs(args);
const queryToken = serializePathAndArgs(name, argsObject);
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(),
isWebSocketConnected: this.webSocketManager.socketState() === "ready",
timeOfOldestInflightRequest:
this.requestManager.timeOfOldestInflightRequest(),
};
}
/**
* Execute a mutation function.
*
* @param name - The name of the mutation.
* @param args - An arguments object for the mutation. If this is omitted,
* the arguments will be `{}`.
* @param options - A {@link MutationOptions} options object for this mutation.
* @returns - A promise of the mutation's result.
*/
async mutation(
name: string,
args?: Record<string, Value>,
options?: MutationOptions,
): Promise<any> {
const result = await this.mutationInternal(name, args, options);
if (!result.success) {
if (result.errorData !== undefined) {
throw forwardData(
result,
new ConvexError(
createHybridErrorStacktrace("mutation", name, result),
),
);
}
throw new Error(createHybridErrorStacktrace("mutation", name, result));
}
return result.value;
}
/**
* @internal
*/
async mutationInternal(
udfPath: string,
args?: Record<string, Value>,
options?: MutationOptions,
componentPath?: string,
): Promise<FunctionResult> {
const mutationArgs = parseArgs(args);
this.tryReportLongDisconnect();
const requestId = this.nextRequestId;
this._nextRequestId++;
if (options !== undefined) {
const optimisticUpdate = options.optimisticUpdate;
if (optimisticUpdate !== undefined) {
const wrappedUpdate = (localQueryStore: OptimisticLocalStore) => {
optimisticUpdate(localQueryStore, mutationArgs);
};
const changedQueries =
this.optimisticQueryResults.applyOptimisticUpdate(
wrappedUpdate,
requestId,
);
this.onTransition(changedQueries);
}
}
const message: MutationRequest = {
type: "Mutation",
requestId,
udfPath,
componentPath,
args: [convexToJson(mutationArgs)],
};
const mightBeSent = this.webSocketManager.sendMessage(message);
return this.requestManager.request(message, mightBeSent);
}
/**
* Execute an action function.
*
* @param name - The name of the action.
* @param args - An arguments object for the action. If this is omitted,
* the arguments will be `{}`.
* @returns A promise of the action's result.
*/
async action(name: string, args?: Record<string, Value>): Promise<any> {
const result = await this.actionInternal(name, args);
if (!result.success) {
if (result.errorData !== undefined) {
throw forwardData(
result,
new ConvexError(createHybridErrorStacktrace("action", name, result)),
);
}
throw new Error(createHybridErrorStacktrace("action", name, result));
}
return result.value;
}
/**
* @internal
*/
async actionInternal(
udfPath: string,
args?: Record<string, Value>,
componentPath?: string,
): Promise<FunctionResult> {
const actionArgs = parseArgs(args);
const requestId = this.nextRequestId;
this._nextRequestId++;
this.tryReportLongDisconnect();
const message: ActionRequest = {
type: "Action",
requestId,
udfPath,
componentPath,
args: [convexToJson(actionArgs)],
};
const mightBeSent = this.webSocketManager.sendMessage(message);
return this.requestManager.request(message, mightBeSent);
}
/**
* Close any network handles associated with this client and stop all subscriptions.
*
* Call this method when you're done with an {@link BaseConvexClient} to
* dispose of its sockets and resources.
*
* @returns A `Promise` fulfilled when the connection has been completely closed.
*/
async close(): Promise<void> {
this.authenticationManager.stop();
return this.webSocketManager.terminate();
}
/**
* Return the address for this client, useful for creating a new client.
*
* Not guaranteed to match the address with which this client was constructed:
* it may be canonicalized.
*/
get url() {
return this.address;
}
/**
* @internal
*/
get nextRequestId() {
return this._nextRequestId;
}
/**
* @internal
*/
get sessionId() {
return this._sessionId;
}
// Instance property so that `mark()` doesn't need to be called as a method.
private mark = (name: MarkName) => {
if (this.debug) {
mark(name, this.sessionId);
}
};
/**
* Reports performance marks to the server. This should only be called when
* we have a functional websocket.
*/
private reportMarks() {
if (this.debug) {
const report = getMarksReport(this.sessionId);
this.webSocketManager.sendMessage({
type: "Event",
eventType: "ClientConnect",
event: report,
});
}
}
private tryReportLongDisconnect() {
if (!this.debug) {
return;
}
const timeOfOldestRequest =
this.connectionState().timeOfOldestInflightRequest;
if (
timeOfOldestRequest === null ||
Date.now() - timeOfOldestRequest.getTime() <= 60 * 1000
) {
return;
}
const endpoint = `${this.address}/api/debug_event`;
fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Convex-Client": `npm-${version}`,
},
body: JSON.stringify({ event: "LongWebsocketDisconnect" }),
})
.then((response) => {
if (!response.ok) {
console.warn(
"Analytics request failed with response:",
response.body,
);
}
})
.catch((error) => {
console.warn("Analytics response failed with error:", error);
});
}
}