Skip to content

Commit 9f6226e

Browse files
biwclaude
andauthored
Detect output format from Vite config, not importer format (#3)
* Detect output format from Vite config, not importer format Changes module format detection from importer-based (was it ESM or CJS?) to output-based (what format is Vite building for?). This fixes the bug where CJS files importing native modules in an ESM build output cause "Cannot determine intended module format" errors. Also adds comprehensive test coverage and documentation about multi-format build limitations. --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent c8afadd commit 9f6226e

File tree

9 files changed

+1012
-155
lines changed

9 files changed

+1012
-155
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "vite-plugin-native-modules",
3-
"version": "2.2.0",
3+
"version": "2.2.1",
44
"description": "A Vite plugin for integrating Node.js native modules into your Vite project",
55
"author": "Ben Williams",
66
"license": "MIT",

src/index.ts

Lines changed: 55 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,9 @@ export default function nativeFilePlugin(
135135
// Reverse mapping from hashed filename to original file path
136136
// Used to resolve transformed bindings/node-gyp-build calls
137137
const hashedFilenameToPath = new Map<string, string>();
138-
// Track module type (ES module vs CommonJS) for virtual modules
139-
// Maps virtual module ID to whether it's an ES module
140-
const virtualModuleTypes = new Map<string, boolean>();
138+
// Track the output format from Vite config
139+
// This determines whether we generate ESM or CJS code in the load hook
140+
let outputFormat: "es" | "cjs" = "es"; // Default to ESM (Vite's default)
141141
let command: "build" | "serve" = "build";
142142

143143
// Helper function to detect if a file is an ES module based on extension and content
@@ -581,6 +581,35 @@ export default function nativeFilePlugin(
581581
return {
582582
configResolved(config) {
583583
command = config.command;
584+
585+
// Detect output format from Vite config
586+
// Priority: rollupOptions.output.format > lib.formats > default (es)
587+
//
588+
// LIMITATION: For multi-format builds (e.g., lib.formats: ['es', 'cjs']), this
589+
// only uses the first format. Rollup's load hook is called once per module, not
590+
// per output format. In practice, the ESM pattern (createRequire + import.meta.url)
591+
// works correctly in both ESM and CJS outputs because Rollup handles the conversion.
592+
const rollupOutput = config.build?.rollupOptions?.output;
593+
if (rollupOutput) {
594+
// rollupOptions.output can be an object or array of objects
595+
const format = Array.isArray(rollupOutput)
596+
? rollupOutput[0]?.format
597+
: rollupOutput.format;
598+
if (format === "cjs" || format === "commonjs") {
599+
outputFormat = "cjs";
600+
} else {
601+
outputFormat = "es";
602+
}
603+
} else if (config.build?.lib) {
604+
// lib mode - use first format
605+
// lib can be false or LibraryOptions, check for formats property
606+
const lib = config.build.lib;
607+
if (typeof lib === "object" && lib.formats) {
608+
const formats = lib.formats;
609+
outputFormat = formats[0] === "cjs" ? "cjs" : "es";
610+
}
611+
}
612+
// Otherwise keep default 'es' (Vite's default for modern builds)
584613
},
585614

586615
generateBundle() {
@@ -602,53 +631,23 @@ export default function nativeFilePlugin(
602631

603632
if (!info) return null;
604633

605-
// Check if this virtual module is being loaded in an ES module context
606-
// Try to get the tracked module type first
607-
let isESModule = virtualModuleTypes.get(id);
608-
609-
// If not tracked, try to detect from the original path or use getModuleInfo if available
610-
if (isESModule === undefined) {
611-
// Try to detect from file extension as fallback
612-
isESModule = detectModuleType(originalPath);
613-
614-
// If getModuleInfo is available, try to use it (though it may not work for virtual modules)
615-
try {
616-
if (typeof this.getModuleInfo === "function") {
617-
const moduleInfo = this.getModuleInfo(id);
618-
const format = (moduleInfo as { format?: string }).format;
619-
if (moduleInfo && format) {
620-
isESModule = format === "es";
621-
}
622-
}
623-
} catch {
624-
// Ignore errors, use fallback detection
625-
}
626-
627-
// If still undefined, default to CommonJS (safer than ES module)
628-
// This prevents mixing require() with import.meta.url
629-
if (isESModule === undefined) {
630-
isESModule = false;
631-
}
632-
}
633-
634-
// Return proxy code that requires the hashed file
635-
// The hashed file will be in the same directory as the output bundle
636-
//
637-
// We use syntheticNamedExports (set in resolveId) to tell Rollup to resolve
638-
// any named export requests from the default export's properties.
639-
// This way, `const { databaseOpen } = require(...)` works correctly
640-
// because Rollup gets databaseOpen from default.databaseOpen.
641-
if (isESModule) {
634+
// Generate code based on OUTPUT format, not the importer's format.
635+
// This is important because:
636+
// 1. Using CJS require() in an ESM output causes "Cannot determine intended
637+
// module format because both require() and top-level await are present"
638+
// 2. Using import.meta.url in a CJS output doesn't work
639+
// 3. The output format is what matters for the final bundled code
640+
if (outputFormat === "es") {
642641
return `
643-
import { createRequire } from 'node:module';
644-
const createRequireLocal = createRequire(import.meta.url);
645-
const nativeModule = createRequireLocal('./${info.hashedFilename}');
646-
export default nativeModule;
647-
`;
642+
import { createRequire } from 'node:module';
643+
const __require = createRequire(import.meta.url);
644+
const nativeModule = __require('./${info.hashedFilename}');
645+
export default nativeModule;
646+
`;
648647
} else {
649648
return `
650-
module.exports = require('./${info.hashedFilename}');
651-
`;
649+
module.exports = require('./${info.hashedFilename}');
650+
`;
652651
}
653652
},
654653

@@ -673,13 +672,15 @@ export default function nativeFilePlugin(
673672
// Check if this matches a hashed filename we've generated
674673
if (hashedFilenameToPath.has(basename)) {
675674
const originalPath = hashedFilenameToPath.get(basename)!;
676-
const importingModuleType = detectModuleTypeWithContext(this, importer);
677675
const virtualId = `\0native:${originalPath}`;
678-
virtualModuleTypes.set(virtualId, importingModuleType);
679-
// Return virtual module ID with syntheticNamedExports enabled
680-
// This tells Rollup to resolve named exports from the default export's properties,
681-
// which fixes the getAugmentedNamespace issue where destructuring fails
682-
// because properties like databaseOpen aren't copied to the namespace.
676+
677+
// Use syntheticNamedExports to enable named import/destructuring patterns
678+
// like `const { databaseOpen } = require('native-module')` or `import { foo } from 'native'`.
679+
// This tells Rollup to derive named exports from the default export's properties.
680+
//
681+
// Note: This is incompatible with `export * from 'native-module'` patterns because
682+
// Rollup cannot enumerate synthetic exports at bundle time. If you encounter errors
683+
// with export * re-exports, consider restructuring to use named imports instead.
683684
return {
684685
id: virtualId,
685686
syntheticNamedExports: true,
@@ -698,11 +699,8 @@ export default function nativeFilePlugin(
698699
// Register the native file (generates hash, stores mapping)
699700
registerNativeFile(resolved);
700701

701-
// Track module type and return virtual module ID
702-
const importingModuleType = detectModuleTypeWithContext(this, importer);
702+
// Return virtual module ID
703703
const virtualId = `\0native:${resolved}`;
704-
virtualModuleTypes.set(virtualId, importingModuleType);
705-
706704
return virtualId;
707705
},
708706

test/bindings.test.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -839,8 +839,9 @@ export { addon };`;
839839
expect(loadResult).not.toContain("module.exports");
840840
});
841841

842-
it("should generate CommonJS code in load hook for bindings in .js file", async () => {
842+
it("should generate ESM code in load hook by default (regardless of importer format)", async () => {
843843
const plugin = nativeFilePlugin() as Plugin;
844+
// Default config - no output format specified, defaults to ESM
844845
(plugin.configResolved as any)({
845846
command: "build",
846847
mode: "production",
@@ -881,10 +882,11 @@ module.exports = { addon };`;
881882
const virtualId = typeof resolveResult === "object" ? resolveResult.id : resolveResult;
882883
const loadResult = await (plugin.load as any).call({} as any, virtualId);
883884
expect(loadResult).toBeDefined();
884-
expect(loadResult).toContain("module.exports");
885-
expect(loadResult).toContain("require(");
886-
expect(loadResult).not.toContain("import { createRequire }");
887-
expect(loadResult).not.toContain("export default");
885+
// Default output format is ESM, so should generate ESM syntax
886+
expect(loadResult).toContain("import { createRequire }");
887+
expect(loadResult).toContain("export default");
888+
expect(loadResult).toContain("import.meta.url");
889+
expect(loadResult).not.toContain("module.exports");
888890
});
889891
});
890892
});

0 commit comments

Comments
 (0)