Skip to content

feat: ship napi-rs consumable archives for wasm32-wasip1#225

Merged
toyobayashi merged 4 commits into
toyobayashi:mainfrom
Brooooooklyn:feat/wasip1-napi-rs-archives
Jul 17, 2026
Merged

feat: ship napi-rs consumable archives for wasm32-wasip1#225
toyobayashi merged 4 commits into
toyobayashi:mainfrom
Brooooooklyn:feat/wasip1-napi-rs-archives

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

This is the packaging half of the WASI work (companion to #220, which is the runtime memory-view correctness fix). It ships the wasm32-wasip1 archives that napi-rs and rolldown need, and fixes the napi-rs archive composition so the published artifacts are actually consumable. The two were tangled together in the original 60k-line branch; splitting them lets each be reviewed on its own terms.

What's here

  • Ship plain wasm32-wasip1 archives. v2 published no lib/wasm32-wasip1 archives at all. This rebuilds and ships them (libemnapi.a, libemnapi-basic-napi-rs.a), building via share/cmake/wasi-sdk-p1.cmake directly, with a pre-copy sysroot assert + post-copy destination assert so a dirty rerun can't mask a missing archive.
  • Make the napi-rs archives consumable (composition per the review discussion):
    • emnapi-napi-rs-mt keeps the full C composition (same sources as libemnapi-mt — async work and TSFN share one C implementation with Emscripten);
    • the actual consumability fix is the import binding of the two env-cleanup hooks: napi-rs imports them from the napi wasm module, so they are rebound via an EMNAPI_NAPI_RS_IMPORTS guard (NAPI_VERSION >= 8) placed in src/emnapi_internal.h — under the full composition both async_cleanup_hook.c and threadsafe_function.c reference the hooks, so the guard must apply to both TUs or wasm-ld rejects the link with an import-module mismatch (this is exactly how the published 2.0.0-alpha.2 fails: a probe link errors 4× — both TUs × both hooks);
    • new emnapi-basic-napi-rs target (basic composition — JS async work/TSFN) for plain wasip1;
    • emnapi-basic-napi-rs-mt is removed — v1.11.2 published that filename, but it was never consumable in v2, and napi-rs doesn't link it under this wiring.
  • Release validation. A standalone release-package validator with per-archive profiles: the mt archive must define napi_create_threadsafe_function/napi_create_async_work (C impl in-archive) and import only the two cleanup hooks from napi; the plain archive must not define them (JS impls); negative controls include the published 2.0.0-alpha.2 layout and member-deletion of both targets.

Shipped sets: wasip1 = {libemnapi, libemnapi-basic-napi-rs}, threads = {libemnapi, libemnapi-mt, libemnapi-napi-rs-mt}.

Consumer validation

napi-rs and rolldown were both built against packed tarballs of this branch merged with #220, with all their emnapi patches and vendored archives deleted — full WASI test lanes green on both. After the recomposition to the full-C mt archive, the napi-rs threaded lane was re-validated against the new composition (link clean, spec suite green, with the C TSFN verified active via the artifact's import list).

The emnapi-basic-mt target requires wasm atomics and cannot be compiled
for the non-threaded wasm32-wasip1 target.

(cherry picked from commit 24f5852)
Comment thread .github/workflows/ci.yml Outdated
Comment on lines +158 to +180

release-package:
timeout-minutes: 15
name: CI release package (wasm32-wasip1 archives)
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v6
- uses: ./.github/actions/setup-target-ci
with:
target: 'release-package'
# keep in sync with MAIN_EM_VERSION in main.yml so the release
# rehearsal uses the same emscripten as the production release lane
em-version: '4.0.23'
needs-emscripten: 'true'
needs-wasi-sdk: 'true'
needs-v8-headers: 'false'
- name: NPM Build
run: npm run build --workspaces --if-present
- name: Release
run: node ./script/release.js
- name: Validate release packages
run: node ./script/test-release-package.js --prepared

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

These aren't needed. The release is triggered manually via the main target in workflows/main.yml.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Removed the job — agreed that CI doesn't need to rehearse the release. I kept script/test-release-package.js itself, since it's what catches the archive layout the published 2.0.0-alpha.2 shipped with (env-bound cleanup hooks, missing plain wasip1 archives). If you'd like, it could run in the manual release lane instead — a single node ./script/test-release-package.js --prepared between release.js and npm publish in main.yml — so a broken archive set can't be published. Happy to drop the script entirely if you'd rather not carry it.

Comment thread packages/emnapi/CMakeLists.txt Outdated
Comment on lines +230 to +254
# napi-rs links the emnapi archive against plain `extern "C"` declarations, so
# every `napi_*` reference must resolve through the `env` wasm import module,
# except `napi_add_env_cleanup_hook` / `napi_remove_env_cleanup_hook` which
# napi-rs imports from the `napi` module (EMNAPI_NAPI_RS_IMPORTS, see
# src/async_cleanup_hook.c). async_work.c and threadsafe_function.c must stay
# out of the archive so that async work and thread-safe functions remain wasm
# imports resolved by the JavaScript implementations from @emnapi/core plugins.
if(EMNAPI_BUILD_FOR_NAPI_RS)
add_library(${EMNAPI_NAPI_RS_MT_TARGET_NAME} STATIC ${EMNAPI_SRC} ${UV_SRC})
target_include_directories(${EMNAPI_NAPI_RS_MT_TARGET_NAME} PUBLIC ${EMNAPI_INCLUDE})
target_compile_definitions(${EMNAPI_NAPI_RS_MT_TARGET_NAME}
PUBLIC ${EMNAPI_DEFINES} "NAPI_EXTERN=__attribute__((__import_module__(\"env\")))"
)

if(EMNAPI_BUILD_BASIC_LIBS)
add_library(${EMNAPI_BASIC_NAPI_RS_MT_TARGET_NAME} STATIC
${ENAPI_BASIC_SRC}
${UV_SRC}
set(EMNAPI_NAPI_RS_SRC ${ENAPI_BASIC_SRC} ${UV_SRC})
if(IS_WASM32_WASIP1_THREADS)
set(EMNAPI_NAPI_RS_TARGET ${EMNAPI_NAPI_RS_MT_TARGET_NAME})
list(APPEND EMNAPI_NAPI_RS_SRC
"${CMAKE_CURRENT_SOURCE_DIR}/src/thread/async_worker_create.c"
"${CMAKE_CURRENT_SOURCE_DIR}/src/thread/async_worker_init.S"
)
target_include_directories(${EMNAPI_BASIC_NAPI_RS_MT_TARGET_NAME} PUBLIC ${EMNAPI_INCLUDE})
target_compile_definitions(${EMNAPI_BASIC_NAPI_RS_MT_TARGET_NAME}
PUBLIC ${EMNAPI_DEFINES} "NAPI_EXTERN=__attribute__((__import_module__(\"env\")))"
)
else()
set(EMNAPI_NAPI_RS_TARGET ${EMNAPI_NAPI_RS_TARGET_NAME})
endif()

add_library(${EMNAPI_NAPI_RS_TARGET} STATIC ${EMNAPI_NAPI_RS_SRC})
target_include_directories(${EMNAPI_NAPI_RS_TARGET} PUBLIC ${EMNAPI_INCLUDE})
target_compile_definitions(${EMNAPI_NAPI_RS_TARGET}
PUBLIC ${EMNAPI_DEFINES} "NAPI_EXTERN=__attribute__((__import_module__(\"env\")))"
PRIVATE "EMNAPI_NAPI_RS_IMPORTS"
)

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Regarding the static library names:

Those with "basic" in the name do not include the C-based async work and tsfn translation units; those without "basic" include the full C implementation.

Those with "mt" in the name are linked with -matomics and -mbulk-memory flags; those without "mt" are not.

  1. For the wasm32-wasip1-threads (multi-threaded) target, napi-rs links against:
  • The C implementation for async work and tsfn (ensuring consistency with Emscripten).
  • libemnapi-napi-rs-mt.a.
  1. For the wasm32-wasip1 (single-threaded) target, napi-rs links against:
  • The JS implementation for async work and tsfn.
  • libemnapi-basic-napi-rs.a.

Alternatively, if you find the naming too confusing, we could simply standardize on libemnapi-napi-rs.a and place it in both the wasm32-wasip1 and wasm32-wasip1-threads directories.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done — reworked to exactly this wiring (and renamed per your naming scheme):

  • wasm32-wasip1-threads: libemnapi-napi-rs-mt.a is back to the full C composition (same sources as libemnapi-mt), so async work / TSFN share the one C implementation with Emscripten.
  • wasm32-wasip1: the plain target is now libemnapi-basic-napi-rs.a (basic composition, JS implementations).

Before reworking we validated the full-C mt archive against napi-rs's threaded WASI lane: link clean, spec suite green (incl. TSFN throw-from-callback and Promise tests), and the artifact's import list confirms the C implementation is active (no napi_create_threadsafe_function/napi_*_async_work imports; the napi module contains exactly the two cleanup hooks).

One required amendment found while validating: the EMNAPI_NAPI_RS_IMPORTS redeclaration has to live in src/emnapi_internal.h rather than async_cleanup_hook.c. Under the full composition threadsafe_function.c also references napi_add/remove_env_cleanup_hook, so with the guard in only one TU the two object files bind the hooks to different import modules and wasm-ld rejects the link (import module mismatch). The published 2.0.0-alpha.2 fails a probe link with 4 such errors (both TUs × both hooks) — with the header placement both TUs agree and the link is clean.

Brooooooklyn and others added 3 commits July 17, 2026 20:56
Build a napi-rs flavored static archive for both wasip1 targets:

- wasm32-wasip1-threads keeps emnapi-napi-rs-mt on the FULL source
  list (${EMNAPI_SRC} ${UV_SRC}): the same C async work and
  thread-safe function implementation that the emscripten emnapi-mt
  archive uses, so both toolchains share one implementation.
- plain wasm32-wasip1 gains emnapi-basic-napi-rs (there was no napi-rs
  archive for the non-threaded target at all): basic composition, with
  async work and thread-safe functions left as wasm imports resolved
  by the JavaScript implementations from @emnapi/core plugins.
- emnapi-basic-napi-rs-mt is removed: it was never shipped in the npm
  package and nothing links it.

The archives keep resolving plain napi_* references through the env
wasm import module (NAPI_EXTERN), but napi_add_env_cleanup_hook /
napi_remove_env_cleanup_hook are re-declared under the napi import
module (EMNAPI_NAPI_RS_IMPORTS): napi-rs imports these two hooks from
the napi module, and an archive that imports them from env fails the
napi-rs link with a wasm-ld import module mismatch — which is exactly
how the published 2.0.0-alpha.2 archive breaks. The re-declaration
lives in src/emnapi_internal.h rather than in async_cleanup_hook.c
because under the full composition threadsafe_function.c also calls
the two hooks: with the guard in only one translation unit the two
object files disagree on the hooks' import module and wasm-ld rejects
the link.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda
Add script/test-release-package.js, a release gate that validates the
packed emnapi npm package (run node ./script/release.js first):

1. tarball-content-allowlist: the tarball ships exactly
   lib/wasm32-wasip1/{libemnapi.a,libemnapi-basic-napi-rs.a} and
   lib/wasm32-wasip1-threads/{libemnapi.a,libemnapi-mt.a,
   libemnapi-napi-rs-mt.a} and no other lib/wasm32-wasip1* entries
2. symbol-profile (llvm-nm): both napi-rs archives define
   emnapi_create_env/emnapi_delete_env and the async cleanup hook
   implementations (napi_add/remove_async_cleanup_hook); the -mt
   archive must DEFINE napi_create_async_work /
   napi_create_threadsafe_function / napi_call_threadsafe_function
   (full composition — the C implementation shared with emscripten's
   emnapi-mt) while the plain archive must NOT define them (they come
   from @emnapi/core plugins) and stays free of pthread/TLS symbols
3. import-module: link a napi-rs-like probe against each archive and
   assert the env cleanup hooks are imported from wasm module "napi"
   on both archives; on the -mt archive the async work / TSFN symbols
   are NOT imported at all (they resolve in-archive, whose C code in
   turn imports the JS-implemented parts of Node-API from "env"); on
   the plain archive they are imported from "env" and no OTHER
   Node-API import exists (--import-undefined turns any missing
   archive member into a silent env import, so an unexpected
   napi_*/node_api_* import means the archive failed to provide a
   definition). This is the check the published 2.0.0-alpha.2 fails:
   its archive binds the cleanup hooks to env and wasm-ld rejects the
   napi-rs link with an import module mismatch, and it ships no plain
   wasip1 archives at all
4. version-equality: emnapi / @emnapi/runtime / @emnapi/core share
   one version (published together by the Release workflow and bumped
   as one unit by the root bump script); independently released
   packages are not asserted against
5. core-plugins-subpath: the packed @emnapi/core resolves
   ./plugins from a scratch consumer and exposes the asyncWork/tsfn
   plugin factories

--prepared validates the existing release output; --tarball <file>
validates a pre-built tarball, which is how the published
2.0.0-alpha.2 regression was captured.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T34fMrYCf2V13Mg8BKmnda
@Brooooooklyn
Brooooooklyn force-pushed the feat/wasip1-napi-rs-archives branch from 7af0a5a to 977599e Compare July 17, 2026 13:04
@toyobayashi
toyobayashi merged commit 1633570 into toyobayashi:main Jul 17, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants