Skip to content

Commit e901abd

Browse files
feat: support native .node bundling (#68)
Co-authored-by: Hiroki Osame <hiroki.osame@gmail.com>
1 parent cdccbbc commit e901abd

8 files changed

Lines changed: 274 additions & 12 deletions

File tree

README.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,40 @@ Sometimes it's useful to use `require()` or `require.resolve()` in ESM. ESM code
249249

250250
When compiling to ESM, _Pkgroll_ detects `require()` usages and shims it with [`createRequire(import.meta.url)`](https://nodejs.org/api/module.html#modulecreaterequirefilename).
251251

252+
### Native modules
253+
254+
_pkgroll_ automatically handles native Node.js addons (`.node` files) when you directly import them:
255+
256+
```js
257+
// src/index.js
258+
import nativeAddon from './native.node'
259+
```
260+
261+
After bundling, the `.node` file will be copied to `dist/natives/` and the import will be automatically rewritten to load from the correct location at runtime.
262+
263+
> [!NOTE]
264+
> - Native modules are platform and architecture-specific. Make sure to distribute the correct `.node` files for your target platforms.
265+
> - This only works with direct `.node` imports. If you're using packages that dynamically load native modules via `bindings` or `node-pre-gyp`, you'll need to handle them separately.
266+
267+
#### Handling dependencies with native modules
268+
269+
If you're using packages with native modules (like `chokidar` which depends on `fsevents`):
270+
271+
- **If in `dependencies`/`peerDependencies`**: ✅ Works automatically - these are externalized (not bundled)
272+
- **If in `devDependencies`**: ⚠️ Will be bundled. If they use `bindings()` or `node-pre-gyp` patterns, move them to `dependencies` instead, or use `optionalDependencies` if they're optional.
273+
274+
Example - if you have `chokidar` in `devDependencies` and get build errors, move it to `dependencies`:
275+
276+
```json
277+
{
278+
"dependencies": {
279+
"chokidar": "^3.0.0"
280+
}
281+
}
282+
```
283+
284+
This externalizes it (users will need to install it), avoiding the need to bundle its native modules.
285+
252286
### Environment variables
253287
Pass in compile-time environment variables with the `--env` flag.
254288

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,11 @@
6565
"cleye": "^1.3.4",
6666
"estree-walker": "^3.0.3",
6767
"execa": "9.6.0",
68-
"fs-fixture": "^2.8.1",
68+
"fs-fixture": "^2.9.0",
6969
"get-node": "^15.0.4",
7070
"get-tsconfig": "^4.10.1",
7171
"kolorist": "^1.8.0",
72-
"lintroll": "^1.19.1",
72+
"lintroll": "^1.20.1",
7373
"manten": "^1.5.0",
7474
"outdent": "^0.8.0",
7575
"react": "^19.1.1",

pnpm-lock.yaml

Lines changed: 10 additions & 10 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/rollup/configs/pkg.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { resolveJsToTs } from '../plugins/resolve-js-to-ts.js';
1414
import { resolveTsconfigPaths } from '../plugins/resolve-tsconfig-paths.js';
1515
import { stripHashbang } from '../plugins/strip-hashbang.js';
1616
import { esmInjectCreateRequire } from '../plugins/esm-inject-create-require.js';
17+
import { nativeModules } from '../plugins/native-modules.js';
1718
import type { Options, Output } from '../types.js';
1819
import type { EntryPointValid } from '../../utils/get-entry-points/types.js';
1920
import { cjsAnnotateExports } from '../plugins/cjs-annotate-exports.js';
@@ -23,6 +24,7 @@ export const getPkgConfig = (
2324
aliases: AliasMap,
2425
entryPoints: EntryPointValid[],
2526
tsconfig: TsConfigResult | null,
27+
distDirectory: string,
2628
) => {
2729
const env = Object.fromEntries(
2830
options.env.map(({ key, value }) => [`process.env.${key}`, JSON.stringify(value)]),
@@ -71,6 +73,7 @@ export const getPkgConfig = (
7173
warnOnError: true,
7274
}),
7375
esmInjectCreateRequire(),
76+
nativeModules(distDirectory),
7477
...(
7578
options.minify
7679
? [esbuildMinify(esbuildConfig)]

src/rollup/get-rollup-configs.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,11 +96,14 @@ export const getRollupConfigs = async (
9696

9797
let config = configs.pkg;
9898
if (!config) {
99+
// Use the first dist directory for shared assets (chunks, natives)
100+
const firstDistDirectory = srcdistPairs[0].dist;
99101
config = getPkgConfig(
100102
flags,
101103
aliases,
102104
entryPoints,
103105
tsconfig,
106+
firstDistDirectory,
104107
);
105108
config.external = getExternalDependencies(packageJson, aliases);
106109
configs.pkg = config;
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import fs from 'node:fs/promises';
2+
import path from 'node:path';
3+
import type { Plugin } from 'rollup';
4+
5+
const PREFIX = '\0natives:';
6+
7+
/**
8+
* Handles native Node.js addons (.node files)
9+
* - Stage 1 (resolve/load): Identifies .node files and generates runtime code.
10+
* - Stage 2 (generateBundle): Copies the identified .node files to the output dir.
11+
*/
12+
export const nativeModules = (
13+
distDirectory: string,
14+
): Plugin => {
15+
const nativeLibsDirectory = `${distDirectory}/natives`;
16+
// Map<original_path, final_destination_path>
17+
const modulesToCopy = new Map<string, string>();
18+
19+
return {
20+
name: 'native-modules',
21+
22+
buildStart: () => {
23+
modulesToCopy.clear();
24+
},
25+
26+
async resolveId(source, importer) {
27+
if (source.startsWith(PREFIX) || !source.endsWith('.node')) {
28+
return null;
29+
}
30+
31+
const resolvedPath = importer
32+
? path.resolve(path.dirname(importer), source)
33+
: path.resolve(source);
34+
35+
try {
36+
await fs.access(resolvedPath);
37+
} catch {
38+
this.warn(`Native module not found: ${resolvedPath}`);
39+
return null;
40+
}
41+
42+
const basename = path.basename(resolvedPath);
43+
let outputName = basename;
44+
let counter = 1;
45+
46+
// Handle name collisions by checking already staged values
47+
const stagedBasenames = new Set(
48+
Array.from(modulesToCopy.values()).map(p => path.basename(p)),
49+
);
50+
while (stagedBasenames.has(outputName)) {
51+
const extension = path.extname(basename);
52+
const name = path.basename(basename, extension);
53+
outputName = `${name}_${counter}${extension}`;
54+
counter += 1;
55+
}
56+
57+
const destinationPath = path.join(nativeLibsDirectory, outputName);
58+
modulesToCopy.set(resolvedPath, destinationPath);
59+
60+
// Return a virtual module ID containing the original path
61+
return PREFIX + resolvedPath;
62+
},
63+
64+
load(id) {
65+
if (!id.startsWith(PREFIX)) {
66+
return null;
67+
}
68+
69+
const originalPath = id.slice(PREFIX.length);
70+
const destinationPath = modulesToCopy.get(originalPath);
71+
72+
if (!destinationPath) {
73+
// Should not happen if resolveId ran correctly
74+
return this.error(`Could not find staged native module for: ${originalPath}`);
75+
}
76+
77+
// Generate the require path relative to the final bundle directory
78+
const relativePath = `./${path.relative(distDirectory, destinationPath)}`;
79+
80+
return `export default require("${relativePath.replaceAll('\\', '/')}");`;
81+
},
82+
83+
generateBundle: async () => {
84+
if (modulesToCopy.size === 0) {
85+
return;
86+
}
87+
88+
// Create the directory once.
89+
await fs.mkdir(nativeLibsDirectory, { recursive: true });
90+
91+
// Copy all staged files in parallel.
92+
await Promise.all(
93+
Array.from(modulesToCopy.entries()).map(
94+
([source, destination]) => fs.copyFile(source, destination),
95+
),
96+
);
97+
},
98+
};
99+
};

tests/specs/builds/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export default testSuite(({ describe }, nodePath: string) => {
88
runTestSuite(import('./output-types.js'), nodePath);
99
runTestSuite(import('./env.js'), nodePath);
1010
runTestSuite(import('./define.js'), nodePath);
11+
runTestSuite(import('./native-modules.js'), nodePath);
1112
runTestSuite(import('./target.js'), nodePath);
1213
runTestSuite(import('./minification.js'), nodePath);
1314
runTestSuite(import('./package-exports.js'), nodePath);
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { testSuite, expect } from 'manten';
2+
import { createFixture } from 'fs-fixture';
3+
import { pkgroll } from '../../utils.js';
4+
import { createPackageJson } from '../../fixtures.js';
5+
6+
export default testSuite(({ describe }, nodePath: string) => {
7+
describe('native modules', ({ test }) => {
8+
test('ESM: copies .node files to natives directory', async () => {
9+
await using fixture = await createFixture({
10+
'package.json': createPackageJson({
11+
type: 'module',
12+
main: './dist/index.mjs',
13+
}),
14+
'src/index.js': `
15+
import native from './native.node';
16+
console.log(native);
17+
`,
18+
'src/native.node': Buffer.from('dummy native module'),
19+
});
20+
21+
const pkgrollProcess = await pkgroll([], {
22+
cwd: fixture.path,
23+
nodePath,
24+
});
25+
26+
expect(pkgrollProcess.exitCode).toBe(0);
27+
expect(pkgrollProcess.stderr).toBe('');
28+
29+
// Check that natives directory was created
30+
expect(await fixture.exists('dist/natives')).toBe(true);
31+
32+
// Check that .node file was copied
33+
const files = await fixture.readdir('dist/natives');
34+
expect(files.some(file => file.endsWith('.node'))).toBe(true);
35+
36+
// Check that import was rewritten and uses createRequire for ESM
37+
const content = await fixture.readFile('dist/index.mjs', 'utf8');
38+
expect(content).toMatch('./natives');
39+
expect(content).toMatch('createRequire');
40+
});
41+
42+
test('CJS: copies .node files to natives directory', async () => {
43+
await using fixture = await createFixture({
44+
'package.json': createPackageJson({
45+
type: 'commonjs',
46+
main: './dist/index.cjs',
47+
}),
48+
'src/index.js': `
49+
import native from './native.node';
50+
console.log(native);
51+
`,
52+
'src/native.node': Buffer.from('dummy native module'),
53+
});
54+
55+
const pkgrollProcess = await pkgroll([], {
56+
cwd: fixture.path,
57+
nodePath,
58+
});
59+
60+
expect(pkgrollProcess.exitCode).toBe(0);
61+
expect(pkgrollProcess.stderr).toBe('');
62+
63+
// Check that natives directory was created
64+
expect(await fixture.exists('dist/natives')).toBe(true);
65+
66+
// Check that .node file was copied
67+
const files = await fixture.readdir('dist/natives');
68+
expect(files.some(file => file.endsWith('.node'))).toBe(true);
69+
70+
// Check that import was transformed to require for CJS
71+
const content = await fixture.readFile('dist/index.cjs', 'utf8');
72+
expect(content).toMatch('./natives');
73+
expect(content).toMatch('require');
74+
// Should NOT have createRequire in CJS output
75+
expect(content).not.toMatch('createRequire');
76+
});
77+
78+
test('handles multiple src:dist pairs', async () => {
79+
await using fixture = await createFixture({
80+
'package.json': createPackageJson({
81+
exports: {
82+
'./a': './dist-a/index.js',
83+
'./b': './dist-b/index.js',
84+
},
85+
}),
86+
'src-a/index.js': `
87+
import native from './native-a.node';
88+
console.log(native);
89+
`,
90+
'src-a/native-a.node': Buffer.from('native module a'),
91+
'src-b/index.js': `
92+
import native from './native-b.node';
93+
console.log(native);
94+
`,
95+
'src-b/native-b.node': Buffer.from('native module b'),
96+
});
97+
98+
const pkgrollProcess = await pkgroll([
99+
'--srcdist',
100+
'src-a:dist-a',
101+
'--srcdist',
102+
'src-b:dist-b',
103+
], {
104+
cwd: fixture.path,
105+
nodePath,
106+
});
107+
108+
expect(pkgrollProcess.exitCode).toBe(0);
109+
expect(pkgrollProcess.stderr).toBe('');
110+
111+
// Should create natives in the first dist directory (like shared chunks)
112+
expect(await fixture.exists('dist-a/natives')).toBe(true);
113+
expect(await fixture.exists('dist-b/natives')).toBe(false);
114+
115+
// Check both .node files were copied to first dist
116+
const files = await fixture.readdir('dist-a/natives');
117+
expect(files.length).toBeGreaterThanOrEqual(2);
118+
expect(files.some(file => file.includes('native-a'))).toBe(true);
119+
expect(files.some(file => file.includes('native-b'))).toBe(true);
120+
});
121+
});
122+
});

0 commit comments

Comments
 (0)