Repository: nodejs/node
Pull request: doc,test: add documentation and test on how to use addons in SEA

Fixes: https://github.com/nodejs/node/issues/60611 
<!--
Before submitting a pull request, please read:

- the CONTRIBUTING guide at https://github.com/nodejs/node/blob/HEAD/CONTRIBUTING.md
- the commit message formatting guidelines at
  https://github.com/nodejs/node/blob/HEAD/doc/contributing/pull-requests.md#commit-message-guidelines

For code changes:
1. Include tests for any bug fixes or new features.
2. Update documentation if relevant.
3. Ensure that `make -j4 test` (UNIX), or `vcbuild test` (Windows) passes.

If you believe this PR should be highlighted in the Node.js CHANGELOG
please add the `notable-change` label.

Developer's Certificate of Origin 1.1

By making a contribution to this project, I certify that:

(a) The contribution was created in whole or in part by me and I
    have the right to submit it under the open source license
    indicated in the file; or

(b) The contribution is based upon previous work that, to the best
    of my knowledge, is covered under an appropriate open source
    license and I have the right under that license to submit that
    work with modifications, whether created in whole or in part
    by me, under the same open source license (unless I am
    permitted to submit under a different license), as indicated
    in the file; or

(c) The contribution was provided directly to me by some other
    person who certified (a), (b) or (c) and I have not modified
    it.

(d) I understand and agree that this project and the contribution
    are public and that a record of the contribution (including all
    personal information I submit with it, including my sign-off) is
    maintained indefinitely and may be redistributed consistent with
    this project or the open source license(s) involved.
-->


Changed files:
filename: doc/api/single-executable-applications.md
patch: @@ -476,6 +476,43 @@ are equal to [`process.execPath`][].
 The value of `__dirname` in the injected main script is equal to the directory
 name of [`process.execPath`][].
 
+### Using native addons in the injected main script
+
+Native addons can be bundled as assets into the single-executable application
+by specifying them in the `assets` field of the configuration file used to
+generate the single-executable application preparation blob.
+The addon can then be loaded in the injected main script by writing the asset
+to a temporary file and loading it with `process.dlopen()`.
+
+```json
+{
+  "main": "/path/to/bundled/script.js",
+  "output": "/path/to/write/the/generated/blob.blob",
+  "assets": {
+    "myaddon.node": "/path/to/myaddon/build/Release/myaddon.node"
+  }
+}
+```
+
+```js
+// script.js
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+const { getRawAsset } = require('node:sea');
+const addonPath = path.join(os.tmpdir(), 'myaddon.node');
+fs.writeFileSync(addonPath, new Uint8Array(getRawAsset('myaddon.node')));
+const myaddon = { exports: {} };
+process.dlopen(myaddon, addonPath);
+console.log(myaddon.exports);
+fs.rmSync(addonPath);
+```
+
+Known caveat: if the single-executable application is produced by postject running on a Linux arm64 docker container,
+[the produced ELF binary does not have the correct hash table to load the addons][postject-linux-arm64-issue] and
+will crash on `process.dlopen()`. Build the single-executable application on other platforms, or at least on
+a non-container Linux arm64 environment to work around this issue.
+
 ## Notes
 
 ### Single executable application creation process
@@ -528,6 +565,7 @@ to help us document them.
 [documentation about startup snapshot support in Node.js]: cli.md#--build-snapshot
 [fuse]: https://www.electronjs.org/docs/latest/tutorial/fuses
 [postject]: https://github.com/nodejs/postject
+[postject-linux-arm64-issue]: https://github.com/nodejs/postject/issues/105
 [signtool]: https://learn.microsoft.com/en-us/windows/win32/seccrypto/signtool
 [single executable applications]: https://github.com/nodejs/single-executable
 [supported by Node.js]: https://github.com/nodejs/node/blob/main/BUILDING.md#platform-list

filename: test/node-api/node-api.status
patch: @@ -12,3 +12,15 @@ prefix node-api
 # https://github.com/nodejs/node/issues/43457
 test_fatal/test_threads: PASS,FLAKY
 test_fatal/test_threads_report: PASS,FLAKY
+
+[$system==linux && $arch==ppc64]
+# https://github.com/nodejs/node/issues/59561
+test_sea_addon/test: SKIP
+
+[$system==linux && $arch==arm64]
+# https://github.com/nodejs/postject/issues/105
+test_sea_addon/test: SKIP
+
+[$system==macos && $arch==x64]
+# https://github.com/nodejs/node/issues/59553
+test_sea_addon/test: SKIP

filename: test/node-api/test_sea_addon/binding.c
patch: @@ -0,0 +1,17 @@
+#include <node_api.h>
+#include <string.h>
+#include "../../js-native-api/common.h"
+
+static napi_value Method(napi_env env, napi_callback_info info) {
+  napi_value world;
+  const char* str = "world";
+  size_t str_len = strlen(str);
+  NODE_API_CALL(env, napi_create_string_utf8(env, str, str_len, &world));
+  return world;
+}
+
+NAPI_MODULE_INIT() {
+  napi_property_descriptor desc = DECLARE_NODE_API_PROPERTY("hello", Method);
+  NODE_API_CALL(env, napi_define_properties(env, exports, 1, &desc));
+  return exports;
+}

filename: test/node-api/test_sea_addon/binding.gyp
patch: @@ -0,0 +1,8 @@
+{
+  "targets": [
+    {
+      "target_name": "binding",
+      "sources": [ "binding.c" ]
+    }
+  ]
+}

filename: test/node-api/test_sea_addon/test.js
patch: @@ -0,0 +1,75 @@
+'use strict';
+// This tests that SEA can load addons packaged as assets by writing them to disk
+// and loading them via process.dlopen().
+const common = require('../../common');
+const { generateSEA, skipIfSingleExecutableIsNotSupported } = require('../../common/sea');
+
+skipIfSingleExecutableIsNotSupported();
+
+const assert = require('assert');
+
+const tmpdir = require('../../common/tmpdir');
+const { copyFileSync, writeFileSync, existsSync, rmSync } = require('fs');
+const {
+  spawnSyncAndExitWithoutError,
+  spawnSyncAndAssert,
+} = require('../../common/child_process');
+const { join } = require('path');
+const configFile = tmpdir.resolve('sea-config.json');
+const seaPrepBlob = tmpdir.resolve('sea-prep.blob');
+const outputFile = tmpdir.resolve(process.platform === 'win32' ? 'sea.exe' : 'sea');
+tmpdir.refresh();
+
+// Copy test fixture to working directory
+const addonPath = join(__dirname, 'build', common.buildType, 'binding.node');
+const copiedAddonPath = tmpdir.resolve('binding.node');
+copyFileSync(addonPath, copiedAddonPath);
+writeFileSync(tmpdir.resolve('sea.js'), `
+const sea = require('node:sea');
+const fs = require('fs');
+const path = require('path');
+
+const addonPath = path.join(${JSON.stringify(tmpdir.path)}, 'hello.node');
+fs.writeFileSync(addonPath, new Uint8Array(sea.getRawAsset('hello.node')));
+const mod = {exports: {}}
+process.dlopen(mod, addonPath);
+console.log('hello,', mod.exports.hello());
+`, 'utf-8');
+
+writeFileSync(configFile, `
+{
+  "main": "sea.js",
+  "output": "sea-prep.blob",
+  "disableExperimentalSEAWarning": true,
+  "assets": {
+    "hello.node": "binding.node"
+  }
+}
+`, 'utf8');
+
+spawnSyncAndExitWithoutError(
+  process.execPath,
+  ['--experimental-sea-config', 'sea-config.json'],
+  { cwd: tmpdir.path },
+);
+assert(existsSync(seaPrepBlob));
+
+generateSEA(outputFile, process.execPath, seaPrepBlob);
+
+// Remove the copied addon after it's been packaged into the SEA blob
+rmSync(copiedAddonPath, { force: true });
+
+spawnSyncAndAssert(
+  outputFile,
+  [],
+  {
+    env: {
+      ...process.env,
+      NODE_DEBUG_NATIVE: 'SEA',
+    },
+    cwd: tmpdir.path,
+  },
+  {
+    stdout: /hello, world/,
+  },
+);