Islands from a CDN
Islands don't have to live under /apps. Both below are hosted on a
separate origin
—
apps.keen-phoenix-svelte.keenmate.dev
— a plain static server (Caddy behind Traefik) in its own repo. They're
registered in config; the server publishes a name → url
manifest and the browser loads each one. What differs is who fetches the bytes.
:direct
browser → CDN
The manifest points straight at the CDN, so the browser imports the bundle cross-origin. Needs CORS on the CDN and a CSP that allows it.
import("https://apps.keen-phoenix-svelte.keenmate.dev/hello/main.mjs")
:proxy
browser → your app → CDN
The manifest points at a same-origin
path; Phoenix fetches the bundle upstream, caches it (ETS), and revalidates it.
No CORS, CSP 'self'. A multi-file bundle — the entry pulls its own
stylesheet and a metrics.json, both proxied from one base:.
import("/apps/metrics/main.mjs") → server fetches https://apps.keen-phoenix-svelte.keenmate.dev/metrics/*
Live load statistics
Measured right here in your browser — the Performance API for when each bundle was requested and finished loading, and a MutationObserver for when the island replaced its placeholder with real DOM. No server round-trip, no library hooks. Times are milliseconds since the page started loading, so the three stages share one clock. Reload to watch them again.
How the two apps are built
Neither app is built by this library — they're hand-written
vanilla-JS ES modules
in the sibling keen-phoenix-svelte-apps
repo. Both default-export the same mount contract
(target, { props, context, live, api, channel, bus, el }) → { setProps, destroy }.
What actually differs is how many files each ships
— and that one fact dictates how you register it.
hello
one file · vanilla JS
hello/
└─ main.mjs ← markup + behaviour + styles
-
Styles ship inside the JS.
On mount it injects a scoped
<style id="keen-hello-style">once — the "CSS-injected-by-JS" pattern. No stylesheet to fetch. -
No companion files
means a single URL fully describes it, so it's registered with a bare
url:. Nothing to resolve relative to the module. -
That's why
:directis trivial here — one cross-originimport()and it's done.
metrics
three files · sibling assets
metrics/
├─ main.mjs ← entry
├─ metrics.css ← <link>ed stylesheet
└─ metrics.json ← fetched at mount
-
The entry resolves its companions relative to itself
—
new URL("./metrics.css", import.meta.url)and the same for the JSON. -
Because those URLs are origin-relative, the
exact same bundle
works unmodified in both modes:
import.meta.urlis the CDN under:direct, and your same-origin/apps/metrics/main.mjsunder:proxy. -
It's registered with
base:(the directory) +entry:, so all three files proxy under one prefix — no per-file registration.
Both are wired up in one config block — the single-file app gets a
url:, the multi-file app a base::
config :keen_phoenix_svelte,
apps: %{
# one self-contained file → a single URL fully describes it
"hello" => %{url: "…/hello/main.mjs", mode: :direct},
# a directory of files → point at the base; siblings proxy under it too
"metrics" => %{
base: "…/metrics/",
entry: "main.mjs",
mode: :proxy,
ttl: :timer.seconds(60)
}
}
Watch the difference in DevTools
Open the Network tab and reload. You'll see the two islands fetched from two different origins:
-
:directone request tohttps://apps.keen-phoenix-svelte.keenmate.dev/hello/main.mjs— a cross-origin document, served byCaddy. -
:proxythree same-origin requests —/apps/metrics/main.mjs,/apps/metrics/metrics.cssand/apps/metrics/metrics.json— each fetched by Phoenix from the CDN, cached, and served back with a shortmax-age+ETag. Reload again and the upstream answers Phoenix's conditionalGETwith a304.