UNPKG

convex

Version:

Client for the Convex Cloud

620 lines (585 loc) 20.1 kB
import path from "path"; import crypto from "crypto"; import { ComponentDirectory, ComponentDefinitionPath, buildComponentDirectory, isComponentDirectory, qualifiedDefinitionPath, toComponentDefinitionPath, EncodedComponentDefinitionPath, } from "./directoryStructure.js"; import { Context, logMessage, logWarning, showSpinner, } from "../../../../bundler/context.js"; import esbuild, { BuildOptions, Metafile, OutputFile, Plugin } from "esbuild"; import chalk from "chalk"; import { AppDefinitionSpecWithoutImpls, ComponentDefinitionSpecWithoutImpls, } from "../../config.js"; import { Bundle, bundle, bundleAuthConfig, bundleSchema, entryPointsByEnvironment, } from "../../../../bundler/index.js"; import { NodeDependency } from "../../deployApi/modules.js"; /** * An esbuild plugin to mark component definitions external or return a list of * all component definitions. * * By default this plugin runs in "bundle" mode and marks all imported component * definition files as external, not traversing further. * * If "discover" mode is specified it traverses the entire tree. */ function componentPlugin({ mode = "bundle", rootComponentDirectory, verbose, ctx, }: { mode: "discover" | "bundle"; rootComponentDirectory: ComponentDirectory; verbose?: boolean; ctx: Context; }): Plugin { const components = new Map<string, ComponentDirectory>(); return { name: `convex-${mode === "discover" ? "discover-components" : "bundle-components"}`, async setup(build) { // This regex can't be really precise since developers could import // "convex.config", "convex.config.js", "convex.config.ts", etc. build.onResolve({ filter: /.*convex.config.*/ }, async (args) => { verbose && logMessage(ctx, "esbuild resolving import:", args); if (args.namespace !== "file") { verbose && logMessage(ctx, " Not a file."); return; } if (args.kind === "entry-point") { verbose && logMessage(ctx, " -> Top-level entry-point."); const componentDirectory = await buildComponentDirectory( ctx, path.resolve(args.path), ); // No attempt to resolve args.path is made for entry points so they // must be relative or absolute file paths, not npm packages. // Whether we're bundling or discovering, we're done. if (components.get(args.path)) { // We always invoke esbuild in a try/catch. // eslint-disable-next-line no-restricted-syntax throw new Error( `Entry point component "${args.path}" already registered.`, ); } components.set(args.path, componentDirectory); return; } const candidates = [args.path]; const ext = path.extname(args.path); if (ext === ".js") { candidates.push(args.path.slice(0, -".js".length) + ".ts"); } if (ext !== ".js" && ext !== ".ts") { candidates.push(args.path + ".js"); candidates.push(args.path + ".ts"); } let resolvedPath = undefined; for (const candidate of candidates) { const result = await build.resolve(candidate, { // We expect this to be "import-statement" but pass 'kind' through // to say honest to normal esbuild behavior. kind: args.kind, resolveDir: args.resolveDir, }); if (result.path) { resolvedPath = result.path; break; } } if (resolvedPath === undefined) { verbose && logMessage(ctx, ` -> ${args.path} not found.`); return; } const parentDir = path.dirname(resolvedPath); let imported = components.get(resolvedPath); if (!imported) { const isComponent = isComponentDirectory(ctx, parentDir, false); if (isComponent.kind !== "ok") { verbose && logMessage(ctx, " -> Not a component:", isComponent); return; } imported = isComponent.component; components.set(resolvedPath, imported); } verbose && logMessage( ctx, " -> Component import! Recording it.", args.path, resolvedPath, ); if (mode === "discover") { return { path: resolvedPath, }; } else { // In bundle mode, transform external imports to use componentPaths: // import rateLimiter from "convex_ratelimiter"; // => import rateLimiter from `_componentDeps/${base64('../node_modules/convex_ratelimiter')}`; // A componentPath is path from the root component to the directory // of the this component's definition file. const componentPath = toComponentDefinitionPath( rootComponentDirectory, imported, ); const encodedPath = hackyMapping(encodeDefinitionPath(componentPath)); return { path: encodedPath, external: true, }; } }); }, }; } /** The path on the deployment that identifier a component definition. */ function hackyMapping(componentPath: EncodedComponentDefinitionPath): string { return `./_componentDeps/${Buffer.from(componentPath).toString("base64").replace(/=+$/, "")}`; } // Share configuration between the component definition discovery and bundling passes. const SHARED_ESBUILD_OPTIONS = { bundle: true, platform: "browser", format: "esm", target: "esnext", // false is the default for splitting. // It's simpler to evaluate these on the server when we don't need a whole // filesystem. Enabled this for speed once the server supports it. splitting: false, // place output files in memory at their source locations write: false, outdir: path.parse(process.cwd()).root, outbase: path.parse(process.cwd()).root, minify: true, keepNames: true, metafile: true, } as const satisfies BuildOptions; // Use the esbuild metafile to discover the dependency graph in which component // definitions are nodes. export async function componentGraph( ctx: Context, absWorkingDir: string, rootComponentDirectory: ComponentDirectory, verbose: boolean = true, ): Promise<{ components: Map<string, ComponentDirectory>; dependencyGraph: [ComponentDirectory, ComponentDirectory][]; }> { let result; try { result = await esbuild.build({ absWorkingDir, // This is mostly useful for formatting error messages. entryPoints: [qualifiedDefinitionPath(rootComponentDirectory)], plugins: [ componentPlugin({ ctx, mode: "discover", verbose, rootComponentDirectory, }), ], sourcemap: "external", sourcesContent: false, ...SHARED_ESBUILD_OPTIONS, }); await registerEsbuildReads(ctx, absWorkingDir, result.metafile); } catch (err: any) { return await ctx.crash({ exitCode: 1, errorType: "invalid filesystem data", printedMessage: `esbuild failed: ${err}`, }); } if (result.errors.length) { const message = result.errors.map((error) => error.text).join("\n"); return await ctx.crash({ exitCode: 1, errorType: "invalid filesystem data", printedMessage: message, }); } for (const warning of result.warnings) { console.log(chalk.yellow(`esbuild warning: ${warning.text}`)); } return await findComponentDependencies(ctx, result.metafile); } /** * Get dependencies of a ComponenDirectory as ComponentPaths. * * Component paths are paths relative to the root component. */ export function getDeps( rootComponent: ComponentDirectory, dependencyGraph: [ComponentDirectory, ComponentDirectory][], definitionPath: string, ): ComponentDefinitionPath[] { return dependencyGraph .filter( ([importer, _imported]) => importer.definitionPath === definitionPath, ) .map(([_importer, imported]) => toComponentDefinitionPath(rootComponent, imported), ); } /** * The returned dependency graph is an array of tuples of [importer, imported] * * This doesn't work on just any esbuild metafile because it assumes input * imports have not been transformed. We run it on the metafile produced by * the esbuild invocation that uses the component plugin in "discover" mode. */ async function findComponentDependencies( ctx: Context, metafile: Metafile, ): Promise<{ components: Map<string, ComponentDirectory>; dependencyGraph: [ComponentDirectory, ComponentDirectory][]; }> { const { inputs } = metafile; // This filter means we only supports *direct imports* of component definitions // from other component definitions. const componentInputs = Object.keys(inputs).filter((path) => path.includes(".config."), ); // Absolute path doesn't appear to be necessary here since only inputs marked // external get transformed to an absolute path but it's not clear what's an // esbuild implementation detail in the metafile or which settings change this. const componentsByAbsPath = new Map<string, ComponentDirectory>(); for (const inputPath of componentInputs) { const importer = await buildComponentDirectory(ctx, inputPath); componentsByAbsPath.set(path.resolve(inputPath), importer); } const dependencyGraph: [ComponentDirectory, ComponentDirectory][] = []; for (const inputPath of componentInputs) { const importer = componentsByAbsPath.get(path.resolve(inputPath))!; const { imports } = inputs[inputPath]; const componentImports = imports.filter((imp) => imp.path.includes(".config."), ); for (const importPath of componentImports.map((dep) => dep.path)) { const imported = componentsByAbsPath.get(path.resolve(importPath)); if (!imported) { return await ctx.crash({ exitCode: 1, errorType: "invalid filesystem data", printedMessage: `Didn't find ${path.resolve(importPath)} in ${[...componentsByAbsPath.keys()].toString()}`, }); } dependencyGraph.push([importer, imported]); } } const components = new Map<string, ComponentDirectory>(); for (const directory of componentsByAbsPath.values()) { components.set(directory.path, directory); } return { components, dependencyGraph }; } // Each path component is less than 64 bytes and escape all a-zA-Z0-9 // This is the only version of the path the server will receive. export function encodeDefinitionPath( s: ComponentDefinitionPath, ): EncodedComponentDefinitionPath { const components = s.split(path.sep); return components .map((s) => { const escaped = s.replaceAll("-", "_").replaceAll("+", "_"); if (escaped.length <= 64) { return escaped; } const hash = crypto.createHash("md5").update(s).digest("hex"); return `${escaped.slice(0, 50)}${hash.slice(0, 14)}`; }) .join(path.sep) as EncodedComponentDefinitionPath; } // NB: If a directory linked to is not a member of the passed // componentDirectories array then there will be external links // with no corresponding definition bundle. // That could be made to throw an error but maybe those are already available // on the Convex definition filesystem somehow, e.g. builtin components. /** Bundle the component definitions listed. */ export async function bundleDefinitions( ctx: Context, absWorkingDir: string, dependencyGraph: [ComponentDirectory, ComponentDirectory][], rootComponentDirectory: ComponentDirectory, componentDirectories: ComponentDirectory[], verbose: boolean = false, ): Promise<{ appDefinitionSpecWithoutImpls: AppDefinitionSpecWithoutImpls; componentDefinitionSpecsWithoutImpls: ComponentDefinitionSpecWithoutImpls[]; }> { let result; try { result = await esbuild.build({ absWorkingDir, entryPoints: componentDirectories.map((dir) => qualifiedDefinitionPath(dir), ), plugins: [ componentPlugin({ ctx, mode: "bundle", verbose, rootComponentDirectory, }), ], sourcemap: false, // we're just building a deps map ...SHARED_ESBUILD_OPTIONS, }); await registerEsbuildReads(ctx, absWorkingDir, result.metafile); } catch (err: any) { return await ctx.crash({ exitCode: 1, errorType: "invalid filesystem data", printedMessage: `esbuild failed: ${err}`, }); } if (result.errors.length) { const message = result.errors.map((error) => error.text).join("\n"); return await ctx.crash({ exitCode: 1, errorType: "invalid filesystem data", printedMessage: message, }); } for (const warning of result.warnings) { console.log(chalk.yellow(`esbuild warning: ${warning.text}`)); } const outputs: { outputJs: OutputFile; outputJsMap?: OutputFile; directory: ComponentDirectory; }[] = []; for (const directory of componentDirectories) { const absInput = path.resolve(absWorkingDir, directory.definitionPath); const expectedOutputJs = absInput.slice(0, absInput.lastIndexOf(".")) + ".js"; const expectedOutputMap = absInput.slice(0, absInput.lastIndexOf(".")) + ".js.map"; const outputJs = result.outputFiles.filter( (outputFile) => outputFile.path === expectedOutputJs, )[0]; if (!outputJs) { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: `no JS found matching ${expectedOutputJs} in ${result.outputFiles.map((x) => x.path).toString()}`, }); } const outputJsMap = result.outputFiles.filter( (outputFile) => outputFile.path === expectedOutputMap, )[0]; outputs.push({ outputJs, outputJsMap, directory, }); } const appBundles = outputs.filter( (out) => out.directory.path === rootComponentDirectory.path, ); if (appBundles.length !== 1) { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "found wrong number of app bundles", }); } const appBundle = appBundles[0]; const componentBundles = outputs.filter( (out) => out.directory.path !== rootComponentDirectory.path, ); const componentDefinitionSpecsWithoutImpls: ComponentDefinitionSpecWithoutImpls[] = componentBundles.map(({ directory, outputJs, outputJsMap }) => ({ definitionPath: encodeDefinitionPath( toComponentDefinitionPath(rootComponentDirectory, directory), ), origDefinitionPath: toComponentDefinitionPath( rootComponentDirectory, directory, ), definition: { path: path.relative(directory.path, outputJs.path), source: outputJs.text, sourceMap: outputJsMap?.text, environment: "isolate" as const, }, dependencies: getDeps( rootComponentDirectory, dependencyGraph, directory.definitionPath, ).map(encodeDefinitionPath), })); const appDeps = getDeps( rootComponentDirectory, dependencyGraph, appBundle.directory.definitionPath, ).map(encodeDefinitionPath); const appDefinitionSpecWithoutImpls: AppDefinitionSpecWithoutImpls = { definition: { path: path.relative(rootComponentDirectory.path, appBundle.outputJs.path), source: appBundle.outputJs.text, sourceMap: appBundle.outputJsMap?.text, environment: "isolate" as const, }, dependencies: appDeps, }; return { appDefinitionSpecWithoutImpls, componentDefinitionSpecsWithoutImpls, }; } export async function bundleImplementations( ctx: Context, rootComponentDirectory: ComponentDirectory, componentDirectories: ComponentDirectory[], nodeExternalPackages: string[], verbose: boolean = false, ): Promise<{ appImplementation: { schema: Bundle | null; functions: Bundle[]; externalNodeDependencies: NodeDependency[]; }; componentImplementations: { schema: Bundle | null; functions: Bundle[]; definitionPath: EncodedComponentDefinitionPath; }[]; }> { let appImplementation; const componentImplementations = []; let isRoot = true; for (const directory of [rootComponentDirectory, ...componentDirectories]) { const resolvedPath = path.resolve( rootComponentDirectory.path, directory.path, ); let schema; if (ctx.fs.exists(path.resolve(resolvedPath, "schema.ts"))) { schema = (await bundleSchema(ctx, resolvedPath))[0] || null; } else if (ctx.fs.exists(path.resolve(resolvedPath, "schema.js"))) { schema = (await bundleSchema(ctx, resolvedPath))[0] || null; } else { schema = null; } const entryPoints = await entryPointsByEnvironment( ctx, resolvedPath, verbose, ); const convexResult: { modules: Bundle[]; externalDependencies: Map<string, string>; bundledModuleNames: Set<string>; } = await bundle(ctx, resolvedPath, entryPoints.isolate, true, "browser"); if (convexResult.externalDependencies.size !== 0) { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "external dependencies not supported", }); } const functions = convexResult.modules; if (isRoot) { if (verbose) { showSpinner(ctx, "Bundling modules for Node.js runtime..."); } const nodeResult: { modules: Bundle[]; externalDependencies: Map<string, string>; bundledModuleNames: Set<string>; } = await bundle( ctx, resolvedPath, entryPoints.node, true, "node", path.join("_deps", "node"), nodeExternalPackages, ); const externalNodeDependencies: NodeDependency[] = []; for (const [ moduleName, moduleVersion, ] of nodeResult.externalDependencies) { externalNodeDependencies.push({ name: moduleName, version: moduleVersion, }); } const authBundle = await bundleAuthConfig(ctx, resolvedPath); appImplementation = { schema, functions: functions.concat(nodeResult.modules).concat(authBundle), externalNodeDependencies, }; } else { // definitionPath is the canonical form const definitionPath = encodeDefinitionPath( toComponentDefinitionPath(rootComponentDirectory, directory), ); componentImplementations.push({ definitionPath, schema, functions }); } isRoot = false; } if (!appImplementation) { return await ctx.crash({ exitCode: 1, errorType: "fatal", printedMessage: "No app implementation found", }); } return { appImplementation, componentImplementations }; } async function registerEsbuildReads( ctx: Context, absWorkingDir: string, metafile: Metafile, ) { for (const [relPath, input] of Object.entries(metafile.inputs)) { if ( // We rewrite these files so this integrity check isn't useful. path.basename(relPath).includes("convex.config") || // TODO: esbuild outputs paths prefixed with "(disabled)" when bundling our internal // udf-system package. The files do actually exist locally, though. relPath.indexOf("(disabled):") !== -1 || relPath.startsWith("wasm-binary:") || relPath.startsWith("wasm-stub:") ) { continue; } const absPath = path.resolve(absWorkingDir, relPath); const st = ctx.fs.stat(absPath); if (st.size !== input.bytes) { // Consider this a transient error so we'll try again and hopefully // no files change right after esbuild next time. logWarning( ctx, `Bundled file ${absPath} changed right after esbuild invocation`, ); return await ctx.crash({ exitCode: 1, errorType: "transient", printedMessage: null, }); } ctx.fs.registerPath(absPath, st); } }