From 240ae83f1f063b2507cc9005e74fed77a316f3ec Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 21 May 2025 00:02:30 +0200 Subject: [PATCH 01/54] feat(babel): add 2 params to requireNodeAddon(); respect package entrypoints This big squashed change makes our Babel plugin to replace `require()` calls with `requireNodeAddons()` with 3 parameters, as initially described in https://github.com/callstackincubator/react-native-node-api-modules/issues/91 While squashing, I also removed my attempt to reimplement Node's resolution algorithm (https://nodejs.org/api/modules.html#all-together) and replaced it with a call to `require.resolve()`, which basically seems to do the same, just better with less code. This code has been tested with package that uses "main" entry point and has no JavaScript files. Finally, it commit will resolve the TODO regarding scanning the filesystem for addons when using `bindings`. Instead of globbing over the directories, I'm scanning the most popular directories where addons will be placed. Such list of directories was heavily influenced by `node-bindings` itself (https://github.com/TooTallNate/node-bindings/blob/v1.3.0/bindings.js#L21). --- packages/host/src/node/babel-plugin/plugin.ts | 93 +++++++++++++++---- 1 file changed, 76 insertions(+), 17 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.ts b/packages/host/src/node/babel-plugin/plugin.ts index 49733089..b490906b 100644 --- a/packages/host/src/node/babel-plugin/plugin.ts +++ b/packages/host/src/node/babel-plugin/plugin.ts @@ -3,6 +3,8 @@ import path from "node:path"; import type { PluginObj, NodePath } from "@babel/core"; import * as t from "@babel/types"; +import { packageDirectorySync } from "pkg-dir"; +import { readPackageSync } from "read-pkg"; import { getLibraryName, isNodeApiModule, NamingStrategy } from "../path-utils"; @@ -20,12 +22,65 @@ 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 = [ + "./", + "./build/Release", + "./build/Debug", + "./build", + "./out/Release", + "./out/Debug", + "./Release", + "./Debug", +]; +function findNodeAddonForBindings(id: string, fromDir: string) { + +} + +export function replaceWithRequireNodeAddon3( p: NodePath, - modulePath: string, - naming: NamingStrategy + resolvedPath: string, + requiredFrom: string ) { - const requireCallArgument = getLibraryName(modulePath, naming); + const pkgRoot = packageDirectorySync({ cwd: resolvedPath }); + if (pkgRoot === undefined) { + throw new Error("Unable to locate package directory!"); + } + + const subpath = path.relative(pkgRoot, resolvedPath); + const dottedSubpath = subpath.startsWith("./") ? subpath : `./${subpath}`; + const fromRelative = path.relative(pkgRoot, requiredFrom); // TODO: might escape package + const { name } = readPackageSync({ cwd: pkgRoot }); + p.replaceWith( t.callExpression( t.memberExpression( @@ -34,7 +89,8 @@ export function replaceWithRequireNodeAddon( ]), t.identifier("requireNodeAddon") ), - [t.stringLiteral(requireCallArgument)] + [dottedSubpath, name, fromRelative] + .map(t.stringLiteral), ) ); } @@ -64,20 +120,23 @@ 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 idWithExt = id.endsWith(".node") ? id : `${id}.node`; + // Support traversing the filesystem to find the Node-API module. + // Currently, we check the most common directories from `bindings`. + for (const subdir of nodeBindingsSubdirs) { + const resolvedPath = path.join(from, subdir, idWithExt); + if (isNodeApiModule(resolvedPath)) { + replaceWithRequireNodeAddon3(p.parentPath, resolvedPath, this.filename); + break; + } } } - } 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, this.filename); + } } } }, From 8768c93879f507542873e66de3f64b1a6fc8d71c Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 21 May 2025 00:39:18 +0200 Subject: [PATCH 02/54] refactor: extract `findNodeAddonForBindings()` --- packages/host/src/node/babel-plugin/plugin.ts | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.ts b/packages/host/src/node/babel-plugin/plugin.ts index b490906b..5bb7b7a9 100644 --- a/packages/host/src/node/babel-plugin/plugin.ts +++ b/packages/host/src/node/babel-plugin/plugin.ts @@ -63,7 +63,16 @@ const nodeBindingsSubdirs = [ "./Debug", ]; function findNodeAddonForBindings(id: string, fromDir: string) { - + 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( @@ -120,15 +129,9 @@ export function plugin(): PluginObj { const [argument] = p.parent.arguments; if (argument.type === "StringLiteral") { const id = argument.value; - 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 from `bindings`. - for (const subdir of nodeBindingsSubdirs) { - const resolvedPath = path.join(from, subdir, idWithExt); - if (isNodeApiModule(resolvedPath)) { - replaceWithRequireNodeAddon3(p.parentPath, resolvedPath, this.filename); - break; - } + const resolvedPath = findNodeAddonForBindings(id, from); + if (resolvedPath !== undefined) { + replaceWithRequireNodeAddon3(p.parentPath, resolvedPath, this.filename); } } } else { From bdc06cf053a050cd0f5e8aafc7fca802f2c6b42a Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 21 May 2025 00:59:06 +0200 Subject: [PATCH 03/54] feat: add shortcut to `isNodeApiModule()` If the path points to a file with a `.node` extension, then it must be a Node addon --- packages/host/src/node/path-utils.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/host/src/node/path-utils.ts b/packages/host/src/node/path-utils.ts index 179688ce..17324718 100644 --- a/packages/host/src/node/path-utils.ts +++ b/packages/host/src/node/path-utils.ts @@ -31,6 +31,13 @@ const packageNameCache = new Map(); * TODO: Consider checking for a specific platform extension. */ export function isNodeApiModule(modulePath: string): boolean { + // HACK: Take a shortcut (if applicable) + if (modulePath.endsWith('.node')) { + try { + fs.accessSync(modulePath); + return true; + } catch {} + } const dir = path.dirname(modulePath); const baseName = path.basename(modulePath, ".node"); let entries: string[]; From fdaf8b58fb67a7d6ac1820da7d29f741743912f4 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 21 May 2025 14:32:43 +0200 Subject: [PATCH 04/54] feat: relax the condition for CJS modules without file exts This small change relaxes the condition for taking the shortcut, as CommonJS modules (in contrast to ESM) do not require developers to explicitly include the file extensions. The Node.js module resolution algorithm (https://nodejs.org/api/modules.html#all-together) in step 4 of LOAD_AS_FILE(X) would try appending the `.node` extension. In theory, we should make sure that other extensions checked in previous steps are not present, but given that we are implementing it for `requireNodeAddon()`, it should be safe to skip those. --- packages/host/src/node/path-utils.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/host/src/node/path-utils.ts b/packages/host/src/node/path-utils.ts index 17324718..103142a4 100644 --- a/packages/host/src/node/path-utils.ts +++ b/packages/host/src/node/path-utils.ts @@ -31,10 +31,10 @@ const packageNameCache = new Map(); * TODO: Consider checking for a specific platform extension. */ export function isNodeApiModule(modulePath: string): boolean { - // HACK: Take a shortcut (if applicable) - if (modulePath.endsWith('.node')) { + { + // HACK: Take a shortcut (if applicable): existing `.node` files are addons try { - fs.accessSync(modulePath); + fs.accessSync(modulePath.endsWith(".node") ? modulePath : `${modulePath}.node`); return true; } catch {} } From 925d958b3f3f11eb311f3cee60e8525d5019d041 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 21 May 2025 14:36:10 +0200 Subject: [PATCH 05/54] style: address linter issues --- packages/host/src/node/babel-plugin/plugin.ts | 3 +-- packages/host/src/node/path-utils.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.ts b/packages/host/src/node/babel-plugin/plugin.ts index 5bb7b7a9..816b6e55 100644 --- a/packages/host/src/node/babel-plugin/plugin.ts +++ b/packages/host/src/node/babel-plugin/plugin.ts @@ -6,7 +6,7 @@ import * as t from "@babel/types"; import { packageDirectorySync } from "pkg-dir"; import { readPackageSync } from "read-pkg"; -import { getLibraryName, isNodeApiModule, NamingStrategy } from "../path-utils"; +import { isNodeApiModule } from "../path-utils"; type PluginOptions = { stripPathSuffix?: boolean; @@ -109,7 +109,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; diff --git a/packages/host/src/node/path-utils.ts b/packages/host/src/node/path-utils.ts index 103142a4..ceee267d 100644 --- a/packages/host/src/node/path-utils.ts +++ b/packages/host/src/node/path-utils.ts @@ -36,7 +36,7 @@ export function isNodeApiModule(modulePath: string): boolean { try { fs.accessSync(modulePath.endsWith(".node") ? modulePath : `${modulePath}.node`); return true; - } catch {} + } catch { /* empty */ } } const dir = path.dirname(modulePath); const baseName = path.basename(modulePath, ".node"); From 58746ae4fa942313b52eaa333423a56c76872a2a Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 4 Jun 2025 12:19:43 +0200 Subject: [PATCH 06/54] feat: extract path normalization from `determineModuleContext()` --- packages/host/src/node/path-utils.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/host/src/node/path-utils.ts b/packages/host/src/node/path-utils.ts index ceee267d..edb6105a 100644 --- a/packages/host/src/node/path-utils.ts +++ b/packages/host/src/node/path-utils.ts @@ -134,10 +134,23 @@ export function determineModuleContext( packageNameCache.set(pkgDir, pkgName); } // Compute module-relative path - const relPath = normalizeModulePath(path.relative(pkgDir, originalPath)); + const relPath = path.relative(pkgDir, originalPath); return { packageName: pkgName, relativePath: relPath }; } +/** + * Traverse the filesystem upward to find a name for the package that which contains a file. + * This variant normalizes the module path. + */ +export function determineNormalizedModuleContext( + modulePath: string, + originalPath = modulePath +): ModuleContext { + const { packageName, relativePath } = determineModuleContext(modulePath, originalPath); + const relPath = normalizeModulePath(relativePath); + return { packageName, relativePath: relPath }; +} + export function normalizeModulePath(modulePath: string) { const dirname = path.normalize(path.dirname(modulePath)); const basename = path.basename(modulePath); @@ -154,7 +167,7 @@ export function escapePath(modulePath: string) { * Get the name of the library which will be used when the module is linked in. */ export function getLibraryName(modulePath: string, naming: NamingStrategy) { - const { packageName, relativePath } = determineModuleContext(modulePath); + const { packageName, relativePath } = determineNormalizedModuleContext(modulePath); const escapedPackageName = escapePath(packageName); return naming.stripPathSuffix ? escapedPackageName From 285b098ca810090371a52d8a68de56b4b64ab516 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 4 Jun 2025 12:25:38 +0200 Subject: [PATCH 07/54] feat: update tests to cover `determineNormalizedModuleContext()` --- packages/host/src/node/path-utils.test.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/packages/host/src/node/path-utils.test.ts b/packages/host/src/node/path-utils.test.ts index 08a788ff..ad1f095e 100644 --- a/packages/host/src/node/path-utils.test.ts +++ b/packages/host/src/node/path-utils.test.ts @@ -6,6 +6,7 @@ import fswin from "fswin"; import { determineModuleContext, + determineNormalizedModuleContext, findNodeApiModulePaths, findPackageDependencyPaths, getLibraryName, @@ -135,14 +136,14 @@ describe("stripExtension", () => { }); }); -describe("determineModuleContext", () => { +describe("determineNormalizedModuleContext", () => { it("strips the file extension", (context) => { const tempDirectoryPath = setupTempDirectory(context, { "package.json": `{ "name": "my-package" }`, }); { - const { packageName, relativePath } = determineModuleContext( + const { packageName, relativePath } = determineNormalizedModuleContext( path.join(tempDirectoryPath, "some-dir/some-file.node") ); assert.equal(packageName, "my-package"); @@ -156,14 +157,16 @@ describe("determineModuleContext", () => { }); { - const { packageName, relativePath } = determineModuleContext( + const { packageName, relativePath } = determineNormalizedModuleContext( path.join(tempDirectoryPath, "some-dir/libsome-file.node") ); assert.equal(packageName, "my-package"); assert.equal(relativePath, "some-dir/some-file"); } }); +}); +describe("determineModuleContext", () => { it("resolves the correct package name", (context) => { const tempDirectoryPath = setupTempDirectory(context, { "package.json": `{ "name": "root-package" }`, @@ -177,7 +180,7 @@ describe("determineModuleContext", () => { path.join(tempDirectoryPath, "sub-package-a/some-file.node") ); assert.equal(packageName, "my-sub-package-a"); - assert.equal(relativePath, "some-file"); + assert.equal(relativePath, "some-file.node"); } { @@ -185,7 +188,7 @@ describe("determineModuleContext", () => { path.join(tempDirectoryPath, "sub-package-b/some-file.node") ); assert.equal(packageName, "my-sub-package-b"); - assert.equal(relativePath, "some-file"); + assert.equal(relativePath, "some-file.node"); } }); }); From e87a66ef1e69f005a4748ca988fdd8ba08fd48d2 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 4 Jun 2025 12:36:08 +0200 Subject: [PATCH 08/54] feat: add test for scoped package names --- packages/host/src/node/path-utils.test.ts | 27 ++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/packages/host/src/node/path-utils.test.ts b/packages/host/src/node/path-utils.test.ts index ad1f095e..f1180030 100644 --- a/packages/host/src/node/path-utils.test.ts +++ b/packages/host/src/node/path-utils.test.ts @@ -167,7 +167,7 @@ describe("determineNormalizedModuleContext", () => { }); describe("determineModuleContext", () => { - it("resolves the correct package name", (context) => { + it("resolves the correct unscoped package name", (context) => { const tempDirectoryPath = setupTempDirectory(context, { "package.json": `{ "name": "root-package" }`, // Two sub-packages with the same name @@ -191,6 +191,31 @@ describe("determineModuleContext", () => { assert.equal(relativePath, "some-file.node"); } }); + + it("resolves the correct scoped package name", (context) => { + const tempDirectoryPath = setupTempDirectory(context, { + "package.json": `{ "name": "root-package" }`, + // Two sub-packages with the same name + "sub-package-a/package.json": `{ "name": "@root-package/my-sub-package-a" }`, + "sub-package-b/package.json": `{ "name": "@root-package/my-sub-package-b" }`, + }); + + { + const { packageName, relativePath } = determineModuleContext( + path.join(tempDirectoryPath, "sub-package-a/some-file.node") + ); + assert.equal(packageName, "@root-package/my-sub-package-a"); + assert.equal(relativePath, "some-file.node"); + } + + { + const { packageName, relativePath } = determineModuleContext( + path.join(tempDirectoryPath, "sub-package-b/some-file.node") + ); + assert.equal(packageName, "@root-package/my-sub-package-b"); + assert.equal(relativePath, "some-file.node"); + } + }); }); describe("getLibraryName", () => { From 798fed7c10b2f00447853397939ab1eb7670e5b9 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 4 Jun 2025 13:55:23 +0200 Subject: [PATCH 09/54] feat: update tests for 3 arg `requireNodeAddon()` --- .../host/src/node/babel-plugin/plugin.test.ts | 75 ++++++------------- packages/host/src/node/babel-plugin/plugin.ts | 2 +- 2 files changed, 23 insertions(+), 54 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.test.ts b/packages/host/src/node/babel-plugin/plugin.test.ts index 73e884aa..6d287e54 100644 --- a/packages/host/src/node/babel-plugin/plugin.test.ts +++ b/packages/host/src/node/babel-plugin/plugin.test.ts @@ -4,9 +4,8 @@ import path from "node:path"; import { transformFileSync } from "@babel/core"; -import { plugin } from "./plugin.js"; +import { plugin, 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) => { @@ -38,66 +37,36 @@ describe("plugin", () => { `, }); - 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 EXPECTED_PKG_NAME = "my-package"; - { - 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}` - ); - } - - { + type TestCaseParams = { + resolvedPath: string; + originalPath: string; + inputFile: string; + options?: PluginOptions; + }; + const runTestCase = ({ + resolvedPath, + originalPath, + inputFile, + options, + }: TestCaseParams) => { const result = transformFileSync( - path.join(tempDirectoryPath, "./sub-directory/addon-1.js"), - { plugins: [[plugin, { naming: "hash" }]] } + path.join(tempDirectoryPath, inputFile), + { plugins: [[plugin, options]] } ); assert(result); const { code } = result; assert( - code && code.includes(`requireNodeAddon("${ADDON_1_REQUIRE_ARG}")`), + code && code.includes(`requireNodeAddon("${resolvedPath}", "${EXPECTED_PKG_NAME}", "${originalPath}")`), `Unexpected code: ${code}` ); - } + }; - { - const result = transformFileSync( - path.join(tempDirectoryPath, "./addon-1-bindings.js"), - { plugins: [[plugin, { naming: "hash" }]] } - ); - assert(result); - const { code } = result; - assert( - code && code.includes(`requireNodeAddon("${ADDON_1_REQUIRE_ARG}")`), - `Unexpected code: ${code}` - ); - } + runTestCase({ resolvedPath: "./addon-1.node", originalPath: "./addon-1.node", inputFile: "./addon-1.js" }); + runTestCase({ resolvedPath: "./addon-2.node", originalPath: "./addon-2.node", inputFile: "./addon-2.js", options: { naming: "hash" } }); + runTestCase({ resolvedPath: "./addon-1.node", originalPath: "../addon-1.node", inputFile: "./sub-directory/addon-1.js", options: { naming: "hash" } }); + runTestCase({ resolvedPath: "./addon-1.node", originalPath: "addon-1", inputFile: "./addon-1-bindings.js", options: { naming: "hash" } }); { const result = transformFileSync( diff --git a/packages/host/src/node/babel-plugin/plugin.ts b/packages/host/src/node/babel-plugin/plugin.ts index 816b6e55..e544f932 100644 --- a/packages/host/src/node/babel-plugin/plugin.ts +++ b/packages/host/src/node/babel-plugin/plugin.ts @@ -8,7 +8,7 @@ import { readPackageSync } from "read-pkg"; import { isNodeApiModule } from "../path-utils"; -type PluginOptions = { +export type PluginOptions = { stripPathSuffix?: boolean; }; From be9926a7881b3b1fb9dd231c5a3ce532c500a344 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 4 Jun 2025 13:55:56 +0200 Subject: [PATCH 10/54] feat: remove xcframework dir from test code --- packages/host/src/node/babel-plugin/plugin.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.test.ts b/packages/host/src/node/babel-plugin/plugin.test.ts index 6d287e54..209a02d0 100644 --- a/packages/host/src/node/babel-plugin/plugin.test.ts +++ b/packages/host/src/node/babel-plugin/plugin.test.ts @@ -11,9 +11,9 @@ describe("plugin", () => { it("transforms require calls, regardless", (context) => { const tempDirectoryPath = setupTempDirectory(context, { "package.json": `{ "name": "my-package" }`, - "addon-1.apple.node/addon-1.node": + "addon-1.node": "// This is supposed to be a binary file", - "addon-2.apple.node/addon-2.node": + "addon-2.node": "// This is supposed to be a binary file", "addon-1.js": ` const addon = require('./addon-1.node'); From d5bf07040dc611de40c614a4a9cd5ee367a103f1 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 4 Jun 2025 13:57:54 +0200 Subject: [PATCH 11/54] feat: change the third param to original path --- packages/host/src/node/babel-plugin/plugin.ts | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.ts b/packages/host/src/node/babel-plugin/plugin.ts index e544f932..249c844f 100644 --- a/packages/host/src/node/babel-plugin/plugin.ts +++ b/packages/host/src/node/babel-plugin/plugin.ts @@ -3,10 +3,8 @@ import path from "node:path"; import type { PluginObj, NodePath } from "@babel/core"; import * as t from "@babel/types"; -import { packageDirectorySync } from "pkg-dir"; -import { readPackageSync } from "read-pkg"; -import { isNodeApiModule } from "../path-utils"; +import { determineModuleContext, isNodeApiModule } from "../path-utils"; export type PluginOptions = { stripPathSuffix?: boolean; @@ -78,17 +76,13 @@ function findNodeAddonForBindings(id: string, fromDir: string) { export function replaceWithRequireNodeAddon3( p: NodePath, resolvedPath: string, + originalPath: string, requiredFrom: string ) { - const pkgRoot = packageDirectorySync({ cwd: resolvedPath }); - if (pkgRoot === undefined) { - throw new Error("Unable to locate package directory!"); - } - - const subpath = path.relative(pkgRoot, resolvedPath); - const dottedSubpath = subpath.startsWith("./") ? subpath : `./${subpath}`; - const fromRelative = path.relative(pkgRoot, requiredFrom); // TODO: might escape package - const { name } = readPackageSync({ cwd: pkgRoot }); + const { packageName, relativePath } = determineModuleContext(resolvedPath); + const finalRelPath = relativePath.startsWith("./") + ? relativePath + : `./${relativePath}`; p.replaceWith( t.callExpression( @@ -98,7 +92,7 @@ export function replaceWithRequireNodeAddon3( ]), t.identifier("requireNodeAddon") ), - [dottedSubpath, name, fromRelative] + [finalRelPath, packageName, originalPath] .map(t.stringLiteral), ) ); @@ -130,14 +124,14 @@ export function plugin(): PluginObj { const id = argument.value; const resolvedPath = findNodeAddonForBindings(id, from); if (resolvedPath !== undefined) { - replaceWithRequireNodeAddon3(p.parentPath, resolvedPath, this.filename); + replaceWithRequireNodeAddon3(p.parentPath, resolvedPath, id, this.filename); } } } else { // This should handle "bare specifiers" and "private imports" that start with `#` const resolvedPath = tryResolveModulePath(id, from); if (!!resolvedPath && isNodeApiModule(resolvedPath)) { - replaceWithRequireNodeAddon3(p, resolvedPath, this.filename); + replaceWithRequireNodeAddon3(p, resolvedPath, id, this.filename); } } } From 2085fdf1755f8c737a9e69c84a78f2eadf27c023 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 4 Jun 2025 14:10:09 +0200 Subject: [PATCH 12/54] feat: add test case for require with "main" entry point --- .../host/src/node/babel-plugin/plugin.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/host/src/node/babel-plugin/plugin.test.ts b/packages/host/src/node/babel-plugin/plugin.test.ts index 209a02d0..bb8e087e 100644 --- a/packages/host/src/node/babel-plugin/plugin.test.ts +++ b/packages/host/src/node/babel-plugin/plugin.test.ts @@ -81,4 +81,34 @@ describe("plugin", () => { ); } }); + + it("transforms require calls to packages with native entry point", (context) => { + const tempDirectoryPath = setupTempDirectory(context, { + "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", + "package.json": `{ "name": "my-consumer" }`, + "test.js": ` + const addon = require('@scope/my-package'); + console.log(addon); + ` + }); + + const EXPECTED_PKG_NAME = "@scope/my-package"; + const EXPECTED_PATH = "./build/Release/addon-1.node"; + + { + const result = transformFileSync( + path.join(tempDirectoryPath, "test.js"), + { plugins: [[plugin]] } + ); + assert(result); + const { code } = result; + assert( + code && code.includes(`requireNodeAddon("${EXPECTED_PATH}", "${EXPECTED_PKG_NAME}", "${EXPECTED_PKG_NAME}")`), + `Unexpected code: ${code}` + ); + }; + }); }); From 3660b6f8c6815bee3c7fdcab8b44c405de0e2de2 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 4 Jun 2025 16:56:50 +0200 Subject: [PATCH 13/54] fix: remove unused `requiredFrom` parameter --- packages/host/src/node/babel-plugin/plugin.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.ts b/packages/host/src/node/babel-plugin/plugin.ts index 249c844f..144325b9 100644 --- a/packages/host/src/node/babel-plugin/plugin.ts +++ b/packages/host/src/node/babel-plugin/plugin.ts @@ -76,8 +76,7 @@ function findNodeAddonForBindings(id: string, fromDir: string) { export function replaceWithRequireNodeAddon3( p: NodePath, resolvedPath: string, - originalPath: string, - requiredFrom: string + originalPath: string ) { const { packageName, relativePath } = determineModuleContext(resolvedPath); const finalRelPath = relativePath.startsWith("./") @@ -124,14 +123,14 @@ export function plugin(): PluginObj { const id = argument.value; const resolvedPath = findNodeAddonForBindings(id, from); if (resolvedPath !== undefined) { - replaceWithRequireNodeAddon3(p.parentPath, resolvedPath, id, this.filename); + replaceWithRequireNodeAddon3(p.parentPath, resolvedPath, id); } } } 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, this.filename); + replaceWithRequireNodeAddon3(p, resolvedPath, id); } } } From a414cd2cad76f8c1af3b3afcc37f7e28ac54370f Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 4 Jun 2025 16:58:06 +0200 Subject: [PATCH 14/54] fix: remove unsupported babel option "naming" --- packages/host/src/node/babel-plugin/plugin.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.test.ts b/packages/host/src/node/babel-plugin/plugin.test.ts index bb8e087e..73e757d2 100644 --- a/packages/host/src/node/babel-plugin/plugin.test.ts +++ b/packages/host/src/node/babel-plugin/plugin.test.ts @@ -64,9 +64,9 @@ describe("plugin", () => { }; runTestCase({ resolvedPath: "./addon-1.node", originalPath: "./addon-1.node", inputFile: "./addon-1.js" }); - runTestCase({ resolvedPath: "./addon-2.node", originalPath: "./addon-2.node", inputFile: "./addon-2.js", options: { naming: "hash" } }); - runTestCase({ resolvedPath: "./addon-1.node", originalPath: "../addon-1.node", inputFile: "./sub-directory/addon-1.js", options: { naming: "hash" } }); - runTestCase({ resolvedPath: "./addon-1.node", originalPath: "addon-1", inputFile: "./addon-1-bindings.js", options: { naming: "hash" } }); + runTestCase({ resolvedPath: "./addon-2.node", originalPath: "./addon-2.node", inputFile: "./addon-2.js" }); + runTestCase({ resolvedPath: "./addon-1.node", originalPath: "../addon-1.node", inputFile: "./sub-directory/addon-1.js" }); + runTestCase({ resolvedPath: "./addon-1.node", originalPath: "addon-1", inputFile: "./addon-1-bindings.js" }); { const result = transformFileSync( From dfeafbb5a01d6dcb09f7a0f5c52d8c105938ab17 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 4 Jun 2025 17:20:04 +0200 Subject: [PATCH 15/54] feat: add tests for `findNodeAddonsForBindings()` --- .../host/src/node/babel-plugin/plugin.test.ts | 30 ++++++++++++++++++- packages/host/src/node/babel-plugin/plugin.ts | 2 +- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.test.ts b/packages/host/src/node/babel-plugin/plugin.test.ts index 73e757d2..004cb762 100644 --- a/packages/host/src/node/babel-plugin/plugin.test.ts +++ b/packages/host/src/node/babel-plugin/plugin.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { transformFileSync } from "@babel/core"; -import { plugin, type PluginOptions } from "./plugin.js"; +import { plugin, findNodeAddonForBindings, type PluginOptions } from "./plugin.js"; import { setupTempDirectory } from "../test-utils.js"; describe("plugin", () => { @@ -112,3 +112,31 @@ describe("plugin", () => { }; }); }); + +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); + }); + }); +}); diff --git a/packages/host/src/node/babel-plugin/plugin.ts b/packages/host/src/node/babel-plugin/plugin.ts index 144325b9..68bafb76 100644 --- a/packages/host/src/node/babel-plugin/plugin.ts +++ b/packages/host/src/node/babel-plugin/plugin.ts @@ -60,7 +60,7 @@ const nodeBindingsSubdirs = [ "./Release", "./Debug", ]; -function findNodeAddonForBindings(id: string, fromDir: string) { +export function findNodeAddonForBindings(id: string, fromDir: string) { 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. From 6cfbc5227827518a485624b84bcf231bac70e4ef Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 4 Jun 2025 17:33:29 +0200 Subject: [PATCH 16/54] fix: ensure that module paths use only forward slash (esp. on Windows) --- packages/host/src/node/babel-plugin/plugin.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.ts b/packages/host/src/node/babel-plugin/plugin.ts index 68bafb76..7b68e44c 100644 --- a/packages/host/src/node/babel-plugin/plugin.ts +++ b/packages/host/src/node/babel-plugin/plugin.ts @@ -79,9 +79,8 @@ export function replaceWithRequireNodeAddon3( originalPath: string ) { const { packageName, relativePath } = determineModuleContext(resolvedPath); - const finalRelPath = relativePath.startsWith("./") - ? relativePath - : `./${relativePath}`; + const relPath = relativePath.replaceAll("\\", "/"); + const finalRelPath = relPath.startsWith("./") ? relPath : `./${relPath}`; p.replaceWith( t.callExpression( From 8bb6e01fb42a8dc4318927855263367ef88b3f5e Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 4 Jun 2025 18:09:13 +0200 Subject: [PATCH 17/54] feat: add test case for addon that "escaped" its package In this particular case we don't want to get packageName="sub-package" with path "../addon-2.node" --- packages/host/src/node/babel-plugin/plugin.test.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/host/src/node/babel-plugin/plugin.test.ts b/packages/host/src/node/babel-plugin/plugin.test.ts index 004cb762..b3b9b2aa 100644 --- a/packages/host/src/node/babel-plugin/plugin.test.ts +++ b/packages/host/src/node/babel-plugin/plugin.test.ts @@ -27,6 +27,11 @@ describe("plugin", () => { 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); @@ -66,6 +71,7 @@ describe("plugin", () => { runTestCase({ resolvedPath: "./addon-1.node", originalPath: "./addon-1.node", inputFile: "./addon-1.js" }); runTestCase({ resolvedPath: "./addon-2.node", originalPath: "./addon-2.node", inputFile: "./addon-2.js" }); runTestCase({ resolvedPath: "./addon-1.node", originalPath: "../addon-1.node", inputFile: "./sub-directory/addon-1.js" }); + runTestCase({ resolvedPath: "./addon-2.node", originalPath: "../addon-2.node", inputFile: "./sub-directory-3/addon-outside.js" }); runTestCase({ resolvedPath: "./addon-1.node", originalPath: "addon-1", inputFile: "./addon-1-bindings.js" }); { From eaa86a0910786992af43fa45343234e0602cfad5 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 11 Jun 2025 17:23:44 +0200 Subject: [PATCH 18/54] feat: make `resolvedPath` optional as suggested by Kraen --- .../host/src/node/babel-plugin/plugin.test.ts | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.test.ts b/packages/host/src/node/babel-plugin/plugin.test.ts index b3b9b2aa..99b94f68 100644 --- a/packages/host/src/node/babel-plugin/plugin.test.ts +++ b/packages/host/src/node/babel-plugin/plugin.test.ts @@ -45,7 +45,7 @@ describe("plugin", () => { const EXPECTED_PKG_NAME = "my-package"; type TestCaseParams = { - resolvedPath: string; + resolvedPath?: string; originalPath: string; inputFile: string; options?: PluginOptions; @@ -62,10 +62,17 @@ describe("plugin", () => { ); assert(result); const { code } = result; - assert( - code && code.includes(`requireNodeAddon("${resolvedPath}", "${EXPECTED_PKG_NAME}", "${originalPath}")`), - `Unexpected code: ${code}` - ); + if (!resolvedPath) { + assert( + code && !code.includes(`requireNodeAddon`), + `Unexpected code: ${code}` + ); + } else { + assert( + code && code.includes(`requireNodeAddon("${resolvedPath}", "${EXPECTED_PKG_NAME}", "${originalPath}")`), + `Unexpected code: ${code}` + ); + } }; runTestCase({ resolvedPath: "./addon-1.node", originalPath: "./addon-1.node", inputFile: "./addon-1.js" }); @@ -73,19 +80,7 @@ describe("plugin", () => { runTestCase({ resolvedPath: "./addon-1.node", originalPath: "../addon-1.node", inputFile: "./sub-directory/addon-1.js" }); runTestCase({ resolvedPath: "./addon-2.node", originalPath: "../addon-2.node", inputFile: "./sub-directory-3/addon-outside.js" }); runTestCase({ resolvedPath: "./addon-1.node", originalPath: "addon-1", inputFile: "./addon-1-bindings.js" }); - - { - 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}` - ); - } + runTestCase({ resolvedPath: undefined, originalPath: "./addon-1.js", inputFile: "./require-js-file.js" }); }); it("transforms require calls to packages with native entry point", (context) => { From 824c9325d24ff53faea7d6476bc90fa2adca8cdf Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 11 Jun 2025 17:58:07 +0200 Subject: [PATCH 19/54] feat: run each test case as separate call to `it()` --- .../host/src/node/babel-plugin/plugin.test.ts | 130 +++++++++--------- 1 file changed, 65 insertions(+), 65 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.test.ts b/packages/host/src/node/babel-plugin/plugin.test.ts index 99b94f68..a1371d45 100644 --- a/packages/host/src/node/babel-plugin/plugin.test.ts +++ b/packages/host/src/node/babel-plugin/plugin.test.ts @@ -8,79 +8,79 @@ import { plugin, findNodeAddonForBindings, type PluginOptions } from "./plugin.j import { setupTempDirectory } from "../test-utils.js"; describe("plugin", () => { - it("transforms require calls, regardless", (context) => { - const tempDirectoryPath = setupTempDirectory(context, { - "package.json": `{ "name": "my-package" }`, - "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); - `, - }); - + describe("transforms require calls, regardless", () => { const EXPECTED_PKG_NAME = "my-package"; type TestCaseParams = { resolvedPath?: string; originalPath: string; inputFile: string; - options?: PluginOptions; - }; - const runTestCase = ({ - resolvedPath, - originalPath, - inputFile, - options, - }: TestCaseParams) => { - const result = transformFileSync( - path.join(tempDirectoryPath, inputFile), - { plugins: [[plugin, options]] } - ); - 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}` - ); - } }; - runTestCase({ resolvedPath: "./addon-1.node", originalPath: "./addon-1.node", inputFile: "./addon-1.js" }); - runTestCase({ resolvedPath: "./addon-2.node", originalPath: "./addon-2.node", inputFile: "./addon-2.js" }); - runTestCase({ resolvedPath: "./addon-1.node", originalPath: "../addon-1.node", inputFile: "./sub-directory/addon-1.js" }); - runTestCase({ resolvedPath: "./addon-2.node", originalPath: "../addon-2.node", inputFile: "./sub-directory-3/addon-outside.js" }); - runTestCase({ resolvedPath: "./addon-1.node", originalPath: "addon-1", inputFile: "./addon-1-bindings.js" }); - runTestCase({ resolvedPath: undefined, originalPath: "./addon-1.js", inputFile: "./require-js-file.js" }); + ([ + { 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 }) => { + 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) => { From 1cdc092e6784489d0f491ec42f37a12408536a98 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 11 Jun 2025 18:16:45 +0200 Subject: [PATCH 20/54] chore: move `findNodeAddonForBindings()` to path-utils --- .../host/src/node/babel-plugin/plugin.test.ts | 30 +------------------ packages/host/src/node/babel-plugin/plugin.ts | 29 ++++-------------- packages/host/src/node/path-utils.test.ts | 29 ++++++++++++++++++ packages/host/src/node/path-utils.ts | 26 ++++++++++++++++ 4 files changed, 61 insertions(+), 53 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.test.ts b/packages/host/src/node/babel-plugin/plugin.test.ts index a1371d45..b1a59d6f 100644 --- a/packages/host/src/node/babel-plugin/plugin.test.ts +++ b/packages/host/src/node/babel-plugin/plugin.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { transformFileSync } from "@babel/core"; -import { plugin, findNodeAddonForBindings, type PluginOptions } from "./plugin.js"; +import { plugin } from "./plugin.js"; import { setupTempDirectory } from "../test-utils.js"; describe("plugin", () => { @@ -113,31 +113,3 @@ describe("plugin", () => { }; }); }); - -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); - }); - }); -}); diff --git a/packages/host/src/node/babel-plugin/plugin.ts b/packages/host/src/node/babel-plugin/plugin.ts index 7b68e44c..90f41aba 100644 --- a/packages/host/src/node/babel-plugin/plugin.ts +++ b/packages/host/src/node/babel-plugin/plugin.ts @@ -4,7 +4,11 @@ import path from "node:path"; import type { PluginObj, NodePath } from "@babel/core"; import * as t from "@babel/types"; -import { determineModuleContext, isNodeApiModule } from "../path-utils"; +import { + determineModuleContext, + isNodeApiModule, + findNodeAddonForBindings, +} from "../path-utils"; export type PluginOptions = { stripPathSuffix?: boolean; @@ -50,29 +54,6 @@ function tryResolveModulePath(id: string, from: string): string | undefined { } } -const nodeBindingsSubdirs = [ - "./", - "./build/Release", - "./build/Debug", - "./build", - "./out/Release", - "./out/Debug", - "./Release", - "./Debug", -]; -export function findNodeAddonForBindings(id: string, fromDir: string) { - 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, resolvedPath: string, diff --git a/packages/host/src/node/path-utils.test.ts b/packages/host/src/node/path-utils.test.ts index f1180030..211e29c9 100644 --- a/packages/host/src/node/path-utils.test.ts +++ b/packages/host/src/node/path-utils.test.ts @@ -7,6 +7,7 @@ import fswin from "fswin"; import { determineModuleContext, determineNormalizedModuleContext, + findNodeAddonForBindings, findNodeApiModulePaths, findPackageDependencyPaths, getLibraryName, @@ -400,3 +401,31 @@ describe("determineModuleContext", () => { assert.equal(readCount, 1); }); }); + +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); + }); + }); +}); \ No newline at end of file diff --git a/packages/host/src/node/path-utils.ts b/packages/host/src/node/path-utils.ts index edb6105a..44f8f709 100644 --- a/packages/host/src/node/path-utils.ts +++ b/packages/host/src/node/path-utils.ts @@ -405,3 +405,29 @@ export function getLatestMtime(fromPath: string): number { return latest; } + +// NOTE: List of paths influenced by `node-bindings` itself +// https://github.com/TooTallNate/node-bindings/blob/v1.3.0/bindings.js#L21 +const nodeBindingsSubdirs = [ + "./", + "./build/Release", + "./build/Debug", + "./build", + "./out/Release", + "./out/Debug", + "./Release", + "./Debug", +]; + +export function findNodeAddonForBindings(id: string, fromDir: string) { + 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; +} \ No newline at end of file From f2bfc83c863da47b28fa59ebda0a44657eda8b84 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 11 Jun 2025 18:21:58 +0200 Subject: [PATCH 21/54] chore: rename `originalPath` parameter to `originalId` --- packages/host/src/node/babel-plugin/plugin.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.ts b/packages/host/src/node/babel-plugin/plugin.ts index 90f41aba..21c50598 100644 --- a/packages/host/src/node/babel-plugin/plugin.ts +++ b/packages/host/src/node/babel-plugin/plugin.ts @@ -57,7 +57,7 @@ function tryResolveModulePath(id: string, from: string): string | undefined { export function replaceWithRequireNodeAddon3( p: NodePath, resolvedPath: string, - originalPath: string + originalId: string ) { const { packageName, relativePath } = determineModuleContext(resolvedPath); const relPath = relativePath.replaceAll("\\", "/"); @@ -71,7 +71,7 @@ export function replaceWithRequireNodeAddon3( ]), t.identifier("requireNodeAddon") ), - [finalRelPath, packageName, originalPath] + [finalRelPath, packageName, originalId] .map(t.stringLiteral), ) ); From 64b967c557c80e89e96b6b0db24e09916140268d Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 11 Jun 2025 21:33:16 +0200 Subject: [PATCH 22/54] chore: replace `[].forEach()` with for-of --- packages/host/src/node/babel-plugin/plugin.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.test.ts b/packages/host/src/node/babel-plugin/plugin.test.ts index b1a59d6f..c95b15d0 100644 --- a/packages/host/src/node/babel-plugin/plugin.test.ts +++ b/packages/host/src/node/babel-plugin/plugin.test.ts @@ -17,14 +17,15 @@ describe("plugin", () => { inputFile: string; }; - ([ + const testCases: ReadonlyArray = [ { 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 }) => { + ]; + for (const { resolvedPath, originalPath, inputFile } of testCases) { const expectedMessage = resolvedPath ? `transform to requireNodeAddon() with "${resolvedPath}"` : "NOT transform to requireNodeAddon()"; @@ -80,7 +81,7 @@ describe("plugin", () => { ); } }); - }); + } }); it("transforms require calls to packages with native entry point", (context) => { From d7ad8a1c126fd13e2e224741db92e0bb01a8df01 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 11 Jun 2025 21:43:30 +0200 Subject: [PATCH 23/54] feat: move path slash normalization to determineModuleContext() --- packages/host/src/node/babel-plugin/plugin.ts | 3 +-- packages/host/src/node/path-utils.ts | 3 ++- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.ts b/packages/host/src/node/babel-plugin/plugin.ts index 21c50598..8ac2e7b5 100644 --- a/packages/host/src/node/babel-plugin/plugin.ts +++ b/packages/host/src/node/babel-plugin/plugin.ts @@ -60,8 +60,7 @@ export function replaceWithRequireNodeAddon3( originalId: string ) { const { packageName, relativePath } = determineModuleContext(resolvedPath); - const relPath = relativePath.replaceAll("\\", "/"); - const finalRelPath = relPath.startsWith("./") ? relPath : `./${relPath}`; + const finalRelPath = relativePath.startsWith("./") ? relativePath : `./${relativePath}`; p.replaceWith( t.callExpression( diff --git a/packages/host/src/node/path-utils.ts b/packages/host/src/node/path-utils.ts index 44f8f709..b18b3234 100644 --- a/packages/host/src/node/path-utils.ts +++ b/packages/host/src/node/path-utils.ts @@ -134,7 +134,8 @@ export function determineModuleContext( packageNameCache.set(pkgDir, pkgName); } // Compute module-relative path - const relPath = path.relative(pkgDir, originalPath); + const relPath = path.relative(pkgDir, originalPath) + .replaceAll("\\", "/"); return { packageName: pkgName, relativePath: relPath }; } From b0071ff342206f4071dad000901bb80d3f588a92 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 11 Jun 2025 21:44:30 +0200 Subject: [PATCH 24/54] chore: use more descriptive variable names --- packages/host/src/node/babel-plugin/plugin.ts | 4 ++-- packages/host/src/node/path-utils.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/host/src/node/babel-plugin/plugin.ts b/packages/host/src/node/babel-plugin/plugin.ts index 8ac2e7b5..486b3821 100644 --- a/packages/host/src/node/babel-plugin/plugin.ts +++ b/packages/host/src/node/babel-plugin/plugin.ts @@ -60,7 +60,7 @@ export function replaceWithRequireNodeAddon3( originalId: string ) { const { packageName, relativePath } = determineModuleContext(resolvedPath); - const finalRelPath = relativePath.startsWith("./") ? relativePath : `./${relativePath}`; + const dotRelativePath = relativePath.startsWith("./") ? relativePath : `./${relativePath}`; p.replaceWith( t.callExpression( @@ -70,7 +70,7 @@ export function replaceWithRequireNodeAddon3( ]), t.identifier("requireNodeAddon") ), - [finalRelPath, packageName, originalId] + [dotRelativePath, packageName, originalId] .map(t.stringLiteral), ) ); diff --git a/packages/host/src/node/path-utils.ts b/packages/host/src/node/path-utils.ts index b18b3234..2770b826 100644 --- a/packages/host/src/node/path-utils.ts +++ b/packages/host/src/node/path-utils.ts @@ -134,9 +134,9 @@ export function determineModuleContext( packageNameCache.set(pkgDir, pkgName); } // Compute module-relative path - const relPath = path.relative(pkgDir, originalPath) + const relativePath = path.relative(pkgDir, originalPath) .replaceAll("\\", "/"); - return { packageName: pkgName, relativePath: relPath }; + return { packageName: pkgName, relativePath }; } /** From e0b51398ce165197267ef3a9299c6cb6e5465a10 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Mon, 19 May 2025 11:46:31 +0200 Subject: [PATCH 25/54] feat: make requireNodeAddon() take 3 arguments This change also resolves a TODO when dispatching of `requireNodeAddon()` failed due to invalid number of arguments --- packages/host/cpp/CxxNodeApiHostModule.cpp | 28 +++++++++++++++++----- packages/host/cpp/CxxNodeApiHostModule.hpp | 8 +++++++ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index 727241cc..7939cde3 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -17,18 +17,34 @@ CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, react::TurboModule &turboModule, const jsi::Value args[], size_t count) { auto &thisModule = static_cast(turboModule); - if (1 == count && args[0].isString()) { - return thisModule.requireNodeAddon(rt, args[0].asString(rt)); + if (3 == count) { + // Must be `requireNodeAddon(requiredPath: string, requiredPackageName: string, requiredFrom: string)` + return thisModule.requireNodeAddon(rt, + args[0].asString(rt), + args[1].asString(rt), + args[2].asString(rt)); } - // TODO: Throw a meaningful error - return jsi::Value::undefined(); + throw jsi::JSError(rt, "Invalid number of arguments to requireNodeAddon()"); } jsi::Value CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, - const jsi::String libraryName) { - const std::string libraryNameStr = libraryName.utf8(rt); + const jsi::String &requiredPath, + const jsi::String &requiredPackageName, + const jsi::String &requiredFrom) { + return requireNodeAddon(rt, + requiredPath.utf8(rt), + requiredPackageName.utf8(rt), + requiredFrom.utf8(rt)); +} + +jsi::Value +CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, + const std::string &requiredPath, + const std::string &requiredPackageName, + const std::string &requiredFrom) { + const std::string &libraryNameStr = requiredPath; auto [it, inserted] = nodeAddons_.emplace(libraryNameStr, NodeAddon()); NodeAddon &addon = it->second; diff --git a/packages/host/cpp/CxxNodeApiHostModule.hpp b/packages/host/cpp/CxxNodeApiHostModule.hpp index f77a89af..1be17857 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.hpp +++ b/packages/host/cpp/CxxNodeApiHostModule.hpp @@ -20,6 +20,14 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule { const facebook::jsi::Value args[], size_t count); facebook::jsi::Value requireNodeAddon(facebook::jsi::Runtime &rt, const facebook::jsi::String path); + facebook::jsi::Value requireNodeAddon(facebook::jsi::Runtime &rt, + const facebook::jsi::String &requiredPath, + const facebook::jsi::String &requiredPackageName, + const facebook::jsi::String &requiredFrom); + facebook::jsi::Value requireNodeAddon(facebook::jsi::Runtime &rt, + const std::string &requiredPath, + const std::string &requiredPackageName, + const std::string &requiredFrom); protected: struct NodeAddon { From 89612c400aa5ef61658308fb89bfc49c46ee815b Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Tue, 20 May 2025 09:53:45 +0200 Subject: [PATCH 26/54] fixup: remove single argument requireNodeAddon() --- packages/host/cpp/CxxNodeApiHostModule.hpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/host/cpp/CxxNodeApiHostModule.hpp b/packages/host/cpp/CxxNodeApiHostModule.hpp index 1be17857..4a98404a 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.hpp +++ b/packages/host/cpp/CxxNodeApiHostModule.hpp @@ -18,8 +18,6 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule { requireNodeAddon(facebook::jsi::Runtime &rt, facebook::react::TurboModule &turboModule, const facebook::jsi::Value args[], size_t count); - facebook::jsi::Value requireNodeAddon(facebook::jsi::Runtime &rt, - const facebook::jsi::String path); facebook::jsi::Value requireNodeAddon(facebook::jsi::Runtime &rt, const facebook::jsi::String &requiredPath, const facebook::jsi::String &requiredPackageName, From 439e7a682ef1b8eaff4b78ec401e38af04c1c8aa Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Mon, 19 May 2025 12:12:06 +0200 Subject: [PATCH 27/54] feat: add path utility functions --- packages/host/cpp/CxxNodeApiHostModule.cpp | 95 ++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index 7939cde3..1ae7def4 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -1,8 +1,103 @@ +#include // std::move, std::pair, std::make_pair +#include // std::vector +#include // std::string +#include // std::string_view #include "CxxNodeApiHostModule.hpp" #include "Logger.hpp" using namespace facebook; +namespace { + +// NOTE: behaves like `explode()` in PHP +std::vector explodePath(const std::string_view &path) { + std::vector parts; + for (size_t pos = 0; std::string_view::npos != pos; /* no-op */) { + if (const size_t nextPos = path.find('/', pos); std::string_view::npos != nextPos) { + parts.emplace_back(path.substr(pos, nextPos - pos)); + pos = nextPos + 1; + } else { + if (std::string_view &&part = path.substr(pos); !part.empty()) { + // Paths ending with `/` are as if there was a tailing dot `/.` + // therefore the last `/` can be safely removed + parts.emplace_back(part); + } + break; + } + } + return parts; +} + +// NOTE: Absolute paths would have the first part empty, relative would have a name +std::string implodePath(const std::vector &parts) { + std::string joinedPath; + for (size_t i = 0; i < parts.size(); ++i) { + if (i > 0) { + joinedPath += '/'; + } + joinedPath += parts[i]; + } + return joinedPath; +} + +// NOTE: Returned path does not include the `/` at the end of the string +// NOTE: For some cases this cannot be a view: `getParentPath("..")` => "../.." +void makeParentPathInplace(std::vector &parts) { + if (!parts.empty() && ".." != parts.back()) { + const bool wasDot = "." == parts.back(); + parts.pop_back(); + if (wasDot && parts.empty()) { + parts.emplace_back(".."); + } + } else { + parts.emplace_back(".."); + } +} + +std::vector simplifyPath(const std::vector &parts) { + std::vector result; + if (!parts.empty()) { + for (const auto &part : parts) { + if ("." == part && !result.empty()) { + continue; // We only allow for a single `./` at the beginning + } else if (".." == part) { + makeParentPathInplace(result); + } else { + result.emplace_back(part); + } + } + } else { + result.emplace_back("."); // Empty path is as if it was "." + } + return result; +} + +std::string joinPath(const std::string_view &baseDir, const std::string_view &rest) { + auto pathComponents = simplifyPath(explodePath(baseDir)); + auto restComponents = simplifyPath(explodePath(rest)); + for (auto &&part : restComponents) { + if (".." == part) { + makeParentPathInplace(pathComponents); + } else if (!part.empty() && "." != part) { + pathComponents.emplace_back(part); + } + } + return implodePath(pathComponents); +} + +std::pair +rpartition(const std::string_view &input, char delimiter) { + if (const size_t pos = input.find_last_of(delimiter); std::string_view::npos != pos) { + const auto head = std::string_view(input).substr(0, pos); + const auto tail = std::string_view(input).substr(pos + 1); + return std::make_pair(head, tail); + } else { + return std::make_pair(std::string_view(), input); + } +} + +} // namespace + namespace callstack::nodeapihost { CxxNodeApiHostModule::CxxNodeApiHostModule( From dec90350f9c572838e7739236722e9cb9211013f Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Mon, 19 May 2025 13:06:23 +0200 Subject: [PATCH 28/54] feat: ensure that paths use safe ASCII alphanumericals --- packages/host/cpp/CxxNodeApiHostModule.cpp | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index 1ae7def4..163bdca6 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -2,6 +2,8 @@ #include // std::vector #include // std::string #include // std::string_view +#include // std::all_of +#include // std::isalnum #include "CxxNodeApiHostModule.hpp" #include "Logger.hpp" @@ -9,6 +11,13 @@ using namespace facebook; namespace { +bool isModulePathLike(const std::string_view &path) { + return std::all_of(path.begin(), path.end(), [](unsigned char c) { + return std::isalnum(c) || '_' == c || '-' == c + || '.' == c || '/' == c || ':' == c; + }); +} + // NOTE: behaves like `explode()` in PHP std::vector explodePath(const std::string_view &path) { std::vector parts; @@ -138,6 +147,13 @@ CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, const std::string &requiredPath, const std::string &requiredPackageName, const std::string &requiredFrom) { + // Ensure that user-supplied inputs contain only allowed characters + if (!isModulePathLike(requiredPath)) { + throw jsi::JSError(rt, "Invalid characters in `requiredPath`. Only ASCII alphanumerics are allowed."); + } + if (!isModulePathLike(requiredFrom)) { + throw jsi::JSError(rt, "Invalid characters in `requiredFrom`. Only ASCII alphanumerics are allowed."); + } const std::string &libraryNameStr = requiredPath; auto [it, inserted] = nodeAddons_.emplace(libraryNameStr, NodeAddon()); From 40a8791973de102f6f13ddb9241a23c8affeb2ef Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Mon, 19 May 2025 13:12:20 +0200 Subject: [PATCH 29/54] feat: declare alias for custom resolver function --- packages/host/cpp/CxxNodeApiHostModule.cpp | 1 - packages/host/cpp/CxxNodeApiHostModule.hpp | 8 ++++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index 163bdca6..ccd2ae62 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -1,7 +1,6 @@ #include // std::move, std::pair, std::make_pair #include // std::vector #include // std::string -#include // std::string_view #include // std::all_of #include // std::isalnum #include "CxxNodeApiHostModule.hpp" diff --git a/packages/host/cpp/CxxNodeApiHostModule.hpp b/packages/host/cpp/CxxNodeApiHostModule.hpp index 4a98404a..da770859 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.hpp +++ b/packages/host/cpp/CxxNodeApiHostModule.hpp @@ -1,5 +1,7 @@ #pragma once +#include // std::string_view + #include #include #include @@ -12,6 +14,12 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule { public: static constexpr const char *kModuleName = "NodeApiHost"; + using ResolverFunc = std::function; + CxxNodeApiHostModule(std::shared_ptr jsInvoker); static facebook::jsi::Value From f9a4aae879c0795f2f774a46f64cd205539f8e6e Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Mon, 19 May 2025 13:17:53 +0200 Subject: [PATCH 30/54] feat: add support for custom prefix resolvers --- packages/host/cpp/CxxNodeApiHostModule.cpp | 14 +++++++++++++- packages/host/cpp/CxxNodeApiHostModule.hpp | 1 + 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index ccd2ae62..c8af473d 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -154,7 +154,19 @@ CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, throw jsi::JSError(rt, "Invalid characters in `requiredFrom`. Only ASCII alphanumerics are allowed."); } - const std::string &libraryNameStr = requiredPath; + // Check if this is a prefixed import (e.g. `node:fs/promises`) + const auto [pathPrefix, strippedPath] = rpartition(requiredPath, ':'); + if (!pathPrefix.empty()) { + // URL protocol or prefix detected, dispatch via custom resolver + if (auto handler = prefixResolvers_.find(pathPrefix); prefixResolvers_.end() != handler) { + // HACK: Smuggle the `pathPrefix` as new `requiredPackageName` + return (handler->second)(rt, strippedPath, pathPrefix, requiredFrom); + } else { + throw jsi::JSError(rt, "Unsupported protocol or prefix \"" + pathPrefix + "\". Have you registered it?"); + } + } + + const std::string &libraryNameStr = strippedPath; auto [it, inserted] = nodeAddons_.emplace(libraryNameStr, NodeAddon()); NodeAddon &addon = it->second; diff --git a/packages/host/cpp/CxxNodeApiHostModule.hpp b/packages/host/cpp/CxxNodeApiHostModule.hpp index da770859..6be0780b 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.hpp +++ b/packages/host/cpp/CxxNodeApiHostModule.hpp @@ -42,6 +42,7 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule { std::string generatedName; }; std::unordered_map nodeAddons_; + std::unordered_map prefixResolvers_; using LoaderPolicy = PosixLoader; // FIXME: HACK: This is temporary workaround // for my lazyness (work on iOS and Android) From 9caedb8adaf0f4a6992d727d2412bdafedbeea4a Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Mon, 19 May 2025 13:21:22 +0200 Subject: [PATCH 31/54] feat: add support for custom package-specific resolvers --- packages/host/cpp/CxxNodeApiHostModule.cpp | 6 ++++++ packages/host/cpp/CxxNodeApiHostModule.hpp | 1 + 2 files changed, 7 insertions(+) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index c8af473d..bd4375bb 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -166,6 +166,12 @@ CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, } } + // Check, if this package has been overridden + if (auto handler = packageOverrides_.find(requiredPackageName); packageOverrides_.end() != handler) { + // This package has a custom resolver, invoke it + return (handler->second)(rt, strippedPath, requiredPackageName, requiredFrom); + } + const std::string &libraryNameStr = strippedPath; auto [it, inserted] = nodeAddons_.emplace(libraryNameStr, NodeAddon()); NodeAddon &addon = it->second; diff --git a/packages/host/cpp/CxxNodeApiHostModule.hpp b/packages/host/cpp/CxxNodeApiHostModule.hpp index 6be0780b..447da64a 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.hpp +++ b/packages/host/cpp/CxxNodeApiHostModule.hpp @@ -43,6 +43,7 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule { }; std::unordered_map nodeAddons_; std::unordered_map prefixResolvers_; + std::unordered_map packageOverrides_; using LoaderPolicy = PosixLoader; // FIXME: HACK: This is temporary workaround // for my lazyness (work on iOS and Android) From d7a2e3c24b9c665e5968c011e38dc306f05a2576 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Mon, 19 May 2025 13:43:19 +0200 Subject: [PATCH 32/54] refactor: extract existing loading code to resolveRelativePath() method --- packages/host/cpp/CxxNodeApiHostModule.cpp | 11 ++++++++++- packages/host/cpp/CxxNodeApiHostModule.hpp | 5 +++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index bd4375bb..c637b8d4 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -172,7 +172,16 @@ CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, return (handler->second)(rt, strippedPath, requiredPackageName, requiredFrom); } - const std::string &libraryNameStr = strippedPath; + // Otherwise, "requiredPath" must be a "relative specifier" or a "bare specifier" + return resolveRelativePath(rt, strippedPath, requiredPackageName, requiredFrom); +} + +jsi::Value +CxxNodeApiHostModule::resolveRelativePath(facebook::jsi::Runtime &rt, + const std::string_view &requiredPath, + const std::string_view &requiredPackageName, + const std::string_view &requiredFrom) { + const std::string libraryNameStr(requiredPath); auto [it, inserted] = nodeAddons_.emplace(libraryNameStr, NodeAddon()); NodeAddon &addon = it->second; diff --git a/packages/host/cpp/CxxNodeApiHostModule.hpp b/packages/host/cpp/CxxNodeApiHostModule.hpp index 447da64a..da3fc4a7 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.hpp +++ b/packages/host/cpp/CxxNodeApiHostModule.hpp @@ -35,6 +35,11 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule { const std::string &requiredPackageName, const std::string &requiredFrom); + facebook::jsi::Value resolveRelativePath(facebook::jsi::Runtime &rt, + const std::string_view &requiredPath, + const std::string_view &requiredPackageName, + const std::string_view &requiredFrom); + protected: struct NodeAddon { void *moduleHandle; From 3b7f209b950d4737feb5ef31543085df223ba864 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Mon, 19 May 2025 13:43:52 +0200 Subject: [PATCH 33/54] feat: add `startsWith()` helper function --- packages/host/cpp/CxxNodeApiHostModule.cpp | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index c637b8d4..d8bb97cc 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -1,7 +1,7 @@ #include // std::move, std::pair, std::make_pair #include // std::vector #include // std::string -#include // std::all_of +#include // std::equal, std::all_of #include // std::isalnum #include "CxxNodeApiHostModule.hpp" #include "Logger.hpp" @@ -10,6 +10,15 @@ using namespace facebook; namespace { +bool startsWith(const std::string &str, const std::string &prefix) { +#if __cplusplus >= 202002L // __cpp_lib_starts_ends_with + return str.starts_with(prefix); +#else + return str.size() >= prefix.size() + && std::equal(prefix.begin(), prefix.end(), str.begin()); +#endif // __cplusplus >= 202002L +} + bool isModulePathLike(const std::string_view &path) { return std::all_of(path.begin(), path.end(), [](unsigned char c) { return std::isalnum(c) || '_' == c || '-' == c From c90b8fc170f27e2d2d73ee582c6b91a39aa10217 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Mon, 19 May 2025 13:45:07 +0200 Subject: [PATCH 34/54] feat: compute merged subpath and verify it before loading --- packages/host/cpp/CxxNodeApiHostModule.cpp | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index d8bb97cc..d7dc7169 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -190,6 +190,15 @@ CxxNodeApiHostModule::resolveRelativePath(facebook::jsi::Runtime &rt, const std::string_view &requiredPath, const std::string_view &requiredPackageName, const std::string_view &requiredFrom) { + // "Rebase" the relative path to get a proper package-relative path + const std::string mergedSubpath = joinPath(requiredFrom, requiredPath); + if (!isModulePathLike(mergedSubpath)) { + throw jsi::JSError(rt, "Computed subpath is invalid. Check `requiredPath` and `requiredFrom`."); + } + if (!startsWith(mergedSubpath, "./")) { + throw jsi::JSError(rt, "Subpath must be relative and cannot leave its package root."); + } + const std::string libraryNameStr(requiredPath); auto [it, inserted] = nodeAddons_.emplace(libraryNameStr, NodeAddon()); NodeAddon &addon = it->second; From d6b254f71f73e92305f310de040c8b312abbc18e Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Mon, 19 May 2025 15:51:20 +0200 Subject: [PATCH 35/54] fix: explicitly cast `pathPrefix` to `std::string` for lookup --- packages/host/cpp/CxxNodeApiHostModule.cpp | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index d7dc7169..5286c845 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -167,11 +167,12 @@ CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, const auto [pathPrefix, strippedPath] = rpartition(requiredPath, ':'); if (!pathPrefix.empty()) { // URL protocol or prefix detected, dispatch via custom resolver - if (auto handler = prefixResolvers_.find(pathPrefix); prefixResolvers_.end() != handler) { + std::string pathPrefixCopy(pathPrefix); // HACK: Need explicit cast to `std::string` + if (auto handler = prefixResolvers_.find(pathPrefixCopy); prefixResolvers_.end() != handler) { // HACK: Smuggle the `pathPrefix` as new `requiredPackageName` return (handler->second)(rt, strippedPath, pathPrefix, requiredFrom); } else { - throw jsi::JSError(rt, "Unsupported protocol or prefix \"" + pathPrefix + "\". Have you registered it?"); + throw jsi::JSError(rt, "Unsupported protocol or prefix \"" + pathPrefixCopy + "\". Have you registered it?"); } } From 063e5b5b6de15dbbf7f609435a86b476cab361f7 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Mon, 19 May 2025 20:42:38 +0200 Subject: [PATCH 36/54] fix: ensure `joinPath` is called on `requiredFrom`'s parent directory --- packages/host/cpp/CxxNodeApiHostModule.cpp | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index 5286c845..4d204631 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -71,6 +71,12 @@ void makeParentPathInplace(std::vector &parts) { } } +std::vector makeParentPath(const std::string_view &path) { + auto parts = explodePath(path); + makeParentPathInplace(parts); + return parts; +} + std::vector simplifyPath(const std::vector &parts) { std::vector result; if (!parts.empty()) { @@ -89,9 +95,10 @@ std::vector simplifyPath(const std::vector & return result; } -std::string joinPath(const std::string_view &baseDir, const std::string_view &rest) { - auto pathComponents = simplifyPath(explodePath(baseDir)); - auto restComponents = simplifyPath(explodePath(rest)); +std::vector joinPath(const std::vector &baseDir, + const std::vector &rest) { + auto pathComponents = simplifyPath(baseDir); + auto restComponents = simplifyPath(rest); for (auto &&part : restComponents) { if (".." == part) { makeParentPathInplace(pathComponents); @@ -99,7 +106,7 @@ std::string joinPath(const std::string_view &baseDir, const std::string_view &re pathComponents.emplace_back(part); } } - return implodePath(pathComponents); + return pathComponents; } std::pair @@ -192,7 +199,9 @@ CxxNodeApiHostModule::resolveRelativePath(facebook::jsi::Runtime &rt, const std::string_view &requiredPackageName, const std::string_view &requiredFrom) { // "Rebase" the relative path to get a proper package-relative path - const std::string mergedSubpath = joinPath(requiredFrom, requiredPath); + const auto requiredFromDirParts = makeParentPath(requiredFrom); + const auto requiredPathParts = explodePath(requiredPath); + const std::string mergedSubpath = implodePath(joinPath(requiredFromDirParts, requiredPathParts)); if (!isModulePathLike(mergedSubpath)) { throw jsi::JSError(rt, "Computed subpath is invalid. Check `requiredPath` and `requiredFrom`."); } From e7fd0be27281eb5e3f08a3dc8c9bd7846ef82b09 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Tue, 20 May 2025 09:53:13 +0200 Subject: [PATCH 37/54] feat: add stub methods for interacting with require cache --- packages/host/cpp/CxxNodeApiHostModule.cpp | 15 +++++++++++++++ packages/host/cpp/CxxNodeApiHostModule.hpp | 9 +++++++++ 2 files changed, 24 insertions(+) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index 4d204631..4e586ded 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -308,4 +308,19 @@ bool CxxNodeApiHostModule::initializeNodeModule(jsi::Runtime &rt, return true; } +std::pair +CxxNodeApiHostModule::lookupRequireCache(::jsi::Runtime &rt, + const std::string_view &packageName, + const std::string_view &subpath) { + // TODO: Implement me + return std::make_pair(jsi::Value(), false); +} + +void CxxNodeApiHostModule::updateRequireCache(jsi::Runtime &rt, + const std::string_view &packageName, + const std::string_view &subpath, + jsi::Value &value) { + // TODO: Implement me +} + } // namespace callstack::nodeapihost diff --git a/packages/host/cpp/CxxNodeApiHostModule.hpp b/packages/host/cpp/CxxNodeApiHostModule.hpp index da3fc4a7..acd61652 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.hpp +++ b/packages/host/cpp/CxxNodeApiHostModule.hpp @@ -40,6 +40,15 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule { const std::string_view &requiredPackageName, const std::string_view &requiredFrom); + std::pair + lookupRequireCache(facebook::jsi::Runtime &rt, + const std::string_view &packageName, + const std::string_view &subpath); + void updateRequireCache(facebook::jsi::Runtime &rt, + const std::string_view &packageName, + const std::string_view &subpath, + facebook::jsi::Value &value); + protected: struct NodeAddon { void *moduleHandle; From 68a4a2e4c5b415b5b9d7c7d86c7655022e284289 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Tue, 20 May 2025 16:12:16 +0200 Subject: [PATCH 38/54] feat: make the `resolveRelativePath()` interact with (no-op) require cache This small change basically introduces "hooks" that can be used to properly implement a require cache --- packages/host/cpp/CxxNodeApiHostModule.cpp | 39 ++++++++++++++-------- 1 file changed, 25 insertions(+), 14 deletions(-) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index 4e586ded..6db0770d 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -209,24 +209,35 @@ CxxNodeApiHostModule::resolveRelativePath(facebook::jsi::Runtime &rt, throw jsi::JSError(rt, "Subpath must be relative and cannot leave its package root."); } - const std::string libraryNameStr(requiredPath); - auto [it, inserted] = nodeAddons_.emplace(libraryNameStr, NodeAddon()); - NodeAddon &addon = it->second; - - // Check if this module has been loaded already, if not then load it... - if (inserted) { - if (!loadNodeAddon(addon, libraryNameStr)) { - return jsi::Value::undefined(); + // Check whether (`requiredPackageName`, `mergedSubpath`) is already cached + // NOTE: Cache must to be `jsi::Runtime`-local + auto [exports, isCached] = lookupRequireCache(rt, + requiredPackageName, + mergedSubpath); + + if (!isCached) { + const std::string libraryNameStr(requiredPath); + auto [it, inserted] = nodeAddons_.emplace(libraryNameStr, NodeAddon()); + NodeAddon &addon = it->second; + + // Check if this module has been loaded already, if not then load it... + if (inserted) { + if (!loadNodeAddon(addon, libraryNameStr)) { + return jsi::Value::undefined(); + } + } + + // Initialize the addon if it has not already been initialized + if (!rt.global().hasProperty(rt, addon.generatedName.data())) { + initializeNodeModule(rt, addon); } - } - // Initialize the addon if it has not already been initialized - if (!rt.global().hasProperty(rt, addon.generatedName.data())) { - initializeNodeModule(rt, addon); + // Look up the exports (using JSI), add to cache and return + exports = rt.global().getProperty(rt, addon.generatedName.data()); + updateRequireCache(rt, requiredPackageName, mergedSubpath, exports); } - // Look the exports up (using JSI) and return it... - return rt.global().getProperty(rt, addon.generatedName.data()); + return std::move(exports); } bool CxxNodeApiHostModule::loadNodeAddon(NodeAddon &addon, From 4102043673e3329e8ad4aa107876373d3fe3e5e7 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 21 May 2025 02:05:41 +0200 Subject: [PATCH 39/54] feat: draft first implementation of AddonRegistry This change implements the AddonRegistry by recycling as much code from CxxNodeApiHostModule as possible. It solves https://github.com/callstackincubator/react-native-node-api-modules/issues/4 and partially https://github.com/callstackincubator/react-native-node-api-modules/issues/30 --- packages/host/cpp/AddonRegistry.cpp | 189 ++++++++++++++++++++++++++++ packages/host/cpp/AddonRegistry.hpp | 46 +++++++ 2 files changed, 235 insertions(+) create mode 100644 packages/host/cpp/AddonRegistry.cpp create mode 100644 packages/host/cpp/AddonRegistry.hpp diff --git a/packages/host/cpp/AddonRegistry.cpp b/packages/host/cpp/AddonRegistry.cpp new file mode 100644 index 00000000..fbeb7a89 --- /dev/null +++ b/packages/host/cpp/AddonRegistry.cpp @@ -0,0 +1,189 @@ +#include "AddonRegistry.hpp" +#include // for std::isalnum + +#ifndef NODE_API_DEFAULT_MODULE_API_VERSION +#define NODE_API_DEFAULT_MODULE_API_VERSION 8 +#endif + +using namespace facebook; + +namespace { +napi_status napi_emplace_named_property_object(napi_env env, + napi_value object, + const char *utf8Name, + napi_value *outObject) { + bool propertyFound = false; + napi_status status = napi_has_named_property(env, object, utf8Name, &propertyFound); + assert(napi_ok == status); + + assert(nullptr != outObject); + if (propertyFound) { + status = napi_get_named_property(env, object, utf8Name, outObject); + } else { + // Need to create it first + status = napi_create_object(env, outObject); + assert(napi_ok == status); + + status = napi_set_named_property(env, object, utf8Name, *outObject); + } + + return status; +} + +void sanitizeLibraryNameInplace(std::string &name) { +#if USING_PATCHED_BABEL_PLUGIN + // Strip the extension (if present) + // NOTE: This is needed when working with updated Babel plugin + if (auto pos = name.find(".node"); std::string::npos != pos) { + name = name.substr(0, pos); + } +#endif + + for (char &c : name) { + if (!std::isalnum(c)) { + c = '-'; + } + } +} +} // namespace + +namespace callstack::nodeapihost { + +AddonRegistry::NodeAddon& AddonRegistry::loadAddon(std::string packageName, + std::string subpath) { + const std::string fqan = packageName + subpath.substr(1); + auto [it, inserted] = + trackedAddons_.try_emplace(fqan, NodeAddon(packageName, subpath)); + NodeAddon &addon = it->second; + + sanitizeLibraryNameInplace(packageName); + sanitizeLibraryNameInplace(subpath); + const std::string libraryName = packageName + subpath; + + if (inserted || !it->second.isLoaded()) { +#if defined(__APPLE__) + const std::string libraryPath = "@rpath/" + libraryName + ".framework/" + libraryName; + tryLoadAddonAsDynamicLib(addon, libraryPath); +#elif defined(__ANDROID__) + const std::string libraryPath = "lib" + libraryName + ".so"; + tryLoadAddonAsDynamicLib(addon, libraryPath); +#else + abort(); +#endif + } + + return addon; +} + +bool AddonRegistry::tryLoadAddonAsDynamicLib(NodeAddon &addon, const std::string &path) { + // Load addon as dynamic library + typename LoaderPolicy::Module library = LoaderPolicy::loadLibrary(path.c_str()); + if (nullptr != library) { + // pending addon remains empty, we should look for the symbols... + typename LoaderPolicy::Symbol initFn = LoaderPolicy::getSymbol(library, "napi_register_module_v1"); + if (nullptr != initFn) { + addon.initFun_ = (napi_addon_register_func)initFn; + addon.moduleApiVersion_ = NODE_API_DEFAULT_MODULE_API_VERSION; + // This solves https://github.com/callstackincubator/react-native-node-api-modules/issues/4 + typename LoaderPolicy::Symbol getVersionFn = LoaderPolicy::getSymbol(library, "node_api_module_get_api_version_v1"); + if (nullptr != getVersionFn) { + addon.moduleApiVersion_ = ((node_api_addon_get_api_version_func)getVersionFn)(); + } + } + + if (nullptr != addon.initFun_) { + addon.moduleHandle_ = (void *)library; + addon.loadedFilePath_ = path; + } + } +} + +jsi::Value AddonRegistry::instantiateAddonInRuntime(jsi::Runtime &rt, NodeAddon &addon) { + // We should check if the module has already been initialized + assert(true == addon.isLoaded()); + assert(addon.moduleApiVersion_ > 0 && addon.moduleApiVersion_ <= 10); + + napi_status status = napi_ok; + napi_env env = reinterpret_cast(rt.createNodeApiEnv(addon.moduleApiVersion_)); + + // Create the "exports" object + napi_value exports; + status = napi_create_object(env, &exports); + assert(napi_ok == status); + + // Call the addon init function to populate the "exports" object + // Allowing it to replace the value entirely by its return value + // TODO: Check the return value (see Node.js specs) + exports = addon.initFun_(env, exports); + + // "Compute" the Fully Qualified Addon Path + const std::string fqap = addon.packageName_ + addon.subpath_.substr(1); + + { + napi_value descriptor; + status = createAddonDescriptor(env, exports, &descriptor); + assert(napi_ok == status); + + napi_value global; + napi_get_global(env, &global); + assert(napi_ok == status); + + status = storeAddonByFullPath(env, global, fqap, descriptor); + assert(napi_ok == status); + } + + return lookupAddonByFullPath(rt, fqap); +} + +napi_status AddonRegistry::createAddonDescriptor(napi_env env, napi_value exports, napi_value *outDescriptor) { + // Create the descriptor object + assert(nullptr != outDescriptor); + napi_status status = napi_create_object(env, outDescriptor); + + // Point the `env` property to the current `napi_env` + if (napi_ok == status) { + napi_value env_value; + status = napi_create_external(env, env, nullptr, nullptr, &env_value); + if (napi_ok == status) { + status = napi_set_named_property(env, *outDescriptor, "env", env_value); + } + } + + // Cache the addons exports in descriptor's `exports` property + if (napi_ok == status) { + status = napi_set_named_property(env, *outDescriptor, "exports", exports); + } + + return status; +} + +napi_status AddonRegistry::storeAddonByFullPath(napi_env env, napi_value global, const std::string &fqap, napi_value descriptor) { + // Get the internal registry object + napi_value registryObject; + napi_status status = napi_emplace_named_property_object(env, global, kInternalRegistryKey, ®istryObject); + assert(napi_ok == status); + + status = napi_set_named_property(env, registryObject, fqap.c_str(), descriptor); + return status; +} + +jsi::Value AddonRegistry::lookupAddonByFullPath(jsi::Runtime &rt, const std::string &fqap) { + // Get the internal registry object + jsi::Object global = rt.global(); + if (!global.hasProperty(rt, kInternalRegistryKey)) { + // Create it first + jsi::Object registryObject = jsi::Object(rt); + global.setProperty(rt, kInternalRegistryKey, registryObject); + } + jsi::Value registryValue = global.getProperty(rt, kInternalRegistryKey); + jsi::Object registryObject = registryValue.asObject(rt); + + // Lookup by addon path + jsi::Value addonValue(nullptr); + if (registryObject.hasProperty(rt, fqap.c_str())) { + addonValue = registryObject.getProperty(rt, fqap.c_str()); + } + return addonValue; +} + +} // namespace callstack::nodeapihost diff --git a/packages/host/cpp/AddonRegistry.hpp b/packages/host/cpp/AddonRegistry.hpp new file mode 100644 index 00000000..0531a629 --- /dev/null +++ b/packages/host/cpp/AddonRegistry.hpp @@ -0,0 +1,46 @@ +#pragma once + +#include +#include +#include +#include "AddonLoaders.hpp" + +// HACK: Feature flag that enables backwards-compatible code until PR is finished +#define USING_PATCHED_BABEL_PLUGIN 1 + +namespace callstack::nodeapihost { + +class AddonRegistry { +public: + struct NodeAddon { + NodeAddon(std::string packageName, std::string subpath) + : packageName_(packageName) + , subpath_(subpath) + {} + + inline bool isLoaded() const { return nullptr != initFun_; } + + std::string packageName_; + std::string subpath_; + std::string loadedFilePath_; + void *moduleHandle_ = nullptr; + napi_addon_register_func initFun_ = nullptr; + int32_t moduleApiVersion_; + }; + + NodeAddon& loadAddon(std::string packageName, std::string subpath); + facebook::jsi::Value instantiateAddonInRuntime(facebook::jsi::Runtime &rt, NodeAddon &addon); + + using LoaderPolicy = PosixLoader; // FIXME: HACK: This is temporary workaround + // for my lazyness (works on iOS and Android) +private: + bool tryLoadAddonAsDynamicLib(NodeAddon &addon, const std::string &path); + napi_status createAddonDescriptor(napi_env env, napi_value exports, napi_value *outDescriptor); + napi_status storeAddonByFullPath(napi_env env, napi_value global, const std::string &fqap, napi_value descriptor); + facebook::jsi::Value lookupAddonByFullPath(facebook::jsi::Runtime &rt, const std::string &fqap); + + static constexpr const char *kInternalRegistryKey = "$NodeApiHost"; + std::unordered_map trackedAddons_; +}; + +} // namespace callstack::nodeapihost From b5b7bf3f695a198c7da201908bb1767234f80ba6 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 21 May 2025 02:09:00 +0200 Subject: [PATCH 40/54] refactor: swap old TM addon loader with AddonRegistry --- packages/host/cpp/CxxNodeApiHostModule.cpp | 108 +++------------------ packages/host/cpp/CxxNodeApiHostModule.hpp | 11 --- 2 files changed, 11 insertions(+), 108 deletions(-) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index 6db0770d..63a328f6 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -4,6 +4,7 @@ #include // std::equal, std::all_of #include // std::isalnum #include "CxxNodeApiHostModule.hpp" +#include "AddonRegistry.hpp" #include "Logger.hpp" using namespace facebook; @@ -124,6 +125,8 @@ rpartition(const std::string_view &input, char delimiter) { namespace callstack::nodeapihost { +AddonRegistry g_platformAddonRegistry; + CxxNodeApiHostModule::CxxNodeApiHostModule( std::shared_ptr jsInvoker) : TurboModule(CxxNodeApiHostModule::kModuleName, jsInvoker) { @@ -216,109 +219,20 @@ CxxNodeApiHostModule::resolveRelativePath(facebook::jsi::Runtime &rt, mergedSubpath); if (!isCached) { - const std::string libraryNameStr(requiredPath); - auto [it, inserted] = nodeAddons_.emplace(libraryNameStr, NodeAddon()); - NodeAddon &addon = it->second; - - // Check if this module has been loaded already, if not then load it... - if (inserted) { - if (!loadNodeAddon(addon, libraryNameStr)) { - return jsi::Value::undefined(); - } - } - - // Initialize the addon if it has not already been initialized - if (!rt.global().hasProperty(rt, addon.generatedName.data())) { - initializeNodeModule(rt, addon); - } - - // Look up the exports (using JSI), add to cache and return - exports = rt.global().getProperty(rt, addon.generatedName.data()); + // Ask the global addon registry to load given Node-API addon. + // If other runtime loaded it already, the OS will return the same pointer. + // NOTE: This method might try multiple platform-specific paths. + const std::string packageNameCopy(requiredPackageName); + auto &addon = g_platformAddonRegistry.loadAddon(packageNameCopy, mergedSubpath); + + // Create a `napi_env` and initialize the addon + exports = g_platformAddonRegistry.instantiateAddonInRuntime(rt, addon); updateRequireCache(rt, requiredPackageName, mergedSubpath, exports); } return std::move(exports); } -bool CxxNodeApiHostModule::loadNodeAddon(NodeAddon &addon, - const std::string &libraryName) const { -#if defined(__APPLE__) - std::string libraryPath = - "@rpath/" + libraryName + ".framework/" + libraryName; -#elif defined(__ANDROID__) - std::string libraryPath = "lib" + libraryName + ".so"; -#else - abort() -#endif - - log_debug("[%s] Loading addon by '%s'", libraryName.c_str(), - libraryPath.c_str()); - - typename LoaderPolicy::Symbol initFn = NULL; - typename LoaderPolicy::Module library = - LoaderPolicy::loadLibrary(libraryPath.c_str()); - if (NULL != library) { - log_debug("[%s] Loaded addon", libraryName.c_str()); - addon.moduleHandle = library; - - // Generate a name allowing us to reference the exports object from JSI - // later Instead of using random numbers to avoid name clashes, we just use - // the pointer address of the loaded module - addon.generatedName.resize(32, '\0'); - snprintf(addon.generatedName.data(), addon.generatedName.size(), - "RN$NodeAddon_%p", addon.moduleHandle); - - initFn = LoaderPolicy::getSymbol(library, "napi_register_module_v1"); - if (NULL != initFn) { - log_debug("[%s] Found napi_register_module_v1 (%p)", libraryName.c_str(), - initFn); - addon.init = (napi_addon_register_func)initFn; - } else { - log_debug("[%s] Failed to find napi_register_module_v1. Expecting the " - "addon to call napi_module_register to register itself.", - libraryName.c_str()); - } - // TODO: Read "node_api_module_get_api_version_v1" to support the addon - // declaring its Node-API version - // @see - // https://github.com/callstackincubator/react-native-node-api/issues/4 - } else { - log_debug("[%s] Failed to load library", libraryName.c_str()); - } - return NULL != initFn; -} - -bool CxxNodeApiHostModule::initializeNodeModule(jsi::Runtime &rt, - NodeAddon &addon) { - // We should check if the module has already been initialized - assert(NULL != addon.moduleHandle); - assert(NULL != addon.init); - napi_status status = napi_ok; - // TODO: Read the version from the addon - // @see - // https://github.com/callstackincubator/react-native-node-api/issues/4 - napi_env env = reinterpret_cast(rt.createNodeApiEnv(8)); - - // Create the "exports" object - napi_value exports; - status = napi_create_object(env, &exports); - assert(status == napi_ok); - - // Call the addon init function to populate the "exports" object - // Allowing it to replace the value entirely by its return value - exports = addon.init(env, exports); - - napi_value global; - napi_get_global(env, &global); - assert(status == napi_ok); - - status = - napi_set_named_property(env, global, addon.generatedName.data(), exports); - assert(status == napi_ok); - - return true; -} - std::pair CxxNodeApiHostModule::lookupRequireCache(::jsi::Runtime &rt, const std::string_view &packageName, diff --git a/packages/host/cpp/CxxNodeApiHostModule.hpp b/packages/host/cpp/CxxNodeApiHostModule.hpp index acd61652..bdb25d31 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.hpp +++ b/packages/host/cpp/CxxNodeApiHostModule.hpp @@ -50,19 +50,8 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule { facebook::jsi::Value &value); protected: - struct NodeAddon { - void *moduleHandle; - napi_addon_register_func init; - std::string generatedName; - }; - std::unordered_map nodeAddons_; std::unordered_map prefixResolvers_; std::unordered_map packageOverrides_; - using LoaderPolicy = PosixLoader; // FIXME: HACK: This is temporary workaround - // for my lazyness (work on iOS and Android) - - bool loadNodeAddon(NodeAddon &addon, const std::string &path) const; - bool initializeNodeModule(facebook::jsi::Runtime &rt, NodeAddon &addon); }; } // namespace callstack::nodeapihost From 2befad7047192d4cfac4c9b1d72cc3e988842f5b Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 21 May 2025 02:14:23 +0200 Subject: [PATCH 41/54] feat: support modules that register via `napi_module_register()` This change solves https://github.com/callstackincubator/react-native-node-api-modules/issues/3 and https://github.com/callstackincubator/react-native-node-api-modules/issues/70 --- packages/host/cpp/AddonRegistry.cpp | 42 +++++++++++++++++----- packages/host/cpp/AddonRegistry.hpp | 2 ++ packages/host/cpp/CxxNodeApiHostModule.cpp | 7 ++++ 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/packages/host/cpp/AddonRegistry.cpp b/packages/host/cpp/AddonRegistry.cpp index fbeb7a89..b78d4e82 100644 --- a/packages/host/cpp/AddonRegistry.cpp +++ b/packages/host/cpp/AddonRegistry.cpp @@ -76,18 +76,31 @@ AddonRegistry::NodeAddon& AddonRegistry::loadAddon(std::string packageName, } bool AddonRegistry::tryLoadAddonAsDynamicLib(NodeAddon &addon, const std::string &path) { + { + // There can be only a SINGLE pending module (the same limitation + // has Node.js since Jan 28, 2014 commit 76b9846, see link below). + // We MUST clear it before attempting to load next addon. + // https://github.com/nodejs/node/blob/76b98462e589a69d9fd48ccb9fb5f6e96b539715/src/node.cc#L1949) + assert(nullptr == pendingRegistration_); + } + // Load addon as dynamic library typename LoaderPolicy::Module library = LoaderPolicy::loadLibrary(path.c_str()); if (nullptr != library) { - // pending addon remains empty, we should look for the symbols... - typename LoaderPolicy::Symbol initFn = LoaderPolicy::getSymbol(library, "napi_register_module_v1"); - if (nullptr != initFn) { - addon.initFun_ = (napi_addon_register_func)initFn; - addon.moduleApiVersion_ = NODE_API_DEFAULT_MODULE_API_VERSION; - // This solves https://github.com/callstackincubator/react-native-node-api-modules/issues/4 - typename LoaderPolicy::Symbol getVersionFn = LoaderPolicy::getSymbol(library, "node_api_module_get_api_version_v1"); - if (nullptr != getVersionFn) { - addon.moduleApiVersion_ = ((node_api_addon_get_api_version_func)getVersionFn)(); + if (nullptr != pendingRegistration_) { + // there is a pending addon that used the deprecated `napi_register_module()` + addon.initFun_ = pendingRegistration_; + } else { + // pending addon remains empty, we should look for the symbols... + typename LoaderPolicy::Symbol initFn = LoaderPolicy::getSymbol(library, "napi_register_module_v1"); + if (nullptr != initFn) { + addon.initFun_ = (napi_addon_register_func)initFn; + addon.moduleApiVersion_ = NODE_API_DEFAULT_MODULE_API_VERSION; + // This solves https://github.com/callstackincubator/react-native-node-api-modules/issues/4 + typename LoaderPolicy::Symbol getVersionFn = LoaderPolicy::getSymbol(library, "node_api_module_get_api_version_v1"); + if (nullptr != getVersionFn) { + addon.moduleApiVersion_ = ((node_api_addon_get_api_version_func)getVersionFn)(); + } } } @@ -96,6 +109,11 @@ bool AddonRegistry::tryLoadAddonAsDynamicLib(NodeAddon &addon, const std::string addon.loadedFilePath_ = path; } } + + // We MUST clear the `pendingAddon_`, even when the module failed to load! + // See: https://github.com/nodejs/node/commit/a60056df3cad2867d337fc1d7adeebe66f89031a + pendingRegistration_ = nullptr; + return addon.isLoaded(); } jsi::Value AddonRegistry::instantiateAddonInRuntime(jsi::Runtime &rt, NodeAddon &addon) { @@ -135,6 +153,12 @@ jsi::Value AddonRegistry::instantiateAddonInRuntime(jsi::Runtime &rt, NodeAddon return lookupAddonByFullPath(rt, fqap); } +bool AddonRegistry::handleOldNapiModuleRegister(napi_addon_register_func addonInitFunc) { + assert(nullptr == pendingRegistration_); + pendingRegistration_ = addonInitFunc; + return true; +} + napi_status AddonRegistry::createAddonDescriptor(napi_env env, napi_value exports, napi_value *outDescriptor) { // Create the descriptor object assert(nullptr != outDescriptor); diff --git a/packages/host/cpp/AddonRegistry.hpp b/packages/host/cpp/AddonRegistry.hpp index 0531a629..b6da6091 100644 --- a/packages/host/cpp/AddonRegistry.hpp +++ b/packages/host/cpp/AddonRegistry.hpp @@ -30,6 +30,7 @@ class AddonRegistry { NodeAddon& loadAddon(std::string packageName, std::string subpath); facebook::jsi::Value instantiateAddonInRuntime(facebook::jsi::Runtime &rt, NodeAddon &addon); + bool handleOldNapiModuleRegister(napi_addon_register_func addonInitFunc); using LoaderPolicy = PosixLoader; // FIXME: HACK: This is temporary workaround // for my lazyness (works on iOS and Android) @@ -41,6 +42,7 @@ class AddonRegistry { static constexpr const char *kInternalRegistryKey = "$NodeApiHost"; std::unordered_map trackedAddons_; + napi_addon_register_func pendingRegistration_; }; } // namespace callstack::nodeapihost diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index 63a328f6..cc20c073 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -248,4 +248,11 @@ void CxxNodeApiHostModule::updateRequireCache(jsi::Runtime &rt, // TODO: Implement me } +extern "C" { +NAPI_EXTERN void NAPI_CDECL napi_module_register(napi_module *mod) { + assert(NULL != mod && NULL != mod->nm_register_func); + g_platformAddonRegistry.handleOldNapiModuleRegister(mod->nm_register_func); +} +} // extern "C" + } // namespace callstack::nodeapihost From 3b3024181953c89a8975802bee7050201b917a95 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 21 May 2025 02:38:26 +0200 Subject: [PATCH 42/54] fixup: update params in NativeNodeApiHost spec --- packages/host/cpp/CxxNodeApiHostModule.cpp | 2 +- packages/host/src/react-native/NativeNodeApiHost.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index cc20c073..36acebcb 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -131,7 +131,7 @@ CxxNodeApiHostModule::CxxNodeApiHostModule( std::shared_ptr jsInvoker) : TurboModule(CxxNodeApiHostModule::kModuleName, jsInvoker) { methodMap_["requireNodeAddon"] = - MethodMetadata{1, &CxxNodeApiHostModule::requireNodeAddon}; + MethodMetadata{3, &CxxNodeApiHostModule::requireNodeAddon}; } jsi::Value diff --git a/packages/host/src/react-native/NativeNodeApiHost.ts b/packages/host/src/react-native/NativeNodeApiHost.ts index fdf04dc8..9737b60a 100644 --- a/packages/host/src/react-native/NativeNodeApiHost.ts +++ b/packages/host/src/react-native/NativeNodeApiHost.ts @@ -2,7 +2,7 @@ import type { TurboModule } from "react-native"; import { TurboModuleRegistry } from "react-native"; export interface Spec extends TurboModule { - requireNodeAddon(libraryName: string): void; + requireNodeAddon(requiredPath: string, packageName?: string, requiredFrom?: string): void; } export default TurboModuleRegistry.getEnforcing("NodeApiHost"); From f4130a1e29f2c60215d2c485cb0c93abe8c9dd16 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Mon, 9 Jun 2025 14:27:13 +0200 Subject: [PATCH 43/54] fix: implement proper extension stripping --- packages/host/cpp/AddonRegistry.cpp | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/host/cpp/AddonRegistry.cpp b/packages/host/cpp/AddonRegistry.cpp index b78d4e82..3de39515 100644 --- a/packages/host/cpp/AddonRegistry.cpp +++ b/packages/host/cpp/AddonRegistry.cpp @@ -30,13 +30,28 @@ napi_status napi_emplace_named_property_object(napi_env env, return status; } +bool endsWith(const std::string &str, const std::string &suffix) { +#if __cplusplus >= 202002L // __cpp_lib_starts_ends_with + return str.ends_with(suffix); +#else + return str.size() >= suffix.size() + && std::equal(suffix.rbegin(), suffix.rend(), str.rbegin()); +#endif +} + +std::string_view stripSuffix(const std::string_view &str, const std::string_view &suffix) { + if (endsWith(str, suffix)) { + return str.substr(0, str.size() - suffix.size()); + } else { + return str; + } +} + void sanitizeLibraryNameInplace(std::string &name) { #if USING_PATCHED_BABEL_PLUGIN // Strip the extension (if present) // NOTE: This is needed when working with updated Babel plugin - if (auto pos = name.find(".node"); std::string::npos != pos) { - name = name.substr(0, pos); - } + name = stripSuffix(name, ".node"); #endif for (char &c : name) { From 6b441b50c58492deae92f97e12b93135ac2602e6 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 18 Jun 2025 10:57:44 +0200 Subject: [PATCH 44/54] feat: extract the "twisted" node api addon example from "wip-refactor-cpp" --- .../2_function_arguments_twisted/napi/addon.c | 57 ++++ .../napi/binding.gyp | 11 + .../napi/node_api_deprecated.h | 269 ++++++++++++++++++ .../napi/package-lock.json | 30 ++ .../napi/package.json | 19 ++ 5 files changed, 386 insertions(+) create mode 100644 packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/addon.c create mode 100644 packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/binding.gyp create mode 100644 packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/node_api_deprecated.h create mode 100644 packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/package-lock.json create mode 100644 packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/package.json diff --git a/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/addon.c b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/addon.c new file mode 100644 index 00000000..1ae258f3 --- /dev/null +++ b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/addon.c @@ -0,0 +1,57 @@ +#include +#include "node_api_deprecated.h" +#include + +static napi_value Add(napi_env env, napi_callback_info info) { + napi_status status; + + size_t argc = 2; + napi_value args[2]; + status = napi_get_cb_info(env, info, &argc, args, NULL, NULL); + assert(status == napi_ok); + + if (argc < 2) { + napi_throw_type_error(env, NULL, "Wrong number of arguments"); + return NULL; + } + + napi_valuetype valuetype0; + status = napi_typeof(env, args[0], &valuetype0); + assert(status == napi_ok); + + napi_valuetype valuetype1; + status = napi_typeof(env, args[1], &valuetype1); + assert(status == napi_ok); + + if (valuetype0 != napi_number || valuetype1 != napi_number) { + napi_throw_type_error(env, NULL, "Wrong arguments"); + return NULL; + } + + double value0; + status = napi_get_value_double(env, args[0], &value0); + assert(status == napi_ok); + + double value1; + status = napi_get_value_double(env, args[1], &value1); + assert(status == napi_ok); + + napi_value sum; + status = napi_create_double(env, value0 + value1, &sum); + assert(status == napi_ok); + + return sum; +} + +#define DECLARE_NAPI_METHOD(name, func) \ + { name, 0, func, 0, 0, 0, napi_default, 0 } + +napi_value Init(napi_env env, napi_value exports) { + napi_status status; + napi_property_descriptor addDescriptor = DECLARE_NAPI_METHOD("add", Add); + status = napi_define_properties(env, exports, 1, &addDescriptor); + assert(status == napi_ok); + return exports; +} + +NAPI_MODULE(NODE_GYP_MODULE_NAME, Init) diff --git a/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/binding.gyp b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/binding.gyp new file mode 100644 index 00000000..7be488b5 --- /dev/null +++ b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/binding.gyp @@ -0,0 +1,11 @@ +{ + "targets": [ + { + "target_name": "addon_twisted", + "sources": [ "addon.c" ], + "defines": [ + "NAPI_VERSION=4" + ], + } + ] +} diff --git a/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/node_api_deprecated.h b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/node_api_deprecated.h new file mode 100644 index 00000000..59d493bb --- /dev/null +++ b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/node_api_deprecated.h @@ -0,0 +1,269 @@ +// Taken from https://github.com/nodejs/node-api-headers/blob/2a5355a65e06b081154e584ca5b0574c9c07b61d/include/node_api.h +#ifndef SRC_NODE_API_H_ +#define SRC_NODE_API_H_ + +#ifdef BUILDING_NODE_EXTENSION + #ifdef _WIN32 + // Building native module against node + #define NAPI_EXTERN __declspec(dllimport) + #elif defined(__wasm32__) + #define NAPI_EXTERN __attribute__((__import_module__("napi"))) + #endif +#endif +#include +#include + +struct uv_loop_s; // Forward declaration. + +#ifdef _WIN32 +# define NAPI_MODULE_EXPORT __declspec(dllexport) +#else +# define NAPI_MODULE_EXPORT __attribute__((visibility("default"))) +#endif + +#if defined(__GNUC__) +# define NAPI_NO_RETURN __attribute__((noreturn)) +#elif defined(_WIN32) +# define NAPI_NO_RETURN __declspec(noreturn) +#else +# define NAPI_NO_RETURN +#endif + +typedef napi_value (*napi_addon_register_func)(napi_env env, + napi_value exports); + +typedef struct napi_module { + int nm_version; + unsigned int nm_flags; + const char* nm_filename; + napi_addon_register_func nm_register_func; + const char* nm_modname; + void* nm_priv; + void* reserved[4]; +} napi_module; + +#define NAPI_MODULE_VERSION 1 + +#if defined(_MSC_VER) +#pragma section(".CRT$XCU", read) +#define NAPI_C_CTOR(fn) \ + static void __cdecl fn(void); \ + __declspec(dllexport, allocate(".CRT$XCU")) void(__cdecl * fn##_)(void) = \ + fn; \ + static void __cdecl fn(void) +#else +#define NAPI_C_CTOR(fn) \ + static void fn(void) __attribute__((constructor)); \ + static void fn(void) +#endif + +#define NAPI_MODULE_X(modname, regfunc, priv, flags) \ + EXTERN_C_START \ + static napi_module _module = \ + { \ + NAPI_MODULE_VERSION, \ + flags, \ + __FILE__, \ + regfunc, \ + #modname, \ + priv, \ + {0}, \ + }; \ + NAPI_C_CTOR(_register_ ## modname) { \ + napi_module_register(&_module); \ + } \ + EXTERN_C_END + +#define NAPI_MODULE_INITIALIZER_X(base, version) \ + NAPI_MODULE_INITIALIZER_X_HELPER(base, version) +#define NAPI_MODULE_INITIALIZER_X_HELPER(base, version) base##version + +#ifdef __wasm32__ +#define NAPI_WASM_INITIALIZER \ + NAPI_MODULE_INITIALIZER_X(napi_register_wasm_v, NAPI_MODULE_VERSION) +#define NAPI_MODULE(modname, regfunc) \ + EXTERN_C_START \ + NAPI_MODULE_EXPORT napi_value NAPI_WASM_INITIALIZER(napi_env env, \ + napi_value exports) { \ + return regfunc(env, exports); \ + } \ + EXTERN_C_END +#else +#define NAPI_MODULE(modname, regfunc) \ + NAPI_MODULE_X(modname, regfunc, NULL, 0) // NOLINT (readability/null_usage) +#endif + +#define NAPI_MODULE_INITIALIZER_BASE napi_register_module_v + +#define NAPI_MODULE_INITIALIZER \ + NAPI_MODULE_INITIALIZER_X(NAPI_MODULE_INITIALIZER_BASE, \ + NAPI_MODULE_VERSION) + +#define NAPI_MODULE_INIT() \ + EXTERN_C_START \ + NAPI_MODULE_EXPORT napi_value \ + NAPI_MODULE_INITIALIZER(napi_env env, napi_value exports); \ + EXTERN_C_END \ + NAPI_MODULE(NODE_GYP_MODULE_NAME, NAPI_MODULE_INITIALIZER) \ + napi_value NAPI_MODULE_INITIALIZER(napi_env env, \ + napi_value exports) + +EXTERN_C_START + +NAPI_EXTERN void napi_module_register(napi_module* mod); + +NAPI_EXTERN NAPI_NO_RETURN void napi_fatal_error(const char* location, + size_t location_len, + const char* message, + size_t message_len); + +// Methods for custom handling of async operations +NAPI_EXTERN napi_status napi_async_init(napi_env env, + napi_value async_resource, + napi_value async_resource_name, + napi_async_context* result); + +NAPI_EXTERN napi_status napi_async_destroy(napi_env env, + napi_async_context async_context); + +NAPI_EXTERN napi_status napi_make_callback(napi_env env, + napi_async_context async_context, + napi_value recv, + napi_value func, + size_t argc, + const napi_value* argv, + napi_value* result); + +// Methods to provide node::Buffer functionality with napi types +NAPI_EXTERN napi_status napi_create_buffer(napi_env env, + size_t length, + void** data, + napi_value* result); +NAPI_EXTERN napi_status napi_create_external_buffer(napi_env env, + size_t length, + void* data, + napi_finalize finalize_cb, + void* finalize_hint, + napi_value* result); +NAPI_EXTERN napi_status napi_create_buffer_copy(napi_env env, + size_t length, + const void* data, + void** result_data, + napi_value* result); +NAPI_EXTERN napi_status napi_is_buffer(napi_env env, + napi_value value, + bool* result); +NAPI_EXTERN napi_status napi_get_buffer_info(napi_env env, + napi_value value, + void** data, + size_t* length); + +// Methods to manage simple async operations +NAPI_EXTERN +napi_status napi_create_async_work(napi_env env, + napi_value async_resource, + napi_value async_resource_name, + napi_async_execute_callback execute, + napi_async_complete_callback complete, + void* data, + napi_async_work* result); +NAPI_EXTERN napi_status napi_delete_async_work(napi_env env, + napi_async_work work); +NAPI_EXTERN napi_status napi_queue_async_work(napi_env env, + napi_async_work work); +NAPI_EXTERN napi_status napi_cancel_async_work(napi_env env, + napi_async_work work); + +// version management +NAPI_EXTERN +napi_status napi_get_node_version(napi_env env, + const napi_node_version** version); + +#if NAPI_VERSION >= 2 + +// Return the current libuv event loop for a given environment +NAPI_EXTERN napi_status napi_get_uv_event_loop(napi_env env, + struct uv_loop_s** loop); + +#endif // NAPI_VERSION >= 2 + +#if NAPI_VERSION >= 3 + +NAPI_EXTERN napi_status napi_fatal_exception(napi_env env, napi_value err); + +NAPI_EXTERN napi_status napi_add_env_cleanup_hook(napi_env env, + void (*fun)(void* arg), + void* arg); + +NAPI_EXTERN napi_status napi_remove_env_cleanup_hook(napi_env env, + void (*fun)(void* arg), + void* arg); + +NAPI_EXTERN napi_status napi_open_callback_scope(napi_env env, + napi_value resource_object, + napi_async_context context, + napi_callback_scope* result); + +NAPI_EXTERN napi_status napi_close_callback_scope(napi_env env, + napi_callback_scope scope); + +#endif // NAPI_VERSION >= 3 + +#if NAPI_VERSION >= 4 + +#ifndef __wasm32__ +// Calling into JS from other threads +NAPI_EXTERN napi_status +napi_create_threadsafe_function(napi_env env, + napi_value func, + napi_value async_resource, + napi_value async_resource_name, + size_t max_queue_size, + size_t initial_thread_count, + void* thread_finalize_data, + napi_finalize thread_finalize_cb, + void* context, + napi_threadsafe_function_call_js call_js_cb, + napi_threadsafe_function* result); + +NAPI_EXTERN napi_status +napi_get_threadsafe_function_context(napi_threadsafe_function func, + void** result); + +NAPI_EXTERN napi_status +napi_call_threadsafe_function(napi_threadsafe_function func, + void* data, + napi_threadsafe_function_call_mode is_blocking); + +NAPI_EXTERN napi_status +napi_acquire_threadsafe_function(napi_threadsafe_function func); + +NAPI_EXTERN napi_status +napi_release_threadsafe_function(napi_threadsafe_function func, + napi_threadsafe_function_release_mode mode); + +NAPI_EXTERN napi_status +napi_unref_threadsafe_function(napi_env env, napi_threadsafe_function func); + +NAPI_EXTERN napi_status +napi_ref_threadsafe_function(napi_env env, napi_threadsafe_function func); +#endif // __wasm32__ + +#endif // NAPI_VERSION >= 4 + +#ifdef NAPI_EXPERIMENTAL + +NAPI_EXTERN napi_status napi_add_async_cleanup_hook( + napi_env env, + napi_async_cleanup_hook hook, + void* arg, + napi_async_cleanup_hook_handle* remove_handle); + +NAPI_EXTERN napi_status napi_remove_async_cleanup_hook( + napi_async_cleanup_hook_handle remove_handle); + +#endif // NAPI_EXPERIMENTAL + +EXTERN_C_END + +#endif // SRC_NODE_API_H_ diff --git a/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/package-lock.json b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/package-lock.json new file mode 100644 index 00000000..5e9323cd --- /dev/null +++ b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "@callstackincubator/example-3", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@callstackincubator/example-3", + "version": "0.0.0", + "dependencies": { + "bindings": "~1.5.0" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + } + } +} diff --git a/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/package.json b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/package.json new file mode 100644 index 00000000..d1cd9a44 --- /dev/null +++ b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/package.json @@ -0,0 +1,19 @@ +{ + "name": "@callstackincubator/example-3", + "version": "0.0.0", + "description": "Node.js Addons Example #2", + "main": "./build/Release/addon_twisted.node", + "private": true, + "dependencies": { + "bindings": "~1.5.0" + }, + "scripts": { + "test": "node addon.js" + }, + "binary": { + "napi_versions": [4] + }, + "gypfile": true, + "readme": "ERROR: No README data found!", + "_id": "function_arguments@0.0.0" +} \ No newline at end of file From a9f3be0b4f46b7a00e63d6911534db6cc60141b9 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 18 Jun 2025 11:00:23 +0200 Subject: [PATCH 45/54] fix: include the generated (and corrected) CMakeList This is basically the generated CMakeList file (from binding.gyp), but with fixed NAPI_VERSION to 4 (as the conversion tool does not respect this setting) --- .../napi/CMakeLists.txt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/CMakeLists.txt diff --git a/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/CMakeLists.txt b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/CMakeLists.txt new file mode 100644 index 00000000..404e3114 --- /dev/null +++ b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/CMakeLists.txt @@ -0,0 +1,15 @@ +cmake_minimum_required(VERSION 3.15) +project(examples-1-getting-started-2_function_arguments_twisted-napi) + +add_compile_definitions(-DNAPI_VERSION=4) + +add_library(addon_twisted SHARED addon.c ${CMAKE_JS_SRC}) +set_target_properties(addon_twisted PROPERTIES PREFIX "" SUFFIX ".node") +target_include_directories(addon_twisted PRIVATE ${CMAKE_JS_INC}) +target_link_libraries(addon_twisted PRIVATE ${CMAKE_JS_LIB}) +target_compile_features(addon_twisted PRIVATE cxx_std_17) + +if(MSVC AND CMAKE_JS_NODELIB_DEF AND CMAKE_JS_NODELIB_TARGET) + # Generate node.lib + execute_process(COMMAND ${CMAKE_AR} /def:${CMAKE_JS_NODELIB_DEF} /out:${CMAKE_JS_NODELIB_TARGET} ${CMAKE_STATIC_LINKER_FLAGS}) +endif() From d5bf7b45e94bff37aee73ce1e65faa16455e16fd Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 11 Jun 2025 18:56:09 +0200 Subject: [PATCH 46/54] chore: rename `requiredFrom` param to `originalId` --- packages/host/cpp/CxxNodeApiHostModule.cpp | 41 ++++++++----------- packages/host/cpp/CxxNodeApiHostModule.hpp | 6 +-- .../src/react-native/NativeNodeApiHost.ts | 2 +- 3 files changed, 20 insertions(+), 29 deletions(-) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index 36acebcb..1fbbcf11 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -140,7 +140,7 @@ CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, const jsi::Value args[], size_t count) { auto &thisModule = static_cast(turboModule); if (3 == count) { - // Must be `requireNodeAddon(requiredPath: string, requiredPackageName: string, requiredFrom: string)` + // Must be `requireNodeAddon(requiredPath: string, requiredPackageName: string, originalId: string)` return thisModule.requireNodeAddon(rt, args[0].asString(rt), args[1].asString(rt), @@ -153,25 +153,22 @@ jsi::Value CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, const jsi::String &requiredPath, const jsi::String &requiredPackageName, - const jsi::String &requiredFrom) { + const jsi::String &originalId) { return requireNodeAddon(rt, requiredPath.utf8(rt), requiredPackageName.utf8(rt), - requiredFrom.utf8(rt)); + originalId.utf8(rt)); } jsi::Value CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, const std::string &requiredPath, const std::string &requiredPackageName, - const std::string &requiredFrom) { + const std::string &originalId) { // Ensure that user-supplied inputs contain only allowed characters if (!isModulePathLike(requiredPath)) { throw jsi::JSError(rt, "Invalid characters in `requiredPath`. Only ASCII alphanumerics are allowed."); } - if (!isModulePathLike(requiredFrom)) { - throw jsi::JSError(rt, "Invalid characters in `requiredFrom`. Only ASCII alphanumerics are allowed."); - } // Check if this is a prefixed import (e.g. `node:fs/promises`) const auto [pathPrefix, strippedPath] = rpartition(requiredPath, ':'); @@ -180,7 +177,7 @@ CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, std::string pathPrefixCopy(pathPrefix); // HACK: Need explicit cast to `std::string` if (auto handler = prefixResolvers_.find(pathPrefixCopy); prefixResolvers_.end() != handler) { // HACK: Smuggle the `pathPrefix` as new `requiredPackageName` - return (handler->second)(rt, strippedPath, pathPrefix, requiredFrom); + return (handler->second)(rt, strippedPath, pathPrefix, originalId); } else { throw jsi::JSError(rt, "Unsupported protocol or prefix \"" + pathPrefixCopy + "\". Have you registered it?"); } @@ -189,45 +186,39 @@ CxxNodeApiHostModule::requireNodeAddon(jsi::Runtime &rt, // Check, if this package has been overridden if (auto handler = packageOverrides_.find(requiredPackageName); packageOverrides_.end() != handler) { // This package has a custom resolver, invoke it - return (handler->second)(rt, strippedPath, requiredPackageName, requiredFrom); + return (handler->second)(rt, strippedPath, requiredPackageName, originalId); } - // Otherwise, "requiredPath" must be a "relative specifier" or a "bare specifier" - return resolveRelativePath(rt, strippedPath, requiredPackageName, requiredFrom); + // Otherwise, "requiredPath" must be a package-relative specifier + return resolveRelativePath(rt, strippedPath, requiredPackageName, originalId); } jsi::Value CxxNodeApiHostModule::resolveRelativePath(facebook::jsi::Runtime &rt, const std::string_view &requiredPath, const std::string_view &requiredPackageName, - const std::string_view &requiredFrom) { - // "Rebase" the relative path to get a proper package-relative path - const auto requiredFromDirParts = makeParentPath(requiredFrom); - const auto requiredPathParts = explodePath(requiredPath); - const std::string mergedSubpath = implodePath(joinPath(requiredFromDirParts, requiredPathParts)); - if (!isModulePathLike(mergedSubpath)) { - throw jsi::JSError(rt, "Computed subpath is invalid. Check `requiredPath` and `requiredFrom`."); - } - if (!startsWith(mergedSubpath, "./")) { - throw jsi::JSError(rt, "Subpath must be relative and cannot leave its package root."); + const std::string_view &originalId) { + if (!startsWith(requiredPath, "./")) { + throw jsi::JSError(rt, "requiredPath must be relative and cannot leave its package root."); } - // Check whether (`requiredPackageName`, `mergedSubpath`) is already cached + // Check whether (`requiredPackageName`, `requiredPath`) is already cached // NOTE: Cache must to be `jsi::Runtime`-local auto [exports, isCached] = lookupRequireCache(rt, requiredPackageName, - mergedSubpath); + requiredPath); if (!isCached) { // Ask the global addon registry to load given Node-API addon. // If other runtime loaded it already, the OS will return the same pointer. // NOTE: This method might try multiple platform-specific paths. const std::string packageNameCopy(requiredPackageName); - auto &addon = g_platformAddonRegistry.loadAddon(packageNameCopy, mergedSubpath); + const std::string requiredPathCopy(requiredPath); + auto &addon = g_platformAddonRegistry.loadAddon(packageNameCopy, requiredPathCopy); // Create a `napi_env` and initialize the addon exports = g_platformAddonRegistry.instantiateAddonInRuntime(rt, addon); - updateRequireCache(rt, requiredPackageName, mergedSubpath, exports); + updateRequireCache(rt, requiredPackageName, requiredPath, exports); } return std::move(exports); diff --git a/packages/host/cpp/CxxNodeApiHostModule.hpp b/packages/host/cpp/CxxNodeApiHostModule.hpp index bdb25d31..bc2e87e9 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.hpp +++ b/packages/host/cpp/CxxNodeApiHostModule.hpp @@ -29,16 +29,16 @@ class JSI_EXPORT CxxNodeApiHostModule : public facebook::react::TurboModule { facebook::jsi::Value requireNodeAddon(facebook::jsi::Runtime &rt, const facebook::jsi::String &requiredPath, const facebook::jsi::String &requiredPackageName, - const facebook::jsi::String &requiredFrom); + const facebook::jsi::String &originalId); facebook::jsi::Value requireNodeAddon(facebook::jsi::Runtime &rt, const std::string &requiredPath, const std::string &requiredPackageName, - const std::string &requiredFrom); + const std::string &originalId); facebook::jsi::Value resolveRelativePath(facebook::jsi::Runtime &rt, const std::string_view &requiredPath, const std::string_view &requiredPackageName, - const std::string_view &requiredFrom); + const std::string_view &originalId); std::pair lookupRequireCache(facebook::jsi::Runtime &rt, diff --git a/packages/host/src/react-native/NativeNodeApiHost.ts b/packages/host/src/react-native/NativeNodeApiHost.ts index 9737b60a..fb254a22 100644 --- a/packages/host/src/react-native/NativeNodeApiHost.ts +++ b/packages/host/src/react-native/NativeNodeApiHost.ts @@ -2,7 +2,7 @@ import type { TurboModule } from "react-native"; import { TurboModuleRegistry } from "react-native"; export interface Spec extends TurboModule { - requireNodeAddon(requiredPath: string, packageName?: string, requiredFrom?: string): void; + requireNodeAddon(requiredPath: string, packageName?: string, originalId?: string): void; } export default TurboModuleRegistry.getEnforcing("NodeApiHost"); From 6179a4d864292aecba87c3fa2a95e319f18b4f77 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 11 Jun 2025 18:56:42 +0200 Subject: [PATCH 47/54] fixup: fix types in `endsWith` (use `string_views`) --- packages/host/cpp/AddonRegistry.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/host/cpp/AddonRegistry.cpp b/packages/host/cpp/AddonRegistry.cpp index 3de39515..b3c95de1 100644 --- a/packages/host/cpp/AddonRegistry.cpp +++ b/packages/host/cpp/AddonRegistry.cpp @@ -30,7 +30,7 @@ napi_status napi_emplace_named_property_object(napi_env env, return status; } -bool endsWith(const std::string &str, const std::string &suffix) { +bool endsWith(const std::string_view &str, const std::string_view &suffix) { #if __cplusplus >= 202002L // __cpp_lib_starts_ends_with return str.ends_with(suffix); #else From 437ba9b7a10a64cb01135733224eb35a8cfb4a54 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 11 Jun 2025 18:57:16 +0200 Subject: [PATCH 48/54] fix: change param types of `startsWith` to `string_view`s --- packages/host/cpp/CxxNodeApiHostModule.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index 1fbbcf11..4e54fd54 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -11,7 +11,7 @@ using namespace facebook; namespace { -bool startsWith(const std::string &str, const std::string &prefix) { +bool startsWith(const std::string_view &str, const std::string_view &prefix) { #if __cplusplus >= 202002L // __cpp_lib_starts_ends_with return str.starts_with(prefix); #else From 8cd8791170aa4ac07a479a95f68292573108cc20 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 18 Jun 2025 16:52:04 +0200 Subject: [PATCH 49/54] feat: add `2_function_arguments_twisted/napi` to list of examples --- packages/node-addon-examples/index.js | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/node-addon-examples/index.js b/packages/node-addon-examples/index.js index 31ac0c3a..549658d4 100644 --- a/packages/node-addon-examples/index.js +++ b/packages/node-addon-examples/index.js @@ -5,6 +5,7 @@ module.exports = { "1_hello_world/node-addon-api-addon-class": () => require("./examples/1-getting-started/1_hello_world/node-addon-api-addon-class/hello.js"), "2_function_arguments/napi": () => require("./examples/1-getting-started/2_function_arguments/napi/addon.js"), "2_function_arguments/node-addon-api": () => require("./examples/1-getting-started/2_function_arguments/node-addon-api/addon.js"), + "2_function_arguments_twisted/napi": () => require("@callstackincubator/example-3"), "3_callbacks/napi": () => require("./examples/1-getting-started/3_callbacks/napi/addon.js"), "3_callbacks/node-addon-api": () => require("./examples/1-getting-started/3_callbacks/node-addon-api/addon.js"), "4_object_factory/napi": () => require("./examples/1-getting-started/4_object_factory/napi/addon.js"), From 3481e824fa944e9c205145d8fd5e1d00ccd6e6ab Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Wed, 18 Jun 2025 19:34:54 +0200 Subject: [PATCH 50/54] fix: defer calling `napi_module_register()` until weak node api injection Early implementation just to get the app running. Without this change, the application crashes immediately after process was created. This is triggered by dynamic linker load addons at start up, and the "twisted addon" has a function decorated with `__attribute__ ((constructor))` which calls `napi_module_register()` before even Weak NodeAPI has change to inject the proper function pointers... --- .../generate-weak-node-api-injector.ts | 1 + .../host/scripts/generate-weak-node-api.ts | 46 +++++++++++++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/host/scripts/generate-weak-node-api-injector.ts b/packages/host/scripts/generate-weak-node-api-injector.ts index ff73b0ed..d9073edc 100644 --- a/packages/host/scripts/generate-weak-node-api-injector.ts +++ b/packages/host/scripts/generate-weak-node-api-injector.ts @@ -42,6 +42,7 @@ export function generateSource(functions: FunctionDecl[]) { log_debug("Injecting WeakNodeApiHost"); inject_weak_node_api_host(WeakNodeApiHost { + .napi_module_register = napi_module_register, ${functions .filter(({ kind }) => kind === "engine") .flatMap(({ name }) => `.${name} = ${name},`) diff --git a/packages/host/scripts/generate-weak-node-api.ts b/packages/host/scripts/generate-weak-node-api.ts index 23a3c89d..c33847b2 100644 --- a/packages/host/scripts/generate-weak-node-api.ts +++ b/packages/host/scripts/generate-weak-node-api.ts @@ -33,17 +33,43 @@ export function generateHeader(functions: FunctionDecl[]) { * Generates source code for a version script for the given Node API version. */ export function generateSource(functions: FunctionDecl[]) { + const interceptModuleRegisterCalls = true; return [ "// This file is generated by react-native-node-api", + `#define WITH_DEFERRED_NAPI_MODULE_REGISTER ${interceptModuleRegisterCalls ? 1 : 0}`, `#include "weak_node_api.hpp"`, // Generated header + "", + // Declare globals needed for bookkeeping intercepted calls + "#if WITH_DEFERRED_NAPI_MODULE_REGISTER", + "#include ", + "#include ", + "std::mutex g_internal_state_mutex;", + "std::vector g_pending_modules;", + "#endif // WITH_DEFERRED_NAPI_MODULE_REGISTER", + "", // Generate the struct of function pointers "WeakNodeApiHost g_host;", "void inject_weak_node_api_host(const WeakNodeApiHost& host) {", " g_host = host;", + "", + "#if WITH_DEFERRED_NAPI_MODULE_REGISTER", + " // Flush pending `napi_module_register()` calls", + " if (nullptr != host.napi_module_register) {", + " std::lock_guard lock(g_internal_state_mutex);", + " fprintf(stderr,", + ` "Flushing %zu intercepted calls to 'napi_module_register'...\\n",`, + " g_pending_modules.size());", + " for (napi_module *module : g_pending_modules) {", + " host.napi_module_register(module);", + " }", + " g_pending_modules.clear();", + " }", + "#endif // WITH_DEFERRED_NAPI_MODULE_REGISTER", "};", - ``, + "", // Generate function calling into the host ...functions.flatMap(({ returnType, noReturn, name, argumentTypes }) => { + const isDeferrable = name === "napi_module_register"; return [ `extern "C" ${returnType} ${ noReturn ? " __attribute__((noreturn))" : "" @@ -52,14 +78,26 @@ export function generateSource(functions: FunctionDecl[]) { .join(", ")}) {`, `if (g_host.${name} == nullptr) {`, ` fprintf(stderr, "Node-API function '${name}' called before it was injected!\\n");`, - " abort();", - "}", - (returnType === "void" ? "" : "return ") + + ...(isDeferrable ? [ + "#if WITH_DEFERRED_NAPI_MODULE_REGISTER", + " {", + " std::lock_guard guard(g_internal_state_mutex);", + " g_pending_modules.push_back(arg0);", + " }", + "#else", + " abort();", + "#endif // WITH_DEFERRED_NAPI_MODULE_REGISTER", + ] : [ + " abort();", + ]), + "} else {", + " " + (returnType === "void" ? "" : "return ") + "g_host." + name + "(" + argumentTypes.map((_, index) => `arg${index}`).join(", ") + ");", + "}", "};", ]; }), From 7d82be1e49cac06fa2f332d4af7be734b28b32e2 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Fri, 20 Jun 2025 14:25:51 +0200 Subject: [PATCH 51/54] hack: apply workaround for "twisted" addon Currently the addon cannot be resolved using the bare specifier, therefore this workaround makes it use an intermediate file "index.js" which would import the native addon. --- .../2_function_arguments_twisted/napi/index.js | 3 +++ .../2_function_arguments_twisted/napi/package.json | 4 ++-- packages/node-addon-examples/index.js | 3 ++- 3 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/index.js diff --git a/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/index.js b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/index.js new file mode 100644 index 00000000..bcc61cf6 --- /dev/null +++ b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/index.js @@ -0,0 +1,3 @@ +const addon = require('bindings')('addon_twisted.node') + +console.log('This should be eight:', addon.add(3, 5)) diff --git a/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/package.json b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/package.json index d1cd9a44..89ee8750 100644 --- a/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/package.json +++ b/packages/node-addon-examples/examples/1-getting-started/2_function_arguments_twisted/napi/package.json @@ -2,13 +2,13 @@ "name": "@callstackincubator/example-3", "version": "0.0.0", "description": "Node.js Addons Example #2", - "main": "./build/Release/addon_twisted.node", + "main": "./index.js", "private": true, "dependencies": { "bindings": "~1.5.0" }, "scripts": { - "test": "node addon.js" + "test": "node index.js" }, "binary": { "napi_versions": [4] diff --git a/packages/node-addon-examples/index.js b/packages/node-addon-examples/index.js index 549658d4..df7a5606 100644 --- a/packages/node-addon-examples/index.js +++ b/packages/node-addon-examples/index.js @@ -5,7 +5,8 @@ module.exports = { "1_hello_world/node-addon-api-addon-class": () => require("./examples/1-getting-started/1_hello_world/node-addon-api-addon-class/hello.js"), "2_function_arguments/napi": () => require("./examples/1-getting-started/2_function_arguments/napi/addon.js"), "2_function_arguments/node-addon-api": () => require("./examples/1-getting-started/2_function_arguments/node-addon-api/addon.js"), - "2_function_arguments_twisted/napi": () => require("@callstackincubator/example-3"), + // "2_function_arguments_twisted/napi": () => require("@callstackincubator/example-3"), + "2_function_arguments_twisted/napi": () => require("./examples/1-getting-started/2_function_arguments_twisted/napi/index.js"), "3_callbacks/napi": () => require("./examples/1-getting-started/3_callbacks/napi/addon.js"), "3_callbacks/node-addon-api": () => require("./examples/1-getting-started/3_callbacks/node-addon-api/addon.js"), "4_object_factory/napi": () => require("./examples/1-getting-started/4_object_factory/napi/addon.js"), From e8ff65d4371589790237a3a3f4b7044b98d61cad Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Fri, 20 Jun 2025 19:23:37 +0200 Subject: [PATCH 52/54] refactor: remove unused path utils I wanted to keep those a bit longer because they will be used in a different source file, but I can revive those functions from Git history, so farewell :wave: --- packages/host/cpp/CxxNodeApiHostModule.cpp | 83 ---------------------- 1 file changed, 83 deletions(-) diff --git a/packages/host/cpp/CxxNodeApiHostModule.cpp b/packages/host/cpp/CxxNodeApiHostModule.cpp index 4e54fd54..4b5bc59d 100644 --- a/packages/host/cpp/CxxNodeApiHostModule.cpp +++ b/packages/host/cpp/CxxNodeApiHostModule.cpp @@ -27,89 +27,6 @@ bool isModulePathLike(const std::string_view &path) { }); } -// NOTE: behaves like `explode()` in PHP -std::vector explodePath(const std::string_view &path) { - std::vector parts; - for (size_t pos = 0; std::string_view::npos != pos; /* no-op */) { - if (const size_t nextPos = path.find('/', pos); std::string_view::npos != nextPos) { - parts.emplace_back(path.substr(pos, nextPos - pos)); - pos = nextPos + 1; - } else { - if (std::string_view &&part = path.substr(pos); !part.empty()) { - // Paths ending with `/` are as if there was a tailing dot `/.` - // therefore the last `/` can be safely removed - parts.emplace_back(part); - } - break; - } - } - return parts; -} - -// NOTE: Absolute paths would have the first part empty, relative would have a name -std::string implodePath(const std::vector &parts) { - std::string joinedPath; - for (size_t i = 0; i < parts.size(); ++i) { - if (i > 0) { - joinedPath += '/'; - } - joinedPath += parts[i]; - } - return joinedPath; -} - -// NOTE: Returned path does not include the `/` at the end of the string -// NOTE: For some cases this cannot be a view: `getParentPath("..")` => "../.." -void makeParentPathInplace(std::vector &parts) { - if (!parts.empty() && ".." != parts.back()) { - const bool wasDot = "." == parts.back(); - parts.pop_back(); - if (wasDot && parts.empty()) { - parts.emplace_back(".."); - } - } else { - parts.emplace_back(".."); - } -} - -std::vector makeParentPath(const std::string_view &path) { - auto parts = explodePath(path); - makeParentPathInplace(parts); - return parts; -} - -std::vector simplifyPath(const std::vector &parts) { - std::vector result; - if (!parts.empty()) { - for (const auto &part : parts) { - if ("." == part && !result.empty()) { - continue; // We only allow for a single `./` at the beginning - } else if (".." == part) { - makeParentPathInplace(result); - } else { - result.emplace_back(part); - } - } - } else { - result.emplace_back("."); // Empty path is as if it was "." - } - return result; -} - -std::vector joinPath(const std::vector &baseDir, - const std::vector &rest) { - auto pathComponents = simplifyPath(baseDir); - auto restComponents = simplifyPath(rest); - for (auto &&part : restComponents) { - if (".." == part) { - makeParentPathInplace(pathComponents); - } else if (!part.empty() && "." != part) { - pathComponents.emplace_back(part); - } - } - return pathComponents; -} - std::pair rpartition(const std::string_view &input, char delimiter) { if (const size_t pos = input.find_last_of(delimiter); std::string_view::npos != pos) { From fc0a1a064af509aa4e737f5de8af03abb1374815 Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Fri, 20 Jun 2025 19:26:01 +0200 Subject: [PATCH 53/54] fix: assertion firing when loading deprecated way --- packages/host/cpp/AddonRegistry.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/host/cpp/AddonRegistry.cpp b/packages/host/cpp/AddonRegistry.cpp index b3c95de1..b5d34ea5 100644 --- a/packages/host/cpp/AddonRegistry.cpp +++ b/packages/host/cpp/AddonRegistry.cpp @@ -102,6 +102,7 @@ bool AddonRegistry::tryLoadAddonAsDynamicLib(NodeAddon &addon, const std::string // Load addon as dynamic library typename LoaderPolicy::Module library = LoaderPolicy::loadLibrary(path.c_str()); if (nullptr != library) { + addon.moduleApiVersion_ = NODE_API_DEFAULT_MODULE_API_VERSION; if (nullptr != pendingRegistration_) { // there is a pending addon that used the deprecated `napi_register_module()` addon.initFun_ = pendingRegistration_; @@ -110,7 +111,6 @@ bool AddonRegistry::tryLoadAddonAsDynamicLib(NodeAddon &addon, const std::string typename LoaderPolicy::Symbol initFn = LoaderPolicy::getSymbol(library, "napi_register_module_v1"); if (nullptr != initFn) { addon.initFun_ = (napi_addon_register_func)initFn; - addon.moduleApiVersion_ = NODE_API_DEFAULT_MODULE_API_VERSION; // This solves https://github.com/callstackincubator/react-native-node-api-modules/issues/4 typename LoaderPolicy::Symbol getVersionFn = LoaderPolicy::getSymbol(library, "node_api_module_get_api_version_v1"); if (nullptr != getVersionFn) { From 04295cfaabebe6f777456300c286fbb24f7e384f Mon Sep 17 00:00:00 2001 From: Mariusz Pasinski Date: Fri, 20 Jun 2025 19:28:54 +0200 Subject: [PATCH 54/54] chore: remove redundant feature flag The plugin has been patched in this branch (rebased) --- packages/host/cpp/AddonRegistry.cpp | 2 -- packages/host/cpp/AddonRegistry.hpp | 3 --- 2 files changed, 5 deletions(-) diff --git a/packages/host/cpp/AddonRegistry.cpp b/packages/host/cpp/AddonRegistry.cpp index b5d34ea5..b4e74c7b 100644 --- a/packages/host/cpp/AddonRegistry.cpp +++ b/packages/host/cpp/AddonRegistry.cpp @@ -48,11 +48,9 @@ std::string_view stripSuffix(const std::string_view &str, const std::string_view } void sanitizeLibraryNameInplace(std::string &name) { -#if USING_PATCHED_BABEL_PLUGIN // Strip the extension (if present) // NOTE: This is needed when working with updated Babel plugin name = stripSuffix(name, ".node"); -#endif for (char &c : name) { if (!std::isalnum(c)) { diff --git a/packages/host/cpp/AddonRegistry.hpp b/packages/host/cpp/AddonRegistry.hpp index b6da6091..d395ef3a 100644 --- a/packages/host/cpp/AddonRegistry.hpp +++ b/packages/host/cpp/AddonRegistry.hpp @@ -5,9 +5,6 @@ #include #include "AddonLoaders.hpp" -// HACK: Feature flag that enables backwards-compatible code until PR is finished -#define USING_PATCHED_BABEL_PLUGIN 1 - namespace callstack::nodeapihost { class AddonRegistry {