The app registry: where each island's compiled bundle is loaded from, and how it is delivered to the browser.
By default apps are local — AppsManager imports /apps/<name>/main.mjs from
your own static path and nothing here is needed. Register an app here only when
its bundle lives elsewhere (a shared CDN, another team's deploy, a
database-driven catalogue).
Mode of operation — :direct vs :proxy
For a registered (external) app you choose who fetches the bytes:
:direct— the browserimport()s the configured URL straight from the CDN. Fewest moving parts; the CDN must send CORS headers and its origin must be allowed by your CSPscript-src.:proxy— the browser imports a same-origin path on your Phoenix app, and the server fetches the real bundle upstream (seeKeenPhoenixSvelte.Apps.Proxy). No CORS,script-src 'self'is enough, and you can gate/patch/verify the bundle — the corporate-friendly default.
The mode only changes which URL the client imports; the client itself is oblivious. Local apps are never affected.
Configuration
config :keen_phoenix_svelte,
# your app, so local apps under priv/static/<base_path> are detected and
# merged into the manifest (enables `preload` + server-side visibility).
otp_app: :my_app,
# global default mode for registered apps
load_mode: :proxy,
# where proxied bundles are served from (must match your router forward).
# Defaults to the same "/apps" prefix as base_path: local bundles are
# static files at /apps/<name>/main.mjs (served by Plug.Static, which runs
# before the router), proxied bundles resolve at /apps/<name> via the
# forward — the two coexist under one prefix.
proxy_path: "/apps",
# local apps load from here
base_path: "/apps",
# server-side proxy cache (see `KeenPhoenixSvelte.Apps.Proxy`)
proxy_cache: [
ttl: :timer.minutes(5), # freshness fallback when the origin sends no cache directives
respect_upstream: true, # honor upstream Cache-Control / ETag / Last-Modified
# the browser-facing Cache-Control for a normal (non-immutable) bundle…
client_cache_control: "public, max-age=60, stale-while-revalidate=300",
# …and the one emitted for a per-app `immutable: true` bundle (defaults to
# "public, max-age=31536000, immutable" if unset).
immutable_cache_control: "public, max-age=31536000, immutable",
# abuse bounds for base/dir apps: cap concurrent upstream fetches, and
# briefly remember an upstream "not found" so a flood of guaranteed-miss
# sub-paths isn't re-fetched every request (set to 0 to disable).
max_concurrent_fetches: 32,
negative_ttl: :timer.seconds(10),
# how often orphaned URLs (no longer in the registry) are swept from the
# cache; `false` disables the sweep.
sweep_interval: :timer.hours(1),
# TLS options for the built-in :httpc fetcher. Defaults to verifying the
# origin cert against the system trust store (`verify: :verify_peer` +
# hostname check); override to pass a custom CA bundle or relax/tighten.
ssl_options: [verify: :verify_peer]
],
apps: %{
# a plain URL uses the global load_mode:
"org-chart" => "https://cdn.acme.com/islands/org-chart@1.4.2/main.mjs",
# or override per app (per-app `ttl`/`immutable`/`client_cache_control`
# tune the proxy cache; a per-app `client_cache_control:` string wins over
# everything):
"report" => %{url: "https://reports.internal/report/main.mjs", mode: :direct},
"pinned" => %{url: "https://cdn.acme.com/pinned@2.0.0/main.mjs", immutable: true},
"hourly" => %{url: "https://cdn.acme.com/hourly/main.mjs", client_cache_control: "public, max-age=3600"},
# a multi-file bundle (JS + CSS + assets) — give a `base:` directory and
# (optionally) the `entry:` the client imports (defaults to "main.mjs").
# Any sub-path is proxied: `/apps/player/player.css` → `<base>/player.css`.
# A `manifest:` (inline list, or a bundle file path to a JSON/text list)
# restricts serving to exactly the files it names — any other sub-path is
# a 404 with no upstream fetch.
"player" => %{
base: "https://cdn.acme.com/player@3/",
entry: "player.mjs",
manifest: "manifest.json"
},
# a LOCAL directory on disk (e.g. a mounted volume another process writes
# to). `entry:` is a glob; the newest match wins, so a content-hashed
# bundle (`bundle.a1b2c3.js`) resolves without knowing the hash. Served
# same-origin through the proxy, cached/revalidated like any other source
# (the file's mtime is the validator, the `:ttl` the re-scan cadence).
# Local only — you can't glob a URL.
"dash" => %{dir: "/srv/apps/dash", entry: "bundle.*.js", ttl: :timer.seconds(30)}
}The registry can just as well come from a database — build the same map at
runtime and set it with Application.put_env/3; it's read on every request.
Summary
Functions
Base path local (unregistered) apps load from.
The default delivery mode for registered apps (:direct or :proxy).
Names of local apps — subdirectories of the built static apps dir that
contain a main.mjs. Needs no per-app registration (they're discovered by
folder), but the library must be told where to look via
config :keen_phoenix_svelte, otp_app: :my_app (its priv/static/<base_path>),
or an explicit :apps_static_path. Returns [] when neither is set.
The client manifest — %{name => url_the_browser_imports} — merging detected
local apps with registered ones.
The resolved proxy-cache options for name, merging the per-app registry entry
over the global :proxy_cache config. Consumed by KeenPhoenixSvelte.Apps.Proxy.
Same-origin base path proxied bundles are served under (matches the router forward).
The registered apps, normalized to %{name => %{url, mode, ttl, immutable, client_cache_control}}.
Resolve the proxy request path (the segments after the forward prefix) to
{name, upstream_url, sub_path}, or nil if nothing matches.
The upstream URL the proxy should fetch for a single-file app, or nil if unknown/not proxied.
Functions
@spec base_path() :: String.t()
Base path local (unregistered) apps load from.
@spec load_mode() :: :direct | :proxy
The default delivery mode for registered apps (:direct or :proxy).
@spec local_apps() :: [String.t()]
Names of local apps — subdirectories of the built static apps dir that
contain a main.mjs. Needs no per-app registration (they're discovered by
folder), but the library must be told where to look via
config :keen_phoenix_svelte, otp_app: :my_app (its priv/static/<base_path>),
or an explicit :apps_static_path. Returns [] when neither is set.
The client manifest — %{name => url_the_browser_imports} — merging detected
local apps with registered ones.
Emitted into the page by <KeenPhoenixSvelte.runtime> and read by AppsManager.
Two sources are collected into one map:
- local apps (
local_apps/0) — folders built topriv/static/<base_path>/<name>/main.mjs. Each maps to its convention URLbase_path/<name>/main.mjs(the same URL the client would fall back to). - registered apps (
registered/0) — the:appsconfig, whose URL points elsewhere (CDN:direct, same-origin:proxy, a base-path or localdir:).
Registered entries override local ones on a name clash (so registering an app
to a CDN wins over a stray same-named folder). For a registered app the URL is:
in :proxy mode a same-origin proxy_path/<name> (base-path app:
proxy_path/<name>/<entry>); in :direct mode the configured CDN URL (or
<base>/<entry>).
Local detection requires config :keen_phoenix_svelte, otp_app: :my_app so the
library can locate your static dir; without it only registered apps appear (the
client still resolves local apps by the same convention — the manifest entry only
adds them to preload and server-side visibility).
@spec proxy_opts(String.t()) :: %{ ttl_ms: non_neg_integer(), respect_upstream: boolean(), immutable: boolean(), client_cache_control: String.t() | nil, global_cache_control: String.t() | nil, immutable_cache_control: String.t() | nil, freshness: (map() -> non_neg_integer()) | nil }
The resolved proxy-cache options for name, merging the per-app registry entry
over the global :proxy_cache config. Consumed by KeenPhoenixSvelte.Apps.Proxy.
@spec proxy_path() :: String.t()
Same-origin base path proxied bundles are served under (matches the router forward).
The registered apps, normalized to %{name => %{url, mode, ttl, immutable, client_cache_control}}.
Resolve the proxy request path (the segments after the forward prefix) to
{name, upstream_url, sub_path}, or nil if nothing matches.
- a base-path app matches on its first segment; the remaining segments are
appended to its
:base(empty → the app's:entry), so a whole directory of files (player.mjs,player.css, fonts…) proxies through one registration. - a single-file app matches when the entire path equals its name.
Path traversal (..) and empty/./backslash segments are rejected.
The upstream URL the proxy should fetch for a single-file app, or nil if unknown/not proxied.