Skip to content

Commit 6ec514a

Browse files
thomasballingerConvex, Inc.
authored and
Convex, Inc.
committed
npm packaging for components (#29129)
Change module resolution logic to not always use `require()` resolution rules for component definitions. GitOrigin-RevId: 6e1540240406ac60ccb3704375eb3c03267e3211
1 parent cb2de7e commit 6ec514a

15 files changed

+166
-175
lines changed

src/bundler/index.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ test("must use isolate", () => {
175175
expect(mustBeIsolate("schema.js")).toBeTruthy();
176176
expect(mustBeIsolate("schema.jsx")).toBeTruthy();
177177
expect(mustBeIsolate("schema.ts")).toBeTruthy();
178+
expect(mustBeIsolate("schema.js")).toBeTruthy();
178179

179180
expect(mustBeIsolate("http.sample.js")).not.toBeTruthy();
180181
expect(mustBeIsolate("https.js")).not.toBeTruthy();

src/bundler/index.ts

+6-8
Original file line numberDiff line numberDiff line change
@@ -245,13 +245,11 @@ async function externalPackageVersions(
245245
}
246246

247247
export async function bundleSchema(ctx: Context, dir: string) {
248-
const result = await bundle(
249-
ctx,
250-
dir,
251-
[path.resolve(dir, "schema.ts")],
252-
true,
253-
"browser",
254-
);
248+
let target = path.resolve(dir, "schema.ts");
249+
if (!ctx.fs.exists(target)) {
250+
target = path.resolve(dir, "schema.js");
251+
}
252+
const result = await bundle(ctx, dir, [target], true, "browser");
255253
return result.modules;
256254
}
257255

@@ -353,7 +351,7 @@ export async function entryPoints(
353351
log(chalk.yellow(`Skipping ${fpath}`));
354352
} else if (base === "_generated.ts") {
355353
log(chalk.yellow(`Skipping ${fpath}`));
356-
} else if (base === "schema.ts") {
354+
} else if (base === "schema.ts" || base === "schema.js") {
357355
log(chalk.yellow(`Skipping ${fpath}`));
358356
} else if ((base.match(/\./g) || []).length > 1) {
359357
log(chalk.yellow(`Skipping ${fpath} that contains multiple dots`));

src/cli/codegen_templates/component_api.ts

+5-2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { Identifier } from "../lib/deployApi/types.js";
1616
import { ComponentDefinitionPath } from "../lib/deployApi/paths.js";
1717
import { resolveFunctionReference } from "./component_server.js";
18+
import { encodeDefinitionPath } from "../lib/components/definition/bundle.js";
1819

1920
export function componentApiJs() {
2021
const lines = [];
@@ -155,12 +156,14 @@ async function buildMountTree(
155156
definitionPath: ComponentDefinitionPath,
156157
attributes: string[],
157158
): Promise<MountTree | null> {
158-
const analysis = startPush.analysis[definitionPath];
159+
// TODO make these types more precise when receiving analysis from server
160+
const analysis =
161+
startPush.analysis[encodeDefinitionPath(definitionPath as any)];
159162
if (!analysis) {
160163
return await ctx.crash({
161164
exitCode: 1,
162165
errorType: "fatal",
163-
printedMessage: `No analysis found for component ${definitionPath}`,
166+
printedMessage: `No analysis found for component ${encodeDefinitionPath(definitionPath as any)} orig: ${definitionPath}\nin\n${Object.keys(startPush.analysis).toString()}`,
164167
});
165168
}
166169
let current = analysis.definition.exports.branch;

src/cli/codegen_templates/component_server.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { Context } from "../../bundler/context.js";
1717
import { CanonicalizedModulePath } from "../lib/deployApi/paths.js";
1818
import { Value, jsonToConvex } from "../../values/value.js";
1919
import { z } from "zod";
20+
import { encodeDefinitionPath } from "../lib/components/definition/bundle.js";
2021

2122
export function componentServerJS(isRoot: boolean): string {
2223
let result = `
@@ -287,12 +288,12 @@ export async function componentServerDTS(
287288
componentDirectory,
288289
);
289290

290-
const analysis = startPush.analysis[definitionPath];
291+
const analysis = startPush.analysis[encodeDefinitionPath(definitionPath)];
291292
if (!analysis) {
292293
return await ctx.crash({
293294
exitCode: 1,
294295
errorType: "fatal",
295-
printedMessage: `No analysis found for component ${definitionPath}`,
296+
printedMessage: `No analysis found for component ${encodeDefinitionPath(definitionPath as any)} orig: ${definitionPath}\nin\n${Object.keys(startPush.analysis).toString()}`,
296297
});
297298
}
298299
for (const childComponent of analysis.definition.childComponents) {

src/cli/lib/codegen.ts

+33-2
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,25 @@ export async function doInitialComponentCodegen(
145145
opts?: { dryRun?: boolean; generateCommonJSApi?: boolean; debug?: boolean },
146146
) {
147147
const { projectConfig } = await readProjectConfig(ctx);
148+
149+
// This component defined in a dist directory; it is probably in a node_module
150+
// directory, installed from a package. It is stuck with the files it has.
151+
// Heuristics for this:
152+
// - component definition has a dist/ directory as an ancestor
153+
// - component definition is a .js file
154+
// - presence of .js.map files
155+
// We may improve this heuristic.
156+
const isPublishedPackage =
157+
componentDirectory.definitionPath.endsWith(".js") &&
158+
!componentDirectory.isRoot;
159+
if (isPublishedPackage) {
160+
logMessage(
161+
ctx,
162+
`skipping initial codegen for installed package ${componentDirectory.path}`,
163+
);
164+
return;
165+
}
166+
148167
const codegenDir = await prepareForCodegen(
149168
ctx,
150169
componentDirectory.path,
@@ -207,6 +226,14 @@ export async function doFinalComponentCodegen(
207226
opts?: { dryRun?: boolean; debug?: boolean; generateCommonJSApi?: boolean },
208227
) {
209228
const { projectConfig } = await readProjectConfig(ctx);
229+
230+
const isPublishedPackage =
231+
componentDirectory.definitionPath.endsWith(".js") &&
232+
!componentDirectory.isRoot;
233+
if (isPublishedPackage) {
234+
return;
235+
}
236+
210237
const codegenDir = path.join(componentDirectory.path, "_generated");
211238
ctx.fs.mkdir(codegenDir, { allowExisting: true, recursive: true });
212239

@@ -308,8 +335,12 @@ async function doDataModelCodegen(
308335
codegenDir: string,
309336
opts?: { dryRun?: boolean; debug?: boolean },
310337
) {
311-
const schemaPath = path.join(functionsDir, "schema.ts");
312-
const hasSchemaFile = ctx.fs.exists(schemaPath);
338+
let schemaPath = path.join(functionsDir, "schema.ts");
339+
let hasSchemaFile = ctx.fs.exists(schemaPath);
340+
if (!hasSchemaFile) {
341+
schemaPath = path.join(functionsDir, "schema.js");
342+
hasSchemaFile = ctx.fs.exists(schemaPath);
343+
}
313344
const schemaContent = hasSchemaFile ? dataModel : dataModelWithoutSchema;
314345

315346
await writeFormattedFile(

src/cli/lib/components.ts

+2-5
Original file line numberDiff line numberDiff line change
@@ -206,16 +206,13 @@ async function startComponentsPushAndCodegen(
206206
const componentDefinitions: ComponentDefinitionConfig[] = [];
207207
for (const componentDefinition of componentDefinitionSpecsWithoutImpls) {
208208
const impl = componentImplementations.filter(
209-
(impl) =>
210-
// convert from ComponentPath
211-
path.resolve(rootComponent.path, impl.definitionPath) ===
212-
componentDefinition.definitionPath,
209+
(impl) => impl.definitionPath === componentDefinition.definitionPath,
213210
)[0];
214211
if (!impl) {
215212
return await ctx.crash({
216213
exitCode: 1,
217214
errorType: "fatal",
218-
printedMessage: `missing! couldn't find ${componentDefinition.definitionPath} in ${componentImplementations.map((impl) => path.resolve(rootComponent.path, impl.definitionPath)).toString()}`,
215+
printedMessage: `missing! couldn't find ${componentDefinition.definitionPath} in ${componentImplementations.map((impl) => impl.definitionPath).toString()}`,
219216
});
220217
}
221218
componentDefinitions.push({

src/cli/lib/components/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export const ROOT_DEFINITION_FILENAME = "convex.config.ts";
22
export const DEFINITION_FILENAME = "convex.config.ts";
3+
export const COMPILED_DEFINITION_FILENAME = "convex.config.js";

src/cli/lib/components/definition/bundle.ts

+60-40
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import path from "path";
2+
import crypto from "crypto";
23
import {
34
ComponentDirectory,
45
ComponentDefinitionPath,
56
buildComponentDirectory,
67
isComponentDirectory,
78
qualifiedDefinitionPath,
89
toComponentDefinitionPath,
10+
EncodedComponentDefinitionPath,
911
} from "./directoryStructure.js";
1012
import {
1113
Context,
@@ -15,7 +17,6 @@ import {
1517
} from "../../../../bundler/context.js";
1618
import esbuild, { BuildOptions, Metafile, OutputFile, Plugin } from "esbuild";
1719
import chalk from "chalk";
18-
import { createRequire } from "module";
1920
import {
2021
AppDefinitionSpecWithoutImpls,
2122
ComponentDefinitionSpecWithoutImpls,
@@ -93,22 +94,15 @@ function componentPlugin({
9394
}
9495
let resolvedPath = undefined;
9596
for (const candidate of candidates) {
96-
try {
97-
// --experimental-import-meta-resolve is required for
98-
// `import.meta.resolve` so we'll use `require.resolve`
99-
// until then. Hopefully they aren't too different.
100-
const require = createRequire(args.resolveDir);
101-
resolvedPath = require.resolve(candidate, {
102-
paths: [args.resolveDir],
103-
});
97+
const result = await build.resolve(candidate, {
98+
// We expect this to be "import-statement" but pass 'kind' through
99+
// to say honest to normal esbuild behavior.
100+
kind: args.kind,
101+
resolveDir: args.resolveDir,
102+
});
103+
if (result.path) {
104+
resolvedPath = result.path;
104105
break;
105-
} catch (e: any) {
106-
if (e.code === "MODULE_NOT_FOUND") {
107-
continue;
108-
}
109-
// We always invoke esbuild in a try/catch.
110-
// eslint-disable-next-line no-restricted-syntax
111-
throw e;
112106
}
113107
}
114108
if (resolvedPath === undefined) {
@@ -129,7 +123,12 @@ function componentPlugin({
129123
}
130124

131125
verbose &&
132-
logMessage(ctx, " -> Component import! Recording it.", args.path);
126+
logMessage(
127+
ctx,
128+
" -> Component import! Recording it.",
129+
args.path,
130+
resolvedPath,
131+
);
133132

134133
if (mode === "discover") {
135134
return {
@@ -146,7 +145,7 @@ function componentPlugin({
146145
rootComponentDirectory,
147146
imported,
148147
);
149-
const encodedPath = hackyMapping(componentPath);
148+
const encodedPath = hackyMapping(encodeDefinitionPath(componentPath));
150149
return {
151150
path: encodedPath,
152151
external: true,
@@ -158,7 +157,7 @@ function componentPlugin({
158157
}
159158

160159
/** The path on the deployment that identifier a component definition. */
161-
function hackyMapping(componentPath: ComponentDefinitionPath): string {
160+
function hackyMapping(componentPath: EncodedComponentDefinitionPath): string {
162161
return `./_componentDeps/${Buffer.from(componentPath).toString("base64").replace(/=+$/, "")}`;
163162
}
164163

@@ -312,6 +311,24 @@ async function findComponentDependencies(
312311
return { components, dependencyGraph };
313312
}
314313

314+
// Each path component is less than 64 bytes and escape all a-zA-Z0-9
315+
// This is the only version of the path the server will receive.
316+
export function encodeDefinitionPath(
317+
s: ComponentDefinitionPath,
318+
): EncodedComponentDefinitionPath {
319+
const components = s.split(path.sep);
320+
return components
321+
.map((s) => {
322+
const escaped = s.replaceAll("-", "_").replaceAll("+", "_");
323+
if (escaped.length <= 64) {
324+
return escaped;
325+
}
326+
const hash = crypto.createHash("md5").update(s).digest("hex");
327+
return `${escaped.slice(0, 50)}${hash.slice(0, 14)}`;
328+
})
329+
.join(path.sep) as EncodedComponentDefinitionPath;
330+
}
331+
315332
// NB: If a directory linked to is not a member of the passed
316333
// componentDirectories array then there will be external links
317334
// with no corresponding definition bundle.
@@ -414,9 +431,15 @@ export async function bundleDefinitions(
414431
(out) => out.directory.path !== rootComponentDirectory.path,
415432
);
416433

417-
const componentDefinitionSpecsWithoutImpls = componentBundles.map(
418-
({ directory, outputJs, outputJsMap }) => ({
419-
definitionPath: directory.path,
434+
const componentDefinitionSpecsWithoutImpls: ComponentDefinitionSpecWithoutImpls[] =
435+
componentBundles.map(({ directory, outputJs, outputJsMap }) => ({
436+
definitionPath: encodeDefinitionPath(
437+
toComponentDefinitionPath(rootComponentDirectory, directory),
438+
),
439+
origDefinitionPath: toComponentDefinitionPath(
440+
rootComponentDirectory,
441+
directory,
442+
),
420443
definition: {
421444
path: path.relative(directory.path, outputJs.path),
422445
source: outputJs.text,
@@ -427,15 +450,14 @@ export async function bundleDefinitions(
427450
rootComponentDirectory,
428451
dependencyGraph,
429452
directory.definitionPath,
430-
),
431-
}),
432-
);
453+
).map(encodeDefinitionPath),
454+
}));
433455
const appDeps = getDeps(
434456
rootComponentDirectory,
435457
dependencyGraph,
436458
appBundle.directory.definitionPath,
437-
);
438-
const appDefinitionSpecWithoutImpls = {
459+
).map(encodeDefinitionPath);
460+
const appDefinitionSpecWithoutImpls: AppDefinitionSpecWithoutImpls = {
439461
definition: {
440462
path: path.relative(rootComponentDirectory.path, appBundle.outputJs.path),
441463
source: appBundle.outputJs.text,
@@ -465,7 +487,7 @@ export async function bundleImplementations(
465487
componentImplementations: {
466488
schema: Bundle | null;
467489
functions: Bundle[];
468-
definitionPath: ComponentDefinitionPath;
490+
definitionPath: EncodedComponentDefinitionPath;
469491
}[];
470492
}> {
471493
let appImplementation;
@@ -478,10 +500,12 @@ export async function bundleImplementations(
478500
directory.path,
479501
);
480502
let schema;
481-
if (!ctx.fs.exists(path.resolve(resolvedPath, "schema.ts"))) {
482-
schema = null;
483-
} else {
503+
if (ctx.fs.exists(path.resolve(resolvedPath, "schema.ts"))) {
504+
schema = (await bundleSchema(ctx, resolvedPath))[0] || null;
505+
} else if (ctx.fs.exists(path.resolve(resolvedPath, "schema.js"))) {
484506
schema = (await bundleSchema(ctx, resolvedPath))[0] || null;
507+
} else {
508+
schema = null;
485509
}
486510

487511
const entryPoints = await entryPointsByEnvironment(
@@ -538,15 +562,11 @@ export async function bundleImplementations(
538562
externalNodeDependencies,
539563
};
540564
} else {
541-
componentImplementations.push({
542-
// these needs to be a componentPath when sent to the server
543-
definitionPath: toComponentDefinitionPath(
544-
rootComponentDirectory,
545-
directory,
546-
),
547-
schema,
548-
functions,
549-
});
565+
// definitionPath is the canonical form
566+
const definitionPath = encodeDefinitionPath(
567+
toComponentDefinitionPath(rootComponentDirectory, directory),
568+
);
569+
componentImplementations.push({ definitionPath, schema, functions });
550570
}
551571
isRoot = false;
552572
}

0 commit comments

Comments
 (0)