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:
parent
d126a9fb23
commit
06209dc45e
5 changed files with 125 additions and 107 deletions
50
internal/check-pinned-umd.mjs
Normal file
50
internal/check-pinned-umd.mjs
Normal 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.")
|
||||
62
library/utils/loadPinnedUmd.js
Normal file
62
library/utils/loadPinnedUmd.js
Normal 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
|
||||
}
|
||||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue