Quick Start
An island on a server page#
<ul data-aegis="likes" data-url="/api/likes" data-count="12">
<li>12 likes</li> <!-- server HTML shows while JS loads; replaced by the island's template -->
</ul>
<script type="module">
import { island, mutation, api } from './aegis.js';
island('likes', ({ props, signal, html }) => {
const count = signal(props.count);
const like = mutation(() => api.post(props.url), { optimistic: () => count.value++ });
return html`<li>${count} likes <button @click=${like} ?disabled=${like.pending}></button></li>`;
}, { types: { count: Number } });
</script>Todo list#
import { mount } from './aegis.js';
mount('#app', ({ signal, html, list }) => {
const todos = signal([]);
const draft = signal('');
const add = () => {
if (!draft.value.trim()) return;
todos.value = [...todos.value, { id: Date.now(), text: draft.value, done: false }];
draft.value = '';
};
const toggle = (t) => { todos.value = todos.value.map(x => x === t ? { ...x, done: !x.done } : x); };
return html`
<form @submit.prevent=${add}>
<input bind:value=${draft} placeholder="What needs doing?">
<button ?disabled=${() => !draft.value.trim()}>Add</button>
</form>
<ul>
${list(todos, (t) => html`<li class=${{ done: t.done }} @click=${() => toggle(t)}>${t.text}</li>`, { key: 'id' })}
</ul>
`;
});Data fetching#
import { island, resource } from './aegis.js';
island('users', ({ props, html, when, list }) => {
const users = resource(props.url, { cache: true, staleTime: 30_000 }); // { data, loading, validating, error, refresh }
return when(users, {
loading: () => html`<p class="skeleton">Loading…</p>`,
error: (e, retry) => html`<p>${e.message} <button @click=${retry}>Retry</button></p>`,
data: (rows, rowsSignal) => html`<ul>${list(rowsSignal, (u) => html`<li>${u.name}</li>`, { key: 'id' })}</ul>`,
});
});No innerHTML, no manual createElement in an effect: when() picks the branch, list() keeps rows keyed, resource() owns the async state.
The cache, explained#
resource(url, { cache: true }) is an SWR cache with canonical keys (query order does not matter; array keys ['users', id]; { params, loader }), staleTime/cacheTime, request dedupe, per-entry generations (a refresh() during an in-flight request never returns pre-mutation data), structural sharing by id, prefetch()/seed() freshness, invalidate('/api/users*') returning a Promise that mutation({ invalidates }) awaits. When you need to see what it is doing:
import { cache } from './aegis.js';
cache.get('/api/users'); // data without a request or a subscription
cache.explain('/api/users?page=2'); // { state: 'stale', why: 'age 41 s ≥ staleTime 30 s — …', suggestedStaleTime: 120000, history: [...] }
cache.stats(); // every entry: age, subscribers, size, fetches, unchanged; prefetch hit/waste per trigger
cache.on((key, ev) => …); // 'mount/fetch', 'focus/fresh', 'invalidate/fetch' — assertions in testsThe cache is bounded — configure({ cache: { maxEntries: 500, maxBytes } }), SIEVE eviction among idle entries, pin: true for reference data — and revalidation (focus, reconnect, interval) goes through one scheduler that coalesces events, prioritises visible and stale entries, paces requests and applies events from a hidden tab when you come back. It speaks HTTP: ETag → If-None-Match → 304 without a body, staleTime: 'http' takes freshness from Cache-Control/Age/Expires, staleTime: 'auto' derives it from the observed read and change rates, and a mutation response can invalidate keys server-side with Aegis-Invalidate: /api/users*, /api/stats. configure({ breaker, retryBudget }) protects your API when it is down: fail-fast per origin and a bounded retry amplification. Optimistic updates are a patch log: a failed mutation rolls back only its own change, concurrent edits survive, and mutation({ commit, updates, patch, onConflict }) lets a server response update lists in place, fan an entity out to every list (configure({ identify })) and resolve a 412 with a built-in 3-way merge. cache: { persist: true } keeps entries in IndexedDB and hydrates the next session from disk before the first request; entries are versioned and synchronised between tabs over BroadcastChannel, leader() elects one tab via Web Locks, and the offline queue is an append-only log with Idempotency-Key headers, FIFO per key and a dead-letter list. trap() stacks, recaptures and sees into shadow roots, roving() covers grids, trees, typeahead, RTL and the combobox aria-activedescendant pattern. Screen readers hear the app: two permanent live regions with a lossless queue, a route announcer with focus reset, focus that moves to a neighbour instead of <body> when a row disappears, audible loading / error / saved states and dialogs that name themselves. f.guard(), draft() and wizard() cover leaving, drafts and multi-step forms, and errors are accessible without a chorus of live regions: one announcement, aria-errormessage, a GOV.UK-style f.summary(). Forms grow (fieldArray, addField, observe), an HTML 422 from Rails or Django is morphed onto the live form with its errors harvested from the markup, and formnovalidate / intent buttons work exactly like they do without JavaScript. Forms separate truth from display: f.issues and f.valid are always right, f.errors is what the user sees, f.canSubmit drives the button, bind:field=${f.field('email')} wires touched / aria / :user-invalid in one attribute, and submit is a status machine with abort, a pending submitter and PRG through the router. lens(get, set) and bind:value=${[get, set]} bind two-way to any derived or nested value, signals({ … }) names signals after their keys, and the engine warns when ${count.value} is dropped into html`` as a one-time snapshot (E048). A computed() is subscribed to its sources only while something observes it and needs no scope; what an effect creates in its body dies before the effect re-runs (Solid / Svelte 5 semantics); the setup ctx helpers survive an await. An exception inside computed() is a cached value, not a torn graph: subscriptions survive and the graph recovers as soon as a dependency changes; effect errors go to scope.onError → onError(fn) → reportError, never to the code that wrote the signal. Every speculative request obeys one network budget (configure({ speculation }): Save-Data, 2g and reduced-data switch it off, a concurrency cap turns bursts into a queue) and one intent detector (hover fires when the cursor slows down, cancels on a fly-by); the router preloads route loader data before the click, predictor() learns the user's route transitions (with a server prior from <script data-aegis-predict>) and warms the likely next page in idle time, and speculate() emits Speculation Rules for server-first pages. The DevTools panel has a cache tab with the same data, age bars and invalidate/remove/explain buttons. fakeClock() from aegis/test makes staleTime, cacheTime and garbage collection deterministic. The engine warns when staleTime is far below the observed change rate (E042) or when a prefetch trigger wastes most of what it warms (E043).