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

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 under assets/apps/.
  • id — required, unique and stable (the LiveView hook keys on it).
  • framework — optional, informational only (becomes a data-framework attribute).
  • 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.

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