Server-side cache and single-flight refresher for :proxy-mode app bundles.
Each fetched bundle is held in a :public ETS table keyed by its upstream URL,
together with its validators (ETag / Last-Modified), the upstream
Cache-Control, and a computed freshness window. A fresh read never touches
this process — it's a direct, concurrent ETS read straight from the plug (bundle
bodies are large refc binaries, so ETS shares them by reference rather than
copying), so the hot path stays as fast as a static file. Only a stale (or
missing) entry routes through the GenServer, where concurrent refreshes for the
same URL are collapsed into a single upstream request (no cache stampede).
Freshness
When an entry goes stale it is revalidated, not blindly re-downloaded: the
refresh sends a conditional GET (If-None-Match / If-Modified-Since). A
304 Not Modified just bumps the timestamp and keeps the cached bytes; a 200
replaces them. How long an entry stays fresh (Apps.proxy_opts/1):
respect_upstream: true(default) — the upstreamCache-Control: max-age(ors-maxage) sets the window;no-cache/no-storeforces revalidation on every request. When the origin says nothing, the:ttlfallback applies.- a per-app
immutable: truepins the entry (never revalidated server-side). - a
freshness: fn ctx -> seconds endconfig hook overrides everything.
This is what makes the proxy correct for unversioned upstreams
(cdn/app.js with no version in the path): they can't be cache-busted by URL,
so the proxy polls/revalidates them on the :ttl cadence instead.
Fetching & TLS
The built-in fetcher is Erlang's :httpc. Because its bytes end up running
same-origin in users' browsers, the fetch leg is locked down: TLS certificates
are verified against the system trust store (verify: :verify_peer with
hostname checking) and upstream redirects are not followed
(autoredirect: false, closing an SSRF path where a 30x could point the fetch
at an internal address). Override the TLS options with :proxy_cache
:ssl_options (e.g. a custom CA bundle), or replace the client entirely — and
its policy — via the :app_provider hook (see KeenPhoenixSvelte.Apps.Proxy).
Memory
Entries are keyed by upstream URL, so a refresh upserts in place — the table
never grows from re-fetching. The only growth is orphaned URLs: ones cached
before the registry pointed a name at a new URL (or dropped the app). A periodic
sweep (:proxy_cache :sweep_interval, default 1h; set false to disable)
evicts any cached URL no longer in KeenPhoenixSvelte.Apps.registered/0, so a
rotating DB-driven registry stays bounded to its current working set.
Summary
Functions
Returns a specification to start this module under a supervisor.
Return a cache entry for url, fetching or revalidating upstream if the cached
copy is missing or stale. opts is a resolved Apps.proxy_opts/1 map. On an
upstream error a still-cached (stale) copy is served fail-open.
The set of file paths an app's manifest lists (its servable allowlist), fetched
and cached like any bundle and parsed once per content change. url is the
resolved manifest source, opts the app's Apps.proxy_opts/1.
Ceiling on concurrent in-flight upstream fetches (:proxy_cache
:max_concurrent_fetches, default 32). Read at boot by the
Task.Supervisor and per-refresh by the GenServer.
Types
Functions
Returns a specification to start this module under a supervisor.
See Supervisor.
Return a cache entry for url, fetching or revalidating upstream if the cached
copy is missing or stale. opts is a resolved Apps.proxy_opts/1 map. On an
upstream error a still-cached (stale) copy is served fail-open.
The set of file paths an app's manifest lists (its servable allowlist), fetched
and cached like any bundle and parsed once per content change. url is the
resolved manifest source, opts the app's Apps.proxy_opts/1.
@spec max_concurrent_fetches() :: pos_integer()
Ceiling on concurrent in-flight upstream fetches (:proxy_cache
:max_concurrent_fetches, default 32). Read at boot by the
Task.Supervisor and per-refresh by the GenServer.