convex
Version:
Client for the Convex Cloud
510 lines (472 loc) • 14.5 kB
text/typescript
import chalk from "chalk";
import axios from "axios";
import equal from "deep-equal";
import path from "path";
import {
Bundle,
bundle,
databaseEntryPoints,
actionsEntryPoints,
actionsDir,
} from "../../bundler/index.js";
import { version } from "../../index.js";
import axiosRetry from "axios-retry";
import {
deprecationCheckWarning,
formatSize,
functionsDir,
fatalServerErr,
} from "./utils.js";
export { provisionHost, productionProvisionHost } from "./utils.js";
import { Context } from "./context.js";
/** Type representing auth configuration. */
export interface AuthInfo {
// Provider-specific application identifier. Corresponds to the `aud` field in an OIDC token.
applicationID: string;
// Domain used for authentication. Corresponds to the `iss` field in an OIDC token.
domain: string;
}
/** Type representing Convex project configuration. */
export interface ProjectConfig {
project: string;
team: string;
prodUrl: string;
functions: string;
authInfo: AuthInfo[];
}
export interface Config {
projectConfig: ProjectConfig;
modules: Bundle[];
udfServerVersion?: string;
}
/** Check if object is of AuthInfo type. */
function isAuthInfo(object: any): object is AuthInfo {
return (
"applicationID" in object &&
typeof object.applicationID === "string" &&
"domain" in object &&
typeof object.domain === "string"
);
}
function isAuthInfos(object: any): object is AuthInfo[] {
return Array.isArray(object) && object.every((item: any) => isAuthInfo(item));
}
/** Error parsing ProjectConfig representation. */
class ParseError extends Error {}
/** Parse object to ProjectConfig. */
export function parseProjectConfig(obj: any): ProjectConfig {
if (typeof obj !== "object") {
throw new ParseError("Expected an object");
}
if (typeof obj.team !== "string") {
if (obj.instanceName && obj.origin) {
// This is likely a convex.json generated 0.1.8 or older.
throw new ParseError(
'If upgrading from convex 0.1.8 or below, please delete "convex.json" and reinitialize using `npx convex reinit`'
);
}
throw new ParseError("Expected team to be a string");
}
if (typeof obj.project !== "string") {
throw new ParseError("Expected project to be a string");
}
if (typeof obj.prodUrl !== "string") {
throw new ParseError("Expected prodUrl to be a string");
}
if (typeof obj.functions !== "string") {
throw new ParseError("Expected functions to be a string");
}
// Allow the `authInfo` key to be omitted, treating it as an empty list of providers.
obj.authInfo = obj.authInfo ?? [];
if (!isAuthInfos(obj.authInfo)) {
throw new ParseError("Expected authInfo to be type AuthInfo[]");
}
// Important! We return the object itself (not a new object) because
// we want to ensure that fields we're unaware of are "passed through".
// It's possible that this is an old client and the server knows about new
// fields that we don't.
return obj;
}
/** Parse a deployment config returned by the backend. */
function parseBackendConfig(obj: any): {
functions: string;
authInfo: AuthInfo[];
} {
if (typeof obj !== "object") {
throw new ParseError("Expected an object");
}
if (typeof obj.functions !== "string") {
throw new ParseError("Expected functions to be a string");
}
// Allow the `authInfo` key to be omitted, treating it as an empty list of providers.
obj.authInfo = obj.authInfo ?? [];
if (!isAuthInfos(obj.authInfo)) {
throw new ParseError("Expected authInfo to be type AuthInfo[]");
}
// Important! We return the object itself (not a new object) because
// we want to ensure that fields we're unaware of are "passed through".
// It's possible that this is an old client and the server knows about new
// fields that we don't.
return obj;
}
export function configName(): string {
return "convex.json";
}
export async function configFilepath(ctx: Context): Promise<string> {
const configFn = configName();
// We used to allow src/convex.json, but no longer (as of 10/7/2022).
// Leave an error message around to help people out. We can remove this
// error message after a couple months.
const preferredLocation = configFn;
const wrongLocation = path.join("src", configFn);
// Allow either location, but not both.
const preferredLocationExists = ctx.fs.exists(preferredLocation);
const wrongLocationExists = ctx.fs.exists(wrongLocation);
if (preferredLocationExists && wrongLocationExists) {
console.error(
chalk.red(
`Error: both ${preferredLocation} and ${wrongLocation} files exist!`
)
);
console.error(`Consolidate these and remove ${wrongLocation}.`);
return await ctx.fatalError(1, "fs");
}
if (!preferredLocationExists && wrongLocationExists) {
console.error(
chalk.red(
`Error: Please move ${wrongLocation} to the root of your project`
)
);
return await ctx.fatalError(1, "fs");
}
return preferredLocation;
}
/** Read configuration from a local `convex.json` file. */
export async function readProjectConfig(ctx: Context): Promise<{
projectConfig: ProjectConfig;
configPath: string;
}> {
let projectConfig;
const configPath = await configFilepath(ctx);
try {
projectConfig = parseProjectConfig(
JSON.parse(ctx.fs.readUtf8File(configPath))
);
} catch (err) {
if (err instanceof ParseError || err instanceof SyntaxError) {
console.error(chalk.red(`Error: Parsing "${configPath}" failed`));
console.error(chalk.gray(err.toString()));
} else {
console.error(
chalk.red(`Error: Unable to read project config file "${configPath}"`)
);
console.error(
"Are you running this command from the root directory of a Convex project?"
);
if (err instanceof Error) {
console.error(chalk.gray(err.message));
}
}
return await ctx.fatalError(1, "fs", err);
}
return {
projectConfig,
configPath,
};
}
/**
* Given an {@link ProjectConfig}, add in the bundled modules to produce the
* complete config.
*/
export async function configFromProjectConfig(
ctx: Context,
projectConfig: ProjectConfig,
configPath: string,
verbose: boolean
): Promise<Config> {
let modules;
try {
const baseDir = functionsDir(configPath, projectConfig);
// We bundle functions entry points separately since they execute on different
// platforms.
const entryPoints = await databaseEntryPoints(ctx.fs, baseDir, verbose);
modules = await bundle(ctx.fs, baseDir, entryPoints, true, "browser");
if (verbose) {
console.log(
"Queries and mutations modules: ",
modules.map(m => m.path)
);
}
// Bundle actions.
const nodeEntryPoints = await actionsEntryPoints(ctx.fs, baseDir, verbose);
const nodeModules = await bundle(
ctx.fs,
baseDir,
nodeEntryPoints,
true,
"node",
path.join(actionsDir, "_deps")
);
if (verbose) {
console.log(
"Actions modules: ",
nodeModules.map(m => m.path)
);
}
modules.push(...nodeModules);
} catch (err) {
console.error(chalk.red("Error: Unable to bundle Convex modules"));
if (err instanceof Error) {
console.error(chalk.gray(err.message));
}
return await ctx.fatalError(1, "fs", err);
}
return {
projectConfig: projectConfig,
modules: modules,
// We're just using the version this CLI is running with for now.
// This could be different than the version of `convex` the app runs with
// if the CLI is installed globally.
udfServerVersion: version,
};
}
/**
* Read the config from `convex.json` and bundle all the modules.
*/
export async function readConfig(
ctx: Context,
verbose: boolean
): Promise<{ config: Config; configPath: string }> {
const { projectConfig, configPath } = await readProjectConfig(ctx);
const config = await configFromProjectConfig(
ctx,
projectConfig,
configPath,
verbose
);
return { config, configPath };
}
/** Write the config to `convex.json` in the current working directory. */
export async function writeProjectConfig(
ctx: Context,
projectConfig: ProjectConfig
) {
const configPath = await configFilepath(ctx);
try {
const contents = JSON.stringify(projectConfig, undefined, 2) + "\n";
ctx.fs.writeUtf8File(configPath, contents, 0o644);
} catch (err) {
console.error(
chalk.red(
`Error: Unable to write project config file "${configPath}" in current directory`
)
);
console.error(
"Are you running this command from the root directory of a Convex project?"
);
return await ctx.fatalError(1, "fs", err);
}
ctx.fs.mkdir(functionsDir(configPath, projectConfig), {
allowExisting: true,
});
}
/** Pull configuration from the given remote origin. */
export async function pullConfig(
ctx: Context,
project: string,
team: string,
origin: string,
adminKey: string
): Promise<Config> {
const client = axios.create();
axiosRetry(client, {
retries: 4,
retryDelay: axiosRetry.exponentialDelay,
retryCondition: error => {
return error.response?.status === 404 || false;
},
});
try {
const res = await client.post(
`${origin}/api/${version}/get_config`,
{ version, adminKey },
{
maxContentLength: Infinity,
}
);
deprecationCheckWarning(ctx, res);
const { functions, authInfo } = parseBackendConfig(res.data.config);
const projectConfig = {
project,
team,
prodUrl: origin,
functions,
authInfo,
};
return {
projectConfig,
modules: res.data.modules,
udfServerVersion: res.data.udfServerVersion,
};
} catch (err) {
console.error(
chalk.red("Error: Unable to pull deployment config from", origin)
);
return await fatalServerErr(ctx, err);
}
}
export function configJSON(config: Config, adminKey: string) {
// Override origin with the url
const projectConfig = {
projectSlug: config.projectConfig.project,
teamSlug: config.projectConfig.team,
functions: config.projectConfig.functions,
authInfo: config.projectConfig.authInfo,
};
return {
config: projectConfig,
modules: config.modules,
udfServerVersion: config.udfServerVersion,
adminKey,
};
}
/** Push configuration to the given remote origin. */
export async function pushConfig(
ctx: Context,
config: Config,
adminKey: string,
url: string
): Promise<void> {
const serializedConfig = configJSON(config, adminKey);
try {
await axios.post(`${url}/api/${version}/push_config`, serializedConfig, {
maxContentLength: Infinity,
maxBodyLength: Infinity,
});
} catch (err) {
console.error(chalk.red("Error: Unable to push deployment config to", url));
return await fatalServerErr(ctx, err);
}
}
type Files = { source: string; filename: string }[];
export type CodegenResponse =
| {
success: true;
files: Files;
}
| {
success: false;
error: string;
};
function renderModule(module: Bundle): string {
const sourceMapSize = formatSize(module.sourceMap?.length ?? 0);
return (
module.path +
` (${formatSize(module.source.length)}, source map ${sourceMapSize})`
);
}
function compareModules(oldModules: Bundle[], newModules: Bundle[]): string {
let diff = "";
const droppedModules = [];
for (const oldModule of oldModules) {
let matches = false;
for (const newModule of newModules) {
if (
oldModule.path === newModule.path &&
oldModule.source === newModule.source &&
oldModule.sourceMap === newModule.sourceMap
) {
matches = true;
break;
}
}
if (!matches) {
droppedModules.push(oldModule);
}
}
if (droppedModules.length > 0) {
diff += "Delete the following modules:\n";
for (const module of droppedModules) {
diff += "[-] " + renderModule(module) + "\n";
}
}
const addedModules = [];
for (const newModule of newModules) {
let matches = false;
for (const oldModule of oldModules) {
if (
oldModule.path === newModule.path &&
oldModule.source === newModule.source &&
oldModule.sourceMap === newModule.sourceMap
) {
matches = true;
break;
}
}
if (!matches) {
addedModules.push(newModule);
}
}
if (addedModules.length > 0) {
diff += "Add the following modules:\n";
for (const module of addedModules) {
diff += "[+] " + renderModule(module) + "\n";
}
}
return diff;
}
/** Generate a human-readable diff between the two configs. */
export function diffConfig(oldConfig: Config, newConfig: Config): string {
let diff = compareModules(oldConfig.modules, newConfig.modules);
const droppedAuth = [];
for (const oldAuth of oldConfig.projectConfig.authInfo) {
let matches = false;
for (const newAuth of newConfig.projectConfig.authInfo) {
if (equal(oldAuth, newAuth)) {
matches = true;
break;
}
}
if (!matches) {
droppedAuth.push(oldAuth);
}
}
if (droppedAuth.length > 0) {
diff += "Remove the following auth providers:\n";
for (const authInfo of droppedAuth) {
diff += "[-] " + JSON.stringify(authInfo) + "\n";
}
}
const addedAuth = [];
for (const newAuth of newConfig.projectConfig.authInfo) {
let matches = false;
for (const oldAuth of oldConfig.projectConfig.authInfo) {
if (equal(newAuth, oldAuth)) {
matches = true;
break;
}
}
if (!matches) {
addedAuth.push(newAuth);
}
}
if (addedAuth.length > 0) {
diff += "Add the following auth providers:\n";
for (const auth of addedAuth) {
diff += "[+] " + JSON.stringify(auth) + "\n";
}
}
let versionMessage = "";
const matches = oldConfig.udfServerVersion === newConfig.udfServerVersion;
if (oldConfig.udfServerVersion && (!newConfig.udfServerVersion || !matches)) {
versionMessage += `[-] ${oldConfig.udfServerVersion}\n`;
}
if (newConfig.udfServerVersion && (!oldConfig.udfServerVersion || !matches)) {
versionMessage += `[+] ${newConfig.udfServerVersion}\n`;
}
if (versionMessage) {
diff += "Change the server's function version:\n";
diff += versionMessage;
}
return diff;
}