diff --git a/app/L0/_all/mod/_core/framework/js/fetch-proxy.js b/app/L0/_all/mod/_core/framework/js/fetch-proxy.js index 88a9683d..5589a3a7 100644 --- a/app/L0/_all/mod/_core/framework/js/fetch-proxy.js +++ b/app/L0/_all/mod/_core/framework/js/fetch-proxy.js @@ -9,7 +9,16 @@ const FETCH_PROXY_MARKER = Symbol.for("space.fetch-proxy-installed"); const RETRYABLE_STATE_SYNC_ERROR = "Server state is still synchronizing. Retry the request."; const STATE_SYNC_RETRY_DELAY_MS = 100; const STATE_SYNC_RETRY_MAX_ATTEMPTS = 3; -const proxyFallbackOrigins = new Set(); +// Persist the proxy-fallback origin set in localStorage so a known-CORS-blocked +// origin (e.g. `https://chatgpt.com` for the OpenAI Codex provider, the +// inference-API host of a polling status widget) routes directly through +// `/api/proxy` from the *first* fetch of every page load instead of paying +// one failed-direct-fetch + DevTools-console error per session start. +// Stale entries are dropped after 7 days to let recovered upstreams (a service +// that finally adds `Access-Control-Allow-Origin`) leave the cache naturally. +const FETCH_PROXY_FALLBACK_ORIGINS_STORAGE_KEY = "space.framework.proxy-fallback-origins"; +const FETCH_PROXY_FALLBACK_ORIGIN_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const proxyFallbackOrigins = new Map(); function requestCanHaveBody(method) { return !["GET", "HEAD"].includes(String(method || "GET").toUpperCase()); @@ -44,14 +53,119 @@ function getProxyFallbackOriginKey(targetUrl) { return new URL(targetUrl, window.location.href).origin; } +function getProxyFallbackOriginStorage() { + try { + return window.localStorage; + } catch { + // Some sandboxed contexts throw on `window.localStorage` access. Falling + // back to in-memory only is acceptable here — origin caching is an + // optimisation, not a correctness requirement. + return null; + } +} + +function loadPersistedProxyFallbackOrigins() { + const storage = getProxyFallbackOriginStorage(); + + if (!storage) { + return; + } + + let raw; + try { + raw = storage.getItem(FETCH_PROXY_FALLBACK_ORIGINS_STORAGE_KEY); + } catch { + return; + } + + if (!raw) { + return; + } + + let parsed; + try { + parsed = JSON.parse(raw); + } catch { + // Corrupt entry: clear it so we don't repeatedly try to parse the same + // garbage on every page load. + try { + storage.removeItem(FETCH_PROXY_FALLBACK_ORIGINS_STORAGE_KEY); + } catch {} + return; + } + + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return; + } + + const now = Date.now(); + Object.entries(parsed).forEach(([origin, lastSeenAt]) => { + if (typeof origin !== "string" || !origin) { + return; + } + + const timestamp = Number.isFinite(Number(lastSeenAt)) ? Number(lastSeenAt) : 0; + + if (timestamp <= 0 || now - timestamp > FETCH_PROXY_FALLBACK_ORIGIN_TTL_MS) { + return; + } + + proxyFallbackOrigins.set(origin, timestamp); + }); +} + +function persistProxyFallbackOrigins() { + const storage = getProxyFallbackOriginStorage(); + + if (!storage) { + return; + } + + if (proxyFallbackOrigins.size === 0) { + try { + storage.removeItem(FETCH_PROXY_FALLBACK_ORIGINS_STORAGE_KEY); + } catch {} + return; + } + + const payload = Object.fromEntries(proxyFallbackOrigins.entries()); + + try { + storage.setItem(FETCH_PROXY_FALLBACK_ORIGINS_STORAGE_KEY, JSON.stringify(payload)); + } catch { + // Quota / disabled-storage failures are non-fatal: the in-memory Map + // still serves the rest of the page lifetime. + } +} + function hasProxyFallbackOrigin(targetUrl) { - return proxyFallbackOrigins.has(getProxyFallbackOriginKey(targetUrl)); + const origin = getProxyFallbackOriginKey(targetUrl); + const lastSeenAt = proxyFallbackOrigins.get(origin); + + if (!Number.isFinite(lastSeenAt)) { + return false; + } + + if (Date.now() - lastSeenAt > FETCH_PROXY_FALLBACK_ORIGIN_TTL_MS) { + // TTL expired since the last touch on this origin. Drop it and force the + // next fetch to re-test the upstream directly so a recovered server + // (one that finally added `Access-Control-Allow-Origin`) can leave the + // cache naturally. + proxyFallbackOrigins.delete(origin); + persistProxyFallbackOrigins(); + return false; + } + + return true; } function rememberProxyFallbackOrigin(targetUrl) { - proxyFallbackOrigins.add(getProxyFallbackOriginKey(targetUrl)); + proxyFallbackOrigins.set(getProxyFallbackOriginKey(targetUrl), Date.now()); + persistProxyFallbackOrigins(); } +loadPersistedProxyFallbackOrigins(); + function requestSupportsProxyFallback(request) { const mode = String(request.mode || "cors").toLowerCase(); return !["no-cors", "same-origin"].includes(mode); @@ -209,7 +323,10 @@ export function installFetchProxy(options = {}) { proxiedFetch.originalFetch = originalFetch; proxiedFetch.hasProxyFallbackOrigin = hasProxyFallbackOrigin; proxiedFetch.rememberProxyFallbackOrigin = rememberProxyFallbackOrigin; - proxiedFetch.clearProxyFallbackOrigins = () => proxyFallbackOrigins.clear(); + proxiedFetch.clearProxyFallbackOrigins = () => { + proxyFallbackOrigins.clear(); + persistProxyFallbackOrigins(); + }; proxiedFetch[FETCH_PROXY_MARKER] = true; window.fetch = proxiedFetch;