diff --git a/internal/generate-tree.mjs b/internal/generate-tree.mjs index 41ec0dec8..308f861b8 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 { isWebBuilt } from './plugin-targets.mjs' import { readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync, @@ -1602,6 +1602,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 e732ef0c5..6cbce1156 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..31e18b447 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