Playbook — Add a new C++ library as a cppjs-package
Persona 3 — Package author. The user wants to wrap an existing C++ library so it can be
pnpm add @cpp.js/package-<name>consumed.
Goal
Produce a cppjs-package-<name> family that:
- Builds for wasm, android, ios (and, where it makes sense, wasi) with one command.
- Exposes the library to JavaScript through Embind / SWIG bridges.
- Ships with
README.md,LICENSE,.npmignore, and anativeVersion-pinnedpackage.json. - Declares its upstream license truth in the family manifest (
cppjs.upstream.license— contract D incppjs-packages/README.md) socppjs licensescan derive NOTICE/SBOM instead of anyone hand-writing them.
When to use
- The user says "I want to use library X (e.g. libsodium, FreeType, …) from JS".
- The user is contributing a new package to the cpp.js ecosystem.
- A
cppjs-package-Xalready exists but the user is creating a new arch sub-package (e.g. only-wasmexists, they need-ios).
Decision: where does the new package live?
Does this package extend or affect GDAL (or another package already in
this monorepo's transitive dep graph)?
│
├─ YES → Add directly to this repo under cppjs-packages/cppjs-package-<name>/
│ Use the @cpp.js/* npm scope.
│
└─ NO → Author it outside this repo:
1. Strongly encourage the user to create it under the
cppjs-community GitHub org and PR upstream once it works.
2. They can also keep it in their own org. In that case the npm
package name MUST stay unscoped: `cppjs-package-<name>`
(NOT `@user/cppjs-package-X`, NOT `@cpp.js/...`).
The @cpp.js/* scope is reserved for packages reviewed and adopted into this repo. Unscoped cppjs-package-<name> is the convention for community-authored packages in user orgs; this lets cpp.js's plugin discovery find them by name pattern.
Files involved
Mirror the canonical cppjs-packages/cppjs-package-zlib/ skeleton:
cppjs-package-<name>/
├── cppjs-package-<name>/ ← meta package
│ ├── package.json ← name, version 0.1.0, deps to sub-arch packages
│ ├── cppjs.config.js ← re-export sub-arch configs
│ ├── README.md
│ ├── LICENSE
│ └── .npmignore
├── cppjs-package-<name>-wasm/
│ ├── package.json ← nativeVersion, workspace deps to other -wasm packages
│ ├── cppjs.config.js ← env, data, libName, build params
│ ├── cppjs.build.js ← source acquisition + cmake/configure invocation
│ ├── assets/CMakeLists.txt ← (only if upstream needs an override)
│ ├── README.md
│ ├── LICENSE
│ └── .npmignore
├── cppjs-package-<name>-android/ ← same shape as -wasm
├── cppjs-package-<name>-ios/ ← same shape + cppjs-package-<name>.podspec
├── cppjs-package-<name>-wasi/ ← optional: wasi (wasm32-wasip3) prebuilt, same shape
└── cppjs-package-<name>-bin-wasi/ ← optional: upstream CLI as npm commands (bin map in
the recipe; governed by cppjs-packages/README.md)
In this repo the recipe body lives once in the family package (build.mjs
with getURL, sha256, replaceList, …) and each variant's
cppjs.build.js imports and spreads it — platform differences stay in the
variant file.
Required content per file
package.json(each sub-arch):"version": "0.1.0"for fresh packages."nativeVersion": "<upstream version>"— pinned viapnpm run check:native."dependencies": workspace refs to other@cpp.js/package-*-<arch>(or unscopedcppjs-package-*-<arch>for community) the library needs to link against. pnpm derives topological build order from this.
cppjs.config.js: exportedtargetSpecsarray withenv,data,libName, optionalcmake.compileOptions. Function-typedenvvalues receive(state, target)and resolve at build time.cppjs.build.js:getSource()(download/copy/patch upstream),prepare()(cmake configure step),build()(cmake build / make install). UsesgetCppJsScript,run, etc. fromcpp.jsexports.cppjs-package-<name>.podspec(ios only): CocoaPods manifest. Always includes.user_target_xcconfig = { 'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'x86_64' }to keep consumer apps from linking arm64-only iOS simulator slices.README.md: one paragraph intent,nativeVersion, license note, install snippet.LICENSE: copy upstream library's license file. cpp.js's package wrapper itself can use a permissive license (MIT) but the bundled native binary's license governs distribution..npmignore: exclude.cppjs/,dist/<...>/source/, build intermediates. Keepdist/prebuilt/(consumers need the prebuilt artifacts).
Native version sourcing
Always use the latest stable upstream version. Resolution order:
- GitHub releases API (
https://api.github.com/repos/<owner>/<repo>/releases). Filter out prereleases unless the library only ships prereleases. - GitHub tags API (
/tags) — fallback when the project doesn't use Releases. - Project HTML page / download index — last resort (autotools projects often ship tarballs with no GitHub releases).
Existing helper: scripts/check-native-versions.js already implements this resolution chain. Run pnpm run check:native -- --update after adding the package to auto-bump (or to write the initial nativeVersion).
Build system preference
- CMake first. If upstream has a
CMakeLists.txt, use it. Easiest cross-platform story — the cpp.js build pipeline is CMake-native. - autotools (
./configure && make) fallback. Use when upstream has no CMake support and porting is too invasive. Requiresstate.config.build.buildType = 'configure'incppjs.config.js. Seecppjs-package-openssl-*for a reference. - Custom Make / scons / etc. Last resort; usually means writing a thin CMake wrapper or using
getDependFilePath+ manual shell-out.
Commands
# 1. Scaffold: scripts/scaffold-package.js generates the skeleton, or copy
# cppjs-packages/cppjs-package-zlib/ as a starting point.
node scripts/scaffold-package.js <name>
# (manual route: cp -r cppjs-packages/cppjs-package-zlib cppjs-packages/cppjs-package-<name>
# then rename every "zlib" reference inside.)
# 2. Resolve and write nativeVersion
pnpm run check:native -- --update
# (Manually verify the picked version is sane.)
# 3. Build all arches
pnpm --filter='@cpp.js/package-<name>*' run build
# Or one arch at a time during development:
pnpm --filter=@cpp.js/package-<name>-wasm run build
# 4. (Only if integrating into THIS repo) Add an e2e exercise to a sample
# that consumes the new package, e.g. cppjs-sample-lib-prebuilt-matrix.
Validation
Required:
-
pnpm --filter='@cpp.js/package-<name>*' run buildsucceeds for wasm, android (Linux/macOS), iOS (macOS only). -
pnpm run check:distshows the new package as built. - Each sub-arch has README + LICENSE + .npmignore + correct podspec (iOS).
-
nativeVersionmatches latest upstream stable; the recipe carries the tarballsha256. - All transitive C++ deps appear in each sub-arch's
package.jsondependencies. - The family manifest declares
cppjs.upstream.license(declared/selected/files) andcppjs licenses --checkpasses. - Consider
types: true(+dts: 'promise'for worker-first packages) so consumers get generated TypeScript for'<pkg>/<header>.h'imports. -
node scripts/check-publish-hygiene.jspasses (K1: no executable leaks; K4 gates apply if a-binpackage exists).
When integrating into this repo (not a community fork):
- An e2e test exists in a sample that exercises the new package (mirror an existing test in
cppjs-samples/cppjs-playground-*). -
pnpm run e2e:dev && pnpm run e2e:prodpass.
When the user is keeping the package outside this repo:
- Skip the e2e step. Their own project tests it.
- Verify the package builds standalone via
pnpm cppjs buildin their package directory.
Common pitfalls
- Forgetting
EXCLUDED_ARCHS[sdk=iphonesimulator*] = x86_64in the iOS podspec. Without it, consumer apps fail to link on Apple Silicon Macs running iOS simulator. - Missing workspace deps. If
package-<name>-wasmdoesn't listpackage-zlib-wasmindependencies, pnpm may build them in the wrong order; the linker then fails to find symbols. - Mixing scoped and unscoped names. Stick to one:
@cpp.js/*for in-repo, plaincppjs-package-*for community/user-org. Don't mix. - Not pinning
nativeVersion. Without a pin,check:native --updatelater overwrites silently and reproducible builds break. bin/artifacts in.npmignore— make suredist/prebuilt/<target>/lib/lib<name>.a(and.sofor android,.xcframeworkfor ios) is not ignored, or consumers can't link.- Wrong upstream license. The cpp.js wrapper README is permissive but the native binary's license governs distribution. If the upstream is GPL-only, surface this prominently in README and ask the user to confirm intent.
- Naming collisions in user orgs. A user can't publish
@cpp.js/package-foo. They needcppjs-package-foo(unscoped, on npm). cpp.js's plugin discovery finds packages matching*cppjs-package-*regardless of scope. - Recommend over enforce. The user always picks where to host (their org / cppjs-community / direct PR here). Surface the decision tree, don't force.
Reference
- Canonical small example:
cppjs-packages/cppjs-package-zlib/ - CMake-heavy example:
cppjs-packages/cppjs-package-tiff/(transitive deps: zlib, jpegturbo, zstd, lerc) - autotools example:
cppjs-packages/cppjs-package-openssl/ - Big aggregator example:
cppjs-packages/cppjs-package-gdal/(depends on ~13 other packages) - Native version checker:
scripts/check-native-versions.js - Distribution CMake template:
cppjs-core/cpp.js/src/assets/cmake/dist.cmake