fix(framework): persist proxyFallbackOrigins across page loads#37
Open
nsyring wants to merge 1 commit intoagent0ai:mainfrom
Open
fix(framework): persist proxyFallbackOrigins across page loads#37nsyring wants to merge 1 commit intoagent0ai:mainfrom
nsyring wants to merge 1 commit intoagent0ai:mainfrom
Conversation
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<origin> to Map<origin, lastSeenAtMs>. - 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(framework): persist
proxyFallbackOriginsacross page loadsSummary
installFetchProxy(...)already remembers cross-origin endpoints whose direct fetch was blocked, so subsequent calls to the same origin go straight through/api/proxywithout re-trying the doomed direct fetch. The cache was an in-memorySet, which means every page load starts cold — the first call tochatgpt.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 redAccess-Control-Allow-Originerror in the DevTools console before the wrapper transparently retries via the proxy.This PR persists the origin set in
localStoragewith 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 cache just grows past the page lifetime.Why
Three concrete reproducers I observed in real usage:
OpenAI Codex provider: every fresh tab logged a red CORS error for
chatgpt.com/backend-api/codex/responseson the first chat call. The Codex hook now routes viaspace.proxy.buildUrl(...)directly (PR feat: native OpenAI Codex (ChatGPT Plus) OAuth provider #22 + Codex proxy fix), so this is fixed at the call site — but it shouldn't have to be fixed at every call site; the framework's fallback cache already has the right idea.Polling status widgets: a widget that polls a local LAN inference API every 3 s logs one CORS error per page load. The widget skill (
SKILL.md) explicitly endorses barefetch(url)for external HTTP because "the runtime already retries blocked origins through/api/proxy" — but that promise only holds after the in-memory cache is warm. With the cache cold every reload, the first three seconds of widget telemetry are noisy.installFetchProxy's own retry path: every fallback retry hits the slow filesystem on Linux and surfaces as a brief UI hitch when the user changes spaces rapidly. The hitch is small but real, and orthogonal to the "is the response correct" question.In all three cases, the runtime knows the origin is CORS-blocked. Forgetting that fact when the page reloads is a regression we never wanted; the in-memory
Setjust happens to be the easy data structure.What changed
app/L0/_all/mod/_core/framework/js/fetch-proxy.js:proxyFallbackOriginsdata structure changes fromSet<origin>toMap<origin, lastSeenAtMs>. The Map keys are still origin strings; the values are timestamps used for TTL eviction.loadPersistedProxyFallbackOrigins()is called on module load. It reads the JSON object atlocalStorage["space.framework.proxy-fallback-origins"], drops stale entries (>7 days since last touch) and corrupt entries, and seeds the in-memory Map. Any storage error (sandboxed context, disabled localStorage) is silent — origin caching is an optimisation, not a correctness requirement, and the in-memory Map still serves the page lifetime.persistProxyFallbackOrigins()writes the current Map back as a JSON object. Called fromrememberProxyFallbackOrigin(...)after a new origin is added and fromclearProxyFallbackOrigins(). Removes the localStorage entry entirely when the Map is empty.hasProxyFallbackOrigin(targetUrl)now checks the entry'slastSeenAtagainst the TTL. Stale entries are evicted on read so a recovered upstream (a service that finally addedAccess-Control-Allow-Origin) can leave the cache naturally without manual intervention.rememberProxyFallbackOrigin(targetUrl)writes the current timestamp; subsequent reads refresh the TTL window. So an actively-used origin stays cached indefinitely; an origin that hasn't been used in 7 days falls out.clearProxyFallbackOrigins()(already exposed on the proxiedFetch closure for callers that want to force-refresh) now clears localStorage too.The cache key remains the origin string returned by
new URL(targetUrl, window.location.href).origin, so the existing semantics of "fall back per origin, not per URL path" are preserved.No API change, no caller change, no new public surface. The helper functions on the proxiedFetch closure (
hasProxyFallbackOrigin,rememberProxyFallbackOrigin,clearProxyFallbackOrigins) keep their signatures.Storage shape
Date.now()of the most recent read OR write that touched the originlastSeenAt. Values older than that are silently dropped.Behavior matrix
/api/proxysucceedsSetnever expires) until tab closeclearProxyFallbackOrigins()calledTest plan
node --check app/L0/_all/mod/_core/framework/js/fetch-proxy.jspassesnpm run desktop:packbuild:localStorageclean: first call tochatgpt.comlogs the expected CORS error once; the next page reload is silent (cache hit on first fetch)localStorage["space.framework.proxy-fallback-origins"]is populated after a successful retry-via-proxy and stripped of stale entries on next readOut of scope (possible follow-ups)
space.proxy.preregisterOrigin(url)API for callers that know an upstream is CORS-blocked at module load time and want to skip even the first failed direct fetch. Useful for known-third-party APIs (Codexchatgpt.com, OpenAI Platform). The current PR is sufficient to eliminate the console noise, and apreregisterAPI would be additive on top of the persistence layer.space.proxy.getFallbackOriginStats()to see hit rates and TTL bucketing.changeevents). If one tab notices a recovered upstream, no other open tab knows. Acceptable for this PR — TTL closes the gap eventually — but worth noting.Relationship to prior PRs
space.proxy.buildUrl(...)routing is still correct (the proxy is the canonical path for known-Cloudflare-blocked endpoints), but the fallback cache no longer "forgets" the origin between page loads, so any Codex-style upstream gets the benefit automatically./api/file_*404 console entries, this addresses cross-origin CORS console entries. Different surfaces, complementary fixes.🤖 Generated with Claude Code