From 34e4137148b679d95f5d6bf307fd2b6481d703e9 Mon Sep 17 00:00:00 2001 From: Ben Williams Date: Fri, 25 Sep 2026 17:18:21 -0700 Subject: [PATCH] Return native exports from CommonJS .node requires in ESM output Relative and package requires only had their specifier rewritten, so CommonJS importers received the virtual module's namespace instead of the addon. Route them through processNodeFile like the other loader patterns. Fixes #28 --- src/index.ts | 44 +++++++--------- test/napi-rs.test.ts | 6 +-- test/vite-static-import-compat.test.ts | 72 ++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 29 deletions(-) diff --git a/src/index.ts b/src/index.ts index d46aa45..cf9359b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1529,13 +1529,22 @@ export default nativeModule; const absolutePath = path.resolve(path.dirname(id), relativePath); if (fs.existsSync(absolutePath)) { - const info = registerNativeFile(absolutePath); - replacements.push({ - start: literalNode.start, - end: literalNode.end, - value: `"./${info.hashedFilename}"`, - }); - modified = true; + if ( + isIdentifier(calleeNode) && + (calleeNode.name === "require" || customRequireVars.has(calleeNode.name)) + ) { + // Replace the whole require() so CommonJS importers get the + // native exports rather than the virtual module's namespace. + processNodeFile(absolutePath, node); + } else { + const info = registerNativeFile(absolutePath); + replacements.push({ + start: literalNode.start, + end: literalNode.end, + value: `"./${info.hashedFilename}"`, + }); + modified = true; + } } } } @@ -1657,14 +1666,7 @@ export default nativeModule; const nodeFilePath = resolveNpmPackageNodeFile(packageName, path.dirname(id)); if (nodeFilePath) { - const info = registerNativeFile(nodeFilePath); - const literalNode = node.arguments[0] as LiteralNode; - replacements.push({ - start: literalNode.start, - end: literalNode.end, - value: `"./${info.hashedFilename}"`, - }); - modified = true; + processNodeFile(nodeFilePath, node); } } } @@ -1695,17 +1697,7 @@ export default nativeModule; const result = findPlatformSpecificNativePackage(prefix, path.dirname(id)); if (result) { - const { nodeFilePath } = result; - const info = registerNativeFile(nodeFilePath); - const templateNode = node.arguments[0]; - if (templateNode.start !== undefined && templateNode.end !== undefined) { - replacements.push({ - start: templateNode.start, - end: templateNode.end, - value: `"./${info.hashedFilename}"`, - }); - modified = true; - } + processNodeFile(result.nodeFilePath, node); } } } diff --git a/test/napi-rs.test.ts b/test/napi-rs.test.ts index 92b0cb9..c8ce5de 100644 --- a/test/napi-rs.test.ts +++ b/test/napi-rs.test.ts @@ -179,7 +179,7 @@ describe("NAPI-RS Support", () => { const joinFilename = joinMatch![1]; // Extract hashed filename from require call - const requireMatch = result.code.match(/require\(['"]\.\/([^'"]+)['"]\)/); + const requireMatch = result.code.match(/require\(['"]\.\/([^'"?]+)/); expect(requireMatch).toBeDefined(); const requireFilename = requireMatch![1]; @@ -747,7 +747,7 @@ describe("NAPI-RS Support", () => { expect(transformResult).toBeDefined(); // Extract hashed filename from transformed code - const match = transformResult.code.match(/require\("\.\/([^"]+\.node)"\)/); + const match = transformResult.code.match(/require\("\.\/([^"?]+\.node)/); expect(match).toBeDefined(); const hashedFilename = match![1]; @@ -883,7 +883,7 @@ describe("NAPI-RS Support", () => { expect(transformResult.code).not.toContain("`@libsql/"); // Extract the hashed filename and verify resolveId returns virtual module ID - const match = transformResult.code.match(/require\("\.\/([^"]+\.node)"\)/); + const match = transformResult.code.match(/require\("\.\/([^"?]+\.node)/); expect(match).toBeDefined(); const resolveResult = await (plugin.resolveId as any).call( diff --git a/test/vite-static-import-compat.test.ts b/test/vite-static-import-compat.test.ts index af5780f..6cd7d0d 100644 --- a/test/vite-static-import-compat.test.ts +++ b/test/vite-static-import-compat.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { pathToFileURL } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { build as buildVite7 } from "vite7"; import vite7Package from "vite7/package.json"; @@ -133,5 +134,76 @@ describe.each(viteVersions)( expectNativeAddonOutput(result); }); + + it.each([ + { name: "relative path", source: "./build/Release/addon.node", comment: "" }, + // NAPI-RS loaders mention their .node filename, which the transform filter requires. + { name: "package", source: "native-pkg", comment: "// Loads native-pkg/addon.node" }, + ])( + "returns the native exports from a CommonJS $name require in ESM output", + async ({ comment, source }) => { + const entryPath = path.join(tempDir, "index.mjs"); + const releaseDir = path.join(tempDir, "build", "Release"); + fs.mkdirSync(releaseDir, { recursive: true }); + fs.writeFileSync(path.join(releaseDir, "addon.node"), Buffer.from("fake native module")); + const packageDir = path.join(tempDir, "node_modules", "native-pkg"); + fs.mkdirSync(packageDir, { recursive: true }); + fs.writeFileSync(path.join(packageDir, "addon.node"), Buffer.from("fake package module")); + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ main: "addon.node", name: "native-pkg" }), + ); + fs.writeFileSync( + path.join(tempDir, "lib.cjs"), + ` + ${comment} + const addon = require("${source}"); + class Wrapped extends addon.Base {} + module.exports = { hasDefault: "default" in addon, wrapped: new Wrapped().marker }; + `, + ); + fs.writeFileSync(entryPath, "import lib from './lib.cjs'; export default lib;\n"); + + // Replace createRequire so the bundle can run without a real native binary. + const fakeNodeModule = { + enforce: "pre" as const, + load(id: string) { + if (id === "\0fake-node-module") { + return `class Base { marker = "native"; } +export function createRequire() { + return () => ({ Base }); +}`; + } + }, + name: "fake-node-module", + resolveId(id: string) { + if (id === "node:module" || id === "module") return "\0fake-node-module"; + }, + }; + + const outDir = path.join(tempDir, "dist"); + await build({ + build: { + outDir, + rollupOptions: { + input: entryPath, + output: { entryFileNames: "index.mjs", format: "es" }, + }, + ssr: true, + }, + configFile: false, + logLevel: "silent", + plugins: [fakeNodeModule, nativeFilePlugin()], + root: tempDir, + ssr: { noExternal: true }, + }); + + const bundledModule = await import( + /* @vite-ignore */ pathToFileURL(path.join(outDir, "index.mjs")).href + ); + + expect(bundledModule.default).toEqual({ hasDefault: false, wrapped: "native" }); + }, + ); }, );