Custom Protocols
What a MapLibre custom protocol is, every scheme this app registers, and the registry that lets every consumer reach every one of them
Almost everything this app draws beyond a plain tile server goes through a custom protocol: a URL scheme of our own, such as slope:// or vrt://, whose tiles are produced by a JavaScript function instead of an HTTP request. This page explains the mechanism, lists every scheme, and describes the one rule that keeps them all reachable.
What a custom protocol is
MapLibre GL JS lets an app claim a URL scheme with addProtocol. Any source whose tiles template starts with that scheme then asks the registered function for each tile instead of fetching it:
maplibregl.addProtocol("slope", async (params, abortController) => {
// params.url is the tile URL with {z}/{x}/{y} already filled in
const bitmap = await computeSlopeTile(params.url, abortController.signal)
return { data: bitmap } // image bytes, an ImageBitmap, or vector-tile bytes
})
map.addSource("slopeSource", { type: "raster", tiles: ["slope://<upstream>/{z}/{x}/{y}"], tileSize: 256 })A few properties shape everything built on top:
- It runs on the main thread. MapLibre's worker asks the main thread for the tile, the handler runs there, and the result goes back to the worker. The add a custom protocol example and the PMTiles and COG readers all work this way.
- The source type decides what the handler returns. A
rasterorraster-demsource wants an image (encoded bytes or anImageBitmap); avectorsource wants Mapbox Vector Tile bytes. This app's derived modes return bitmaps, see Handing the tile back to MapLibre. - Each request holds a slot in MapLibre's image-request queue for as long as the handler runs, which is why slow handlers can starve the basemap. See the stalled-tiles watchdog and
MAX_PARALLEL_IMAGE_REQUESTSinTerrainViewer.tsx. - Only MapLibre's own sources get dispatched.
fetch("slope://…")from anywhere else fails with "URL scheme is not supported". That limit is what the registry below exists for.
Every scheme this app registers
Our own terrain sources, which read an elevation dataset MapLibre cannot read on its own and hand it back as Terrarium or Terrain-RGB tiles:
| Scheme | What it serves | Page |
|---|---|---|
float32dem:// | A WMS that returns float32 GeoTIFF elevation | WMS Float32 DEM Protocol |
vrt:// | A GDAL VRT mosaic of COGs in any CRS, reprojected with proj4 | VRT Mosaic Protocol |
lerc:// | Esri LERC tiles from an ArcGIS ImageServer | ArcGIS LERC Elevation Protocol |
quantized-mesh:// | Cesium quantized-mesh terrain, rasterised | Quantized Mesh Protocol |
demdiff:// | The difference of two sources (an nDSM, a change layer) | Derived Terrain Protocol |
Derived modes, which read one of the sources above and compute something from it:
| Scheme | What it computes | Page |
|---|---|---|
slope://, aspect://, curvature://, tpi://, tri://, roughness://, blobness:// | Neighbourhood kernels over a padded elevation grid | Terrain Analysis Rendering Pipeline |
lrm:// | Local Relief Model from an ancestor-tile low-pass | LRM |
svf://, openness://, local-dominance:// | Horizon searches over a wide neighbourhood | Terrain Analysis Rendering Pipeline and Tile Caches |
normals://, matcap://, phong://, shadow:// | Surface normals and the lighting built on them | Lighting Effects |
tells:// | Mound and pit candidates, as vector tiles | Visualization modes |
cog-contour:// | Contours from a COG, computed in a worker | PMTiles and COG Contours |
Third-party schemes
Registered through the same registry, but written by others:
| Scheme | What it serves | Project |
|---|---|---|
cog:// | Cloud-Optimized GeoTIFFs, read by range requests | geomatico/maplibre-cog-protocol, see Terrain sources |
pmtiles:// | Tiles packed in a PMTiles archive | protomaps/PMTiles, see PMTiles and COG Contours |
dem-contour:// | Contour vector tiles computed from a DEM | onthegomap/maplibre-contour, registered by its own setupMaplibre and fed as described below |
The registry
lib/protocol-registry.ts is the one place a scheme is registered, and the one way anything fetches a tile URL itself.
import { registerProtocol, fetchTileBitmap, dispatchTile } from "@/lib/protocol-registry"
// Registration (components/TerrainViewer.tsx, once at startup):
registerProtocol("vrt", withTileResultCache(vrtProtocol))
registerProtocol("slope", withTileResultCache(slopeProtocol))
// Consumption, anywhere in the app:
const bitmap = await fetchTileBitmap("vrt://…/15/8074/14743", signal) // custom scheme: its handler; http(s): fetch()registerProtocol records the handler and passes it on to maplibregl.addProtocol. dispatchTile looks a URL's scheme up and calls its handler directly; fetchTileBitmap does that for our schemes and a plain fetch() for everything else. An unregistered custom scheme throws an error that names the registry, instead of the browser's generic one.
Why it exists
MapLibre dispatches our schemes for its own sources only. Several parts of the app build a tile URL from a template and fetch it themselves:
- every derived mode reading its upstream (slope over a VRT asks for
vrt://tiles), - the GeoTIFF export and the 2D elevation picker and profile,
- the contours layer,
- the mound detector.
Before the registry each of them kept its own list of schemes to special-case, and every new protocol had to be added to every list. Twice one was missed: hillshade worked, because MapLibre fetched it, and every other mode drew nothing, first for lerc:// and quantized-mesh://, then for vrt:// and demdiff://. With the registry a consumer never needs to know which schemes exist, so a new source type works in every mode, in the export, in the picker and in the contours the day it is registered.
The rule
Register through registerProtocol, never maplibregl.addProtocol directly. Fetch a tile URL through fetchTileBitmap or dispatchTile, never fetch() directly.
One resolver for every consumer
The consumers also agree on which template to fetch. useClientDemUpstream (components/LayersAndSources/MapSources.tsx) turns a source id into the template the viz modes read: vrt://…, lerc://…, a difference's demdiff://…, a TileJSON's resolved tiles. The export and the 2D picker (getClientExportSource in lib/client-export.ts) and the contours layer fall back to it for every source type they have no direct path for. That is what makes the feature matrix green across all source types.
Contours
maplibre-contour's DemSource fetches its DEM tiles itself, in a worker, with fetch(), so it cannot read our schemes. For a template on one of them, buildRegistryDemSource in components/LayersAndSources/ContoursLayer.tsx builds a DemSource with worker: false and gives its manager two functions:
- a
getTilethat callsdispatchTileand returns the bitmap, and - a
decodeImagethat draws the bitmap to anOffscreenCanvasand hands the pixels to maplibre-contour's owndecodeParsedImage.
Isolines for these sources are computed on the main thread, the price of the custom getTile. A remote COG with the client toggle on goes to the same worker a local COG file uses (cog-contour://) instead.
Region reads
A tile handler answers one z/x/y at a time; an export wants one area at one size. Where a source can deliver that area directly, its scheme also registers a region reader (registerRegionReader in the registry, the readers in lib/region-readers.ts), and the export (readRegionGrid in lib/client-export.ts) uses it instead of a tile mosaic:
| Scheme | Region read |
|---|---|
float32dem://, float32dem-bbox:// (WMS) | One GetMap per 2048 px chunk of the area |
vrt:// | Each source file read once per 2048 px chunk, reprojected with the same approximate transformer as the tiles |
demdiff:// | The difference of its two operands' region reads, each through its own reader or tiles |
| everything else | A tile mosaic at the zoom that meets the export size |
The IGN LiDAR nDSM, a difference of two WMS layers, exports in two GetMap requests instead of a tile mosaic of each operand. COGs already had their own windowed read. A reader returns floats with NaN for nodata; on the export's EPSG:3857 grid, so they go straight into the file.
Writing a new one
- Write the handler in
lib/<name>-protocol.ts. Return a bitmap throughtoTileImage(lib/tile-image.ts), or MVT bytes for a vector source. - Honour the abort signal, and throw on failure. A thrown error is not cached and MapLibre asks again later. A tile that silently comes back empty is cached for the session, which is how the VRT reader used to leave permanent holes after a timed-out range read.
- Mark nodata with alpha 254, not 0. See Derived Terrain Protocol.
- Register it in
TerrainViewer.tsxwithregisterProtocol, wrapped inwithTileResultCacheunless it has its own caching. See Tile Caches. - If it is a terrain source, make
useClientDemUpstreamresolve the new source type to its template. Every consumer then picks it up.
State reference (URL & storage)
Every URL parameter the app reads, generated from the source at build time — state parameters mirrored by nuqs, instruction parameters read once on load, and the settings kept in localStorage
Terrain Analysis Rendering Pipeline
How Slope, Aspect, Curvature and friends turn a raster-dem source into a colored layer