Design: architecture
Status: describes the implemented system (0.1.0) — the original load-bearing core (dlopen + flat-buffer roundtrip + shim error path) was proven in the Phase 0 spike; see spike results. Companion docs: shim, memory, concurrency, io; the API-surface target is archived at ../archive/api-surface.md.
1. Layer diagram
user code (TypeScript)
│
▼
@shzd/bun-geos (pure TS)
├── loader.ts resolve platform package · BUN_GEOS_LIBRARY · version assert
├── symbols.ts dlopen symbol table for geos_c (the _r set) + shim
├── geos.ts Geos class: context handle, ops, disposal
├── geometry.ts Geometry wrapper (numeric ptr + owning context)
├── arena.ts arena contexts · bulk destroy
├── io/ geojson.ts · wkb.ts · arrow.ts · wkt.ts (adapters)
├── spatial/ prepared.ts · strtree.ts (id registry over GEOS STRtree)
└── parallel/ pool.ts · protocol.ts · worker.ts (worker-pool batch helper)
│ bun:ffi — numbers, Float64Array, one crossing per geometry/array
▼
shim.so|dylib|dll (~300 lines C: handlers, batch builders, strtree
│ collection, destroy_many, interrupt flag)
▼
libgeos_c → GEOS 3.14 (the C++ engine, untouched)
All three native artifacts (shim, libgeos, libgeos_c) ship together inside one
platform package — see packaging.
2. Components
Loader (loader.ts)
- Compute expected platform package name from
process.platform/process.arch/ libc detection (spike-verified:process.report.getReport().header.glibcVersionRuntimecleanly detects glibc in Bun 1.4.3; needed because Bun's installer ignores thelibcfield and Linux users get both-gnuand-muslpackages — spike results F2). - Resolve it relative to the main package (
import.meta.resolve/createRequire(import.meta.url).resolve). - Unless
BUN_GEOS_LIBRARYis set (D8):dlopenthe bundled shim, which is already linked against the bundledlibgeos_c— with--disable-new-dtagsso DT_RPATH (not RUNPATH) resolves the transitivelibgeos.sodependency (spike results F3). - Parse
GEOSversion()(e.g."3.14.1-CAPI-1.18.2") and assert the3.14prefix. Mismatch — including via the env override — is a hard error with remediation text. (Spike note:GEOSversionis the one C API function with no_rvariant.) - Install handlers through the shim (never via JSCallback — D4; empirically confirmed:
a JSCallback handler receives only the
"%s"format string, never the message). - If
dlopenthrows because the JIT is disabled (Bun 1.4+ makes everybun:ffientry point throwTypeErrorin that mode), fail with a message stating bun-geos requires the JIT — there is no JIT-less fallback (pure-WASM is permanently out of scope, D1).
Symbol table (symbols.ts)
One declarative map of every geos_c _r function we use, plus the shim exports.
Single source of truth for the FFI surface; the shim covers only what TS cannot
(shim draws the line explicitly).
Geos class
Owns a GEOSContextHandle_t (D3). All operations are methods or free functions taking a
Geos. Symbol.dispose → destroy tracked/arena objects + GEOS_finish_r.
Geometries remember their owning Geos; using a geometry with a different context is a
programming error (TypeError) — the Rust-lesson rule enforced at the API level.
Geometry wrapper
Holds: numeric GEOSGeometry*, owning context reference, cached hasZ/hasM and
envelope. Never a finalizer-dependent object (D10): explicit
[Symbol.dispose]()/dispose() is the primary path; the FinalizationRegistry is a
backstop only.
Arenas
using arena = geos.arena() — every geometry created inside is registered; on exit the
shim's destroy_many frees them in one FFI call. For hot loops this is the difference
between N crossings and one.
I/O adapters (io)
GeoJSON (JS-side conversion), WKB (native readers/writers + batch variants), GeoArrow
(near zero-copy), WKT. Flat Float64Array + offsets is the canonical currency (D9).
STRtree wrapper
JS-side Map<id, Geometry> registry; the tree stores integer ids as void* items
(borrowed-pointer rule from GEOS research §4). Query results
collected by the shim into a preallocated array → returned as Int32Array.
3. Design principles
- Batch-first. Every op ships a singular and a plural form; the plural form is not a JS loop over the singular (Shapely's lesson). Plural forms take/return typed arrays.
- Flat buffers canonical. Coordinates cross the FFI boundary as interleaved
Float64Arrays, never as JS object graphs or per-coordinate calls. - No callbacks, no BigInts, no per-item crossings on hot paths. The shim absorbs everything loop-shaped.
- Explicit memory, visible heap.
using/dispose/arenas are the contract; the registry is insurance; per-context byte accounting is reported. - Strict 3.14. No capability flags anywhere (D5).
- Errors carry context. GEOS messages + op name + JS-side structural detail
(ring/vertex indices) in every
GeosError(D13).
4. Worked data flow — unionAll(parcelGeometries)
Geometry[] ──(extract cached ptrs)──► GEOSGeometry*[]
│ shim: union_all(ctx, ptrArray, n) → GEOSGeometry*
│ └─ internally: strategy dispatch —
│ coverage-shaped input? → GEOSCoverageUnion_r
│ disjoint subsets? → GEOSDisjointSubsetUnion_r
│ else → GEOSUnaryUnion_r
▼
GEOSGeometry* result ──► new Geometry wrapper (registered to arena if open)
Two FFI crossings total, regardless of input size. The singular a.union(b) is the same
path with n = 2 and no dispatch (direct GEOSUnion_r).
5. Target repository layout (implementation phase)
bun-geos/
├── src/ # the TS listed above (index.ts re-exports)
├── native/
│ └── shim.c # the entire C surface, reviewed as prose
├── packages/ # generated per release (see packaging doc)
│ └── bun-geos-<platform>/…
├── test/
│ ├── unit/ · differential/ · properties/ · bench/
└── docs/ # this documentation
(AGENTS.md currently describes the pre-implementation single-file scaffold; the layout
above is what Phase 1 creates. Toolchain rules in AGENTS.md — Bun-only, bun check,
.ts import extensions, verbatimModuleSyntax — all continue to apply.)