crossbind
GitHub
GUIDE · BUILD · PUBLISHING PACKAGES

Publishing packages

A crossbind package is an npm package with a crossbind.config.js that apps list in their dependencies. You build it once per platform and publish the libraries; apps link them without compiling your code. Every @crossbind/port-* package is made this way.

Package types

TypeIn crossbind.config.jsWhat an app gets
Prebuiltexport.type: 'cmake', the defaultcompiled libraries per platform under dist/prebuilt/ - nothing to compile
Sourceexport.type: 'source'your headers and sources, compiled into the app's own build
CMakeexport.type: 'cmake' with your own CMakeLists.txt and paths.output: '.'your CMake project, added with add_subdirectory and built with the app
Cargoexport.type: 'cargo'a Rust crate built per platform - see Rust crates

Prebuilt is what the ports are, and what the rest of this page builds. Source and CMake packages move the compile into every app's build. A CMake package names its library target after the CROSSBIND_LIB_NAME the app's build passes in, since only that target's PUBLIC include directories reach the app.

Each C++ type has a template: npm create crossbind@beta -- my-lib Library Prebuilt, with Source or CMake in place of Prebuilt.

The package config

crossbind.config.js
export default {
general: { name: 'mylib' },
paths: {
config: import.meta.url,
native: ['src/native'],
output: 'dist',
},
export: {
type: 'cmake',
libName: ['mylib'],
},
};

export.libName lists the archives the package ships, one per library (['ssl', 'crypto'] for OpenSSL), and defaults to general.name; paths.output is the folder you publish. types: true also writes one declaration file over the package's headers and points types in package.json at it, so an app importing mylib/<header>.h gets types. The other export fields are under Configuration.

A package's targetSpecs reach every app that depends on it: the data and env it declares (Data files), and flags such as binary.addonFlags for a system library a Node.js addon must link, or cmake.compileOptions for a define your headers expect.

Build once per platform

Without -e, crossbind build makes only the libraries, which is what a package publishes: static archives, and shared libraries on Android. Run it for each platform you support; iOS and macOS build on a Mac, the rest in the toolchain images:

shell
crossbind build -p wasm
crossbind build -p android
crossbind build -p ios
crossbind build -p darwin,linux,linuxmusl,win32

With no -p it builds wasm, WASI, Android and iOS; macOS, Linux and Windows build only when named. Each target gets a folder named <platform>-<arch>-<runtime>-<buildType>:

dist/
dist/prebuilt/
├── CMakeLists.txt
├── wasm-wasm32-st-release/{include,lib}
├── wasm-wasm32-mt-release/{include,lib}
├── android-arm64-v8a-mt-release/{include,lib}
├── ios-iphoneos-mt-release/{include,lib}
└── linux-x64-mt-release/{include,lib}

dist/prebuilt/CMakeLists.txt lists every target present and is how an app's build finds your libraries; an import path after your package name resolves inside the target's include/. An iOS build also writes <libName>.xcframework at the package root, with a .podspec beside it when the package has none.

Publish dist/, the xcframework and the podspec, and keep .crossbind/ out, as the ports' .npmignore does. A prepublishOnly script that runs the build keeps npm publish from shipping stale libraries.

One package, or a family

One package can carry every platform, as lib-prebuilt-matrix does, but then every app downloads all of them. The ports split each library into a family instead: a meta package, @crossbind/port-zlib, with the shared recipe and config, and one package per platform - -wasm, -android, -ios, -darwin, -linux, -linuxmusl, -win32, -wasi - each depending on the meta package and built with its own crossbind build -p <platform>.

What makes the variants one library to an app is general.alias. Every variant names the meta package there, so @crossbind/port-zlib/zlib.h resolves to whichever variant in the app's dependencies has libraries for the target being built. For zlib's wasm variant the config comes down to:

crossbind.config.js
export default {
general: {
name: 'z',
alias: { package: '@crossbind/port-zlib' },
},
export: { type: 'cmake', publicHeaders: ['zlib.h'] },
paths: { config: import.meta.url, output: 'dist' },
};

zlib writes the shared part once, in the meta package's mergeConfig.mjs, and each variant's config calls it; each variant's crossbind.build.js is one line, export { default } from '@crossbind/port-zlib/build.mjs';, re-exporting the recipe.

Wrapping an upstream project

To package a library you do not maintain, put a crossbind.build.js next to the config; the CLI merges its exports into the build. crossbind build then downloads the tarball of the nativeVersion in package.json, checks its SHA-256, applies your patches and builds it with the upstream's own build system for each target. zlib's recipe, shortened:

crossbind.build.js
export default {
sha256: 'bb329a0a2cd0274d05519d61c667c062e06990d72e125ee2dfa8de64f0119d16',
getURL: (version) => `https://github.com/madler/zlib/releases/download/v${version}/zlib-${version}.tar.gz`,
buildType: 'cmake',
getBuildParams: () => ['-DZLIB_BUILD_SHARED=OFF', '-DZLIB_BUILD_TESTING=OFF'],
};
HookUse it to
getURL(version), sha256name the upstream tarball and pin it; a different download refuses to build
buildType'cmake', the default, or 'configure' for autotools; configureProgram and makePhases adjust the two steps
getBuildParams(target, depPaths)pass -D flags or configure arguments, per target
envset CFLAGS, LDFLAGS and the like for the upstream build, as a list or a function of the target
replaceList, sourceReplaceList(target, depPaths)patch the extracted source once, or each target's copy
copyToSource, copyToDistadd a file to the source before the build, or ship one in each target's folder
beforeRun(cmakeDir)run commands first, such as autoreconf -fi
getExtraLibs(target)add link libraries the dependencies do not bring
bundleddeclare third-party code the upstream compiles in, per platform, so its licence is listed and shipped

The build already puts your native dependencies' include and library folders on CFLAGS and LDFLAGS; depPaths.<libName>.header and .lib name them per target for flags like GDAL's -DZLIB_INCLUDE_DIR. When a recipe builds a static library on Android, say so in the config with targetSpecs: [{ platform: 'android', specs: { libType: 'static' } }], as zlib does, so apps link the archive instead of looking for a .so. Every argument is in crossbind-build.md.

Native dependencies

A package that links another declares it twice, each time with the platform variant that matches its own - a -wasm package depends on -wasm packages. The config's dependencies is what crossbind links and puts on the include path. The package.json dependencies is what installs it along with your package and, in a workspace, orders the builds so its libraries exist before yours link against them. Neither entry does the other's job.

package.json
{
"name": "@crossbind/port-gdal-wasm",
"dependencies": {
"@crossbind/port-gdal": "^2.0.0-beta.63",
"@crossbind/port-proj-wasm": "^2.0.0-beta.63",
"@crossbind/port-zlib-wasm": "^2.0.0-beta.63"
}
}
crossbind.config.js
import mergeConfig from '@crossbind/port-gdal/mergeConfig.mjs';
import projWasm from '@crossbind/port-proj-wasm/crossbind.config.js';
import zlibWasm from '@crossbind/port-zlib-wasm/crossbind.config.js';
 
export default mergeConfig({
dependencies: [projWasm, zlibWasm],
paths: { config: import.meta.url },
});

Rust crates

A Rust crate ships as one package that carries every platform. Set export.type: 'cargo' and point export.crate at the folder holding Cargo.toml. The crate stays plain Rust: crossbind generates a companion bridge crate from the public surface of its src/lib.rs.

crossbind.config.js
export default {
general: { name: 'demo' },
export: {
type: 'cargo',
libName: ['demo'],
crate: '.',
bindings: {
vectors: [{ of: 'i32', name: 'RustIntVector' }],
},
},
paths: {
config: import.meta.url,
output: 'dist',
},
};

crossbind build -p <platform> runs cargo build --release --target <triple> for each target and stages the static library in dist/prebuilt/<target>/lib/ like any prebuilt; WASI is skipped. The bridge builds against @crossbind/core-embind-rust, so add it as a dev dependency. bindings.vectors declares standalone Vec<T> classes of i32, f64 or bool.

Apps import the package by its name - import { initNative, RustyCounter } from '@crossbind/embind-rust-demo' - with types mapped as on the Rust page. An app that finds no prebuilt for its target, or one built against another embind-rs, builds it in the package's own folder first, with the toolchain any Rust build needs (Rust).

Licences

An app that ships your package takes on its licence and those of everything linked in, so make that checkable, as the ports do:

  • Pin the upstream source with sha256 in the recipe. A mismatch refuses the build; without a pin the download is not checked.
  • Ship the upstream licence text as LICENSE in every package that carries binaries, and set the npm license field to the wrapped library's licence as an SPDX expression.
  • In a family, record the upstream licence once, in the meta package's package.json under crossbind.upstream.license: declared, selected for your pick of an OR expression, files and copyright. crossbind licenses reads it there; a package outside a family it lists from its license field and LICENSE file.
  • Declare third-party code the upstream compiles in under bundled in the recipe. The build copies its licence texts to dist/licenses/<name>/, and crossbind licenses --platform <platform> lists it.

Run crossbind licenses --check in each platform package before you publish; it fails when a licence field it lists is missing or not valid SPDX. Apps then write notices and a CycloneDX SBOM for everything they link with --notices and --sbom (CLI).

Type to search every guide page and section.
↑↓ navigate↵ openesc close