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 <tree>/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.
This commit is contained in:
parent
9ce04ffea0
commit
275cdc8070
3 changed files with 69 additions and 20 deletions
|
|
@ -30,7 +30,7 @@
|
||||||
* Run standalone: bun generate-tree.mjs (expects vite build already ran)
|
* Run standalone: bun generate-tree.mjs (expects vite build already ran)
|
||||||
* Wired into: bun run build (runs after the per-bundle vite builds)
|
* 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 {
|
import {
|
||||||
readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync,
|
readFileSync, writeFileSync, readdirSync, existsSync, mkdirSync, statSync,
|
||||||
cpSync, rmSync, mkdtempSync,
|
cpSync, rmSync, mkdtempSync,
|
||||||
|
|
@ -1421,6 +1421,21 @@ function writeManifestAndHtml(root, bundles, tokenUrlExpr, bakedToken) {
|
||||||
}
|
}
|
||||||
mkdirSync(root, { recursive: true })
|
mkdirSync(root, { recursive: true })
|
||||||
writeFileSync(join(root, 'manifest.json'), JSON.stringify(manifest, null, 2) + '\n')
|
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
|
// ★ 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
|
// 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
|
// folder of the tree but not on the root manifest (measured 2026-09-25), so a partner page's
|
||||||
|
|
|
||||||
|
|
@ -99,7 +99,8 @@ const OPENFREEMAP_DARK_STYLE = 'https://tiles.openfreemap.org/styles/fiord'
|
||||||
// 'midnight-glow' / … resolves per engine here; raw URLs pass through.
|
// 'midnight-glow' / … resolves per engine here; raw URLs pass through.
|
||||||
import { resolveNamedStyle, applyStylePaint } from '../styles/registry.js'
|
import { resolveNamedStyle, applyStylePaint } from '../styles/registry.js'
|
||||||
import { resolveMapProvider, explicitMapProvider } from './useMapProvider.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 { installRtlTextPlugin } from '../maps/rtl-text.js'
|
||||||
import { localizeBasemapLabels } from '../maps/localize-labels.js'
|
import { localizeBasemapLabels } from '../maps/localize-labels.js'
|
||||||
import { tStatic } from '../i18n/index.js'
|
import { tStatic } from '../i18n/index.js'
|
||||||
|
|
@ -138,7 +139,26 @@ export function engineGlobal(provider) {
|
||||||
* inject undefined.
|
* inject undefined.
|
||||||
*/
|
*/
|
||||||
export function engineStylesheetUrl(provider) {
|
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 (engineGlobal(provider)) return // already loaded — no-op
|
||||||
if (typeof document === 'undefined') return
|
if (typeof document === 'undefined') return
|
||||||
|
|
||||||
if (!document.querySelector(`link[href="${cfg.css}"]`)) {
|
const root = bundleTreeRoot()
|
||||||
const link = document.createElement('link')
|
const cssUrl = vendoredUrl(cfg.css, root) || cfg.css
|
||||||
link.rel = 'stylesheet'
|
const jsUrl = vendoredUrl(cfg.js, root) || cfg.js
|
||||||
link.href = cfg.css
|
if (!document.querySelector(`link[href="${cssUrl}"]`) && !document.querySelector(`link[href="${cfg.css}"]`)) {
|
||||||
pinElement(link, cfg.css)
|
appendPinned('link', cssUrl, cfg.css, { rel: 'stylesheet' }).catch(() => {})
|
||||||
document.head.appendChild(link)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = document.querySelector(`script[src="${cfg.js}"]`)
|
const existing = document.querySelector(`script[src="${jsUrl}"]`) || document.querySelector(`script[src="${cfg.js}"]`)
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (engineGlobal(provider)) return
|
if (engineGlobal(provider)) return
|
||||||
await new Promise(resolve => {
|
await new Promise(resolve => {
|
||||||
|
|
@ -169,14 +188,8 @@ export async function ensureEngineScript(provider) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await new Promise((resolve, reject) => {
|
await appendPinned('script', jsUrl, cfg.js, { async: false }).catch(() => {
|
||||||
const s = document.createElement('script')
|
throw new Error(`[useMapInstance] Failed to load ${provider} engine 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)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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' },
|
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 <script>/<link> for a registry entry (no-op for an unknown url). */
|
/**
|
||||||
|
* SAME-ORIGIN COPIES (the Driver, 2026-09-28: maps must load fast). generate-tree copies each file below
|
||||||
|
* from node_modules into <tree>/<path> and REFUSES a copy whose bytes do not match that entry's pin, so the
|
||||||
|
* one integrity hash guards both copies. A built page then loads the engine from the tree it already came
|
||||||
|
* from — no second origin to connect to, one cached copy for every bundle — and jsdelivr is the fallback.
|
||||||
|
* MapLibre only: Mapbox GL's licence ties it to Mapbox, whose servers a Mapbox map talks to anyway.
|
||||||
|
*/
|
||||||
|
export const VENDORED = [
|
||||||
|
{ cdn: CDN.maplibreGlJs.url, path: 'vendor/maplibre-gl@5.24.0/maplibre-gl.js', from: 'node_modules/maplibre-gl/dist/maplibre-gl.js' },
|
||||||
|
{ cdn: CDN.maplibreGlCss.url, path: 'vendor/maplibre-gl@5.24.0/maplibre-gl.css', from: 'node_modules/maplibre-gl/dist/maplibre-gl.css' },
|
||||||
|
]
|
||||||
|
|
||||||
|
/** The same-origin copy of a pinned CDN url under a built tree's root, or null (dev, or not vendored). */
|
||||||
|
export function vendoredUrl(cdnUrl, treeRoot) {
|
||||||
|
const v = VENDORED.find((x) => x.cdn === cdnUrl)
|
||||||
|
return v && treeRoot ? String(treeRoot).replace(/\/+$/, '') + '/' + v.path : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set integrity + crossorigin on a <script>/<link> for a registry entry (no-op for an unknown url). A
|
||||||
|
* same-origin copy carries its CDN entry's pin. */
|
||||||
export function pinElement(el, url) {
|
export function pinElement(el, url) {
|
||||||
const e = Object.values(CDN).find((x) => x.url === url)
|
const v = VENDORED.find((x) => String(url).endsWith('/' + x.path))
|
||||||
|
const pinUrl = v ? v.cdn : url
|
||||||
|
const e = Object.values(CDN).find((x) => x.url === pinUrl)
|
||||||
if (!e) return el
|
if (!e) return el
|
||||||
el.integrity = e.integrity
|
el.integrity = e.integrity
|
||||||
el.crossOrigin = 'anonymous'
|
el.crossOrigin = 'anonymous'
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue