One ES module, no dependencies, no compiler. Your server renders the HTML; Aegis wakes up the parts that need to be alive: signals, islands, a real cache, forms and a router, all in the same file.
<div data-aegis="counter" data-start="5">
<button>Clicked 5 times</button>
</div>
<script type="module">
import { island } from './aegis.js';
island('counter', ({ props, signal, html }) => {
const count = signal(props.start);
return html`<button @click=${() => count.value++}>Clicked ${count} times</button>`;
}, { types: { start: Number } });
// no mount() call: every [data-aegis] on the page is hydrated after island()
</script>
The whole mental model fits on a card.
${x.value} is a snapshot, ${x} and ${() => …} are live. A signal or a function in a template re-renders on change; a plain value renders once (dev warns with E019).island() / component() / mount() die with the component. Outside a scope you get E001.ctx versions. on, effect, interval, observe from the setup context are bound to the component's scope; the imported ones are not.innerHTML. Data goes through ${} in html`` (text nodes, never parsed). Server HTML goes through swap() / adopt().Each part is independent. Import what you use; a bundler tree-shakes the rest, and build.mjs does the same without one.
TC39-aligned signals, computeds and effects with glitch-free propagation, scopes that clean up after themselves, deep reactive() objects.
<div data-aegis="chart"> on a server page comes alive with typed props, lazy loading on visibility, JSON props, morph-safe swaps.
html`` parsed once by a real tokenizer, CSP-safe, with @click, .prop, ?bool, bind:value and keyed list().
One resource() for SWR, offline and streaming; mutations with optimistic patch logs; ETag, persistence, cross-tab sync, a circuit breaker.
wireForm() upgrades a plain <form>: Constraint Validation, schemas, async rules, 422 mapping, wizards, drafts, accessible errors.
Navigation API router with loaders, guards before the URL commits, View Transitions; focus traps, roving tabindex, live regions.
Routing →Aegis exports a lot. You need about twelve names; the table says which.
| Job | Use | Not |
|---|---|---|
| Component on a server page | island(name, Component, { types }) |
register() (low-level; keep for { load }) |
| Component anywhere else | mount(el, Component) |
component() |
| Custom element | element(tag, Component, { props }) |
defineElement() |
| State | signal, computed, effect, reactive |
store() |
| Template | html``, when, list, show |
text/attr/cls/style (for adopting existing DOM) |
| Data | resource(url, { cache, offline, params, loader }), mutation, api, invalidate('/api/users*') / invalidate(['users']) |
cachedResource(), offlineResource() |
| Forms | wireForm(formEl) for server forms, form(defaults) for virtual ones |
— |
| Server HTML | swap, adopt, boost |
innerHTML with data |
| Router | router({ '/users/:id': { loader, component: Page } }, { outlet, hash }) — props = params + { data, query } |
handler + manual mount() |
Attribute sinks are typed from the template's static prefix: href=${v} can never become javascript:, srcdoc and on* need trusted(), Trusted Types and Sanitizer API are used when present, islands have trust zones, headers never leak to other origins.
Median of 5 runs, headless Chrome, milliseconds. Run bench.html yourself.
| DOM (1,000 rows) | ms | Core | ms |
|---|---|---|---|
| create | 17.1 (12.7 with delegateEvents) |
deep chain 100 computeds × 1,000 writes | 9.4 |
| replace all | 14.3 | fan-out 1,000 effects × 100 writes | 10.4 |
| update every 10th | 0.5 | diamond × 100,000 | 30.5 |
| select row | 0.1 | batch 1,000 signals × 100 | 2.5 |
swap rows (2 insertBefore calls) |
0.4 | html`` × 1,000, static |
3.5 |
| remove row | 0.1 | html`` × 1,000, 2 signal bindings |
4.4 |
| append 1,000 | 13.4 | reactive() filter 100k rows in an effect |
30 |
| create 10,000 | 146.6 (131.5 with delegateEvents) |
reactive() for..of 100k rows |
20 |
| clear | 12.5 | reactive() wrap 100k rows, heap |
17 MB |
No npm required. Django, Rails, Laravel, Go, PHP, htmx, Turbo, jQuery pages: islands inserted by anyone come alive with hydrate(root, { watch: true }).
<script type="importmap">{ "imports": { "aegis": "https://aegisjs.com/aegis.min.js" } }</script>
<script type="module">
import { island, resource } from 'aegis';
island('users', ({ props, html, when, list }) => {
const users = resource(props.url, { cache: true, staleTime: 30_000 });
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>`,
});
});
</script>