Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/wise-quokkas-hop.md
Original file line number Diff line number Diff line change
@@ -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`.
40 changes: 39 additions & 1 deletion packages/cloudflare/src/cli/build/utils/workerd.spec.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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();
});
});
26 changes: 22 additions & 4 deletions packages/cloudflare/src/cli/build/utils/workerd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,27 @@ export function transformPackageJson(json: PackageJson) {
return { transformed, hasBuildCondition };
}

export async function copyWorkerdPackages(options: BuildOptions, nodePackages: Map<string, string>) {
const isNodeModuleRegex = getCrossPlatformPathRegex(`.*/node_modules/(?<pkg>.*)`, { escape: false });
const NODE_MODULES_PATH_REGEX = getCrossPlatformPathRegex(`.*/node_modules/(?<pkg>.*)`, { 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<string, string>) {
// Copy full external packages when they use "workerd" build condition
const nextConfig = loadConfig(path.join(options.appBuildOutputPath, ".next"));
const externalPackages =
Expand All @@ -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)}`
);
Expand Down
Loading