MapLibre GL JS Runtime (@honua/sdk-js/runtime)

Status: implemented in src/runtime/ (ticket honua-sdk-js-21). Public entrypoint: @honua/sdk-js/runtime (subpath export only; the root barrel does not re-export the runtime so hosts that do not need a map can avoid pulling in the MapLibre-aware code).

The runtime binds a server-produced MapPackage (from honua-io/honua-server#731) to a caller-provided maplibre-gl.Map. It composes the style, projects sourceBindings[] through the shared @honua/sdk-js/contract adapters, applies StyleRef overrides and ThemeSpec tokens, wires popups / legend / initial view, and exposes a stable operational API for #22 (mixed-protocol composition) and #29 (operator components) to build on.

The runtime does not instantiate maplibre-gl.Map, issue edit writes, or duplicate query logic — maplibre-gl stays a peer dependency, edits flow through the existing adapters, and queries go through the contract's Source handles.

Standalone data-to-map bridge

For the shortest path — any contract Source to a styled, interactive MapLibre layer set without a MapPackage, plan review, or Honua server — use mountSource(map, source, options) from @honua/sdk-js/map (@experimental). It selects bounded GeoJSON vs the dynamic query-tile path from capabilities plus a result-size heuristic, applies geometry-appropriate default styling, wires optional popups/hover, and returns one disposable handle with diff-updating setFilter()/refresh(). See the cookbook: docs/data-to-map-bridge.md. The plan-bound workflows below remain the right tool when you need fingerprinted plan review, provenance, and freshness policy.

Accepted plan to native GeoJSON

@honua/sdk-js/map includes the first production slice of the automatic source-to-map workflow. It executes an already-accepted query plan against a canonical Source, projects the result to a native MapLibre GeoJSON source, generates geometry-aware default layers, and owns refresh/disposal without importing maplibre-gl:

import { mountSourceToMapLibre } from "@honua/sdk-js/map";
import { explainQuery } from "@honua/sdk-js/query-planner";

const plan = explainQuery({ descriptor: parcels.descriptor, query: { pagination: { limit: 5_000 }, outSr: 4326 } });
const mounted = await mountSourceToMapLibre(map, parcels, plan);
console.table(mounted.diagnostics);
await mounted.refresh();
mounted.dispose();

The mount is peer-injected and therefore safe to import in browser, SSR, Node, and worker builds. executeQueryPlan verifies the accepted fingerprint and source context before querying. The adapter then:

The focused API remains available when an application has already chosen the materialized feature lane. The automatic workflow below composes it with the native tile and raster lanes without changing its plan-binding guarantees.

Explain and mount automatically

explainAutomaticSourceToMapLibre evaluates every currently representable lane before renderer mutation: bounded GeoJSON, vector tiles, vector/raster PMTiles, dynamic query tiles, native raster, WMS, and WMTS. It returns the selected and rejected candidates with stable reason codes, fidelity, native-versus-materialized path, optional-peer requirements, bounds, freshness, credential-free provenance, authorization scope, and cache implications.

import {
  explainAutomaticSourceToMapLibre,
  mountAutomaticSourceToMapLibre,
} from "@honua/sdk-js/map";
import { explainQuery } from "@honua/sdk-js/query-planner";

const queryPlan = explainQuery({
  descriptor: parcels.descriptor,
  query: { pagination: { limit: 5_000 }, returnGeometry: true, outSr: 4326 },
  sourceVersion: "parcels-2026-07-12",
  authorizationScope: ["parcels:read"],
});
const strategyPlan = explainAutomaticSourceToMapLibre(parcels, { queryPlan });

console.table(strategyPlan.candidates);
const mounted = await mountAutomaticSourceToMapLibre(map, parcels, strategyPlan, { queryPlan });
await mounted.ready;
await mounted.refresh();
mounted.dispose();

Native vector and PMTiles plans require an accepted sourceLayer; PMTiles also requires archive payload evidence (pmtilesType) from archive metadata. A dynamic query-tile plan accepts the pure source spec returned by buildMapLibreQueryTileSourceSpec. This avoids network discovery or protocol guessing inside the planner:

const strategyPlan = explainAutomaticSourceToMapLibre(parcels, {
  queryTileSource: buildMapLibreQueryTileSourceSpec(queryTileDescriptor),
  sourceLayer: "parcels",
});

Selection is fail-closed. Unsafe credential-bearing URLs, unsupported CRS, missing capabilities or metadata, unbounded feature queries, stale evidence, and ineligible explicit overrides produce typed diagnostics and no selected strategy. Freshness checks require an explicit observedAt, now, and maxAgeMs, keeping explanation deterministic. Mounting re-explains the source and policy to reject stale or tampered plans, checks cancellation before host mutation, rolls back partial native mounts, and provides one ready/refresh/cancel/dispose lifecycle. Native immutable/source-owned lanes use a no-op refresh; bounded GeoJSON delegates to the query-backed setData refresh. No API in this section imports maplibre-gl or pmtiles.

This automatic slice does not complete the broader #390 application runtime. Owned map construction, generalized styling, labels/popups/selection, edits, realtime subscription orchestration, and the published browser sample matrix remain explicit residual work.

Metadata-driven raster sources

@honua/sdk-js/map can select and mount an exact raster strategy directly from a discovered SourceDescriptor. The peer-injected workflow supports native XYZ raster templates (maplibre-raster), WMS GetMap, and RESTful WMTS tiles:

import { mountRasterSourceToMapLibre } from "@honua/sdk-js/map";

const mounted = mountRasterSourceToMapLibre(map, imagery.descriptor, {
  tileSize: 512,
  paint: { "raster-opacity": 0.85 },
});

console.log(mounted.strategy); // native-raster-tiles, wms-raster, or wmts-raster
console.table(mounted.diagnostics);
mounted.dispose();

Selection is deterministic and fail-closed. All three strategies require the descriptor's render and tiles capabilities. Native raster URLs must contain the {z}, {x}, and {y} placeholders; WMS requires locator.typeName; WMTS requires both locator.typeName and locator.tileMatrixSetId. Unsupported vector, PMTiles, query-tile, and query-only descriptors throw HonuaMapLibreRasterStrategyError before map mutation instead of falling back to GeoJSON or guessing protocol metadata.

projectRasterSourceToMapLibre is the pure projection half. It returns the selected strategy, native raster source, raster layer, and stable fidelity diagnostics without touching the renderer or network. The mount checks source and layer conflicts, rolls back renderers that mutate and then throw, and owns idempotent best-effort cleanup. Neither API imports maplibre-gl, so both are safe in Node, SSR, and worker module graphs.

Module layout

src/runtime/
├── index.ts           # barrel — public surface
├── map-package.ts     # HonuaMapPackage type (mirrors honua-server#731)
├── map-package-fetch.ts # hosted fetch + load-from-id helpers
├── map-package-validation.ts # typed validation diagnostics
├── map-package-watch.ts # disposable polling watcher
├── load-package.ts    # loadMapPackage(pkg, map, opts) → HonuaMapRuntime
├── runtime.ts         # HonuaMapRuntime class + event/telemetry types
├── source-bridge.ts   # SourceBinding[] → SourceDescriptor[] + native sources
├── query-tiles.ts     # dynamic query tile MapLibre helpers + request lifecycle
├── style-compose.ts   # applyStyleRefs + applyTheme
├── diff.ts            # MapPackageDiff primitives for updatePackage
├── popups.ts          # bindPopup + default unstyled DOM renderer
├── legend.ts          # buildLegend + swatch backfill
└── errors.ts          # HonuaMapPackageError (stages)

Public surface

import maplibregl from "maplibre-gl";
import { HonuaClient } from "@honua/sdk-js";
import { loadMapPackage } from "@honua/sdk-js/runtime";

const map = new maplibregl.Map({ container: "map" });
const runtime = await loadMapPackage(pkg, map, {
  client: new HonuaClient({ baseUrl: "https://honua.example.com" }),
  popupFactory: () => new maplibregl.Popup(),
});

runtime.setLayerVisibility("parcels-fill", false);
const legend = runtime.getLegend();
await runtime.updatePackage(nextPkg);
runtime.dispose();
Export Shape Notes
loadMapPackage(pkg, map, opts) Promise<HonuaMapRuntime> Inline-package load entry point. Throws HonuaMapPackageError for binding failures under sourceErrorPolicy: "fail-fast"; under the default "tolerant" policy a single per-source binding failure does not abort the load — see Tolerant binding below. Query-time adapter failures surface on the per-Source promises from runtime.dataset and through the shared HonuaClient interceptor chain; consumers can broadcast them through runtime.reportSourceError(sourceId, error) to convert query-time rejections into the canonical source-error event.
fetchMapPackage(idOrLocator, opts) Promise<FetchMapPackageResult> Fetches a hosted package through HonuaClient.pipelineFetch, validates it, resolves style refs when a resolver is supplied, and uses ETag / Last-Modified validators from the server when available.
loadMapPackageFromId(idOrLocator, map, opts) Promise<LoadMapPackageFromIdResult> Builder-style helper: fetches a hosted package, then delegates to loadMapPackage. The result includes the runtime plus fetch diagnostics/cache state.
watchMapPackage(idOrLocator, opts) MapPackageWatchHandle Opt-in polling watcher with dispose() and refresh(). It reuses fetchMapPackage, reports structural updates that require full style reload, and can apply updates to an existing HonuaMapRuntime.
validateMapPackage(pkg, opts) ValidateMapPackageResult Returns typed diagnostics for format/version, missing sources, unsupported protocols, stale/expired packages, and style-ref target mismatches.
HonuaMapRuntime class map, honuaMap, dataset, mapPackage, composedStyle, getLegend, setLayerVisibility, bindPopup, setViewState, updatePackage, on, reportSourceError, dispose.
runtime.hitTest(input, opts) Promise<HonuaHitTestResult> Renderer-neutral hit-test wrapper over MapLibre queryRenderedFeatures. Returns normalized pointer location, source-qualified feature identities, raw rendered features, geometry/properties when supplied by the renderer, optional detail payloads, and typed degraded states for unsupported or partial renderer data.
runtime.onPointer(handler, opts) { remove() } Subscribes to "click", "dblclick", or "mousemove" and invokes hitTest for each pointer event.
HonuaMapPackage type v1 package shape. format is gated against HONUA_MAP_PACKAGE_FORMAT_V1 ("honua_map_package.v1").
HONUA_MAP_PACKAGE_FORMAT_V1 const Canonical format tag.
HonuaMapPackageError / HonuaMapPackageErrorStage class / union Stage union: "load" | "update" | "style-compose" | "source-bind" | "view" | "popup" | "dispose".
HonuaRuntimeEvent / HonuaRuntimeEventListener types See Events below.
HonuaRuntimeTelemetry type before / after / error collector, matching the HonuaRequestInterceptor shape.
MaplibreMap interface Duck-typed subset of maplibre-gl.Map; keeps the SDK bundle-neutral.
SetViewStateInput type { bbox?, center?, zoom?, pitch?, bearing?, padding?, animate? }.
applyStyleRefs, applyTheme, composeStyle functions Pure helpers — safe to call outside a runtime for testing / SSR composition.
projectSourceBindings, toHonuaSourceSpec functions Exposed for #22 and adapter tickets that need the bridge without loading a package.
buildWmsRasterSourceSpec, buildWmtsRasterSourceSpec functions Pre-bake a MapLibre raster source spec from a WMS / WMTS SourceDescriptor. Used by callers that compose a map outside loadMapPackage. See the source-binding projection table for the URL templates emitted on each protocol.
buildMapLibreQueryTileSourceSpec, buildQueryTileJson, buildQueryTileUrlTemplate, buildQueryTileUrl functions Build TileJSON and MapLibre vector source specs from QueryTileSourceDescriptor. See dynamic-query-tiles.md.
QueryTileRequestController, queryTilesForViewport, diagnoseQueryTileSourceSupport class / functions Opt-in viewport tile lifecycle helper with abortable requests, bounded cache, diagnostics, and unsupported-protocol/fallback reporting.
hitTestMap, normalizePointerEvent, normalizeHitTestFeatures, createQueryTileDetailLoader functions Framework-neutral interaction helpers exported from @honua/sdk-js/interactions; useful for apps that do not load a full HonuaMapRuntime.
diffPackages, MapPackageDiff function / type Stable-id diff used by updatePackage.
buildLegend, LegendEntry function / type Shared with operator components.
bindPopup, defaultPopupRenderer, PopupFactory, PopupRenderer function / types The default DOM renderer is intentionally unstyled — rich popups belong in #29.

Loader options

interface LoadMapPackageOptions {
  client: HonuaClient;
  resolveStyleRef?: (styleId: string, presetId?: string) => Promise<HonuaStyleRefBody>;
  resolveTheme?: (themeId: string) => Promise<HonuaMapPackageThemeSpec>;
  resolveSource?: SourceResolver;          // forwarded to createDataset for tiles / MapLibre-native sources
  skipCompatibilityCheck?: boolean;        // tests / conformance fixtures
  telemetry?: HonuaRuntimeTelemetry;
  popupFactory?: PopupFactory;             // required only if runtime.bindPopup is called
  popupRenderer?: PopupRenderer;           // defaults to defaultPopupRenderer
  applyInitialView?: boolean;              // default true
  onEvent?: HonuaRuntimeEventListener;     // subscribed BEFORE initial emissions
  sourceErrorPolicy?: "tolerant" | "fail-fast"; // default "tolerant" — see Tolerant binding
  styleSpecValidationMode?: "strict" | "warning-only" | "renderer-deferred"; // default "strict"
}

resolveStyleRef and resolveTheme are only invoked when the package omits the inline body. Draft-1 of honua-server#731 attaches both inline; out-of-band retrieval plugs in through these hooks without reopening the loader.

onEvent is registered on the runtime before the first source-ready / package-loaded emissions, so callers that need to observe the initial lifecycle without racing against loadMapPackage's return must use this hook instead of calling runtime.on(...) after await. Subsequent events (package-updated, disposed, ...) also flow through the same listener.

Runtime source/layer mutation helpers lazily use @maplibre/maplibre-gl-style-spec without constructing a MapLibre map. "strict" reports style-spec errors as fatal HonuaRuntimeDiagnosticError entries before renderer mutation, "warning-only" preserves the diagnostics as warnings, and "renderer-deferred" skips SDK style-spec checks so the host renderer reports invalid values.

Source and Layer Mutation

HonuaMapRuntime owns the renderer-specific implementation used by HonuaController's app-level CRUD surface. The API follows Mapbox GL source and layer concepts:

runtime.addSource("dispatch", {
  type: "geojson",
  data: { type: "FeatureCollection", features: [] },
});

runtime.addLayer(
  {
    id: "dispatch-points",
    type: "circle",
    source: "dispatch",
    paint: { "circle-color": "#00a884", "circle-radius": 6 },
  },
  { beforeId: "parcels-fill" },
);

runtime.updateLayer("dispatch-points", {
  paint: { "circle-color": "#006f5f" },
  metadata: { editedBy: "operator" },
});
runtime.moveLayer("dispatch-points", { position: "top" });
runtime.refreshSource("dispatch");
runtime.removeLayer("dispatch-points");
runtime.removeSource("dispatch");

Supported source inputs include common native MapLibre source entries and Honua custom source entries for GeoServices, OGC Features, WMS, and WMTS. Query-tile sources can be created with buildMapLibreQueryTileSourceSpec(), while hosted MapPackage source bindings should continue to flow through loadMapPackage() or updatePackage() when server state is the source of truth.

The mutation helpers validate source specs, layer specs, style expressions, filters, layer ordering, and duplicate/missing IDs before mutating the map. Renderer failures are wrapped as HonuaRuntimeDiagnosticError diagnostics. For incremental paint/layout/filter patches the runtime uses MapLibre's property setters when available; structural updates fall back to setStyle(..., { diff: true }).

Hosted MapPackage Fetch

Builder-style usage starts from a package id instead of an inline MapPackage:

import { HonuaClient } from "@honua/sdk-js";
import { loadMapPackageFromId, watchMapPackage } from "@honua/sdk-js/runtime";

const client = new HonuaClient({ baseUrl: "https://honua.example.com" });
const { runtime, diagnostics } = await loadMapPackageFromId("map_123", map, {
  client,
  popupFactory: () => new maplibregl.Popup(),
  resolveStyleRef: (styleId, presetId) => fetchStyleBody(styleId, presetId),
});

for (const diagnostic of diagnostics) {
  console.warn(diagnostic.code, diagnostic.message);
}

const watcher = watchMapPackage("map_123", {
  client,
  runtime,
  intervalMs: 30_000,
  onEvent: (event) => {
    if (event.type === "reload-required") {
      console.info(event.reason);
    }
  },
});

// Later, when the host tears down the map:
watcher.dispose();

fetchMapPackage accepts either an id ("map_123"), a direct path or same-origin URL, a honua://map-packages/{id} locator, or an object with id, packageId, path, url, or href. Id locators default to /api/v1/map-packages/{id}; pass resolvePath when a deployment uses a different hosted package route.

For low-latency hosted package updates, watchMapPackage can use a configured or package-advertised realtime channel and fall back to polling when the channel is unavailable. See map-package-realtime-watch.md for the message schema, lifecycle events, reconnect behavior, auth notes, and fallback contract.

Fetch results include:

Validation errors throw HonuaMapPackageError { stage: "validate" } with { diagnostics } in detail. Pass allowInvalid: true when a host wants to display diagnostics without rejecting the fetch.

Tolerant binding (sourceErrorPolicy)

loadMapPackage defaults to sourceErrorPolicy: "tolerant": a single per-source binding failure (resolver throws, toHonuaSourceSpec throws, eager dataset.source(id) materialization throws) does not abort the load. The failed source is dropped from the composed style along with any layer whose source references it; the runtime emits one source-error event per failed source after the source-ready events and before package-loaded; HonuaRuntimeTelemetry.error receives a source-bind span. Remaining sources continue to render.

Pass sourceErrorPolicy: "fail-fast" to restore the historical single-source behaviour, where any binding failure rejects the load with HonuaMapPackageError({ stage: "source-bind" }).

Configuration-level binding errors (unknown protocol, missing locator.url, duplicate sourceId, deferred workspace_artifact) raised by projectSourceBindings always fail-fast under either policy — those are operator errors that must be fixed.

Mixed-source consumers that fan a query out across the dataset can broadcast a query-time rejection back through the runtime by calling runtime.reportSourceError(sourceId, error) — the helper emits the same source-error event and pipes the failure through the source-bind telemetry span, so observers see one consistent per-source error channel for both bind-time and query-time failures. The full guide lives in composition.md.

Hit Testing and Pointer Events

hitTest accepts a MapLibre-style event, { point, lngLat }, or a raw DOM pointer event. It returns screen point, optional longitude/latitude, and a feature stack normalized to Honua source identity:

const hit = await runtime.hitTest(event, {
  layers: ["incidents-symbol", "incidents-fill"],
  sourceIds: ["incidents"],
  featureIdProperty: "incident_id",
  tolerance: 4,
  maxResults: 5,
});

const first = hit.features[0];
if (first?.selectionTarget) {
  view.select([first.selectionTarget], { replace: true });
}

Every HonuaHitFeature includes layerId, sourceId, sourceLayer, featureId, selectionTarget, properties, geometry, rawFeature, and degraded[] when the renderer omits a field. The top-level result also has degraded[]; for example, non-MapLibre renderers without queryRenderedFeatures return an empty feature list plus { reason: "renderer-unsupported" }.

Optional detail loading is bounded by the caller's AbortSignal:

const controller = new AbortController();
const hit = await runtime.hitTest(event, {
  layers: ["incidents-symbol"],
  queryTileSources: { incidents: incidentsQueryTileDescriptor },
  loadDetails: true,
  signal: controller.signal,
});

When queryTileSources includes a descriptor for the hit source, the runtime composes with the existing query-tile detail path and runtime.dataset.source(sourceId). Without a descriptor it falls back to a single-feature source query when an id field is available. Failed, aborted, or unavailable detail reads are reported in feature.degraded instead of rejecting the whole hit-test result.

Apps that do not use HonuaMapRuntime can use the neutral helpers directly:

import { hitTestMap } from "@honua/sdk-js/interactions";

map.on("click", async (event) => {
  const hit = await hitTestMap(map, event, {
    layers: interactiveLayerIds,
    sourceIds: ["incidents"],
    featureIdProperty: "incident_id",
    maxResults: 1,
  });
  openInspector(hit.features[0]);
});

MapPackage version gate

Source binding projection

projectSourceBindings routes each SourceBinding to one of three destinations, using the alignment table in source-binding-alignment.md:

Server wire protocol (snake_case) Route SDK protocol / source type
geoservices_feature_service contract adapter geoservices-feature-service, custom source type honua-feature-service.
geoservices_map_service contract adapter geoservices-map-service, custom source type honua-map-service.
ogc_features contract adapter ogc-features, custom source type honua-ogc-features. Collection id is copied from locator.collectionId.
wfs contract adapter wfs (built-in), custom source type honua-wfs. locator.typeName (and optional locator.featureNamespace) are forwarded; first-party WFS 2.0 adapter ships in @honua/sdk-js/contract.
wms contract adapter wms, custom source type honua-wms. locator.typeName projects as layers, locator.styleId as styles. Use buildWmsRasterSourceSpec(descriptor) to produce a MapLibre-ready { type: "raster", tiles, tileSize } spec with a pre-baked KVP GetMap template that uses MapLibre's {bbox-epsg3857} / {width} / {height} placeholders.
wmts contract adapter wmts, custom source type honua-wmts. locator.typeName / locator.styleId / locator.tileMatrixSetId project as layer / style / tileMatrixSet. Use buildWmtsRasterSourceSpec(descriptor) to produce a MapLibre-ready { type: "raster", tiles, tileSize, scheme: "xyz" } spec using the RESTful {layer}/{style}/{tms}/{z}/{y}/{x}.{ext} route.
odata contract adapter odata. The entity-set request path comes from locator.entitySet when set; otherwise it is derived from locator.layerId as Layers(<layerId>)/Features to match Honua Server's layer-scoped OData routes. Optional path prefix from locator.url.
vector_tile / ogc_tiles MapLibre-native Projected to a { type: "vector", tiles: [url], attribution? } source entry — no contract adapter.
raster_tile / ogc_maps MapLibre-native Projected to a { type: "raster", tiles: [url], attribution? } source entry.
workspace_artifact deferred Throws HonuaMapPackageError { stage: "source-bind" } — no artifact resolver is wired yet.

geoservices-image-service, geoservices-geometry-service, and geoservices-gp-service are contract-layer adapters (constructed directly via geoServicesImageSource, geoServicesGeometryServiceSource, geoServicesGPServiceSource) and are not currently translated by source-bridge.ts. ImageServer / Geometry / GP bindings on a MapPackage are rejected at stage: "source-bind" because those services are typically utility / non-map surfaces; routing them through the runtime is a follow-on for downstream tickets that need ImageServer rasters or geoprocessing tasks composed alongside a map.

Additional contract:

Style composition

composeStyle runs two passes over pkg.mapSpec:

  1. applyStyleRefs merges each styleRefs[*].body onto its corresponding layer by id. The body is a Record<string, HonuaStyleRefLayerOverride> where keys are mapSpec.layers[].id and values carry any of paint, layout, minzoom, maxzoom, filter, metadata. Unknown layer ids are silently skipped (they may belong to a downstream adapter plugin). If ref.body is absent and no resolveStyleRef was supplied, the loader throws HonuaMapPackageError { stage: "style-compose" }.
  2. applyTheme substitutes {theme:key} placeholders in string paint / layout values against pkg.theme.tokens (or the resolved pkg.themeId body). Only a full-string match on /^\{theme:([^}]+)\}$/ is replaced — substring interpolation is not supported in v1. Unresolved tokens are left untouched so authors can flag missing tokens at review time.

Theme application recurses into nested arrays / objects inside paint / layout so expression literals can reference theme tokens.

Operational API

Method Behavior
addSource(sourceId, source) Adds a Honua custom source or MapLibre-native source to runtime.honuaMap, runtime.composedStyle, and the host map via map.addSource() when available. Falls back to setStyle(..., { diff: true }) for renderer adapters that only expose full style application.
updateSource(sourceId, source) Replaces a source spec and reapplies the composed style while preserving layer ids. Use for locator/data/source-option changes; paint/layout/filter-only updates should use layer helpers to avoid source reloads.
removeSource(sourceId) Removes the source and any dependent layers from the runtime style, HonuaMap, and host map. Returns the removed layer ids.
addLayer(layer, order?) Adds a typed layer spec, validates paint/layout/filter expressions first, and supports MapLibre beforeId as a string or { beforeId }, { afterId }, `{ position: "top"
updateLayer(layerId, patch) Patches layer paint/layout/filter in place with setPaintProperty / setLayoutProperty / setFilter when the layer shape is stable. Structural layer changes or explicit reordering use a diffed setStyle path.
removeLayer(layerId) / moveLayer(layerId, order?) Removes or reorders runtime-owned layers while keeping runtime.composedStyle and runtime.honuaMap aligned with the renderer.
setLayerPaint(layerId, paint) / setLayerLayout(layerId, layout) / setLayerFilter(layerId, filter) Convenience wrappers around updateLayer for common Mapbox-style style changes.
setLayerVisibility(layerId, visible) map.setLayoutProperty(layerId, "visibility", …).
validateStyleExpression(value) / validateFilterExpression(filter, layerId?) Runs Honua expression checks plus MapLibre style-spec validation when enabled, returning typed diagnostics with path, layer id, source id, protocol, severity, and MapLibre validation context. Mutating helpers throw HonuaRuntimeDiagnosticError before touching the renderer when diagnostics contain errors.
getLegend() Runs buildLegend against the current package and composed style; backfills missing swatches from the first fill-color / circle-color / line-color paint property when it is a string literal.
bindPopup(layerId, binding?) Requires opts.popupFactory. When binding is omitted the runtime looks up pkg.popupBindings[] by the layer's source id. The default renderer emits an unstyled <dl> of the first feature's properties (or a {field} template when binding.template is set). Returns a { remove() } handle; re-binding on the same layer tears down the prior handle.
bindHover(layerId, options?) / bindClick(layerId, handler, options?) / bindSelect(layerId, options?) Infers source and source-layer from the runtime layer, binds MapLibre layer events, and manages hover/click/select feature-state without requiring app code to pass MapLibre types. bindClick emits a source-qualified selection target when a feature id is available.
bindSelectionToExploration(layerId, view, options?) / syncSelectionFromExploration(layerId, view, options?) Bridges runtime layer interactions to ExplorationContext selection and reflects source-qualified shared selection back into MapLibre feature-state.
layerSelectionTarget(layerId, id) / setFeatureStateForTarget(target, state) / getFeatureStateForTarget(target) / removeFeatureStateForTarget(target, key?) Converts layer + feature id pairs into source-qualified targets and applies feature-state using either runtime targets or ExplorationContext source-qualified targets.
setViewState(view) If view.bbox is supplied and map.fitBounds exists, fits the bounds (animate: false by default). Otherwise falls back to map.jumpTo for center / zoom / pitch / bearing. A final fallback applies pkg.initialView.bbox when no input is given.
updatePackage(next) See the Update lifecycle section.
on(listener) Subscribes to HonuaRuntimeEvent. Returns a { remove() } handle.
dispose() Clears popup bindings, removes every layer and source owned by the composed style via honuaMap.clear() + map.removeLayer / map.removeSource, emits disposed, and rejects subsequent mutating calls. Idempotent.

runtime.map, runtime.honuaMap, runtime.dataset, runtime.mapPackage, and runtime.composedStyle are readable at any time. The runtime helpers delegate to the lower-level src/interactions/feature-state and src/interactions/exploration-bindings modules, so apps can still use those lower-level primitives directly when they need custom behavior.

Update lifecycle

updatePackage(next) does three things in order:

  1. Format gate. next.format must match the currently loaded format, or the call throws HonuaMapPackageError { stage: "update" } before any map mutation.
  2. Diff. diffPackages(previous, next) produces a MapPackageDiff keyed by stable ids:
    • Added / removed / changed source bindings (locator, filter, attribution, or protocol differences).
    • Added / removed / changed layer ids (paint, layout, filter, source, source-layer, min/max zoom, metadata).
    • A structuralReason string and incremental: false when any of the following hold: mapSpec.version changed, the layer set changed, the layer order changed, any source binding was added / removed / changed, OR the composed layer changed outside the runtime's paint / layout / filter patch surface. Source-binding changes force the structural path because the runtime must rebuild the underlying Dataset and HonuaMap so runtime.dataset.source(id) observes the new locator / filter.
  3. Apply. If diff.incremental is false, the runtime rebuilds the composed style and calls map.setStyle(composed) first; only once that returns does it clear the old HonuaMap and swap in the freshly projected dataset / honuaMap references. This ordering guarantees that if the host map's setStyle throws, the runtime's previous state — dataset, honuaMap, mapPackage, composedStyle, and all popup bindings — is left intact so the caller can retry without a half-applied update. After a successful swap, any popup binding whose layer id is no longer present, whose layer source changed, or whose package-resolved binding changed is torn down so stale click listeners do not linger. Otherwise it removes dropped layers, patches changed layers in place via setPaintProperty / setLayoutProperty / setFilter, and emits a single package-updated event with the diff attached. Theme-only tweaks and single-layer paint/filter edits never trigger a full setStyle; root layer changes such as minzoom, maxzoom, metadata, source, source-layer, or type do.

Incremental layer patching iterates the union of previous and next paint / layout keys. Keys present in the previous layer but dropped in the next are cleared by calling setPaintProperty(layerId, key, undefined) / setLayoutProperty(layerId, key, undefined) so MapLibre resets them to the property default rather than retaining the stale value. Identical values are short-circuited with a strict-equality check so unchanged properties do not trip a MapLibre setter call.

After any structural reload, runtime.dataset and runtime.honuaMap return the new references (both are exposed through getters, not fixed readonly fields, so live callers see the refreshed state immediately).

Events

Subscribe through LoadMapPackageOptions.onEvent to observe the initial emissions, or runtime.on(listener) for subsequent events. onEvent is the only way to capture source-ready / package-loaded on a successful load because those events are dispatched synchronously before loadMapPackage returns the runtime handle. runtime.on(...) handles every subsequent event.

Event Emitted when
{ type: "package-loaded", packageId } After the first setStyle + initial view apply succeed. Fired last, once per successful load.
{ type: "source-ready", sourceId } Once per source id produced by the contract Dataset, fired synchronously just before package-loaded.
{ type: "source-error", sourceId, error } Per-source binding or query-time failure. Under sourceErrorPolicy: "tolerant" the loader emits one source-error per source whose bind-time projection / spec-build / eager materialization threw; the runtime fans this through the listener chain right after source-ready and before package-loaded, with telemetry observing a source-bind error span. Consumers that fan a query out across the dataset broadcast query-time rejections through runtime.reportSourceError(sourceId, error), which emits the same event so listeners do not have to subscribe to a parallel error channel.
{ type: "package-updated", packageId, diff } After updatePackage completes (both incremental and full-setStyle paths).
{ type: "layer-rendered", layerId } Declared for MapLibre render callbacks bridged by the host. The runtime itself does not fire it today.
{ type: "disposed", packageId } Once inside dispose(), just before listeners are cleared.

Listeners fire synchronously in subscription order. Adapter-level request errors continue to flow through the HonuaClient interceptor chain for trace correlation — the runtime does not add a parallel pipeline.

Errors

HonuaMapPackageError wraps every runtime-binding failure and carries { packageId, stage, detail, cause }. Stages:

Per-source protocol failures keep their existing classes (HonuaCapabilityNotSupportedError, HonuaHttpError, adapter-specific errors) and are not wrapped by the runtime. They surface on the per-Source promises exposed by runtime.dataset and through the shared HonuaClient interceptor chain. The runtime additionally emits source-error events for per-source binding failures absorbed under the tolerant policy and for any query-time rejection a consumer broadcasts through runtime.reportSourceError(sourceId, error). See Tolerant binding above and composition.md for the full mixed-source contract.

Telemetry

HonuaRuntimeTelemetry is a { before?, after?, error? } collector matching the HonuaRequestInterceptor contract. The runtime emits spans for kind: "load" | "update" | "dispose" | "source-bind" | "popup" with startedAt / finishedAt / durationMs. The source-bind error span fires once per source whose binding fails under the tolerant sourceErrorPolicy, and once per call to runtime.reportSourceError(sourceId, error); its detail carries { sourceId } so observability stacks see the failure alongside other runtime spans. Adapter traffic is still instrumented through the shared HonuaClient interceptor chain, so distributed-trace correlation is preserved end-to-end.

Peer dependency posture

Test coverage

test/runtime/runtime.test.ts exercises the full load → updatePackage → dispose lifecycle against a recording mock map (31 tests). Behavior covered includes:

Regression coverage added alongside this release (+11 tests) locks in the fix-pass behaviors:

Conformance-style assertions rely only on the duck-typed MaplibreMap interface so no maplibre-gl dependency creeps into the SDK's runtime bundle.

Deferred follow-ups