Variants and assemble
One bindings list, N binaries, one types.d.ts — the shared surface, createInstance, eager vs factory roots, and the exports fragment.
A custom build usually ships more than one binary from the same symbol set: a
single-threaded one that loads anywhere, and a pthread-enabled one for hosts
that are cross-origin isolated. variants expresses that in the config, and
libcascade assemble turns the resulting artifacts into a package surface.
Variants share one bindings list
variants: [
{ name: 'single', settings: { EVAL_CTORS: 2 } },
{
name: 'multi',
compilerFlags: { threads: true },
settings: { PTHREAD_POOL_SIZE: 'navigator.hardwareConcurrency', SHARED_MEMORY: true },
},
],Two binaries, one bindings array, a handful of flags of difference. Nothing
templates one config into two.
Note what the multi variant does not declare. compilerFlags: { threads: true }
renders -pthread, and the requires: ['threads'] that drives image selection
and the generated capability probe is inferred from exactly that — a build
against shared memory cannot run without SharedArrayBuffer, so the capability
is a consequence of the flags rather than something you restate. Declare
requires yourself only to add a capability the flags do not imply.
That sharing is load-bearing. When variants can drift in their symbol lists,
their generated .d.ts files drift too, and a consumer holding a
TopoDS_Shape from one variant cannot pass it to a function typed against the
other — the two OpenCascadeInstance types are structurally incomparable
despite describing the same classes. Sharing the list makes one shared surface
possible.
A per-variant bindings extension is allowed but discouraged for exactly that
reason.
A capability is both a build and a runtime fact
A variant's threads capability does two things:
- At build time it selects the multi-threaded container image for that variant.
- At load time it becomes the capability the generated selector probes:
SharedArrayBufferpresent ∧ (globalThis.crossOriginIsolated?? running under Node).
Capabilities are inferred from the variant's effective build flags —
compilerFlags.threads, a -pthread raw flag, or a SHARED_MEMORY /
USE_PTHREADS setting all imply threads. An explicit requires array is
unioned with what was inferred, so it can add a capability but never remove
one: a build that genuinely needs shared memory cannot be configured to skip
its own probe.
A variant with no capabilities is loadable everywhere.
What assemble writes
npx libcascade assemble| File | Role |
|---|---|
types.d.ts | One d.ts unioning every variant's surface. Symbols only some variants bind are typed optional. |
init.js / init.d.ts | The ./init subpath — createInstance, the selector, and the override symbol. |
init.<variant>.js | The ./<variant>/init subpath — the same entry pinned to one variant. Multi-variant packages only. |
index.js / index.d.ts | The root entry, in eager or factory mode. |
variant.d.ts | Types for the raw per-variant glue subpaths (./single, ./multi, …). |
exports.json | The exports fragment to merge into package.json. |
The shared d.ts and the eager barrel are rendered from one symbol list computed once, so the declaration and the runtime export cannot disagree.
createInstance
./init is the entry every consumer can use, in every mode:
import { createInstance } from 'my-occt-package/init';
const oc = await createInstance(); // most capable variant
const single = await createInstance({ variant: 'single' });
const capped = await createInstance({ variant: 'multi', threadCount: 4 });| Option | Effect |
|---|---|
variant | Load a named variant instead of the selected one. |
threadCount | Size OCCT's default thread pool and raise its launch cap to match. Omitted, OCCT sizes the pool itself. |
locateFile | Emscripten's WASM URL resolver, for bundlers that relocate the binary. |
wasmBinary | Pre-fetched bytes, instead of a network fetch. |
wasmMemory | A WebAssembly.Memory you own. |
It owns the plumbing every consumer used to reimplement:
- Glue self-reference. Emscripten spawns pthread workers from the glue's
own URL;
createInstancepasses it asmainScriptUrlOrBlobso you do not have to know that name. - Node
file:URL → path conversion. Node'sWorkertakes a path, and afile:URL's pathname keeps a leading slash before a Windows drive letter that Node rejects. - OCCT thread-pool sizing. For a
threadsvariant it callsOSD_ThreadPool.DefaultPool(…)andSetNbDefaultThreadsToLaunch(…)so the lazily-created default pool does not stay smaller than the worker count baked into the binary. - An actionable error when you ask for a variant this host cannot run, naming the unmet capability and how to provide it.
./<variant>/init — one variant, one glue
A multi-variant package also generates a pinned entry per variant:
import { createInstance } from 'my-occt-package/single/init';
const oc = await createInstance(); // always the single variantIt exports the same createInstance and owns the same plumbing. The one thing
it does differently is name exactly one glue file.
That matters because of how bundlers treat asset URLs. The shared ./init must
be able to reach every variant — the choice is made at runtime — so it
contains one new URL('./<glue>.js', import.meta.url) per variant. Vite's
vite:asset-import-meta-url transform emits an asset for every one of those at
transform time, before tree-shaking, and no branch is ever statically dead.
An app that only ever loads the single variant therefore still ships the multi
variant's glue JS — 60–80 KB it never fetches. The pinned entry contains one
such URL, so nothing else is emitted.
The .wasm is unaffected either way. The glue import is deliberately opaque to
bundlers, so only the binary a consumer references itself — via
./<variant>/wasm or locateFile — enters a build. That property holds for
both entries and is pinned by a Vite host-app test in the toolchain suite.
./init | ./<variant>/init | |
|---|---|---|
| Variant | selected at load time | fixed at import time |
| Glue assets in the bundle | one per declared variant | one |
| Capability probe + actionable error | yes | yes |
Symbol.for('<pkg>.select') override | picks the variant | must name this entry's variant, or the call throws |
Pick ./init when the app should adapt to the host (a library, or a page that
may or may not be cross-origin isolated). Pick ./<variant>/init when the
answer is already known — a Node CLI that always wants threads, a page that
deliberately serves the single-threaded build — and the bytes matter.
Single-variant packages get no pinned entry: their ./init already resolves
exactly one glue.
A pinned entry knows about one variant and nothing else, so both
createInstance({ variant: 'other' }) and a Symbol.for('<pkg>.select')
override naming another variant fail with
Unknown variant "other". Declared: single. — loudly, rather than by quietly
handing back a variant you did not ask for.
Root modes
factory
assemble: { exports: 'factory' }The root re-exports createInstance. Nothing is instantiated until you ask.
The factory entries re-export the shared surface with export type *, so
import type { TopoDS_Shape } from 'my-occt-package' keeps working in type
position without promising a runtime value the root does not export.
Pick this when your package is a library whose consumers decide when — and whether — the WASM module comes up.
eager
assemble: { exports: 'eager' }The root probes capabilities, selects a variant, initialises it with a
top-level await, and re-exports every bound symbol as a named value:
import oc, { BRepPrimAPI_MakeBox } from 'libcascade';
using box = new BRepPrimAPI_MakeBox(10, 10, 10);
using also = new oc.BRepPrimAPI_MakeBox(10, 10, 10);That is the mode the libcascade package itself ships. Named value exports are
what make gp_Pnt usable as a value in an external editor instead of failing
with "cannot be used as a value".
Importing ./init never evaluates the eager root, so a consumer who wants the
factory never pays for an instantiation they did not ask for.
Selection and the override symbol
The selector picks the most capable variant whose requires all probe
true; configuration order breaks ties. To force a choice, set the override
before importing the root or calling createInstance:
globalThis[Symbol.for('libcascade.select')] = 'single';The symbol key is <your package name>.select. It is a pre-import override:
set it in a module that runs first, not after the root has already evaluated.
The exports fragment
npx libcascade assemble --write-exports{
"exports": {
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
"./init": { "types": "./dist/init.d.ts", "default": "./dist/init.js" },
"./single": { "types": "./dist/variant.d.ts", "default": "./dist/myapp_single.js" },
"./single/init": { "types": "./dist/init.d.ts", "default": "./dist/init.single.js" },
"./single/wasm": "./dist/myapp_single.wasm",
"./multi": { "types": "./dist/variant.d.ts", "default": "./dist/myapp_multi.js" },
"./multi/init": { "types": "./dist/init.d.ts", "default": "./dist/init.multi.js" },
"./multi/wasm": "./dist/myapp_multi.wasm"
}
}The pinned entries reuse init.d.ts — they export the same surface, so there is
no reason to generate N copies of the same declarations.
The merge keeps every subpath you declared yourself — hand-written aliases such
as ./wasm or ./api-reference.json keep resolving. It does not touch
files; add the generated files to that list once.
What dist/ holds, and what ships
dist/ is not a publish manifest. It holds three classes of file, and files
in package.json is the authoritative statement of which ones ship:
| Class | Files | Ships |
|---|---|---|
| Shipped surface | types.d.ts, init.js / init.d.ts, init.<variant>.js, index.js / index.d.ts, variant.d.ts, <outputName>.js, <outputName>.wasm | Yes — this is what exports points at |
| Durable records | <outputName>.build-manifest.json, <outputName>.provenance.json, <outputName>.js.symbols | Your call — libcascade ships all three so consumers can audit the build |
| In-repo build products | <outputName>.d.ts (one per variant), exports.json | No |
The per-variant <outputName>.d.ts files look like leftovers next to the
generated types.d.ts, and they are not. They are the container dts step's
artifact, with two real consumers:
assembleparses every one of them to build the sharedtypes.d.ts. Delete them andassemblecannot run.- The bindgen's own type tests import them directly. In this repository, 30
files under
tests/doimport type { … } from '../dist/opencascade_single', because the per-variant d.ts is the artifact under test. They cannot be repointed attypes.d.ts, which is a different thing — a cross-variant union.
So they are build products consumed in-repo that do not publish. Leave
them in dist/ and leave them out of files; exports.json is likewise a
review aid for --write-exports and never ships.
Consumer prerequisites for a threads variant
Cross-origin isolation
Browsers gate SharedArrayBuffer behind cross-origin isolation. Every page
that loads the threaded binary must be served with:
Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corpWithout them the capability probe fails, the selector falls back to a variant
without requires, and an explicit createInstance({ variant: 'multi' })
throws with that remediation in the message. Node exposes SharedArrayBuffer
unconditionally — no headers needed.
Vite needs ES-module workers
Emscripten spawns its pthread workers as ES modules with top-level await.
Vite's default worker format is iife, which cannot emit them:
export default {
worker: { format: 'es' },
};Bundlers and the glue
The generated init entries reference each glue with new URL(…, import.meta.url), so bundlers emit it as a first-class asset, but load it
through an import that is deliberately opaque to them
(import(/* webpackIgnore: true */ /* @vite-ignore */ href)). Cutting that
graph edge is what keeps the .wasm out: a statically analysable import makes
a bundler follow the glue, and a pthread build contains
new Worker(new URL(<glue>, import.meta.url)), so the glue gets re-bundled as
a worker entry and every variant's binary comes with it. No configuration is
needed either way. The glue resolves its .wasm sibling relative to its own
URL; pass locateFile only when your deployment moves the binary away from the
glue. See
Bundler and locateFile for
per-bundler recipes.
Related
- Config reference — the
variantsandassemblefields. - CLI reference —
assembleflags. - Custom multi-threaded build — the threads variant end to end.