diff --git a/documentation/how-to.md b/documentation/how-to.md index f95f87157..7a5d03390 100644 --- a/documentation/how-to.md +++ b/documentation/how-to.md @@ -288,7 +288,12 @@ Step by step instead: `bun run build` → `bun run cap:sync` → open `deploymen The engine is a **runtime parameter** — same bundle, no rebuild. Resolution order (first defined wins): `profile-config.provider` → `?provider=` in the page URL → -`window.MAP_PROVIDER` → default `mapbox`. +`window.MAP_PROVIDER` → default `auto`. + +`auto` picks **Mapbox when a token is available** (a `tk` in the profile-config, `window.MAP_TOKEN`, +`?tk=`, a token pasted once in the tree, the studio's ⚙ Settings, or a configured token URL) and +**MapLibre otherwise** — keyless, with no request to Mapbox. The repo ships no Mapbox token, so a +fresh checkout draws every map with MapLibre until you give it one. An explicit `provider` always wins. ```html @@ -299,8 +304,8 @@ https:///dx-maps///index.html?provider=maplibre ``` `maplibre` runs token-free on the OpenFreeMap style (the right GL script/CSS is injected -automatically); the geocoder search bar keeps working in both engines. `mapbox` (the default) -behaves exactly as before — existing embeds never change. +automatically); the geocoder search bar keeps working in both engines. `mapbox` behaves exactly +as before, and an embed that passes a token keeps getting Mapbox under `auto`. > Status: **on `main`.** Generated pages honor `provider:'maplibre'` end-to-end — the page > loads the MapLibre peer instead of Mapbox and **skips the token ladder entirely** (no diff --git a/internal/check-auto-engine.mjs b/internal/check-auto-engine.mjs new file mode 100644 index 000000000..f71c71beb --- /dev/null +++ b/internal/check-auto-engine.mjs @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * check-auto-engine — the default engine is 'auto': Mapbox WHEN a token is available, MapLibre otherwise. + * + * WHY (2026-09-25, by project decision). The public repo carries no Mapbox token. With a default of + * 'mapbox', a keyless visitor got a map that could not draw and a paste-a-token prompt, and the only + * way out was a token in the repo. 'auto' asks the token ladder instead (useMapProvider.js), and the + * generated leaf asks the same ladder before it mounts (generate-tree.mjs, PROVIDER). + * + * THREE CASES, on one map leaf of THIS tree (or --dir), each in a fresh browser context: + * 1 NO TOKEN the map draws with MapLibre, makes ZERO requests to any *.mapbox.com host, and + * PAINTS (distinct colours on the canvas, not a blank rectangle). + * 2 HOST TOKEN the host sets window.MAP_TOKEN before the page runs: the map draws with Mapbox, + * and THAT token is the access_token on its requests to Mapbox. + * 3 EXPLICIT WINS the same host token, with ?provider=maplibre: MapLibre, zero Mapbox requests. + * The token in case 2 is a well-formed FAKE, built at run time; Mapbox requests that carry it are + * recorded and aborted, never sent. The engine is read from the canvas that drew + * (mapboxgl-canvas / maplibregl-canvas), not from which globals happen to exist. + * + * USAGE node internal/check-auto-engine.mjs [--dir ] [--leaf /] + * Run through internal/browser-slot.sh. + * EXIT 0 all three hold · 1 one does not · 75 not measured. + */ +import { chromium } from 'playwright' +import { spawn } from 'node:child_process' +import { createServer } from 'node:http' +import { existsSync } from 'node:fs' +import { resolve, dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { TREE_NAME } from './_tree-name.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const argv = process.argv.slice(2) +const arg = (k, d) => { const i = argv.indexOf(k); return i >= 0 && argv[i + 1] ? argv[i + 1] : d } +const DIR = resolve(arg('--dir', resolve(ROOT, 'public'))) +const LEAF = arg('--leaf', 'doxa-simple-map/doxa-simple-map') +const MIN_COLOURS = 40 +const MAPBOX_HOST = /(^|\.)mapbox\.com$/i +// A fake public token of the right SHAPE (pk..), so the engine accepts it. +const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url') +const FAKE_TOKEN = ['pk', b64({ u: 'auto-engine-check', a: 'not-a-real-token' }), 'c2lnbmF0dXJl'].join('.') + +if (!existsSync(join(DIR, TREE_NAME, LEAF, 'index.html'))) { console.log(`AUTO-ENGINE: NOT MEASURED — no ${LEAF} under ${DIR}/${TREE_NAME}/`); process.exit(75) } + +const port = await new Promise((ok) => { const s = createServer().listen(0, '127.0.0.1', () => { const n = s.address().port; s.close(() => ok(n)) }) }) +const srv = spawn(process.execPath, [resolve(ROOT, 'internal/serve-strict.mjs'), '--port', String(port), '--directory', DIR], { stdio: 'ignore' }) +const stop = () => { try { srv.kill() } catch { /* gone */ } } +let up = false +for (let i = 0; i < 40 && !up; i++) { await new Promise((r) => setTimeout(r, 150)); up = await fetch(`http://127.0.0.1:${port}/${TREE_NAME}/manifest.json`).then((r) => r.ok).catch(() => false) } +if (!up) { stop(); console.log(`AUTO-ENGINE: NOT MEASURED — could not serve ${DIR}`); process.exit(75) } +console.log(`AUTO-ENGINE: measuring THIS TREE — ${DIR} (127.0.0.1:${port}), leaf ${LEAF}`) + +const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }) + +async function measure({ hostToken = '', query = '' }) { + const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } }) + const page = await ctx.newPage() + const mapbox = [] + // The engine script itself may come from Mapbox's CDN; everything that CARRIES a token is recorded + // and aborted, so the fake token never leaves this machine. + await page.route((url) => MAPBOX_HOST.test(url.hostname), (route) => { + const u = new URL(route.request().url()) + mapbox.push(u) + if (u.searchParams.has('access_token')) return route.abort() + return route.continue() + }) + if (hostToken) await page.addInitScript((tk) => { window.MAP_TOKEN = tk }, hostToken) + try { + await page.goto(`http://127.0.0.1:${port}/${TREE_NAME}/${LEAF}/index.html${query}`, { waitUntil: 'load', timeout: 90000 }) + const deadline = Date.now() + 45000 + let engine = '' + while (Date.now() < deadline && !engine) { + engine = await page.evaluate(() => { + const roots = [document] + for (const el of document.querySelectorAll('*')) if (el.shadowRoot) roots.push(el.shadowRoot) + for (const r of roots) { + if (r.querySelector('canvas.maplibregl-canvas')) return 'maplibre' + if (r.querySelector('canvas.mapboxgl-canvas')) return 'mapbox' + } + return '' + }) + if (!engine) await page.waitForTimeout(500) + } + const prompt = await page.evaluate(() => !!document.getElementById('tkIn')) + let colours = 0 + if (engine === 'maplibre') { + await page.waitForTimeout(8000) // tiles + // Straight from CDP: page.screenshot waits for web fonts first and timed out on this leaf. + const cdp = await ctx.newCDPSession(page) + const png = Buffer.from((await cdp.send('Page.captureScreenshot', { format: 'png' })).data, 'base64') + colours = await page.evaluate(async (data) => { + const img = new Image(); img.src = 'data:image/png;base64,' + data; await img.decode() + const c = document.createElement('canvas'); c.width = img.width; c.height = img.height + const g = c.getContext('2d'); g.drawImage(img, 0, 0) + const d = g.getImageData(0, 0, c.width, c.height).data, seen = new Set() + for (let i = 0; i < d.length; i += 4) seen.add(((d[i] >> 3) << 10) | ((d[i + 1] >> 3) << 5) | (d[i + 2] >> 3)) + return seen.size + }, png.toString('base64')) + } else { + await page.waitForTimeout(3000) + } + return { engine: engine || 'none', prompt, colours, mapbox } + } catch (e) { + return { err: String(e.message).split('\n')[0].slice(0, 120), mapbox } + } finally { await ctx.close() } +} + +const results = [] +try { + results.push({ name: '1 no token', r: await measure({}) }) + results.push({ name: '2 host token', r: await measure({ hostToken: FAKE_TOKEN }) }) + results.push({ name: '3 explicit maplibre + token', r: await measure({ hostToken: FAKE_TOKEN, query: '?provider=maplibre' }) }) +} finally { await browser.close(); stop() } + +let bad = 0, unmeasured = 0 +const say = (ok, name, msg) => { console.log(` ${ok ? 'ok ' : 'FAIL'} ${name.padEnd(30)} ${msg}`); if (!ok) bad++ } +for (const { name, r } of results) { + if (r.err) { console.log(` ?? ${name.padEnd(30)} NOT MEASURED — ${r.err}`); unmeasured++; continue } + const withToken = r.mapbox.filter((u) => u.searchParams.has('access_token')) + const summary = `engine ${r.engine}, ${r.mapbox.length} Mapbox request(s)${r.prompt ? ', TOKEN PROMPT shown' : ''}` + if (name.startsWith('1')) { + say(r.engine === 'maplibre' && r.mapbox.length === 0 && r.colours >= MIN_COLOURS && !r.prompt, name, + `${summary}, ${r.colours} colours (floor ${MIN_COLOURS}) — want maplibre, 0 Mapbox requests, painted, no prompt`) + } else if (name.startsWith('2')) { + const carries = withToken.length > 0 && withToken.every((u) => u.searchParams.get('access_token') === FAKE_TOKEN) + say(r.engine === 'mapbox' && carries, name, + `${summary}, ${withToken.length} carry a token${withToken.length && !carries ? ' — NOT the host token' : ''} — want mapbox, the host token on its requests`) + } else { + say(r.engine === 'maplibre' && r.mapbox.length === 0, name, `${summary} — want maplibre, 0 Mapbox requests`) + } +} +if (bad) { console.log(`AUTO-ENGINE: FAIL — ${bad} of 3 case(s) do not hold.`); process.exit(1) } +if (unmeasured) { console.log(`AUTO-ENGINE: NOT MEASURED — ${unmeasured} case(s) could not be judged.`); process.exit(75) } +console.log('AUTO-ENGINE: PASS — no token draws MapLibre keyless; a host token draws Mapbox with that token; an explicit provider wins.') diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index 15791e8a2..18e4c77a4 100644 --- a/internal/generate-tree.mjs +++ b/internal/generate-tree.mjs @@ -801,7 +801,7 @@ function mapPageHtml(opts) { geocoder css, mapbox-gl.js and the geocoder js from Mapbox's CDN, chosen at BUILD time — so a partner embedding it with ?provider=maplibre still sent every visitor's IP to Mapbox (4 requests per leaf, measured). The provider is chosen at RUNTIME (profile-config.provider - → ?provider= → window.MAP_PROVIDER → mapbox), and the element's own runtime loaders load + → ?provider= → window.MAP_PROVIDER → auto), and the element's own runtime loaders load exactly that engine: ensureEngineScript / ensureEngineCSS (useMapInstance) and the geocoder's on-demand loader — the path composite leaves already run on. Gate: check:leaf-engine-per-provider (0 Mapbox requests under maplibre; Mapbox unchanged). -->` @@ -868,11 +868,8 @@ ${apiScript} var BASE_CONFIG = ${JSON.stringify(pageConfig)}; // MapLibre map pages AND shell pages (app-shell / nested-mfe-spa) need NO token: // the ladder below is skipped entirely and the element mounts immediately. - // …and a Mapbox leaf opened with provider=maplibre (config, ?provider= or window.MAP_PROVIDER — - // the same ladder as resolveMapProvider) needs no Mapbox token either: without this, the public - // copy (no baked token) asked a MapLibre visitor to paste a Mapbox token it would never use. - var RUNTIME_PROVIDER = String((BASE_CONFIG && BASE_CONFIG.provider) || new URLSearchParams(location.search).get('provider') || window.MAP_PROVIDER || 'mapbox').toLowerCase(); - var TOKEN_FREE = ${JSON.stringify(tokenFree)} || RUNTIME_PROVIDER === 'maplibre'; + // (A leaf whose engine is MapLibre at RUNTIME joins them — see PROVIDER below.) + var TOKEN_FREE = ${JSON.stringify(tokenFree)}; // A token-free COMPOSITE still nests plugins, and a nested Mapbox map reads window.MAP_TOKEN // rather than this page's profile-config. With no baked token (the public copy), it must get // one from ?tk= or a pasted token, or be asked for one — see the rungs below. @@ -882,6 +879,8 @@ ${apiScript} // Expose the baked token as window.MAP_TOKEN so a BAKED composite (a compiled element that mounts // a map component directly, with no ProfileLoader/profile-config wrapper) still finds a token — // useMapInstance falls back to window.MAP_TOKEN. No effect when BAKED_TOKEN is empty. + // A token the HOST set before this page ran outranks the baked one (measured: it used to lose). + var HOST_TOKEN = (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN) || ''; if (BAKED_TOKEN && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = BAKED_TOKEN; } var DEFAULT_TOKEN_URL = '/api/maps/token'; @@ -949,13 +948,24 @@ ${apiScript} try { stored = localStorage.getItem('MAPS_STAGING_TK') || ''; } catch (e) {} var urlTk = qp('tk'); if (urlTk) { try { localStorage.setItem('MAPS_STAGING_TK', urlTk); } catch (e) {} } - var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || BAKED_TOKEN; + var directToken = (BASE_CONFIG && BASE_CONFIG.tk) || urlTk || stored || HOST_TOKEN || BAKED_TOKEN; + // PROVIDER — the same ladder as resolveMapProvider (library/composables/useMapProvider.js): + // profile-config.provider → ?provider= → window.MAP_PROVIDER → 'auto'. An explicit engine wins. + // 'auto' = Mapbox when a token is available (a rung above, a host-set window.MAP_TOKEN, or a token + // endpoint to ask), otherwise MapLibre, keyless: no token prompt, no request to Mapbox. The + // element decides the same way from the token this page hands it, so the two cannot disagree. + var ASKED = String((BASE_CONFIG && BASE_CONFIG.provider) || qp('provider') || window.MAP_PROVIDER || '').toLowerCase().trim(); + var EXPLICIT = (ASKED === 'mapbox' || ASKED === 'maplibre') ? ASKED : ''; + var HAS_TOKEN = !!(directToken || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN)); + var HAS_ENDPOINT = !!(window.MAP_TOKEN_URL || (TOKEN_SRC && TOKEN_SRC !== 'window.MAP_TOKEN_URL')); + if (EXPLICIT === 'maplibre' || (!EXPLICIT && !HAS_TOKEN && !HAS_ENDPOINT)) { TOKEN_FREE = true; } if (TOKEN_FREE) { if (directToken && typeof window !== 'undefined' && !window.MAP_TOKEN) { window.MAP_TOKEN = directToken; } mount(''); // A token-free composite with no token anywhere: its content renders, but its nested maps // cannot. Ask once; the token is stored for the whole tree and the page reloads with it. - if (NESTS_MAPS && !directToken) { + // Only when Mapbox was ASKED for: under 'auto' the nested maps draw with MapLibre instead. + if (NESTS_MAPS && !directToken && EXPLICIT === 'mapbox') { var bar = document.createElement('div'); bar.id = 'status'; bar.setAttribute('style', 'position:fixed;left:12px;right:12px;bottom:12px;z-index:2147483000;padding:10px 12px;border-radius:10px;background:#0e1623;color:#eef2f7;font:13px system-ui,sans-serif;box-shadow:0 6px 24px rgba(0,0,0,.25)'); @@ -986,7 +996,9 @@ ${apiScript} function fetchToken(i, errors) { if (i >= candidates.length) { - // Every endpoint failed: say which, and still offer the paste-once box (no dead ends). + // Every endpoint failed. Under 'auto' that means no token: draw with MapLibre, keyless. + if (!EXPLICIT) { TOKEN_FREE = true; return mount(''); } + // Mapbox was asked for: say which endpoints failed, and still offer the paste-once box. askToken(); if (status) { var d = document.createElement('div'); @@ -1276,10 +1288,11 @@ function scanBundles() { //