convex
Version:
Client for the Convex Cloud
1,029 lines (957 loc) • 28.9 kB
text/typescript
import chalk from "chalk";
import os from "os";
import path from "path";
import { z } from "zod";
import { ProjectConfig } from "../config.js";
import { spawn } from "child_process";
import { InvalidArgumentError } from "commander";
import fetchRetryFactory, { RequestInitRetryParams } from "fetch-retry";
import {
Context,
ErrorType,
logError,
logMessage,
logWarning,
} from "../../../bundler/context.js";
import { version } from "../../version.js";
import { Project } from "../api.js";
import {
getConfiguredDeploymentFromEnvVar,
isPreviewDeployKey,
} from "../deployment.js";
import { promptSearch, promptYesNo } from "./prompts.js";
const retryingFetch = fetchRetryFactory(fetch);
export const productionProvisionHost = "https://provision.convex.dev";
export const provisionHost =
process.env.CONVEX_PROVISION_HOST || productionProvisionHost;
const BIG_BRAIN_URL = `${provisionHost}/api/`;
export const CONVEX_DEPLOY_KEY_ENV_VAR_NAME = "CONVEX_DEPLOY_KEY";
export function parsePositiveInteger(value: string) {
const parsedValue = parseInteger(value);
if (parsedValue <= 0) {
// eslint-disable-next-line no-restricted-syntax
throw new InvalidArgumentError("Not a positive number.");
}
return parsedValue;
}
export function parseInteger(value: string) {
const parsedValue = +value;
if (isNaN(parsedValue)) {
// eslint-disable-next-line no-restricted-syntax
throw new InvalidArgumentError("Not a number.");
}
return parsedValue;
}
export type ErrorData = {
code: string;
message: string;
};
/**
* Error thrown on non-2XX reponse codes to make most `fetch()` error handling
* follow a single code path.
*/
export class ThrowingFetchError extends Error {
response: Response;
serverErrorData?: ErrorData;
constructor(
msg: string,
{
code,
message,
response,
}: { cause?: Error; code?: string; message?: string; response: Response },
) {
if (code !== undefined && message !== undefined) {
super(`${msg}: ${code}: ${message}`);
this.serverErrorData = { code, message };
} else {
super(msg);
}
Object.setPrototypeOf(this, ThrowingFetchError.prototype);
this.response = response;
}
public static async fromResponse(
response: Response,
msg?: string,
): Promise<ThrowingFetchError> {
msg = `${msg ? `${msg} ` : ""}${response.status} ${response.statusText}`;
let code, message;
try {
({ code, message } = await response.json());
} catch (e: unknown) {
// Do nothing because the non-2XX response code is the primary error here.
}
return new ThrowingFetchError(msg, { code, message, response });
}
async handle(ctx: Context): Promise<never> {
let error_type: ErrorType = "transient";
await checkFetchErrorForDeprecation(ctx, this.response);
let msg = this.message;
if (this.response.status === 400) {
error_type = "invalid filesystem or env vars";
} else if (this.response.status === 401) {
error_type = "fatal";
msg = `${msg}\nAuthenticate with \`npx convex dev\``;
} else if (this.response.status === 404) {
error_type = "fatal";
msg = `${msg}: ${this.response.url}`;
}
return await ctx.crash({
exitCode: 1,
errorType: error_type,
errForSentry: this,
printedMessage: chalk.red(msg.trim()),
});
}
}
/**
* Thin wrapper around `fetch()` which throws a FetchDataError on non-2XX
* responses which includes error code and message from the response JSON.
* (Axios-style)
*
* It also accepts retry options from fetch-retry.
*/
export async function throwingFetch(
resource: RequestInfo | URL,
options: (RequestInit & RequestInitRetryParams) | undefined,
): Promise<Response> {
const Headers = globalThis.Headers;
const headers = new Headers((options || {})["headers"]);
if (options?.body) {
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
}
const response = await retryingFetch(resource, options);
if (!response.ok) {
// This error must always be handled manually.
// eslint-disable-next-line no-restricted-syntax
throw await ThrowingFetchError.fromResponse(
response,
`Error fetching ${options?.method ? options.method + " " : ""} ${
typeof resource === "string"
? resource
: "url" in resource
? resource.url
: resource.toString()
}`,
);
}
return response;
}
/**
* Handle an error a fetch error or non-2xx response.
*/
export async function logAndHandleFetchError(
ctx: Context,
err: unknown,
): Promise<never> {
if (ctx.spinner) {
// Fail the spinner so the stderr lines appear
ctx.spinner.fail();
}
if (err instanceof ThrowingFetchError) {
return await err.handle(ctx);
} else {
return await ctx.crash({
exitCode: 1,
errorType: "transient",
errForSentry: err,
printedMessage: chalk.red(err),
});
}
}
function logDeprecationWarning(ctx: Context, deprecationMessage: string) {
if (ctx.deprecationMessagePrinted) {
return;
}
ctx.deprecationMessagePrinted = true;
logWarning(ctx, chalk.yellow(deprecationMessage));
}
async function checkFetchErrorForDeprecation(ctx: Context, resp: Response) {
const headers = resp.headers;
if (headers) {
const deprecationState = headers.get("x-convex-deprecation-state");
const deprecationMessage = headers.get("x-convex-deprecation-message");
switch (deprecationState) {
case null:
break;
case "Deprecated":
// This version is deprecated. Print a warning and crash.
// Gotcha:
// 1. Don't use `logDeprecationWarning` because we should always print
// why this we crashed (even if we printed a warning earlier).
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: chalk.red(deprecationMessage),
});
default:
// The error included a deprecation warning. Print, but handle the
// error normally (it was for another reason).
logDeprecationWarning(
ctx,
deprecationMessage || "(no deprecation message included)",
);
break;
}
}
}
/// Call this method after a successful API response to conditionally print the
/// "please upgrade" message.
export function deprecationCheckWarning(ctx: Context, resp: Response) {
const headers = resp.headers;
if (headers) {
const deprecationState = headers.get("x-convex-deprecation-state");
const deprecationMessage = headers.get("x-convex-deprecation-message");
switch (deprecationState) {
case null:
break;
case "Deprecated":
// This should never happen because such states are errors, not warnings.
// eslint-disable-next-line no-restricted-syntax
throw new Error(
"Called deprecationCheckWarning on a fatal error. This is a bug.",
);
default:
logDeprecationWarning(
ctx,
deprecationMessage || "(no deprecation message included)",
);
break;
}
}
}
type Team = {
id: number;
name: string;
slug: string;
};
export async function hasTeam(ctx: Context, teamSlug: string) {
const teams: Team[] = await bigBrainAPI({ ctx, method: "GET", url: "teams" });
return teams.some((team) => team.slug === teamSlug);
}
export async function validateOrSelectTeam(
ctx: Context,
teamSlug: string | undefined,
promptMessage: string,
): Promise<{ teamSlug: string; chosen: boolean }> {
const teams: Team[] = await bigBrainAPI({ ctx, method: "GET", url: "teams" });
if (teams.length === 0) {
await ctx.crash({
exitCode: 1,
errorType: "fatal",
errForSentry: "No teams found",
printedMessage: chalk.red("Error: No teams found"),
});
}
if (!teamSlug) {
// Prompt the user to select if they belong to more than one team.
switch (teams.length) {
case 1:
return { teamSlug: teams[0].slug, chosen: false };
default:
return {
teamSlug: await promptSearch(ctx, {
message: promptMessage,
choices: teams.map((team: Team) => ({
name: `${team.name} (${team.slug})`,
value: team.slug,
})),
}),
chosen: true,
};
}
} else {
// Validate the chosen team.
if (!teams.find((team) => team.slug === teamSlug)) {
await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `Error: Team ${teamSlug} not found, fix the --team option or remove it`,
});
}
return { teamSlug, chosen: false };
}
}
export async function hasProject(
ctx: Context,
teamSlug: string,
projectSlug: string,
) {
try {
const projects: Project[] = await bigBrainAPIMaybeThrows({
ctx,
method: "GET",
url: `teams/${teamSlug}/projects`,
});
return !!projects.find((project) => project.slug === projectSlug);
} catch (e) {
return false;
}
}
export async function hasProjects(ctx: Context) {
return !!(await bigBrainAPI({ ctx, method: "GET", url: `has_projects` }));
}
export async function validateOrSelectProject(
ctx: Context,
projectSlug: string | undefined,
teamSlug: string,
singleProjectPrompt: string,
multiProjectPrompt: string,
): Promise<string | null> {
const projects: Project[] = await bigBrainAPI({
ctx,
method: "GET",
url: `teams/${teamSlug}/projects`,
});
if (projects.length === 0) {
// Unexpected error
// eslint-disable-next-line no-restricted-syntax
throw new Error("No projects found");
}
if (!projectSlug) {
const nonDemoProjects = projects.filter((project) => !project.isDemo);
if (nonDemoProjects.length === 0) {
// Unexpected error
// eslint-disable-next-line no-restricted-syntax
throw new Error("No projects found");
}
// Prompt the user to select project.
switch (nonDemoProjects.length) {
case 1: {
const project = nonDemoProjects[0];
const confirmed = await promptYesNo(ctx, {
message: `${singleProjectPrompt} ${project.name} (${project.slug})?`,
});
if (!confirmed) {
return null;
}
return nonDemoProjects[0].slug;
}
default:
return await promptSearch(ctx, {
message: multiProjectPrompt,
choices: nonDemoProjects.map((project: Project) => ({
name: `${project.name} (${project.slug})`,
value: project.slug,
})),
});
}
} else {
// Validate the chosen project.
if (!projects.find((project) => project.slug === projectSlug)) {
return await ctx.crash({
exitCode: 1,
errorType: "fatal",
printedMessage: `Error: Project ${projectSlug} not found, fix the --project option or remove it`,
});
}
return projectSlug;
}
}
/**
* @param ctx
* @returns a Record of dependency name to dependency version for dependencies
* and devDependencies
*/
export async function loadPackageJson(
ctx: Context,
includePeerDeps = false,
): Promise<Record<string, string>> {
let packageJson;
try {
packageJson = ctx.fs.readUtf8File("package.json");
} catch (err) {
return await ctx.crash({
exitCode: 1,
errorType: "invalid filesystem data",
printedMessage: `Unable to read your package.json: ${
err as any
}. Make sure you're running this command from the root directory of a Convex app that contains the package.json`,
});
}
let obj;
try {
obj = JSON.parse(packageJson);
} catch (err) {
return await ctx.crash({
exitCode: 1,
errorType: "invalid filesystem data",
errForSentry: err,
printedMessage: `Unable to parse package.json: ${err as any}`,
});
}
if (typeof obj !== "object") {
return await ctx.crash({
exitCode: 1,
errorType: "invalid filesystem data",
printedMessage: "Expected to parse an object from package.json",
});
}
const packages = {
...(includePeerDeps ? obj.peerDependencies ?? {} : {}),
...(obj.dependencies ?? {}),
...(obj.devDependencies ?? {}),
};
return packages;
}
export async function ensureHasConvexDependency(ctx: Context, cmd: string) {
const packages = await loadPackageJson(ctx, true);
const hasConvexDependency = "convex" in packages;
if (!hasConvexDependency) {
return await ctx.crash({
exitCode: 1,
errorType: "invalid filesystem data",
printedMessage: `In order to ${cmd}, add \`convex\` to your package.json dependencies.`,
});
}
}
/** Return a new array with elements of the passed in array sorted by a key lambda */
export const sorted = <T>(arr: T[], key: (el: T) => any): T[] => {
const newArr = [...arr];
const cmp = (a: T, b: T) => {
if (key(a) < key(b)) return -1;
if (key(a) > key(b)) return 1;
return 0;
};
return newArr.sort(cmp);
};
export function functionsDir(
configPath: string,
projectConfig: ProjectConfig,
): string {
return path.join(path.dirname(configPath), projectConfig.functions);
}
export function rootDirectory(): string {
let dirName;
// Use a different directory for config files generated for tests
if (process.env.CONVEX_PROVISION_HOST) {
const port = process.env.CONVEX_PROVISION_HOST.split(":")[2];
if (port === undefined || port === "8050") {
dirName = `.convex-test`;
} else {
dirName = `.convex-test-${port}`;
}
} else {
dirName = ".convex";
}
return path.join(os.homedir(), dirName);
}
export function globalConfigPath(): string {
return path.join(rootDirectory(), "config.json");
}
async function readGlobalConfig(ctx: Context): Promise<GlobalConfig | null> {
const configPath = globalConfigPath();
let configFile;
try {
configFile = ctx.fs.readUtf8File(configPath);
} catch (err) {
return null;
}
try {
const schema = z.object({
accessToken: z.string().min(1),
});
const config: GlobalConfig = schema.parse(JSON.parse(configFile));
return config;
} catch (err) {
// Print an error an act as if the file does not exist.
logError(
ctx,
chalk.red(
`Failed to parse global config in ${configPath} with error ${
err as any
}.`,
),
);
return null;
}
}
export function readAdminKeyFromEnvVar(): string | undefined {
return process.env[CONVEX_DEPLOY_KEY_ENV_VAR_NAME] ?? undefined;
}
export async function getAuthHeaderForBigBrain(
ctx: Context,
): Promise<string | null> {
if (process.env.CONVEX_OVERRIDE_ACCESS_TOKEN) {
return `Bearer ${process.env.CONVEX_OVERRIDE_ACCESS_TOKEN}`;
}
const globalConfig = await readGlobalConfig(ctx);
if (globalConfig) {
return `Bearer ${globalConfig.accessToken}`;
}
const adminKey = readAdminKeyFromEnvVar();
if (adminKey !== undefined && isPreviewDeployKey(adminKey)) {
return `Bearer ${adminKey}`;
}
return null;
}
export async function bigBrainFetch(ctx: Context): Promise<typeof fetch> {
const authHeader = await getAuthHeaderForBigBrain(ctx);
const bigBrainHeaders: Record<string, string> = authHeader
? {
Authorization: authHeader,
"Convex-Client": `npm-cli-${version}`,
}
: {
"Convex-Client": `npm-cli-${version}`,
};
return (resource: RequestInfo | URL, options: RequestInit | undefined) => {
const { headers: optionsHeaders, ...rest } = options || {};
const headers = {
...bigBrainHeaders,
...(optionsHeaders || {}),
};
const opts = {
retries: 6,
retryDelay,
headers,
...rest,
};
const url =
resource instanceof URL
? resource.pathname
: typeof resource === "string"
? new URL(resource, BIG_BRAIN_URL)
: new URL(resource.url, BIG_BRAIN_URL);
return throwingFetch(url, opts);
};
}
export async function bigBrainAPI({
ctx,
method,
url,
data,
}: {
ctx: Context;
method: string;
url: string;
data?: any;
}): Promise<any> {
const dataString =
data === undefined
? undefined
: typeof data === "string"
? data
: JSON.stringify(data);
try {
return await bigBrainAPIMaybeThrows({
ctx,
method,
url,
data: dataString,
});
} catch (err: unknown) {
return await logAndHandleFetchError(ctx, err);
}
}
export async function bigBrainAPIMaybeThrows({
ctx,
method,
url,
data,
}: {
ctx: Context;
method: string;
url: string;
data?: any;
}): Promise<any> {
const fetch = await bigBrainFetch(ctx);
const dataString =
data === undefined
? method === "POST" || method === "post"
? JSON.stringify({})
: undefined
: typeof data === "string"
? data
: JSON.stringify(data);
const res = await fetch(url, {
method,
...(dataString ? { body: dataString } : {}),
headers:
method === "POST" || method === "post"
? {
"Content-Type": "application/json",
}
: {},
});
deprecationCheckWarning(ctx, res);
if (res.status === 200) {
return await res.json();
}
}
export type GlobalConfig = {
accessToken: string;
};
/**
* Polls an arbitrary function until a condition is met.
*
* @param fetch Function performing a fetch, returning resulting data.
* @param condition This function will terminate polling when it returns `true`.
* @param waitMs How long to wait in between fetches.
* @returns The resulting data from `fetch`.
*/
export const poll = async function <Result>(
fetch: () => Promise<Result>,
condition: (data: Result) => boolean,
waitMs = 1000,
) {
let result = await fetch();
while (!condition(result)) {
await wait(waitMs);
result = await fetch();
}
return result;
};
const wait = function (waitMs: number) {
return new Promise((resolve) => {
setTimeout(resolve, waitMs);
});
};
export function waitForever() {
// This never resolves
return new Promise((_) => {
// ignore
});
}
// Returns a promise and a function that resolves the promise.
export function waitUntilCalled(): [Promise<unknown>, () => void] {
let onCalled: (v: unknown) => void;
const waitPromise = new Promise((resolve) => (onCalled = resolve));
return [waitPromise, () => onCalled(null)];
}
// We can eventually switch to something like `filesize` for i18n and
// more robust formatting, but let's keep our CLI bundle small for now.
export function formatSize(n: number): string {
if (n < 1024) {
return `${n} B`;
}
if (n < 1024 * 1024) {
return `${(n / 1024).toFixed(1)} KB`;
}
if (n < 1024 * 1024 * 1024) {
return `${(n / 1024 / 1024).toFixed(1)} MB`;
}
return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`;
}
export function formatDuration(ms: number): string {
const twoDigits = (n: number, unit: string) =>
`${n.toLocaleString("en-US", { maximumFractionDigits: 2 })}${unit}`;
if (ms < 1e-3) {
return twoDigits(ms * 1e9, "ns");
}
if (ms < 1) {
return twoDigits(ms * 1e3, "µs");
}
if (ms < 1e3) {
return twoDigits(ms, "ms");
}
const s = ms / 1e3;
if (s < 60) {
return twoDigits(ms / 1e3, "s");
}
return twoDigits(s / 60, "m");
}
export function getCurrentTimeString() {
const now = new Date();
const hours = String(now.getHours()).padStart(2, "0");
const minutes = String(now.getMinutes()).padStart(2, "0");
const seconds = String(now.getSeconds()).padStart(2, "0");
return `${hours}:${minutes}:${seconds}`;
}
// We don't allow running commands in project subdirectories yet,
// but we can provide better errors if we look around.
export async function findParentConfigs(ctx: Context): Promise<{
parentPackageJson: string;
parentConvexJson?: string;
}> {
const parentPackageJson = findUp(ctx, "package.json");
if (!parentPackageJson) {
return await ctx.crash({
exitCode: 1,
errorType: "invalid filesystem data",
printedMessage:
"No package.json found. To create a new project using Convex, see https://docs.convex.dev/home#quickstarts",
});
}
const candidateConvexJson =
parentPackageJson &&
path.join(path.dirname(parentPackageJson), "convex.json");
const parentConvexJson =
candidateConvexJson && ctx.fs.exists(candidateConvexJson)
? candidateConvexJson
: undefined;
return {
parentPackageJson,
parentConvexJson,
};
}
/**
* Finds a file in the current working directory or a parent.
*
* @returns The absolute path of the first file found or undefined.
*/
function findUp(ctx: Context, filename: string): string | undefined {
let curDir = path.resolve(".");
let parentDir = curDir;
do {
const candidate = path.join(curDir, filename);
if (ctx.fs.exists(candidate)) {
return candidate;
}
curDir = parentDir;
parentDir = path.dirname(curDir);
} while (parentDir !== curDir);
return;
}
/**
* Returns whether there's an existing project config. Throws
* if this is not a valid directory for a project config.
*/
export async function isInExistingProject(ctx: Context) {
const { parentPackageJson, parentConvexJson } = await findParentConfigs(ctx);
if (parentPackageJson !== path.resolve("package.json")) {
return await ctx.crash({
exitCode: 1,
errorType: "invalid filesystem data",
printedMessage: "Run this command from the root directory of a project.",
});
}
return !!parentConvexJson;
}
export async function getConfiguredDeploymentOrCrash(
ctx: Context,
): Promise<string> {
const configuredDeployment = await getConfiguredDeploymentName(ctx);
if (configuredDeployment !== null) {
return configuredDeployment;
}
return await ctx.crash({
exitCode: 1,
errorType: "invalid filesystem data",
printedMessage:
"No CONVEX_DEPLOYMENT set, run `npx convex dev` to configure a Convex project",
});
}
export async function getConfiguredDeploymentName(ctx: Context) {
const { parentPackageJson } = await findParentConfigs(ctx);
if (parentPackageJson !== path.resolve("package.json")) {
return await ctx.crash({
exitCode: 1,
errorType: "invalid filesystem data",
printedMessage: "Run this command from the root directory of a project.",
});
}
return getConfiguredDeploymentFromEnvVar().name;
}
// `spawnAsync` is the async version of Node's `spawnSync` (and `spawn`).
//
// By default, this returns the produced `stdout` and `stderror` and
// an error if one was encountered (to mirror `spawnSync`).
//
// If `stdio` is set to `"inherit"`, pipes `stdout` and `stderror` (
// pausing the spinner if one is running) and rejects the promise
// on errors (to mirror `execFileSync`).
export function spawnAsync(
ctx: Context,
command: string,
args: ReadonlyArray<string>,
): Promise<{
stdout: string;
stderr: string;
status: null | number;
error?: Error | undefined;
}>;
export function spawnAsync(
ctx: Context,
command: string,
args: ReadonlyArray<string>,
options: { stdio: "inherit" },
): Promise<void>;
export function spawnAsync(
ctx: Context,
command: string,
args: ReadonlyArray<string>,
options?: { stdio: "inherit" },
) {
return new Promise((resolve, reject) => {
const child = spawn(command, args);
let stdout = "";
let stderr = "";
const pipeOutput = options?.stdio === "inherit";
if (pipeOutput) {
child.stdout.on("data", (text) =>
logMessage(ctx, text.toString("utf-8").trimEnd()),
);
child.stderr.on("data", (text) =>
logError(ctx, text.toString("utf-8").trimEnd()),
);
} else {
child.stdout.on("data", (data) => {
stdout += data.toString("utf-8");
});
child.stderr.on("data", (data) => {
stderr += data.toString("utf-8");
});
}
const completionListener = (code: number | null) => {
child.removeListener("error", errorListener);
const result = pipeOutput
? { status: code }
: { stdout, stderr, status: code };
if (code !== 0) {
const argumentString =
args && args.length > 0 ? ` ${args.join(" ")}` : "";
const error = new Error(
`\`${command}${argumentString}\` exited with non-zero code: ${code}`,
);
if (pipeOutput) {
reject({ ...result, error });
} else {
resolve({ ...result, error });
}
} else {
resolve(result);
}
};
const errorListener = (error: Error) => {
child.removeListener("exit", completionListener);
child.removeListener("close", completionListener);
if (pipeOutput) {
reject({ error, status: null });
} else {
resolve({ error, status: null });
}
};
if (pipeOutput) {
child.once("exit", completionListener);
} else {
child.once("close", completionListener);
}
child.once("error", errorListener);
});
}
const IDEMPOTENT_METHODS = ["GET", "HEAD", "PUT", "DELETE", "OPTIONS", "TRACE"];
function retryDelay(
attempt: number,
_error: Error | null,
_response: Response | null,
): number {
// immediate, 1s delay, 2s delay, 4s delay, etc.
const delay = attempt === 0 ? 1 : 2 ** (attempt - 1) * 1000;
const randomSum = delay * 0.2 * Math.random();
return delay + randomSum;
}
function deploymentFetchRetryOn(onError?: (err: any) => void, method?: string) {
return function (
_attempt: number,
error: Error | null,
response: Response | null,
) {
if (onError && error !== null) {
onError(error);
}
// Retry on network errors.
if (error) {
// TODO filter out all SSL errors
// https://github.com/nodejs/node/blob/8a41d9b636be86350cd32847c3f89d327c4f6ff7/src/crypto/crypto_common.cc#L218-L245
return true;
}
// Retry on 404s since these can sometimes happen with newly created
// deployments for POSTs.
if (response?.status === 404) {
return true;
}
// Whatever the error code it doesn't hurt to retry idempotent requests.
if (
response &&
!response.ok &&
method &&
IDEMPOTENT_METHODS.includes(method.toUpperCase())
) {
// ...but it's a bit annoying to wait for things we know won't succced
if (
[
400, // Bad Request
401, // Unauthorized
402, // PaymentRequired
403, // Forbidden
405, // Method Not Allowed
406, // Not Acceptable
412, // Precondition Failed
413, // Payload Too Large
414, // URI Too Long
415, // Unsupported Media Type
416, // Range Not Satisfiable
].includes(response.status)
) {
return false;
}
return true;
}
return false;
};
}
/**
* Unlike `deploymentFetch`, this does not add on any headers, so the caller
* must supply any headers.
*/
export function bareDeploymentFetch(
deploymentUrl: string,
onError?: (err: any) => void,
): typeof throwingFetch {
return (resource: RequestInfo | URL, options: RequestInit | undefined) => {
const url =
resource instanceof URL
? resource.pathname
: typeof resource === "string"
? new URL(resource, deploymentUrl)
: new URL(resource.url, deploymentUrl);
const func = throwingFetch(url, {
retries: 6,
retryDelay,
retryOn: deploymentFetchRetryOn(onError, options?.method),
...options,
});
return func;
};
}
/**
* This returns a `fetch` function that will fetch against `deploymentUrl`.
*
* It will also set the `Authorization` header, `Content-Type` header, and
* the `Convex-Client` header if they are not set in the `fetch`.
*/
export function deploymentFetch(
deploymentUrl: string,
adminKey: string,
onError?: (err: any) => void,
): typeof throwingFetch {
return (resource: RequestInfo | URL, options: RequestInit | undefined) => {
const url =
resource instanceof URL
? resource.pathname
: typeof resource === "string"
? new URL(resource, deploymentUrl)
: new URL(resource.url, deploymentUrl);
const headers = new Headers(options?.headers || {});
if (!headers.has("Authorization")) {
headers.set("Authorization", `Convex ${adminKey}`);
}
if (!headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
if (!headers.has("Convex-Client")) {
headers.set("Convex-Client", `npm-cli-${version}`);
}
const func = throwingFetch(url, {
retries: 6,
retryDelay,
retryOn: deploymentFetchRetryOn(onError, options?.method),
...options,
headers,
});
return func;
};
}