UNPKG

convex

Version:

Client for the Convex Cloud

526 lines (523 loc) 18.2 kB
"use strict"; import { version } from "../../index.js"; import { convexToJson } from "../../values/index.js"; import { createHybridErrorStacktrace, forwardData, logFatalError } from "../logging.js"; import { LocalSyncState } from "./local_state.js"; import { RequestManager } from "./request_manager.js"; import { OptimisticQueryResults } from "./optimistic_updates_impl.js"; import { RemoteQuerySet } from "./remote_query_set.js"; import { serializePathAndArgs } from "./udf_path_utils.js"; import { WebSocketManager } from "./web_socket_manager.js"; import { newSessionId } from "./session.js"; import { AuthenticationManager } from "./authentication_manager.js"; export {} from "./authentication_manager.js"; import { getMarksReport, mark } from "./metrics.js"; import { parseArgs, validateDeploymentUrl } from "../../common/index.js"; import { ConvexError } from "../../values/errors.js"; export class BaseConvexClient { /** * @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, onTransition, options) { this.firstMessageReceived = false; // Instance property so that `mark()` doesn't need to be called as a method. this.mark = (name) => { if (this.debug) { mark(name, this.sessionId); } }; 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; const i = address.search("://"); if (i === -1) { throw new Error("Provided address was not an absolute URL."); } const origin = address.substring(i + 3); 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); }, pauseSocket: () => this.webSocketManager.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) { window.addEventListener("beforeunload", (e) => { if (this.requestManager.hasIncompleteRequests()) { e.preventDefault(); 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) => { this.mark("convexWebSocketOpen"); this.webSocketManager.sendMessage({ ...reconnectMetadata, type: "Connect", sessionId: this._sessionId, maxObservedTimestamp: this.maxObservedTimestamp }); 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); } }, (serverMessage) => { 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) { this.notifyOnQueryResultChanges(/* @__PURE__ */ 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.stop(); throw error; } case "Ping": break; default: { const _typeCheck = 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. */ hasSyncedPastLastReconnect() { const hasSyncedPastLastReconnect = this.requestManager.hasSyncedPastLastReconnect() || this.state.hasSyncedPastLastReconnect(); return hasSyncedPastLastReconnect; } observedTimestamp(observedTs) { if (this.maxObservedTimestamp === void 0 || 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. */ notifyOnQueryResultChanges(completedRequest) { const remoteQueryResults = this.remoteQuerySet.remoteQueryResults(); const queryTokenToValue = /* @__PURE__ */ new Map(); for (const [queryId, result] of remoteQueryResults) { const queryToken = this.state.queryToken(queryId); 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, onChange) { void this.authenticationManager.setConfig(fetchToken, onChange); } hasAuth() { return this.state.hasAuth(); } /** @internal */ setAdminAuth(value, fakeUserIdentity) { 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, args, options) { const argsObject = parseArgs(args); const { modification, queryToken, unsubscribe } = this.state.subscribe( name, argsObject, options?.journal ); if (modification !== null) { this.webSocketManager.sendMessage(modification); } return { queryToken, unsubscribe: () => { const modification2 = unsubscribe(); if (modification2) { this.webSocketManager.sendMessage(modification2); } } }; } /** * 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, args) { 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) { 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) { return this.optimisticQueryResults.hasQueryResult(queryToken); } /** * @internal */ localQueryLogs(udfPath, args) { 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, args) { 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() { 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, args, options) { const result = await this.mutationInternal(name, args, options); if (!result.success) { if (result.errorData !== void 0) { throw forwardData( result, new ConvexError( createHybridErrorStacktrace("mutation", name, result) ) ); } throw new Error(createHybridErrorStacktrace("mutation", name, result)); } return result.value; } /** * @internal */ async mutationInternal(udfPath, args, options) { const mutationArgs = parseArgs(args); this.tryReportLongDisconnect(); const requestId = this.nextRequestId; this._nextRequestId++; if (options !== void 0) { const optimisticUpdate = options.optimisticUpdate; if (optimisticUpdate !== void 0) { const wrappedUpdate = (localQueryStore) => { optimisticUpdate(localQueryStore, mutationArgs); }; const changedQueries = this.optimisticQueryResults.applyOptimisticUpdate( wrappedUpdate, requestId ); this.onTransition(changedQueries); } } const message = { type: "Mutation", requestId, udfPath, 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, args) { const result = await this.actionInternal(name, args); if (!result.success) { if (result.errorData !== void 0) { throw forwardData( result, new ConvexError(createHybridErrorStacktrace("action", name, result)) ); } throw new Error(createHybridErrorStacktrace("action", name, result)); } return result.value; } /** * @internal */ async actionInternal(udfPath, args) { const actionArgs = parseArgs(args); const requestId = this.nextRequestId; this._nextRequestId++; this.tryReportLongDisconnect(); const message = { type: "Action", requestId, udfPath, 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() { this.authenticationManager.stop(); return this.webSocketManager.stop(); } /** * @internal */ get nextRequestId() { return this._nextRequestId; } /** * @internal */ get sessionId() { return this._sessionId; } /** * Reports performance marks to the server. This should only be called when * we have a functional websocket. */ reportMarks() { if (this.debug) { const report = getMarksReport(this.sessionId); this.webSocketManager.sendMessage({ type: "Event", eventType: "ClientConnect", event: report }); } } tryReportLongDisconnect() { if (!this.debug) { return; } const timeOfOldestRequest = this.connectionState().timeOfOldestInflightRequest; if (timeOfOldestRequest === null || Date.now() - timeOfOldestRequest.getTime() <= 60 * 1e3) { 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); }); } } //# sourceMappingURL=client.js.map