From 8e0ff4d86ef563d6e55cf8de418e8bd0413d14ed Mon Sep 17 00:00:00 2001 From: "Syring, Nikolas" Date: Mon, 27 Apr 2026 13:43:19 +0200 Subject: [PATCH] fix(framework): persist proxyFallbackOrigins across page loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit installFetchProxy already remembers cross-origin endpoints whose direct fetch was blocked, so subsequent calls to the same origin go straight through /api/proxy without re-trying the doomed direct fetch. The cache was an in-memory Set, which means every page load starts cold — the first call to chatgpt.com (Codex provider), to a polling status widget's external API, or to any other CORS-blocked upstream pays one failed-direct-fetch round trip and emits a red Access-Control-Allow- Origin error in the DevTools console before the wrapper transparently retries via the proxy. Persist the origin set in localStorage with a 7-day TTL so a known- CORS-blocked origin routes directly through the proxy from the first fetch of every page load. No upstream config change, no caller change, no new opt-in. The widget skill explicitly endorses bare fetch(url) for external HTTP because "the runtime already retries blocked origins through /api/proxy" — this PR makes that promise hold from the first fetch of every page load instead of only after the in-memory cache is warm. Implementation: - proxyFallbackOrigins changes from Set to Map. - loadPersistedProxyFallbackOrigins on module load reads the JSON object at localStorage["space.framework.proxy-fallback-origins"], drops stale entries (>7 days) and corrupt entries, and seeds the Map. Any storage error (sandboxed context, disabled localStorage) is silent. - persistProxyFallbackOrigins writes the current Map back as a JSON object. Removes the localStorage entry entirely when the Map is empty. - hasProxyFallbackOrigin checks lastSeenAt against the TTL and evicts stale entries on read so a recovered upstream (a service that finally added Access-Control-Allow-Origin) can leave the cache naturally. - rememberProxyFallbackOrigin writes the current timestamp; subsequent reads refresh the TTL window. Active origins stay cached indefinitely; origins not seen in 7 days fall out. - clearProxyFallbackOrigins clears localStorage too. The cache key remains the origin string returned by `new URL(...).origin`, so the existing per-origin (not per-URL) fallback semantics are preserved. No API change, no caller change. The helper functions exposed on the proxiedFetch closure keep their signatures. --- .../mod/_core/framework/js/fetch-proxy.js | 125 +++++++++++++++++- 1 file changed, 121 insertions(+), 4 deletions(-) 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;