App
Fragment federation
Microfrontends over the wire. Independent SvelteKit + ogygia apps combine into one page. `federate()` is one identity per app; a peer's routes and widgets are region values you render with `<Region>`; a whole app mounts under shell chrome. Hops are Ed25519-signed, identity and experiments carry, and a publish or deploy on one team thaws every frozen page that baked its fragments.
On this page
- Not module federation
- One identity per app: federate()
- A remote fragment is a region
- Mount a whole app: mount(peer)
- Mount from plain SvelteKit: mount.kit
- Reading the visitor: user(c)
- Cross-app thaw
- Security: signed hops, carried identity
- Key rotation
- Observability
- Serving the chunks: CORS
- What crosses, what doesn’t
Experimental
This surface is new, and its shape can change. The mechanics below are e2e-tested. See examples/mfe in the repo for a three-team demo that runs. But treat the API as pre-1.0.
Three teams, three repos, three deploys — one page. Fragment federation combines independent SvelteKit + ogygia apps over HTTP at render time. The server of another team answers with a fragment document. The shell bakes its HTML into the first paint. The islands inside wake in the page of the shell, with chunks loaded from the origin of the team that owns them.
There is one primitive per app: federate(). It declares the app’s identity, the peers it talks to, and — if it is an MFE — the routes and widgets it serves. From it you get typed peer handles. A remote route or widget is a region value you render with <Region>. A whole remote app mounts under shell chrome with mount(). And because a fragment is a region, it folds into frozen pages: a publish or a deploy on one team thaws every frozen page of every team that baked its fragments.
Not module federation
Module federation shares JS modules between builds at runtime. That couples bundlers, versions, and upgrade schedules. Fragment federation shares complete HTML plus self-contained islands. Each team ships its own complete build, with its own Svelte copy (about 12 kB gz per extra build on a page). A foreign island hydrates through a function that the producing build exports, closed over its own Svelte. Thus version skew between teams is not a problem, and the broken dependency of one team cannot take down the code of another. The price of independence is a few kB. The gain is that “independent” is real.
One identity per app: federate()
Every app calls federate() once, at server module-eval. It is the whole federation config: who I am, who I trust, and what I serve. It returns the peer handles, typed and keyed by their names.
The shell — an app that consumes fragments:
import { federate } from 'ogygia/federation';
import { env } from '$env/dynamic/private';
import { session_from } from '$lib/auth';
export const { cms, dash } = federate({
name: 'shell',
key: env.SHELL_SIGNING_KEY, // this app's Ed25519 PRIVATE key — signs every outbound hop
visitor: (event) => session_from(event.cookies), // the ONE on-behalf-of identity, derived per hop
peers: {
cms: { origin: 'http://cms.internal', key: env.CMS_PUBLIC_KEY, timeout: 800, cache: { ttl: 30_000 } },
dash: { origin: ['http://dash-1', 'http://dash-2'], key: [env.DASH_PUBLIC_KEY, env.DASH_PUBLIC_KEY_OLD] },
},
});The cms — an app that serves fragments (an MFE):
import { federate } from 'ogygia/federation';
import { region } from 'ogygia';
import { env } from '$env/dynamic/private';
import { cms_router } from '$lib/router';
import Kpis from '$lib/Kpis.svelte' with { region: 'raw' };
export const { shell } = federate({
name: 'cms',
key: env.CMS_SIGNING_KEY,
peers: { shell: { origin: 'https://www.example.com', key: env.SHELL_PUBLIC_KEY } },
expose: cms_router, // serve my whole route table as fragments
base: '/cms', // the mount base the exposed table renders under
widgets: {
kpis: { props: ['org'], make: (props, { user }) => region(Kpis, { org: props.org, viewer: user ?? null }) },
},
});The peers map is symmetric and bidirectional. Every app lists every app it talks to, in either direction. peers.X.key — the peer’s public key(s) — verifies inbound calls from X. The top-level key — this app’s private key — signs outbound calls to anyone. That one map is the whole trust config. A list of keys is rotation overlap. Extra origins on a peer are same-build replicas, tried in order for reads (a retried mutation risks a double write, so POSTs stay on the first). Audience defaults to the peer’s origin host; set audience per peer only when a reverse proxy rewrites Host between the hops.
visitor is declared once. It reads the live event (cookies, locals) and becomes the on-behalf-of identity signed into every hop, including the hole-time fetch of a deferred region. There is no second place to wire identity.
No route files. federate() registers the app with the ogygia handle. The handle (from ogygia/server, already in your hooks.server.ts) serves everything under /og/:
/og/fragment/page— the exposed route table/og/fragment/<name>— a named widget/og/fragment/__catalog— the manifest (unsigned by intent; it is inventory, not data)/og/thaw— cross-app thaw notices (see Cross-app thaw)/og/frag— the deferred-region hole endpoint
Kit shells and router shells get the same endpoints, because the handle is the one thing every ogygia app has. Put the federate() in a .server.ts and make sure hooks.server.ts imports it — directly, or transitively through your router (which uses the peer handles). It evaluates on boot; the handle reads the registration back.
expose/widgets absent = a pure shell. peers empty = a pure MFE. Both may be set: that is a chain (a shell that is itself mounted by another shell).
Fail-closed. A federate() that exposes a table or widgets but lists no peer with a key means nobody is allowed to call — a config bug, not a choice. It warns loudly. If you mean it — behind mTLS or a service mesh — pass open: true to acknowledge an intentionally unauthenticated endpoint. It never serves unauthenticated silently.
Make a key pair with:
npx ogygia keys shell # prints SHELL_SIGNING_KEY (private, stays here) / SHELL_PUBLIC_KEY (goes to callers)A remote fragment is a region
Both peer.page(path) (a remote route) and peer.widget(name, props) (a remote component) return a Promise<RegionValue> — the same held region value a local region() returns. So the wire law, <Region of>, placeholder, CSS-travels, and nested-island wake all apply unchanged:
<Region of={cms.page('/solar')} /> <!-- a remote ROUTE -->
<Region of={dash.widget('kpis', { org }, { render: 'deferred' })}> <!-- a remote COMPONENT, deferred -->
{#snippet placeholder()}<KpiSkeleton />{/snippet}
</Region>The peer handles are server values (they hold the signing key, so they live in a .server.ts). In practice you build the region in a load and render it from data:
import { routes, page } from 'ogygia/router';
import { cms, dash } from '$lib/federation.server';
import Home from '$lib/views/Home.svelte';
export const shell_router = routes({
'/': page(Home, {
load: async () => ({
article: cms.page('/solar'), // a remote route — static (baked)
kpis: dash.widget('kpis', { org: 'acme' }, { render: 'deferred' }), // a remote widget — a live hole
}),
}),
});<script>
import { Region } from 'ogygia';
let { data } = $props();
</script>
<Region of={data.article} />
<Region of={data.kpis}>
{#snippet placeholder()}<KpiSkeleton />{/snippet}
</Region>page() takes the route path (and an optional search); widget() takes the widget name and its props. Both take the region dials as a trailing argument:
| dial | behavior | in a frozen shell |
|---|---|---|
render: 'static' (default) | one buffered signed hop during this render; the fragment HTML is baked into the page | baked into the frozen bytes; thawed by a notice from the peer |
render: 'deferred' | a hole; the browser fetches it from the shell’s own signed endpoint, which derives the visitor’s claims at hole time and forwards to the peer | per-visitor, stays live over a frozen shell |
Pass claims on the dials to override the visitor identity for that one hop (the default is the federation’s visitor, plus every experiment bucket the request decided). The fragment’s CSS travels with it and is hoisted to <head>; the islands inside wake themselves once the HTML lands.
Static bakes; deferred stays live. A static remote region is one buffered, signed hop folded into the shell’s own server render — a bare await on the region value, so it is SSR-stitched with no client fetch for the pixels. A deferred remote region is a hole below the fold: the browser fetches the shell, never an MFE origin, so there is one cookie domain and no exposed internal host. Inside a streamed page, a deferred region arrives down the stream as a late region.
No allowlist. No open proxy. A deferred region’s hole URL is a shell-signed capability minted at render time: it names the peer, the route or widget, and the props, and the browser can choose none of them. There is no endpoint that turns browser input into a signed call, so there is nothing to allowlist. Widgets still authorize what they return against user (the signed claims), never against a prop — props are caller-chosen, claims are not.
Mount a whole app: mount(peer)
Where a region is a slot the shell owns, a mount is a route the MFE owns. One table entry renders a whole remote app under shell chrome:
import { mount } from 'ogygia/router';
import { cms } from '$lib/federation.server';
// in the route table:
'/cms/[...rest]': mount(cms),GET loads the document. The redirects of the MFE re-throw as shell redirects. POSTs forward the form body and follow the PRG redirect of the MFE. Two channels ride the document beyond the body. Status: the MFE’s own 404/500 page renders under shell chrome, and the shell answers with that status — a 200 around an error page poisons caches and SEO. Head: the SEO and social meta of the MFE page (description, og:*, canonical) joins the head of the shell; charset, viewport, and http-equiv stay the shell’s own. A dead or slow MFE renders the shell’s error boundary card. The page of the shell is never blocked by another team.
Canary / blue-green is a per-request resolver in place of a fixed peer. It is A/B testing of infrastructure itself, with the same pick verb that selects components:
const v2 = flag('cms-v2', 10);
'/cms/[...rest]': mount(v2.pick({ off: cmsV1, on: cmsV2 })), // a flag pick of peersA per-cohort rollout is when(). Off means the route does not exist for that visitor:
'/cms/[...rest]': when(cmsRollout, mount(cms)),You can also pass a plain resolver — mount((c) => peer). Stickiness comes from the flag; each peer keeps its own cache and coalescing, and the resolver only selects between prebuilt handles.
Streamed mounts: mount(cms, { stream: true }) flushes the shell chrome immediately with a skeleton in the slot; the fragment swaps in down the same response when the MFE answers. Pass stream: { fallback } for an html string or a component (a component bakes as an inline region). A slow team does not delay the first byte of the shell. The trades (GET only): the status and title flush before the document arrives, and a late redirect degrades to a link card.
Mount from plain SvelteKit: mount.kit
The shell does not have to use the ogygia router. From an ordinary Kit catchall (src/routes/cms/[...rest]/):
import { mount } from 'ogygia/router';
import { cms } from '$lib/federation.server';
const m = mount.kit(cms, { user: (e) => session_from(e.cookies) });
export const load = m.load;
export const actions = m.actions;<script>
let { data } = $props();
</script>
<svelte:head>
{#if data.doc.title}<title>{data.doc.title}</title>{/if}
{@html data.doc.css.join('') + (data.doc.head ?? '')}
</svelte:head>
{@html data.doc.body}Same peer, same signing, same reliability dials. Two honest differences: identity is read off the event (there is no c.visitor outside the router), and a Kit load cannot set a response status — an upstream 4xx becomes error(status) in Kit, so the status is correct but the shell’s error page renders. The router’s mount() keeps the MFE’s own error body under shell chrome with the real status.
Reading the visitor: user(c)
Inside an exposed router’s loads, actions, and endpoints — and in a widget’s make(props, { user }) — read the verified, signature-bound visitor claims with user:
import { user } from 'ogygia/router'; // also exported from 'ogygia/federation'
load: async (c) => {
const viewer = user(c); // the shell's session, carried on-behalf-of; undefined when anonymous
return { posts: await db.postsFor(viewer?.org) };
}user(c) is undefined on the standalone front door and for anonymous calls. It is the verified claims, not a header an HTTP caller can set — see Security.
Cross-app thaw
This is the reason a fragment is a region. A remote fragment baked into a shell page is stored in the shell’s freeze store like any other page. The question freezing always asks is when does this stored page stop being true? For a fragment, the answer lives on another team’s server. Federation answers it with no config on either side.
A publish thaws its consumers. Every freeze.invalidate(...) on any app already computes what it evicts. After the local evict and edge purge, it now also notifies every peer. Each peer drops the frozen pages and cached documents that baked the invalidated fragments, purges them at its own edges, and — if it is itself consumed — forwards the notice up the chain. So a publish on the CMS team thaws the shell pages that embedded CMS fragments. Nobody wired it.
A deploy thaws everything it touched. On boot, an app compares its build id (Kit’s version.name) with the one it last stored. Different → it broadcasts a thaw-all to its peers. So a new MFE build thaws every shell page that baked its fragments — the fix for a frozen shell stranded on the MFE’s old hashed asset URLs. This is deploy: 'auto' (the default); deploy: 'manual' turns it off, and freeze.invalidateApp() does it by hand.
Provenance rides the wire. A fragment answer carries two additive fields: sources — the og.source receipts the MFE’s render consumed, plus any it adopted from its own peers (a chain) — and build, the answering app’s build id. The consumer files its frozen page under peer-prefixed tags: a blanket a:<peer> (so a deploy thaw reaches every page that touched the peer), p:<peer>:<path> (so freeze.invalidate('<path>') on the peer thaws exactly the pages that baked that route), and r:<peer>:<receipt> for each source. A thaw notice carries only tags, so its blast radius is “re-render some pages,” never data.
An MFE that declared no og.source receipts still gets the blanket a:<peer> tag, so deploy-thaw works even for teams that never adopt source tracking. Freeze turned off on a peer answers /og/thaw with a 204 and does nothing; notices are still sent (they are cheap), so turning freeze on later needs no re-wiring. A peer that is down never fails a publish — notices retry with backoff, then fall back to the store’s TTL backstop.
Security: signed hops, carried identity
Each server-to-server hop is Ed25519-signed by the caller. Peers hold only public keys. There is no shared secret to distribute, rotate, or leak. The signature covers the timestamp, the method, the path and query, the body hash, the audience (the target host), the claims, and a random nonce. Ed25519 is deterministic: without the nonce, two identical concurrent requests would make byte-identical signatures, and the replay guard would reject the second. An unsigned or invalid call is a 401, before any routing runs.
The signature buys three things beyond authenticity:
- Audience binding. The target host is signed in. A hop signed for
cms.internalis rejected atdash.internal, although both trust the same shell key. This stops the confused-deputy that a shared key plus a fixed fragment path would permit. The audience defaults to the destination host; set the sameaudienceon both ends only when a reverse proxy rewritesHostbetween the hops. - Replay rejection. A byte-identical redelivery of a captured signed hop is refused inside its freshness window (±120 s,
skewMsto change it). A mutating POST cannot replay. Protection is per-process; audience binding is the cross-instance guarantee. - Fail-closed. Exposing a table or widgets with no peer keys warns loudly and takes
open: trueto acknowledge an intentionally open endpoint (behind mTLS or a mesh). It never serves unauthenticated silently.
Visitor identity rides inside the signature (on-behalf-of). The shell declares it once as federate({ visitor }); each hop signs c.visitor plus the request’s experiment buckets into the payload. Claims never transit the browser. On the MFE, verified claims surface as user(c). They attach in-process through a Symbol — an HTTP caller cannot set a JS symbol, so the public front door of the MFE can never smuggle identity through a header. To forge identity, you must forge the signature. Teams chain: an MFE that stitches a third team signs again with its own key and forwards the same claims; the third team’s key list simply trusts both callers.
Claims are signed, not encrypted. That is integrity, not confidentiality. Run the hops over TLS. Then visitor ids and roles are not readable on the wire.
A deferred region’s hole runs visitor() at hole time on the shell, with the browser’s cookies, so a frozen (anonymous) shell still serves personal holes — the same posture as a local deferred region.
Key rotation
peers.X.key is a list, exactly so that rotation is overlap, not downtime:
- Make the new pair:
npx ogygia keys shell-2026q4. - Add the new public key to the
keylist of each app that hears from the shell (the old key stays). Deploy them. - Switch the shell’s
keyto the new private key. Deploy the shell. - Remove the old public key from the peers. Deploy them.
Each step is safe to pause on. At no point does a valid caller get a 401. The same list is how a second authorized caller (the re-signing stitch of a sibling team) exists at the same time.
Observability
The W3C traceparent continues through each hop: one trace-id from the browser through shell → cms → dash. Each mounted response carries per-team Server-Timing: <label>;dur= for the hop and <label>-render;dur= for the MFE’s own render (the label defaults to the peer name). Thus “which team made the page slow” is a DevTools look, not a three-team argument.
Serving the chunks: CORS
The JS of a foreign island is loaded by the page of another origin, through a dynamic import(). That is a CORS request. The immutable assets of the MFE (/_app/immutable/*) must answer with Access-Control-Allow-Origin (usually * — they are content-hashed public files). Without it, each foreign island degrades silently with “Failed to fetch dynamically imported module.” In production, this header belongs on the CDN or the reverse proxy of the MFE. A bare adapter-node server does not set it. HTML checks cannot catch this failure class — keep a real-browser check in your test set.
What crosses, what doesn’t
Fragment props are plain data. Snippets and children do not cross the boundary. A wired class that arrives from another build throws loudly; it does not half-revive into a foreign Svelte instance. For state shared between the islands of two teams on one page, a small contracts package that exports a SharedState operates across builds: values cross as devalue snapshots, live references never do, and any server that can print a JSON script tag can seed it.
page does not cross either. On the MFE server, $app/state is the page of the MFE. In the browser, a mounted island hydrates inside the document of the shell, and the same read is the page of the shell: the same URL, but the route, the params, and page.data of the shell. The MFE’s own page seed stops at the fragment boundary. So an island that reads page.data paints the MFE value on the server and repaints with the shell value after hydrate. In dev, ogygia warns once per island, with the entry and the MFE origin. Pass the value as a prop, or read it with a remote function.
One more result of self-contained fragments: the document advertises the MFE’s own runtime URL. So a non-ogygia host — a PHP page, a bare node:http server, anything that can print HTML — can mount the fragment of an ogygia app, and its islands still wake, hydrated end-to-end by the build that produced them.