@crossbind/core-embind-napi 2.0.0-beta.62 → 2.0.0-beta.63

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@ Runs the crossbind embind runtime inside a native Node.js addon, so the bindings
4
4
  WebAssembly and React Native also build `.node` addons for Node.js and Electron.
5
5
 
6
6
  <a href="https://www.npmjs.com/package/@crossbind/core-embind-napi">
7
- <img alt="NPM version" src="https://img.shields.io/npm/v/@crossbind/core-embind-napi?style=for-the-badge" />
7
+ <img alt="NPM version" src="https://img.shields.io/npm/v/@crossbind/core-embind-napi/beta?style=for-the-badge" />
8
8
  </a>
9
9
  <a href="https://github.com/crossbind/crossbind/blob/main/LICENSE">
10
10
  <img alt="License" src="https://img.shields.io/github/license/crossbind/crossbind?style=for-the-badge" />
@@ -12,15 +12,17 @@ WebAssembly and React Native also build `.node` addons for Node.js and Electron.
12
12
 
13
13
  ## Use
14
14
 
15
- Add it next to `crossbind` and build the native platform:
15
+ Add it next to `crossbind` and build the addons of this machine's platform, or of every desktop
16
+ platform:
16
17
 
17
18
  ```bash
18
- pnpm add -D crossbind @crossbind/core-embind-napi
19
- pnpm crossbind build -p darwin
19
+ pnpm add -D crossbind@beta @crossbind/core-embind-napi@beta
20
+ pnpm crossbind build -p host -e node -b release
21
+ pnpm crossbind build -p darwin,linux,linuxmusl,win32 -e node -b release
20
22
  ```
21
23
 
22
- `dist/` then holds one addon per platform and architecture (`<name>.darwin-arm64.node`,
23
- `<name>.darwin-x64.node`) and the loader that picks one at runtime:
24
+ `dist/` then holds one addon per platform and architecture (e.g. `<name>.darwin-arm64.node`,
25
+ `<name>.linux-x64.node`, `<name>.win32-x64.node`) and the loader that picks one at runtime:
24
26
 
25
27
  ```js
26
28
  const initNative = require('./dist/<name>.native.cjs');
@@ -30,12 +32,14 @@ const { Native } = await initNative();
30
32
 
31
33
  crossbind resolves this package from your project; you never import it yourself. See the
32
34
  [Node.js integration guide](https://github.com/crossbind/crossbind/blob/main/docs/playbooks/integration/nodejs.md#native-addon-node-api) for
33
- requirements and current limits (macOS only, no `worker_threads` yet).
35
+ requirements, Electron and current limits (no `worker_threads` yet).
34
36
 
35
37
  ## Contents
36
38
 
37
39
  - `cpp/` — the addon's CMake project and its Node-API entry point.
38
40
  - `js/loader.js` — the loader crossbind bundles into `dist/<name>.native.cjs`.
41
+ - `js/addonPlatform.js` — tells glibc and musl Linux apart, so the loader picks the `linux` or `linuxmusl` addon.
42
+ - `js/unpackedPath.js` — points a packaged Electron app's addon at the data its asar archive keeps unpacked.
39
43
  - `third_party/node-api-jsi/` — Microsoft's JSI implementation over Node-API (MIT), vendored at a
40
44
  pinned commit; provenance and local patches are in its README.
41
45
 
@@ -45,8 +45,10 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux")
45
45
  find_package(Threads REQUIRED)
46
46
  target_link_libraries(addon PRIVATE Threads::Threads ${CMAKE_DL_LIBS})
47
47
  target_link_options(addon PRIVATE "LINKER:--exclude-libs,ALL")
48
+ file(GLOB MUSL_LIBC "${CMAKE_SYSROOT}/lib/ld-musl-*.so.1")
48
49
  add_custom_command(TARGET addon POST_BUILD
49
50
  COMMAND "${CMAKE_COMMAND}" "-DNM=${CMAKE_NM}" "-DADDON=$<TARGET_FILE:addon>"
51
+ "-DUNVERSIONED_LIBRARIES=${MUSL_LIBC}"
50
52
  -P "${CMAKE_CURRENT_SOURCE_DIR}/check-undefined.cmake"
51
53
  VERBATIM)
52
54
  endif()
@@ -66,4 +68,9 @@ if(APPLE)
66
68
  string(REGEX REPLACE "^NODE_API_FUNC\\(([A-Za-z0-9_]+)\\).*" "\\1" func "${line}")
67
69
  target_link_options(addon PRIVATE "LINKER:-U,_${func}")
68
70
  endforeach()
71
+ # Only Node-API's entry points leave the addon: dyld merges exported weak definitions across the
72
+ # images of a process, so two addons would share inline state such as embind's registration guards.
73
+ target_link_options(addon PRIVATE
74
+ "LINKER:-exported_symbol,_napi_register_module_v1"
75
+ "LINKER:-exported_symbol,_node_api_module_get_api_version_v1")
69
76
  endif()
@@ -1,6 +1,20 @@
1
1
  # A Linux shared object may leave symbols undefined, and the loader binds functions lazily: an addon
2
2
  # that misses an archive loads, then dies at its first call into the missing code. Every undefined
3
3
  # symbol has to be a Node-API function or carry the version of a system library that defines it.
4
+ # musl versions nothing, so a name its C library (UNVERSIONED_LIBRARIES) defines passes as it is.
5
+ set(defined)
6
+ foreach(library IN LISTS UNVERSIONED_LIBRARIES)
7
+ execute_process(
8
+ COMMAND "${NM}" -D --defined-only "${library}"
9
+ OUTPUT_VARIABLE exports
10
+ COMMAND_ERROR_IS_FATAL ANY)
11
+ string(REPLACE "\n" ";" export_lines "${exports}")
12
+ foreach(export_line IN LISTS export_lines)
13
+ if(export_line MATCHES " ([^ ]+)$")
14
+ list(APPEND defined "${CMAKE_MATCH_1}")
15
+ endif()
16
+ endforeach()
17
+ endforeach()
4
18
  execute_process(
5
19
  COMMAND "${NM}" -D --undefined-only "${ADDON}"
6
20
  OUTPUT_VARIABLE listing
@@ -8,8 +22,14 @@ execute_process(
8
22
  string(REPLACE "\n" ";" lines "${listing}")
9
23
  set(unresolved)
10
24
  foreach(line IN LISTS lines)
11
- if(line MATCHES "^ *U ([^@ ]+)$" AND NOT CMAKE_MATCH_1 MATCHES "^(napi|node_api)_")
12
- list(APPEND unresolved "${CMAKE_MATCH_1}")
25
+ if(NOT line MATCHES "^ *U ([^@ ]+)$")
26
+ continue()
27
+ endif()
28
+ # A failed MATCHES clears CMAKE_MATCH_1, so the name is kept before the next one.
29
+ set(symbol "${CMAKE_MATCH_1}")
30
+ list(FIND defined "${symbol}" index)
31
+ if(NOT symbol MATCHES "^(napi|node_api)_" AND index EQUAL -1)
32
+ list(APPEND unresolved "${symbol}")
13
33
  endif()
14
34
  endforeach()
15
35
  if(unresolved)
@@ -82,9 +82,10 @@ std::string readString(napi_env env, napi_value value) {
82
82
  return result;
83
83
  }
84
84
 
85
- // start(dataPath) registers every EMSCRIPTEN_BINDINGS block of the addon into this environment's
86
- // embind runtime, so embind.js must already be loaded. A second call in the same environment is a
87
- // no-op: registering twice fails with "Cannot register public name ... twice".
85
+ // start(dataPath, scope) registers every EMSCRIPTEN_BINDINGS block of the addon into this
86
+ // environment's embind runtime, so embind.js must already be loaded. scope is the object that
87
+ // runtime keeps its state on, which this addon's global() returns. A second call in the same
88
+ // environment is a no-op: registering twice fails with "Cannot register public name ... twice".
88
89
  napi_value Start(napi_env env, napi_callback_info info) {
89
90
  void *existing = nullptr;
90
91
  NODE_API(napi_get_instance_data)(env, &existing);
@@ -100,17 +101,20 @@ napi_value Start(napi_env env, napi_callback_info info) {
100
101
  return nullptr;
101
102
  }
102
103
 
103
- size_t argc = 1;
104
- napi_value argv[1];
104
+ size_t argc = 2;
105
+ napi_value argv[2];
105
106
  NODE_API(napi_get_cb_info)(env, info, &argc, argv, nullptr, nullptr);
106
107
  const std::string dataPath = argc > 0 ? readString(env, argv[0]) : std::string();
108
+ napi_valuetype scopeType = napi_undefined;
109
+ if (argc > 1) NODE_API(napi_typeof)(env, argv[1], &scopeType);
110
+ napi_value scope = scopeType == napi_object ? argv[1] : nullptr;
107
111
 
108
112
  // An exception must not unwind through Node-API, which would end the process.
109
113
  try {
110
114
  auto state = std::make_unique<EnvState>();
111
115
  state->env = env;
112
116
  JSRuntimeApi::setCurrent(&state->api);
113
- state->runtime = Microsoft::NodeApiJsi::makeNodeApiJsiRuntime(env, &state->api, [] {});
117
+ state->runtime = Microsoft::NodeApiJsi::makeNodeApiJsiRuntime(env, &state->api, [] {}, scope);
114
118
  // The environment owns it before any binding registers: registered functions point into it,
115
119
  // so it has to outlive a registration that fails halfway.
116
120
  EnvState *owned = state.release();
@@ -0,0 +1,13 @@
1
+ import path from 'node:path';
2
+
3
+ // A project's own build leaves the addon beside the loader. A package that publishes one addon per
4
+ // platform ships it in <package>-<platform>-<arch>, an optional dependency whose main is the addon.
5
+ export default function addonLocation({ dir, fileName, packageName, exists, resolve }) {
6
+ const beside = path.join(dir, fileName);
7
+ if (exists(beside) || !packageName) return beside;
8
+ try {
9
+ return resolve(packageName);
10
+ } catch {
11
+ return beside;
12
+ }
13
+ }
@@ -0,0 +1,19 @@
1
+ // The platform part of an addon file name. Node reports musl and glibc systems alike as 'linux'; a
2
+ // process linked against glibc names that glibc in its report header, a musl one does not.
3
+ export default function addonPlatform(platform = process.platform, readReport = processReport) {
4
+ if (platform !== 'linux') return platform;
5
+ const header = readReport()?.header;
6
+ return !header || header.glibcVersionRuntime ? 'linux' : 'linuxmusl';
7
+ }
8
+
9
+ function processReport() {
10
+ if (!process.report) return undefined;
11
+ // Only the header is read; the network section can stall on DNS lookups.
12
+ const { excludeNetwork } = process.report;
13
+ process.report.excludeNetwork = true;
14
+ try {
15
+ return process.report.getReport();
16
+ } finally {
17
+ process.report.excludeNetwork = excludeNetwork;
18
+ }
19
+ }
package/js/loader.js CHANGED
@@ -1,12 +1,29 @@
1
1
  // Entry of the per-project loader crossbind bundles next to the native addons
2
2
  // (dist/<name>.native.cjs). It keeps the wasm build's initNative() contract, so the same app code
3
3
  // runs on either build.
4
+ import fs from 'node:fs';
5
+ import { createRequire } from 'node:module';
4
6
  import path from 'node:path';
5
- import Module from '@crossbind/core-embind-jsi';
7
+ import Module, { crossbindScope } from '@crossbind/core-embind-jsi';
6
8
  import systemConfig from 'crossbind/systemConfig';
9
+ import addonPlatform from './addonPlatform.js';
10
+ import addonLocation from './addonLocation.js';
11
+ import missingAddonRemedy from './missingAddon.js';
12
+ import stopOnExit from './stopOnExit.js';
13
+ import unpackedPath from './unpackedPath.js';
7
14
 
8
- function addonFileName() {
9
- return systemConfig.paths.addon.replace('{platform}', process.platform).replace('{arch}', process.arch);
15
+ const platform = addonPlatform();
16
+ const fill = (pattern) => pattern.replace('{platform}', platform).replace('{arch}', process.arch);
17
+ const addonPackage = systemConfig.paths.addonPackage ? fill(systemConfig.paths.addonPackage) : null;
18
+
19
+ function addonFile(config) {
20
+ return config.addonPath ?? addonLocation({
21
+ dir: __dirname,
22
+ fileName: fill(systemConfig.paths.addon),
23
+ packageName: addonPackage,
24
+ exists: fs.existsSync,
25
+ resolve: createRequire(__filename).resolve,
26
+ });
10
27
  }
11
28
 
12
29
  function loadAddon(file) {
@@ -14,38 +31,43 @@ function loadAddon(file) {
14
31
  try {
15
32
  process.dlopen(addon, file);
16
33
  } catch (error) {
17
- throw new Error(
18
- `crossbind: cannot load ${file} - build it with \`crossbind build -p ${process.platform} -a ${process.arch}\`.`,
19
- { cause: error },
20
- );
34
+ throw new Error(`crossbind: cannot load ${file} - ${fs.existsSync(file) ? error.message : missingAddonRemedy({ platform, arch: process.arch, addonPackage, published: Object.keys(systemConfig.env) })}.`, { cause: error });
21
35
  }
22
36
  return addon.exports;
23
37
  }
24
38
 
25
- function setEnv(dataPath, runtimeEnv) {
39
+ function setEnv(dataPath, runtimeEnv = {}) {
40
+ const fillDataPath = (value) => String(value).replace('_CROSSBIND_DATA_PATH_', dataPath);
26
41
  // Build-time values are keyed by addon: a target-scoped value must not leak from one arch's
27
- // build into another's. Values passed to initNative() win, as in the wasm runtime.
28
- const env = { ...systemConfig.env[`${process.platform}-${process.arch}`], ...runtimeEnv };
29
- Object.entries(env).forEach(([key, value]) => {
30
- Module.Crossbind.setEnv(key, String(value).replace('_CROSSBIND_DATA_PATH_', dataPath), false);
42
+ // build into another's. They never replace a variable the process already has; values passed to
43
+ // initNative() do, as in the wasm runtime, even after a package entry booted the addon.
44
+ Object.entries(systemConfig.env[`${platform}-${process.arch}`] ?? {}).forEach(([key, value]) => {
45
+ Module.Crossbind.setEnv(key, fillDataPath(value), false);
46
+ });
47
+ Object.entries(runtimeEnv).forEach(([key, value]) => {
48
+ Module.Crossbind.setEnv(key, fillDataPath(value), true);
31
49
  });
32
50
  }
33
51
 
34
52
  let addon = null;
53
+ let addonDataPath = null;
35
54
 
36
55
  function boot(config) {
37
- const dataPath = config.dataPath ?? path.join(__dirname, 'data');
38
56
  // Loaded and started once per process: Node cannot unload an addon, and a second dlopen gets a
39
57
  // fresh environment whose start() would register every binding twice.
40
58
  if (!addon) {
41
- const loaded = loadAddon(config.addonPath ?? path.join(__dirname, addonFileName()));
42
- loaded.start(dataPath);
59
+ addonDataPath = config.dataPath ?? unpackedPath(path.join(__dirname, 'data'));
60
+ const loaded = loadAddon(addonFile(config));
61
+ // The embind runtime this bundle carries keeps its state on crossbindScope (crossbind's utils/scopedEmbind.js).
62
+ loaded.start(addonDataPath, crossbindScope);
43
63
  // Native values released after this (thread-local ones die inside exit()) must not call
44
64
  // back into the runtime.
45
- process.once('exit', () => loaded.stop());
65
+ stopOnExit(() => loaded.stop());
46
66
  addon = loaded;
67
+ } else if (config.dataPath && config.dataPath !== addonDataPath) {
68
+ throw new Error(`crossbind: the addon started with its data at ${addonDataPath}; a process cannot move it to ${config.dataPath}.`);
47
69
  }
48
- setEnv(dataPath, config.env);
70
+ setEnv(addonDataPath, config.env);
49
71
  return Module;
50
72
  }
51
73
 
@@ -63,6 +85,11 @@ export default function initNative(config = {}) {
63
85
  return booted;
64
86
  }
65
87
 
88
+ // Loading an addon is synchronous, so a package entry boots it on require.
89
+ initNative.sync = function sync(config = {}) {
90
+ return boot(config);
91
+ };
92
+
66
93
  // The wasm runtime's contract: forget the booted module so the next call boots again. Native state
67
94
  // cannot be reset (Node cannot unload an addon), so that call resolves the same module.
68
95
  initNative.terminate = function terminate() {
@@ -0,0 +1,9 @@
1
+ // What a process without its addon is told: install the addon package npm left out, learn that the package
2
+ // publishes none for this machine, or build the addon an app makes itself. published lists the
3
+ // <platform>-<arch> of every addon the package ships.
4
+ export default function missingAddonRemedy({ platform, arch, addonPackage, published }) {
5
+ const target = `${platform}-${arch}`;
6
+ if (!addonPackage) return `build it with \`crossbind build -p ${platform} -a ${arch}\``;
7
+ if (!published.includes(target)) return `this package publishes no addon for ${target}, only for ${published.join(', ')}`;
8
+ return `install ${addonPackage}, which npm leaves out with --omit=optional; addons exist for ${published.join(', ')}`;
9
+ }
@@ -0,0 +1,15 @@
1
+ // Every package bundles a loader of its own, and Node warns at an eleventh 'exit' listener, so the addons share one,
2
+ // found by a registered symbol. Each boot moves it to the end of the list: the stops then run after every handler
3
+ // registered so far, as a listener added at that boot would, since a call into an addon after its stop crashes.
4
+ const ADDON_STOPS = Symbol.for('crossbind.addonStops.v1');
5
+
6
+ export default function stopOnExit(stop, proc = process) {
7
+ if (!proc[ADDON_STOPS]) {
8
+ const stops = [];
9
+ proc[ADDON_STOPS] = { stops, onExit: () => stops.forEach((stopAddon) => stopAddon()) };
10
+ }
11
+ const { stops, onExit } = proc[ADDON_STOPS];
12
+ stops.push(stop);
13
+ proc.removeListener('exit', onExit);
14
+ proc.on('exit', onExit);
15
+ }
@@ -0,0 +1,5 @@
1
+ // Native code cannot read inside an Electron asar archive, so a packaged app keeps what an addon
2
+ // reads in <archive>.asar.unpacked beside it.
3
+ export default function unpackedPath(file, isElectron = Boolean(process.versions.electron)) {
4
+ return isElectron ? file.replace(/\.asar(?=[\\/]|$)/, '.asar.unpacked') : file;
5
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crossbind/core-embind-napi",
3
- "version": "2.0.0-beta.62",
3
+ "version": "2.0.0-beta.63",
4
4
  "description": "Node-API host for the crossbind embind runtime: native Node.js addons from the same C++ and Rust bindings.",
5
5
  "homepage": "https://github.com/crossbind/crossbind/tree/main/core/embind-napi#readme",
6
6
  "repository": {
@@ -14,7 +14,7 @@
14
14
  "third_party"
15
15
  ],
16
16
  "dependencies": {
17
- "@crossbind/core-embind-jsi": "^2.0.0-beta.62"
17
+ "@crossbind/core-embind-jsi": "^2.0.0-beta.63"
18
18
  },
19
19
  "keywords": [
20
20
  "embind",
@@ -53,3 +53,7 @@ no `jsi-version.h` is needed.
53
53
  it. A string return went from 75 to 69 ns and a string argument from 95 to 84 ns; together with
54
54
  the direct BigInt read, a Rust `xxh64` call on 32 bytes with a BigInt seed and result went from
55
55
  141 to 110 ns. A million callbacks in one call peak at the same 70 MB.
56
+ - `makeNodeApiJsiRuntime` takes an optional object that `global()` returns in place of the
57
+ global object. Each addon's loader bundles its own embind runtime, which keeps its `Module` and
58
+ the functions the addon calls by name on such an object, so addons of several packages run in
59
+ one process. The runtime's own lookups (`Error`, `Proxy`, `Symbol`) still use the global object.
@@ -200,7 +200,8 @@ class NodeApiJsiRuntime : public jsi::Runtime, public crossbind::IDirectReads {
200
200
  NodeApiJsiRuntime(
201
201
  napi_env env,
202
202
  JSRuntimeApi *jsrApi,
203
- std::function<void()> onDelete) noexcept;
203
+ std::function<void()> onDelete,
204
+ napi_value globalScope) noexcept;
204
205
  ~NodeApiJsiRuntime() override;
205
206
 
206
207
  jsi::ICast *castInterface(const jsi::UUID &interfaceUUID) override;
@@ -1116,6 +1117,7 @@ class NodeApiJsiRuntime : public jsi::Runtime, public crossbind::IDirectReads {
1116
1117
  struct CachedValue {
1117
1118
  NodeApiRefHolder Error;
1118
1119
  NodeApiRefHolder Global;
1120
+ NodeApiRefHolder GlobalScope;
1119
1121
  NodeApiRefHolder HostObjectProxyHandler;
1120
1122
  NodeApiRefHolder ProxyConstructor;
1121
1123
  NodeApiRefHolder SymbolToString;
@@ -1214,7 +1216,8 @@ bool StringKey::EqualTo::operator()(
1214
1216
  NodeApiJsiRuntime::NodeApiJsiRuntime(
1215
1217
  napi_env env,
1216
1218
  JSRuntimeApi *jsrApi,
1217
- std::function<void()> onDelete) noexcept
1219
+ std::function<void()> onDelete,
1220
+ napi_value globalScope) noexcept
1218
1221
  : env_(env), jsrApi_(jsrApi), onDelete_(std::move(onDelete)) {
1219
1222
  NodeApiScope scope{*this};
1220
1223
  propertyId_.Error = makeNodeApiRef(
@@ -1266,6 +1269,10 @@ NodeApiJsiRuntime::NodeApiJsiRuntime(
1266
1269
 
1267
1270
  cachedValue_.Global =
1268
1271
  makeNodeApiRef(getGlobal(), NodeApiPointerValueKind::Object);
1272
+ if (globalScope) {
1273
+ cachedValue_.GlobalScope =
1274
+ makeNodeApiRef(globalScope, NodeApiPointerValueKind::Object);
1275
+ }
1269
1276
  cachedValue_.Error = makeNodeApiRef(
1270
1277
  getProperty(
1271
1278
  getNodeApiValue(cachedValue_.Global),
@@ -1337,7 +1344,9 @@ bool NodeApiJsiRuntime::drainMicrotasks(int maxMicrotasksHint) {
1337
1344
  #endif
1338
1345
 
1339
1346
  jsi::Object NodeApiJsiRuntime::global() {
1340
- return make<jsi::Object>(cachedValue_.Global->clone(*this));
1347
+ const NodeApiRefHolder &global =
1348
+ cachedValue_.GlobalScope ? cachedValue_.GlobalScope : cachedValue_.Global;
1349
+ return make<jsi::Object>(global->clone(*this));
1341
1350
  }
1342
1351
 
1343
1352
  std::string NodeApiJsiRuntime::description() {
@@ -3737,8 +3746,10 @@ void NodeApiJsiRuntime::popPointerValueScope() noexcept {
3737
3746
  std::unique_ptr<jsi::Runtime> makeNodeApiJsiRuntime(
3738
3747
  napi_env env,
3739
3748
  JSRuntimeApi *jsrApi,
3740
- std::function<void()> onDelete) noexcept {
3741
- return std::make_unique<NodeApiJsiRuntime>(env, jsrApi, std::move(onDelete));
3749
+ std::function<void()> onDelete,
3750
+ napi_value globalScope) noexcept {
3751
+ return std::make_unique<NodeApiJsiRuntime>(
3752
+ env, jsrApi, std::move(onDelete), globalScope);
3742
3753
  }
3743
3754
 
3744
3755
  } // namespace Microsoft::NodeApiJsi
@@ -15,7 +15,8 @@ namespace Microsoft::NodeApiJsi {
15
15
  std::unique_ptr<facebook::jsi::Runtime> makeNodeApiJsiRuntime(
16
16
  napi_env env,
17
17
  JSRuntimeApi *jsrApi,
18
- std::function<void()> onDelete) noexcept;
18
+ std::function<void()> onDelete,
19
+ napi_value globalScope = nullptr) noexcept;
19
20
 
20
21
  struct NodeApiEnvScope {
21
22
  NodeApiEnvScope(napi_env env) : env_(env) {