Islands — micro-frontends
An island is already a micro-frontend: a UI subtree rendered inside a worker, replayed to the DOM over an op protocol. What makes it a publishable MFE is the contract — a framework-free module that names the app, declares its props/events wire shape, and carries the worker factory. Shells import the contract and nothing else: no worker component, no worker framework.
The contract is the boundary
One file is imported by both sides — it is the only shared artifact. Schemas come from @atolljs/core's bundledz vocabulary (message-domain validation — distinct from reef's fixed-width memory layouts):
import { z } from '@atolljs/core';
import { defineIslandContract } from '@atolljs/islands';
export const counterContract = defineIslandContract({
app: 'counter',
props: z.object({ label: z.string().optional() }),
events: {
incremented: z.object({ count: z.number(), label: z.string() }),
},
worker: () =>
new Worker(new URL('./counter.worker.tsx', import.meta.url), { type: 'module' }),
});- Shell side — props and
onEventpayload types infer from the schemas;contract.workersupplies the connection, so call sites pass no worker at all. - Worker side — the same module attaches to the app (
defineReactMonoWorker(App, { contract })and friends): props parse at mount andupdateProps, declared event payloads parse atemit(). Contract drift between shell and worker fails loudly instead of silently dropping fields. - Neither side imports the other. A React shell never bundles Angular; an Angular worker never bundles React.
Facades per shell
Every *-island facade accepts the contract directly — pick the facade for your shell framework, not the worker's:
| Shell | Facade | Call site |
|---|---|---|
| React | islandComponent(contract) | <CounterIsland label="a" onEvent={h} /> |
| Vue | islandComponent(contract) | <CounterIsland v-bind="{ label, onEvent }" /> |
| Solid | islandComponent(contract) | <CounterIsland label="a" onEvent={h} /> |
| Svelte | use:island | <div use:island={{ app: contract, props, onEvent }} /> |
| Angular | islandComponent({ contract, selector }) | <counter-island [props]="p" [onEvent]="h" /> |
Angular's facade generates a standalone component whose [props]/[onEvent] inputs type off the contract — the worker's component class never enters the shell bundle.
And the call sites — each shell's own idiom, verbatim from the examples:
import { islandComponent } from '@atolljs/react-island';
import counterContract from '../mfe/contracts/counter.contract';
const CounterIsland = islandComponent(counterContract);
<CounterIsland
label="alpha"
onEvent={(name, payload) => {
if (name === 'incremented') console.log(payload.count, payload.label);
}}
/><script setup lang="ts">
import { islandComponent } from '@atolljs/vue-island';
import counterContract from '../mfe/contracts/counter.contract';
const CounterIsland = islandComponent(counterContract);
</script>
<template>
<!-- attrs forward as island props; onEvent narrows to the contract -->
<CounterIsland v-bind="{ label: 'alpha', onEvent }" />
</template>import { islandComponent } from '@atolljs/solid-island';
import counterContract from '../mfe/contracts/counter.contract';
const CounterIsland = islandComponent(counterContract);
<CounterIsland
label="alpha"
onEvent={(name, payload) => {
if (name === 'incremented') console.log(payload.count, payload.label);
}}
/><script lang="ts">
import { island } from '@atolljs/svelte-island';
import counterContract from '../mfe/contracts/counter.contract';
</script>
<!-- the contract object IS the app — its worker factory supplies the connection -->
<div use:island={{
app: counterContract,
props: { label: 'alpha' },
onEvent: (name, payload) => { /* narrowed to the contract */ },
}} />import { islandComponent } from '@atolljs/angular-island';
import counterContract from '../mfe/contracts/counter.contract';
const CounterIsland = islandComponent({
contract: counterContract,
selector: 'counter-island',
});
// → standalone component; [props]/[onEvent] type off the contract
// in the shell component's template:
<counter-island
[props]="{ label: 'alpha' }"
[onEvent]="onCounterEvent" // IslandContractEventHandler<typeof contract>
/>Live examples — every shell hosting every framework
examples/mfe/ in the repo holds five contracts + five worker entries (one MFE each in React, Vue, Solid, Svelte, Angular). Each host below mounts all five — including its own framework through the same contract path.
If the frame is blank, the examples aren't up — run npm run dev:all (dev servers) or npm run serve:all (built apps) from the repository root.
If the frame is blank, the examples aren't up — run npm run dev:all (dev servers) or npm run serve:all (built apps) from the repository root.
If the frame is blank, the examples aren't up — run npm run dev:all (dev servers) or npm run serve:all (built apps) from the repository root.
If the frame is blank, the examples aren't up — run npm run dev:all (dev servers) or npm run serve:all (built apps) from the repository root.
If the frame is blank, the examples aren't up — run npm run dev:all (dev servers) or npm run serve:all (built apps) from the repository root.
If the frame is blank, the examples aren't up — run npm run dev:all (dev servers) or npm run serve:all (built apps) from the repository root.
If the frame is blank, the examples aren't up — run npm run dev:all (dev servers) or npm run serve:all (built apps) from the repository root.
| Host | Directory | Port |
|---|---|---|
| React | examples/react-host | 5180 |
| Vue | examples/vue-host | 5181 |
| Solid | examples/solid-host | 5182 |
| Svelte | examples/svelte-host | 5183 |
| Angular | examples/angular-host | 5184 |
Authoring a publishable MFE
The CLI scaffolds the whole shape — atoll add mfe <name> emits the contract + worker + publish config into an existing project, and atoll new <dir> --mfe stands up a standalone MFE package with a dev harness that mounts the island through its own contract. Hand-rolled, an MFE ships two artifacts per framework entry: the contract module and the worker bundle. The worker attaches the contract so both sides validate the same wire shape:
import { defineReactMonoWorker, emit } from '@atolljs/react-island/worker';
import counterContract from './counter.contract';
function CounterApp({ label = 'react mfe' }: { label?: string }) {
const [count, setCount] = useState(0);
return <button onClick={() => {
const n = count + 1;
setCount(n);
emit('incremented', { count: n, label }); // payload validates at emit()
}}>{label}: {count}</button>;
}
export const worker = defineReactMonoWorker(CounterApp, { contract: counterContract });Same-shape helpers exist per worker framework: defineVueMonoWorker, defineSolidMonoWorker, defineSvelteMonoWorker, defineAngularMonoWorker(Component, { contract }). One worker per MFE keeps each framework's runtime — and its failure domain — inside its own bundle.
Mount semantics, the worker-side adapter options, and validation errors are documented in the island apps quickstart and the repo's docs/islands-worker.md (Contracts section).
Distributing the MFE — npm package or CDN
The two artifacts are different kinds of entry — and that distinction matters:
- The contract is a module entry. It belongs in the package's
exportsmap ("." → "./src/mfe/x.contract.ts") — shellsimportit, and shipping it as TS source means consumers' bundlers compile it and infer the typed surface directly. - The worker is a fetched asset, not an import. Nothing in a shell's module graph references it — the browser fetches it as a Worker script. So it's never an
exportsentry, and it must not be a secondbuild.libentry in the publish config: a two-entry lib build code-splits the shared contract into a separate chunk, leaving runtimeimports inside the worker bundle — breaking self-containment and forcing CORS on every chunk. Keep the publish build single-entry.
Shape 1 — npm package. Ship the contract and the built bundle together (files: ['src/mfe', 'dist-mfe']); the worker URL resolves package-relative — no CDN, no CORS:
// Hoisted new URL = ASSET semantics: the consumer's bundler emits the
// file verbatim. Written inline as new Worker(new URL(...)) it would be
// detected as a worker ENTRY and re-bundled instead of copied.
const bundledWorkerUrl = new URL(
'../../dist-mfe/ticker.worker.js',
import.meta.url,
);
worker: () => new Worker(bundledWorkerUrl, { type: 'module' }),
// consuming shell's vite.config.ts — the optimizer must not pre-bundle the
// contract, or the new URL asset reference resolves against the bundle:
// optimizeDeps: { exclude: ['@scope/my-mfe'] }Shape 2 — remote URL. The contract's worker field is just a factory, but one browser rule applies: a worker's script URL must be same-origin — new Worker('https://cdn…') throws SecurityError regardless of CORS. The escape is a same-origin module shim that imports the remote bundle; a blob: URL inherits the page's origin, so only the fetch inside it needs CORS:
const MFE_BASE = import.meta.env.VITE_MFE_ORIGIN ?? 'https://mfe.example.com';
const WORKER_URL = `${MFE_BASE}/ticker@1.4.0.worker.js`;
export const tickerContract = defineIslandContract({
app: 'ticker',
props: z.object({
label: z.string().optional(),
intervalMs: z.number().optional(),
}),
events: { tick: z.object({ count: z.number() }) },
worker: () =>
new Worker(
// blob: is same-origin — the remote import inside it fetches w/ CORS
URL.createObjectURL(
new Blob([`import ${JSON.stringify(WORKER_URL)};`], {
type: 'text/javascript',
}),
),
{ type: 'module' },
),
});Three rules, all browser/platform constraints rather than Atoll ones:
- Same-origin worker URL — the constructor itself never consults CORS; the remote bundle loads through the blob (or a hosted shim file) and its
importfetches cross-origin. - CORS — the remote bundle needs
Access-Control-Allow-Originfor that import fetch. That single header also satisfies COEP on the shell page (module-worker fetches are CORS-mode, so neitherrequire-corpnorcredentiallessasks for more). - Version the URL — bundlers fingerprint local entries for free; a remote URL is the cache key, so pin a version or content hash (
ticker@1.4.0.worker.js) and keep the contract module and deployed bundle on the same version — prop or event drift surfaces as a mount-time or emit-timeZodError.
Two publish-build edges the scaffolded vite.mfe.config.ts handles for you: lib mode doesn't define process.env.NODE_ENV (framework dev/prod checks crash on a bare process), and the contract's own worker factory is bundled into the artifact — its new URL would resolve the previous dist-mfe output and inline it into its successor. A small enforce: 'pre' plugin stubs it (dead code anyway — a worker never spawns itself). See the working pair in examples/mfe-publish + examples/mfe-consumer.
Page-side requirements don't change: the SharedArrayBuffer doorbell still needs COOP/COEP on the shell (the buffer is posted to the worker, not fetched), and non-isolated pages fall back to mode: 'poll' / doorbell: false as before. The full serving matrix and failure table live in the repo's docs/islands-remote.md.