convex
Version:
Client for the Convex Cloud
526 lines (523 loc) • 18.2 kB
JavaScript
"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