UNPKG

convex

Version:

Client for the Convex Cloud

498 lines (459 loc) 15.8 kB
import path from "path"; import chalk from "chalk"; import esbuild from "esbuild"; import { parse as parseAST } from "@babel/parser"; import { Identifier, ImportSpecifier } from "@babel/types"; import * as Sentry from "@sentry/node"; import { Filesystem } from "./fs.js"; import { Context, logFailure, logWarning } from "./context.js"; import { wasmPlugin } from "./wasm.js"; import { ExternalPackage, computeExternalPackages, createExternalPlugin, findExactVersionAndDependencies, } from "./external.js"; export { nodeFs, RecordingFs } from "./fs.js"; export type { Filesystem } from "./fs.js"; export const actionsDir = "actions"; // Returns a generator of { isDir, path, depth } for all paths // within dirPath in some topological order (not including // dirPath itself). export function* walkDir( fs: Filesystem, dirPath: string, depth?: number, ): Generator<{ isDir: boolean; path: string; depth: number }, void, void> { depth = depth ?? 0; for (const dirEntry of fs.listDir(dirPath).sort()) { const childPath = path.join(dirPath, dirEntry.name); if (dirEntry.isDirectory()) { yield { isDir: true, path: childPath, depth }; yield* walkDir(fs, childPath, depth + 1); } else if (dirEntry.isFile()) { yield { isDir: false, path: childPath, depth }; } } } // Convex specific module environment. type ModuleEnvironment = "node" | "isolate"; export interface Bundle { path: string; source: string; sourceMap?: string; environment: ModuleEnvironment; } export interface BundleHash { path: string; hash: string; environment: ModuleEnvironment; } type EsBuildResult = esbuild.BuildResult & { outputFiles: esbuild.OutputFile[]; // Set of referenced external modules. externalModuleNames: Set<string>; // Set of bundled modules. bundledModuleNames: Set<string>; }; async function doEsbuild( ctx: Context, dir: string, entryPoints: string[], generateSourceMaps: boolean, platform: esbuild.Platform, chunksFolder: string, externalPackages: Map<string, ExternalPackage>, ): Promise<EsBuildResult> { const external = createExternalPlugin(ctx, externalPackages); try { const result = await esbuild.build({ entryPoints, bundle: true, platform: platform, format: "esm", target: "esnext", outdir: "out", outbase: dir, conditions: ["convex", "module"], // The wasmPlugin should be last so it doesn't run on external modules. plugins: [external.plugin, wasmPlugin], write: false, sourcemap: generateSourceMaps, splitting: true, chunkNames: path.join(chunksFolder, "[hash]"), treeShaking: true, minify: false, keepNames: true, metafile: true, }); for (const [relPath, input] of Object.entries(result.metafile!.inputs)) { // TODO: esbuild outputs paths prefixed with "(disabled)"" when bundling our internal // udf-runtime package. The files do actually exist locally, though. if ( relPath.indexOf("(disabled):") !== -1 || relPath.startsWith("wasm-binary:") || relPath.startsWith("wasm-stub:") ) { continue; } const absPath = path.resolve(relPath); const st = ctx.fs.stat(absPath); if (st.size !== input.bytes) { logWarning( ctx, `Bundled file ${absPath} changed right after esbuild invocation`, ); // Consider this a transient error so we'll try again and hopefully // no files change right after esbuild next time. return await ctx.crash(1, "transient"); } ctx.fs.registerPath(absPath, st); } return { ...result, externalModuleNames: external.externalModuleNames, bundledModuleNames: external.bundledModuleNames, }; } catch (err) { // We don't print any error because esbuild already printed // all the relevant information. return await ctx.crash(1, "invalid filesystem data"); } } export async function bundle( ctx: Context, dir: string, entryPoints: string[], generateSourceMaps: boolean, platform: esbuild.Platform, chunksFolder = "_deps", externalPackagesAllowList: string[] = [], ): Promise<{ modules: Bundle[]; externalDependencies: Map<string, string>; bundledModuleNames: Set<string>; }> { const availableExternalPackages = await computeExternalPackages( ctx, externalPackagesAllowList, ); const result = await doEsbuild( ctx, dir, entryPoints, generateSourceMaps, platform, chunksFolder, availableExternalPackages, ); if (result.errors.length) { for (const error of result.errors) { console.log(chalk.red(`esbuild error: ${error.text}`)); } return await ctx.crash(1, "invalid filesystem data"); } for (const warning of result.warnings) { console.log(chalk.yellow(`esbuild warning: ${warning.text}`)); } const sourceMaps = new Map(); const modules: Bundle[] = []; const environment = platform === "node" ? "node" : "isolate"; for (const outputFile of result.outputFiles) { const relPath = path.relative(path.normalize("out"), outputFile.path); if (path.extname(relPath) === ".map") { sourceMaps.set(relPath, outputFile.text); continue; } const posixRelPath = relPath.split(path.sep).join(path.posix.sep); modules.push({ path: posixRelPath, source: outputFile.text, environment }); } for (const module of modules) { const sourceMapPath = module.path + ".map"; const sourceMap = sourceMaps.get(sourceMapPath); if (sourceMap) { module.sourceMap = sourceMap; } } return { modules, externalDependencies: await externalPackageVersions( ctx, availableExternalPackages, result.externalModuleNames, ), bundledModuleNames: result.bundledModuleNames, }; } // We could return the full list of availableExternalPackages, but this would be // installing more packages that we need. Instead, we collect all external // dependencies we found during bundling the /convex function, as well as their // respective peer and optional dependencies. async function externalPackageVersions( ctx: Context, availableExternalPackages: Map<string, ExternalPackage>, referencedPackages: Set<string>, ): Promise<Map<string, string>> { const versions = new Map<string, string>(); const referencedPackagesQueue = Array.from(referencedPackages.keys()); for (let i = 0; i < referencedPackagesQueue.length; i++) { const moduleName = referencedPackagesQueue[i]; // This assertion is safe because referencedPackages can only contain // packages in availableExternalPackages. const modulePath = availableExternalPackages.get(moduleName)!.path; // Since we don't support lock files and different install commands yet, we // pick up the exact version installed on the local filesystem. const { version, peerAndOptionalDependencies } = await findExactVersionAndDependencies(ctx, moduleName, modulePath); versions.set(moduleName, version); for (const dependency of peerAndOptionalDependencies) { if ( availableExternalPackages.has(dependency) && !referencedPackages.has(dependency) ) { referencedPackagesQueue.push(dependency); referencedPackages.add(dependency); } } } return versions; } export async function bundleSchema(ctx: Context, dir: string) { const result = await bundle( ctx, dir, [path.resolve(dir, "schema.ts")], true, "browser", ); return result.modules; } export async function bundleAuthConfig(ctx: Context, dir: string) { const authConfigPath = path.resolve(dir, "auth.config.js"); const authConfigTsPath = path.resolve(dir, "auth.config.ts"); if (ctx.fs.exists(authConfigPath) && ctx.fs.exists(authConfigTsPath)) { logFailure( ctx, `Found both ${authConfigPath} and ${authConfigTsPath}, choose one.`, ); return await ctx.crash(1, "invalid filesystem data"); } const chosenPath = ctx.fs.exists(authConfigTsPath) ? authConfigTsPath : authConfigPath; if (!ctx.fs.exists(chosenPath)) { return []; } const result = await bundle(ctx, dir, [chosenPath], true, "browser"); return result.modules; } export async function doesImportConvexHttpRouter(source: string) { try { const ast = parseAST(source, { sourceType: "module", plugins: ["typescript"], }); return ast.program.body.some((node) => { if (node.type !== "ImportDeclaration") return false; return node.specifiers.some((s) => { const specifier = s as ImportSpecifier; const imported = specifier.imported as Identifier; return imported.name === "httpRouter"; }); }); } catch { return ( source.match( /import\s*\{\s*httpRouter.*\}\s*from\s*"\s*convex\/server\s*"/, ) !== null ); } } export async function entryPoints( ctx: Context, dir: string, verbose: boolean, ): Promise<string[]> { const entryPoints = []; const log = (line: string) => { if (verbose) { console.log(line); } }; for (const { isDir, path: fpath, depth } of walkDir(ctx.fs, dir)) { if (isDir) { continue; } const relPath = path.relative(dir, fpath); const parsedPath = path.parse(fpath); const base = parsedPath.base; const extension = parsedPath.ext.toLowerCase(); if (relPath.startsWith("_deps" + path.sep)) { logFailure( ctx, `The path "${fpath}" is within the "_deps" directory, which is reserved for dependencies. Please move your code to another directory.`, ); return await ctx.crash(1, "invalid filesystem data"); } if (depth === 0 && base.toLowerCase().startsWith("https.")) { const source = ctx.fs.readUtf8File(fpath); if (await doesImportConvexHttpRouter(source)) logWarning( ctx, chalk.yellow( `Found ${fpath}. HTTP action routes will not be imported from this file. Did you mean to include http${extension}?`, ), ); Sentry.captureMessage( `User code top level directory contains file ${base} which imports httpRouter.`, "warning", ); } if (relPath.startsWith("_generated" + path.sep)) { log(chalk.yellow(`Skipping ${fpath}`)); } else if (base.startsWith(".")) { log(chalk.yellow(`Skipping dotfile ${fpath}`)); } else if (base.startsWith("#")) { log(chalk.yellow(`Skipping likely emacs tempfile ${fpath}`)); } else if (base === "README.md") { log(chalk.yellow(`Skipping ${fpath}`)); } else if (base === "_generated.ts") { log(chalk.yellow(`Skipping ${fpath}`)); } else if (base === "schema.ts") { log(chalk.yellow(`Skipping ${fpath}`)); } else if ((base.match(/\./g) || []).length > 1) { log(chalk.yellow(`Skipping ${fpath} that contains multiple dots`)); } else if (base === "tsconfig.json") { log(chalk.yellow(`Skipping ${fpath}`)); } else if (relPath.endsWith(".config.js")) { log(chalk.yellow(`Skipping ${fpath}`)); } else if (relPath.includes(" ")) { log(chalk.yellow(`Skipping ${relPath} because it contains a space`)); } else if (base.endsWith(".d.ts")) { log(chalk.yellow(`Skipping ${fpath} declaration file`)); } else if (base.endsWith(".json")) { log(chalk.yellow(`Skipping ${fpath} json file`)); } else if (base.endsWith(".jsonl")) { log(chalk.yellow(`Skipping ${fpath} jsonl file`)); } else { log(chalk.green(`Preparing ${fpath}`)); entryPoints.push(fpath); } } // If using TypeScript, require that at least one line starts with `export` or `import`, // a TypeScript requirement. This prevents confusing type errors described in CX-5067. const nonEmptyEntryPoints = entryPoints.filter((fpath) => { // This check only makes sense for TypeScript files if (!fpath.endsWith(".ts") && !fpath.endsWith(".tsx")) { return true; } const contents = ctx.fs.readUtf8File(fpath); if (/^\s{0,100}(import|export)/m.test(contents)) { return true; } log( chalk.yellow( `Skipping ${fpath} because it has no export or import to make it a valid TypeScript module`, ), ); }); return nonEmptyEntryPoints; } // A fallback regex in case we fail to parse the AST. export const useNodeDirectiveRegex = /^\s*("|')use node("|');?\s*$/; function hasUseNodeDirective( fs: Filesystem, fpath: string, verbose: boolean, ): boolean { // Do a quick check for the exact string. If it doesn't exist, don't // bother parsing. const source = fs.readUtf8File(fpath); if (source.indexOf("use node") === -1) { return false; } // We parse the AST here to extract the "use node" declaration. This is more // robust than doing a regex. We only use regex as a fallback. try { const ast = parseAST(source, { // parse in strict mode and allow module declarations sourceType: "module", // esbuild supports jsx and typescript by default. Allow the same plugins // here too. plugins: ["jsx", "typescript"], }); return ast.program.directives .map((d) => d.value.value) .includes("use node"); } catch (error: any) { // Given that we have failed to parse, we are most likely going to fail in // the esbuild step, which seem to return better formatted error messages. // We don't throw here and fallback to regex. let lineMatches = false; for (const line of source.split("\n")) { if (line.match(useNodeDirectiveRegex)) { lineMatches = true; break; } } if (verbose) { // Log that we failed to parse in verbose node if we need this for debugging. console.warn( `Failed to parse ${fpath}. Use node is set to ${lineMatches} based on regex. Parse error: ${error.toString()}.`, ); } return lineMatches; } } export function mustBeIsolate(relPath: string): boolean { // Check if the path without extension matches any of the static paths. return ["http", "crons", "schema", "auth.config"].includes( relPath.replace(/\.[^/.]+$/, ""), ); } async function determineEnvironment( ctx: Context, dir: string, fpath: string, verbose: boolean, ): Promise<ModuleEnvironment> { const relPath = path.relative(dir, fpath); const useNodeDirectiveFound = hasUseNodeDirective(ctx.fs, fpath, verbose); if (useNodeDirectiveFound) { if (mustBeIsolate(relPath)) { logFailure(ctx, `"use node" directive is not allowed for ${relPath}.`); return await ctx.crash(1, "invalid filesystem data"); } return "node"; } const actionsPrefix = actionsDir + path.sep; if (relPath.startsWith(actionsPrefix)) { logFailure( ctx, `${relPath} is in /actions subfolder but has no "use node"; directive. You can now define actions in any folder and indicate they should run in node by adding "use node" directive. /actions is a deprecated way to choose Node.js environment, and we require "use node" for all files within that folder to avoid unexpected errors during the migration. See https://docs.convex.dev/functions/actions for more details`, ); return await ctx.crash(1, "invalid filesystem data"); } return "isolate"; } export async function entryPointsByEnvironment( ctx: Context, dir: string, verbose: boolean, ) { const isolate = []; const node = []; for (const entryPoint of await entryPoints(ctx, dir, verbose)) { const environment = await determineEnvironment( ctx, dir, entryPoint, verbose, ); if (environment === "node") { node.push(entryPoint); } else { isolate.push(entryPoint); } } return { isolate, node }; }