Skip to content
Closed
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
0a106e5
feat(babel): add 2 params to requireNodeAddon(); respect package entr…
May 20, 2025
6725125
refactor: extract `findNodeAddonForBindings()`
May 20, 2025
c64891f
feat: add shortcut to `isNodeApiModule()`
May 20, 2025
2d17eb6
feat: relax the condition for CJS modules without file exts
May 21, 2025
8bb3e77
style: address linter issues
May 21, 2025
0ed5259
feat: extract path normalization from `determineModuleContext()`
Jun 4, 2025
4dcf2fd
feat: update tests to cover `determineNormalizedModuleContext()`
Jun 4, 2025
9bb9eec
feat: add test for scoped package names
Jun 4, 2025
4154615
feat: update tests for 3 arg `requireNodeAddon()`
Jun 4, 2025
ee87dd0
feat: remove xcframework dir from test code
Jun 4, 2025
a60c848
feat: change the third param to original path
Jun 4, 2025
41d4ebb
feat: add test case for require with "main" entry point
Jun 4, 2025
5596273
fix: remove unused `requiredFrom` parameter
Jun 4, 2025
d23fe0b
fix: remove unsupported babel option "naming"
Jun 4, 2025
63725af
feat: add tests for `findNodeAddonsForBindings()`
Jun 4, 2025
25b55da
fix: ensure that module paths use only forward slash (esp. on Windows)
Jun 4, 2025
e5c28a7
feat: add test case for addon that "escaped" its package
Jun 4, 2025
7106775
feat: make `resolvedPath` optional as suggested by Kraen
Jun 11, 2025
47db302
feat: run each test case as separate call to `it()`
Jun 11, 2025
8e1c278
chore: move `findNodeAddonForBindings()` to path-utils
Jun 11, 2025
4aa8549
chore: rename `originalPath` parameter to `originalId`
Jun 11, 2025
a431e8e
chore: replace `[].forEach()` with for-of
Jun 11, 2025
446b1a4
feat: move path slash normalization to determineModuleContext()
Jun 11, 2025
cdf1316
chore: use more descriptive variable names
Jun 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,112 +4,140 @@ import path from "node:path";

import { transformFileSync } from "@babel/core";

import { plugin } from "./plugin.js";
import { plugin, findNodeAddonForBindings, type PluginOptions } from "./plugin.js";
import { setupTempDirectory } from "../test-utils.js";
import { getLibraryName } from "../path-utils.js";

describe("plugin", () => {
it("transforms require calls, regardless", (context) => {
describe("transforms require calls, regardless", () => {
const EXPECTED_PKG_NAME = "my-package";

type TestCaseParams = {
resolvedPath?: string;
originalPath: string;
inputFile: string;
};

([
{ resolvedPath: "./addon-1.node", originalPath: "./addon-1.node", inputFile: "./addon-1.js" },
{ resolvedPath: "./addon-2.node", originalPath: "./addon-2.node", inputFile: "./addon-2.js" },
{ resolvedPath: "./addon-1.node", originalPath: "../addon-1.node", inputFile: "./sub-directory/addon-1.js" },
{ resolvedPath: "./addon-2.node", originalPath: "../addon-2.node", inputFile: "./sub-directory-3/addon-outside.js" },
{ resolvedPath: "./addon-1.node", originalPath: "addon-1", inputFile: "./addon-1-bindings.js" },
{ resolvedPath: undefined, originalPath: "./addon-1.js", inputFile: "./require-js-file.js" },
] as TestCaseParams[]).forEach(({ resolvedPath, originalPath, inputFile }) => {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for-of loop please 😊 I know it always does, but nothing actually guaranties that the callback will be called synchronously.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also, I'd suggest using satisfies instead of as in a place like this.

const expectedMessage = resolvedPath
? `transform to requireNodeAddon() with "${resolvedPath}"`
: "NOT transform to requireNodeAddon()";

it(`${inputFile} should ${expectedMessage}`, (context) => {
const tempDirectoryPath = setupTempDirectory(context, {
"package.json": `{ "name": "${EXPECTED_PKG_NAME}" }`,
"addon-1.node":
"// This is supposed to be a binary file",
"addon-2.node":
"// This is supposed to be a binary file",
"addon-1.js": `
const addon = require('./addon-1.node');
console.log(addon);
`,
"addon-2.js": `
const addon = require('./addon-2.node');
console.log(addon);
`,
"sub-directory/addon-1.js": `
const addon = require('../addon-1.node');
console.log(addon);
`,
"sub-directory-3/package.json": `{ "name": "sub-package" }`,
"sub-directory-3/addon-outside.js": `
const addon = require('../addon-2.node');
console.log(addon);
`,
"addon-1-bindings.js": `
const addon = require('bindings')('addon-1');
console.log(addon);
`,
"require-js-file.js": `
const addon = require('./addon-1.js');
console.log(addon);
`,
});
const result = transformFileSync(
path.join(tempDirectoryPath, inputFile),
{ plugins: [[plugin, {}]] }
);
assert(result);
const { code } = result;
if (!resolvedPath) {
assert(
code && !code.includes(`requireNodeAddon`),
`Unexpected code: ${code}`
);
} else {
assert(
code && code.includes(`requireNodeAddon("${resolvedPath}", "${EXPECTED_PKG_NAME}", "${originalPath}")`),
`Unexpected code: ${code}`
);
}
});
});
});

it("transforms require calls to packages with native entry point", (context) => {
const tempDirectoryPath = setupTempDirectory(context, {
"package.json": `{ "name": "my-package" }`,
"addon-1.apple.node/addon-1.node":
"// This is supposed to be a binary file",
"addon-2.apple.node/addon-2.node":
"node_modules/@scope/my-package/package.json":
`{ "name": "@scope/my-package", "main": "./build/Release/addon-1.node" }`,
"node_modules/@scope/my-package/build/Release/addon-1.node":
"// This is supposed to be a binary file",
"addon-1.js": `
const addon = require('./addon-1.node');
"package.json": `{ "name": "my-consumer" }`,
"test.js": `
const addon = require('@scope/my-package');
console.log(addon);
`,
"addon-2.js": `
const addon = require('./addon-2.node');
console.log(addon);
`,
"sub-directory/addon-1.js": `
const addon = require('../addon-1.node');
console.log(addon);
`,
"addon-1-bindings.js": `
const addon = require('bindings')('addon-1');
console.log(addon);
`,
"require-js-file.js": `
const addon = require('./addon-1.js');
console.log(addon);
`,
`
});

const ADDON_1_REQUIRE_ARG = getLibraryName(
path.join(tempDirectoryPath, "addon-1"),
{ stripPathSuffix: false }
);
const ADDON_2_REQUIRE_ARG = getLibraryName(
path.join(tempDirectoryPath, "addon-2"),
{ stripPathSuffix: false }
);

{
const result = transformFileSync(
path.join(tempDirectoryPath, "./addon-1.js"),
{ plugins: [[plugin, { stripPathSuffix: false }]] }
);
assert(result);
const { code } = result;
assert(
code && code.includes(`requireNodeAddon("${ADDON_1_REQUIRE_ARG}")`),
`Unexpected code: ${code}`
);
}

{
const result = transformFileSync(
path.join(tempDirectoryPath, "./addon-2.js"),
{ plugins: [[plugin, { naming: "hash" }]] }
);
assert(result);
const { code } = result;
assert(
code && code.includes(`requireNodeAddon("${ADDON_2_REQUIRE_ARG}")`),
`Unexpected code: ${code}`
);
}

{
const result = transformFileSync(
path.join(tempDirectoryPath, "./sub-directory/addon-1.js"),
{ plugins: [[plugin, { naming: "hash" }]] }
);
assert(result);
const { code } = result;
assert(
code && code.includes(`requireNodeAddon("${ADDON_1_REQUIRE_ARG}")`),
`Unexpected code: ${code}`
);
}
const EXPECTED_PKG_NAME = "@scope/my-package";
const EXPECTED_PATH = "./build/Release/addon-1.node";

{
const result = transformFileSync(
path.join(tempDirectoryPath, "./addon-1-bindings.js"),
{ plugins: [[plugin, { naming: "hash" }]] }
path.join(tempDirectoryPath, "test.js"),
{ plugins: [[plugin]] }
);
assert(result);
const { code } = result;
assert(
code && code.includes(`requireNodeAddon("${ADDON_1_REQUIRE_ARG}")`),
code && code.includes(`requireNodeAddon("${EXPECTED_PATH}", "${EXPECTED_PKG_NAME}", "${EXPECTED_PKG_NAME}")`),
`Unexpected code: ${code}`
);
}
};
});
});

{
const result = transformFileSync(
path.join(tempDirectoryPath, "./require-js-file.js"),
{ plugins: [[plugin, { naming: "hash" }]] }
);
assert(result);
const { code } = result;
assert(
code && !code.includes(`requireNodeAddon`),
`Unexpected code: ${code}`
);
}
describe("findNodeAddonForBindings()", () => {
it("should look for addons in common paths", (context) => {
// Arrange
const expectedPaths = {
"addon_1": "addon_1.node",
"addon_2": "build/Release/addon_2.node",
"addon_3": "build/Debug/addon_3.node",
"addon_4": "build/addon_4.node",
"addon_5": "out/Release/addon_5.node",
"addon_6": "out/Debug/addon_6.node",
"addon_7": "Release/addon_7.node",
"addon_8": "Debug/addon_8.node",
};
const tempDirectoryPath = setupTempDirectory(context,
Object.fromEntries(
Object.values(expectedPaths)
.map((p) => [p, "// This is supposed to be a binary file"])
)
);
// Act & Assert
Object.entries(expectedPaths).forEach(([name, relPath]) => {
const expectedPath = path.join(tempDirectoryPath, relPath);
const actualPath = findNodeAddonForBindings(name, tempDirectoryPath);
assert.equal(actualPath, expectedPath);
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import path from "node:path";
import type { PluginObj, NodePath } from "@babel/core";
import * as t from "@babel/types";

import { getLibraryName, isNodeApiModule, NamingStrategy } from "../path-utils";
import { determineModuleContext, isNodeApiModule } from "../path-utils";

type PluginOptions = {
export type PluginOptions = {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add an option to allow-list URL schemes which will trigger a re-write into a requireNodeAddon call.

stripPathSuffix?: boolean;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is not used anymore, perhaps delete the option entirely?

};

Expand All @@ -20,12 +20,68 @@ function assertOptions(opts: unknown): asserts opts is PluginOptions {
}
}

export function replaceWithRequireNodeAddon(
// This function should work with both CommonJS and ECMAScript modules,
// (pretending that addons are supported with ES module imports), hence it
// must accept following import specifiers:
// - "Relative specifiers" (e.g. `./build/Release/addon.node`)
// - "Bare specifiers", in particular
// - to an entry point (e.g. `@callstack/example-addon`)
// - any specific exported feature within
// - "Absolute specifiers" like `node:fs/promise` and URLs.
//
// This function should also respect the Package entry points defined in the
// respective "package.json" file using "main" or "exports" and "imports"
// fields (including conditional exports and subpath imports).
// - https://nodejs.org/api/packages.html#package-entry-points
// - https://nodejs.org/api/packages.html#subpath-imports
function tryResolveModulePath(id: string, from: string): string | undefined {
if (id.includes(":")) {
// This must be a prefixed "Absolute specifier". We assume its a built-in
// module and pass it through without any changes. For security reasons,
// we don't support URLs to dynamic libraries (like Node-API addons).
return undefined;
} else {
// TODO: Stay compatible with https://nodejs.org/api/modules.html#all-together
try {
return require.resolve(id, { paths: [from] });
} catch {
return undefined;
}
}
}

const nodeBindingsSubdirs = [
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like a comment on what inspired a list of these exact sub-paths.

"./",
"./build/Release",
"./build/Debug",
"./build",
"./out/Release",
"./out/Debug",
"./Release",
"./Debug",
];
export function findNodeAddonForBindings(id: string, fromDir: string) {
Copy link
Collaborator

@kraenhansen kraenhansen Jun 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels more like a "path util" to me. Any reason it lives in this file and not in the other?

const idWithExt = id.endsWith(".node") ? id : `${id}.node`;
// Support traversing the filesystem to find the Node-API module.
// Currently, we check the most common directories like `bindings` does.
for (const subdir of nodeBindingsSubdirs) {
const resolvedPath = path.join(fromDir, subdir, idWithExt);
if (isNodeApiModule(resolvedPath)) {
return resolvedPath;
}
}
return undefined;
}

export function replaceWithRequireNodeAddon3(
p: NodePath,
modulePath: string,
naming: NamingStrategy
resolvedPath: string,
originalPath: string
) {
const requireCallArgument = getLibraryName(modulePath, naming);
const { packageName, relativePath } = determineModuleContext(resolvedPath);
const relPath = relativePath.replaceAll("\\", "/");
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd like something less abbreviated here.

Suggested change
const relPath = relativePath.replaceAll("\\", "/");
const relativePosixPath = relativePath.replaceAll("\\", "/");

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discussed this in-person: We should move this into determineModuleContext instead.

const finalRelPath = relPath.startsWith("./") ? relPath : `./${relPath}`;

p.replaceWith(
t.callExpression(
t.memberExpression(
Expand All @@ -34,7 +90,8 @@ export function replaceWithRequireNodeAddon(
]),
t.identifier("requireNodeAddon")
),
[t.stringLiteral(requireCallArgument)]
[finalRelPath, packageName, originalPath]
.map(t.stringLiteral),
)
);
}
Expand All @@ -44,7 +101,6 @@ export function plugin(): PluginObj {
visitor: {
CallExpression(p) {
assertOptions(this.opts);
const { stripPathSuffix = false } = this.opts;
if (typeof this.filename !== "string") {
// This transformation only works when the filename is known
return;
Expand All @@ -64,20 +120,17 @@ export function plugin(): PluginObj {
const [argument] = p.parent.arguments;
if (argument.type === "StringLiteral") {
const id = argument.value;
const relativePath = path.join(from, id);
// TODO: Support traversing the filesystem to find the Node-API module
if (isNodeApiModule(relativePath)) {
replaceWithRequireNodeAddon(p.parentPath, relativePath, {
stripPathSuffix,
});
const resolvedPath = findNodeAddonForBindings(id, from);
if (resolvedPath !== undefined) {
replaceWithRequireNodeAddon3(p.parentPath, resolvedPath, id);
}
}
} else if (
!path.isAbsolute(id) &&
isNodeApiModule(path.join(from, id))
) {
const relativePath = path.join(from, id);
replaceWithRequireNodeAddon(p, relativePath, { stripPathSuffix });
} else {
// This should handle "bare specifiers" and "private imports" that start with `#`
const resolvedPath = tryResolveModulePath(id, from);
if (!!resolvedPath && isNodeApiModule(resolvedPath)) {
replaceWithRequireNodeAddon3(p, resolvedPath, id);
}
}
}
},
Expand Down
Loading