Docs

GitHub

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 under assets/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.

← Back to the overview · Source on GitHub · Full docs on HexDocs