All notable changes to keen_phoenix_svelte are documented here.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
[1.0.0-rc.9] - 2026-07-27 [PUBLISHED]
Fixed
- The app proxy relays a definitive upstream
404/410instead of masking it as502. A missing sub-path on abase:/dir:app now returns a genuine404(and410 Gonefor gone resources), matching themanifest:-blocked case and letting a browser tell "file not found" apart from a real gateway failure. Other upstream errors (timeouts, DNS,5xx) still surface as502. Misses are negative-cached exactly as before.
[1.0.0-rc.8] - 2026-07-26 [PUBLISHED]
Security
- Hardened the app proxy's upstream fetch and responses. The built-in
:httpcclient now verifies TLS certificates (verify: :verify_peeragainst the system trust store, with hostname checking) so the Phoenix→origin leg — whose bytes run same-origin in users' browsers — can't be MITM'd; override viaproxy_cache: [ssl_options: […]]. It no longer follows upstream redirects (autoredirect: false), closing an SSRF path where a malicious 30x could redirect the fetch to an internal address. Proxied responses now sendX-Content-Type-Options: nosniff. Local:dirsub-paths are additionally asserted to resolve within the configured directory (defense in depth over the existing../backslash rejection). See the new "Security & trust model" section in the external-apps guide. - Bounded the app proxy's sub-path fan-out. A
base:/dir:app proxies any sub-path, so an unauthenticated flood of guaranteed-miss paths could otherwise drive unbounded outbound fetches. Concurrent upstream fetches are now capped (proxy_cache: [max_concurrent_fetches: 32]; excess sheds with503 + Retry-After), and a definitive upstream404/410(or a missing local file) is negative-cached briefly (proxy_cache: [negative_ttl: :timer.seconds(10)],0to disable) so the same miss isn't re-fetched every request.
Added
- Per-app
manifest:allowlist for base/dir bundles. Register the exact set of files an app ships — an inline list, or a path to a JSON/text manifest in the bundle (a Vitemanifest.jsonis parsed for its output files) — and any sub-path not in it is a404decided before any upstream fetch. The declaredentry:and the manifest file itself are always allowed; a manifest that can't be loaded fails open (the fetch bounds above still apply). Closes the sub-path fan-out for apps that opt in. keenManifest()Vite plugin (@keenmate/phoenix_svelte/vite/manifest) that generates the custom manifest — a flat JSON array of servable files — by scanning the build output incloseBundle. Unlike Vite's graph-onlymanifest.json, this capturespublic/assets (fonts, images, favicons) that Vite copies verbatim, so amanifest:-restricted app doesn't 404 its own static files. Point the app atmanifest: "keen-manifest.json".- Configurable proxy
Cache-Control— the browser-facingCache-Controlthe app proxy emits is now fully tunable. A new globalproxy_cache: [immutable_cache_control: "…"]overrides what a per-appimmutable: trueemits (the built-in 1-year immutable string stays the default), and a per-appclient_cache_control: "…"string sets an exact header for that one app. The value is resolved most-specific-first: per-appclient_cache_control→ per-appimmutable: true(→immutable_cache_control) → globalclient_cache_control→ built-in default. Previously the immutable string was hard-coded and no per-app custom header existed.
Changed
- The LiveView hook is renamed
KeenSvelte→KeenApp. It mounts islands of any framework (Svelte, React, Lit, vanilla JS), so the neutral name matches the rest of the vocabulary (<.app>,data-app,AppsManager).<.app>now emitsphx-hook="KeenApp"andgetHooks()returns{ KeenApp }, so consumers that register hooks withhooks: getHooks()need no change. Breaking only if you registered the hook by its literal name — rename that keyKeenSvelte→KeenApp.
Fixed
- Docs —
apihelper paths are relative toapi_base. The server-communication and authoring guides showed island calls double-prefixed (api.post("/api/like", …)) alongsideapi_base: "/api", which the helper resolves to/api/api/like(a 404); corrected to relative paths (api.post("/like", …)). Also clarified in the install guide that pre-release (rc) versions must be pinned exactly (~> 1.0/^1.0don't match pre-releases), and that non-Svelte islands need their framework runtime (lit,react, …) added toassets/package.json.
1.0.0-rc.7 - 2026-07-25 [PUBLISHED]
Added
<.app component="…">— a first-class attribute for selecting which view a multi-component island renders. Pure sugar for acomponentkey inprops(and it overrides one already there), so the client readsprops.componentwith no change to the mount contract. Ship several related views in one app — one bundle, one shared framework runtime, imported once and mounted per view — instead of separate apps that each inline their own runtime copy.
1.0.0-rc.6 - 2026-07-24 [PUBLISHED]
Added
- Eager mounting —
<.app eager>— mount an island before the LiveView socket connects, so it paints atapp.jsparse time (the same early path a plain page uses) instead of waiting for theKeenSveltehook to fire on connect. It mounts withlive: nullandliveStatus: "pending"; when the socket connects the hook hands it thelivebridge and dispatches akeen:live-readyevent on the element. For islands whose first frame doesn't needlive— they fetch fromapi, achannel, or another server entirely. A no-op on plain pages (there is nolivethere at all). liveStatusin the app boundary — every island now receivesliveStatus: "ready" | "pending" | "none", telling it whetherliveis available now, arriving on connect (an eager mount), or never present (a plain page) — enough to show a loader while"pending"and pick its transport.keen:live-readyevent and optionalsetLive(live)handle method — the two ways an eagerly-mounted island receives itslivebridge once the socket connects: listen for theCustomEventon the island element (detail.live), or expose asetLive(live)method on your mount handle and the hook calls it. Server-pushed prop updates (setProps) keep working throughout; only the imperativelive.*calls need the bridge.
1.0.0-rc.5 - 2026-07-23 [PUBLISHED]
Removed
frameworkattribute on<.app>— it only emitted adata-frameworktag the runtime never read (mounting is identical for every framework via the shared contract), so it added markup and API surface for no behavior. Drop it from any<.app framework="…">call; the app's bundle already is whatever framework it is.
Added
- Automatic, page-scoped preloading —
<.runtime preload={:auto}>(now the default) preloads exactly the island bundles a page actually mounts, with no per-page list to maintain. Each<.app>records itself as it renders; because the page body renders before the root layout's<head>,<.runtime>already knows the set and emits<link rel="modulepreload">only for those apps. A page with no islands preloads nothing. Explicitpreload={[…]}/true/falsestill work (a list is the override for preloading an app a later interaction will mount).
Changed
preloadnow defaults to:auto(wasfalse). Bundles on a page are preloaded during initial HTML parse by default; opt out withpreload={false}. Only apps rendered on the page are ever preloaded, so this is a strict load-time improvement — and it silently no-ops (falls back to lazy loading) if<.runtime>is placed before the page's<.app>tags.
Fixed
- Vite helper no longer needs
svelte-preprocess—@keenmate/phoenix_svelte/vite(appConfig) importedsvelte-preprocess, which is not a dependency of the package and is not installed by the documentedassets/package.json, so the documented published-install build failed withCannot find package 'svelte-preprocess'. The helper now usesvitePreprocessfrom@sveltejs/vite-plugin-svelte(already required), matching the example's inlined config — no extra dependency, no build break.
Docs
- Installation — production/setup build wiring —
installation.mdnow documents thenpmscripts(dev/prod) and themixalias wiring (assets.setup→npm install;assets.build/assets.deploy→npm run prod) needed to build the island bundles for one-shot,mix setup, andmix assets.deploy. Without theassets.deploystep a release shipped with no island bundles. Also notes thatbuilder.jsis CommonJS, soassets/package.jsonmust stay non-"type": "module".
1.0.0-rc.4 - 2026-07-22 [PUBLISHED]
Added
Base-path proxy — proxy a whole multi-file bundle (JS + CSS + assets), not just one file
- A registered app can now name an upstream directory with
base:(and an optionalentry:, defaultmain.mjs) instead of a singleurl:. Any sub-path is forwarded:/apps/<name>/<sub-path>proxies to<base>/<sub-path>, so a vendor bundle whose JS entry pulls a separate stylesheet, fonts, or images all re-serve same-origin from one registration. The client imports the entry (/apps/<name>/<entry>in:proxymode,<base>/<entry>in:direct). KeenPhoenixSvelte.Apps.resolve/1resolves a proxy request path to{name, upstream_url, sub_path}— matching a base-path app on its first segment (rest forwarded) or a single-file app by full name. Path traversal (..) and empty/./backslash segments are rejected before any upstream fetch.KeenPhoenixSvelte.Apps.Proxynow types each proxied file by its extension (.mjs/.js→text/javascript,.css→text/css, elseMIME), so a base-path bundle's stylesheet and assets are served with correctContent-Typewhile JS is still forced to a module-friendly type regardless of what the origin reports.
Unified app manifest — local + registered apps in one map
- The client manifest (
Apps.manifest/0, emitted as#keen-apps) now merges detected local apps with registered ones. Local apps are found by scanningpriv/static/<base_path>/<name>/main.mjs— setconfig :keen_phoenix_svelte, otp_app: :my_appso the library can locate the static dir (or point:apps_static_pathat it directly). Registered entries override local ones on a name clash. Without:otp_appbehavior is unchanged — the client still resolves local apps by the naming convention; the manifest entry only adds them topreloadand server-side visibility. NewApps.local_apps/0. - Docs render Mermaid diagrams (enabled in ex_doc): external-apps now shows the
resolution flow (two sources → one manifest → client
import) and the lazy load/preload timeline.
<.runtime preload={…}> — fetch island bundles during initial HTML parse
KeenPhoenixSvelte.runtimegained apreloadattribute that emits<link rel="modulepreload">for app bundles, so the browser downloads them in parallel with the page instead of waiting for the LiveView hook to fire the lazyimport()(which on a LiveView can't run until the socket connects). Accepts a list of app names (scope it to the islands on this page; registered apps use their manifest URL, an unregistered local app falls back tobase_path/<name>/main.mjs) ortrue(every manifest app); defaultfalse. A cross-origin (:direct) URL getscrossorigin="anonymous"so the preload's credentials mode matches the module import and the fetch is actually reused.
Local dir: source — proxy a content-hashed bundle from a directory on disk
- A registered app can now name a local directory with
dir:(a mounted volume another process rebuilds) instead of a remoteurl:/base:. Theentry:is treated as a glob (defaultmain.mjs); the newest match wins, so a content-hashed entry (bundle.a1b2c3.js) resolves without knowing the hash. The client imports a stable/apps/<name>; sibling/hashed chunks are served as literal files under the same prefix. - Implemented by reusing the existing
ProxyCacherather than a new mechanism: afile*:-scheme source reads from disk, with the file's signature (name + mtime + size) acting as the validator — an unchanged file is the304(cached bytes kept), a newer file swaps them in and mints a fresh weakETag. The:ttlis the folder re-scan cadence (fresh reads are pure ETS, no I/O);immutable: truepins it. Localfile*:cache entries are exempt from the orphan sweep. Local only — you can't glob a URL.
Docs
- New guide "Island-able vs page-owning apps" (
docs/packaging-apps.md): the one question that decides whether a bundle can be mounted inline at all — built to mount into a target vs. built to be the whole page — with the island-able checklist, the page-owning anti-pattern, and the iframe fallback for apps you can't repackage. Cross-linked from the authoring and external-apps guides. - Authoring apps guide (
docs/authoring-apps.md) expanded from a Svelte-only walkthrough to cover any framework — the Vite library-mode config that emits one self-containedmain.mjs(single.mjs, runtime bundled in, CSS inlined), per-frameworkmain.jsadapter recipes for React, Lit, Vue, and plain JS mapping each lifecycle onto{ setProps, destroy }, the three single-file CSS strategies, optional runtime-sharing, and an authoring checklist.
Removed
- The
<.svelte>component alias is gone — there is now one island component,<.app>.svelte/1was a thin back-compat alias for the rc.1 name; keeping two names for the same framework-neutral component only invited confusion (and reads wrong in a React/Lit/Vue example). Replace<.svelte …>with<.app …>— the attributes are identical (passframework="svelte"if you want the informationaldata-frameworktag). All docs and the demo now use<.app>.
1.0.0-rc.3 - 2026-07-21 [PUBLISHED]
Added
Proxy cache with revalidation — :proxy mode now works for unversioned upstreams
KeenPhoenixSvelte.Apps.ProxyCache— a single-flight cache/refresher backing the app proxy. Bundles are held in a:publicETS table (direct concurrent reads on the hot path; large bodies are refc binaries, shared not copied), and a stale entry triggers a conditionalGET(If-None-Match/If-Modified-Since): a304keeps the cached bytes, a200swaps them. Concurrent requests for the same stale URL collapse into one upstream fetch (no cache stampede); an upstream error serves the last-good copy fail-open.- Freshness policy —
respect_upstream: true(default) honors the upstreamCache-Control/ETag/Last-Modified, falling back to a:ttl(default 5 min) when the origin is silent. This is what makes the proxy correct for unversioned upstreams (cdn/app.js,.../server-status.js) that can't be cache-busted by URL — they're re-checked on the TTL cadence instead of being pinned forever. Config via a:proxy_cachekeyword, with per-appttlandimmutableoverrides (KeenPhoenixSvelte.Apps.proxy_opts/1). - Bounded memory — cache entries upsert by URL, so refreshes never grow the
table; a periodic sweep (
:sweep_interval, default 1h,falseto disable) evicts orphaned URLs no longer in the registry, so a rotating DB-driven registry stays bounded to its current working set. - End-to-end conditional chain — the proxy forwards an
ETag(synthesizing a weak one when the origin ships none) and a revalidate-friendlyCache-Control(configurable; per-appimmutableopt-in for truly versioned URLs), and answers the browser'sIf-None-Matchwith a304. So browser → Phoenix → origin all revalidate cheaply. Replaces the previous fixed 1-yearimmutableheader, which pinned proxied bundles in browsers for up to a year even after the upstream changed. - The injectable
:app_providergains a 2-arity formfn url, validators -> {:ok, resp} | :not_modified | {:error, reason} endfor conditional revalidation; the 1-arityfn url -> {:ok, body} endstill works (treated as a fresh200). - The library now starts a small supervision tree (
mod:inmix.exs) for the cache and itsTask.Supervisor— idle unless the proxy is mounted and hit.
Changed
Naming aligned with <.app> — the proxy and its config now speak "app", not
"island" (the concept stays "island" in prose; the identifiers match the
component). These are renames from 1.0.0-rc.2:
KeenPhoenixSvelte.IslandProxy→KeenPhoenixSvelte.Apps.Proxy. Update yourforwardinrouter.ex.- The injectable bundle fetcher config
:island_provider→:app_provider(still a 1-arityfn url -> {:ok, body_binary} end). proxy_pathdefault"/keen-islands"→"/apps", so it shares thebase_pathprefix: local bundles are static files at/apps/<name>/main.mjs(served by Plug.Static, which runs before the router) and proxied bundles resolve at/apps/<name>via the forward — the two coexist under one prefix.- Built-in placeholder skeleton keyframe
keen-island-pulse→keen-app-pulse, and the doc example&MyApp.island_loader/1→&MyApp.app_loader/1(cosmetic; no API change).
Fixed
- Package metadata / links — corrected
:source_urltoKeenMate/keen-phoenix-svelte(waskeenmate/keen_phoenix_svelte, wrong casing and underscores), addedhomepage_url, and added aWebsitelink to the live demo at https://keen-phoenix-svelte.keenmate.dev alongside the existing GitHub link.
1.0.0-rc.2 - 2026-07-20 [PUBLISHED]
Added
Framework-neutral component
- A single
app/1component for every island, regardless of framework. The mount boundary was always framework-agnostic — the entry just default-exports(target, opts) => { setProps, destroy }— so islands can be built with Svelte, Lit, React, or hand-written vanilla JS, and all mount identically. An optionalframeworkattribute emits adata-frameworktag for debugging (informational only; the runtime never reads it).svelte/1is retained as a back-compat alias for the1.0.0-rc.1name (no break).
Placeholder / loader — no flash of empty container
app/1now renders a placeholder inside the wrapper that the client clears the instant it mounts the island (after the bundle loads, so it stays visible for the whole fetch). Because the wrapper isphx-update="ignore", it's rendered once and never re-diffed. Resolution is: a per-call<:placeholder>slot › the server-wideconfig :keen_phoenix_svelte, :placeholder› a built-in dependency-free skeleton. The server default accepts a raw HTML string, a 0/1-arity function (1-arity gets the app name),{mod, fun}, orfalseto disable globally. On the client,AppsManager.createclears the target (replaceChildren) right before mount, so this works for every framework.
Event bus — island-to-island messaging
busadded to the app boundary: a page-wide, client-side pub/sub built on a DOMEventTarget.bus.emit(type, detail),bus.on(type, handler)andbus.once(type, handler)(the latter two return an unsubscribe function, ideal for a Svelte$effectcleanup). Lets independent islands on a page coordinate without the server and without importing each other.getBus()exported from the package and wired into both mount paths — theKeenSveltehook andmountStatic()— so the bus is present with or without LiveView (unlikelive, which isnullon plain pages).- The mount boundary is now
(target, { props, context, live, api, channel, bus, el }) => handle.
External apps — registry + :direct/:proxy delivery
KeenPhoenixSvelte.Apps— a config- (or DB-) driven registry for apps whose bundle lives elsewhere (a CDN, another deploy).<KeenPhoenixSvelte.runtime>now also emits aname → urlmanifest (#keen-apps), andAppsManagergainedresolve(name)to load registered apps from that URL (unlisted apps still usebasePath).getAppsManifest()exported.- A per-app/global mode of operation:
:direct(browser imports the CDN URL — needs CORS + a permissive CSP) or:proxy(browser imports a same-origin path and Phoenix fetches the bundle upstream — no CORS,script-src 'self', the corporate-friendly mode). KeenPhoenixSvelte.Apps.Proxy— aPlugfor the proxy mode: fetches the upstream bundle (built-in:httpc, or an injectable:app_provider), caches it in:persistent_term, and serves it astext/javascriptwith an immutable cache header.
Changed
- Example app reworked into "KeenSpace" — a Teams-style workspace that doubles
as production-quality reference code:
- Chat over a Phoenix channel +
Presence; clicking an avatar asks the host LiveView to render a profile card beside the island. - Video catalogue over the
apiREST helper with an in-page Plyr player and the canonicallive-or-api"save" fallback. - Calendar from a simulated Microsoft Graph via
context.tokens+ its ownfetch; "Join online" opens a per-meeting chat over achannel. - A bus-driven activity toast island, a Welcome page, a plain
(non-LiveView) route exercising
mountStatic(), a user switcher, and a simulated i18n (English / Spanish) with the locale delivered throughcontext. - A framework-free
greeterisland loaded from outside/appsvia the app registry — proxied in dev, direct in prod — demonstrating both delivery modes. - Lit, React, and vanilla-JS islands (
kudos-lit,reactions-react,hello-js) mounted through the same boundary as the Svelte apps, showing the components are framework-neutral. - Deploy tooling: root
Dockerfile+.dockerignore,Makefilecontainer-*targets, and a prodruntime.exs.
- Chat over a Phoenix channel +
1.0.0-rc.1 - 2026-07-19 [PUBLISHED]
Initial version. Auto-mounts compiled Svelte apps into Phoenix as self-contained islands, on both LiveView and plain controller-rendered pages.
Added
Core mounting
<.svelte name id props>function component — renders a hook-bound<div>withphx-update="ignore",data-app, and JSON-encodeddata-props.KeenSvelteLiveView hook — mounts the app inmounted(), pushes prop changes via the mount handle inupdated(), tears down indestroyed().AppsManager— lazilyimport()s/apps/<name>/main.mjson demand and caches it; only bundles present on a page are fetched.register()escape hatch for pre-bundled apps.- Vite config helper (
@keenmate/phoenix_svelte/vite) building one self-contained ES module per app with CSS injected by JS (emitCss: false).
Svelte version independence
- Mount contract is
(target, opts) => handlewherehandleis{ setProps, destroy }. Works with Svelte 5 (mount/unmount+$state) and falls back to$set/$destroyfor Svelte 4.
Dual mount trigger (LiveView + plain pages)
mountStatic()scans[data-app]on plain pages and mounts apps that are not managed by a LiveView (skips[data-phx-session]), withlive: null.
Runtime context & the app boundary
<KeenPhoenixSvelte.runtime context={...}>emits a once-per-page<script type="application/json" id="keen-context">, read by the client and injected into every app ascontext.- The mount options object provides
props,context,live,api,channel, andel.
Server communication
livebridge:pushEvent,pushEventTo(defaults to the app's own root for LiveComponents),handleEventwith automatic subscription cleanup on destroy,removeHandleEvent,upload/uploadTo.apiREST helper:get/post/put/patch/deleteto your Phoenix backend withx-csrf-token+credentials: same-originattached (CSRF + session).channelhelper: promise-basedjoined/push/on/leaveover a Phoenix socket (lazy connect fromcontext.socket_path/socket_token). Envelope- agnostic (resolves the raw reply) and auto-attaches acidcorrelation id.
Performance
- Prop-change diffing —
updated()skips redundant re-renders whendata-propsis unchanged.
Example app
- Phoenix 1.8 LiveView demo with the
likeapp running on a LiveView route (/, viapushEvent) and a plain controller route (/plain, via REST). /api/likebehind afetch_session+protect_from_forgerypipeline.UserSocket(signedPhoenix.Tokenauth) +DemoChannelat/socket, with channel and socket-auth tests.
Tooling
- Root
Makefile:setup,dev/server,build-assets,test,clean.
Notes
- Independent of
simplificator_3000_phoenix; thechannelhelper is designed to fit its channel-macro envelope ({data, requestId, metadata}+cid) without depending on it.
Known limitations / deferred
- No SSR (islands mount client-side; brief empty container before hydration).
- Per-app bundles duplicate the Svelte 5 runtime (~71 kB/app); externalize
svelteinto a shared chunk if you ship many apps. - Server-rendered slots (
@inner_block→ component) intentionally out of scope. - External-service token delivery (a server-minted token for a different
service in
context.tokens.*) — planned, not yet implemented.