convex
Version:
Client for the Convex Cloud
680 lines (677 loc) • 24.8 kB
JavaScript
"use strict";
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __hasOwnProp = Object.prototype.hasOwnProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
}
return to;
};
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
var client_exports = {};
__export(client_exports, {
BaseConvexClient: () => BaseConvexClient
});
module.exports = __toCommonJS(client_exports);
var import__ = require("../../index.js");
var import_values = require("../../values/index.js");
var import_logging = require("../logging.js");
var import_local_state = require("./local_state.js");
var import_request_manager = require("./request_manager.js");
var import_optimistic_updates_impl = require("./optimistic_updates_impl.js");
var import_remote_query_set = require("./remote_query_set.js");
var import_udf_path_utils = require("./udf_path_utils.js");
var import_web_socket_manager = require("./web_socket_manager.js");
var import_session = require("./session.js");
var import_authentication_manager = require("./authentication_manager.js");
var import_metrics = require("./metrics.js");
var import_common = require("../../common/index.js");
var import_errors = require("../../values/errors.js");
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 -- additional handlers
* can be added via `addOnTransitionHandler`.
* @param options - See {@link BaseConvexClientOptions} for a full description.
*/
constructor(address, onTransition, options) {
__publicField(this, "address");
__publicField(this, "state");
__publicField(this, "requestManager");
__publicField(this, "webSocketManager");
__publicField(this, "authenticationManager");
__publicField(this, "remoteQuerySet");
__publicField(this, "optimisticQueryResults");
__publicField(this, "_transitionHandlerCounter", 0);
__publicField(this, "_nextRequestId");
__publicField(this, "_onTransitionFns", /* @__PURE__ */ new Map());
__publicField(this, "_sessionId");
__publicField(this, "firstMessageReceived", false);
__publicField(this, "debug");
__publicField(this, "logger");
__publicField(this, "maxObservedTimestamp");
// Instance property so that `mark()` doesn't need to be called as a method.
__publicField(this, "mark", (name) => {
if (this.debug) {
(0, import_metrics.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) {
(0, import_common.validateDeploymentUrl)(address);
}
options = { ...options };
const authRefreshTokenLeewaySeconds = options.authRefreshTokenLeewaySeconds ?? 2;
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.debug = options.reportDebugInfoToConvex ?? false;
this.address = address;
this.logger = options.logger ?? (0, import_logging.instantiateDefaultLogger)({ verbose: options.verbose ?? false });
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/${import__.version}/sync`;
this.state = new import_local_state.LocalSyncState();
this.remoteQuerySet = new import_remote_query_set.RemoteQuerySet(
(queryId) => this.state.queryPath(queryId),
this.logger
);
this.requestManager = new import_request_manager.RequestManager(this.logger);
this.authenticationManager = new import_authentication_manager.AuthenticationManager(
this.state,
{
authenticate: (token) => {
const message = this.state.setAuth(token);
this.webSocketManager.sendMessage(message);
return message.baseVersion;
},
stopSocket: () => this.webSocketManager.stop(),
tryRestartSocket: () => this.webSocketManager.tryRestart(),
pauseSocket: () => {
this.webSocketManager.pause();
this.state.pause();
},
resumeSocket: () => this.webSocketManager.resume(),
clearAuth: () => {
this.clearAuth();
}
},
{
logger: this.logger,
refreshTokenLeewaySeconds: authRefreshTokenLeewaySeconds
}
);
this.optimisticQueryResults = new import_optimistic_updates_impl.OptimisticQueryResults();
this.addOnTransitionHandler((transition) => {
onTransition(transition.queries.map((q) => q.token));
});
this._nextRequestId = 0;
this._sessionId = (0, import_session.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 import_web_socket_manager.WebSocketManager(
wsUri,
{
onOpen: (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 import_remote_query_set.RemoteQuerySet(
(queryId) => this.state.queryPath(queryId),
this.logger
);
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) => {
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 completedMutationInfo = this.requestManager.onResponse(serverMessage);
if (completedMutationInfo !== null) {
this.notifyOnQueryResultChanges(
/* @__PURE__ */ new Map([
[
completedMutationInfo.requestId,
completedMutationInfo.result
]
])
);
}
break;
}
case "ActionResponse": {
this.requestManager.onResponse(serverMessage);
break;
}
case "AuthError": {
this.authenticationManager.onAuthError(serverMessage);
break;
}
case "FatalError": {
const error = (0, import_logging.logFatalError)(this.logger, serverMessage.error);
void this.webSocketManager.terminate();
throw error;
}
case "Ping":
break;
// do nothing
default: {
const _typeCheck = serverMessage;
}
}
return {
hasSyncedPastLastReconnect: this.hasSyncedPastLastReconnect()
};
}
},
webSocketConstructor,
this.logger
);
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(completedRequests) {
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);
}
}
const changedQueryTokens = this.optimisticQueryResults.ingestQueryResultsFromServer(
queryTokenToValue,
new Set(completedRequests.keys())
);
this.handleTransition({
queries: changedQueryTokens.map((token) => ({
token,
modification: {
kind: "Updated",
result: queryTokenToValue.get(token).result
}
})),
reflectedMutations: Array.from(completedRequests).map(
([requestId, result]) => ({
requestId,
result
})
),
timestamp: this.remoteQuerySet.timestamp()
});
}
handleTransition(transition) {
for (const fn of this._onTransitionFns.values()) {
fn(transition);
}
}
/**
* Add a handler that will be called on a transition.
*
* Any external side effects (e.g. setting React state) should be handled here.
*
* @param fn
*
* @returns
*/
addOnTransitionHandler(fn) {
const id = this._transitionHandlerCounter++;
this._onTransitionFns.set(id, fn);
return () => this._onTransitionFns.delete(id);
}
/**
* 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 = (0, import_common.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 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 = (0, import_common.parseArgs)(args);
const queryToken = (0, import_udf_path_utils.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 = (0, import_common.parseArgs)(args);
const queryToken = (0, import_udf_path_utils.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 = (0, import_common.parseArgs)(args);
const queryToken = (0, import_udf_path_utils.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() {
const wsConnectionState = this.webSocketManager.connectionState();
return {
hasInflightRequests: this.requestManager.hasInflightRequests(),
isWebSocketConnected: wsConnectionState.isConnected,
hasEverConnected: wsConnectionState.hasEverConnected,
connectionCount: wsConnectionState.connectionCount,
connectionRetries: wsConnectionState.connectionRetries,
timeOfOldestInflightRequest: this.requestManager.timeOfOldestInflightRequest(),
inflightMutations: this.requestManager.inflightMutations(),
inflightActions: this.requestManager.inflightActions()
};
}
/**
* 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 (0, import_logging.forwardData)(
result,
new import_errors.ConvexError(
(0, import_logging.createHybridErrorStacktrace)("mutation", name, result)
)
);
}
throw new Error((0, import_logging.createHybridErrorStacktrace)("mutation", name, result));
}
return result.value;
}
/**
* @internal
*/
async mutationInternal(udfPath, args, options, componentPath) {
const { mutationPromise } = this.enqueueMutation(
udfPath,
args,
options,
componentPath
);
return mutationPromise;
}
/**
* @internal
*/
enqueueMutation(udfPath, args, options, componentPath) {
const mutationArgs = (0, import_common.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 changedQueryTokens = this.optimisticQueryResults.applyOptimisticUpdate(
wrappedUpdate,
requestId
);
const changedQueries = changedQueryTokens.map((token) => {
const localResult = this.localQueryResultByToken(token);
return {
token,
modification: {
kind: "Updated",
result: localResult === void 0 ? void 0 : {
success: true,
value: localResult,
logLines: []
}
}
};
});
this.handleTransition({
queries: changedQueries,
reflectedMutations: [],
timestamp: this.remoteQuerySet.timestamp()
});
}
}
const message = {
type: "Mutation",
requestId,
udfPath,
componentPath,
args: [(0, import_values.convexToJson)(mutationArgs)]
};
const mightBeSent = this.webSocketManager.sendMessage(message);
const mutationPromise = this.requestManager.request(message, mightBeSent);
return {
requestId,
mutationPromise
};
}
/**
* 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 (0, import_logging.forwardData)(
result,
new import_errors.ConvexError((0, import_logging.createHybridErrorStacktrace)("action", name, result))
);
}
throw new Error((0, import_logging.createHybridErrorStacktrace)("action", name, result));
}
return result.value;
}
/**
* @internal
*/
async actionInternal(udfPath, args, componentPath) {
const actionArgs = (0, import_common.parseArgs)(args);
const requestId = this.nextRequestId;
this._nextRequestId++;
this.tryReportLongDisconnect();
const message = {
type: "Action",
requestId,
udfPath,
componentPath,
args: [(0, import_values.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.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;
}
/**
* Reports performance marks to the server. This should only be called when
* we have a functional websocket.
*/
reportMarks() {
if (this.debug) {
const report = (0, import_metrics.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-${import__.version}`
},
body: JSON.stringify({ event: "LongWebsocketDisconnect" })
}).then((response) => {
if (!response.ok) {
this.logger.warn(
"Analytics request failed with response:",
response.body
);
}
}).catch((error) => {
this.logger.warn("Analytics response failed with error:", error);
});
}
}
//# sourceMappingURL=client.js.map