By default an island is local: AppsManager imports /apps/<name>/main.mjs
from your own static path and you configure nothing. Register an app only when
its compiled bundle lives elsewhere — a shared CDN, another team's deploy, or
a database-driven catalogue.
This guide is about where the bytes come from. It assumes the bundle is already an island — it follows the mount contract and is built to be embeddable. A third-party bundle that owns the page (auto-mounts, absolute asset paths, page globals) can't be proxied into an island; see Island-able vs page-owning apps for how to tell, and what to do instead.
How an app's URL is resolved
Two sources feed one name → url manifest, and the client reads it to decide
where to import() each bundle. Local apps are discovered by folder at build
time; registered apps come from config (registered wins on a name clash):
flowchart TD
A["assets/apps folders"] -->|Vite build| B["priv/static/apps main.mjs"]
B -.->|otp_app scan| M
C["config apps: registered"] --> M["Apps.manifest — name to url"]
M -->|runtime component| K["keen-apps JSON in page"]
K --> R{"name in manifest?"}
R -->|yes| U["import manifest url"]
R -->|no| F["import /apps/name/main.mjs"](local folders and registered config merge into one manifest — registered wins on
a name clash; the "no" branch is the convention fallback when :otp_app is unset.)
The fallback exists so local apps work even without :otp_app — the client
derives the same /apps/<name>/main.mjs URL by convention. Setting :otp_app just
lifts local apps into the manifest, so they're visible server-side (for
preload and tooling)
instead of only being resolvable by the naming convention.
When the bundle actually loads
Without preloading the bundle is fetched lazily — the import() runs in the
hook's mounted(), which on a LiveView can't happen until the socket connects and
the view mounts, so the download starts well into the page load. By default
<KeenPhoenixSvelte.runtime> preloads the bundles for the islands on this page
(preload={:auto}), moving the download (not the render) up to initial HTML
parse:
sequenceDiagram
participant B as Browser
participant P as Phoenix
B->>P: GET page
P-->>B: HTML + keen-apps manifest + modulepreload (auto)
Note over B: parse HTML
opt preload on (default for on-page apps)
B->>P: modulepreload fetches bundle in parallel
end
B->>P: connect LiveView socket
P-->>B: join, mount, patch DOM
Note over B: KeenApp.mounted runs
B->>P: import bundle (instant if preloaded)
Note over B: mount, first renderPreloading is automatic and page-scoped: each
<.app>records itself as it renders and<.runtime>emits<link rel="modulepreload">for exactly those bundles. Opt out withpreload={false}, or override with an explicit list — seeruntime/1.
First render: LiveView vs a plain page
Preloading moves the download early, but on a LiveView the render still waits for the socket. It helps to separate the two loads a first LiveView hit does:
- Dead render (HTTP). The first request is plain HTTP. The LiveView runs
mount/3(connected?(socket) == false) andrender/1on the server and returns a complete HTML page — painted immediately. Each<.app>div is in that HTML with its<:placeholder>skeleton, because the wrapper is server-rendered andphx-update="ignore". The island itself is not here: this library does no SSR, so the Svelte app is client-only. - Connected render (WebSocket).
liveSocket.connect()opens the socket, the server runsmount/3again (connected?(socket) == true) and patches the DOM. Only after that patch do hooks fire — soKeenApp.mounted()(and thus the islandimport()+ first paint) happens here, not on the initial HTML.
So on a LiveView page the user sees a full page with a placeholder instantly, and the island content swaps in after the socket connects. That connect + mount round-trip — not the bundle, which preloading already has cached — is the bulk of the delay between "bundle loaded" and "first render".
A plain (non-LiveView) page has the same dead render, but no connected phase:
mountStatic() swaps the placeholder for the island the
instant the deferred app.js parses — no socket to wait for. Same app, same
bytes, earlier paint. The island mounts with live: null
and reaches the server over api / channel instead.
The example app demonstrates the difference directly: /proxying (a LiveView)
and /proxying-plain (a plain controller page) render the same two islands
with an in-browser load-stats panel, so you can compare the first render delta
side by side. The island that needs the live bridge pays for the connect; one
that doesn't can live on a plain page and skip it.
Eager mounting: paint before connect, on a LiveView
You don't have to move an island to a plain page to skip the connect wait.
<.app eager> mounts the island on
a LiveView page via the same early mountStatic() path — it paints at app.js
parse time, then the hook upgrades it with the live bridge once the socket
connects (firing keen:live-ready).
So you get the plain-page first-paint and keep live:
<.app name="dashboard" id="dash" eager props={%{unit: @unit}} />| mode | first paint | live at first paint |
|---|---|---|
| default | after socket connect | yes (liveStatus: "ready") |
eager | at app.js parse | no — arrives on connect ("pending" → keen:live-ready) |
When it's safe (and when it isn't). Eager suits an island whose first render
doesn't depend on live — e.g. one that fetches from api, joins a channel, or
calls a different server entirely (its own REST endpoint, a CDN, a third-party
API). Those transports are available immediately, connect or not, so mounting
early costs nothing. An island that must pushEvent to this LiveView just to
draw its first frame should stay on the default path (or show a loader while
liveStatus === "pending"). On a plain page eager is a no-op — there is no
live there at all (liveStatus: "none").
The /eager page in the example mounts the same island both ways side by side,
with the load-stats panel, so you can watch the eager copy paint first and turn
"live" a moment later.
The one thing that changes: the URL
AppsManager.resolve(name) decides where to import a bundle from. The server
emits a name → url manifest into the page (via
<KeenPhoenixSvelte.runtime>, read from #keen-apps); registered apps import
from that URL, everything else falls back to the base path. So the entire
external-app story is "put the right URL in the manifest."
config :keen_phoenix_svelte,
apps: %{
"org-chart" => "https://cdn.acme.com/islands/org-chart@1.4.2/main.mjs"
}<.app name="org-chart" id="org-chart" props={%{unit: @unit_id}} />The registry is just data — build the same map from a database at runtime and set
it with Application.put_env(:keen_phoenix_svelte, :apps, map); it's read per
request.
Mode of operation: :direct vs :proxy
Because import(url) runs in the browser, the URL decides who fetches the
bytes. That's the only real choice, and it's a config value:
config :keen_phoenix_svelte,
load_mode: :proxy, # :direct (default) | :proxy
proxy_path: "/apps" # must match your router forward (defaults to /apps):direct | :proxy | |
|---|---|---|
| Who fetches | Browser → CDN | Browser → your app → CDN |
| Manifest URL | the CDN URL | "/apps/<name>" (same-origin) |
| CORS | required on the CDN | none |
| CSP | must allow the CDN in script-src | script-src 'self' |
| Auth / gating / SRI | hard (public, cross-origin) | easy (you serve it) |
| Server load | none (CDN edge-caches) | in the path → cached + revalidated (ProxyCache) |
:proxy is the corporate-friendly default: it turns a cross-origin bundle into a
first-party asset, sidestepping CORS and a strict CSP. It needs the proxy plug:
# router.ex
forward "/apps", KeenPhoenixSvelte.Apps.ProxyKeenPhoenixSvelte.Apps.Proxy resolves /apps/<name> to the registered
upstream URL, fetches it (built-in :httpc, or an injectable :app_provider),
and caches it in ETS — see KeenPhoenixSvelte.Apps.ProxyCache.
The cache revalidates rather than pinning forever: a stale entry triggers a
single-flight conditional GET (If-None-Match/If-Modified-Since), so a 304
costs nothing and a 200 swaps the bytes in. Freshness follows the upstream's
own Cache-Control/ETag when present, and a :ttl (default 5 min) otherwise —
so this works for unversioned upstreams (.../server-status.js) that can't be
busted by URL, not just versioned ones. Tune it globally or per app:
config :keen_phoenix_svelte,
proxy_cache: [
ttl: :timer.minutes(5), # fallback when the origin sends no cache directives
respect_upstream: true, # honor upstream Cache-Control / ETag / Last-Modified
client_cache_control: "public, max-age=60, stale-while-revalidate=300",
# what `immutable: true` emits (defaults to a 1-year immutable string):
immutable_cache_control: "public, max-age=31536000, immutable"
],
apps: %{
# a truly versioned, immutable URL can skip revalidation entirely:
"org-chart" => %{url: "https://cdn.acme.com/org-chart@1.4.2/main.mjs", immutable: true},
# a bare, unversioned file is re-checked every minute:
"status" => %{url: "https://cdn.acme.com/status.js", ttl: :timer.minutes(1)},
# an explicit per-app browser Cache-Control wins over everything:
"hourly" => %{url: "https://cdn.acme.com/hourly/main.mjs", client_cache_control: "public, max-age=3600"}
}The browser-facing Cache-Control is resolved most-specific-first: a per-app
client_cache_control: string, else a per-app immutable: true (emitting the
global immutable_cache_control, or a built-in 1-year immutable string), else the
global client_cache_control, else the built-in default. The proxy also forwards
an ETag and answers the browser's If-None-Match with a 304, so the browser →
Phoenix → origin conditional chain revalidates cheaply end-to-end.
Per-app override, if some apps should stay direct:
apps: %{
"org-chart" => %{url: "https://cdn.acme.com/.../main.mjs", mode: :direct},
"report" => "https://reports.internal/report/main.mjs" # uses load_mode
}Multi-file bundles: a base-path app
Some bundles aren't a single self-contained module — a vendor player might ship a
JS entry plus a separate stylesheet, fonts, or images. Point the registry at
the upstream directory with base: (and, if the entry isn't main.mjs, an
entry:), and every sub-path proxies through one registration:
config :keen_phoenix_svelte,
load_mode: :proxy,
apps: %{
"player" => %{base: "https://cdn.acme.com/player@3/", entry: "player.mjs"}
}The client imports the entry (/apps/player/player.mjs in :proxy mode), and any
sibling the bundle references re-serves same-origin under the same prefix:
| Request | Proxied to upstream |
|---|---|
/apps/player/player.mjs | https://cdn.acme.com/player@3/player.mjs |
/apps/player/player.css | https://cdn.acme.com/player@3/player.css |
/apps/player/fonts/x.woff2 | https://cdn.acme.com/player@3/fonts/x.woff2 |
Each file is typed by its extension (.mjs/.js → text/javascript, .css →
text/css, otherwise MIME), while JS is always forced to a module-friendly type
regardless of what the origin reports. .. and other unsafe segments are rejected
before any upstream fetch. Every file shares the app's cache/revalidation and
ttl / immutable / client_cache_control settings. A single-file app (url:)
is unchanged — it's just the one-file case.
A bundle whose entry doesn't ESM-
export defaulta mount function (e.g. a UMD build that setswindow.SomethingCreate) still can't mount directly — write a thin adapter entry that imports the vendor files (now same-origin) and adapts them to(target, opts) => { setProps, destroy }. Base-path proxying is what gets all those files delivered.
A code-split app (many chunks)
The same base-path registration handles your own app when it's big enough to
code-split — a dashboard that lazy-loads routes, say, whose build emits
main.mjs plus a pile of hashed chunk-*.js, CSS, and assets. Register the
output directory, not the single entry file:
config :keen_phoenix_svelte,
load_mode: :proxy,
apps: %{
# remote: the whole build directory on a CDN…
"huge-app-3000" => %{base: "https://cdn.acme.com/huge-app-3000@2/", entry: "main.mjs"},
# …or local: a directory the Phoenix node can see (entry may be a glob).
"dash" => %{dir: "/srv/apps/dash", entry: "main.mjs"}
}The one thing that makes or breaks it is where the chunk URLs point. The
browser requests each chunk at whatever URL your bundler baked into the entry, so
that URL has to resolve back under /apps/huge-app-3000/ for the proxy to see it:
- Set the bundler's base to the app's proxy prefix — Vite
base: "/apps/huge-app-3000/"(Rollupoutput.dir+ a matching public path). Then a dynamicimport("./chunk-x.js")fetches/apps/huge-app-3000/chunk-x.js, whichresolve/1matches on the first segment and proxies to<base>/chunk-x.js. - Or keep references relative to the module —
new URL("./chunk-x.js", import.meta.url)— which resolves against the served location automatically, no build-time base needed.
If instead the build hard-codes an absolute path (/assets/…) or a different
origin, those requests never reach this proxy and 404 (or leak cross-origin).
Registering the app single-file (url:) has the same effect — only the exact name
resolves, so every chunk 404s. base: / dir: is what opens the whole prefix.
Once the URLs line up, nothing else is special: each chunk is typed by its
extension, rejected if it contains .., and shares the one registration's
cache — its ttl / immutable / client_cache_control. In :direct mode the
chunks load straight from the CDN relative to main.mjs instead (no proxy in the
path) — same code-splitting, just cross-origin.
Restricting to a manifest (manifest:)
A base:/dir: app proxies any sub-path under its prefix — including ones
that don't exist. That's not a security hole (the host is fixed by config; .. is
rejected), but an unauthenticated client can still make you fetch a stream of
guaranteed-miss paths (/apps/huge-app-3000/does-not-exist-N.js). Two bounds are
always on: concurrent upstream fetches are capped (proxy_cache: [max_concurrent_fetches: 32]), and a definitive upstream 404 is briefly
remembered (negative_ttl, default 10s) so the same miss isn't re-fetched.
To close it completely, hand the app the exact list of files it ships — then
anything else is a 404 decided locally, before any upstream fetch:
apps: %{
# inline — you know the file set up front:
"player" => %{base: "https://cdn.acme.com/player@3/",
manifest: ["player.mjs", "player.css", "fonts/inter.woff2"]},
# or point at a file in the bundle — fetched + cached like any asset, re-parsed
# only when it changes:
"huge-app-3000" => %{base: "https://cdn.acme.com/huge-app-3000@2/",
manifest: "keen-manifest.json"}
}Every entry is a sub-path relative to the app's base: (the part after
/apps/<name>/), matched exactly after normalization (leading ./ or / and
surrounding whitespace are stripped; nested folders like assets/fonts/x.woff2
are fine at any depth). The declared entry: and the manifest file itself are
always allowed even if absent. If the manifest can't be loaded it fails open
(reverts to the always-on bounds above), so a transient CDN blip never 404s your
whole app.
A manifest: string is fetched, cached like any asset, and re-parsed only when it
changes. The parser accepts three shapes, auto-detected:
- flat JSON array —
["main.mjs", "assets/x.js"]. This is the custom format; generate it with the plugin below. - Vite
manifest.json— the object Vite emits withbuild.manifest: true. All string leaves (file/css/assets/…) are pulled out automatically, so you can point straight at.vite/manifest.jsonwith no transformation. - newline text — one path per line,
#comments and blank lines ignored.
Which format? — the public/ gap
Vite's own manifest.json only lists what's in the module graph: entry
chunks, code-split chunks, and assets imported from JS/CSS. Files Vite copies
verbatim from public/ (favicons, fonts you drop in, images referenced only
by a runtime-built URL) never appear in it — so a strict Vite manifest would 404
them. If your app serves any public/ assets, use the custom format instead:
it's generated by scanning the finished output directory, so it captures
everything that ships, graph or not.
Generating the custom manifest
Ship it from your build with the bundled Vite plugin — it walks outDir in the
final closeBundle hook (after public/ is copied) and writes a sorted flat
array to keen-manifest.json:
// assets/apps.vite.config.js
import { defineConfig } from "vite"
import { appConfig } from "@keenmate/phoenix_svelte/vite"
import { keenManifest } from "@keenmate/phoenix_svelte/vite/manifest"
export default defineConfig(({ mode }) => {
const config = appConfig({ appName: process.env.SVELTE_APP, mode })
config.plugins.push(keenManifest()) // → outDir/keen-manifest.json
return config
})keenManifest({ fileName }) takes an optional output name (default
"keen-manifest.json"). Register the app with the matching
manifest: "keen-manifest.json" and you're done — the paths it emits already
match the sub-path shape the proxy compares against.
Local folder: a content-hashed bundle on a volume
Sometimes the bundle isn't remote at all — it's dropped onto a filesystem the
Phoenix node can see: a mounted volume (/apps in a container) that a separate
process rebuilds and replaces. If that process emits a content-hashed entry
(bundle.a1b2c3.js, the usual Vite output), you can't hard-code the name — it
changes every build. A dir: app resolves it by glob instead:
config :keen_phoenix_svelte,
apps: %{
# `entry:` is a glob; the NEWEST match wins, so a rebuilt hash is picked up
# without anyone knowing it. Served same-origin through the proxy plug.
"dash" => %{dir: "/srv/apps/dash", entry: "bundle.*.js", ttl: :timer.seconds(30)}
}The client imports a bare, stable /apps/dash (never the hashed name); the proxy
globs the directory at request time, serves the newest file, and any sibling the
bundle references (import.meta.url → /apps/dash/chunk.xyz.js) is served as a
literal file under the same prefix.
Crucially this reuses the same cache as the URL proxy — there's no second
mechanism. The only thing that changes is the source: instead of a conditional
HTTP GET, freshness is a File.stat, and the file's signature (name + mtime +
size) plays the role the upstream ETag plays for a URL. Unchanged file → the
equivalent of a 304, cached bytes kept; a newer file → new bytes + a fresh
weak ETag. The :ttl is simply how often the folder is re-scanned (within it,
reads are pure ETS — no filesystem access), and immutable: true pins it. A weak
ETag is still emitted to the browser, so the browser → Phoenix 304 chain works
exactly as with a URL.
Two constraints:
- Local only. You can't glob a URL (HTTP has no directory listing), so
dir:requires a path on disk. Remote hashed bundles need the upstream to publish a manifest you register against, or a stable (unhashed) filename. - Ambiguity → newest wins. If a deploy leaves both the old and new hash in the
folder, the most-recently-modified file is chosen. Have the writer swap
atomically (write-then-
rename) so a half-written file is never globbed.
Security & trust model
The proxy fetches upstream bytes and serves them same-origin — your users' browsers execute them as first-party script. A few boundaries follow from that:
- The registry is the trust root. Whoever controls the
:appsmap (or the database that feeds it) chooses which hosts the server fetches and whose bytes run on your origin. If the registry is tenant- or user-driven, treat each entry as untrusted input: validate the host against an allowlist before registering it. A client cannot point the fetch elsewhere — sub-path segments can't contain/, and the authority is fixed by the configuredbase:/url:— so the only way to reach an arbitrary host is to control the registry. <.app name={…}>must not be user-controlled. The name becomes the mount target and, for an unregistered name, theimport()path (/apps/<name>/main.mjs). It's a developer-authored identifier, like a route — never interpolate request data into it.- TLS is verified and redirects are not followed. The built-in fetcher pins
verify: :verify_peeragainst the system trust store (so the Phoenix→origin leg can't be MITM'd) and usesautoredirect: false(so a malicious upstream can't 30x-redirect the fetch to an internal address). Override the TLS options withproxy_cache: [ssl_options: […]], or swap the whole client via:app_provider(e.g. Finch/Req) if you need redirect-following or a different policy. - Assets are typed by us, with
nosniff. Each response carries an explicitContent-TypeandX-Content-Type-Options: nosniff, so a browser won't MIME-sniff a bundle into something executable it shouldn't be. Note that abase:/dir:app serves any sub-path extension same-origin (an.htmlor.svgsibling included) — fine when the upstream is fully trusted, a stored-XSS vector if it ever hosts untrusted content. Keep bundle directories to code + assets you control. - Local
:dirreads are contained. Sub-paths are rejected if they contain.., empty,., or backslash segments, and the resolved file is asserted to sit under the app's:dirbefore any read — a symlink or odd segment that resolves outside the tree is a404, never a disk read. - Sub-path fan-out is bounded. A
base:/dir:app proxies any sub-path under its prefix, so a flood of guaranteed-miss paths could otherwise drive unbounded outbound fetches. Three defenses: concurrent upstream fetches are capped (max_concurrent_fetches, excess sheds with503), a definitive404is negative-cached briefly (negative_ttl), and — strongest — amanifest:allowlist rejects any unlisted file locally before a fetch (see above).
Notes
- The app boundary is unchanged — an externally-loaded island receives the same
(target, { props, context, live, api, channel, bus, el }) => handle. It only needs to be a self-contained ES module honoring that contract; it doesn't have to be built with this library (see thegreeterdemo inexample/, which is hand-written vanilla JS). - Cross-origin
import()sends no credentials, so a:directCDN never sees your cookies; and module fetches are governed by CSPscript-src, notconnect-src. SRIisn't available for dynamicimport(); if you need integrity, use:proxyand verify the bundle server-side before caching.