KeenPhoenixSvelte.Apps.ProxyCache (KeenPhoenixSvelte v1.0.0-rc.9)

Copy Markdown View Source

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 upstream Cache-Control: max-age (or s-maxage) sets the window; no-cache/no-store forces revalidation on every request. When the origin says nothing, the :ttl fallback applies.
  • a per-app immutable: true pins the entry (never revalidated server-side).
  • a freshness: fn ctx -> seconds end config 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

entry()

@type entry() :: %{
  body: binary() | nil,
  etag: String.t() | nil,
  last_modified: String.t() | nil,
  cache_control: String.t() | nil,
  fetched_at: integer(),
  fresh_for: non_neg_integer(),
  negative: term()
}

Functions

child_spec(init_arg)

Returns a specification to start this module under a supervisor.

See Supervisor.

get(url, opts)

@spec get(String.t(), map()) :: {:ok, entry()} | {:error, term()}

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.

manifest_set(url, opts)

@spec manifest_set(String.t(), map()) ::
  {:ok, MapSet.t(String.t())} | {:error, term()}

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.

max_concurrent_fetches()

@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.

start_link(opts)