One pinned-UMD loader for PPLR and the Wall: library/utils/loadPinnedUmd.js

pplr-data-maps and the Wall each carried the same ~40-line d3 loader (pinned, SRI, and a trap that keeps
the UMD from writing into a host page's own window.d3). It is now one library helper, fed from the
CDN registry. check:pinned-umd (headless, gates:post): the tag carries the pinned integrity, a host's d3
gains no keys, and a failed load rejects leaving the host's d3 in place. Both bundles build.
This commit is contained in:
Doxa Maps 2026-09-26 06:49:33 +00:00
parent d126a9fb23
commit 06209dc45e
5 changed files with 125 additions and 107 deletions

View file

@ -0,0 +1,50 @@
#!/usr/bin/env node
/**
* check-pinned-umd — library/utils/loadPinnedUmd.js loads a pinned UMD library WITHOUT touching the host
* page's global of the same name, and sets integrity + crossorigin. Headless: a stand-in document whose
* <script> "executes" a d3-style UMD body (`(t = globalThis).d3 = t.d3 || {}; t.d3.x = …`).
* CASES host has no d3 · host owns an object d3 (must gain NO keys) · host has a non-configurable var d3
* · a failed load rejects and leaves the host's d3 as it was · the tag carries the pinned SRI.
* EXIT 0 pass · 1 fail
*/
let bad = 0
const check = (name, ok, got) => { if (!ok) bad++; console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ' — ' + JSON.stringify(got)}`) }
const PIN = { url: 'https://cdn.example/d3.min.js', integrity: 'sha384-TEST' }
let fail = false, lastTag = null
function page() {
const doc = { currentScript: null, head: { appendChild(s) {
lastTag = s
setTimeout(() => {
if (fail) return s.onerror && s.onerror()
doc.currentScript = s
const t = globalThis; t.d3 = t.d3 || {}; t.d3.version = '7.9.0'; t.d3.select = () => 'ours' // the UMD body
doc.currentScript = null
s.onload && s.onload()
}, 0)
} }, createElement: () => ({}) }
globalThis.window = globalThis
globalThis.document = doc
}
const fresh = async () => (await import(`../library/utils/loadPinnedUmd.js?${Math.random()}`)).loadPinnedUmd
page(); delete globalThis.d3
let lib = await (await fresh())(PIN, 'd3')
check('no host d3: returns the library', lib && lib.select() === 'ours', lib)
check('no host d3: leaves no d3 on the page', globalThis.d3 === undefined, globalThis.d3)
check('the tag carries integrity + crossorigin', lastTag.integrity === PIN.integrity && lastTag.crossOrigin === 'anonymous', lastTag)
page(); const host = { version: '6.0.0', mine: true }; globalThis.d3 = host
lib = await (await fresh())(PIN, 'd3')
check('host owns a d3: gets our library', lib && lib.version === '7.9.0', lib)
check('host owns a d3: its object gained NO keys', Object.keys(host).join() === 'version,mine' && host.version === '6.0.0', host)
check('host owns a d3: window.d3 is still the host\'s', globalThis.d3 === host, globalThis.d3)
page(); delete globalThis.d3; fail = true; globalThis.d3 = host
let rejected = false
try { await (await fresh())(PIN, 'd3') } catch { rejected = true }
check('failed load rejects', rejected, rejected)
check('failed load leaves the host d3 in place', globalThis.d3 === host, globalThis.d3)
fail = false
if (bad) { console.log(`PINNED-UMD: FAIL — ${bad} case(s).`); process.exit(1) }
console.log("PINNED-UMD: PASS — a pinned UMD loads with SRI and never touches the host page's global.")

View file

@ -0,0 +1,62 @@
/**
* loadPinnedUmd — load a PINNED, integrity-checked UMD library and capture it WITHOUT touching the host
* page's global of the same name. One copy of what pplr-data-maps (60faff1a) and the Wall carried twice.
*
* WHY THE TRAP (measured by rega on PPLR): a UMD build such as d3's writes its exports INTO an existing
* global — `(t = globalThis).d3 = t.d3 || {}` — so a host that owns a d3 had ours written over it (577
* added keys, version '7.9.0' on the host's object) even though we "put it back" afterwards. So while OUR
* script executes (document.currentScript === the tag), `window[globalName]` reads and writes a private
* slot; any other code, at every moment, reads and writes the host's own value. A non-configurable
* `var` global cannot be trapped: it is emptied for the load and restored after.
*
* @param {{url: string, integrity: string}} pin an entry of library/constants/cdn.js
* @param {string} globalName the name the UMD assigns (e.g. 'd3')
* @returns {Promise<any>} the library, cached per url; rejects on network/integrity failure
*/
const cache = new Map()
export function loadPinnedUmd(pin, globalName) {
if (typeof window === 'undefined' || typeof document === 'undefined') return Promise.reject(new Error('no window'))
if (cache.has(pin.url)) return cache.get(pin.url)
const p = new Promise((resolve, reject) => {
const s = document.createElement('script')
s.src = pin.url
s.integrity = pin.integrity
s.crossOrigin = 'anonymous'
const own = Object.getOwnPropertyDescriptor(window, globalName)
let hostValue = window[globalName]
let ours
let trapped = false
if (!own || (own.configurable && 'value' in own)) {
Object.defineProperty(window, globalName, {
configurable: true, enumerable: own ? own.enumerable : true,
get: () => (document.currentScript === s ? ours : hostValue),
set: (v) => { if (document.currentScript === s) ours = v; else hostValue = v },
})
trapped = true
} else if (own.writable) {
window[globalName] = undefined
}
const restore = () => {
if (trapped) {
delete window[globalName]
if (own || hostValue !== undefined) {
Object.defineProperty(window, globalName, { value: hostValue, writable: own ? own.writable : true,
configurable: true, enumerable: own ? own.enumerable : true })
}
} else if (own && own.writable) {
window[globalName] = hostValue
}
}
s.onload = () => {
const got = trapped ? ours : window[globalName]
restore()
if (got) resolve(got)
else { cache.delete(pin.url); reject(new Error(`${globalName} loaded but exported nothing (an AMD loader on the page?)`)) }
}
s.onerror = () => { restore(); cache.delete(pin.url); reject(new Error(`${globalName} failed to load (network or integrity)`)) }
document.head.appendChild(s)
})
cache.set(pin.url, p)
return p
}

View file

@ -138,6 +138,7 @@
"check:packet-strings-parity": "node internal/check-packet-strings-parity.mjs",
"check:packets-all": "node internal/check-packets-all.mjs",
"check:parse": "node internal/inline-parse-check.mjs",
"check:pinned-umd": "node internal/check-pinned-umd.mjs",
"check:popup-escaping": "node internal/check-popup-escaping.mjs",
"check:poster-card-fits": "node internal/check-poster-card-fits.mjs",
"check:poster-degrades": "node internal/check-poster-degrades-honestly.mjs",
@ -198,7 +199,7 @@
"dev": "bun x vite",
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:done-evidence check:script-flags check:backlog-status check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:backlog-refs check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts",
"library-index": "node internal/generate-library-index.mjs",
"lists": "node library/scripts/people-groups/build-lists.mjs",
@ -328,6 +329,7 @@
"check:packet-strings-parity": "the PACKET catalog (packet-strings.json) must be whole in every language: all string keys, all 12 prayer points with every field, all 12 wheel rhythms. check:i18n-parity covers library/i18n/locales only — six gates READ this file and none checked it. Asserts PRESENCE, not translatedness, with point4_passage declared-absent on record.",
"check:packets-all": "runs check:packet-render over EVERY generated packet, not one hand-named file. The single-file gate SKIPs with no argument, so it was in package.json, passing in all 11 locales, and in neither gates:pre nor gates:post — it only ran when someone typed a filename. SKIPs when no packets are generated.",
"check:pin-dim-slider": "the side menu's pin-dim slider and the legend's dimming must COMPOSE, not compete. Both want the pin layer's circle-opacity, so there is ONE writer and the value is the PRODUCT: slider x (1 matched, 0 dimmed). Asserts the LIVE paint property, not the store: flat 1 with clustering off, flat 0.5 with clustering on, ['case', expr, 0.5, 0] once something is dimmed, and ['case', expr, 1, 0] when the slider returns to 1. Dims by RELIGION deliberately - a region selection sets a layer FILTER rather than an opacity expression, so testing that path would report a failure about code that never writes opacity.",
"check:pinned-umd": "library/utils/loadPinnedUmd.js (d3 for PPLR and the Wall) loads a pinned UMD with SRI and never touches the host page's global of the same name: a host's own d3 gains no keys, a failed load leaves it in place. Headless.",
"check:popup-escaping": "the library's pin and region popups render hostile data (an <img onerror>, a <script>, a javascript: URL) as text, never markup: every field goes through escapeHtml/safeUrl (library/utils/html.js). Headless.",
"check:poster-card-fits": "a poster card's print-options row must stay inside its own card. Reported by the repo agent off a 1440px screenshot: 27/27 cards overflowing by 110px, 36 neighbour intrusions. .pfs already had width:82%, so SOURCE READING SAID IT WAS FINE — flex items default to min-width:auto and refuse to shrink. Asserts BOTH no-overlap AND that the controls keep a usable box, because a row whose buttons collapsed to nothing would also \"not overflow\". Sweeps 390/768/1024/1440/1920px and asserts the row sits BELOW the preview too — measuring only left/right edges is how it passed a driver-visible regression. CARD_FIT_WIDTH overrides the list. SKIPs when nothing is served.",
"check:poster-degrades": "with NEITHER poster nor source — the DEFAULT drop — nothing must print and the reader must be told why. check:poster-print-fallback SKIPs that case, so the behaviour a reader most often meets had no gate. MUST be served from the TREE ROOT, not public/, or assetsBase resolves outside the tree and the poster loads when it should 404.",

View file

@ -7,6 +7,7 @@
* pplr/<file>. The files are derived by ../scripts/build-data.mjs into data/pplr/.
*/
import { CDN, pinElement } from '@map/constants/cdn.js'
import { loadPinnedUmd } from '@map/utils/loadPinnedUmd.js'
let base = './assets'
export function setDataBase(b) { if (b) base = String(b).replace(/\/+$/, '') }
export function dataUrl(file) { return base + '/pplr/' + file }
@ -16,63 +17,13 @@ export function dataUrl(file) { return base + '/pplr/' + file }
// window.d3, overwriting a host page's own d3, or running on whatever d3 the host already had. Now one
// exact version with integrity + crossorigin, captured here (pplrD3()), and the host's window.d3
// put back exactly as it was.
const D3_URL = 'https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js'
const D3_SRI = 'sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i'
let d3Lib = null
let d3Promise = null
export function pplrD3() { return d3Lib }
// d3 7.9.0, pinned with SRI and kept off the host's window.d3: the shared loader
// (library/utils/loadPinnedUmd.js) that this file and the Wall each used to carry a copy of.
export function ensureD3() {
if (d3Lib) return Promise.resolve(d3Lib)
if (typeof window === 'undefined') return Promise.reject(new Error('no window'))
if (!d3Promise) d3Promise = new Promise((res, rej) => {
const s = document.createElement('script')
s.src = D3_URL
s.integrity = D3_SRI
s.crossOrigin = 'anonymous'
// ★ PUTTING THE HOST'S window.d3 BACK IS NOT ENOUGH. d3's UMD build writes its exports INTO an
// existing window.d3 — `(t = globalThis).d3 = t.d3 || {}` — so a host that owns a d3 had ours
// written over it before we restored it: rega measured 577 added keys and version '7.9.0' on a
// host's own object (a host on d3 v6 would get v7 functions). So while OUR script executes
// (document.currentScript === s), window.d3 reads and writes a fresh slot of ours; any other code,
// at every moment, reads and writes the host's own value. Nothing is blanked while the file loads.
const own = Object.getOwnPropertyDescriptor(window, 'd3')
let hostD3 = window.d3
let ours
let trapped = false
if (!own || (own.configurable && 'value' in own)) {
Object.defineProperty(window, 'd3', {
configurable: true, enumerable: own ? own.enumerable : true,
get: () => (document.currentScript === s ? ours : hostD3),
set: (v) => { if (document.currentScript === s) ours = v; else hostD3 = v },
})
trapped = true
} else if (own.writable) {
// A `var d3` global is non-configurable, so it cannot be trapped: empty it for the load instead
// (the host reads undefined until onload), which still keeps our exports out of its object.
window.d3 = undefined
}
const restore = () => {
if (trapped) {
delete window.d3
if (own || hostD3 !== undefined) {
Object.defineProperty(window, 'd3', { value: hostD3, writable: own ? own.writable : true,
configurable: true, enumerable: own ? own.enumerable : true })
}
} else if (own && own.writable) {
window.d3 = hostD3
}
}
s.onload = () => {
const lib = trapped ? ours : window.d3
restore()
d3Lib = lib
if (d3Lib) res(d3Lib)
else { d3Promise = null; rej(new Error('d3 loaded but exported nothing (an AMD loader on the page?)')) }
}
s.onerror = () => { restore(); d3Promise = null; rej(new Error('d3 failed to load (network or integrity)')) }
document.head.appendChild(s)
})
return d3Promise
return loadPinnedUmd(CDN.d3, 'd3').then((d3) => (d3Lib = d3))
}
// The 17 locale names per PG, split out of pg-points so the first paint stays small. Loaded on

View file

@ -5,66 +5,19 @@
* as a script tag, so it (a) could change underneath every partner without notice and (b) became
* `window.d3`, overwriting a host page's own d3, or, if the host already had one, the Wall ran on
* whatever version the host shipped. Now: one exact version, `integrity` + `crossorigin`, and the
* library is captured into this module and the host's `window.d3` put back exactly as it was.
* library is captured into this module and the host's `window.d3` put back exactly as it was. The
* loader itself is shared with pplr-data-maps: library/utils/loadPinnedUmd.js.
*/
const D3_URL = 'https://cdn.jsdelivr.net/npm/d3@7.9.0/dist/d3.min.js'
const D3_SRI = 'sha384-CjloA8y00+1SDAUkjs099PVfnY2KmDC2BZnws9kh8D/lX1s46w6EPhpXdqMfjK6i'
import { CDN } from '@map/constants/cdn.js'
import { loadPinnedUmd } from '@map/utils/loadPinnedUmd.js'
let lib = null
let loading = null
/** The Wall's own d3 (null until ensureD3() has resolved). */
export function wallD3() { return lib }
/** d3 7.9.0, pinned with SRI, kept off the host's window.d3 (library/utils/loadPinnedUmd.js). */
export function ensureD3() {
if (lib) return Promise.resolve(lib)
if (typeof window === 'undefined') return Promise.reject(new Error('no window'))
if (!loading) loading = new Promise((resolve, reject) => {
const s = document.createElement('script')
s.src = D3_URL
s.integrity = D3_SRI
s.crossOrigin = 'anonymous'
// ★ PUTTING THE HOST'S window.d3 BACK IS NOT ENOUGH. d3's UMD build writes its exports INTO an
// existing window.d3 (`(t = globalThis).d3 = t.d3 || {}`), so a host that owns a d3 had ours
// written over it before we restored it (rega measured it on PPLR: 577 added keys, version
// '7.9.0' on the host's object). While OUR script executes (document.currentScript === s),
// window.d3 reads and writes a fresh slot of ours; any other code, at every moment, sees the
// host's own value. Same fix as pplr-data-maps (60faff1a), which rega re-ran PASS in 3 variants.
const own = Object.getOwnPropertyDescriptor(window, 'd3')
let hostD3 = window.d3
let ours
let trapped = false
if (!own || (own.configurable && 'value' in own)) {
Object.defineProperty(window, 'd3', {
configurable: true, enumerable: own ? own.enumerable : true,
get: () => (document.currentScript === s ? ours : hostD3),
set: (v) => { if (document.currentScript === s) ours = v; else hostD3 = v },
})
trapped = true
} else if (own.writable) {
// A `var d3` global is non-configurable, so it cannot be trapped: empty it for the load instead.
window.d3 = undefined
}
const restore = () => {
if (trapped) {
delete window.d3
if (own || hostD3 !== undefined) {
Object.defineProperty(window, 'd3', { value: hostD3, writable: own ? own.writable : true,
configurable: true, enumerable: own ? own.enumerable : true })
}
} else if (own && own.writable) {
window.d3 = hostD3
}
}
s.onload = () => {
const got = trapped ? ours : window.d3
restore()
lib = got
if (lib) resolve(lib)
else { loading = null; reject(new Error('d3 loaded but exported nothing (an AMD loader on the page?)')) }
}
s.onerror = () => { restore(); loading = null; reject(new Error('d3 failed to load (network or integrity)')) }
document.head.appendChild(s)
})
return loading
return loadPinnedUmd(CDN.d3, 'd3').then((d3) => (lib = d3))
}