convex
Version:
Client for the Convex Cloud
405 lines (380 loc) • 13 kB
text/typescript
import { validateDeploymentUrl } from "../common/index.js";
import {
BaseConvexClient,
BaseConvexClientOptions,
QueryToken,
UserIdentityAttributes,
} from "./index.js";
import {
FunctionArgs,
FunctionReference,
FunctionReturnType,
} from "../server/index.js";
import { getFunctionName } from "../server/api.js";
import { AuthTokenFetcher } from "./sync/authentication_manager.js";
// In Node.js builds this points to a bundled WebSocket implementation. If no
// WebSocket implementation is manually specified or globally available,
// this one is used.
let defaultWebSocketConstructor: typeof WebSocket | undefined;
/** internal */
export function setDefaultWebSocketConstructor(ws: typeof WebSocket) {
defaultWebSocketConstructor = ws;
}
export type ConvexClientOptions = BaseConvexClientOptions & {
/**
* `disabled` makes onUpdate callback registration a no-op and actions,
* mutations and one-shot queries throw. Setting disabled to true may be
* useful for server-side rendering, where subscriptions don't make sense.
*/
disabled?: boolean;
/**
* Whether to prompt users in browsers about queued or in-flight mutations.
* This only works in environments where `window.onbeforeunload` is available.
*
* Defaults to true when `window` is defined, otherwise false.
*/
unsavedChangesWarning?: boolean;
};
/**
* Stops callbacks from running.
*
* @public
*/
export type Unsubscribe<T> = {
/** Stop calling callback when query results changes. If this is the last listener on this query, stop received updates. */
(): void;
/** Stop calling callback when query results changes. If this is the last listener on this query, stop received updates. */
unsubscribe(): void;
/** Get the last known value, possibly with local optimistic updates applied. */
getCurrentValue(): T | undefined;
/** @internal */
getQueryLogs(): string[] | undefined;
};
/**
* Subscribes to Convex query functions and executes mutations and actions over a WebSocket.
*
* Optimistic updates for mutations are not provided for this client.
* Third party clients may choose to wrap {@link browser.BaseConvexClient} for additional control.
*
* ```ts
* const client = new ConvexClient("https://happy-otter-123.convex.cloud");
* const unsubscribe = client.onUpdate(api.messages.list, (messages) => {
* console.log(messages[0].body);
* });
* ```
*
* @public
*/
export class ConvexClient {
private listeners: Set<QueryInfo>;
private _client: BaseConvexClient | undefined;
// A synthetic server event to run callbacks the first time
private callNewListenersWithCurrentValuesTimer:
| ReturnType<typeof setTimeout>
| undefined;
private _closed: boolean;
disabled: boolean;
/**
* Once closed no registered callbacks will fire again.
*/
get closed(): boolean {
return this._closed;
}
get client(): BaseConvexClient {
if (this._client) return this._client;
throw new Error("ConvexClient is disabled");
}
/**
* Construct a client and immediately initiate a WebSocket connection to the passed address.
*
* @public
*/
constructor(address: string, options: ConvexClientOptions = {}) {
if (options.skipConvexDeploymentUrlCheck !== true) {
validateDeploymentUrl(address);
}
const { disabled, ...baseOptions } = options;
this._closed = false;
this.disabled = !!disabled;
if (
defaultWebSocketConstructor &&
!("webSocketConstructor" in baseOptions) &&
typeof WebSocket === "undefined"
) {
baseOptions.webSocketConstructor = defaultWebSocketConstructor;
}
if (
typeof window === "undefined" &&
!("unsavedChangesWarning" in baseOptions)
) {
baseOptions.unsavedChangesWarning = false;
}
if (!this.disabled) {
this._client = new BaseConvexClient(
address,
(updatedQueries) => this._transition(updatedQueries),
baseOptions,
);
}
this.listeners = new Set();
}
/**
* Call a callback whenever a new result for a query is received. The callback
* will run soon after being registered if a result for the query is already
* in memory.
*
* The return value is an {@link Unsubscribe} object which is both a function
* an an object with properties. Both of the patterns below work with this object:
*
*```ts
* // call the return value as a function
* const unsubscribe = client.onUpdate(api.messages.list, {}, (messages) => {
* console.log(messages);
* });
* unsubscribe();
*
* // unpack the return value into its properties
* const {
* getCurrentValue,
* unsubscribe,
* } = client.onUpdate(api.messages.list, {}, (messages) => {
* console.log(messages);
* });
*```
*
* @param query - A {@link server.FunctionReference} for the public query to run.
* @param args - The arguments to run the query with.
* @param callback - Function to call when the query result updates.
* @param onError - Function to call when the query result updates with an error.
* If not provided, errors will be thrown instead of calling the callback.
*
* @return an {@link Unsubscribe} function to stop calling the onUpdate function.
*/
onUpdate<Query extends FunctionReference<"query">>(
query: Query,
args: FunctionArgs<Query>,
callback: (result: FunctionReturnType<Query>) => unknown,
onError?: (e: Error) => unknown,
): Unsubscribe<Query["_returnType"]> {
if (this.disabled) {
const disabledUnsubscribe = (() => {}) as Unsubscribe<
Query["_returnType"]
>;
const unsubscribeProps: RemoveCallSignature<
Unsubscribe<Query["_returnType"]>
> = {
unsubscribe: disabledUnsubscribe,
getCurrentValue: () => undefined,
getQueryLogs: () => undefined,
};
Object.assign(disabledUnsubscribe, unsubscribeProps);
return disabledUnsubscribe;
}
// BaseConvexClient takes care of deduplicating queries subscriptions...
const { queryToken, unsubscribe } = this.client.subscribe(
getFunctionName(query),
args,
);
// ...but we still need to bookkeep callbacks to actually call them.
const queryInfo: QueryInfo = {
queryToken,
callback,
onError,
unsubscribe,
hasEverRun: false,
query,
args,
};
this.listeners.add(queryInfo);
// If the callback is registered for a query with a result immediately available
// schedule a fake transition to call the callback soon instead of waiting for
// a new server update (which could take seconds or days).
if (
this.queryResultReady(queryToken) &&
this.callNewListenersWithCurrentValuesTimer === undefined
) {
this.callNewListenersWithCurrentValuesTimer = setTimeout(
() => this.callNewListenersWithCurrentValues(),
0,
);
}
const unsubscribeProps: RemoveCallSignature<
Unsubscribe<Query["_returnType"]>
> = {
unsubscribe: () => {
if (this.closed) {
// all unsubscribes already ran
return;
}
this.listeners.delete(queryInfo);
unsubscribe();
},
getCurrentValue: () => this.client.localQueryResultByToken(queryToken),
getQueryLogs: () => this.client.localQueryLogs(queryToken),
};
const ret = unsubscribeProps.unsubscribe as Unsubscribe<
Query["_returnType"]
>;
Object.assign(ret, unsubscribeProps);
return ret;
}
// Run all callbacks that have never been run before if they have a query
// result available now.
private callNewListenersWithCurrentValues() {
this.callNewListenersWithCurrentValuesTimer = undefined;
this._transition([], true);
}
private queryResultReady(queryToken: QueryToken): boolean {
return this.client.hasLocalQueryResultByToken(queryToken);
}
async close() {
if (this.disabled) return;
// prevent pending updates
this.listeners.clear();
this._closed = true;
return this.client.close();
}
/**
* 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,
) {
this.client.setAuth(
fetchToken,
onChange ??
(() => {
// Do nothing
}),
);
}
/**
* @internal
*/
setAdminAuth(token: string, identity?: UserIdentityAttributes) {
if (this.closed) {
throw new Error("ConvexClient has already been closed.");
}
if (this.disabled) return;
this.client.setAdminAuth(token, identity);
}
/**
* @internal
*/
_transition(updatedQueries: QueryToken[], callNewListeners = false) {
// Deduping subscriptions happens in the BaseConvexClient, so not much to do here.
// Call all callbacks in the order they were registered
for (const queryInfo of this.listeners) {
const { callback, queryToken, onError, hasEverRun } = queryInfo;
if (
updatedQueries.includes(queryToken) ||
(callNewListeners &&
!hasEverRun &&
this.client.hasLocalQueryResultByToken(queryToken))
) {
queryInfo.hasEverRun = true;
let newValue;
try {
newValue = this.client.localQueryResultByToken(queryToken);
} catch (error) {
if (!(error instanceof Error)) throw error;
if (onError) {
onError(
error,
"Second argument to onUpdate onError is reserved for later use",
);
} else {
// Make some noise without unsubscribing or failing to call other callbacks.
void Promise.reject(error);
}
continue;
}
callback(
newValue,
"Second argument to onUpdate callback is reserved for later use",
);
}
}
}
/**
* Execute a mutation function.
*
* @param mutation - A {@link server.FunctionReference} for the public mutation
* to run.
* @param args - An arguments object for the mutation.
* @param options - A {@link MutationOptions} options object for the mutation.
* @returns A promise of the mutation's result.
*/
async mutation<Mutation extends FunctionReference<"mutation">>(
mutation: Mutation,
args: FunctionArgs<Mutation>,
): Promise<Awaited<FunctionReturnType<Mutation>>> {
if (this.disabled) throw new Error("ConvexClient is disabled");
return await this.client.mutation(getFunctionName(mutation), args);
}
/**
* Execute an action function.
*
* @param action - A {@link server.FunctionReference} for the public action
* to run.
* @param args - An arguments object for the action.
* @returns A promise of the action's result.
*/
async action<Action extends FunctionReference<"action">>(
action: Action,
args: FunctionArgs<Action>,
): Promise<Awaited<FunctionReturnType<Action>>> {
if (this.disabled) throw new Error("ConvexClient is disabled");
return await this.client.action(getFunctionName(action), args);
}
/**
* Fetch a query result once.
*
* @param query - A {@link server.FunctionReference} for the public query
* to run.
* @param args - An arguments object for the query.
* @returns A promise of the query's result.
*/
async query<Query extends FunctionReference<"query">>(
query: Query,
args: Query["_args"],
): Promise<Awaited<Query["_returnType"]>> {
if (this.disabled) throw new Error("ConvexClient is disabled");
const value = this.client.localQueryResult(getFunctionName(query), args) as
| Awaited<Query["_returnType"]>
| undefined;
if (value !== undefined) return Promise.resolve(value);
return new Promise((resolve, reject) => {
const { unsubscribe } = this.onUpdate(
query,
args,
(value) => {
unsubscribe();
resolve(value);
},
(e: Error) => {
unsubscribe();
reject(e);
},
);
});
}
}
// internal information tracked about each registered callback
type QueryInfo = {
callback: (result: any, meta: unknown) => unknown;
onError: ((e: Error, meta: unknown) => unknown) | undefined;
unsubscribe: () => void;
queryToken: QueryToken;
hasEverRun: boolean;
// query and args are just here for debugging, the queryToken is authoritative
query: FunctionReference<"query">;
args: any;
};
type RemoveCallSignature<T> = Omit<T, never>;