How it works
A pocket guide to the two questions the demo raises: what do I write in each
framework
to make an island, and how does the app proxy work?
The full guides live in
the HexDocs
and in keen_phoenix_svelte/docs/.
First: island-able vs page-owning apps
One question decides whether a bundle can be an island at all: was it built to mount into a container you hand it, or to be the whole page? It's not about size, richness, or being a "SPA" — a full file-management applet in the middle of a page is a perfect island. It's a packaging decision.
Island-able
- ·Mounts into the target it's handed — never #app or document.body
- ·Exports a mount function — doesn't auto-run on import
- ·Bundles its own deps — no reliance on page-global jQuery/Bootstrap
- ·Assets resolved relative to itself — no absolute /css, /js root paths
- ·Stable entry name (main.mjs), not a hashed index-a1b2c3.js
- ·Scoped styles; routes internally, doesn't drive the browser URL bar
Page-owning (must iframe)
- ·Ships an index.html and auto-mounts to a fixed #app
- ·Absolute root asset URLs (/css/…, /js/…, /vite.svg)
- ·Needs page globals loaded by its own <script> tags
- ·Hashed entry filename; wants to own the URL / history
If you control the build, make it island-able (the checklist above is a library
build, not an app build). If you can't — or it's a whole app, not a component —
embed it in an iframe
instead; it just won't get the in-process boundary (config + messaging go over postMessage). Full guide: Island-able vs page-owning apps.
1 · The mount contract
An app is one folder under assets/apps/<name>/. Its
main.js
default-exports a single function. That's the entire API you implement — the same
shape in every framework:
(target, { props, context, live, api, channel, bus, el }) => { setProps, destroy }
The library hands you the boundary, calls setProps
when the server changes props, and destroy
on teardown (live-nav or page leave). It never assumes a framework — that's why
React, Lit, Svelte and plain JS all coexist.
2 · The boundary object
The second argument. Use only what you need — a static island can ignore all of it:
target
The element to mount into. You own everything inside it.
props
Small per-island config from <.app props={…}> — config, not payload.
context
Page-wide user, CSRF, tokens, api_base and socket — emitted once per page.
live
The LiveView bridge: pushEvent / handleEvent / upload. null on plain pages (and initially on an eager mount).
liveStatus
"ready" | "pending" | "none" — whether live is here now, coming on connect (eager), or never (plain page).
api
REST helper; attaches x-csrf-token + the session cookie.
channel
Promise-based Phoenix channel factory with auto cid correlation.
bus
Page-wide client-side event bus for island-to-island messaging — no server.
3 · Talking to the server (websockets)
Two of the boundary pieces ride a websocket. Pick by lifetime: live
piggybacks on the LiveView's own socket for request/reply tied to the page;
channel
opens a dedicated topic
of your own (rooms, presence, pub/sub) that's independent of LiveView and works
the same on a plain page.
live
The LiveView bridge
same socket ·null on plain pages
Available only when the island is hosted in a LiveView. No separate endpoint —
pushEvent
/ handleEvent
travel over the socket that's already open. handleEvent
subscriptions are removed automatically on destroy.
Island (client)
// Inside an island hosted by a LiveView — talks over the page's existing socket.
live.pushEvent("save_video", { id }, (reply) => {
saved = reply.saved; // the server reply is authoritative
});
// Subscribe to server pushes (handler auto-removed on destroy):
const ref = live.handleEvent("saved_changed", ({ ids }) => { savedIds = ids; });
// Not on a LiveView page? `live` is null — fall back to REST over `api`:
if (live) live.pushEvent("save_video", { id });
else await api.post(`/api/videos/${id}/save`, {});
Host LiveView (server)
# The host LiveView. The reply is authoritative; an updated assign flows back
# into the island as a prop change (updated() -> setProps).
def handle_event("save_video", %{"id" => id}, socket) do
saved = toggle_saved(socket, id)
{:reply, %{saved: saved}, assign(socket, saved: saved)}
end
channel
A dedicated Phoenix channel
own topic · works on plain pages
A promise-based wrapper over a Phoenix channel. The socket connects lazily from
context.socket_path
+ context.socket_token, so it needs no LiveView. push()
is envelope-agnostic
— it resolves with the raw reply (you read reply.data
/ reply.error) and auto-attaches a cid
correlation id.
Island (client)
// A designated channel — its own topic, its own lifecycle (rooms, presence,
// pub/sub). The socket connects lazily using context.socket_token.
const ch = channel(`chat:${roomId}`);
ch.on("new_message", ({ data }) => { messages = [...messages, data]; }); // server push
ch.on("presence_diff", (diff) => { presences = applyDiff(presences, diff); });
await ch.joined; // resolves once joined
const { data } = await ch.push("history"); // push() resolves with the RAW reply
await ch.push("send_message", { text }); // auto-attaches a `cid` correlation id
ch.leave(); // on room switch / island destroy
Socket + channel (server)
# Your app owns the socket + channels (authenticated with the socket_token).
# keen_phoenix_svelte ships only the client — any Phoenix.Channel works.
# user_socket.ex
channel "chat:*", MyAppWeb.ChatChannel
# chat_channel.ex — replies are envelope-agnostic; here a {data: …} shape.
def join("chat:" <> room, _params, socket) do
{:ok, %{data: %{messages: recent(room)}}, assign(socket, room: room)}
end
def handle_in("send_message", %{"text" => text}, socket) do
msg = save_message(socket.assigns.room, text)
broadcast!(socket, "new_message", %{data: msg})
{:reply, {:ok, %{data: msg}}, socket}
end
The Chat
island runs on channel
+ Presence; Videos
uses the live/api fallback pair above;
Inline edit
drives live
the other way — an autonomous editor island pushing edits back
to its host LiveView, which owns persistence. keen_phoenix_svelte
provides only the client — it doesn't depend on any particular channel framework.
4 · The adapter, per framework
Every framework needs a tiny main.js
that maps its own render/teardown model onto { setProps, destroy }. These
are the actual adapters the demo islands ship — usually under 15 lines.
Svelte 5
Runes ($state) must be compiled, so the mount logic lives in a .svelte.js module re-exported from main.js. Mirror server-pushed props into a local $state.
// main.js — stable entry point
export { default } from "./mount.svelte.js";
// mount.svelte.js (a .svelte.js so $state is compiled)
import { mount, unmount } from "svelte";
import App from "./App.svelte";
export default (target, { props, ...boundary }) => {
const state = $state({ ...props, ...boundary });
const app = mount(App, { target, props: state });
return {
setProps: (next) => Object.assign(state, next), // server → island
destroy: () => unmount(app), // teardown
};
};
React 18
esbuild compiles the JSX (no extra Vite plugin). Create a root, re-render on prop pushes, unmount on destroy.
import { createElement } from "react";
import { createRoot } from "react-dom/client";
import App from "./App.jsx";
export default (target, { props = {}, bus, context, live }) => {
const root = createRoot(target);
let all = { ...props, bus, context, live };
const render = () => root.render(createElement(App, all));
render();
return {
setProps: (next) => { all = { ...all, ...next }; render(); },
destroy: () => root.unmount(),
};
};
Lit / Web Component
A custom element already has its own lifecycle. Map props onto reactive properties (assigning re-renders) and forward its events to the bus.
import "./KudosButton.js"; // defines <kudos-button> (extends LitElement)
export default (target, { props = {}, bus }) => {
const el = document.createElement("kudos-button");
Object.assign(el, props); // props → reactive properties
el.addEventListener("kudos", (e) =>
bus?.emit("activity", { title: "Kudos!", text: `Count ${e.detail.count}` }));
target.appendChild(el);
return {
setProps: (next) => Object.assign(el, next),
destroy: () => el.remove(),
};
};
Vanilla JS
No framework at all. Build DOM, wire listeners, and clean them up in destroy so live navigation doesn't leak.
export default (target, { props = {}, bus }) => {
const root = document.createElement("div");
const render = () => (root.textContent = props.label ?? "Hello");
render();
root.onclick = () => bus?.emit("activity", { title: "Vanilla JS" });
target.appendChild(root);
return {
setProps: (next) => { props = { ...props, ...next }; render(); },
destroy: () => root.remove(), // listeners on root go with it
};
};
No central registration — the builder discovers assets/apps/*
automatically and each builds to priv/static/apps/<name>/main.mjs,
a self-contained ES module with its CSS injected by JS. The hook import()s it on demand, so a page only fetches the islands it shows.
5 · Dropping it on a page
Render a mount point anywhere — a LiveView or a plain controller page. On a plain
page live
is null
and the island mounts via mountStatic(); the code above doesn't change.
<.app name="kudos-lit" id="kudos-1" props={%{label: "Kudos"}} />
name— the folder underassets/apps/.id— required, unique and stable (the LiveView hook keys on it).-
props— small config. For big datasets pass an id and let the island fetch/subscribe.
6 · When it mounts (eager vs default)
On a LiveView the first HTTP response is a full server render
— you
see the page and the island's placeholder
right away. But the island's JS only mounts when the KeenApp
hook runs, and the hook can't run until the socket connects. On a
cold load that connect is usually the biggest slice of the wait between placeholder
and live island.
<.app eager>
mounts the island before
connect — the same early path a plain page uses — so it paints without waiting. It
starts with live: null
and liveStatus: "pending"; when the socket connects the hook hands it
the live
bridge and dispatches a keen:live-ready
event on the element.
<.app name="dashboard" id="dash" eager props={%{unit: @unit}} />
default |
eager |
|
|---|---|---|
| First paint | after socket connect | at app.js parse |
live at first paint |
yes (liveStatus: "ready") |
no — arrives on connect ("pending" → keen:live-ready)
|
| Use it when | the first frame needs live |
the island draws from api / a channel / another server
|
Consuming it is optional — read liveStatus, and if it's
"pending"
wait for the event before touching live. Server-pushed prop
changes (setProps) keep working the whole time; only the imperative
live.*
calls need the bridge.
export default (target, { liveStatus, live, el, api }) => {
render(target); // draw the first frame immediately
if (liveStatus === "pending") { // eager: the live bridge is on its way
showConnectingHint();
el.addEventListener("keen:live-ready", (e) => {
wireLiveEvents(e.detail.live); // now safe to pushEvent / handleEvent
hideConnectingHint();
});
} else if (liveStatus === "none") { // plain page — no live at all
loadOverREST(api); // use api / a channel / another server
}
// liveStatus === "ready": live is already here, use it directly.
};
The Eager mount page mounts the same island both ways side by side, timed — watch the eager copy paint first and turn "live" a moment later. The Proxying (LiveView) vs Proxying (plain) pages show the same gap between a LiveView mount and a plain-page mount.
7 · External apps & the proxy
By default an island is local — imported from /apps/<name>/main.mjs
on your own static path, no config. You only register
an app when its bundle lives elsewhere
(a shared CDN, another team's deploy, a DB-driven catalogue). The server emits a
name → url
manifest and the client imports from there.
The one real choice is who fetches the bytes, because
import(url)
runs in the browser:
:direct |
:proxy |
|
|---|---|---|
| Who fetches the bytes | Browser → CDN | Browser → your app → CDN |
| Manifest URL | the CDN URL | /apps/<name> (same-origin) |
| CORS | required on the CDN | none |
| CSP script-src | must allow the CDN | 'self' |
| Auth / gating / SRI | hard (public, cross-origin) | easy (you serve it) |
| Server load | none (CDN edge) | in path, cached + revalidated (ETS) |
:proxy
is the corporate-friendly mode: it turns a cross-origin bundle into a first-party
asset, sidestepping CORS and a strict CSP. Phoenix fetches the upstream, caches it in
ETS, and re-serves it same-origin. A stale entry is revalidated
with a
single-flight conditional GET
(If-None-Match), driven by the
upstream's Cache-Control/ETag with a :ttl
fallback — so even an unversioned …/app.js
is re-checked, not pinned. The
Proxying
page shows both modes against a real external CDN — metrics
(multi-file) over :proxy, hello
(single-file) over :direct
— side by side.
# config/config.exs — register apps whose bundle lives elsewhere
config :keen_phoenix_svelte,
load_mode: :proxy, # :direct (default) | :proxy
proxy_path: "/apps", # must match the router forward below
apps: %{
"org-chart" => "https://cdn.acme.com/islands/org-chart@1.4.2/main.mjs"
}
# router.ex — only needed for :proxy mode
forward "/apps", KeenPhoenixSvelte.Apps.Proxy
A versioned CDN URL (…/org-chart@1.4.2/…) can set
immutable: true
to skip revalidation; anything else is re-checked on the :ttl
cadence, so it
never goes stale. The registry is just data: build the same map from a database at runtime
with Application.put_env/3; it's read per request.