UNPKG

convex

Version:

Client for the Convex Cloud

795 lines (744 loc) 25.1 kB
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); }); } }