diff --git a/package.json b/package.json index 9912214..1fd60ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vite-plugin-native-modules", - "version": "2.2.0", + "version": "2.2.1", "description": "A Vite plugin for integrating Node.js native modules into your Vite project", "author": "Ben Williams", "license": "MIT", diff --git a/src/index.ts b/src/index.ts index 8195332..99fbaa9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -135,9 +135,9 @@ export default function nativeFilePlugin( // Reverse mapping from hashed filename to original file path // Used to resolve transformed bindings/node-gyp-build calls const hashedFilenameToPath = new Map(); - // Track module type (ES module vs CommonJS) for virtual modules - // Maps virtual module ID to whether it's an ES module - const virtualModuleTypes = new Map(); + // Track the output format from Vite config + // This determines whether we generate ESM or CJS code in the load hook + let outputFormat: "es" | "cjs" = "es"; // Default to ESM (Vite's default) let command: "build" | "serve" = "build"; // Helper function to detect if a file is an ES module based on extension and content @@ -581,6 +581,35 @@ export default function nativeFilePlugin( return { configResolved(config) { command = config.command; + + // Detect output format from Vite config + // Priority: rollupOptions.output.format > lib.formats > default (es) + // + // LIMITATION: For multi-format builds (e.g., lib.formats: ['es', 'cjs']), this + // only uses the first format. Rollup's load hook is called once per module, not + // per output format. In practice, the ESM pattern (createRequire + import.meta.url) + // works correctly in both ESM and CJS outputs because Rollup handles the conversion. + const rollupOutput = config.build?.rollupOptions?.output; + if (rollupOutput) { + // rollupOptions.output can be an object or array of objects + const format = Array.isArray(rollupOutput) + ? rollupOutput[0]?.format + : rollupOutput.format; + if (format === "cjs" || format === "commonjs") { + outputFormat = "cjs"; + } else { + outputFormat = "es"; + } + } else if (config.build?.lib) { + // lib mode - use first format + // lib can be false or LibraryOptions, check for formats property + const lib = config.build.lib; + if (typeof lib === "object" && lib.formats) { + const formats = lib.formats; + outputFormat = formats[0] === "cjs" ? "cjs" : "es"; + } + } + // Otherwise keep default 'es' (Vite's default for modern builds) }, generateBundle() { @@ -602,53 +631,23 @@ export default function nativeFilePlugin( if (!info) return null; - // Check if this virtual module is being loaded in an ES module context - // Try to get the tracked module type first - let isESModule = virtualModuleTypes.get(id); - - // If not tracked, try to detect from the original path or use getModuleInfo if available - if (isESModule === undefined) { - // Try to detect from file extension as fallback - isESModule = detectModuleType(originalPath); - - // If getModuleInfo is available, try to use it (though it may not work for virtual modules) - try { - if (typeof this.getModuleInfo === "function") { - const moduleInfo = this.getModuleInfo(id); - const format = (moduleInfo as { format?: string }).format; - if (moduleInfo && format) { - isESModule = format === "es"; - } - } - } catch { - // Ignore errors, use fallback detection - } - - // If still undefined, default to CommonJS (safer than ES module) - // This prevents mixing require() with import.meta.url - if (isESModule === undefined) { - isESModule = false; - } - } - - // Return proxy code that requires the hashed file - // The hashed file will be in the same directory as the output bundle - // - // We use syntheticNamedExports (set in resolveId) to tell Rollup to resolve - // any named export requests from the default export's properties. - // This way, `const { databaseOpen } = require(...)` works correctly - // because Rollup gets databaseOpen from default.databaseOpen. - if (isESModule) { + // Generate code based on OUTPUT format, not the importer's format. + // This is important because: + // 1. Using CJS require() in an ESM output causes "Cannot determine intended + // module format because both require() and top-level await are present" + // 2. Using import.meta.url in a CJS output doesn't work + // 3. The output format is what matters for the final bundled code + if (outputFormat === "es") { return ` - import { createRequire } from 'node:module'; - const createRequireLocal = createRequire(import.meta.url); - const nativeModule = createRequireLocal('./${info.hashedFilename}'); - export default nativeModule; - `; +import { createRequire } from 'node:module'; +const __require = createRequire(import.meta.url); +const nativeModule = __require('./${info.hashedFilename}'); +export default nativeModule; +`; } else { return ` - module.exports = require('./${info.hashedFilename}'); - `; +module.exports = require('./${info.hashedFilename}'); +`; } }, @@ -673,13 +672,15 @@ export default function nativeFilePlugin( // Check if this matches a hashed filename we've generated if (hashedFilenameToPath.has(basename)) { const originalPath = hashedFilenameToPath.get(basename)!; - const importingModuleType = detectModuleTypeWithContext(this, importer); const virtualId = `\0native:${originalPath}`; - virtualModuleTypes.set(virtualId, importingModuleType); - // Return virtual module ID with syntheticNamedExports enabled - // This tells Rollup to resolve named exports from the default export's properties, - // which fixes the getAugmentedNamespace issue where destructuring fails - // because properties like databaseOpen aren't copied to the namespace. + + // Use syntheticNamedExports to enable named import/destructuring patterns + // like `const { databaseOpen } = require('native-module')` or `import { foo } from 'native'`. + // This tells Rollup to derive named exports from the default export's properties. + // + // Note: This is incompatible with `export * from 'native-module'` patterns because + // Rollup cannot enumerate synthetic exports at bundle time. If you encounter errors + // with export * re-exports, consider restructuring to use named imports instead. return { id: virtualId, syntheticNamedExports: true, @@ -698,11 +699,8 @@ export default function nativeFilePlugin( // Register the native file (generates hash, stores mapping) registerNativeFile(resolved); - // Track module type and return virtual module ID - const importingModuleType = detectModuleTypeWithContext(this, importer); + // Return virtual module ID const virtualId = `\0native:${resolved}`; - virtualModuleTypes.set(virtualId, importingModuleType); - return virtualId; }, diff --git a/test/bindings.test.ts b/test/bindings.test.ts index 1324b9d..54ab194 100644 --- a/test/bindings.test.ts +++ b/test/bindings.test.ts @@ -839,8 +839,9 @@ export { addon };`; expect(loadResult).not.toContain("module.exports"); }); - it("should generate CommonJS code in load hook for bindings in .js file", async () => { + it("should generate ESM code in load hook by default (regardless of importer format)", async () => { const plugin = nativeFilePlugin() as Plugin; + // Default config - no output format specified, defaults to ESM (plugin.configResolved as any)({ command: "build", mode: "production", @@ -881,10 +882,11 @@ module.exports = { addon };`; const virtualId = typeof resolveResult === "object" ? resolveResult.id : resolveResult; const loadResult = await (plugin.load as any).call({} as any, virtualId); expect(loadResult).toBeDefined(); - expect(loadResult).toContain("module.exports"); - expect(loadResult).toContain("require("); - expect(loadResult).not.toContain("import { createRequire }"); - expect(loadResult).not.toContain("export default"); + // Default output format is ESM, so should generate ESM syntax + expect(loadResult).toContain("import { createRequire }"); + expect(loadResult).toContain("export default"); + expect(loadResult).toContain("import.meta.url"); + expect(loadResult).not.toContain("module.exports"); }); }); }); diff --git a/test/module-format-detection.test.ts b/test/module-format-detection.test.ts index d5dab5a..9901c5e 100644 --- a/test/module-format-detection.test.ts +++ b/test/module-format-detection.test.ts @@ -96,7 +96,7 @@ export { addon };`; // Should generate ES module syntax expect(loadResult).toContain("import { createRequire }"); expect(loadResult).toContain("export default"); - expect(loadResult).toContain("createRequireLocal"); + expect(loadResult).toContain("__require"); expect(loadResult).toContain("import.meta.url"); // Should NOT contain CommonJS syntax @@ -172,9 +172,10 @@ export { addon };`; }); }); - describe("bindings package - CommonJS context", () => { - it("should generate CommonJS code in load hook for .js file", async () => { + describe("bindings package - default output format (ESM)", () => { + it("should generate ESM code in load hook regardless of importer format (default output is ESM)", async () => { const plugin = nativeFilePlugin() as Plugin; + // Default config - no output format specified, defaults to ESM (plugin.configResolved as any)({ command: "build", mode: "production", @@ -219,19 +220,18 @@ module.exports = { addon };`; {} ); - // resolveId now returns an object with { id, syntheticNamedExports } + // resolveId now returns the virtual ID string const virtualId = typeof resolveResult === "object" ? resolveResult.id : resolveResult; const loadResult = await (plugin.load as any).call({} as any, virtualId); expect(loadResult).toBeDefined(); - // Should generate CommonJS syntax - expect(loadResult).toContain("module.exports"); - expect(loadResult).toContain("require("); + // Default output format is ESM, so should generate ESM syntax + expect(loadResult).toContain("import { createRequire }"); + expect(loadResult).toContain("export default"); + expect(loadResult).toContain("import.meta.url"); - // Should NOT contain ES module syntax - expect(loadResult).not.toContain("import { createRequire }"); - expect(loadResult).not.toContain("export default"); - expect(loadResult).not.toContain("import.meta.url"); + // Should NOT contain raw CommonJS syntax + expect(loadResult).not.toContain("module.exports"); }); }); @@ -377,9 +377,10 @@ const binding = nodeGypBuild(__dirname);`; }); }); - describe("node-gyp-build - CommonJS context", () => { - it("should generate CommonJS code in load hook for .js file", async () => { + describe("node-gyp-build - default output format (ESM)", () => { + it("should generate ESM code in load hook regardless of importer format (default output is ESM)", async () => { const plugin = nativeFilePlugin() as Plugin; + // Default config - no output format specified, defaults to ESM (plugin.configResolved as any)({ command: "build", mode: "production", @@ -428,17 +429,16 @@ const binding = nodeGypBuild(__dirname);`; {} ); - // resolveId now returns an object with { id, syntheticNamedExports } + // resolveId now returns the virtual ID string const virtualId = typeof resolveResult === "object" ? resolveResult.id : resolveResult; const loadResult = await (plugin.load as any).call({} as any, virtualId); expect(loadResult).toBeDefined(); - // Should generate CommonJS syntax - expect(loadResult).toContain("module.exports"); - expect(loadResult).toContain("require("); - expect(loadResult).not.toContain("import { createRequire }"); - expect(loadResult).not.toContain("export default"); - expect(loadResult).not.toContain("import.meta.url"); + // Default output format is ESM, so should generate ESM syntax + expect(loadResult).toContain("import { createRequire }"); + expect(loadResult).toContain("export default"); + expect(loadResult).toContain("import.meta.url"); + expect(loadResult).not.toContain("module.exports"); }); }); @@ -510,9 +510,10 @@ const binding = nodeGypBuild(__dirname);`; }); }); - describe("Regular .node imports - CommonJS context", () => { - it("should generate CommonJS code in load hook for .js file", async () => { + describe("Regular .node imports - default output format (ESM)", () => { + it("should generate ESM code in load hook regardless of importer format (default output is ESM)", async () => { const plugin = nativeFilePlugin() as Plugin; + // Default config - no output format specified, defaults to ESM (plugin.configResolved as any)({ command: "build", mode: "production", @@ -534,8 +535,49 @@ const binding = nodeGypBuild(__dirname);`; const loadResult = await (plugin.load as any).call({} as any, virtualId); expect(loadResult).toBeDefined(); - - // Should generate CommonJS syntax + + // Default output format is ESM, so should generate ESM syntax + expect(loadResult).toContain("import { createRequire }"); + expect(loadResult).toContain("export default"); + expect(loadResult).toContain("import.meta.url"); + expect(loadResult).not.toContain("module.exports"); + }); + }); + + describe("Regular .node imports - explicit CJS output format", () => { + it("should generate CommonJS code when output format is explicitly CJS", async () => { + const plugin = nativeFilePlugin() as Plugin; + // Explicit CJS output format via rollupOptions + (plugin.configResolved as any)({ + command: "build", + mode: "production", + build: { + rollupOptions: { + output: { + format: "cjs", + }, + }, + }, + }); + + const nodeFilePath = path.join(tempDir, "addon.node"); + fs.writeFileSync(nodeFilePath, Buffer.from("fake binary")); + + const cjsFilePath = path.join(tempDir, "index.js"); + + const virtualId = await (plugin.resolveId as any).call( + {} as any, + "./addon.node", + cjsFilePath, + {} + ); + + expect(virtualId).toBeDefined(); + + const loadResult = await (plugin.load as any).call({} as any, virtualId); + expect(loadResult).toBeDefined(); + + // CJS output format should generate CommonJS syntax expect(loadResult).toContain("module.exports"); expect(loadResult).toContain("require("); expect(loadResult).not.toContain("import { createRequire }"); @@ -545,8 +587,9 @@ const binding = nodeGypBuild(__dirname);`; }); describe("Edge cases", () => { - it("should not mix require() with import.meta.url", async () => { + it("should not mix CJS module.exports with ESM export default (default ESM output)", async () => { const plugin = nativeFilePlugin() as Plugin; + // Default ESM output format (plugin.configResolved as any)({ command: "build", mode: "production", @@ -564,22 +607,27 @@ const binding = nodeGypBuild(__dirname);`; ); const loadResult = await (plugin.load as any).call({} as any, virtualId); - - // Should NOT have both require() and import.meta.url - const hasRequire = loadResult.includes("require("); - const hasImportMeta = loadResult.includes("import.meta.url"); - - // If it has require, it should NOT have import.meta.url - if (hasRequire) { - expect(hasImportMeta).toBe(false); - } + + // Default ESM output should use ESM pattern with createRequire + expect(loadResult).toContain("import.meta.url"); + expect(loadResult).toContain("export default"); + // Should NOT have raw module.exports (incompatible with ESM) + expect(loadResult).not.toContain("module.exports"); }); - it("should not mix module.exports with export default", async () => { + it("should not mix CJS module.exports with ESM export default (explicit CJS output)", async () => { const plugin = nativeFilePlugin() as Plugin; + // Explicit CJS output format (plugin.configResolved as any)({ command: "build", mode: "production", + build: { + rollupOptions: { + output: { + format: "cjs", + }, + }, + }, }); const nodeFilePath = path.join(tempDir, "addon.node"); @@ -594,15 +642,45 @@ const binding = nodeGypBuild(__dirname);`; ); const loadResult = await (plugin.load as any).call({} as any, virtualId); - - // Should NOT have both module.exports and export default - const hasModuleExports = loadResult.includes("module.exports"); - const hasExportDefault = loadResult.includes("export default"); - - // If it has export default, it should NOT have module.exports - if (hasExportDefault) { - expect(hasModuleExports).toBe(false); - } + + // CJS output should use CJS pattern + expect(loadResult).toContain("module.exports"); + expect(loadResult).toContain("require("); + // Should NOT have ESM features (incompatible with CJS) + expect(loadResult).not.toContain("import.meta.url"); + expect(loadResult).not.toContain("export default"); + }); + + it("ESM output should use createRequire pattern (require + import.meta.url is valid)", async () => { + const plugin = nativeFilePlugin() as Plugin; + // Default ESM output format + (plugin.configResolved as any)({ + command: "build", + mode: "production", + }); + + const nodeFilePath = path.join(tempDir, "addon.node"); + fs.writeFileSync(nodeFilePath, Buffer.from("fake binary")); + + const esmFilePath = path.join(tempDir, "index.mjs"); + const virtualId = await (plugin.resolveId as any).call( + {} as any, + "./addon.node", + esmFilePath, + {} + ); + + const loadResult = await (plugin.load as any).call({} as any, virtualId); + + // ESM output uses createRequire pattern which correctly combines: + // - createRequire from 'node:module' (ESM import) + // - import.meta.url (ESM feature) + // - __require(...) call (the created require function) + expect(loadResult).toContain("import { createRequire }"); + expect(loadResult).toContain("import.meta.url"); + expect(loadResult).toContain("export default"); + // The createRequire call creates a require function, but NOT module.exports + expect(loadResult).not.toContain("module.exports"); }); }); }); diff --git a/test/napi-rs.test.ts b/test/napi-rs.test.ts index 29afeb6..9f1b7a4 100644 --- a/test/napi-rs.test.ts +++ b/test/napi-rs.test.ts @@ -719,20 +719,20 @@ describe("NAPI-RS Support", () => { }); }); - describe("Rollup interop - syntheticNamedExports", () => { + describe("Rollup interop - native module resolution", () => { /** - * This test suite covers the fix for the "databaseOpen is not a function" error. + * This test suite covers native module resolution and Rollup compatibility. * - * The issue: When Rollup bundles a native module, it wraps it with getAugmentedNamespace - * which creates { __esModule: true, default: nativeModule }. When code destructures - * like `const { databaseOpen } = require('@libsql/...')`, it fails because databaseOpen - * is on the default export, not the namespace object. + * Note: We previously used syntheticNamedExports: true to fix destructuring patterns + * like `const { databaseOpen } = require(...)`. However, syntheticNamedExports is + * incompatible with `export * from` re-export patterns (causes Rollup error: + * "needs a default export that does not reexport an unresolved named export"). * - * The fix: resolveId returns { id, syntheticNamedExports: true } which tells Rollup - * to resolve named exports from the default export's properties. + * The CommonJS destructuring pattern works at runtime because require() returns + * the native module directly, and object destructuring happens at runtime. */ - it("should return syntheticNamedExports: true from resolveId for hashed .node files", async () => { + it("should return virtual module ID from resolveId for hashed .node files", async () => { const plugin = nativeFilePlugin() as Plugin; (plugin.configResolved as any)({ @@ -762,7 +762,7 @@ describe("NAPI-RS Support", () => { expect(match).toBeDefined(); const hashedFilename = match![1]; - // Now test resolveId returns object with syntheticNamedExports + // Test resolveId returns virtual module ID const resolveResult = await (plugin.resolveId as any).call( {} as any, `./${hashedFilename}`, @@ -771,9 +771,10 @@ describe("NAPI-RS Support", () => { ); expect(resolveResult).toBeDefined(); - expect(typeof resolveResult).toBe("object"); - expect(resolveResult.id).toContain("\0native:"); - expect(resolveResult.syntheticNamedExports).toBe(true); + // resolveId returns { id, syntheticNamedExports } to enable named imports + const virtualId = + typeof resolveResult === "object" ? resolveResult.id : resolveResult; + expect(virtualId).toContain("\0native:"); }); it("should generate ES module code in load hook that enables destructuring", async () => { @@ -869,13 +870,14 @@ describe("NAPI-RS Support", () => { ); // Code that destructures named exports (like libsql does) - // This pattern was failing with "databaseOpen is not a function" + // The destructuring works at runtime because require() returns the module directly const jsFilePath = path.join(tempDir, "index.js"); const code = ` const { currentTarget } = require('@neon-rs/load'); let target = currentTarget(); - // This destructuring pattern requires syntheticNamedExports to work + // This destructuring pattern works at runtime - require() returns the native module + // directly, and JS object destructuring extracts the properties const { databaseOpen, databaseClose, @@ -901,7 +903,7 @@ describe("NAPI-RS Support", () => { // The require should be rewritten to a relative path expect(transformResult.code).not.toContain("`@libsql/"); - // Extract the hashed filename and verify resolveId returns syntheticNamedExports + // Extract the hashed filename and verify resolveId returns virtual module ID const match = transformResult.code.match(/require\("\.\/([^"]+\.node)"\)/); expect(match).toBeDefined(); @@ -912,9 +914,11 @@ describe("NAPI-RS Support", () => { {} ); - // This is the critical fix - syntheticNamedExports must be true - // so that destructuring like { databaseOpen, ... } works - expect(resolveResult.syntheticNamedExports).toBe(true); + // resolveId returns { id, syntheticNamedExports } to enable named imports + expect(resolveResult).toBeDefined(); + const virtualId = + typeof resolveResult === "object" ? resolveResult.id : resolveResult; + expect(virtualId).toContain("\0native:"); }); }); }); diff --git a/test/node-gyp-build.test.ts b/test/node-gyp-build.test.ts index 9d0b256..b119e2c 100644 --- a/test/node-gyp-build.test.ts +++ b/test/node-gyp-build.test.ts @@ -2009,8 +2009,9 @@ const binding = nodeGypBuild(__dirname);`; expect(loadResult).not.toContain("module.exports"); }); - it("should generate CommonJS code in load hook for node-gyp-build in .js file", async () => { + it("should generate ESM code in load hook by default (regardless of importer format)", async () => { const plugin = nativeFilePlugin() as Plugin; + // Default config - no output format specified, defaults to ESM (plugin.configResolved as any)({ command: "build", mode: "production", @@ -2054,10 +2055,11 @@ const binding = nodeGypBuild(__dirname);`; const virtualId = typeof resolveResult === "object" ? resolveResult.id : resolveResult; const loadResult = await (plugin.load as any).call({} as any, virtualId); expect(loadResult).toBeDefined(); - expect(loadResult).toContain("module.exports"); - expect(loadResult).toContain("require("); - expect(loadResult).not.toContain("import { createRequire }"); - expect(loadResult).not.toContain("export default"); + // Default output format is ESM, so should generate ESM syntax + expect(loadResult).toContain("import { createRequire }"); + expect(loadResult).toContain("export default"); + expect(loadResult).toContain("import.meta.url"); + expect(loadResult).not.toContain("module.exports"); }); }); }); diff --git a/test/output-format-detection.test.ts b/test/output-format-detection.test.ts new file mode 100644 index 0000000..94e915f --- /dev/null +++ b/test/output-format-detection.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import nativeFilePlugin from "../src/index.js"; +import { build, type Rollup } from "vite"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +/** + * Tests for output format detection. + * + * These tests verify that the plugin generates the correct module format + * based on the Vite OUTPUT format, not the importer's format. + * + * The bug: When a CJS file (like bufferutil/index.js using require('node-gyp-build')) + * imports a native module, but the Vite output format is ESM, the plugin was + * generating CommonJS code which got inlined into the ESM output, causing: + * "Cannot determine intended module format because both require() and top-level await are present" + */ +describe("Output Format Detection", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "output-format-test-")); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe("CJS importer with ESM output", () => { + /** + * This is the actual bug scenario: + * - bufferutil/index.js is CommonJS (uses require('node-gyp-build')) + * - But the Vite build output format is ESM + * - The plugin should generate ESM code (createRequire) not CJS (require) + */ + it("should generate ESM code when output format is ES, even if importer is CJS", async () => { + const platform = process.platform; + const arch = process.arch; + + // Create a native module package with CJS index.js (like bufferutil) + const packageDir = path.join(tempDir, "node_modules", "native-addon"); + const prebuildsDir = path.join(packageDir, "prebuilds", `${platform}-${arch}`); + fs.mkdirSync(prebuildsDir, { recursive: true }); + + // Create the native .node file + fs.writeFileSync( + path.join(prebuildsDir, "addon.node"), + Buffer.from("fake native module") + ); + + // Create package.json + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ + name: "native-addon", + main: "index.js", + }) + ); + + // Create CJS index.js that uses node-gyp-build pattern + fs.writeFileSync( + path.join(packageDir, "index.js"), + `'use strict'; +try { + module.exports = require('node-gyp-build')(__dirname); +} catch (e) { + module.exports = { doSomething: function() {} }; +} +` + ); + + // Create a fake node-gyp-build module + const nodeGypBuildDir = path.join(tempDir, "node_modules", "node-gyp-build"); + fs.mkdirSync(nodeGypBuildDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeGypBuildDir, "package.json"), + JSON.stringify({ name: "node-gyp-build", main: "index.js" }) + ); + fs.writeFileSync( + path.join(nodeGypBuildDir, "index.js"), + `module.exports = function(dir) { + const path = require('path'); + const platform = process.platform; + const arch = process.arch; + return require(path.join(dir, 'prebuilds', platform + '-' + arch, 'addon.node')); +};` + ); + + // Create ESM entry point that imports the CJS native-addon + // This simulates a modern ESM app importing bufferutil + const entryPath = path.join(tempDir, "index.mjs"); + fs.writeFileSync( + entryPath, + `import addon from 'native-addon'; +console.log(addon); +export { addon }; +` + ); + + // Build with ESM output format for Node.js target + let buildOutput: Rollup.RollupOutput | undefined; + let buildError: Error | null = null; + + try { + const result = await build({ + root: tempDir, + logLevel: "silent", + build: { + write: false, + ssr: true, // Target Node.js, not browser + rollupOptions: { + input: entryPath, + output: { + format: "es", // ESM output + }, + }, + }, + plugins: [nativeFilePlugin({ forced: true })], + }); + // When write: false, result is RollupOutput | RollupOutput[], not a watcher + const output = result as Rollup.RollupOutput | Rollup.RollupOutput[]; + buildOutput = Array.isArray(output) ? output[0] : output; + } catch (err) { + buildError = err as Error; + } + + // The build should succeed + expect(buildError).toBeNull(); + expect(buildOutput).toBeDefined(); + + // Find the main output chunk + const mainChunk = buildOutput!.output.find( + (o): o is Rollup.OutputChunk => o.type === "chunk" && o.isEntry + ); + expect(mainChunk).toBeDefined(); + + // The output should NOT contain raw `require(` calls (should use createRequire or be converted) + // We check for the specific pattern that causes issues: module.exports = require + expect(mainChunk!.code).not.toMatch(/module\.exports\s*=\s*require\(/); + + // The output should contain ESM syntax + expect(mainChunk!.code).toContain("export"); + }); + + it("should not cause 'Cannot determine intended module format' error", async () => { + const platform = process.platform; + const arch = process.arch; + + // Create native module package + const packageDir = path.join(tempDir, "node_modules", "native-addon"); + const prebuildsDir = path.join(packageDir, "prebuilds", `${platform}-${arch}`); + fs.mkdirSync(prebuildsDir, { recursive: true }); + + fs.writeFileSync( + path.join(prebuildsDir, "addon.node"), + Buffer.from("fake native module") + ); + + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ name: "native-addon", main: "index.js" }) + ); + + fs.writeFileSync( + path.join(packageDir, "index.js"), + `'use strict'; +module.exports = require('node-gyp-build')(__dirname); +` + ); + + // Create node-gyp-build + const nodeGypBuildDir = path.join(tempDir, "node_modules", "node-gyp-build"); + fs.mkdirSync(nodeGypBuildDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeGypBuildDir, "package.json"), + JSON.stringify({ name: "node-gyp-build", main: "index.js" }) + ); + fs.writeFileSync( + path.join(nodeGypBuildDir, "index.js"), + `module.exports = function(dir) { + const path = require('path'); + return require(path.join(dir, 'prebuilds', process.platform + '-' + process.arch, 'addon.node')); +};` + ); + + // Entry with top-level await (ESM feature that triggers the error) + const entryPath = path.join(tempDir, "index.mjs"); + fs.writeFileSync( + entryPath, + `import addon from 'native-addon'; + +// Top-level await - ESM only feature +const result = await Promise.resolve(addon); +console.log(result); + +export { addon }; +` + ); + + // Build with ESM output for Node.js target + let buildError: Error | null = null; + + try { + await build({ + root: tempDir, + logLevel: "silent", + build: { + write: false, + ssr: true, // Target Node.js, not browser + rollupOptions: { + input: entryPath, + output: { + format: "es", + }, + }, + }, + plugins: [nativeFilePlugin({ forced: true })], + }); + } catch (err) { + buildError = err as Error; + } + + // Should not throw the mixed module format error + if (buildError) { + expect(buildError.message).not.toContain("Cannot determine intended module format"); + expect(buildError.message).not.toContain("both require() and top-level await"); + } + }); + }); + + describe("ESM importer with CJS output", () => { + /** + * The reverse scenario: + * - An ESM file imports a native module + * - But the Vite build output format is CJS + * - The plugin should generate CJS code (require) not ESM (import.meta.url) + */ + it("should generate CJS code when output format is CJS, even if importer is ESM", async () => { + const platform = process.platform; + const arch = process.arch; + + // Create native module package + const packageDir = path.join(tempDir, "node_modules", "native-addon"); + const prebuildsDir = path.join(packageDir, "prebuilds", `${platform}-${arch}`); + fs.mkdirSync(prebuildsDir, { recursive: true }); + + fs.writeFileSync( + path.join(prebuildsDir, "addon.node"), + Buffer.from("fake native module") + ); + + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ name: "native-addon", main: "index.js" }) + ); + + fs.writeFileSync( + path.join(packageDir, "index.js"), + `'use strict'; +module.exports = require('node-gyp-build')(__dirname); +` + ); + + // Create node-gyp-build + const nodeGypBuildDir = path.join(tempDir, "node_modules", "node-gyp-build"); + fs.mkdirSync(nodeGypBuildDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeGypBuildDir, "package.json"), + JSON.stringify({ name: "node-gyp-build", main: "index.js" }) + ); + fs.writeFileSync( + path.join(nodeGypBuildDir, "index.js"), + `module.exports = function(dir) { + const path = require('path'); + return require(path.join(dir, 'prebuilds', process.platform + '-' + process.arch, 'addon.node')); +};` + ); + + // Create ESM entry (using import syntax) + const entryPath = path.join(tempDir, "index.mjs"); + fs.writeFileSync( + entryPath, + `import addon from 'native-addon'; +console.log(addon); +export { addon }; +` + ); + + // Build with CJS output format for Node.js target + let buildOutput: Rollup.RollupOutput | undefined; + let buildError: Error | null = null; + + try { + const result = await build({ + root: tempDir, + logLevel: "silent", + build: { + write: false, + ssr: true, // Target Node.js, not browser + rollupOptions: { + input: entryPath, + output: { + format: "cjs", // CJS output + }, + }, + }, + plugins: [nativeFilePlugin({ forced: true })], + }); + // When write: false, result is RollupOutput | RollupOutput[], not a watcher + const output = result as Rollup.RollupOutput | Rollup.RollupOutput[]; + buildOutput = Array.isArray(output) ? output[0] : output; + } catch (err) { + buildError = err as Error; + } + + // The build should succeed + expect(buildError).toBeNull(); + expect(buildOutput).toBeDefined(); + + // Find the main output chunk + const mainChunk = buildOutput!.output.find( + (o): o is Rollup.OutputChunk => o.type === "chunk" && o.isEntry + ); + expect(mainChunk).toBeDefined(); + + // The output should NOT contain import.meta.url (doesn't work in CJS) + expect(mainChunk!.code).not.toContain("import.meta.url"); + + // The output should use CJS syntax + expect(mainChunk!.code).toMatch(/require\(/); + }); + }); + + describe("lib mode format detection", () => { + it("should detect format from lib.formats when rollupOptions.output.format is not set", async () => { + const platform = process.platform; + const arch = process.arch; + + // Create native module package + const packageDir = path.join(tempDir, "node_modules", "native-addon"); + const prebuildsDir = path.join(packageDir, "prebuilds", `${platform}-${arch}`); + fs.mkdirSync(prebuildsDir, { recursive: true }); + + fs.writeFileSync( + path.join(prebuildsDir, "addon.node"), + Buffer.from("fake native module") + ); + + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ name: "native-addon", main: "index.js" }) + ); + + fs.writeFileSync( + path.join(packageDir, "index.js"), + `'use strict'; +module.exports = require('node-gyp-build')(__dirname); +` + ); + + // Create node-gyp-build + const nodeGypBuildDir = path.join(tempDir, "node_modules", "node-gyp-build"); + fs.mkdirSync(nodeGypBuildDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeGypBuildDir, "package.json"), + JSON.stringify({ name: "node-gyp-build", main: "index.js" }) + ); + fs.writeFileSync( + path.join(nodeGypBuildDir, "index.js"), + `module.exports = function(dir) { + const path = require('path'); + return require(path.join(dir, 'prebuilds', process.platform + '-' + process.arch, 'addon.node')); +};` + ); + + // Create entry + const entryPath = path.join(tempDir, "index.js"); + fs.writeFileSync( + entryPath, + `const addon = require('native-addon'); +module.exports = { addon }; +` + ); + + // Build using lib mode with cjs format for Node.js target + let buildOutput: Rollup.RollupOutput | Rollup.RollupOutput[] | undefined; + let buildError: Error | null = null; + + try { + const result = await build({ + root: tempDir, + logLevel: "silent", + build: { + write: false, + ssr: true, // Target Node.js, not browser + lib: { + entry: entryPath, + formats: ["cjs"], // CJS format via lib mode + }, + }, + plugins: [nativeFilePlugin({ forced: true })], + }); + // When write: false, result is RollupOutput | RollupOutput[], not a watcher + buildOutput = result as Rollup.RollupOutput | Rollup.RollupOutput[]; + } catch (err) { + buildError = err as Error; + } + + // The build should succeed + expect(buildError).toBeNull(); + expect(buildOutput).toBeDefined(); + + // Get the output (lib mode may return array) + const output = Array.isArray(buildOutput) ? buildOutput[0] : buildOutput; + const mainChunk = output!.output.find( + (o): o is Rollup.OutputChunk => o.type === "chunk" && o.isEntry + ); + expect(mainChunk).toBeDefined(); + + // The output should NOT contain import.meta.url (doesn't work in CJS) + expect(mainChunk!.code).not.toContain("import.meta.url"); + }); + }); +}); diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 29db105..2e615c6 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -580,13 +580,14 @@ describe("nativeFilePlugin", () => { }); describe("Module Loading", () => { - it("should load virtual modules with proper require code", async () => { + it("should load virtual modules with ESM code by default", async () => { const plugin = nativeFilePlugin() as Plugin; expect(plugin.configResolved).toBeDefined(); expect(plugin.resolveId).toBeDefined(); expect(plugin.load).toBeDefined(); + // Default config - no output format specified, defaults to ESM (plugin.configResolved as any)({ command: "build", mode: "production", @@ -610,13 +611,13 @@ describe("nativeFilePlugin", () => { const loadResult = await (plugin.load as any).call({} as any, virtualId); expect(loadResult).toBeDefined(); - // For CommonJS files (.js), should use module.exports - // For ES modules (.mjs), should use import/export - // Since importerPath is .js, it should be CommonJS - expect(loadResult).toContain("module.exports"); - expect(loadResult).toContain("require("); - - // Test ES module format with .mjs file + // Default output format is ESM, so should use ESM syntax regardless of importer + expect(loadResult).toContain("import { createRequire }"); + expect(loadResult).toContain("export default"); + expect(loadResult).toContain("import.meta.url"); + expect(loadResult).not.toContain("module.exports"); + + // Test with .mjs importer - should also use ESM (same default behavior) const esmImporterPath = path.join(tempDir, "index.mjs"); const esmVirtualId = await (plugin.resolveId as any).call( {} as any, @@ -628,7 +629,7 @@ describe("nativeFilePlugin", () => { expect(esmLoadResult).toBeDefined(); expect(esmLoadResult).toContain("import { createRequire }"); expect(esmLoadResult).toContain("export default"); - expect(esmLoadResult).toContain("createRequireLocal"); + expect(esmLoadResult).toContain("import.meta.url"); }); it("should return null for non-virtual modules", async () => { @@ -643,8 +644,9 @@ describe("nativeFilePlugin", () => { expect(result).toBeNull(); }); - it("should handle edge case: virtual module ID without tracked type defaults to CommonJS", async () => { + it("should default to ESM output format", async () => { const plugin = nativeFilePlugin() as Plugin; + // Default config - defaults to ESM output (plugin.configResolved as any)({ command: "build", mode: "production", @@ -665,18 +667,27 @@ describe("nativeFilePlugin", () => { const loadResult = await (plugin.load as any).call({} as any, virtualId); expect(loadResult).toBeDefined(); - - // Should default to CommonJS (safer than ES module) - expect(loadResult).toContain("module.exports"); - expect(loadResult).toContain("require("); - expect(loadResult).not.toContain("import { createRequire }"); + + // Default output format is ESM + expect(loadResult).toContain("import { createRequire }"); + expect(loadResult).toContain("export default"); + expect(loadResult).toContain("import.meta.url"); + expect(loadResult).not.toContain("module.exports"); }); - it("should not mix require() with import.meta.url in load hook output", async () => { + it("should use CJS output when explicitly configured", async () => { const plugin = nativeFilePlugin() as Plugin; + // Explicit CJS output format (plugin.configResolved as any)({ command: "build", mode: "production", + build: { + rollupOptions: { + output: { + format: "cjs", + }, + }, + }, }); const nodeFilePath = path.join(tempDir, "test.node"); @@ -691,14 +702,12 @@ describe("nativeFilePlugin", () => { ); const loadResult = await (plugin.load as any).call({} as any, virtualId); - - // Should NOT have both require() and import.meta.url - const hasRequire = loadResult.includes("require("); - const hasImportMeta = loadResult.includes("import.meta.url"); - - if (hasRequire) { - expect(hasImportMeta).toBe(false); - } + + // CJS output format should generate CommonJS syntax + expect(loadResult).toContain("module.exports"); + expect(loadResult).toContain("require("); + expect(loadResult).not.toContain("import { createRequire }"); + expect(loadResult).not.toContain("import.meta.url"); }); it("should not mix module.exports with export default in load hook output", async () => { diff --git a/test/synthetic-exports-integration.test.ts b/test/synthetic-exports-integration.test.ts new file mode 100644 index 0000000..ee186a8 --- /dev/null +++ b/test/synthetic-exports-integration.test.ts @@ -0,0 +1,337 @@ +import { describe, expect, it, beforeEach, afterEach } from "vitest"; +import nativeFilePlugin from "../src/index.js"; +import { build } from "vite"; +import fs from "node:fs"; +import path from "node:path"; +import os from "node:os"; + +/** + * Integration tests for syntheticNamedExports handling. + * + * These tests run actual Vite/Rollup builds to verify the plugin doesn't + * cause Rollup errors related to syntheticNamedExports, particularly: + * + * "Module that is marked with `syntheticNamedExports: true` needs a default + * export that does not reexport an unresolved named export of the same module." + * + * This error occurs when: + * 1. A module is marked with syntheticNamedExports: true + * 2. Another module uses `export * from` to re-export from it + * 3. The synthetic exports can't be resolved at bundle time + */ +describe("syntheticNamedExports integration", () => { + let tempDir: string; + + beforeEach(() => { + tempDir = fs.mkdtempSync( + path.join(os.tmpdir(), "synthetic-exports-test-") + ); + }); + + afterEach(() => { + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe("bufferutil-style pattern (node-gyp-build)", () => { + /** + * This test simulates the bufferutil package structure: + * - index.js uses require('node-gyp-build')(__dirname) + * - prebuilds/darwin-arm64/bufferutil.node is the native module + * + * Previously this would error when bundled with syntheticNamedExports: true + */ + it("should build without syntheticNamedExports error for node-gyp-build pattern", async () => { + const platform = process.platform; + const arch = process.arch; + + // Create bufferutil-like package structure + const packageDir = path.join(tempDir, "node_modules", "bufferutil"); + const prebuildsDir = path.join(packageDir, "prebuilds", `${platform}-${arch}`); + fs.mkdirSync(prebuildsDir, { recursive: true }); + + // Create the native module + fs.writeFileSync( + path.join(prebuildsDir, "bufferutil.node"), + Buffer.from("fake native module") + ); + + // Create package.json + fs.writeFileSync( + path.join(packageDir, "package.json"), + JSON.stringify({ + name: "bufferutil", + main: "index.js", + }) + ); + + // Create index.js that uses node-gyp-build pattern + fs.writeFileSync( + path.join(packageDir, "index.js"), + `'use strict'; +try { + module.exports = require('node-gyp-build')(__dirname); +} catch (e) { + module.exports = { mask: function() {}, unmask: function() {} }; +} +` + ); + + // Create a fake node-gyp-build module + const nodeGypBuildDir = path.join(tempDir, "node_modules", "node-gyp-build"); + fs.mkdirSync(nodeGypBuildDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeGypBuildDir, "package.json"), + JSON.stringify({ name: "node-gyp-build", main: "index.js" }) + ); + fs.writeFileSync( + path.join(nodeGypBuildDir, "index.js"), + `module.exports = function(dir) { + const path = require('path'); + const platform = process.platform; + const arch = process.arch; + return require(path.join(dir, 'prebuilds', platform + '-' + arch, 'bufferutil.node')); +};` + ); + + // Create main entry point that imports bufferutil + const entryPath = path.join(tempDir, "index.js"); + fs.writeFileSync( + entryPath, + `const bufferutil = require('bufferutil'); +console.log(bufferutil); +` + ); + + // Run Vite build + let buildError: Error | null = null; + try { + await build({ + root: tempDir, + logLevel: "silent", + build: { + write: false, + rollupOptions: { + input: entryPath, + }, + lib: { + entry: entryPath, + formats: ["cjs"], + }, + }, + plugins: [nativeFilePlugin({ forced: true })], + }); + } catch (err) { + buildError = err as Error; + } + + // The build should succeed without the syntheticNamedExports error + if (buildError) { + expect(buildError.message).not.toContain("syntheticNamedExports"); + expect(buildError.message).not.toContain( + "needs a default export that does not reexport" + ); + } + }); + + /** + * Test with export * re-export pattern that might trigger the error + */ + it("should build without error when module with native addon is re-exported with export *", async () => { + const platform = process.platform; + const arch = process.arch; + + // Create native-addon package + const addonDir = path.join(tempDir, "node_modules", "native-addon"); + const prebuildsDir = path.join(addonDir, "prebuilds", `${platform}-${arch}`); + fs.mkdirSync(prebuildsDir, { recursive: true }); + + fs.writeFileSync( + path.join(prebuildsDir, "addon.node"), + Buffer.from("fake native module") + ); + + fs.writeFileSync( + path.join(addonDir, "package.json"), + JSON.stringify({ + name: "native-addon", + main: "index.js", + }) + ); + + fs.writeFileSync( + path.join(addonDir, "index.js"), + `'use strict'; +try { + module.exports = require('node-gyp-build')(__dirname); +} catch (e) { + module.exports = { doSomething: function() {} }; +} +` + ); + + // Create node-gyp-build + const nodeGypBuildDir = path.join(tempDir, "node_modules", "node-gyp-build"); + fs.mkdirSync(nodeGypBuildDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeGypBuildDir, "package.json"), + JSON.stringify({ name: "node-gyp-build", main: "index.js" }) + ); + fs.writeFileSync( + path.join(nodeGypBuildDir, "index.js"), + `module.exports = function(dir) { + const path = require('path'); + const platform = process.platform; + const arch = process.arch; + return require(path.join(dir, 'prebuilds', platform + '-' + arch, 'addon.node')); +};` + ); + + // Create a wrapper module that re-exports with export * + const wrapperDir = path.join(tempDir, "node_modules", "addon-wrapper"); + fs.mkdirSync(wrapperDir, { recursive: true }); + fs.writeFileSync( + path.join(wrapperDir, "package.json"), + JSON.stringify({ + name: "addon-wrapper", + main: "index.js", + type: "module", + }) + ); + // This pattern can trigger the syntheticNamedExports error + fs.writeFileSync( + path.join(wrapperDir, "index.js"), + `export * from 'native-addon'; +export { default } from 'native-addon'; +` + ); + + // Create entry point + const entryPath = path.join(tempDir, "index.mjs"); + fs.writeFileSync( + entryPath, + `import addon from 'addon-wrapper'; +console.log(addon); +` + ); + + let buildError: Error | null = null; + try { + await build({ + root: tempDir, + logLevel: "silent", + build: { + write: false, + rollupOptions: { + input: entryPath, + }, + lib: { + entry: entryPath, + formats: ["es"], + }, + }, + plugins: [nativeFilePlugin({ forced: true })], + }); + } catch (err) { + buildError = err as Error; + } + + if (buildError) { + // Check it's not the syntheticNamedExports error + expect(buildError.message).not.toContain("syntheticNamedExports"); + expect(buildError.message).not.toContain( + "needs a default export that does not reexport" + ); + } + }); + + /** + * Test with named import destructuring pattern + */ + it("should build successfully with named import destructuring from native module", async () => { + const platform = process.platform; + const arch = process.arch; + + // Create native package + const nativeDir = path.join(tempDir, "node_modules", "my-native"); + const prebuildsDir = path.join(nativeDir, "prebuilds", `${platform}-${arch}`); + fs.mkdirSync(prebuildsDir, { recursive: true }); + + fs.writeFileSync( + path.join(prebuildsDir, "native.node"), + Buffer.from("fake native module") + ); + + fs.writeFileSync( + path.join(nativeDir, "package.json"), + JSON.stringify({ name: "my-native", main: "index.js" }) + ); + + fs.writeFileSync( + path.join(nativeDir, "index.js"), + `'use strict'; +try { + module.exports = require('node-gyp-build')(__dirname); +} catch (e) { + module.exports = { foo: function() {}, bar: function() {} }; +} +` + ); + + // Create node-gyp-build + const nodeGypBuildDir = path.join(tempDir, "node_modules", "node-gyp-build"); + fs.mkdirSync(nodeGypBuildDir, { recursive: true }); + fs.writeFileSync( + path.join(nodeGypBuildDir, "package.json"), + JSON.stringify({ name: "node-gyp-build", main: "index.js" }) + ); + fs.writeFileSync( + path.join(nodeGypBuildDir, "index.js"), + `module.exports = function(dir) { + const path = require('path'); + const platform = process.platform; + const arch = process.arch; + return require(path.join(dir, 'prebuilds', platform + '-' + arch, 'native.node')); +};` + ); + + // Entry point with destructuring + const entryPath = path.join(tempDir, "index.js"); + fs.writeFileSync( + entryPath, + `const { foo, bar } = require('my-native'); +console.log(foo, bar); +` + ); + + let buildError: Error | null = null; + try { + await build({ + root: tempDir, + logLevel: "silent", + build: { + write: false, + rollupOptions: { + input: entryPath, + }, + lib: { + entry: entryPath, + formats: ["cjs"], + }, + }, + plugins: [nativeFilePlugin({ forced: true })], + }); + } catch (err) { + buildError = err as Error; + } + + if (buildError) { + expect(buildError.message).not.toContain("syntheticNamedExports"); + expect(buildError.message).not.toContain( + "needs a default export that does not reexport" + ); + } + }); + }); +});