Merge branch 'bundler-2/driver-1650' into bundler-2/handoff
# Conflicts: # internal/check-embed-url-absolute.mjs # internal/check-legend-top-offset.mjs # internal/check-map-menu-anchor.mjs # internal/check-mercator-slice.mjs # internal/check-one-scroll-container.mjs # library/components/map-controls/MapControlMenu.vue # library/composables/poster/useMapPoster.js # library/composables/usePosterPrints.js # library/maps/mercator-slice.js # library/utils/embedBase.js # package.json # plugin-designs/doxa-research-map/research-map.vue
This commit is contained in:
commit
6156895e54
11 changed files with 172 additions and 7 deletions
100
internal/check-retry-fetch.mjs
Normal file
100
internal/check-retry-fetch.mjs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* check-retry-fetch — a runtime data fetch survives a transport blip, and a real error still fails.
|
||||
*
|
||||
* THE DEFECT (Driver, 2026-09-28): net::ERR_NETWORK_CHANGED killed an embedded UPG-100's load.
|
||||
*
|
||||
* ★ THE URLs IN HIS CONSOLE CANNOT BE FIXED, BECAUSE THEY DO NOT EXIST IN PRODUCTION. They were module
|
||||
* URLs — locale JSON with ?import, useOptionalT.js, useShadowStyles.js, useMapTheme.js. The build is
|
||||
* IIFE with inlineDynamicImports (vite.config.js:216-220), so every one of those is inside the single
|
||||
* bundle and is never fetched separately. Those failures are dev-server module requests. Wrapping them
|
||||
* in a retry is impossible AND unnecessary; saying so is the useful part of this card.
|
||||
*
|
||||
* ★ THE FAILURE CLASS IS REAL ONE LAYER DOWN, and that is what this guards. A built bundle still makes
|
||||
* runtime requests — boundaries geojson, the people-group list, prayer statistics — and none of them
|
||||
* retried. The same blip takes out whichever was in flight, and the feature is simply ABSENT: an empty
|
||||
* map, a missing outline, a statistic that never arrives, with nothing a reader could act on.
|
||||
*
|
||||
* Exit 0 pass · 1 fail.
|
||||
*/
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve, dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const { retryFetch } = await import(resolve(ROOT, 'library/composables/retryFetch.js'))
|
||||
const fails = []
|
||||
const ok = (w, c, d = '') => { if (!c) fails.push(`${w}${d ? `\n ${d}` : ''}`) }
|
||||
const realFetch = globalThis.fetch
|
||||
|
||||
// ── behaviour ────────────────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
let n = 0
|
||||
globalThis.fetch = async () => { if (++n < 3) throw new TypeError('Failed to fetch'); return { ok: true, attempt: n } }
|
||||
const r = await retryFetch('u', undefined, { backoff: () => 1 })
|
||||
ok('a transport blip is survived', r.attempt === 3, `resolved on attempt ${r.attempt}`)
|
||||
}
|
||||
{
|
||||
// ★ COUNT THE ATTEMPTS, do not merely assert that it throws. An unbounded loop with a short backoff
|
||||
// ALSO throws eventually, so "it failed in the end" passes a retry policy that hammers a dead server
|
||||
// forever — caught by this gate's own negative test, which is the only reason the weak version did
|
||||
// not ship. The property is BOUNDED give-up, and the bound is the number.
|
||||
let calls = 0
|
||||
globalThis.fetch = async () => { calls++; throw new TypeError('Failed to fetch') }
|
||||
let threw = false
|
||||
try { await retryFetch('u', undefined, { backoff: () => 1 }) } catch { threw = true }
|
||||
ok('a persistently dead endpoint still FAILS — retrying forever would hide real breakage', threw)
|
||||
ok('...after a BOUNDED number of attempts, not indefinitely', calls === 3, `fetch called ${calls}x, expected 3`)
|
||||
calls = 0
|
||||
try { await retryFetch('u', undefined, { attempts: 5, backoff: () => 1 }) } catch { /* expected */ }
|
||||
ok('...and the bound is the caller\'s, when they state one', calls === 5, `fetch called ${calls}x, expected 5`)
|
||||
}
|
||||
{
|
||||
// A 404/500 RESOLVES. It must be handed back untouched, not retried: the server said something.
|
||||
let calls = 0
|
||||
globalThis.fetch = async () => { calls++; return { ok: false, status: 404 } }
|
||||
const r = await retryFetch('u', undefined, { backoff: () => 1 })
|
||||
ok('a 404 is returned, not retried', r.status === 404 && calls === 1, `fetch called ${calls}x`)
|
||||
}
|
||||
{
|
||||
// An abort is a DECISION, never a blip.
|
||||
let calls = 0
|
||||
globalThis.fetch = async () => { calls++; const e = new Error('aborted'); e.name = 'AbortError'; throw e }
|
||||
let threw = false
|
||||
try { await retryFetch('u', undefined, { backoff: () => 1 }) } catch (e) { threw = e.name === 'AbortError' }
|
||||
ok('an abort is not retried', threw && calls === 1, `fetch called ${calls}x`)
|
||||
}
|
||||
globalThis.fetch = realFetch
|
||||
|
||||
// ── and the shared data paths actually use it ────────────────────────────────────────────────────
|
||||
// ★ ONE SITE IS DELIBERATELY EXEMPT AND IS NAMED, not silently skipped. usePosterPrints probes for an
|
||||
// OPTIONAL pre-rendered .webp and falls through to generating the poster when it is absent — a miss is
|
||||
// the expected path, so retrying would delay the fallback for an asset that is legitimately not there.
|
||||
// Everything else in that file, and every other runtime request, must retry.
|
||||
const EXEMPT = new Map([['library/composables/usePosterPrints.js', 1]])
|
||||
for (const rel of [
|
||||
'library/composables/useMapLayers.js',
|
||||
'library/composables/useCountryOutline.js',
|
||||
'library/lists/peopleGroupList.js',
|
||||
'library/composables/usePrayerStatistics.js',
|
||||
'library/composables/usePosterPrints.js',
|
||||
'library/composables/useSharedMapToken.js',
|
||||
'library/composables/useCsvExport.js',
|
||||
'library/components/PeopleGroupDetail.vue',
|
||||
]) {
|
||||
const src = readFileSync(join(ROOT, rel), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
|
||||
const bare = (src.match(/(?<![A-Za-z.])fetch\s*\(/g) || []).length
|
||||
const allowed = EXEMPT.get(rel) || 0
|
||||
if (bare > allowed) {
|
||||
fails.push(`${rel} has ${bare} bare fetch() call(s) for a runtime request, ${allowed} allowed — a transport\n`
|
||||
+ ' blip there leaves the feature silently absent. Use retryFetch().')
|
||||
}
|
||||
}
|
||||
|
||||
if (fails.length) {
|
||||
console.error('RETRY-FETCH: FAIL')
|
||||
for (const f of fails) console.error(` ✗ ${f}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log('RETRY-FETCH: PASS — a blip is survived, a dead endpoint still fails, a 404 and an abort are not '
|
||||
+ 'retried, and the shared data paths all go through it.')
|
||||
|
|
@ -108,6 +108,7 @@
|
|||
</template>
|
||||
|
||||
<script setup>
|
||||
import { retryFetch } from '@map/composables/retryFetch.js'
|
||||
import { computed, ref, watch, inject } from 'vue';
|
||||
import { markPrayedLocally } from '../composables/usePrayedLocally.js';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
|
@ -247,7 +248,7 @@ async function fetchDetail(slugVal) {
|
|||
// click and routed to the slow WP origin, making images load 10+ seconds.
|
||||
// Live counts (prayer / committed / adopted) come from /list which IS
|
||||
// cache-busted in DataSourceManager — so freshness stays intact.
|
||||
const res = await fetch(`${baseUrl}/api/people-groups/detail/${encodeURIComponent(slugVal)}?lang=${encodeURIComponent(locale.value)}`);
|
||||
const res = await retryFetch(`${baseUrl}/api/people-groups/detail/${encodeURIComponent(slugVal)}?lang=${encodeURIComponent(locale.value)}`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
detailData.value = await res.json();
|
||||
} catch (err) {
|
||||
|
|
|
|||
56
library/composables/retryFetch.js
Normal file
56
library/composables/retryFetch.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* retryFetch.js — fetch(), with the same transient-blip retry the lazy components already get.
|
||||
*
|
||||
* ★ THE DEFECT AND WHAT IT IS NOT. The Driver hit net::ERR_NETWORK_CHANGED on an embedded UPG-100 and
|
||||
* the load died. The URLs in his console were module URLs — locale JSON with ?import, useOptionalT.js,
|
||||
* useShadowStyles.js, useMapTheme.js — which look like lazy loads that need retrying. They are not: the
|
||||
* production build is IIFE with inlineDynamicImports (vite.config.js:216-220), so every one of those
|
||||
* modules is INSIDE the single bundle and is never fetched separately. Those failures are dev-server
|
||||
* module requests and cannot happen for a reader.
|
||||
*
|
||||
* ★ BUT THE FAILURE CLASS IS REAL IN PRODUCTION, one layer down. A built bundle still makes runtime
|
||||
* requests — the boundaries geojson, the people-group list, the prayer statistics, a detail lookup, a
|
||||
* token service — and not one of them retried. The same wifi switch, VPN flap or sleep-wake that killed
|
||||
* the Driver's dev load takes out whichever of those happened to be in flight, and the feature is simply
|
||||
* absent: an empty map, a missing outline, a statistic that never arrives. No error a reader can act on.
|
||||
*
|
||||
* So this is the fetch-shaped sibling of retryableAsyncComponent, deliberately with the SAME policy —
|
||||
* 3 attempts, growing backoff — because it is the same event being survived and two different answers
|
||||
* to "how long is a blip" would be worse than one.
|
||||
*
|
||||
* ★ IT RETRIES TRANSPORT FAILURES ONLY. A rejected fetch() is a network-layer failure; a 404 or a 500
|
||||
* RESOLVES, and retrying those would hide real breakage and hammer a struggling server. A 5xx is
|
||||
* arguably transient, but it is the server saying something, and this helper deliberately does not
|
||||
* second-guess it — the caller sees the response it would have seen.
|
||||
*/
|
||||
|
||||
const DEFAULT_ATTEMPTS = 3
|
||||
const DEFAULT_BACKOFF = (attempt) => attempt * 500
|
||||
|
||||
/**
|
||||
* @param {string|Request} input
|
||||
* @param {RequestInit} [init]
|
||||
* @param {Object} [opts]
|
||||
* @param {number} [opts.attempts] total tries, including the first
|
||||
* @param {function} [opts.backoff] (attempt) => ms
|
||||
* @param {AbortSignal} [opts.signal] abort ends it immediately, without further attempts
|
||||
* @returns {Promise<Response>} the response, or the LAST transport error if every attempt failed
|
||||
*/
|
||||
export async function retryFetch(input, init = undefined, { attempts = DEFAULT_ATTEMPTS, backoff = DEFAULT_BACKOFF, signal = null } = {}) {
|
||||
let lastError
|
||||
for (let attempt = 1; attempt <= Math.max(1, attempts); attempt++) {
|
||||
if (signal?.aborted) throw signal.reason ?? new Error('aborted')
|
||||
try {
|
||||
return await fetch(input, init)
|
||||
} catch (err) {
|
||||
// An ABORT is a decision, not a blip: never retry one.
|
||||
if (err?.name === 'AbortError' || signal?.aborted) throw err
|
||||
lastError = err
|
||||
if (attempt >= attempts) break
|
||||
await new Promise((r) => setTimeout(r, backoff(attempt)))
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
|
||||
export default retryFetch
|
||||
|
|
@ -31,6 +31,7 @@
|
|||
* outline.clearCountry()
|
||||
*/
|
||||
|
||||
import { retryFetch } from './retryFetch.js'
|
||||
import { resolveAssetsBase } from '../utils/embedBase.js'
|
||||
import { emissivePaint } from './useMapProvider.js'
|
||||
|
||||
|
|
@ -161,7 +162,7 @@ export function useCountryOutline(getMap, opts = {}) {
|
|||
// Add EMPTY first, then setData after the fetch, so addLayer below stays SYNCHRONOUS and
|
||||
// the outline keeps its place in the layer order.
|
||||
m.addSource(SOURCE_ID, { type: 'geojson', data: { type: 'FeatureCollection', features: [] } })
|
||||
fetch(`${resolveAssetsBase(null)}/boundaries/world-adm0-web.geojson`)
|
||||
retryFetch(`${resolveAssetsBase(null)}/boundaries/world-adm0-web.geojson`)
|
||||
.then((r) => r.json())
|
||||
.then((gj) => {
|
||||
// The tileset names the ISO field iso_3166_1_alpha_3; the geojson names it iso3.
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
* The result is cached for the session; if the fetch fails we still export the loaded record
|
||||
* (24 fields) rather than producing nothing — degraded, never broken.
|
||||
*/
|
||||
import { retryFetch } from './retryFetch.js'
|
||||
import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
|
||||
import { inject } from 'vue'
|
||||
import sourcesConfig from '@map/api/sources.json'
|
||||
|
|
@ -145,7 +146,7 @@ export function useCsvExport({ rowStore, rows, selectionSet, filterSet, FACET_AC
|
|||
const bulk = src.endpoints?.bulk || '/api/people-groups/list'
|
||||
const lang = src.queryParams?.lang || 'en'
|
||||
const url = `${getApiBaseUrl()}${bulk}?fields=${fields.join(',')}&lang=${lang}`
|
||||
const res = await fetch(url, { cache: 'no-store' })
|
||||
const res = await retryFetch(url, { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`export list HTTP ${res.status}`)
|
||||
const json = await res.json()
|
||||
// Only the groups of this page's people-group LIST (library/lists/) are exported.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// Vue 3 Composition API - ES Module with NPM packages
|
||||
|
||||
// Import Vue functions from NPM package
|
||||
import { retryFetch } from './retryFetch.js'
|
||||
import { ref, toRaw } from 'vue';
|
||||
import { resolveAssetsBase } from '../utils/embedBase.js'
|
||||
import { emissivePaint } from './useMapProvider.js';
|
||||
|
|
@ -434,7 +435,7 @@ export function useMapLayers(options = {}) {
|
|||
// EMPTY first so the addLayer below stays synchronous and keeps its z-order slot
|
||||
// (regions → shadow → pins → hitbox); setData once the fetch lands.
|
||||
map.addSource('regions', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
|
||||
fetch(`${resolveAssetsBase(null)}/boundaries/world-adm0-web.geojson`)
|
||||
retryFetch(`${resolveAssetsBase(null)}/boundaries/world-adm0-web.geojson`)
|
||||
.then((r) => r.json())
|
||||
.then((gj) => {
|
||||
// Tileset field is iso_3166_1_alpha_3; the geojson's is iso3. Normalise ONCE
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
// is the mechanism asked for by name: no new browser window, the print dialog
|
||||
// opening directly in the page.
|
||||
|
||||
import { retryFetch } from './retryFetch.js'
|
||||
import { ref } from 'vue';
|
||||
import { openPrintFrame } from './printInPlace.js';
|
||||
import { fetchPosterRaster, fetchPosterSource } from '../packet/poster-fetch.js';
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@
|
|||
* Uses the same shared data config as the rest of the app (sources.json activeSource
|
||||
* endpoint + getApiBaseUrl); no DOM, shadow-DOM safe.
|
||||
*/
|
||||
import { retryFetch } from './retryFetch.js'
|
||||
import { ref } from 'vue'
|
||||
import { getApiBaseUrl } from '../api/apiBaseUrl.js'
|
||||
import sourcesConfig from '../api/sources.json'
|
||||
|
|
@ -49,7 +50,7 @@ async function load() {
|
|||
const src = sourcesConfig?.sources?.[sourcesConfig.activeSource] || {}
|
||||
const base = getApiBaseUrl()
|
||||
const endpoint = src.endpoints?.statistics || '/api/people-groups/statistics'
|
||||
const res = await fetch(`${base}${endpoint}`, { cache: 'no-store' })
|
||||
const res = await retryFetch(`${base}${endpoint}`, { cache: 'no-store' })
|
||||
if (!res.ok) throw new Error(`statistics HTTP ${res.status}`)
|
||||
const d = await res.json()
|
||||
total.value = Number(d.total || 0)
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
* 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 { retryFetch } from './retryFetch.js'
|
||||
import { ref, onBeforeUnmount } from 'vue'
|
||||
|
||||
const KEY = '__doxaMapToken' // the value, on window, so every bundle sees one copy
|
||||
|
|
@ -130,7 +131,7 @@ 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)
|
||||
const p = retryFetch(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()
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@
|
|||
*
|
||||
* Pure except for the fetch; safe outside a Vue setup().
|
||||
*/
|
||||
import { retryFetch } from '../composables/retryFetch.js'
|
||||
import registry from './lists.json'
|
||||
import { resolveAssetsBase } from '../utils/embedBase.js'
|
||||
|
||||
|
|
@ -90,7 +91,7 @@ const membership = new Map()
|
|||
export function loadListMembership(id, profileConfig) {
|
||||
const url = `${resolveAssetsBase(unwrapConfig(profileConfig))}/people-groups/lists/${id}.json`
|
||||
if (!membership.has(url)) {
|
||||
membership.set(url, fetch(url)
|
||||
membership.set(url, retryFetch(url)
|
||||
.then((r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json() })
|
||||
.then((d) => new Set(Array.isArray(d?.slugs) ? d.slugs : []))
|
||||
.catch((e) => { membership.delete(url); throw new Error(`list "${id}" membership ${url}: ${e.message}`) }))
|
||||
|
|
|
|||
|
|
@ -285,6 +285,7 @@
|
|||
"check:mercator-slice": "node internal/check-mercator-slice.mjs",
|
||||
"check:embed-url-absolute": "node internal/check-embed-url-absolute.mjs",
|
||||
"check:grid-image-loading": "node internal/check-grid-image-loading.mjs",
|
||||
"check:retry-fetch": "node internal/check-retry-fetch.mjs",
|
||||
"check:setup-order": "node internal/check-setup-order.mjs",
|
||||
"check:sfc-parse": "node internal/check-sfc-script-parses.mjs",
|
||||
"check:shadow-css-parses": "node internal/check-shadow-css-parses.mjs",
|
||||
|
|
|
|||
Loading…
Reference in a new issue