PMTiles
PMTiles is a single-file archive format
for an entire pyramid of map tiles (raster or vector). One .pmtiles file on
static hosting — S3, R2, GCS, or any HTTP server that honours Range requests —
is a complete basemap or overlay backend. No tile server, no Honua server.
MapLibre GL JS renders PMTiles through a registered protocol handler
(maplibregl.addProtocol("pmtiles", …)). The Honua SDK wires that up for you:
the runtime auto-registers the pmtiles:// protocol on map attach, and the
pmtiles package is an optional peer dependency imported lazily, so a build
that never touches PMTiles pays no bundle cost.
pmtilesis an optionalpeerDependency(seepackage.json). Install it alongsidemaplibre-glwhen you use PMTiles:npm i pmtiles.- Reference an archive as a MapLibre source
urlwith thepmtiles://scheme:pmtiles://https://example.com/basemap.pmtiles.
Discovering a direct archive with connect()
The shared connect() workflow owns direct PMTiles discovery. An explicit
protocol: "pmtiles" works with an ordinary HTTPS asset URL. For auto, use a
pmtiles://https://... marker; a .pmtiles filename is deliberately not enough
evidence and causes an ambiguous-protocol error before cache, authentication,
or network work begins.
import { connect } from "@honua/sdk-js";
const connection = await connect({
endpoint: "pmtiles://https://example.com/basemaps/hawaii.pmtiles",
protocol: "auto",
authorizationScopeFingerprint: "public",
});
const source = connection.source();
console.log(source.descriptor.locator.sourceType); // "vector" or "raster"
console.log(connection.inspection.sources[0]?.metadata?.pmtiles?.bounds);
Discovery runs through the normal client auth/interceptor/timeout pipeline and
accepts only exact HTTP 206 ranges. It permits at most two physical HTTP
attempts, 512 KiB per range, 1 MiB reserved in total, and 4 MiB of cumulative
inflated internal directory/metadata output; caller-provided pmtiles.limits
may only lower those ceilings. A retryable or authentication response is not
replayed inside this bounded lane. Redirects, compressed HTTP ranges,
aggregate whole-archive coverage, unbounded internal compression, inconsistent
Content-Range/validators, unknown magic, cancellation, and response overflow
fail closed. Disjoint ranges require one strong ETag or canonical Last-Modified
validator; weak ETags are not byte-identity evidence.
The cache identity binds the canonical asset URL, authorization-scope digest,
and normalized transfer/decompression policy; cached inspection retains and
cross-binds retrieval time, PMTiles spec version, validator evidence, and the
complete bounded range ledger plus cumulative decompressed-byte count. Cache
replay rechecks that count against the current caller ceiling. The snapshot
retains at most 1,000,000 UTF-8 bytes of canonical raw metadata and
at most 2,048 raw vector_layers entries, using the same admission rules on
live discovery and cache replay. Normalized vector-layer metadata is capped at
8,000 retained structural nodes so every accepted live result remains portable
through the shared cache envelope. Cached extents, the exact capability and
provenance record, and derived source fields are cross-bound to the reviewed
archive metadata. The returned
typed PMTiles adapter reuses that reviewed description and does not perform a
second native fetch.
Rendering a PMTiles source (runtime)
loadMapPackage auto-registers the pmtiles:// protocol before it applies the
composed style, so a pmtiles source binding renders with no manual
addProtocol call. The registration is lazy (the pmtiles and maplibre-gl
packages are imported only when a PMTiles-backed map loads) and idempotent
across every map you attach.
import { HonuaClient } from "@honua/sdk-js/honua";
import { HONUA_MAP_PACKAGE_FORMAT_V1, loadMapPackage } from "@honua/sdk-js/runtime";
import * as maplibregl from "maplibre-gl";
const map = new maplibregl.Map({ container: "map", style: { version: 8, sources: {}, layers: [] } });
await new Promise((resolve) => map.on("load", resolve));
await loadMapPackage(
{
format: HONUA_MAP_PACKAGE_FORMAT_V1,
mapPackageId: "pmtiles-basemap",
sourceBindings: [
{
sourceId: "basemap",
protocol: "pmtiles",
// `sourceType` picks the MapLibre source kind: "vector" (default) or "raster".
locator: { url: "pmtiles://https://example.com/basemap.pmtiles", sourceType: "vector" },
},
],
mapSpec: {
version: 8,
sources: {},
layers: [
{ id: "water", type: "fill", source: "basemap", "source-layer": "water", paint: { "fill-color": "#9cf" } },
],
},
},
map,
// No protocol-backed sources → no Honua server needed. `HonuaClient` is
// required by the contract but never called here.
{ client: new HonuaClient({ baseUrl: location.origin }), skipCompatibilityCheck: true },
);
A pmtiles source binding projects onto a MapLibre-native source
({ type: "vector" | "raster", url: "pmtiles://…" }) via projectSourceBindings.
The locator.sourceType hint selects the MapLibre source kind and defaults to
vector (the common PMTiles basemap case).
Registering the protocol yourself
If you add a PMTiles source to a live map imperatively (runtime.addSource,
which is synchronous) or drive MapLibre directly, register the protocol first:
import { ensurePmtilesProtocol } from "@honua/sdk-js/runtime";
await ensurePmtilesProtocol(); // lazy, idempotent — safe to call repeatedly
map.addSource("basemap", { type: "vector", url: "pmtiles://https://example.com/basemap.pmtiles" });
Inspecting archive metadata (contract)
PMTiles participates in the protocol-neutral Dataset / Source model as a
tiles-only protocol. Its default capability set is { tiles }, so the
canonical query family throws HonuaCapabilityNotSupportedError — an archive has
no feature-query surface. Archive metadata is inspected through the typed escape
hatch or the standalone helper:
import { createDataset, describePmtilesArchive } from "@honua/sdk-js/contract";
// Standalone: inspect any archive URL.
const info = await describePmtilesArchive("https://example.com/basemap.pmtiles");
console.log(info.tileKind); // "mvt" | "png" | "jpeg" | "webp" | "avif" | "unknown"
console.log(info.bounds); // [west, south, east, north] in degrees
console.log(info.minZoom, info.maxZoom);
console.log(info.vectorLayers.map((layer) => layer.id)); // source-layer names
// Through a Dataset: `describe()` on the typed adapter handle.
const dataset = createDataset({
id: "basemaps",
client,
skipCompatibilityCheck: true,
sources: [
{
id: "basemap",
protocol: "pmtiles",
locator: { url: "pmtiles://https://example.com/basemap.pmtiles" },
capabilities: new Set(["tiles"]),
},
],
});
const archive = dataset.source("basemap")!.protocol("pmtiles");
const meta = await archive!.describe();
PmtilesArchiveDescription carries url, tileKind, bounds, minZoom,
maxZoom, center, vectorLayers, an optional attribution, and the raw
metadata JSON.
Build-less CDN recipe
PMTiles works from a plain HTML page with no bundler — load maplibre-gl and
pmtiles from a CDN and register the protocol:
<script src="https://unpkg.com/pmtiles@4/dist/pmtiles.js"></script>
<link href="https://unpkg.com/maplibre-gl@6/dist/maplibre-gl.css" rel="stylesheet" />
<div id="map" style="position:absolute;inset:0"></div>
<script type="module">
import * as maplibregl from "https://unpkg.com/maplibre-gl@6/dist/maplibre-gl.mjs";
const protocol = new pmtiles.Protocol();
maplibregl.addProtocol("pmtiles", protocol.tile);
new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: { basemap: { type: "raster", url: "pmtiles://https://example.com/basemap.pmtiles" } },
layers: [{ id: "basemap", type: "raster", source: "basemap" }],
},
});
</script>
Notes
- Immutable + HTTP caching. PMTiles archives are immutable; the reader fetches byte ranges and relies on HTTP caching. There is no realtime path.
- Range requests required. Static hosts must honour the
Rangeheader (S3 / R2 / GCS do). Theexamples/pmtiles-staticmock server does too. - Runnable example. See
examples/pmtiles-staticandnpm run demo:pmtiles-static.