crossbind
GitHub
GUIDE · PLATFORMS · BROWSER

Browser

In the browser your C++ runs as WebAssembly behind a JavaScript loader. A bundler plugin turns each header you import into a JavaScript module, compiles the wasm while you develop and emits it with your bundle; without a bundler, the CLI builds the loader and the wasm for a <script> tag.

Setup

A browser project needs three things: the plugin for its bundler, a crossbind.config.js at the project root and a header imported from app code. The scaffolder sets up all three - React, Vue or Svelte on Vite, React on Rspack, or a page with no bundler:

shell
npm create crossbind@beta my-app Web React Vite

In an existing app, add the plugin for your bundler from the sections below and this config; Configuration has the rest of what the file takes.

crossbind.config.js
export default {
paths: {
config: import.meta.url,
},
};

C++ lives in src/native. Import a header and call initNative() once: it boots the module and binds every header the app imports.

src/main.js
import { initNative, Native } from './native/native.h';
 
initNative().then(async () => {
console.log(await Native.sample());
});

The call returns a promise because the module runs in a Web Worker by default - see Workers and storage.

Vite

shell
npm install -D @crossbind/plugin-vite@beta
vite.config.js
import { defineConfig } from 'vite';
import viteCrossbindPlugin from '@crossbind/plugin-vite';
 
export default defineConfig({
plugins: [viteCrossbindPlugin()],
});

The plugin sits next to your framework's plugin; React, Vue and Svelte need nothing more.

Webpack

shell
npm install -D @crossbind/plugin-webpack@beta @crossbind/plugin-webpack-loader@beta
webpack.config.mjs
import CrossbindWebpackPlugin from '@crossbind/plugin-webpack';
 
const crossbindWebpackPlugin = new CrossbindWebpackPlugin();
 
export default {
module: {
rules: [crossbindWebpackPlugin.getRule()],
},
plugins: [crossbindWebpackPlugin],
devServer: crossbindWebpackPlugin.getDevServerConfig(),
};

getRule() hands headers and .rs files to the loader package, which is why both are installed. getDevServerConfig() serves the compiled module in dev and sends the COOP/COEP headers; if you already have a devServer block, spread it in and keep its headers and setupMiddlewares.

Keep the config an ES module
crossbind loads your project config with a top-level await, and Node cannot require() a module that does: a CommonJS webpack.config.js that requires the plugin fails with ERR_REQUIRE_ASYNC_MODULE. Name it webpack.config.mjs, or set "type": "module".

Rspack

Rspack takes the same two packages through its webpack-compatible plugin API.

shell
npm install -D @crossbind/plugin-webpack@beta @crossbind/plugin-webpack-loader@beta
rspack.config.mjs
import { defineConfig } from '@rspack/cli';
import CrossbindWebpackPlugin from '@crossbind/plugin-webpack';
 
const crossbindWebpackPlugin = new CrossbindWebpackPlugin();
 
export default defineConfig({
module: {
rules: [crossbindWebpackPlugin.getRule()],
},
plugins: [crossbindWebpackPlugin],
devServer: crossbindWebpackPlugin.getDevServerConfig(),
});

Rollup

The Rollup plugin is the core the Vite plugin wraps; use it directly only for a plain Rollup build. Rollup has no dev server: rollup -c -w rebuilds the wasm when a native file changes, and nothing reloads a page.

shell
npm install -D @crossbind/plugin-rollup@beta
rollup.config.mjs
import rollupCrossbindPlugin from '@crossbind/plugin-rollup';
 
export default {
input: 'src/index.js',
output: { file: 'dist/index.js', format: 'esm' },
plugins: [rollupCrossbindPlugin()],
};

The bundle step writes crossbind.js and crossbind.wasm into the output directory. Plain Rollup has no base path, so the generated code loads them from the site root.

No bundler

A static page builds with the CLI and loads the result with a <script> tag. Point paths.output at the folder you serve and build for the browser:

crossbind.config.js
export default {
general: { name: 'my-app' },
paths: {
config: import.meta.url,
output: 'dist',
},
};
shell
npm install -D crossbind@beta
npx crossbind build -p wasm -a wasm32 -r st -e browser -b release

That writes dist/my-app-wasm-wasm32-st-release.browser.js and its .wasm. The script is a UMD bundle that defines a global initNative, so load it with a classic <script> tag; path says where the files are, relative to the page.

index.html
<script src="./dist/my-app-wasm-wasm32-st-release.browser.js"></script>
<script>
initNative({ path: './dist' }).then(async ({ Native }) => {
document.querySelector('#out').textContent = await Native.sample();
});
</script>

Nothing reads your imports here, so the build binds every .h file under src/native, and a library reaches the page through a header of your own. There is no dev server and no rebuild on save.

Workers and storage

In a browser, initNative() starts the module in a Web Worker: useWorker defaults to on wherever Worker exists. The page thread never blocks on C++, and every call returns a promise, construction included (await new X()). Set dts: 'promise' in crossbind.config.js so the generated TypeScript says the same.

Pass useWorker: false to keep the module on the page thread, where calls return their values directly and a long call blocks the page:

src/main.js
initNative({ useWorker: false }).then(() => {
console.log(Native.sample());
});

Files under /opfs/<app>/ survive a reload; OPFS needs the worker, and the generated loader turns it on by default only for mt builds. Files under /memfs/<app>/ live in memory for the session. Files covers both, and Threads and workers how useWorker and runtime: 'mt' combine - they are independent.

Development

The first dev start compiles the wasm in Docker, or on a remote runner, and takes a while; later starts reuse the cache in .crossbind/. Dev servers build the debug variant, production builds the release one.

Saving a header, a C++ source or a .rs file in src/native rebuilds the wasm and reloads the page, and a function you newly import from a library header binds when you save the importing file. The Vite and webpack dev servers, and vite preview, send the COOP/COEP headers, so a multithreaded build runs locally as it is.

Production build

vite build, rspack build or a production webpack build compiles the release variant and writes crossbind.js, crossbind.wasm and, when the project packs data files, crossbind.data.txt into the output directory. Deploy them with the bundle. The generated code loads them from Vite's base or webpack's output.publicPath (auto included), so an app served from a sub-path finds them.

A single-threaded build needs no special headers. A runtime: 'mt' build needs SharedArrayBuffer, which browsers enable only on a cross-origin isolated page, so the production host has to send:

response headers
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Without them SharedArrayBuffer is undefined and the module fails to start; crossOriginIsolated must be true in the console of the deployed page. Threads and workers shows where each host takes the headers. GitHub Pages cannot send them, so a multithreaded build does not run there.

Another bundler

The plugins are thin: the work is done by functions crossbind exports, and @crossbind/plugin-rollup is the smallest complete plugin to copy. Each one starts by picking the browser target:

my-plugin.js
import {
state, buildDependencies, createBridgeFile, getCrossbindScript, getDependFilePath,
createLib, buildWasm, appSourceFiles, getTargetParams, getFilteredBuildTargets,
} from 'crossbind';
 
const targetParams = getTargetParams({ platform: ['wasm'], arch: ['wasm32'], runtime: ['st'], runtimeEnv: ['browser'] }, true);
const target = getFilteredBuildTargets(targetParams, { buildType: 'release' })?.[0];
  • Before the first transform, buildDependencies() puts the library headers in place.
  • Imported headers become modules through createBridgeFile() and getCrossbindScript(); a library header such as @crossbind/port-zlib/zlib.h resolves through getDependFilePath().
  • The bundle step compiles with createLib() and buildWasm() and emits the target's jsName, wasmName and dataTxtName from state.config.paths.build as crossbind.js, crossbind.wasm and crossbind.data.txt.
  • In dev, the plugin serves those files and watches state.config.paths.native and appSourceFiles(), whose imports decide what a library header binds.

Limits

  • Next.js has no example or test in the repository, and the webpack plugin does not run under Turbopack.
  • COEP in development. The dev servers send Cross-Origin-Embedder-Policy: require-corp for every build, single-threaded too, so a cross-origin image or script without a Cross-Origin-Resource-Policy header fails to load in dev but not in production.
  • Synchronous callbacks into page code do not cross the worker: design them as promise round-trips, or set useWorker: false.

Complete apps: web-react-vite, web-vue-vite, web-svelte-vite, web-react-rspack and web-vanilla - each one is also a scaffolder template.

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