UNPKG

convex

Version:

Client for the Convex Cloud

510 lines (472 loc) 14.5 kB
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; }