convex
Version:
Client for the Convex Cloud
706 lines (703 loc) • 22.2 kB
JavaScript
import chalk from "chalk";
import equal from "deep-equal";
import { EOL } from "os";
import path from "path";
import {
changeSpinner,
logError,
logFailure,
logFinishedStep,
logMessage,
showSpinner
} from "../../bundler/context.js";
import {
bundle,
bundleAuthConfig,
entryPointsByEnvironment
} from "../../bundler/index.js";
import { version } from "../version.js";
import { deploymentDashboardUrlPage } from "../dashboard.js";
import {
formatSize,
functionsDir,
loadPackageJson,
deploymentFetch,
fetchDeprecationCheckWarning,
logAndHandleFetchError,
ThrowingFetchError
} from "./utils.js";
import { getTargetDeploymentName } from "./deployment.js";
import { createHash } from "crypto";
import { promisify } from "util";
import zlib from "zlib";
import { recursivelyDelete } from "./fsUtils.js";
export { productionProvisionHost, provisionHost } from "./utils.js";
const brotli = promisify(zlib.brotliCompress);
const DEFAULT_FUNCTIONS_PATH = "convex/";
function isAuthInfo(object) {
return "applicationID" in object && typeof object.applicationID === "string" && "domain" in object && typeof object.domain === "string";
}
function isAuthInfos(object) {
return Array.isArray(object) && object.every((item) => isAuthInfo(item));
}
class ParseError extends Error {
}
export async function parseProjectConfig(ctx, obj) {
if (typeof obj !== "object") {
logError(ctx, "Expected `convex.json` to contain an object");
return await ctx.crash(1, "invalid filesystem data");
}
if (typeof obj.node === "undefined") {
obj.node = {
externalPackages: []
};
} else if (typeof obj.node.externalPackages === "undefined") {
obj.node.externalPackages = [];
} else if (!Array.isArray(obj.node.externalPackages) || !obj.node.externalPackages.every((item) => typeof item === "string")) {
logError(
ctx,
"Expected `node.externalPackages` in `convex.json` to be an array of strings"
);
return await ctx.crash(1, "invalid filesystem data");
}
if (typeof obj.generateCommonJSApi === "undefined") {
obj.generateCommonJSApi = false;
} else if (typeof obj.generateCommonJSApi !== "boolean") {
logError(
ctx,
"Expected `generateCommonJSApi` in `convex.json` to be true or false"
);
return await ctx.crash(1, "invalid filesystem data");
}
if (typeof obj.functions === "undefined") {
obj.functions = DEFAULT_FUNCTIONS_PATH;
} else if (typeof obj.functions !== "string") {
logError(ctx, "Expected `functions` in `convex.json` to be a string");
return await ctx.crash(1, "invalid filesystem data");
}
if (obj.authInfo !== void 0) {
if (!isAuthInfos(obj.authInfo)) {
logError(
ctx,
"Expected `authInfo` in `convex.json` to be of type AuthInfo[]"
);
return await ctx.crash(1, "invalid filesystem data");
}
}
return obj;
}
function parseBackendConfig(obj) {
if (typeof obj !== "object") {
throw new ParseError("Expected an object");
}
const { functions, authInfo } = obj;
if (typeof functions !== "string") {
throw new ParseError("Expected functions to be a string");
}
if ((authInfo ?? null) !== null && !isAuthInfos(authInfo)) {
throw new ParseError("Expected authInfo to be type AuthInfo[]");
}
return {
functions,
...(authInfo ?? null) !== null ? { authInfo } : {}
};
}
export function configName() {
return "convex.json";
}
export async function configFilepath(ctx) {
const configFn = configName();
const preferredLocation = configFn;
const wrongLocation = path.join("src", configFn);
const preferredLocationExists = ctx.fs.exists(preferredLocation);
const wrongLocationExists = ctx.fs.exists(wrongLocation);
if (preferredLocationExists && wrongLocationExists) {
logError(
ctx,
chalk.red(
`Error: both ${preferredLocation} and ${wrongLocation} files exist!`
)
);
logFailure(ctx, `Consolidate these and remove ${wrongLocation}.`);
return await ctx.crash(1, "invalid filesystem data");
}
if (!preferredLocationExists && wrongLocationExists) {
logFailure(
ctx,
`Error: Please move ${wrongLocation} to the root of your project`
);
return await ctx.crash(1, "invalid filesystem data");
}
return preferredLocation;
}
export async function getFunctionsDirectoryPath(ctx) {
const { projectConfig, configPath } = await readProjectConfig(ctx);
return functionsDir(configPath, projectConfig);
}
export async function readProjectConfig(ctx) {
if (!ctx.fs.exists("convex.json")) {
const packages = await loadPackageJson(ctx);
const isCreateReactApp = "react-scripts" in packages;
return {
projectConfig: {
functions: isCreateReactApp ? `src/${DEFAULT_FUNCTIONS_PATH}` : DEFAULT_FUNCTIONS_PATH,
node: {
externalPackages: []
},
generateCommonJSApi: false
},
configPath: configName()
};
}
let projectConfig;
const configPath = await configFilepath(ctx);
try {
projectConfig = await parseProjectConfig(
ctx,
JSON.parse(ctx.fs.readUtf8File(configPath))
);
} catch (err) {
if (err instanceof ParseError || err instanceof SyntaxError) {
logError(ctx, chalk.red(`Error: Parsing "${configPath}" failed`));
logMessage(ctx, chalk.gray(err.toString()));
} else {
logFailure(
ctx,
`Error: Unable to read project config file "${configPath}"
Are you running this command from the root directory of a Convex project? If so, run \`npx convex dev\` first.`
);
if (err instanceof Error) {
logError(ctx, chalk.red(err.message));
}
}
return await ctx.crash(1, "invalid filesystem data", err);
}
return {
projectConfig,
configPath
};
}
export async function enforceDeprecatedConfigField(ctx, config, field) {
const value = config[field];
if (typeof value === "string") {
return value;
}
const err = new ParseError(`Expected ${field} to be a string`);
logFailure(ctx, `Error: Parsing convex.json failed`);
logMessage(ctx, chalk.gray(err.toString()));
return await ctx.crash(1, "invalid filesystem data", err);
}
export async function configFromProjectConfig(ctx, projectConfig, configPath, verbose) {
const baseDir = functionsDir(configPath, projectConfig);
const entryPoints = await entryPointsByEnvironment(ctx, baseDir, verbose);
if (verbose) {
showSpinner(ctx, "Bundling modules for Convex's runtime...");
}
const convexResult = await bundle(
ctx,
baseDir,
entryPoints.isolate,
true,
"browser"
);
if (verbose) {
logMessage(
ctx,
"Convex's runtime modules: ",
convexResult.modules.map((m) => m.path)
);
}
if (verbose) {
showSpinner(ctx, "Bundling modules for Node.js runtime...");
}
const nodeResult = await bundle(
ctx,
baseDir,
entryPoints.node,
true,
"node",
path.join("_deps", "node"),
projectConfig.node.externalPackages
);
if (verbose) {
logMessage(
ctx,
"Node.js runtime modules: ",
nodeResult.modules.map((m) => m.path)
);
if (projectConfig.node.externalPackages.length > 0) {
logMessage(
ctx,
"Node.js runtime external dependencies (to be installed on the server): ",
[...nodeResult.externalDependencies.entries()].map(
(a) => `${a[0]}: ${a[1]}`
)
);
}
}
const modules = convexResult.modules;
modules.push(...nodeResult.modules);
modules.push(...await bundleAuthConfig(ctx, baseDir));
const nodeDependencies = [];
for (const [moduleName, moduleVersion] of nodeResult.externalDependencies) {
nodeDependencies.push({ name: moduleName, version: moduleVersion });
}
const bundledModuleInfos = Array.from(
convexResult.bundledModuleNames.keys()
).map((moduleName) => {
return {
name: moduleName,
platform: "convex"
};
});
bundledModuleInfos.push(
...Array.from(nodeResult.bundledModuleNames.keys()).map(
(moduleName) => {
return {
name: moduleName,
platform: "node"
};
}
)
);
return {
config: {
projectConfig,
modules,
nodeDependencies,
// 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
},
bundledModuleInfos
};
}
export async function readConfig(ctx, verbose) {
const { projectConfig, configPath } = await readProjectConfig(ctx);
const { config, bundledModuleInfos } = await configFromProjectConfig(
ctx,
projectConfig,
configPath,
verbose
);
return { config, configPath, bundledModuleInfos };
}
export async function upgradeOldAuthInfoToAuthConfig(ctx, config, functionsPath) {
if (config.authInfo !== void 0) {
const authConfigPathJS = path.resolve(functionsPath, "auth.config.js");
const authConfigPathTS = path.resolve(functionsPath, "auth.config.js");
const authConfigPath = ctx.fs.exists(authConfigPathJS) ? authConfigPathJS : authConfigPathTS;
const authConfigRelativePath = path.join(
config.functions,
ctx.fs.exists(authConfigPathJS) ? "auth.config.js" : "auth.config.ts"
);
if (ctx.fs.exists(authConfigPath)) {
logFailure(
ctx,
`Cannot set auth config in both \`${authConfigRelativePath}\` and convex.json, remove it from convex.json`
);
await ctx.crash(1, "invalid filesystem data");
}
if (config.authInfo.length > 0) {
const providersStringLines = JSON.stringify(
config.authInfo,
null,
2
).split(EOL);
const indentedProvidersString = [providersStringLines[0]].concat(providersStringLines.slice(1).map((line) => ` ${line}`)).join(EOL);
ctx.fs.writeUtf8File(
authConfigPath,
` export default {
providers: ${indentedProvidersString},
};`
);
logMessage(
ctx,
chalk.yellowBright(
`Moved auth config from config.json to \`${authConfigRelativePath}\``
)
);
}
delete config.authInfo;
}
return config;
}
export async function writeProjectConfig(ctx, projectConfig, { deleteIfAllDefault } = {
deleteIfAllDefault: false
}) {
const configPath = await configFilepath(ctx);
const strippedConfig = filterWriteableConfig(stripDefaults(projectConfig));
if (Object.keys(strippedConfig).length > 0) {
try {
const contents = JSON.stringify(strippedConfig, void 0, 2) + "\n";
ctx.fs.writeUtf8File(configPath, contents, 420);
} catch (err) {
logFailure(
ctx,
`Error: Unable to write project config file "${configPath}" in current directory
Are you running this command from the root directory of a Convex project?`
);
return await ctx.crash(1, "invalid filesystem data", err);
}
} else if (deleteIfAllDefault && ctx.fs.exists(configPath)) {
ctx.fs.unlink(configPath);
logMessage(
ctx,
chalk.yellowBright(
`Deleted ${configPath} since it completely matched defaults`
)
);
}
ctx.fs.mkdir(functionsDir(configPath, projectConfig), {
allowExisting: true
});
}
function stripDefaults(projectConfig) {
const stripped = { ...projectConfig };
if (stripped.functions === DEFAULT_FUNCTIONS_PATH) {
delete stripped.functions;
}
if (Array.isArray(stripped.authInfo) && stripped.authInfo.length === 0) {
delete stripped.authInfo;
}
if (stripped.node.externalPackages.length === 0) {
delete stripped.node.externalPackages;
}
if (stripped.generateCommonJSApi === false) {
delete stripped.generateCommonJSApi;
}
if (Object.keys(stripped.node).length === 0) {
delete stripped.node;
}
return stripped;
}
function filterWriteableConfig(projectConfig) {
const writeable = { ...projectConfig };
delete writeable.project;
delete writeable.team;
delete writeable.prodUrl;
return writeable;
}
export function removedExistingConfig(ctx, configPath, options) {
if (!options.allowExistingConfig) {
return false;
}
recursivelyDelete(ctx, configPath);
logFinishedStep(ctx, `Removed existing ${configPath}`);
return true;
}
export async function pullConfig(ctx, project, team, origin, adminKey) {
const fetch = deploymentFetch(origin);
changeSpinner(ctx, "Downloading current deployment state...");
try {
const res = await fetch("/api/get_config_hashes", {
method: "POST",
body: JSON.stringify({ version, adminKey }),
headers: {
"Content-Type": "application/json",
"Convex-Client": `npm-cli-${version}`
}
});
fetchDeprecationCheckWarning(ctx, res);
const data = await res.json();
const backendConfig = parseBackendConfig(data.config);
const projectConfig = {
...backendConfig,
// This field is not stored in the backend, which is ok since it is also
// not used to diff configs.
node: {
externalPackages: []
},
// This field is not stored in the backend, it only affects the client.
generateCommonJSApi: false,
project,
team,
prodUrl: origin
};
return {
projectConfig,
moduleHashes: data.moduleHashes,
// TODO(presley): Add this to diffConfig().
nodeDependencies: data.nodeDependencies,
udfServerVersion: data.udfServerVersion
};
} catch (err) {
logFailure(ctx, `Error: Unable to pull deployment config from ${origin}`);
return await logAndHandleFetchError(ctx, err);
}
}
export function config2JSON(adminKey, functions, udfServerVersion, appDefinition, componentDefinitions) {
return {
adminKey,
functions,
udfServerVersion,
appDefinition,
componentDefinitions,
nodeDependencies: []
};
}
export function configJSON(config, adminKey, schemaId, pushMetrics, bundledModuleInfos) {
const projectConfig = {
projectSlug: config.projectConfig.project,
teamSlug: config.projectConfig.team,
functions: config.projectConfig.functions,
authInfo: config.projectConfig.authInfo
};
return {
config: projectConfig,
modules: config.modules,
nodeDependencies: config.nodeDependencies,
udfServerVersion: config.udfServerVersion,
schemaId,
adminKey,
pushMetrics,
bundledModuleInfos
};
}
export async function pushConfig2(ctx, adminKey, url, functions, udfServerVersion, appDefinition, componentDefinitions) {
const serializedConfig = config2JSON(
adminKey,
functions,
udfServerVersion,
appDefinition,
componentDefinitions
);
const fetch = deploymentFetch(url);
changeSpinner(ctx, "Analyzing and deploying source code...");
try {
const response = await fetch("/api/deploy2/start_push", {
body: JSON.stringify(serializedConfig),
method: "POST",
headers: {
"Content-Type": "application/json",
"Convex-Client": `npm-cli-${version}`
}
});
return await response.json();
} catch (error) {
logFailure(ctx, "Error: Unable to start push to " + url);
return await logAndHandleFetchError(ctx, error);
}
}
export async function pushConfig(ctx, config, adminKey, url, pushMetrics, schemaId, bundledModuleInfos) {
const serializedConfig = configJSON(
config,
adminKey,
schemaId,
pushMetrics,
bundledModuleInfos
);
const fetch = deploymentFetch(url);
try {
if (config.nodeDependencies.length > 0) {
changeSpinner(
ctx,
"Installing external packages and deploying source code..."
);
} else {
changeSpinner(ctx, "Analyzing and deploying source code...");
}
await fetch("/api/push_config", {
body: await brotli(JSON.stringify(serializedConfig), {
params: {
[zlib.constants.BROTLI_PARAM_MODE]: zlib.constants.BROTLI_MODE_TEXT,
[zlib.constants.BROTLI_PARAM_QUALITY]: 4
}
}),
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Encoding": "br",
"Convex-Client": `npm-cli-${version}`
}
});
} catch (error) {
const data = error instanceof ThrowingFetchError ? error.serverErrorData : void 0;
if (data?.code === "AuthConfigMissingEnvironmentVariable") {
const errorMessage = data.message || "(no error message given)";
const configuredDeployment = getTargetDeploymentName();
const [, variableName] = errorMessage.match(/Environment variable (\S+)/i) ?? [];
const variableQuery = variableName !== void 0 ? `?var=${variableName}` : "";
const dashboardUrl = await deploymentDashboardUrlPage(
configuredDeployment,
`/settings/environment-variables${variableQuery}`
);
logFailure(
ctx,
`Environment variable ${chalk.bold(
variableName
)} is used in auth config file but its value was not set. Go to:
${chalk.bold(
dashboardUrl
)}
to set it up. `
);
await ctx.crash(1, "invalid filesystem or env vars", error);
}
logFailure(ctx, "Error: Unable to push deployment config to " + url);
return await logAndHandleFetchError(ctx, error);
}
}
function renderModule(module) {
return module.path + ` (${formatSize(module.sourceSize)}, source map ${module.sourceMapSize})`;
}
function hash(bundle2) {
return createHash("sha256").update(bundle2.source).update(bundle2.sourceMap || "").digest("hex");
}
function compareModules(oldModules, newModules) {
let diff = "";
const oldModuleMap = new Map(
oldModules.map((value) => [value.path, value.hash])
);
const newModuleMap = new Map(
newModules.map((value) => [
value.path,
{
hash: hash(value),
sourceMapSize: value.sourceMap?.length ?? 0,
sourceSize: value.source.length
}
])
);
const updatedModules = [];
const identicalModules = [];
const droppedModules = [];
const addedModules = [];
for (const [path2, oldHash] of oldModuleMap.entries()) {
const newModule = newModuleMap.get(path2);
if (newModule === void 0) {
droppedModules.push(path2);
} else if (newModule.hash !== oldHash) {
updatedModules.push({
path: path2,
sourceMapSize: newModule.sourceMapSize,
sourceSize: newModule.sourceSize
});
} else {
identicalModules.push({
path: path2,
size: newModule.sourceSize + newModule.sourceMapSize
});
}
}
for (const [path2, newModule] of newModuleMap.entries()) {
if (oldModuleMap.get(path2) === void 0) {
addedModules.push({
path: path2,
sourceMapSize: newModule.sourceMapSize,
sourceSize: newModule.sourceSize
});
}
}
if (droppedModules.length > 0 || updatedModules.length > 0) {
diff += "Delete the following modules:\n";
for (const module of droppedModules) {
diff += `[-] ${module}
`;
}
for (const module of updatedModules) {
diff += `[-] ${module.path}
`;
}
}
if (addedModules.length > 0 || updatedModules.length > 0) {
diff += "Add the following modules:\n";
for (const module of addedModules) {
diff += "[+] " + renderModule(module) + "\n";
}
for (const module of updatedModules) {
diff += "[+] " + renderModule(module) + "\n";
}
}
return {
diffString: diff,
stats: {
updated: {
count: updatedModules.length,
size: updatedModules.reduce((acc, curr) => {
return acc + curr.sourceMapSize + curr.sourceSize;
}, 0)
},
identical: {
count: identicalModules.length,
size: identicalModules.reduce((acc, curr) => {
return acc + curr.size;
}, 0)
},
added: {
count: addedModules.length,
size: addedModules.reduce((acc, curr) => {
return acc + curr.sourceMapSize + curr.sourceSize;
}, 0)
},
numDropped: droppedModules.length
}
};
}
export function diffConfig(oldConfig, newConfig) {
const { diffString, stats } = compareModules(
oldConfig.moduleHashes,
newConfig.modules
);
let diff = diffString;
const droppedAuth = [];
if (oldConfig.projectConfig.authInfo !== void 0 && newConfig.projectConfig.authInfo !== void 0) {
for (const oldAuth of oldConfig.projectConfig.authInfo) {
let matches2 = false;
for (const newAuth of newConfig.projectConfig.authInfo) {
if (equal(oldAuth, newAuth)) {
matches2 = true;
break;
}
}
if (!matches2) {
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 matches2 = false;
for (const oldAuth of oldConfig.projectConfig.authInfo) {
if (equal(newAuth, oldAuth)) {
matches2 = true;
break;
}
}
if (!matches2) {
addedAuth.push(newAuth);
}
}
if (addedAuth.length > 0) {
diff += "Add the following auth providers:\n";
for (const auth of addedAuth) {
diff += "[+] " + JSON.stringify(auth) + "\n";
}
}
} else if (oldConfig.projectConfig.authInfo !== void 0 !== (newConfig.projectConfig.authInfo !== void 0)) {
diff += "Moved auth config into auth.config.ts\n";
}
let versionMessage = "";
const matches = oldConfig.udfServerVersion === newConfig.udfServerVersion;
if (oldConfig.udfServerVersion && (!newConfig.udfServerVersion || !matches)) {
versionMessage += `[-] ${oldConfig.udfServerVersion}
`;
}
if (newConfig.udfServerVersion && (!oldConfig.udfServerVersion || !matches)) {
versionMessage += `[+] ${newConfig.udfServerVersion}
`;
}
if (versionMessage) {
diff += "Change the server's function version:\n";
diff += versionMessage;
}
return { diffString: diff, stats };
}
//# sourceMappingURL=config.js.map
;