Proxying

GitHub

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 :direct is trivial here — one cross-origin import() 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 itselfnew 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.url is the CDN under :direct, and your same-origin /apps/metrics/main.mjs under :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:

  • :direct one request to https://apps.keen-phoenix-svelte.keenmate.dev/hello/main.mjs — a cross-origin document, served by Caddy.
  • :proxy three same-origin requests — /apps/metrics/main.mjs, /apps/metrics/metrics.css and /apps/metrics/metrics.json — each fetched by Phoenix from the CDN, cached, and served back with a short max-age + ETag. Reload again and the upstream answers Phoenix's conditional GET with a 304.