diff --git a/npm-packages/convex/src/bundler/index.test.ts b/npm-packages/convex/src/bundler/index.test.ts index c074fa19e..d7e935ca2 100644 --- a/npm-packages/convex/src/bundler/index.test.ts +++ b/npm-packages/convex/src/bundler/index.test.ts @@ -1,5 +1,6 @@ import { expect, test, afterEach, vi } from "vitest"; import { oneoffContext } from "./context.js"; +import { normalizeModulePath } from "./index.js"; // Although these tests are run as ESM by ts-lint, this file is built as both // CJS and ESM by TypeScript so normal recipes like `__dirname` for getting the @@ -180,3 +181,17 @@ test("must use isolate", () => { expect(mustBeIsolate("schema2.js")).not.toBeTruthy(); expect(mustBeIsolate("schema/http.js")).not.toBeTruthy(); }); + +test("normalizeModulePath creates valid module identifiers from output paths", () => { + // Test typical case: esbuild output in out/ directory + expect(normalizeModulePath("out/convex/myFunction.js")).toBe("convex/myFunction.js"); + expect(normalizeModulePath("out\\actions\\myAction.js")).toBe("actions/myAction.js"); + + // Test Windows absolute paths (typical Windows scenario) + expect(normalizeModulePath("C:\\project\\out\\convex\\foo.js")).toBe("convex/foo.js"); + expect(normalizeModulePath("D:/Users/dev/myapp/out/convex/bar.js")).toBe("convex/bar.js"); + + // Test custom base directory + expect(normalizeModulePath("build/convex/test.js", "build")).toBe("convex/test.js"); + expect(normalizeModulePath("C:/project/dist/actions/upload.js", "dist")).toBe("actions/upload.js"); +}); diff --git a/npm-packages/convex/src/bundler/index.ts b/npm-packages/convex/src/bundler/index.ts index a9eb58f42..c9f945fcd 100644 --- a/npm-packages/convex/src/bundler/index.ts +++ b/npm-packages/convex/src/bundler/index.ts @@ -206,8 +206,8 @@ export async function bundle( sourceMaps.set(relPath, outputFile.text); continue; } - const posixRelPath = relPath.split(path.sep).join(path.posix.sep); - modules.push({ path: posixRelPath, source: outputFile.text, environment }); + const normalizedPath = normalizeModulePath(outputFile.path); + modules.push({ path: normalizedPath, source: outputFile.text, environment }); } for (const module of modules) { const sourceMapPath = module.path + ".map"; @@ -437,6 +437,39 @@ export async function entryPoints( // A fallback regex in case we fail to parse the AST. export const useNodeDirectiveRegex = /^\s*("|')use node("|');?\s*$/; +export function normalizeModulePath(outputPath: string, baseDir: string = "out"): string { + // Normalize both paths to handle mixed separators (Windows/POSIX) + const normalizedOutput = outputPath.replace(/\\/g, '/'); + const normalizedBase = baseDir.replace(/\\/g, '/'); + + // Extract the relative path from the esbuild output directory + const relativePath = path.posix.relative(normalizedBase, normalizedOutput); + + // Ensure we don't have upward traversal in module identifiers + if (relativePath.startsWith('../')) { + // For absolute paths or paths outside the base, extract just the meaningful part + const outputParts = normalizedOutput.split('/'); + const baseParts = normalizedBase.split('/'); + + // Find where the base directory appears in the output path + const baseIndex = outputParts.findIndex((part, index) => { + return baseParts.every((basePart, bIndex) => + outputParts[index + bIndex] === basePart + ); + }); + + if (baseIndex >= 0) { + // Extract everything after the base directory + return outputParts.slice(baseIndex + baseParts.length).join('/'); + } + + // Fallback: if we can't find the base, return the filename part + return outputParts[outputParts.length - 1]; + } + + return relativePath; +} + function hasUseNodeDirective(ctx: Context, fpath: string): boolean { // Do a quick check for the exact string. If it doesn't exist, don't // bother parsing.