Ship a package
The generated wrapper is enough for a script (include + using .Module). This page is the next step: a real Julia package that precompiles, owns C++ lifetimes, and does not fork the generated file.
The working reference is examples/BoxWorld in RepliBuild-Hub — a physics sandbox on the Box2D wrapper.
How to call the generated API is Call a wrapper. This page is layout, precompilation, and the ergonomic layer you write once.
Two layers
- ABI layer — RepliBuild output (
<Name>.jl, the.so,compilation_metadata.json,thunk_manifest.json). Vendor it unmodified. Regenerate withwrap(). Never edit it. - Ergonomic layer — your package. Julia-idiomatic types, lifecycles, defaults, naming. Every C++-ism the wrapper cannot hide (ctor-only classes, header-inline defaults, abstract-class vtables) is wrapped once, here.
Library-specific niceties belong in your package, not in hand-edits to the generated file. That is what keeps the wrapper regenerable.
Layout: vendor the wrapper
MyPkg/
├── Project.toml # [deps] must include RepliBuild
├── lib/ # verbatim from julia/
│ ├── Box2d.jl
│ ├── libbox2d.so
│ ├── compilation_metadata.json
│ └── thunk_manifest.json
├── src/MyPkg.jl
└── test/runtests.jlmodule MyPkg
using RepliBuild # C++ wrappers dispatch thunks through RepliBuild
include(joinpath(@__DIR__, "..", "lib", "Box2d.jl"))
using .Box2d
# … your ergonomic layer …
endThe copy in lib/ is self-contained:
- The wrapper resolves its
.sonext to itself first (build-time absolute path is the fallback), so the vendored pair stays bound wherever the package lives. - Metadata is read from the wrapper's own directory at load.
- The only Julia dependency is
RepliBuild: every wrapper imports it for its build-identity check, and C++ wrappers dispatch through it. A C wrapper needs nothing more. A wrapper with Tier 2 functions — most C++ wrappers — also needs the JLCS dialect library loadable on the machine that runs it, in that machine's RepliBuild install. That means a system LLVM/MLIR 21+ there, and the dialect built against it (Install).
Alternatives:
RepliBuild.use("box2d")at runtime, once that name is registered (scripts, not precompiled packages).using Box2dafterregister: the package it adds to your Julia environment precompiles like any other, but it points at the build vendored in~/.replibuild/, so it is for this machine, not for shipping.RepliBuild.scaffold_package, for a skeleton that builds the library on the consumer's machine instead of shipping a binary.
Precompilation
The generated module is precompilation-safe:
dlopen, JIT setup, and C stdout unbuffering live in the wrapper's__init__, which Julia runs at load, never during precompilation.- Duplicate method signatures are deduplicated at generation time. Method overwriting is a hard error during package precompilation.
Older files fail with Method overwriting is not permitted. Regenerate with a current RepliBuild.wrap(toml). use()-managed packages rebuild automatically — the cache fingerprints the generator.
Your layer must follow the same two rules:
- Never bake process addresses at top level. A
constfromdlsym,cglobal, or a JIT lookup is evaluated during precompilation and baked stale. Use aReffilled in your package's__init__. - Baking pure data is good. Parsing
compilation_metadata.jsonintoconstoffset tables at top level is safe — the metadata ships inside the package and cannot drift from the vendored.so.
dispatch_tier is not a read-only lookup; do not call it at module scope. Ask at runtime. See Call a wrapper.
Load time
Loading a C++ wrapper stands up a per-binary engine and compiles the thunks that wrapper dispatches to. That costs on the order of a second for a mid-sized library, once per process, in __init__.
Multiple wrapped libraries compose: one engine per .so. One library's initialization failure disables the hard ABI path for that library only — its ccall wrappers and every other library keep working. The failure does not throw: the wrapper logs Failed to initialize JIT for lib….so with the reason, using MyPkg succeeds, and calling one of that library's Tier 2 functions then raises. To degrade features instead, check the engine in your __init__: RepliBuild.JITManager.GLOBAL_JIT.engines holds one entry per library, with binary_path and init_error (nothing when it worked).
With [compile] aot_thunks = true, wrap compiles the thunks the wrapper binds into a companion _thunks.so instead of leaving them to be compiled at load: faster startup, a second binary, and no JIT compilation in the consumer. It does not remove the dialect library: the AOT thunks still need libJLCS when they run. On Linux they import jlcs_catch_current_exception from it, and on Windows every thunk call goes through its jlcs_guard_* frame, which is what catches a C++ exception there. An AOT wrapper whose thunks library will not open fails in __init__, naming the state of libJLCS.
C++-isms to wrap once
The wrapper gives you the raw material. Your layer hides each of these behind ordinary Julia functions:
Ctor-only classes (no factory, e.g. b2World): allocate struct_size bytes, call the constructor symbol on that storage, run the destructor at teardown.
mem = zeros(UInt8, struct_size("b2World"))
ccall((:_ZN7b2WorldC2ERK6b2Vec2, Box2d.LIBRARY_PATH), Cvoid,
(Ptr{Cvoid}, Ptr{b2Vec2}), pointer(mem), gravity_ref)Header-inline default constructors (b2BodyDef()): inline functions have no symbol. Write fields at their DWARF offsets — member_offset, never a literal — so layout drift fails loudly.
Header-inline accessors (GetPosition()): same, read the member at its metadata offset.
Abstract-class instances the library virtual-calls (a b2CircleShape it Clone()s): plant the real compiler vtable — dlsym the _ZTV… symbol, skip 16 bytes (offset-to-top + RTTI) to the address point, store it at offset 0. Resolve that address in __init__ (it is a process address).
Finalizers that call a C++ destructor: warm the thunk once in __init__ so the first GC pass is not the first compile. Offer destroy!(x) for deterministic teardown; keep the finalizer as the safety net. Make destruction idempotent.
Every layout fact your layer uses should come from the wrapper's own struct_size / member_offset (or compilation_metadata.json).
Testing
Treat the wrapper boundary as RepliBuild's job. Test your claims: create/use/destroy/idempotent-destroy, library behavior through your API, the finalizer path (GC.gc() after dropping a live object), and that the C++ engine registered if you depend on it:
@test any(e -> occursin("libbox2d", e.binary_path) && e.init_error === nothing,
RepliBuild.JITManager.GLOBAL_JIT.engines)That holds for a JIT wrapper. An AOT wrapper creates no engine; for it, call one of its Tier 2 functions in the test instead.
Deployment
- The vendored
.sois platform-specific. Multi-platform: per-platform artifacts, orscaffold_packageso the consumer builds locally. The.jlis platform-independent apart from the binary it binds. - Strip nothing. The DWARF in the
.sois read at load. - Linux and Windows, same as RepliBuild. The vendored binary is
.soor.dll; the.jlis the same either way. - On Windows, a C wrapper's
.dllimports only the Windows C runtime and whatever[link] link_librariesadds. A C++ wrapper also needsC:\msys64\clang64\binon the consumer'sPATH:libJLCS.dllimportslibLLVM-<N>.dllandlibc++.dllfrom there, and a library that uses the standard library importslibc++.dllitself. - A JIT wrapper also runs
llvm-dwarfdumpat load, so the consumer needs it onPATHtoo.