BeMapAPI
BeMap 4.1.0

BeNomad Tiles

Web: PMTiles#

Serve BeNomad vector basemaps to a browser by reading one authenticated PMTiles archive over HTTP, either through the BeMap SDK or through raw MapLibre + pmtiles.js.

MapLibre-only. PMTiles needs the vector-tile pipeline that Leaflet and OpenLayers don't have: use bemap.MapLibreMap. pmtiles.js must be loaded on the page whenever ctx.tilesHost is set, otherwise the pmtiles:// protocol is never registered and the map stays empty. For native / fleet clients that can't do Range reads, use the ZXY delivery mode instead.

Try it#

A BeNomad Tiles basemap over Paris. Once the Context has a tilesHost, login, JWT renewal, request signing, style resolution and tile caching are all handled by the SDK: this is the whole integration.

At a glance#

  • One archive per map: pmtiles://https://<tilesHost>/<map>, read by byte ranges, no tile pyramid to deploy.
  • Two read modes: cacheable 200-slices (?r=A-B, the default) or classic HTTP Range (206).
  • The 200-slice default lands in the browser's own HTTP cache, so no Service Worker is required.
  • Path A: bemap.Context + bemap.MapLibreMap: auth, JWT refresh and caching are automatic.
  • Path B: raw MapLibre + pmtiles.js: you own login, the TILES_SOURCE swap and the ~55 min re-login.
  • Always address the map by the default alias, never a dated filename.

Usage#

Script load order (required)#

The order matters: bemap-js-api.js registers the pmtiles:// protocol at construction time and needs both globals already present.

html
<link rel="stylesheet" href="/dist/maplibre-gl.css">
<link rel="stylesheet" href="/dist/bemap-js-api.css">
<script src="/dist/maplibre-gl.js"></script>
<script src="/dist/pmtiles.js"></script>
<script src="/dist/bemap-js-api.js"></script>

Set tilesHost on the Context. That single field promotes the MapLibre background to BeNomad Tiles and switches on bemap.TilesAuth (login, JWT, transformRequest signing, silent renewal) and the tile cache.

js
var ctx = new bemap.Context({
    host:      'bemap-beta.benomad.com',        // BeMap host - services auth
    secure:    true,
    login:     'YOUR_LOGIN',                    // demo / evaluation only
    password:  'YOUR_PASSWORD',                 // production → token provider
    tilesHost: 'mptiles-api-beta.benomad.net'   // tiles backend
});

var map = new bemap.MapLibreMap(ctx, 'map');
map.move(2.35, 48.85, 12);                      // lon, lat, zoom - lon FIRST

// Tile- and style-load failures are logged to the browser console. If you need to
// react in code, MapLibre's own error event is the available hook - note the
// callback receives a bemap.MapEvent, so the native error lives on evt.native.
map.on('error', function (evt) {
    var err = evt && evt.native && evt.native.error;
    console.error('maplibre tiles error:', err && (err.message || err));
});

Never ship a login/password to a production browser: swap them for a backend token provider. The two hosts move together: swap host and tilesHost in the same edit: beta uses mptiles-api-beta.benomad.net, preprod mptiles-api-preprod.benomad.net, production mptiles-api.benomad.net.

Path B: raw MapLibre + pmtiles.js (no SDK)#

Only if you're already committed to raw MapLibre. You take over everything the SDK does for you: the protocol registration, the login call, the style placeholder swap, and the token lifetime.

js
var TILES = 'https://mptiles-api-beta.benomad.net';
var LOGIN = 'YOUR_LOGIN', PASS = 'YOUR_PASSWORD';   // demo / evaluation only

function b64(s) { return btoa(unescape(encodeURIComponent(s))); }

async function login() {
    var r = await fetch(TILES + '/api/login', {
        method: 'POST',
        headers: { 'Authorization': 'Basic ' + b64(LOGIN + ':' + PASS) }
    });
    if (!r.ok) throw new Error('login HTTP ' + r.status);
    return (await r.json()).token;
}

async function resolveStyle(token) {
    var raw = await (await fetch(TILES + '/api/default-style?token=' + token)).json();
    var srcPh = (raw.metadata && raw.metadata.source_placeholder) || 'TILES_SOURCE';
    raw.sources = raw.sources || {};
    raw.sources['tiles'] = Object.assign({}, raw.sources[srcPh], {
        type: 'vector',
        url: 'pmtiles://' + TILES + '/default.pmtiles?token=' + token,
        maxzoom: 14
    });
    if (srcPh !== 'tiles') delete raw.sources[srcPh];
    (raw.layers || []).forEach(function (l) { if (l.source === srcPh) l.source = 'tiles'; });
    return raw;
}

(async function () {
    var token = await login();
    maplibregl.addProtocol('pmtiles', new pmtiles.Protocol().tile);
    var map = new maplibregl.Map({
        container: 'map', style: await resolveStyle(token),
        center: [2.35, 48.85], zoom: 12
    });
    // The token lives ~1 h - re-login and re-apply the style before it lapses.
    setInterval(async function () {
        try { var t = await login(); map.setStyle(await resolveStyle(t)); }
        catch (e) { console.warn('refresh failed', e); }
    }, 55 * 60 * 1000);
})();

This snippet authenticates with ?token= on the query string, the only method that composes with pmtiles.js Range requests without extra plumbing. X-Session-Token is more secure but needs a fetch interceptor, which is exactly what Path A gives you.

Reference#

The two read modes#

ModeRequestSuccessCacheable
200-slice (default)GET /default?r=A-B200Yes: Cache-Control: public, max-age=2592000, immutable + ETag
RangeGET /default.pmtiles + Range: bytes=A-B206No: browsers don't reliably cache partials

Both return identical bytes. tilesSliceMode: '200' is the default because a plain 200 with immutable is stored by the browser's native HTTP cache: repeat tiles and repeat visits cost nothing and no Service Worker is needed. On the 206 path the Service Worker is the only client-side cache, so the SDK turns it back on automatically when you select 'range' ('206' is accepted as a synonym).

How the archive URL is built#

text
pmtiles://https://<tilesHost>/<map>

The map name is bare: .pmtiles is optional and the default alias is resolved server-side, so the client never pins a build. Precedence for <map>: ctx.tilesFile → ctx.geoserver → the literal 'default'. Use the alias; a raw dated filename (OSM_250901_WORLD.pmtiles) breaks the moment the archive is rebuilt.

bemap.TilesStyle.resolvePlaceholders() performs the substitution: it replaces the style's metadata.source_placeholder source (default TILES_SOURCE) with a pmtiles:// source pointing at the resolved archive, repoints every layer at it, absolutises a root-relative glyphs against the tiles host, and expands __BILINGUAL_PLACE__ into a bilingual label expression.

Constructor options#

OptionDefaultEffect
tiles-A custom PMTiles URL (self-hosted / foreign origin) instead of the tilesHost archive.
tilesStyleserver default styleStyle URL or style object to apply.
tilesSliceMode'200''200' cacheable slices, or 'range' / '206' for classic Range reads.
serviceWorker(auto)true / false force the tile Service Worker. Unset → off under '200', on under 'range'.
browserCache(unset)Legacy opt-out. false skips the worker everywhere; any other value (including 'auto') is an explicit force-on. Omit it.
serviceWorkerPath(unset)Pins the worker URL and disables auto-discovery. Leave unset unless you serve it from a non-standard path.

Under the '200' default you normally set none of these. Tile URLs carry the archive version token (?v=…) and slices come back immutable, so the browser's own HTTP cache does the job, see Cache, slices & resilience.

Discovery#

The fetch* calls are authenticated, not rate-limited, and return Promises.

js
map.fetchAvailableMaps();     // { default, defaultStyle, aliases, tilesets, styles }
map.fetchAvailableStyles();   // { styles: [...], defaultStyle }
map.fetchDefaultMap();        // the server-side default map name
map.fetchDefaultStyle();      // the server-side default style
map.getTilesConfig();         // synchronous: tilesHost, tilesSliceMode, tilesAuth,
                              // rangeCacheMode, tileGate, serviceWorker { enabled, path }

Negative cases#

SituationStatus
GET on a .pmtiles with no Range header403
Range span larger than 10 MB403
Multi-part Range (bytes=0-9,20-29)403
Missing or expired token401
Rate limit exceeded (100 req/s per user)429
Tile exists in the pyramid but is empty(no request): emptiness is encoded in the PMTiles directory, so the client never asks. 204 is a z/x/y behaviour, see Mobile & fleet.

Notes#

  • The JWT lives ~1 h. On Path A the SDK refreshes it proactively and re-applies the current style; on Path B you re-login yourself.
  • HTTPS only. The Service Worker additionally requires HTTPS or localhost.
  • Discovery endpoints are exempt from the 100 req/s tile budget: poll them freely at startup.
  • A 403 on tiles can mean the account lacks the role for that tileset: that is provisioning, not integration. Check the Range header first, though: three of the five 403 causes are malformed range requests. (400 "Access Denied" is a BeMap service entitlement response, not something the tiles worker returns, see Troubleshooting.)

Gotchas#

  • Coordinates are lon, lat. map.move(2.35, 48.85, 12) is Paris; the reverse is the Indian Ocean.
  • Forgetting pmtiles.js is the most common blank-map cause: the protocol is only registered when the global exists at construction time.
  • Loading the scripts out of order has the same effect: bemap-js-api.js must come last.
  • Don't hardcode a dated archive name. Address default and let the server resolve it.
  • 206 responses are not browser-cached. If you switch to tilesSliceMode: 'range', keep the Service Worker on or every pan re-downloads.
  • Path B and setStyle() wipe custom layers you added by hand: re-add them after each token refresh.

See also#

  • BeNomad Tiles overview: the chapter entry point, delivery modes compared
  • Tiles authentication: login, JWT, cookie / header / query modes
  • Tile caching: browser HTTP cache vs Service Worker
  • Tile styles: placeholders, bilingual labels, custom styles
  • Mobile: ZXY: the other delivery mode
  • Tiles troubleshooting: grey tiles, 401/403/429
  • Display map (MapLibre): the map object itself
  • The Context: tilesHost, tilesFile, tokenStorage