Skip to content
Merged
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
15 changes: 15 additions & 0 deletions packages/vinext/src/build/standalone.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ?? [];
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good fallback strategy. resolve.paths() is stable Node API and gives the same lookup directories that require.resolve() would search. The pkg.name === packageName guard on line 87 prevents false positives from similarly-named directories that aren't the right package (e.g., a stale directory left over from a different version).

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 {
Expand Down
28 changes: 25 additions & 3 deletions packages/vinext/src/plugins/server-externals-manifest.ts
Original file line number Diff line number Diff line change
@@ -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.
*
Expand All @@ -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)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: this regex matches RFC 3986 scheme syntax, which is the right call. Worth noting it also matches Windows drive letters like C:/path, but that's harmless since (a) those would never appear as Rollup external specifiers, and (b) the \\ startsWith check above catches the backslash variant.

One thing I noticed: the node: prefix check that was in the original code (specifier.startsWith("node:")) is now redundant since the regex catches it. But removing it was the right move — the regex is more general and handles virtual:, file:, data:, etc. all at once. Clean.

return null;
}

if (specifier.startsWith("@")) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: scoped packages (@scope/pkg) aren't checked against BUILTIN_MODULES before returning. This is fine because no Node builtin starts with @, but it does mean if Node ever added a @node/something builtin (unlikely), it would slip through. Not worth changing, just noting the asymmetry for future readers.

const parts = specifier.split("/");
if (parts.length >= 2) {
Expand All @@ -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)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Checking both specifier and packageName against builtins is a nice touch — handles fs/promises (where the specifier itself is a builtin subpath) and bare builtins like crypto (where packageName equals the specifier). The builtinModules list includes entries like fs/promises directly, so both paths are necessary.

return null;
}
return packageName;
}

/**
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
Expand Down
53 changes: 53 additions & 0 deletions tests/server-externals-manifest.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vite-plus/test";
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a few direct unit tests for packageNameFromSpecifier — it's now exported and the logic is non-trivial (regex, builtin set, scoped packages). The integration test here is valuable but a table-driven unit test would catch regressions faster and document the edge cases explicitly. Not blocking — the integration test covers the important paths.

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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the as { handler: Function } cast is a reasonable workaround for Vite's plugin types (where writeBundle can be either a function or an object with handler). If this pattern shows up in more tests, it might be worth a small getPluginHook test helper to reduce the casting boilerplate, but fine as-is for a single test.


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 });
}
});
});
48 changes: 48 additions & 0 deletions tests/standalone-build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading