codegen.ts•24.8 kB
import path from "path";
import prettier from "prettier";
import { withTmpDir, TempDir } from "../../bundler/fs.js";
import { entryPoints } from "../../bundler/index.js";
import { apiCodegen } from "../codegen_templates/api.js";
import { apiCjsCodegen } from "../codegen_templates/api_cjs.js";
import {
dynamicDataModelDTS,
dynamicDataModelTS,
noSchemaDataModelDTS,
noSchemaDataModelTS,
staticDataModelDTS,
staticDataModelTS,
} from "../codegen_templates/dataModel.js";
import { readmeCodegen } from "../codegen_templates/readme.js";
import { serverCodegen } from "../codegen_templates/server.js";
import { tsconfigCodegen } from "../codegen_templates/tsconfig.js";
import { Context } from "../../bundler/context.js";
import {
logError,
logMessage,
logOutput,
logVerbose,
} from "../../bundler/log.js";
import { typeCheckFunctionsInMode, TypeCheckMode } from "./typecheck.js";
import {
configFilepath,
readProjectConfig,
usesTypeScriptCodegen,
usesComponentApiImports,
} from "./config.js";
import { recursivelyDelete } from "./fsUtils.js";
import { componentServerTS } from "../codegen_templates/component_server.js";
import {
ComponentDirectory,
isComponentDirectory,
} from "./components/definition/directoryStructure.js";
import { StartPushResponse } from "./deployApi/startPush.js";
import {
componentApiDTS,
componentApiJs,
componentApiStubDTS,
componentApiStubTS,
componentApiTSWithTypes,
componentTS,
rootComponentApiCJS,
} from "../codegen_templates/component_api.js";
import { functionsDir } from "./utils/utils.js";
import { LargeIndexDeletionCheck } from "./indexes.js";
export type CodegenOptions = {
url?: string | undefined;
adminKey?: string | undefined;
dryRun: boolean;
debug: boolean;
typecheck: TypeCheckMode;
init: boolean;
commonjs: boolean;
liveComponentSources: boolean;
debugNodeApis: boolean;
systemUdfs: boolean;
largeIndexDeletionCheck: LargeIndexDeletionCheck;
codegenOnlyThisComponent?: string | undefined;
};
export async function doInitialCodegen(
ctx: Context,
options: { init: boolean },
) {
const { projectConfig: existingProjectConfig } = await readProjectConfig(ctx);
const configPath = await configFilepath(ctx);
const functionsPath = functionsDir(configPath, existingProjectConfig);
if (options.init) {
await doInitCodegen(ctx, functionsPath, true);
}
const componentDir = isComponentDirectory(ctx, functionsPath, true);
if (componentDir.kind === "err") {
return await ctx.crash({
exitCode: 1,
errorType: "invalid filesystem data",
printedMessage: `Invalid component directory: ${componentDir.why}`,
});
}
if (componentDir.component.isRootWithoutConfig) {
// Disable typechecking since there isn't any code yet.
await doCodegen(ctx, functionsPath, "disable");
} else {
await withTmpDir(async (tmpDir) => {
await doInitialComponentCodegen(ctx, tmpDir, componentDir.component);
});
}
}
export async function doInitCodegen(
ctx: Context,
functionsDir: string,
skipIfExists: boolean,
opts?: { dryRun?: boolean; debug?: boolean },
): Promise<void> {
await prepareForCodegen(ctx, functionsDir, opts);
await withTmpDir(async (tmpDir) => {
await doReadmeCodegen(ctx, tmpDir, functionsDir, skipIfExists, opts);
await doTsconfigCodegen(ctx, tmpDir, functionsDir, skipIfExists, opts);
});
}
async function prepareForCodegen(
ctx: Context,
functionsDir: string,
opts?: { dryRun?: boolean },
) {
// Delete the old _generated.ts because v0.1.2 used to put the react generated
// code there
const legacyCodegenPath = path.join(functionsDir, "_generated.ts");
if (ctx.fs.exists(legacyCodegenPath)) {
if (opts?.dryRun) {
logError(
`Command would delete legacy codegen file: ${legacyCodegenPath}}`,
);
} else {
logError(`Deleting legacy codegen file: ${legacyCodegenPath}}`);
ctx.fs.unlink(legacyCodegenPath);
}
}
// Create the codegen dir if it doesn't already exist.
const codegenDir = path.join(functionsDir, "_generated");
ctx.fs.mkdir(codegenDir, { allowExisting: true, recursive: true });
return codegenDir;
}
/** Codegen only for an application (a root component) */
export async function doCodegen(
ctx: Context,
functionsDir: string,
typeCheckMode: TypeCheckMode,
opts?: { dryRun?: boolean; generateCommonJSApi?: boolean; debug?: boolean },
) {
const { projectConfig } = await readProjectConfig(ctx);
const codegenDir = await prepareForCodegen(ctx, functionsDir, opts);
await withTmpDir(async (tmpDir) => {
// Write files in dependency order so a watching dev server doesn't
// see inconsistent results where a file we write imports from a
// file that doesn't exist yet. We'll collect all the paths we write
// and then delete any remaining paths at the end.
const writtenFiles = [];
const useTypeScript = usesTypeScriptCodegen(projectConfig);
const generateCommonJSApi =
opts?.generateCommonJSApi || projectConfig.generateCommonJSApi;
// First, `dataModel.d.ts` imports from the developer's `schema.js` file.
const schemaFiles = await doDataModelCodegen(
ctx,
tmpDir,
functionsDir,
codegenDir,
useTypeScript,
opts,
);
writtenFiles.push(...schemaFiles);
// Next, the `server.d.ts` file imports from `dataModel.d.ts`.
const serverFiles = await writeServerFiles(
ctx,
tmpDir,
codegenDir,
useTypeScript,
opts,
);
writtenFiles.push(...serverFiles);
// The `api.d.ts` file imports from the developer's modules, which then
// import from `server.d.ts`. Note that there's a cycle here, since the
// developer's modules could also import from the `api.{js,d.ts}` files.
const apiFiles = await doApiCodegen(
ctx,
tmpDir,
functionsDir,
codegenDir,
useTypeScript,
generateCommonJSApi,
opts,
);
writtenFiles.push(...apiFiles);
// Cleanup any files that weren't written in this run.
// Skip cleanup in debug mode since we don't actually write files in that mode.
if (!opts?.debug) {
for (const file of ctx.fs.listDir(codegenDir)) {
if (!writtenFiles.includes(file.name)) {
recursivelyDelete(ctx, path.join(codegenDir, file.name), opts);
}
}
}
// Generated code is updated, typecheck the query and mutation functions.
await typeCheckFunctionsInMode(ctx, typeCheckMode, functionsDir);
});
}
// Just enough to be able to bundle code for analysis: we need an api proxy object
// so that imports aren't broken, we need basics in server, we need something in
// data model.
export async function doInitialComponentCodegen(
ctx: Context,
tmpDir: TempDir,
componentDirectory: ComponentDirectory,
opts?: {
dryRun?: boolean;
generateCommonJSApi?: boolean;
debug?: boolean;
verbose?: boolean;
},
) {
const { projectConfig } = await readProjectConfig(ctx);
if (isPublishedPackage(componentDirectory)) {
if (opts?.verbose) {
logMessage(
`skipping initial codegen for installed package ${componentDirectory.path}`,
);
}
return;
}
const codegenDir = await prepareForCodegen(
ctx,
componentDirectory.path,
opts,
);
// Write files in dependency order so a watching dev server doesn't
// see inconsistent results where a file we write imports from a
// file that doesn't exist yet. We'll collect all the paths we write
// and then delete any remaining paths at the end.
const writtenFiles = [];
// Non-root components always use .ts files; root components respect the config
// But for initial (placeholder, stub) codegen we don't care, just use .d.ts and .js.
const useTypeScript =
!componentDirectory.isRoot || usesTypeScriptCodegen(projectConfig);
const generateCommonJSApi =
opts?.generateCommonJSApi || projectConfig.generateCommonJSApi;
// First, `dataModel.d.ts` imports from the developer's `schema.js` file.
const dataModelFiles = await doInitialComponentDataModelCodegen(
ctx,
tmpDir,
componentDirectory,
codegenDir,
useTypeScript,
opts,
);
writtenFiles.push(...dataModelFiles);
// Next, the `server.d.ts` file imports from `dataModel.d.ts`.
const serverFiles = await doInitialComponentServerCodegen(
ctx,
componentDirectory.isRoot,
tmpDir,
codegenDir,
useTypeScript,
opts,
);
writtenFiles.push(...serverFiles);
// The `api.d.ts` file imports from the developer's modules, which then
// import from `server.d.ts`. Note that there's a cycle here, since the
// developer's modules could also import from the `api.{js,d.ts}` files.
const apiFiles = await doInitialComponentApiCodegen(
ctx,
componentDirectory.isRoot,
tmpDir,
codegenDir,
useTypeScript,
generateCommonJSApi,
opts,
);
writtenFiles.push(...apiFiles);
// component.ts is generated in doFinalComponentCodegen, but don't delete
// if it already exists from a previous full codegen run.
if (!componentDirectory.isRoot) {
const componentTSPath = path.join(codegenDir, "component.ts");
if (ctx.fs.exists(componentTSPath)) {
writtenFiles.push("component.ts");
}
}
// Cleanup any files that weren't written in this run.
// Skip cleanup in debug mode since we don't actually write files in that mode.
if (!opts?.debug) {
for (const file of ctx.fs.listDir(codegenDir)) {
if (!writtenFiles.includes(file.name)) {
recursivelyDelete(ctx, path.join(codegenDir, file.name), opts);
}
}
}
}
/* This component defined in a dist directory; it is probably in a node_module
* directory, installed from a package. It is stuck with the files it has.
* Heuristics for this:
* - component definition has a dist/ directory as an ancestor
* - component definition is a .js file
* - presence of .js.map files
* We may improve this heuristic.
*/
export function isPublishedPackage(componentDirectory: ComponentDirectory) {
return (
componentDirectory.definitionPath.endsWith(".js") &&
!componentDirectory.isRoot
);
}
// Handles root and non-root components; it's "component" codegen because
// it's not the old legacy path.
export async function doFinalComponentCodegen(
ctx: Context,
tmpDir: TempDir,
rootComponent: ComponentDirectory,
componentDirectory: ComponentDirectory,
startPushResponse: StartPushResponse,
componentsMap: Map<string, ComponentDirectory>,
opts?: {
dryRun?: boolean;
debug?: boolean;
generateCommonJSApi?: boolean;
},
) {
const { projectConfig } = await readProjectConfig(ctx);
const isPublishedPackage =
componentDirectory.definitionPath.endsWith(".js") &&
!componentDirectory.isRoot;
// We never codegen for a published package (you need to link to the convex.config.ts file instead).
if (isPublishedPackage) {
return;
}
const codegenDir = path.join(componentDirectory.path, "_generated");
ctx.fs.mkdir(codegenDir, { allowExisting: true, recursive: true });
// Non-root components always use .ts files; root components respect the config
const useTypeScript =
!componentDirectory.isRoot || usesTypeScriptCodegen(projectConfig);
// `dataModel` and `api` files depend on analyze results so will get replaced
// in the later post-analysis codegen phase, but `server` files don't need
// analysis info so the stubs from initial codegen are sufficient.
// dataModel
const hasSchemaFile = schemaFileExists(ctx, componentDirectory.path);
let dataModelContents: string;
if (hasSchemaFile) {
if (projectConfig.codegen.staticDataModel) {
dataModelContents = useTypeScript
? await staticDataModelTS(
ctx,
startPushResponse,
rootComponent,
componentDirectory,
)
: await staticDataModelDTS(
ctx,
startPushResponse,
rootComponent,
componentDirectory,
);
} else {
dataModelContents = useTypeScript
? dynamicDataModelTS()
: dynamicDataModelDTS();
}
} else {
dataModelContents = useTypeScript
? noSchemaDataModelTS()
: noSchemaDataModelDTS();
}
const dataModelPath = path.join(
codegenDir,
useTypeScript ? "dataModel.ts" : "dataModel.d.ts",
);
await writeFormattedFile(
ctx,
tmpDir,
dataModelContents,
"typescript",
dataModelPath,
opts,
);
// component.ts
if (!componentDirectory.isRoot) {
const componentTSPath = path.join(codegenDir, "component.ts");
const componentTSContents = await componentTS(
ctx,
startPushResponse,
rootComponent,
componentDirectory,
);
await writeFormattedFile(
ctx,
tmpDir,
componentTSContents,
"typescript",
componentTSPath,
opts,
);
}
// server.ts - regenerate it in final codegen for consistency, even though
// the stub from initial codegen would be sufficient.
await writeServerFilesForComponent(
ctx,
componentDirectory.isRoot,
tmpDir,
codegenDir,
useTypeScript,
opts,
);
// api
if (!useTypeScript) {
const apiDTSPath = path.join(codegenDir, "api.d.ts");
const apiContents = await componentApiDTS(
ctx,
startPushResponse,
rootComponent,
componentDirectory,
componentsMap,
{
staticApi: projectConfig.codegen.staticApi,
useComponentApiImports: usesComponentApiImports(projectConfig),
},
);
await writeFormattedFile(
ctx,
tmpDir,
apiContents,
"typescript",
apiDTSPath,
opts,
);
if (opts?.generateCommonJSApi || projectConfig.generateCommonJSApi) {
const apiCjsDTSPath = path.join(codegenDir, "api_cjs.d.cts");
await writeFormattedFile(
ctx,
tmpDir,
apiContents,
"typescript",
apiCjsDTSPath,
opts,
);
}
} else {
const apiTSPath = path.join(codegenDir, "api.ts");
const apiContents = await componentApiTSWithTypes(
ctx,
startPushResponse,
rootComponent,
componentDirectory,
componentsMap,
{
staticApi: projectConfig.codegen.staticApi,
useComponentApiImports: usesComponentApiImports(projectConfig),
},
);
await writeFormattedFile(
ctx,
tmpDir,
apiContents,
"typescript",
apiTSPath,
opts,
);
}
}
async function doReadmeCodegen(
ctx: Context,
tmpDir: TempDir,
functionsDir: string,
skipIfExists: boolean,
opts?: { dryRun?: boolean; debug?: boolean },
) {
const readmePath = path.join(functionsDir, "README.md");
if (skipIfExists && ctx.fs.exists(readmePath)) {
logVerbose(`Not overwriting README.md.`);
return;
}
await writeFormattedFile(
ctx,
tmpDir,
readmeCodegen(),
"markdown",
readmePath,
opts,
);
}
async function doTsconfigCodegen(
ctx: Context,
tmpDir: TempDir,
functionsDir: string,
skipIfExists: boolean,
opts?: { dryRun?: boolean; debug?: boolean },
) {
const tsconfigPath = path.join(functionsDir, "tsconfig.json");
if (skipIfExists && ctx.fs.exists(tsconfigPath)) {
logVerbose(`Not overwriting tsconfig.json.`);
return;
}
await writeFormattedFile(
ctx,
tmpDir,
tsconfigCodegen(),
"json",
tsconfigPath,
opts,
);
}
function schemaFileExists(ctx: Context, functionsDir: string) {
let schemaPath = path.join(functionsDir, "schema.ts");
let hasSchemaFile = ctx.fs.exists(schemaPath);
if (!hasSchemaFile) {
schemaPath = path.join(functionsDir, "schema.js");
hasSchemaFile = ctx.fs.exists(schemaPath);
}
return hasSchemaFile;
}
async function doDataModelCodegen(
ctx: Context,
tmpDir: TempDir,
functionsDir: string,
codegenDir: string,
useTypeScript: boolean,
opts?: { dryRun?: boolean; debug?: boolean },
) {
const hasSchemaFile = schemaFileExists(ctx, functionsDir);
const schemaContent = hasSchemaFile
? useTypeScript
? dynamicDataModelTS()
: dynamicDataModelDTS()
: useTypeScript
? noSchemaDataModelTS()
: noSchemaDataModelDTS();
const filename = useTypeScript ? "dataModel.ts" : "dataModel.d.ts";
await writeFormattedFile(
ctx,
tmpDir,
schemaContent,
"typescript",
path.join(codegenDir, filename),
opts,
);
return [filename];
}
/**
* Write server.ts/.js/.d.ts files for root components.
* Returns list of filenames written.
*/
async function writeServerFiles(
ctx: Context,
tmpDir: TempDir,
codegenDir: string,
useTypeScript: boolean,
opts?: { dryRun?: boolean; debug?: boolean },
): Promise<string[]> {
if (!useTypeScript) {
const serverContent = serverCodegen({ useTypeScript: false });
await writeFormattedFile(
ctx,
tmpDir,
serverContent.JS!,
"typescript",
path.join(codegenDir, "server.js"),
opts,
);
await writeFormattedFile(
ctx,
tmpDir,
serverContent.DTS!,
"typescript",
path.join(codegenDir, "server.d.ts"),
opts,
);
return ["server.js", "server.d.ts"];
} else {
const serverContent = serverCodegen({ useTypeScript: true });
await writeFormattedFile(
ctx,
tmpDir,
serverContent.TS!,
"typescript",
path.join(codegenDir, "server.ts"),
opts,
);
return ["server.ts"];
}
}
/**
* Write server.ts file for non-root components.
* Returns list of filenames written.
*/
async function writeComponentServerFile(
ctx: Context,
tmpDir: TempDir,
codegenDir: string,
opts?: { dryRun?: boolean; debug?: boolean },
): Promise<string[]> {
const serverTSPath = path.join(codegenDir, "server.ts");
const serverTSContents = componentServerTS(false);
await writeFormattedFile(
ctx,
tmpDir,
serverTSContents,
"typescript",
serverTSPath,
opts,
);
return ["server.ts"];
}
/**
* Write server files for either root or non-root components.
* Root components get server.ts/server.js/server.d.ts based on useTypeScript.
* Non-root components always get server.ts.
* Returns list of filenames written.
*/
async function writeServerFilesForComponent(
ctx: Context,
isRoot: boolean,
tmpDir: TempDir,
codegenDir: string,
useTypeScript: boolean,
opts?: { dryRun?: boolean; debug?: boolean },
): Promise<string[]> {
if (isRoot) {
return await writeServerFiles(ctx, tmpDir, codegenDir, useTypeScript, opts);
} else {
return await writeComponentServerFile(ctx, tmpDir, codegenDir, opts);
}
}
async function doInitialComponentServerCodegen(
ctx: Context,
isRoot: boolean,
tmpDir: TempDir,
codegenDir: string,
useTypeScript: boolean,
opts?: { dryRun?: boolean; debug?: boolean },
) {
return await writeServerFilesForComponent(
ctx,
isRoot,
tmpDir,
codegenDir,
useTypeScript,
opts,
);
}
async function doInitialComponentDataModelCodegen(
ctx: Context,
tmpDir: TempDir,
componentDirectory: ComponentDirectory,
codegenDir: string,
useTypeScript: boolean,
opts?: { dryRun?: boolean; debug?: boolean },
) {
const hasSchemaFile = schemaFileExists(ctx, componentDirectory.path);
const dataModelContent = hasSchemaFile
? useTypeScript
? dynamicDataModelTS()
: dynamicDataModelDTS()
: useTypeScript
? noSchemaDataModelTS()
: noSchemaDataModelDTS();
const filename = useTypeScript ? "dataModel.ts" : "dataModel.d.ts";
const dataModelPath = path.join(codegenDir, filename);
// Don't write our stub if the file already exists, since it may have
// better type information from `doFinalComponentDataModelCodegen`.
if (!ctx.fs.exists(dataModelPath)) {
await writeFormattedFile(
ctx,
tmpDir,
dataModelContent,
"typescript",
dataModelPath,
opts,
);
}
return [filename];
}
async function doInitialComponentApiCodegen(
ctx: Context,
isRoot: boolean,
tmpDir: TempDir,
codegenDir: string,
useTypeScript: boolean,
generateCommonJSApi: boolean,
opts?: { dryRun?: boolean; debug?: boolean },
) {
const writtenFiles: string[] = [];
if (!useTypeScript) {
const apiJS = componentApiJs();
await writeFormattedFile(
ctx,
tmpDir,
apiJS,
"typescript",
path.join(codegenDir, "api.js"),
opts,
);
// Don't write the `.d.ts` stub if it already exists.
const apiDTSPath = path.join(codegenDir, "api.d.ts");
const apiStubDTS = componentApiStubDTS();
if (!ctx.fs.exists(apiDTSPath)) {
await writeFormattedFile(
ctx,
tmpDir,
apiStubDTS,
"typescript",
apiDTSPath,
opts,
);
}
writtenFiles.push("api.js", "api.d.ts");
if (generateCommonJSApi && isRoot) {
const apiCjsJS = rootComponentApiCJS();
await writeFormattedFile(
ctx,
tmpDir,
apiCjsJS,
"typescript",
path.join(codegenDir, "api_cjs.cjs"),
opts,
);
const cjsStubPath = path.join(codegenDir, "api_cjs.d.cts");
if (!ctx.fs.exists(cjsStubPath)) {
await writeFormattedFile(
ctx,
tmpDir,
apiStubDTS,
"typescript",
cjsStubPath,
opts,
);
}
writtenFiles.push("api_cjs.cjs", "api_cjs.d.cts");
}
} else {
const apiTSPath = path.join(codegenDir, "api.ts");
const apiTS = componentApiStubTS();
// Don't write the `.ts` stub if it already exists.
if (!ctx.fs.exists(apiTSPath)) {
await writeFormattedFile(
ctx,
tmpDir,
apiTS,
"typescript",
apiTSPath,
opts,
);
}
writtenFiles.push("api.ts");
}
return writtenFiles;
}
async function doApiCodegen(
ctx: Context,
tmpDir: TempDir,
functionsDir: string,
codegenDir: string,
useTypeScript: boolean,
generateCommonJSApi: boolean,
opts?: { dryRun?: boolean; debug?: boolean },
) {
const absModulePaths = await entryPoints(ctx, functionsDir);
const modulePaths = absModulePaths
.map((p) => path.relative(functionsDir, p))
.sort();
const writtenFiles: string[] = [];
if (!useTypeScript) {
const apiContent = apiCodegen(modulePaths, { useTypeScript: false });
await writeFormattedFile(
ctx,
tmpDir,
apiContent.JS!,
"typescript",
path.join(codegenDir, "api.js"),
opts,
);
await writeFormattedFile(
ctx,
tmpDir,
apiContent.DTS!,
"typescript",
path.join(codegenDir, "api.d.ts"),
opts,
);
writtenFiles.push("api.js", "api.d.ts");
if (generateCommonJSApi) {
const apiCjsContent = apiCjsCodegen(modulePaths);
await writeFormattedFile(
ctx,
tmpDir,
apiCjsContent.JS!,
"typescript",
path.join(codegenDir, "api_cjs.cjs"),
opts,
);
await writeFormattedFile(
ctx,
tmpDir,
apiCjsContent.DTS!,
"typescript",
path.join(codegenDir, "api_cjs.d.cts"),
opts,
);
writtenFiles.push("api_cjs.cjs", "api_cjs.d.cts");
}
} else {
const apiContent = apiCodegen(modulePaths, { useTypeScript: true });
await writeFormattedFile(
ctx,
tmpDir,
apiContent.TS!,
"typescript",
path.join(codegenDir, "api.ts"),
opts,
);
writtenFiles.push("api.ts");
}
return writtenFiles;
}
async function writeFormattedFile(
ctx: Context,
tmpDir: TempDir,
contents: string,
filetype: string,
destination: string,
options?: {
dryRun?: boolean;
debug?: boolean;
},
) {
// Run prettier so we don't have to think about formatting!
//
// This is a little sketchy because we are using the default prettier config
// (not our user's one) but it's better than nothing.
const formattedContents = await prettier.format(contents, {
parser: filetype,
pluginSearchDirs: false,
});
if (options?.debug) {
// NB: The `test_codegen_projects_are_up_to_date` smoke test depends
// on this output format.
logOutput(`# ${path.resolve(destination)}`);
logOutput(formattedContents);
return;
}
try {
const existing = ctx.fs.readUtf8File(destination);
if (existing === formattedContents) {
return;
}
} catch (err: any) {
if (err.code !== "ENOENT") {
// eslint-disable-next-line no-restricted-syntax
throw err;
}
}
if (options?.dryRun) {
logOutput(`Command would write file: ${destination}`);
return;
}
const tmpPath = tmpDir.writeUtf8File(formattedContents);
ctx.fs.swapTmpFile(tmpPath, destination);
}