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):

counter.contract.ts — the whole public surface
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 onEvent payload types infer from the schemas; contract.worker supplies 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 and updateProps, declared event payloads parse at emit(). 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:

ShellFacadeCall site
ReactislandComponent(contract)<CounterIsland label="a" onEvent={h} />
VueislandComponent(contract)<CounterIsland v-bind="{ label, onEvent }" />
SolidislandComponent(contract)<CounterIsland label="a" onEvent={h} />
Svelteuse:island<div use:island={{ app: contract, props, onEvent }} />
AngularislandComponent({ 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:

react — examples/react-host/src/shell.tsx
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);
  }}
/>
vue — examples/vue-host/src/App.vue
<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>
solid — examples/solid-host/src/shell.tsx
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);
  }}
/>
svelte — examples/svelte-host/src/App.svelte
<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 */ },
}} />
angular — examples/angular-host/src/shell.ts
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.

HostDirectoryPort
Reactexamples/react-host5180
Vueexamples/vue-host5181
Solidexamples/solid-host5182
Svelteexamples/svelte-host5183
Angularexamples/angular-host5184

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:

counter.worker.tsx — worker entry
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 exports map ("." → "./src/mfe/x.contract.ts") — shells import it, 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 exports entry, and it must not be a second build.lib entry in the publish config: a two-entry lib build code-splits the shared contract into a separate chunk, leaving runtime imports 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:

contract inside the published package
// 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:

ticker.contract.ts — remote worker via same-origin shim
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 import fetches cross-origin.
  • CORS — the remote bundle needs Access-Control-Allow-Origin for that import fetch. That single header also satisfies COEP on the shell page (module-worker fetches are CORS-mode, so neither require-corp nor credentialless asks 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-time ZodError.

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.