diff --git a/packages/vinext/src/build/standalone.ts b/packages/vinext/src/build/standalone.ts index 25a642c4..9ae05c52 100644 --- a/packages/vinext/src/build/standalone.ts +++ b/packages/vinext/src/build/standalone.ts @@ -75,6 +75,21 @@ function resolvePackageJsonPath(packageName: string, resolver: NodeRequire): str try { return resolver.resolve(`${packageName}/package.json`); } catch { + // Some packages only expose subpath exports (for example `rsc-html-stream`, + // which exports `./server` but not `.` or `./package.json`). resolver.resolve() + // cannot access those hidden paths, but Node still exposes the installed + // node_modules lookup locations via resolve.paths(). + const lookupPaths = resolver.resolve.paths(packageName) ?? []; + for (const lookupPath of lookupPaths) { + const candidate = path.join(lookupPath, packageName, "package.json"); + if (fs.existsSync(candidate)) { + const pkg = readPackageJson(candidate); + if (pkg.name === packageName) { + return candidate; + } + } + } + // Some packages do not export ./package.json via exports map. // Fallback: resolve package entry and walk up to the nearest matching package.json. try { diff --git a/packages/vinext/src/plugins/server-externals-manifest.ts b/packages/vinext/src/plugins/server-externals-manifest.ts index 732dc40b..cf4b488d 100644 --- a/packages/vinext/src/plugins/server-externals-manifest.ts +++ b/packages/vinext/src/plugins/server-externals-manifest.ts @@ -1,7 +1,14 @@ import fs from "node:fs"; import path from "node:path"; +import { builtinModules } from "node:module"; import type { Plugin } from "vite"; +const BUILTIN_MODULES = new Set( + builtinModules.flatMap((name) => + name.startsWith("node:") ? [name, name.slice(5)] : [name, `node:${name}`], + ), +); + /** * Extract the npm package name from a bare module specifier. * @@ -11,16 +18,23 @@ import type { Plugin } from "vite"; * - Node built-ins ("node:fs") * - Package self-references ("#imports") */ -function packageNameFromSpecifier(specifier: string): string | null { +export function packageNameFromSpecifier(specifier: string): string | null { if ( + !specifier || specifier.startsWith(".") || specifier.startsWith("/") || - specifier.startsWith("node:") || + specifier.startsWith("\\") || specifier.startsWith("#") ) { return null; } + // External specifiers can include non-package schemes such as + // "virtual:vite-rsc" or "file:...". Those are never npm packages. + if (/^[a-zA-Z][a-zA-Z\d+.-]*:/.test(specifier)) { + return null; + } + if (specifier.startsWith("@")) { const parts = specifier.split("/"); if (parts.length >= 2) { @@ -29,7 +43,11 @@ function packageNameFromSpecifier(specifier: string): string | null { return null; } - return specifier.split("/")[0] || null; + const packageName = specifier.split("/")[0] || null; + if (!packageName || BUILTIN_MODULES.has(specifier) || BUILTIN_MODULES.has(packageName)) { + return null; + } + return packageName; } /** @@ -95,6 +113,7 @@ export function createServerExternalsManifestPlugin(): Plugin { outDir = path.basename(dir) === "server" ? dir : path.dirname(dir); } + const bundleFiles = new Set(Object.keys(bundle)); for (const item of Object.values(bundle)) { if (item.type !== "chunk") continue; // In Rollup output, item.imports normally contains filenames of other @@ -104,6 +123,9 @@ export function createServerExternalsManifestPlugin(): Plugin { // filenames (relative/absolute paths) and extracts the package name from // bare specifiers — which is exactly what the standalone BFS needs. for (const specifier of [...item.imports, ...item.dynamicImports]) { + if (bundleFiles.has(specifier)) { + continue; + } const pkg = packageNameFromSpecifier(specifier); if (pkg) externals.add(pkg); } diff --git a/tests/server-externals-manifest.test.ts b/tests/server-externals-manifest.test.ts new file mode 100644 index 00000000..b0d2055b --- /dev/null +++ b/tests/server-externals-manifest.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vite-plus/test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { createServerExternalsManifestPlugin } from "../packages/vinext/src/plugins/server-externals-manifest.js"; + +describe("createServerExternalsManifestPlugin", () => { + it("ignores bundle files, virtual modules, and node builtins when writing the manifest", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "vinext-externals-manifest-")); + const outDir = path.join(tmpDir, "dist", "server"); + fs.mkdirSync(outDir, { recursive: true }); + + try { + const plugin = createServerExternalsManifestPlugin(); + const writeBundle = (plugin.writeBundle as { handler: Function }).handler; + + writeBundle.call( + { environment: { name: "ssr" } }, + { dir: outDir }, + { + "index.js": { + type: "chunk", + imports: [ + "react", + "@scope/pkg/subpath", + "ipaddr.js", + "index.js", + "assets/chunk-abc.js", + "virtual:vite-rsc", + "crypto", + "fs/promises", + "node:path", + ], + dynamicImports: ["react-dom/server.edge"], + }, + "assets/chunk-abc.js": { + type: "chunk", + imports: [], + dynamicImports: [], + }, + }, + ); + + const manifest = JSON.parse( + fs.readFileSync(path.join(outDir, "vinext-externals.json"), "utf-8"), + ) as string[]; + + expect(manifest.sort()).toEqual(["@scope/pkg", "ipaddr.js", "react", "react-dom"].sort()); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/standalone-build.test.ts b/tests/standalone-build.test.ts index bd1691e1..a7ec156c 100644 --- a/tests/standalone-build.test.ts +++ b/tests/standalone-build.test.ts @@ -398,6 +398,54 @@ describe("emitStandaloneOutput", () => { ).toBe(true); }); + it("copies vinext runtime dependencies that only expose subpath exports", () => { + const appRoot = path.join(tmpDir, "app"); + fs.mkdirSync(appRoot, { recursive: true }); + + writeFile(appRoot, "package.json", JSON.stringify({ name: "app" }, null, 2)); + writeFile(appRoot, "dist/client/assets/main.js", "console.log('client');\n"); + writeFile(appRoot, "dist/server/entry.js", 'console.log("server");\n'); + writeFile(appRoot, "dist/server/vinext-externals.json", JSON.stringify([])); + + const fakeVinextRoot = path.join(tmpDir, "fake-vinext"); + writeFile( + fakeVinextRoot, + "package.json", + JSON.stringify( + { + name: "vinext", + version: "0.0.0-test", + type: "module", + dependencies: { + "rsc-html-stream": "1.0.0", + }, + }, + null, + 2, + ), + ); + writeFile( + fakeVinextRoot, + "dist/server/prod-server.js", + "export async function startProdServer() {}\n", + ); + writePackage(fakeVinextRoot, "rsc-html-stream", {}, { exports: { "./server": "./server.js" } }); + writeFile(fakeVinextRoot, "node_modules/rsc-html-stream/server.js", "export {};\n"); + + const result = emitStandaloneOutput({ + root: appRoot, + outDir: path.join(appRoot, "dist"), + vinextPackageRoot: fakeVinextRoot, + }); + + expect(result.copiedPackages).toContain("rsc-html-stream"); + expect( + fs.existsSync( + path.join(appRoot, "dist/standalone/node_modules/rsc-html-stream/package.json"), + ), + ).toBe(true); + }); + it("copies packages referenced through symlinked node_modules entries", () => { const appRoot = path.join(tmpDir, "app"); fs.mkdirSync(appRoot, { recursive: true });