diff --git a/.changeset/wise-quokkas-hop.md b/.changeset/wise-quokkas-hop.md new file mode 100644 index 000000000..89c2b840d --- /dev/null +++ b/.changeset/wise-quokkas-hop.md @@ -0,0 +1,9 @@ +--- +"@opennextjs/cloudflare": patch +--- + +Fix `copyWorkerdPackages` on Windows so packages listed in `serverExternalPackages` are correctly identified and their `workerd`-condition files (e.g. `web.mjs`) end up in the bundle. + +The package name was extracted from a `node_modules/...` path with the platform's native separator. On Windows that produced `@scope\name`, which never matched the forward-slash entries in `serverExternalPackages`, so the workerd-condition copy was silently skipped and downstream esbuild would fail with `Could not resolve` errors (e.g. for `@libsql/isomorphic-ws` → `./web.mjs`). + +The extraction now normalizes separators and trims to the package name, so nested `package.json` paths like `@scope/pkg/lib-cjs/package.json` also resolve to `@scope/pkg`. diff --git a/packages/cloudflare/src/cli/build/utils/workerd.spec.ts b/packages/cloudflare/src/cli/build/utils/workerd.spec.ts index 7f64287bc..7e877896b 100644 --- a/packages/cloudflare/src/cli/build/utils/workerd.spec.ts +++ b/packages/cloudflare/src/cli/build/utils/workerd.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { transformBuildCondition, transformPackageJson } from "./workerd.js"; +import { extractExternalPackageName, transformBuildCondition, transformPackageJson } from "./workerd.js"; describe("transformBuildCondition", () => { test("top level", () => { @@ -254,3 +254,41 @@ describe("transformPackageJson", () => { expect(hasBuildCondition).toBe(true); }); }); + +describe("extractExternalPackageName", () => { + test("scoped package on posix", () => { + expect(extractExternalPackageName("/app/node_modules/@libsql/isomorphic-ws")).toBe( + "@libsql/isomorphic-ws" + ); + }); + + test("unscoped package on posix", () => { + expect(extractExternalPackageName("/app/node_modules/ws")).toBe("ws"); + }); + + test("scoped package on windows", () => { + expect(extractExternalPackageName("F:\\app\\node_modules\\@libsql\\isomorphic-ws")).toBe( + "@libsql/isomorphic-ws" + ); + }); + + test("unscoped package on windows", () => { + expect(extractExternalPackageName("F:\\app\\node_modules\\ws")).toBe("ws"); + }); + + test("trims nested subpath to the package name (posix)", () => { + expect(extractExternalPackageName("/app/node_modules/@libsql/hrana-client/lib-cjs")).toBe( + "@libsql/hrana-client" + ); + }); + + test("trims nested subpath to the package name (windows)", () => { + expect(extractExternalPackageName("F:\\app\\node_modules\\@libsql\\hrana-client\\lib-cjs")).toBe( + "@libsql/hrana-client" + ); + }); + + test("returns undefined when the path is not under node_modules", () => { + expect(extractExternalPackageName("/app/src/lib/foo.ts")).toBeUndefined(); + }); +}); diff --git a/packages/cloudflare/src/cli/build/utils/workerd.ts b/packages/cloudflare/src/cli/build/utils/workerd.ts index e5010740a..e38993f91 100644 --- a/packages/cloudflare/src/cli/build/utils/workerd.ts +++ b/packages/cloudflare/src/cli/build/utils/workerd.ts @@ -76,9 +76,27 @@ export function transformPackageJson(json: PackageJson) { return { transformed, hasBuildCondition }; } -export async function copyWorkerdPackages(options: BuildOptions, nodePackages: Map) { - const isNodeModuleRegex = getCrossPlatformPathRegex(`.*/node_modules/(?.*)`, { escape: false }); +const NODE_MODULES_PATH_REGEX = getCrossPlatformPathRegex(`.*/node_modules/(?.*)`, { escape: false }); + +/** + * Extracts the npm package name from a path inside `node_modules`. + * + * Normalizes Windows backslashes to POSIX separators so the result can be + * compared against `serverExternalPackages` entries (which always use `/`). + * Trims back to the package name proper, so nested `package.json` files + * (e.g. `@scope/pkg/lib-cjs/package.json`) return `@scope/pkg`. + * + * @returns The package name, or `undefined` if `src` is not under `node_modules`. + */ +export function extractExternalPackageName(src: string): string | undefined { + const match = src.match(NODE_MODULES_PATH_REGEX); + const raw = match?.groups?.pkg; + if (!raw) return undefined; + const parts = raw.replaceAll("\\", "/").split("/"); + return parts[0]?.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]; +} +export async function copyWorkerdPackages(options: BuildOptions, nodePackages: Map) { // Copy full external packages when they use "workerd" build condition const nextConfig = loadConfig(path.join(options.appBuildOutputPath, ".next")); const externalPackages = @@ -88,8 +106,8 @@ export async function copyWorkerdPackages(options: BuildOptions, nodePackages: M try { const pkgJson = JSON.parse(await fs.readFile(path.join(src, "package.json"), "utf8")); const { transformed, hasBuildCondition } = transformPackageJson(pkgJson); - const match = src.match(isNodeModuleRegex); - if (match?.groups?.pkg && externalPackages.includes(match.groups.pkg) && hasBuildCondition) { + const pkg = extractExternalPackageName(src); + if (pkg && externalPackages.includes(pkg) && hasBuildCondition) { logger.debug( `Copying package using a workerd condition: ${path.relative(options.appPath, src)} -> ${path.relative(options.appPath, dst)}` );