Skip to content

[prototype] TypeScript bindings for Node, Deno, Bun, ArkTS, and the browser - #553

Draft
sargunv-bot wants to merge 96 commits into
maplibre:mainfrom
sargunv-bot:t3code/typescript-multi-runtime-bindings
Draft

[prototype] TypeScript bindings for Node, Deno, Bun, ArkTS, and the browser#553
sargunv-bot wants to merge 96 commits into
maplibre:mainfrom
sargunv-bot:t3code/typescript-multi-runtime-bindings

Conversation

@sargunv-bot

Copy link
Copy Markdown
Collaborator

Summary

TypeScript bindings for the C API, shared across five runtimes — Node, Deno, Bun, ArkTS, and the browser — over one normalized C dispatch ABI, with per-target payload packages published from CI (#50).

Test plan

mise run //bindings/typescript:test and the per-runtime tasks: 128/128 on each of the three Node-API runtimes, 114/114 in Chromium, 102/102 over WebAssembly under Node, plus the ArkTS suite on an OpenHarmony device and an ASan test for the notifier lifetime.

AI assistance

  • Tools: Claude Code
  • Context: Implemented against the plan in Add Node/TypeScript bindings #50; design and review decisions are mine, drafting and test-writing were AI-assisted.

sargunv-bot and others added 30 commits August 3, 2026 22:47
Adds the header-driven generator, the shared host-support shim, and the
Node-API addon that together give the TypeScript binding one call ABI both
its transports can use.

JavaScript cannot pass or receive a C struct by value and cannot spell a
wasm32 struct lowering, so every entrypoint is normalized to an entrypoint id
plus an array of eight-byte slots. The dispatch, the layout tables for both
ABI classes, and the schema fingerprint are generated from the public headers
and checked in; generated static assertions compile for each target, so a
layout table that disagrees with the compiler fails the build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gives the TypeScript binding memory the C API can address, one call path, and
the handshake that decides whether an installed runtime payload describes the
same ABI this package was generated from.

Slabs are ordinary ArrayBuffers whose address the addon reports, so one
allocator and one struct encoder serve both transports. Addresses cross as
bigint: Android tags heap pointers in the top byte, so a pointer converted
through a JavaScript number loses bits the allocator requires. A failing call
copies the C API's thread-local diagnostic before returning, because reading
it afterwards would read whatever the next call left behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Gives the TypeScript binding its first public surface: the loaded library, a
thread-affine runtime, pump and poll, and the copied event values a poll
returns.

Handles carry the specification's live/releasing/closed state, so a released
wrapper reports its own closed-handle error before crossing into C, a failed
native destroy leaves the handle usable for a retry, and a wrapper collected
while still open is reported as a leak rather than destroyed from whatever
context the collector chose.

A wake source moves between host contexts through a token the native library
issues rather than through its handle id, because an id is copyable data and
every receiver that copied one would become an owner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes what an external review found in the first slice.

A struct-returning entrypoint stores through slot 0, so the generated metadata
now names the record the caller must supply storage for, the TypeScript side
refuses a call whose storage is absent or too small, and the shim rejects an
address that is null or misaligned rather than storing through it.

The staged payload gains a payload-relative rpath, so a package that moves
loads the native library it ships rather than the one in the build tree. The
handshake covers the support contract as well as the public headers, because a
transport that changed `call` would otherwise pass a fingerprint built only
from the C API. Slabs that empty go back to the transport. Transfer tokens stop
at the end of their sequence rather than wrapping onto a live one. An OpenGL
payload records opengl rather than the context provider that reaches it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the phase-one public surface: a map created from its runtime, style
loading by URL and by document, the loaded document read back through the C
API's size-probe copy-out, and the camera family.

Camera options carry the C API's field mask, so an omitted field and a field
set to zero stay different values in both directions. A map retains its
runtime, so closing a runtime with live maps fails rather than leaving them
naming a released object.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Builds one TypeScript source into ESM and CommonJS with declarations, and
tests that a consumer reaching the package either way drives the same native
library through the same names.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The adapter's log and queued-provider listeners took only the record, and a
retirement arrives as a null record, so a host whose listener is one C function
for every registration could not tell which registration a delivery or a
retirement belonged to. Dart escaped it by minting a native callback per
closure; a host with no closures to mint could not.

Each listener now takes the registration's own listener_data, which the
registration carries beside its listener. Retirement stays the null record: it
is unambiguous once the context is there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MapLibre logs from its worker, network, and rendering threads and asks the
callback whether it consumed the record. JavaScript can neither run there nor
answer in time, so the C callback adapter answers with the registration's fixed
policy, copies the record, and hands it to a listener that returns void.

The support layer queues that record and wakes this execution context through a
non-blocking thread-safe function; the drain decodes it, runs the host callback
inside a boundary that contains its failures, and destroys the record exactly
once. A registration's native state is released when the adapter reports it
retired, not when the host stopped using it, because native code can still be
inside a delivery until then.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A public wrapper's handle state and the loaded library's transport were
reachable through members marked internal, which is documentation rather than
access control. Public code could build a second wrapper for one native handle
and destroy it twice. The association now lives in module-private maps, and
every wrapper is constructed through its own module.

A wake source in transit is an owned object rather than a loose ArrayBuffer.
Between issuing the token and adopting it, nothing owns the handle, so a
transfer that is never posted or never adopted now releases it — explicitly
through discard, or through the collector when the host drops it.

Public numbers are checked for representability before they are encoded,
because a DataView write narrows modulo the target width: a width of 2^32 + 1
would have created a one-pixel map. Loading checks the C ABI version before it
hands back a library.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A map-sourced event carried the id of the map that produced it and no way to
reach that map, so a host running several maps could not tell which one an
event belonged to.

Each map now carries a copied identity, and the runtime resolves an event's
source to the live wrapper through weak references it holds itself. An event
for a map that has already closed carries its identity and no wrapper, because
there is nothing live to name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MapLibre asks what a URL should become on one of its own threads, with the
answer due before the call returns, so a JavaScript handler is too late. The
adapter evaluates a table of rules instead, and this exposes that table.

The C API borrows a rule table for as long as the registration lives, so the
table, its rules, and their strings are allocated for the registration and
released only once the call that replaced or cleared it has returned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MapLibre asks whether a provider will serve a request on its own thread, with
the answer due immediately, so the routes answer there in native code and a
claimed request is copied and handed to the handler here. Everything no route
matches passes straight through to the native loader.

A claimed request is answered whenever the handler can: inline, or long after
the callback returned, because the request handle outlives the record. The
completion path is consumed before the call reaches C, so a native failure
cannot become a second completion, and a request the host gives up on is
released rather than left outstanding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds the projection helper the C API exposes, plus the two process-global
conversions that depend on no map.

A projection owns the camera and viewport snapshot it was created from, so it
keeps answering after the map it came from closes; it is the one handle whose
validity does not follow its parent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MapLibre holds a style value as a tagged alternative and an object as an ordered
list of members that may repeat a name, and it reads some values only from one
alternative — a cluster id that arrives as a double reads as absent. A plain
JavaScript object expresses none of that, so a value crosses as the model
MapLibre itself holds, with the full signed and unsigned 64-bit domains.

Style documents keep crossing as text, because native holds a document as the
bytes it parsed. On top of the value model this adds adding, finding, and
removing style sources and layers.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The schema fingerprint covers the support contract, so a change to mln_abi.h
changes it — but the generate task did not list that header among its inputs,
and a stale fingerprint reached the tree. The handshake would have refused the
payload it described.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The suite is a tree of named cases with plain bodies rather than framework
calls, and each runtime's runner registers the same tree. A case that passes
under one runtime and fails under another is then a difference between the
runtimes rather than between two suites.

All three load the same compiled Node-API addon. Deno needs the FFI permission
to load a native addon at all, and a local node_modules directory to resolve
the runtime payload package.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A payload is named for the target and the public render backend it carries,
because MapLibre Native compiles exactly one renderer per build. Discovery
tries the published names and falls back to a checkout's locally staged
payload, so an installed one wins over a development one.

Each runtime's test runner is written against that runtime's own globals and
module specifiers, so the runtime that runs it checks it rather than the shared
project pulling in every runtime's type packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The same public layer now runs over a WebAssembly module and over the Node-API
addon, and the same conformance cases run against both. What differs is only
what a pointer is: every address is an offset in the module's one linear
memory, so a slab is a region of that memory rather than a buffer of its own,
and library-owned memory is reachable through the same views instead of through
a copy call.

The module is a portable JavaScript-host build. It names no DOM global, so Node
instantiates the artifact a browser would. Its HTTP transport is Emscripten's
fetch, which needs a host adapter outside a browser, so the conformance cases
provoke logging through a malformed style rather than through the network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The payload needs the Emscripten SDK, which is a multi-gigabyte tool an
environment may leave out, so the default test task runs Node, Bun, and Deno
and the browser target runs the same conformance cases over WebAssembly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A session drives one map's rendering into a target the host owns, and the
binding takes no ownership of it: a native pointer is an address the host
already has, and the host keeps it valid for the window the C API documents.

A session is affine to the context that attached it rather than to the map's,
so a host may render from a context that never touches the map. The C API keeps
the map alive by rejecting a destroy while a session is attached, so the
session holds no parent reference of its own.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An asynchronous record reaches the callback after the code that logged it has
moved on, which is why MapLibre keeps errors synchronous by default. The mask
combines and tests, and an unknown bit passes through for the C API to reject
rather than being rejected here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Offline work runs against a database, so every operation is a command: the call
reports acceptance and an id, the completion arrives as a runtime event naming
that id, and the result is taken afterwards. Taking transfers ownership once,
and a take that fails leaves the operation there to retry.

A region's metadata belongs to the list handle, which the take releases, so the
bytes are copied before it does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pixels are premultiplied RGBA and are copied at the boundary, so a caller
may reuse or mutate its buffer afterwards. A buffer too small for the extent it
claims is rejected here, because the alternative is reading past it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The default vitest config excludes the WebAssembly suite, and an exclude wins
over a filename on the command line, so the task that exists to run it was
running nothing. It has its own config now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects an external review found, all of which would have surfaced as
something other than what they were.

Linear memory came from `HEAPU8`, which Emscripten refreshes only in the agent
that grew it, so a MapLibre worker growing memory left this agent reading a
buffer that ended before the record it was handed. It comes from the memory
object now, which always names the live buffer.

A foreign read used `slice`, which clamps rather than rejecting, so a stale
pointer became a short copy that failed later with a message naming nothing.
Reads are bounds-checked, and a C string must find its terminator inside the
module's memory.

The notification sink was stored and never read: nothing on the WebAssembly
side could wake this agent, so a record produced by a worker waited for the
host to ask. A pump now delivers what arrived, which is where a host returns
from waiting anyway, and the transport says plainly that it schedules nothing.

The payload is also described for what it is: the whole binding API works in
Node, Bun, and Deno, but the module's default resource loading is Emscripten
Fetch, which is XHR, so a non-browser host serves resources through a provider.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Feature state lives on the renderer rather than on the map, so it belongs to a
session: a resize that retires the renderer starts it empty again. The selector
carries the C API's field mask, so a vector source's layer and a single state
key are each present or absent rather than empty.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each callback family owns its records differently — an adapter destroy for a
log record or a claimed request, plain free for a copied tile — and the shared
layer had to know which. Naming the kind is enough now, and the third kind the
custom geometry source will need is already there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MapLibre asks a custom geometry source for a tile on one of its own threads and
expects nothing back, so the request is copied and the handler runs on the
host's own context. The host answers whenever the data is ready.

Answering needs GeoJSON as MapLibre holds it rather than as text: a geometry is
a tagged variant, a feature's properties are ordered members that may repeat a
name, and an identifier keeps the alternative it arrived as. The whole
descriptor graph is written into call-scoped storage, because every interior
pointer is borrowed only for the call.

The retirement sentinel arrives as a tile at a zoom no real tile uses, which is
what releases the host state behind a source after its in-flight fetches drain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The data crosses as the descriptor graph MapLibre holds rather than as text, so
an integer keeps the alternative it arrived as and an object keeps its member
order. Replacing the data keeps the options the source was added with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sargunv-bot and others added 26 commits August 4, 2026 18:27
MapLibre stops wanting a request when the map is pointed elsewhere and does
not tell the host, which is why a provider that takes its time asks before
doing the work. An answer that arrives after that is refused, and the
refusal carries the library's own status and diagnostic rather than
something this binding decided.

76 of 99 proven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A future library can shrink or replace a payload, and no call this build can
make produces one, so the decoder is handed a short payload directly. It
keeps the bytes it was actually sent rather than reading the fields this
build expects past their end, and the same payload at full size still
decodes, so the guard is a size check and not a decoder that never runs.

77 of 99 proven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing a caller can do makes a native destroy fail, so the conformance
suite arranges it. `internal/faults.ts` queues a status per entrypoint and
`checked` reports it without running the call; no shipped path reads it, and
a normal call sees one branch that is never taken.

A map whose destroy refused is still live and still usable, because nothing
was released — treating it as gone would leak it — and a later release
closes it.

The WebAssembly thread pool goes to 192. A browser cannot grow it, and the
suite had outgrown 64: the symptom is the whole run hanging with no per-test
timeout able to fire, because the blocked thread is the one that would run
them. This was misread twice as the change under test being at fault.

79 of 99 proven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The session still holds the frame, so the wrapper must too: one that marked
itself released would leave the session holding a frame nobody can give
back. A later release succeeds.

This case was written once before and removed for hanging the browser. The
hang was the thread pool, not the frame.

80 of 99 proven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Taking an offline result acquires a native list and copies out of it. A copy
that fails must still give the list back; no call a caller can make fails
there, so it is arranged. The runtime then closes cleanly, which a leaked
list would prevent.

81 of 99 proven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A handle's native id lives in a private field of its own class. No code
outside that class can reach one to pass it to another kind's operation, so
forging the mismatch means defeating the language rather than the binding.

Every specification case this binding can prove is now proven. The two left
are the HTTP header transforms, which need the adapter's callback family
wrapped: 81 proven, 16 that cannot happen here, 2 waiting on that feature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last two specification cases needed a feature, not a test. The adapter
takes a rule table rather than a callback, so this mirrors the resource
rewrite rules beside it: the first rule whose URL matches supplies its whole
header list, and names and values are copied at registration because native
code reads them for as long as the table is installed.

The library refuses the feature on WebAssembly, where a browser's fetch
follows redirects itself, and on OpenHarmony, whose HTTP client cannot stop
transformed headers crossing origins. That is a host capability rather than
a transport property — OpenHarmony runs the same transport Node does — so
the cases name `httpHeaderTransforms` and the hosts that have it declare it.

83 of 99 proven, and nothing is left unwritten: the other 16 describe
behaviour that cannot occur in this binding, each with the reason.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… C has it

An external review found both.

The registry chose a destructor with a two-way branch: log records one way,
everything else through the resource-provider request destructor. A custom
geometry record is allocated with `malloc` and freed with `free`, so it was
reaching the wrong destructor — type confusion across the boundary. The shim
already dispatches on the kind and the transport already exposes it; the
registry now uses that instead of deciding for itself.

`ResourceRequest.complete()` marked itself settled before encoding the
response. A response this binding refuses never reaches C, so the native
request was still outstanding while the wrapper reported it answered: nobody
could complete it and nobody could give it up. It is settled once the
completion reaches C.

The regression case fails without the second fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BND-044 was called inapplicable on the grounds that this binding runs no
native release from a finalizer. It does not release there, but it does
report: `internal/handle.ts` installs a FinalizationRegistry that reports a
leaked handle, which is the behaviour the case describes. It is unwritten,
not inapplicable, and saying otherwise made the coverage number look
better than it was.

BND-192 was filed under "no second thread". It applies to a binding that
ships an owner-thread execution adapter, and the reason this one does not
run it is that it has no such adapter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two external reviewers, independently, found the coverage number inflated:
the check verifies an identifier is accounted for, not that the tagged case
proves the requirement. Thirteen tags claimed more than their case showed.

Those tags are gone. Each now says what is actually missing — a released id
replayed rather than an unknown one, one test per input-struct family rather
than examples, every option type rather than camera, teardown across reload
and reuse rather than registration, headers observed on a request rather
than a request arriving. A partial proof recorded as a whole one is worse
than no proof, because nobody goes back to it.

Two of the thirteen are now genuinely proven: a released offline operation
id is refused after a new operation exists, and a queried feature's strings
outlive the native result they were copied from, which is destroyed before
the query returns.

BND-088 is inapplicable rather than unwritten: it wants a park released from
another thread.

73 of 99 proven, honestly. 16 cannot happen here. 10 are unwritten and say
what they need.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old case admitted MapLibre might never ask for a tile, so it never
delivered a custom geometry record — which is how the wrong-destructor
defect survived on that exact path. Rendering is what makes MapLibre ask,
and the browser can render now.

The case asks, answers, then removes the layer and source and keeps
drawing: nothing arrives for a retired registration. The same source id is
used again and the new registration receives while the retired one still
does not. Closing the map delivers nothing afterwards.

74 of 99 proven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old case compared camera options and was tagged for all of them. This
takes each value type this binding exposes and changes one field at a time,
so a comparison that ignored a field is caught rather than one example
standing in for the rest. Absence is expressed by omitting the key, which is
the distinction the case is about and which this package's strict optional
types already enforce.

75 of 99 proven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The old tag stood on a few examples. This takes the families from the
generated tables rather than a list here, so a family added to the C API
joins the case by existing rather than by somebody remembering. Each is
initialized from its library defaults, reports the size this build expects,
and starts with no field claimed.

A family with no size field is collected and reported rather than skipped,
so the assertion covers the whole set instead of whichever members happened
to have one.

76 of 99 proven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both describe data crossing into user code through a transform callback.
This binding installs the adapter's native rule tables instead — for URL
rewrites and for HTTP headers — so no transform request data reaches user
code to be copied. The requirement is vacuous here rather than unmet, and
each entry says which choice makes it so, because a binding that grew a
callback transform would have to claim them.

76 proven, 18 that cannot happen here, 5 unwritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every unwritten entry now says what stands in the way rather than what is
missing in general: the offline region create, delete, and status
entrypoints exist in C and are not wrapped; the surface attach path needs a
host with a native window, which no runner has; proving a transformed header
reaches a request needs an HTTP endpoint the suite controls, because nothing
exposes the headers a request carries.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The binding wrapped only the region list, so there was no region state to
observe. `startOfflineRegionStatus` and `takeOfflineRegionStatus` complete
the smallest slice of that: an operation id, a completion through the
runtime event model, and a status copied out as public values with counts
keeping their full domain.

Whether a missing region is an error or an empty status is the library's
decision; what this binding owes is that the answer reaches the caller
either way rather than being invented or lost, and that a spent operation
cannot be taken twice.

77 of 99 proven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An attempt: a `node:http` endpoint served a style, a transform rule matched
its URL, and a map was pointed at it with `setStyleUrl`. After ten seconds
of pumping the endpoint had never been contacted, so the header was never
the obstacle — whatever issues an HTTP request for a style is not running
under the conformance runtime.

The endpoint machinery is not kept, because a capability nothing uses is
clutter. The note now says what was tried and what it found, so the next
attempt starts from the real blocker rather than repeating this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three agents worked one case each, with file ownership kept apart so they
could not overwrite one another.

The leak channel. `internal/handle.ts` installs a FinalizationRegistry, and
a test that waits for a collection is a test of the collector. The registry
is now constructed with the same function a case can invoke, so the case
proves what that path does when it runs: a dropped handle is reported and
left alone, the native object still answers, and an explicit release still
closes it. A release that native refuses is reported through the same
channel and a retry closes it.

The surface attach path. The note claiming it was unreachable was wrong.
`mln_opengl_surface_attach` in the WebGL build validates the whole
descriptor before reporting unsupported, so a browser can drive the surface
descriptor across the boundary and read native's own answers. Each family's
C entry point is made to refuse in turn and the error names that exact
function, which is what says a path reached its own family. Deleting the
surface handle write makes the case fail, and nothing else in the suite
caught that.

Transformed headers. The library was making the request all along. `pump`
blocks inside native code, so a synchronous pump loop starves an in-process
origin: the host never gets a turn to accept the connection. Yielding
between pumps lets the request arrive, and the case reads the headers that
actually landed as the table is installed, replaced, and cleared.

81 of 99 proven. The other 18 cannot happen in this binding, each with the
reason. Nothing is left unwritten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A registration is made before the add call, because native code needs the
listener addresses to install. A call that refuses left it in the registry
holding the handlers, and through them the map, for the runtime's life.

Retirement alone is not enough here: it waits for native's own sentinel,
which never comes for an identity native never received. The registry now
takes a discard for that case, and the case fails without it.

Found by an external review.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The browser suite reached a working runtime through `internal/wasm-transport`
and `Maplibre.fromTransport`, neither exported, against a payload with no
package. That proved this repository could assemble a browser runtime, not
that anyone else could.

`@maplibre/native-ffi/browser` is its own entry point, so a server never
pulls WebAssembly loading in. It carries `createWebGlContext` because a page
otherwise cannot build a render context at all: Emscripten addresses a
context by the number it assigned, so one from `canvas.getContext()` is
unusable and the only route was the module's undocumented GL registry, which
is what the test runner had been doing. The payload is a package like the
Node-API ones, CI packs it, and cross-origin isolation is stated where a
consumer meets it, including a pre-flight check that names both headers.

Proved by packing both packages, installing the tarballs, and running a live
map three ways: package discovery, `require`, and a served `moduleUrl`.

The conformance harness is no longer exported from the package. A test locks
both halves, and asserts `Maplibre` is still there so it cannot pass by
exporting nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A tile server runs one runtime per worker, which is the shape the C API asks
for: each owner thread may hold one live runtime. Node workers are separate
JavaScript realms but share the loaded library, so every realm minted
registration identities from 1 against one process-wide queue and one
notifier slot.

Both halves were reproduced before anything changed. Worker 2 registering
displaced worker 1's notifier, so worker 1 stopped being woken. And a record
carrying identity 1 was resolved by whichever realm drained first against its
own map, deleting and retiring a live registration belonging to the other
realm, freeing state native still held. Which realm lost depended on load
order.

The shim now mints identities and keys the queue and notifier by an owner.
The identity is pointer-width with the owner in the high half, because
`listener_data` crosses as a `void*` and a 64-bit identity would lose its top
half on wasm32. Routing is arithmetic rather than a table, a stale identity is
simply not found, and exhaustion is reported rather than wrapped onto a live
value.

`Maplibre.close()` exists now, which it did not before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closing the facade while a runtime was live succeeded, and the runtime then
reported a closed handle from `pump`, because pump drains the registry the
close had destroyed. That is the failure arriving at the wrong call.

The specification is explicit: releasing a parent while children are live
MUST fail without consuming or destroying the parent. A runtime drains this
context's registry, so the facade is its parent, and this binding already
answers `childrenLive` for a runtime closed while a map is live. The check
runs before anything is released, so the refusal consumes nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The runner compared values by serializing them, and wrote a bigint as the
digits alone, so `7n` and the string `"7"` compared equal. Every address in
this binding crosses as a bigint, so a case comparing one could pass against
a value of the wrong type. The ArkTS runner already marked them; this one now
does too.

Found by an external review, which reported both runners; only this one was
wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A host stopped notifications and dropped the boxed threadsafe function
straight after, while a MapLibre thread could already be between reading
that notifier and calling it. The producer then called into freed memory.
The window is small, so it read as a flake rather than as a use after
free, which is what an ASan build proves it is.

The support layer now counts the notifier calls each owner has begun and
finished, and whoever replaces a notifier or destroys an owner waits for
the calls that began before it. Two monotonic counts rather than one
in-flight count: a steady stream of records would hold an in-flight count
above zero forever and never let the waiter go. The wait happens under the
queue lock the producer drops before it calls, so a wake cannot deadlock
against the thread that produced the record.

`destroy_callback_owner` now drops the box too. A host that destroys an
owner without stopping notifications first is still done with it, and
leaving the entry in the list kept it alive for a process that never asks
about that owner again.

Proved by `//bindings/typescript:test:notifier-lifetime`, which builds the
shim under ASan with a hook that widens the window so the race lands every
time. It passes with the wait and fails without it.

Closes maplibre#540.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The render-session cases only ever ran over WebAssembly, because a page
has a WebGL context and a server process has nothing. That left the whole
render domain unproven on the three runtimes most consumers will use, on
the reasoning that a native host has no context — which is not a fact
about the host, only about what the suite was willing to build.

The suite now asks a driver for one. `openHeadlessOpenGl()` opens a
surfaceless EGL display, makes a context and a pbuffer, and hands back the
context, a texture factory, and the surface. Each runtime reaches libEGL
the way it can: `bun:ffi` under Bun, `Deno.dlopen` under Deno, and koffi
under Node, which has no FFI of its own. koffi is a devDependency of the
api package, so nothing a consumer installs grows by it. It is not used
under Bun because declaring a function at module scope and then triggering
a collection aborts Bun 1.3.9 outright, reproduced in six lines with no
MapLibre code involved.

The fixture is asked for only when the loaded build carries the OpenGL
backend, and a host with no driver reports no context, so the render cases
stay registered by capability rather than by runtime.

`hostSurface()` joins the case context because a pbuffer is a real thing
to present through. BND-162 takes the descriptor all the way where one
exists — a session attaches and answers an update — and keeps the refusal
path for the hosts that have none. That is a stronger statement than any
refusal, and the only one that says the surface arm of the context union
is written correctly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pool went to 192 to stop the browser suite wedging partway through,
on the reading that a runtime leaks a thread. It does not. A closed
runtime's threads exit, but the worker each one ran on returns to the pool
through a message, and a message is only delivered when the closing thread
yields. Awaiting a promise is not a yield: a promise settles on the
microtask queue, which runs to exhaustion before the browser looks at its
messages. So the suite took workers for 114 cases straight and gave none
back.

Measured by exporting `PThread` and counting: forty create-and-close rounds
with no task boundary hold forty workers, and all forty come back within
100ms of one timer. The suite ends holding 67 for the same reason.

The browser runner now waits on a timer between cases, which is the task
boundary the reclamation needs. With that, the pool walks down to sixteen
before the suite stops passing — one runtime with a map takes twelve to
sixteen workers — so 64 ships instead of 192, roughly four times what a
page with a map needs. That matters beyond the suite: 192 workers start
before the module fetches its own WebAssembly, and on a host short of
memory the fetch is what loses.

`loadBrowser()` states the constraint where a consumer meets it. Ordinary
event-driven code yields constantly and never sees it; a loop that opens
and closes runtimes without reaching a task boundary would empty any pool
that fits in a browser.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sargunv sargunv changed the title TypeScript bindings for Node, Deno, Bun, ArkTS, and the browser [prototype] TypeScript bindings for Node, Deno, Bun, ArkTS, and the browser Aug 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants