diff --git a/packages/server/src/server/plugins/compiler.test.ts b/packages/server/src/server/plugins/compiler.test.ts index fc86f85db3..0ddf97b6f3 100644 --- a/packages/server/src/server/plugins/compiler.test.ts +++ b/packages/server/src/server/plugins/compiler.test.ts @@ -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( diff --git a/packages/server/src/server/plugins/compiler.ts b/packages/server/src/server/plugins/compiler.ts index b48449bb50..5cdce3ad6c 100644 --- a/packages/server/src/server/plugins/compiler.ts +++ b/packages/server/src/server/plugins/compiler.ts @@ -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" &&