fix(plugins): treat Node type declarations as host types in the type walk

Dependency declarations that `import "node"` (e.g. utif2) pulled @types/node
into the install-time type walk, which failed on node: specifiers newer than
the host Node (node:stream/iter on Node 24) or, with @types/node absent,
on the unresolved "node" import. Map bare "node" in declarations to
@types/node and treat any node: specifier as a builtin.
This commit is contained in:
Gabriele Magno
2026-10-16 13:19:22 +02:00
parent 8d9a7123ba
commit b86cbd38e5
2 changed files with 55 additions and 7 deletions
@@ -386,6 +386,46 @@ export type Value = string;`,
},
);
it.each([
{ name: "import 'node' with Node types installed", source: 'import "node";', nodeTypes: true },
{ name: "import 'node' without Node types", source: 'import "node";', nodeTypes: false },
{
name: "a node: builtin unknown to the host",
source: 'import type { Future } from "node:paseo-future-builtin";',
nodeTypes: false,
},
])("treats Node declarations as host types in server dependencies: $name", async (fixture) => {
const entries = await createSplitPlugin();
const dependency = path.join(entries.directory, "node_modules/image-codec");
await mkdir(dependency, { recursive: true });
await writeFile(
path.join(dependency, "package.json"),
JSON.stringify({ name: "image-codec", main: "index.js", types: "index.d.ts" }),
);
await writeFile(path.join(dependency, "index.js"), 'module.exports = "decoded";');
await writeFile(
path.join(dependency, "index.d.ts"),
`${fixture.source}\ndeclare const value: string;\nexport = value;`,
);
if (fixture.nodeTypes) {
const nodeTypes = path.join(entries.directory, "node_modules/@types/node");
await mkdir(nodeTypes, { recursive: true });
await writeFile(
path.join(nodeTypes, "package.json"),
JSON.stringify({ name: "@types/node", types: "index.d.ts" }),
);
await writeFile(
path.join(nodeTypes, "index.d.ts"),
'declare module "node:paseo-future-builtin" { export * from "node:stream"; }\nimport type { Future } from "node:paseo-future-builtin";',
);
}
await writeFile(entries.server, 'import value from "image-codec"; export default value;');
await expect(compilePlugin({ client: null, server: entries.server })).resolves.toMatchObject({
serverBundle: expect.stringContaining("decoded"),
});
});
it("checks the physical owner of type-only symlinks", async () => {
const entries = await createSplitPlugin();
await writeFile(
+15 -7
View File
@@ -92,6 +92,18 @@ function containsPath(directory: string, filePath: string): boolean {
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
// @types/node can declare builtins newer than the host Node (node:stream/iter on Node 24).
function isNodeSpecifier(specifier: string): boolean {
return isBuiltin(specifier) || specifier.startsWith("node:") || specifier === "@types/node";
}
function declaredPackage(specifier: string, kind: PluginImportKind, typeOnly: boolean): string {
if (kind === "type-reference") return `@types/${specifier.replace(/^@/, "").replace("/", "__")}`;
// Declarations load Node's ambient types with `import "node"`.
if (typeOnly && specifier === "node") return "@types/node";
return specifier;
}
function dependencyName(specifier: string): string {
const segments = specifier.split("/");
return specifier.startsWith("@") ? segments.slice(0, 2).join("/") : segments[0];
@@ -236,10 +248,7 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory:
if (checked.has(key) || !/\.[cm]?[jt]sx?$/.test(file)) return null;
checked.add(key);
for (const { specifier, kind, typeOnly } of imports.read(file)) {
const packageSpecifier =
kind === "type-reference"
? `@types/${specifier.replace(/^@/, "").replace("/", "__")}`
: specifier;
const packageSpecifier = declaredPackage(specifier, kind, typeOnly);
const error =
runtimeSpecifierError(specifier, owner, file) ??
runtimeSpecifierError(packageSpecifier, owner, file) ??
@@ -253,7 +262,7 @@ function createRuntimeBoundaryPlugin(target: PluginBuildTarget, pluginDirectory:
if (
(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier) ||
/^(zod|react|react-native|@tanstack\/react-query)(\/|$)/.test(specifier) ||
isBuiltin(specifier) ||
isNodeSpecifier(specifier) ||
packageSpecifier === "@types/node"
)
continue;
@@ -328,8 +337,7 @@ function runtimeSpecifierError(
!(PLUGIN_SDK_SPECIFIERS as readonly string[]).includes(specifier)
)
kind = "Unknown SDK";
else if (target !== "server" && (isBuiltin(specifier) || specifier === "@types/node"))
kind = "Node";
else if (target !== "server" && isNodeSpecifier(specifier)) kind = "Node";
else if (target !== "server" && isPluginServerOnlySdkSpecifier(specifier)) kind = "server-only";
else if (
target !== "client" &&