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/.
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.
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. 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" framework="lit" props={%{label: "Kudos"}} />
name— the folder underassets/apps/.id— required, unique and stable (the LiveView hook keys on it).-
framework— optional, informational only (becomes adata-frameworkattribute). -
props— small config. For big datasets pass an id and let the island fetch/subscribe.
6 · 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. It's
the mode the
home page
demo uses for its externally-loaded greeter island.
# 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.