doxa-maps/library/composables/useSharedMapToken.js

147 lines
7.9 KiB
JavaScript

/**
* useSharedMapToken.js — ONE page, ONE map token, shared by every element on it.
*
* THE BUG THIS ENDS (Driver, 2026-09-23; the repo already names it
* BUG-COMPOSITE-LEAVES-ARE-TOKEN-FREE-BUT-NEST-REAL-MAPS):
* "the element gets the tk to the element itself and does not need to only gatekeep that to one
* component only — all components in a plugin can share that tk"
*
* Inside ONE element that was already true: ProfileLoader provides 'mapboxToken' and eleven
* components inject it. The gate is the ELEMENT BOUNDARY. Every custom element mounts its own Vue
* app with its own ProfileLoader, which re-derives the token from ITS OWN profile-config — so a
* composite that nests a real map hands the child no `tk` and the child starts from nothing.
*
* ★ AND THE FALLBACK THAT WAS SUPPOSED TO COVER THAT COULD NOT FIRE. ProfileLoader's ladder already
* read `window.MAP_TOKEN` after config.tk. MEASURED on the built tree: window.MAP_TOKEN IS set on
* the composite leaf — and the child STILL had no search bar. The reason is that the ladder lives
* in a computed() and window.MAP_TOKEN is a PLAIN GLOBAL, not a reactive source. The generated
* composite leaf FETCHES its token (one fetch( in the page), so the computed evaluates to '' long
* before the token lands, caches that, and never re-runs — nothing reactive ever changed.
* So the symptom looked like "no token" while the token was right there, seconds later. A grep
* for the fallback would have said the fallback existed; only reading WHEN it ran explains it.
*
* THE FIX: make the page-level token a REACTIVE, CROSS-ELEMENT fact.
* - publishMapToken(tk) stores it and announces it on `window`.
* - useSharedMapToken() returns a ref that is seeded from whatever is already known and UPDATES
* when a later publish happens — so an async token reaches elements that mounted before it.
* - Assignments to `window.MAP_TOKEN` are adopted automatically (see installBridge), so every
* ALREADY-GENERATED page keeps working untouched: the leaf pages assign that global today and
* now that assignment broadcasts instead of landing silently.
*
* ★ WHY AN EVENT AND NOT A SHARED MODULE REF. Each bundle is its own IIFE with its own copy of this
* module, so a module-level ref is NOT shared between <research-map-plus-grid> and the
* <doxa-research-map> it nests — they are different bundles. The storage therefore lives on
* `window` and changes travel as a DOM event, which is the only channel both copies can see.
*
* SECURITY: this shares a PUBLIC Mapbox token (pk.*) that is already in the page's HTML and in
* every tile request. It does not widen exposure — it stops a second element on the SAME page from
* being unable to use what that page was already given. Nothing is persisted or sent anywhere.
*/
import { ref, onBeforeUnmount } from 'vue'
const KEY = '__doxaMapToken' // the value, on window, so every bundle sees one copy
const EVENT = 'doxa:map-token' // fired when it changes
const FLAG = '__doxaMapTokenBridge'
const readStored = () => {
if (typeof window === 'undefined') return ''
const v = window[KEY]
return typeof v === 'string' ? v : ''
}
/** Announce a token to every element on the page. No-ops on an empty or unchanged value. */
export function publishMapToken(tk) {
if (typeof window === 'undefined') return ''
installBridge()
const next = typeof tk === 'string' ? tk.trim() : ''
if (!next || next === readStored()) return readStored()
window[KEY] = next
try { window.dispatchEvent(new CustomEvent(EVENT, { detail: next })) } catch { /* pre-CustomEvent host */ }
return next
}
/**
* Withdraw the page-wide token (a "Clear" in the studio). publishMapToken('') is deliberately a no-op, and
* `delete window.MAP_TOKEN` removed the bridge's accessor while its install flag stayed set, so the bridge
* never came back and the OLD token stayed live. This empties the value through the same channel.
*/
export function clearMapToken() {
if (typeof window === 'undefined') return
const had = readStored() || (typeof window.MAP_TOKEN === 'string' && window.MAP_TOKEN)
installBridge()
window[KEY] = ''
if (!Object.getOwnPropertyDescriptor(window, 'MAP_TOKEN')?.get) { try { window.MAP_TOKEN = '' } catch { /* host-owned */ } }
if (had) { try { window.dispatchEvent(new CustomEvent(EVENT, { detail: '' })) } catch { /* pre-CustomEvent host */ } }
}
/**
* Adopt `window.MAP_TOKEN` as a source, in BOTH directions and for the whole life of the page.
*
* Every generated leaf assigns `window.MAP_TOKEN`, some of them from an async fetch. Replacing that
* property with an accessor means such an assignment publishes to every element automatically —
* which is what makes this fix reach pages nobody regenerates. Installed once per bundle; guarded
* because N bundles on one page would otherwise stack N accessors on the same property.
*/
function installBridge() {
if (typeof window === 'undefined') return
// Installed AND still in place: a host (the studio's old Clear did) can `delete window.MAP_TOKEN`, which
// removes the accessor while the flag stays set — then nothing was ever adopted again. Re-install.
if (window[FLAG] && Object.getOwnPropertyDescriptor(window, 'MAP_TOKEN')?.get) return
// Seed from whatever the page already set before this bundle loaded.
const existing = typeof window.MAP_TOKEN === 'string' ? window.MAP_TOKEN : ''
if (existing && !readStored()) window[KEY] = existing
try {
Object.defineProperty(window, 'MAP_TOKEN', {
configurable: true,
get() { return readStored() },
set(v) { publishMapToken(v) },
})
window[FLAG] = true
} catch {
// A host may have made MAP_TOKEN non-configurable. Reading it still works through the ladder
// below, so degrade to "no live adoption" rather than throwing during bundle init.
}
}
/**
* A reactive view of the page's shared map token.
* @returns {import('vue').Ref<string>} updates when any element publishes a token
*/
export function useSharedMapToken() {
installBridge()
const token = ref(readStored())
if (typeof window !== 'undefined') {
const onChange = () => { token.value = readStored() }
window.addEventListener(EVENT, onChange)
// A composable may be called outside a component (rare here); guard so it cannot throw.
try { onBeforeUnmount(() => window.removeEventListener(EVENT, onChange)) } catch { /* not in setup */ }
}
return token
}
/**
* profile-config.tokenUrl: a token SERVICE the element asks when it was handed no token (the studio's
* header and the gallery write it; nothing used to read it). Same contract as the leaf pages' ladder:
* JSON {token|tk|accessToken} or a bare string, and only a PUBLIC token (pk.…) is accepted. One fetch per
* URL per page; the answer is published page-wide. While it is in flight, window.__doxaMapTokenPending
* holds the promise, so a map about to start on Mapbox waits for it instead of failing tokenless.
* @returns {Promise<string>} the token, or '' when the service did not give one
*/
export function fetchMapToken(url) {
if (typeof window === 'undefined' || !/^https?:\/\//i.test(String(url || ''))) return Promise.resolve('')
const cache = (window.__doxaMapTokenFetches = window.__doxaMapTokenFetches || {})
if (cache[url]) return cache[url]
const p = fetch(url)
.then((r) => { if (!r.ok) throw new Error('HTTP ' + r.status); return (r.headers.get('content-type') || '').includes('json') ? r.json() : r.text() })
.then((d) => {
const tk = String((typeof d === 'string' ? d : d && (d.token || d.tk || d.accessToken)) || '').trim()
if (!tk.startsWith('pk.')) return ''
publishMapToken(tk)
return tk
})
.catch((e) => { console.warn(`[map token] ${url}: ${e && e.message ? e.message : e}`); return '' })
.finally(() => { if (window.__doxaMapTokenPending === p) window.__doxaMapTokenPending = null })
window.__doxaMapTokenPending = p
cache[url] = p
return p
}