C++ Binding Rules — write C++ that cpp.js can auto-bind
cpp.js generates JS bindings automatically. There are no
EMSCRIPTEN_BINDINGSmacros to hand-write. But the generator only handles a constrained subset of C++. Stay inside that subset and you get binding-for-free; step outside and you'll need a wrapper or a SWIG escape (swig-escape.md).
This doc tells you the rules. For the canonical type table (which JS type maps to which C++ type, with toArray/toVector examples), use the website:
https://cpp.js.org/docs/api/cpp-bindings/overviewhttps://cpp.js.org/docs/api/cpp-bindings/data-types
This page covers what the website doesn't: the rules an agent must follow when writing C++ that cpp.js will bind.
The hard rules
1. No raw pointers in public API
// ❌ Won't bind
MyClass* getInstance();
void process(int* data, size_t len);
char* getName();
// ✅ Bind cleanly
std::shared_ptr<MyClass> getInstance();
void process(const std::vector<int>& data);
std::string getName();
cpp.js doesn't expose pointer arithmetic, lifetime, or aliasing semantics to JS. If your library uses raw pointers, you have two options:
- Wrap it (preferred — see § Wrapper pattern below).
- Hide it behind a SWIG
.ifile (escape hatch — seeswig-escape.md).
2. C++11 minimum, C++17 recommended
Build defaults assume modern C++. Use:
std::string,std::vector<T>,std::map<K,V>,std::unordered_map<K,V>std::shared_ptr<T>for heap-allocated objects you return to JSstd::optional<T>(C++17),std::variant<...>(C++17) — supported via website type table- Range-based for,
auto, lambdas,nullptr
Avoid:
std::unique_ptrreturned by value across the binding (useshared_ptrfor cross-boundary ownership)- C-style strings (
char*) and C-style arrays (int[]) in public API - Custom allocators, placement new, manual
malloc/freeexposed to JS
3. Class members must be public to bind
class Matrix {
public:
int rows; // ✅ accessible from JS
int cols;
int get(int i, int j) const;
private:
std::vector<int> data; // ❌ not exposed (still works internally)
};
Private members are fine — they just won't appear in JS. Don't try to hide everything private and expect JS to call into your class.
4. Inheritance + virtual works; multiple inheritance doesn't
Single-base virtual polymorphism is supported. Multiple inheritance (especially diamond) breaks the auto-binder. Refactor to composition or use a .i wrapper.
5. Templates must be explicitly instantiated
// ❌ Won't bind — template only
template<typename T> class Buffer { ... };
// ✅ Bind these specific instantiations
template class Buffer<int>;
template class Buffer<float>;
The auto-binder needs concrete types. Add template class Buffer<T>; declarations for every instantiation you want to expose.
6. Memory + lifecycle is C++-side
You don't call m.delete() in JS. cpp.js doesn't expose raw pointers, so JS-side manual cleanup isn't required. C++ destructors and shared_ptr reference counting handle it. See lifecycle-and-types.md.
7. Exceptions: thrown C++ exceptions become JS exceptions
throw std::runtime_error("...") in C++ surfaces as a thrown JS Error with the message. Use this rather than out-parameters or status codes — it's the binding-friendly path.
double sqrt(double x) {
if (x < 0) throw std::invalid_argument("sqrt of negative");
return std::sqrt(x);
}
In JS:
try {
m.sqrt(-1);
} catch (e) {
console.error(e.message); // "sqrt of negative"
}
Wrapper pattern
If the upstream library you're using has raw pointers, multiple inheritance, templates, or other unbindable patterns, you wrap it. Two locations work:
A. App-side wrapper (preferred for one-off integration)
You're building an app that uses an unwrapped C++ library. Write the wrapper in your src/native/ folder:
my-app/
└── src/native/
├── upstream/ # vendored upstream lib
│ └── upstream.h # has raw pointers
└── wrapper.h # YOUR clean API
└── wrapper.cpp
// wrapper.h
#include "upstream/upstream.h"
class CleanWrapper {
public:
CleanWrapper();
std::vector<float> process(const std::vector<float>& input);
private:
std::shared_ptr<upstream::RawType> raw_;
};
cpp.js binds CleanWrapper; the raw type stays internal.
B. Lib-side wrapper (when authoring a cppjs-package-*)
If you're writing a reusable @cpp.js/package-X, put the wrapper inside the package's source folder so all consumers benefit:
cppjs-package-mylib/
└── cppjs-package-mylib-wasm/
└── src/native/
└── wrapper.h # exposed binding API
App-side wrapper is the default; lib-side only when you're publishing a package.
Advanced: JSPI flag (experimental)
The Emscripten -sJSPI flag enables JavaScript Promise Integration — letting C++ code call into JS-promising code synchronously (the C++ stack suspends on await). The living demos are cppjs-playground-backend-nodejs and cppjs-playground-backend-nodejs-multithread (Node, run with --experimental-wasm-jspi), where a _JSPI method performs a curl request over the network.
You'd opt in via targetSpecs[].specs.emccFlags in cppjs.config.js:
targetSpecs: [{
platform: 'wasm',
specs: { emccFlags: ['-sJSPI'] },
}]
Naming rule: _JSPI suffix
Once -sJSPI is enabled, any C++ method or function that should be JSPI-wrapped must end with _JSPI. The cpp.js auto-binder detects the suffix and emits emscripten::async() on the binding so the call returns a Promise on the JS side and the C++ stack can suspend mid-execution.
// native.h
class Native {
public:
static std::string sample(); // regular sync binding
static void ops_JSPI(); // JSPI-wrapped — async on JS side
static std::vector<std::string> listVirtualFiles_JSPI();
};
The auto-generated bridge becomes:
.class_function("sample", &Native::sample)
#ifdef CPPJS_JSPI
.class_function("ops_JSPI", &Native::ops_JSPI, emscripten::async())
#endif
#ifdef CPPJS_JSPI
.class_function("listVirtualFiles_JSPI", &Native::listVirtualFiles_JSPI, emscripten::async())
#endif
Every async registration is guarded behind CPPJS_JSPI, which cpp.js defines only for targets whose emccFlags include -sJSPI. One bridge file serves every target of a package, so on a target without the flag a _JSPI binding is simply absent on the JS side — the build logs _JSPI bindings skipped: this target links without -sJSPI — instead of aborting emsdk DEBUG builds at embind registration time ("Async bindings are only supported with JSPI").
On the JS side, call the function with the suffix preserved and await it:
await m.Native.ops_JSPI();
const files = await m.Native.listVirtualFiles_JSPI();
If you forget the suffix, the binding stays synchronous; calls into JS promises from inside that C++ function will then crash with Cannot suspend without JSPI at runtime.
Where JSPI actually works (verified against the playgrounds):
- Node (st and mt): works behind
node --experimental-wasm-jspi; without the flag a JSPI-linked module aborts at boot ("JSPI not supported by current environment"). - Browser, st runtime: Chromium only. Firefox and WebKit ship no
WebAssembly.Suspending, and a glue linked with-sJSPIrefuses to boot there even if nothing ever suspends. - Browser, mt (pthreads) runtime: do NOT combine with
-sJSPI. The pthread mailbox enters wasm outside a promising export, so Chromium throwsSuspendError: trying to suspend without WebAssembly.promisingat boot. The mt web playgrounds deliberately carry no JSPI flag for this reason.
Use cases: callbacks into JS that fetch network data, awaiting JS promises mid-C++. Don't enable it unless you specifically need synchronous cross-boundary await. See performance.md for override safety.
Common mistakes (from the build-pipeline source code)
- Returning a
unique_ptrfrom a bindable function → binding silently fails or returns null. Useshared_ptr. - Defining the class in the
.cpponly (forward-declared in.h, full definition hidden) → binder needs the full definition in the header it scans. - Anonymous namespaces wrapping the public API → not exposed. Public API stays in named or no namespace.
extern "C"decoration on C++ class methods → invalid. Only useextern "C"for C-style free functions.- Returning a reference or pointer to a stack object → undefined behavior; binder doesn't catch it. Always return by value or by
shared_ptr.
When the rules don't fit
Three escape hatches, in order of preference:
- Wrap it in C++ (above) — most maintainable.
- Write a
.ifile for SWIG (swig-escape.md) — fine for selective custom types. - Open an issue — if a common pattern keeps falling outside the auto-binder, the binder itself can be extended.
See also
swig-escape.md— when and how to write a manual SWIG.ifile.lifecycle-and-types.md— why JS-sidem.delete()isn't a thing in cpp.js.cppjs-config.md—targetSpecs[]for emccFlags overrides like-sJSPI.- Website: Type table, Classes & functions.