convex
Version:
Client for the Convex Cloud
479 lines (451 loc) • 14.6 kB
text/typescript
import {
FunctionReference,
FunctionReturnType,
OptionalRestArgs,
getFunctionName,
} from "../server/api.js";
import { parseArgs, validateDeploymentUrl } from "../common/index.js";
import { version } from "../index.js";
import {
ConvexError,
JSONValue,
convexToJson,
jsonToConvex,
} from "../values/index.js";
import { logToConsole } from "./logging.js";
import { FunctionArgs, UserIdentityAttributes } from "../server/index.js";
export const STATUS_CODE_OK = 200;
export const STATUS_CODE_BAD_REQUEST = 400;
// Special custom 5xx HTTP status code to mean that the UDF returned an error.
//
// Must match the constant of the same name in the backend.
export const STATUS_CODE_UDF_FAILED = 560;
// Allow fetch to be shimmed in for Node.js < 18
let specifiedFetch: typeof globalThis.fetch | undefined = undefined;
export function setFetch(f: typeof globalThis.fetch) {
specifiedFetch = f;
}
/**
* A Convex client that runs queries and mutations over HTTP.
*
* This is appropriate for server-side code (like Netlify Lambdas) or non-reactive
* webapps.
*
* If you're building a React app, consider using
* {@link react.ConvexReactClient} instead.
*
* @public
*/
export class ConvexHttpClient {
private readonly address: string;
private auth?: string;
private adminAuth?: string;
private encodedTsPromise?: Promise<string>;
private debug: boolean;
private fetchOptions?: FetchOptions;
/**
* Create a new {@link ConvexHttpClient}.
*
* @param address - The url of your Convex deployment, often provided
* by an environment variable. E.g. `https://small-mouse-123.convex.cloud`.
* @param skipConvexDeploymentUrlCheck - 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.
*/
constructor(address: string, skipConvexDeploymentUrlCheck?: boolean) {
if (skipConvexDeploymentUrlCheck !== true) {
validateDeploymentUrl(address);
}
this.address = address;
this.debug = true;
}
/**
* Obtain the {@link ConvexHttpClient}'s URL to its backend.
* @deprecated Use url, which returns the url without /api at the end.
*
* @returns The URL to the Convex backend, including the client's API version.
*/
backendUrl(): string {
return `${this.address}/api`;
}
/**
* 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;
}
/**
* Set the authentication token to be used for subsequent queries and mutations.
*
* Should be called whenever the token changes (i.e. due to expiration and refresh).
*
* @param value - JWT-encoded OpenID Connect identity token.
*/
setAuth(value: string) {
this.clearAuth();
this.auth = value;
}
/**
* @internal
*/
setAdminAuth(token: string, actingAsIdentity?: UserIdentityAttributes) {
this.clearAuth();
if (actingAsIdentity !== undefined) {
// Encode the identity to a base64 string
const bytes = new TextEncoder().encode(JSON.stringify(actingAsIdentity));
const actingAsIdentityEncoded = btoa(String.fromCodePoint(...bytes));
this.adminAuth = `${token}:${actingAsIdentityEncoded}`;
} else {
this.adminAuth = token;
}
}
/**
* Clear the current authentication token if set.
*/
clearAuth() {
this.auth = undefined;
this.adminAuth = undefined;
}
/**
* Sets whether the result log lines should be printed on the console or not.
*
* @internal
*/
setDebug(debug: boolean) {
this.debug = debug;
}
/**
* Used to customize the fetch behavior in some runtimes.
*
* @internal
*/
setFetchOptions(fetchOptions: FetchOptions) {
this.fetchOptions = fetchOptions;
}
/**
* This API is experimental: it may change or disappear.
*
* Execute a Convex query function at the same timestamp as every other
* consistent query execution run by this HTTP client.
*
* This doesn't make sense for long-lived ConvexHttpClients as Convex
* backends can read a limited amount into the past: beyond 30 seconds
* in the past may not be available.
*
* Create a new client to use a consistent time.
*
* @param name - The name of the query.
* @param args - The arguments object for the query. If this is omitted,
* the arguments will be `{}`.
* @returns A promise of the query's result.
*
* @deprecated This API is experimental: it may change or disappear.
*/
async consistentQuery<Query extends FunctionReference<"query">>(
query: Query,
...args: OptionalRestArgs<Query>
): Promise<FunctionReturnType<Query>> {
const queryArgs = parseArgs(args[0]);
const timestampPromise = this.getTimestamp();
return await this.queryInner(query, queryArgs, { timestampPromise });
}
private async getTimestamp() {
if (this.encodedTsPromise) {
return this.encodedTsPromise;
}
return (this.encodedTsPromise = this.getTimestampInner());
}
private async getTimestampInner() {
const localFetch = specifiedFetch || fetch;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Convex-Client": `npm-${version}`,
};
const response = await localFetch(`${this.address}/api/query_ts`, {
...this.fetchOptions,
method: "POST",
headers: headers,
credentials: "include",
});
if (!response.ok) {
throw new Error(await response.text());
}
const { ts } = (await response.json()) as { ts: string };
return ts;
}
/**
* Execute a Convex query function.
*
* @param name - The name of the query.
* @param args - The arguments object for the query. If this is omitted,
* the arguments will be `{}`.
* @returns A promise of the query's result.
*/
async query<Query extends FunctionReference<"query">>(
query: Query,
...args: OptionalRestArgs<Query>
): Promise<FunctionReturnType<Query>> {
const queryArgs = parseArgs(args[0]);
return await this.queryInner(query, queryArgs, {});
}
private async queryInner<Query extends FunctionReference<"query">>(
query: Query,
queryArgs: FunctionArgs<Query>,
options: { timestampPromise?: Promise<string> },
): Promise<FunctionReturnType<Query>> {
const name = getFunctionName(query);
const args = [convexToJson(queryArgs)];
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Convex-Client": `npm-${version}`,
};
if (this.adminAuth) {
headers["Authorization"] = `Convex ${this.adminAuth}`;
} else if (this.auth) {
headers["Authorization"] = `Bearer ${this.auth}`;
}
const localFetch = specifiedFetch || fetch;
const timestamp = options.timestampPromise
? await options.timestampPromise
: undefined;
const body = JSON.stringify({
path: name,
format: "convex_encoded_json",
args,
...(timestamp ? { ts: timestamp } : {}),
});
const endpoint = timestamp
? `${this.address}/api/query_at_ts`
: `${this.address}/api/query`;
const response = await localFetch(endpoint, {
...this.fetchOptions,
body,
method: "POST",
headers: headers,
credentials: "include",
});
if (!response.ok && response.status !== STATUS_CODE_UDF_FAILED) {
throw new Error(await response.text());
}
const respJSON = await response.json();
if (this.debug) {
for (const line of respJSON.logLines ?? []) {
logToConsole("info", "query", name, line);
}
}
switch (respJSON.status) {
case "success":
return jsonToConvex(respJSON.value);
case "error":
if (respJSON.errorData !== undefined) {
throw forwardErrorData(
respJSON.errorData,
new ConvexError(respJSON.errorMessage),
);
}
throw new Error(respJSON.errorMessage);
default:
throw new Error(`Invalid response: ${JSON.stringify(respJSON)}`);
}
}
/**
* Execute a Convex mutation function.
*
* @param name - The name of the mutation.
* @param args - The arguments object for the mutation. If this is omitted,
* the arguments will be `{}`.
* @returns A promise of the mutation's result.
*/
async mutation<Mutation extends FunctionReference<"mutation">>(
mutation: Mutation,
...args: OptionalRestArgs<Mutation>
): Promise<FunctionReturnType<Mutation>> {
const mutationArgs = parseArgs(args[0]);
const name = getFunctionName(mutation);
const body = JSON.stringify({
path: name,
format: "convex_encoded_json",
args: [convexToJson(mutationArgs)],
});
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Convex-Client": `npm-${version}`,
};
if (this.adminAuth) {
headers["Authorization"] = `Convex ${this.adminAuth}`;
} else if (this.auth) {
headers["Authorization"] = `Bearer ${this.auth}`;
}
const localFetch = specifiedFetch || fetch;
const response = await localFetch(`${this.address}/api/mutation`, {
...this.fetchOptions,
body,
method: "POST",
headers: headers,
credentials: "include",
});
if (!response.ok && response.status !== STATUS_CODE_UDF_FAILED) {
throw new Error(await response.text());
}
const respJSON = await response.json();
if (this.debug) {
for (const line of respJSON.logLines ?? []) {
logToConsole("info", "mutation", name, line);
}
}
switch (respJSON.status) {
case "success":
return jsonToConvex(respJSON.value);
case "error":
if (respJSON.errorData !== undefined) {
throw forwardErrorData(
respJSON.errorData,
new ConvexError(respJSON.errorMessage),
);
}
throw new Error(respJSON.errorMessage);
default:
throw new Error(`Invalid response: ${JSON.stringify(respJSON)}`);
}
}
/**
* Execute a Convex action function.
*
* @param name - The name of the action.
* @param args - The arguments object for the action. If this is omitted,
* the arguments will be `{}`.
* @returns A promise of the action's result.
*/
async action<Action extends FunctionReference<"action">>(
action: Action,
...args: OptionalRestArgs<Action>
): Promise<FunctionReturnType<Action>> {
const actionArgs = parseArgs(args[0]);
const name = getFunctionName(action);
const body = JSON.stringify({
path: name,
format: "convex_encoded_json",
args: [convexToJson(actionArgs)],
});
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Convex-Client": `npm-${version}`,
};
if (this.adminAuth) {
headers["Authorization"] = `Convex ${this.adminAuth}`;
} else if (this.auth) {
headers["Authorization"] = `Bearer ${this.auth}`;
}
const localFetch = specifiedFetch || fetch;
const response = await localFetch(`${this.address}/api/action`, {
...this.fetchOptions,
body,
method: "POST",
headers: headers,
credentials: "include",
});
if (!response.ok && response.status !== STATUS_CODE_UDF_FAILED) {
throw new Error(await response.text());
}
const respJSON = await response.json();
if (this.debug) {
for (const line of respJSON.logLines ?? []) {
logToConsole("info", "action", name, line);
}
}
switch (respJSON.status) {
case "success":
return jsonToConvex(respJSON.value);
case "error":
if (respJSON.errorData !== undefined) {
throw forwardErrorData(
respJSON.errorData,
new ConvexError(respJSON.errorMessage),
);
}
throw new Error(respJSON.errorMessage);
default:
throw new Error(`Invalid response: ${JSON.stringify(respJSON)}`);
}
}
/**
* Execute a Convex function of an unknown type.
*
* @param name - The name of the function.
* @param args - The arguments object for the function. If this is omitted,
* the arguments will be `{}`.
* @returns A promise of the function's result.
*
* @internal
*/
async function<
AnyFunction extends FunctionReference<"query" | "mutation" | "action">,
>(
anyFunction: AnyFunction | string,
componentPath?: string,
...args: OptionalRestArgs<AnyFunction>
): Promise<FunctionReturnType<AnyFunction>> {
const functionArgs = parseArgs(args[0]);
const name =
typeof anyFunction === "string"
? anyFunction
: getFunctionName(anyFunction);
const body = JSON.stringify({
componentPath: componentPath,
path: name,
format: "convex_encoded_json",
args: convexToJson(functionArgs),
});
const headers: Record<string, string> = {
"Content-Type": "application/json",
"Convex-Client": `npm-${version}`,
};
if (this.adminAuth) {
headers["Authorization"] = `Convex ${this.adminAuth}`;
} else if (this.auth) {
headers["Authorization"] = `Bearer ${this.auth}`;
}
const localFetch = specifiedFetch || fetch;
const response = await localFetch(`${this.address}/api/function`, {
...this.fetchOptions,
body,
method: "POST",
headers: headers,
credentials: "include",
});
if (!response.ok && response.status !== STATUS_CODE_UDF_FAILED) {
throw new Error(await response.text());
}
const respJSON = await response.json();
if (this.debug) {
for (const line of respJSON.logLines ?? []) {
logToConsole("info", "any", name, line);
}
}
switch (respJSON.status) {
case "success":
return jsonToConvex(respJSON.value);
case "error":
if (respJSON.errorData !== undefined) {
throw forwardErrorData(
respJSON.errorData,
new ConvexError(respJSON.errorMessage),
);
}
throw new Error(respJSON.errorMessage);
default:
throw new Error(`Invalid response: ${JSON.stringify(respJSON)}`);
}
}
}
function forwardErrorData(errorData: JSONValue, error: ConvexError<string>) {
(error as ConvexError<any>).data = jsonToConvex(errorData);
return error;
}
/**
* @internal
*/
type FetchOptions = { cache: "force-cache" | "no-store" };