From 275cdc807092b348e6d67b2e5fc4651dc8b12cca Mon Sep 17 00:00:00 2001 From: Doxa Maps Date: Mon, 28 Sep 2026 01:03:25 +0000 Subject: [PATCH] The map engine loads from the tree it was served from, with the CDN as fallback MapLibre GL came from cdn.jsdelivr.net: a second origin to connect to before a map could start. generate-tree now copies the pinned maplibre-gl JS and CSS from node_modules into /vendor/maplibre-gl@5.24.0/ and stops the build if their bytes do not match the integrity pin. A built bundle loads the engine from its own tree with that same pin, one cached copy for every bundle, and falls back to jsdelivr if the copy is missing. Mapbox GL stays on api.mapbox.com. --- internal/generate-tree.mjs | 17 +++++++++- library/composables/useMapInstance.js | 47 +++++++++++++++++---------- library/constants/cdn.js | 25 ++++++++++++-- 3 files changed, 69 insertions(+), 20 deletions(-) diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index 1bf0b5f2b..226bf9eed 100644 --- a/internal/generate-tree.mjs +++ b/internal/generate-tree.mjs @@ -30,7 +30,7 @@ * Run standalone: bun generate-tree.mjs (expects vite build already ran) * Wired into: bun run build (runs after the per-bundle vite builds) */ -import { CDN } from '../library/constants/cdn.js' +import { CDN, VENDORED } from '../library/constants/cdn.js' import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync, cpSync, rmSync, mkdtempSync, @@ -1421,6 +1421,21 @@ function writeManifestAndHtml(root, bundles, tokenUrlExpr, bakedToken) { } mkdirSync(root, { recursive: true }) writeFileSync(join(root, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n') + // ★ THE MAP ENGINE, SAME-ORIGIN (the Driver, 2026-09-28: maps must load fast). Each pinned file in + // library/constants/cdn.js VENDORED is copied from node_modules into the tree, and a copy whose bytes do + // not hash to that entry's integrity pin stops the build: the page loads it with the same pin. + for (const v of VENDORED) { + const src = resolve(__dirname, '..', v.from) + const pin = Object.values(CDN).find((e) => e.url === v.cdn)?.integrity || '' + const bytes = readFileSync(src) + const got = 'sha384-' + createHash('sha384').update(bytes).digest('base64') + if (got !== pin) { + console.error(`generate-tree: ${v.from} does not match its pin (${pin.slice(0, 20)}…, got ${got.slice(0, 20)}…). Reinstall node_modules at the pinned version, or re-pin library/constants/cdn.js.`) + process.exit(1) + } + mkdirSync(dirname(join(root, v.path)), { recursive: true }) + writeFileSync(join(root, v.path), bytes) + } // ★ A CORS-READABLE COPY for the Research Gallery, byte-identical, written in the SAME step so the // two can never diverge. The live static layer sends Access-Control-Allow-Origin on paths inside a // folder of the tree but not on the root manifest (measured 2026-09-25), so a partner page's diff --git a/library/composables/useMapInstance.js b/library/composables/useMapInstance.js index 5c56340a9..8be394860 100644 --- a/library/composables/useMapInstance.js +++ b/library/composables/useMapInstance.js @@ -99,7 +99,8 @@ const OPENFREEMAP_DARK_STYLE = 'https://tiles.openfreemap.org/styles/fiord' // 'midnight-glow' / … resolves per engine here; raw URLs pass through. import { resolveNamedStyle, applyStylePaint } from '../styles/registry.js' import { resolveMapProvider, explicitMapProvider } from './useMapProvider.js' -import { CDN, pinElement } from '../constants/cdn.js' +import { CDN, pinElement, vendoredUrl } from '../constants/cdn.js' +import { bundleTreeRoot } from '../utils/embedBase.js' import { installRtlTextPlugin } from '../maps/rtl-text.js' import { localizeBasemapLabels } from '../maps/localize-labels.js' import { tStatic } from '../i18n/index.js' @@ -138,7 +139,26 @@ export function engineGlobal(provider) { * inject undefined. */ export function engineStylesheetUrl(provider) { - return (ENGINES[provider] || ENGINES.mapbox).css || null + const css = (ENGINES[provider] || ENGINES.mapbox).css || null + return (css && vendoredUrl(css, bundleTreeRoot())) || css +} + +// Load one engine file from its same-origin copy when this bundle was served from a built tree, falling +// back to the pinned CDN url if that copy is missing (a tree copied without its vendor/ folder). +function appendPinned(tag, url, fallbackUrl, attrs) { + return new Promise((resolve, reject) => { + const el = document.createElement(tag) + Object.assign(el, attrs) + if (tag === 'script') el.src = url; else el.href = url + pinElement(el, url) + el.addEventListener('load', () => resolve(el), { once: true }) + el.addEventListener('error', () => { + el.remove() + if (fallbackUrl && fallbackUrl !== url) appendPinned(tag, fallbackUrl, null, attrs).then(resolve, reject) + else reject(new Error(`[useMapInstance] Failed to load ${url}`)) + }, { once: true }) + document.head.appendChild(el) + }) } /** @@ -151,15 +171,14 @@ export async function ensureEngineScript(provider) { if (engineGlobal(provider)) return // already loaded — no-op if (typeof document === 'undefined') return - if (!document.querySelector(`link[href="${cfg.css}"]`)) { - const link = document.createElement('link') - link.rel = 'stylesheet' - link.href = cfg.css - pinElement(link, cfg.css) - document.head.appendChild(link) + const root = bundleTreeRoot() + const cssUrl = vendoredUrl(cfg.css, root) || cfg.css + const jsUrl = vendoredUrl(cfg.js, root) || cfg.js + if (!document.querySelector(`link[href="${cssUrl}"]`) && !document.querySelector(`link[href="${cfg.css}"]`)) { + appendPinned('link', cssUrl, cfg.css, { rel: 'stylesheet' }).catch(() => {}) } - const existing = document.querySelector(`script[src="${cfg.js}"]`) + const existing = document.querySelector(`script[src="${jsUrl}"]`) || document.querySelector(`script[src="${cfg.js}"]`) if (existing) { if (engineGlobal(provider)) return await new Promise(resolve => { @@ -169,14 +188,8 @@ export async function ensureEngineScript(provider) { return } - await new Promise((resolve, reject) => { - const s = document.createElement('script') - s.src = cfg.js - pinElement(s, cfg.js) - s.async = false - s.addEventListener('load', resolve, { once: true }) - s.addEventListener('error', () => reject(new Error(`[useMapInstance] Failed to load ${provider} engine script`)), { once: true }) - document.head.appendChild(s) + await appendPinned('script', jsUrl, cfg.js, { async: false }).catch(() => { + throw new Error(`[useMapInstance] Failed to load ${provider} engine script`) }) } diff --git a/library/constants/cdn.js b/library/constants/cdn.js index d8b2ec825..c52d22064 100644 --- a/library/constants/cdn.js +++ b/library/constants/cdn.js @@ -34,9 +34,30 @@ export const CDN = { d3: { url: 'https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js', integrity: 'sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i' }, } -/** Set integrity + crossorigin on a