Skip to content

AzerothJS/AzerothJS

Repository files navigation

AzerothJS

A TypeScript UI framework built on fine-grained reactivity with no Virtual DOM: signals drive effects that update real DOM nodes in place. Components are written as component blocks in .azeroth single-file components; a small compiler lowers them to one mode-aware runtime artifact that clones DOM on the client, serializes to HTML on the server, and adopts that HTML on hydration — all from a single intermediate representation.

Status: 0.6.0-beta. The API is close to stable but may still change before 1.0.

Why AzerothJS

AzerothJS is, first, a framework to learn from. Every layer — the signal graph, the DOM renderer, the control-flow primitives, the .azeroth compiler and its IR — is written from scratch with no hidden runtime magic, so you can read it end to end and understand exactly how a modern reactive framework works: how a signal re-runs an effect, how markup becomes a clonable template plus surgical bindings, how one compiled artifact serves client render, SSR, and hydration. The source is meant to be studied, not just imported.

It is also a framework you can build real things with. The reactivity is fine-grained and the renderer touches the DOM directly (no Virtual DOM diff), so it stays fast in practice, and the packages below cover what a real application needs: routing, stores, forms, server-side rendering, a Vite build plugin, and test helpers.

Architecture

The framework is a layered stack — each layer depends only on the ones above it:

@azerothjs/reactivity   signals · memos · effects · roots · resources · render-mode · SSR/hydration
        │
@azerothjs/renderer     h() · render/hydrate · Show/For/Switch/Dynamic/Suspense/Portal · bindings
        │                     (control-flow ranges from @azerothjs/component)
        ├── @azerothjs/store    @azerothjs/form    @azerothjs/router    @azerothjs/server (SSR)
        │
@azerothjs/core         umbrella: re-exports everything above behind one install
@azerothjs/compiler     .azeroth → JS (the Vite plugin) — build-time, not a runtime dependency
@azerothjs/testing      renderTest / leakGuard / fire — for testing apps built on the framework

Data flows one way at runtime: a signal write notifies its subscribers (effects and memos); each effect re-runs and writes the precise DOM nodes it owns. There is no component re-render and no diff — the graph itself is the update mechanism.

Packages

All packages are published under the @azerothjs scope and versioned in lockstep.

Package Purpose
@azerothjs/reactivity Signals, memos, effects, batch, untrack, createRoot, resources, and the SSR/hydration render-mode primitives.
@azerothjs/renderer h() and the DOM renderer; Show, For, Switch, Match, Dynamic, Suspense, Transition, Portal; classList, styleMap, css; render/hydrate.
@azerothjs/component Component teardown and error handling: destroyComponent, ErrorBoundary, and the co-range primitives control flow is built on.
@azerothjs/store A minimal reactive state container: an app-wide singleton on the client, isolated per request under SSR.
@azerothjs/form Reactive form state: per-field signals, sync validators, submit lifecycle, plus phone() and a country dataset.
@azerothjs/router Fine-grained reactive client-side routing with nested layouts, loaders, and a swappable history adapter.
@azerothjs/server Server-side rendering: renderToString, renderToStaticMarkup, renderToDocument, island helpers.
@azerothjs/compiler The .azeroth single-file-component compiler and the azeroth() Vite plugin.
@azerothjs/core Umbrella package re-exporting the runtime APIs from one entry point.
@azerothjs/testing Test helpers (renderTest, cleanup, leakGuard, fire) for apps built on AzerothJS.

Install

Install the runtime umbrella, and the compiler as a dev dependency for the Vite build:

npm i @azerothjs/core
npm i -D @azerothjs/compiler

@azerothjs/core re-exports every runtime API, so one import path covers signals, the renderer, control flow, stores, forms, the router, and SSR. You can also depend on individual packages directly for a smaller surface — tree-shaking drops unused exports either way, so the choice is one of explicitness, not bundle size. The @azerothjs/* packages share one version; install the same version across them.

Reactivity mental model

Three primitives, the same as you'd use directly in TypeScript:

import { createSignal, createMemo, createEffect } from '@azerothjs/core';

const [count, setCount] = createSignal(0);     // a readable value + its setter
const doubled = createMemo(() => count() * 2); // lazily recomputed when count changes
createEffect(() => console.log(doubled()));    // re-runs whenever its reads change

setCount(c => c + 1); // logs 2
  • A signal is a getter/setter pair. Reading it inside an effect or memo subscribes the reader.
  • A memo is a derived signal: computed lazily, cached, and only recomputed when a dependency actually changes.
  • An effect runs immediately, tracks every signal/memo it reads, and re-runs when any of them change. createRoot owns a set of effects so they can all be disposed together; onCleanup registers teardown; batch coalesces multiple writes into one update; untrack reads without subscribing.

Dependencies are tracked automatically at read time — there is no dependency array to maintain.

The .azeroth compiler

A .azeroth file is a TypeScript module written with component blocks. Inside a component, state declares reactive state, derived a memo, and effect a side effect — read and written as plain variables:

export default component Counter(props: { start?: number })
{
    state count = props.start ?? 0;
    derived parity = count % 2 === 0 ? 'even' : 'odd';

    <button
        class="btn"
        class:positive={count > 0}
        onClick={() => count++}
    >
        Count: {count} ({parity})
    </button>
}

The compiler:

  1. parses the module into components and pass-through (opaque) regions;
  2. analyzes each component's reactive sources and which ones every expression reads;
  3. lowers the markup into a target-independent Render Plan IR — a static template skeleton plus a list of surgical bindings;
  4. emits one mode-dispatched artifact from that IR.

Reads of reactive state compile to getter calls and writes to setter calls (count++ becomes the signal's functional-update setter), so authored code stays plain while the output is fine-grained: {count} updates only its own text node, not the component. There is one emitter and one IR — the same plan clones a hoisted <template> on the client, serializes to HTML for SSR, and adopts that HTML on hydration, so the markers line up by construction.

Rendering: CSR, SSR, and hydration

The same component runs in three modes, selected by how you call into the runtime:

// Client: build and mount real DOM
import { render } from '@azerothjs/core';
import App from './app.component.azeroth';

render(() => App({}), document.getElementById('root')!);
// Server: render to an HTML string (or a full document)
import { renderToString } from '@azerothjs/core';
import App from './app.component.azeroth';

const html = renderToString(() => App({}));
// Client over server-rendered HTML: adopt existing nodes instead of rebuilding
import { hydrate } from '@azerothjs/core';
import App from './app.component.azeroth';

hydrate(() => App({}), document.getElementById('root')!);

On the server, effects do not run and signals/memos compute once to produce HTML, with comment markers delimiting reactive holes and control-flow ranges. On the client, hydrate walks that HTML and adopts the existing nodes (no rebuild), then wires up reactivity so subsequent updates are surgical.

Control flow

Control flow is expressed with components, not template directives, so it composes like any other markup and works identically across CSR/SSR/hydration:

import { Show, For, Switch, Match } from '@azerothjs/core';

component TodoList(props: { todos: { id: number; text: string; done: boolean }[] })
{
    <Show when={props.todos.length > 0} fallback={<p>Nothing to do.</p>}>
        <ul>
            <For each={props.todos}>
                {(todo) => <li class:done={todo.done}>{todo.text}</li>}
            </For>
        </ul>
    </Show>
}

Show toggles a branch, For does keyed list reconciliation with minimal DOM moves, Switch/Match pick one branch, Dynamic renders a component chosen at runtime, Suspense coordinates async resources, Portal renders elsewhere in the document, and ErrorBoundary catches render/effect errors.

Forms

The canonical way to write a form is the form keyword. It owns the fields, validation, and submit lifecycle (lowering to createForm), and a field two-way-binds straight to an input with bind:value / bind:checked - no manual value + onInput wiring. A field is read as f.field; the rest of the form API is explicit (f.errors(), f.touched(), f.submitting(), f.handleSubmit, f.setError(...)).

import { required, email as emailRule, minLength, combine } from '@azerothjs/core';

export default component SignIn
{
    form login = { email: '', password: '' } with {
        validate: {
            email: combine(required('Email is required'), emailRule('Enter a valid email')),
            password: combine(required('Password is required'), minLength(8))
        },
        onSubmit: async (values) => { await signIn(values); }
    };

    <form onSubmit={login.handleSubmit}>
        <input type="email" bind:value={login.email} />
        <Show when={login.touched().email}><span>{login.errors().email}</span></Show>
        <input type="password" bind:value={login.password} />
        <button disabled={login.submitting()}>{login.submitting() ? 'Signing in...' : 'Sign in'}</button>
    </form>
}

A field declared with a number initial (form f = { age: 18 }) stays a number end to end: bind:value coerces the input's string on the way in, so f.values().age and onSubmit see 25, not "25", with no per-field wiring (Number('') is the empty default, 0).

The validators (required/email/minLength/pattern/combine/phone/...) are sync and per-field. Cross-field rules (password confirmation, end >= start) go in a validateForm clause that sees the whole typed snapshot and returns a partial field -> error map; server errors go on a field via setError:

form signup = { email: '', password: '', confirm: '' } with {
    validate: { password: combine(required(), minLength(8)) },
    validateForm: (v) => ({ confirm: v.confirm !== v.password ? 'Passwords must match' : null }),
    onSubmit: async (values) => { await register(values); }
};

Checks that need a server round-trip (is this username taken?) go in validateAsync. Each runs after the field's sync validators pass, debounced, with an AbortSignal that cancels superseded requests; validating() reports the in-flight fields and every async check is awaited before submit:

form signup = { username: '' } with {
    validate: { username: combine(required(), minLength(3)) },
    validateAsync: {
        username: async (value, signal) =>
        {
            const res = await fetch(`/api/username-available?u=${value}`, { signal });
            return (await res.json()).available ? null : 'That username is taken';
        }
    },
    onSubmit: async (values) => { await register(values); }
};

A dynamic list of repeated sub-forms (invoice line items, team members) is the form NAME[] keyword - it lowers to createFieldArray (one createForm per row), with append/remove/move and aggregated values()/isValid()/error(). The = { ... } is the blank row; with { ... } carries initial rows, per-row validate, and the array-level validateArray. Rows render through <For>, and a row field two-way-binds straight to an input with bind:value={row.field} (the rest of the row API is explicit through row.form - row.form.errors(), row.form.touched()):

form items[] = { description: '', qty: 1, price: 0 } with {
    validate: { description: required(), qty: min(1), price: min(0) },
    validateArray: (rows) => rows.length === 0 ? 'Add at least one item' : null
};

<For each={items.rows()} key={(item) => item.key}>
    {(item, i) =>
        <fieldset>
            <input bind:value={item.description} />
            <input type="number" bind:value={item.qty} />
            <button type="button" onClick={() => items.remove(i())}>Remove</button>
        </fieldset>
    }
</For>

See packages/compiler/examples/SignInForm.azeroth (the minimal reference), SignUpForm.azeroth (cross-field), AsyncUsernameForm.azeroth (async), and TeamMembersForm.azeroth (field array). For a different taste, the createForm runtime primitive can also be driven by a hand-built field component - both are supported, but the form keyword is the idiomatic style.

Build integration (Vite)

The compiler ships a Vite plugin that compiles .azeroth files during dev and build. It also runs markup lint and semantic diagnostics, surfacing them as build warnings:

// vite.config.ts
import { defineConfig } from 'vite';
import { azeroth } from '@azerothjs/compiler';

export default defineConfig({
    plugins: [azeroth()]
});

With the plugin installed, imports of .azeroth files just work and source maps chain back to the original markup. Component imports use the explicit .azeroth extension (import Modal from './modal.component.azeroth'). The plugin requires Vite 6 or newer.

Testing

@azerothjs/testing provides the lifecycle helpers app tests need — mount in a fresh root, assert, and dispose without leaking effects:

import { renderTest, fire, leakGuard } from '@azerothjs/testing';
import Counter from './counter.component.azeroth';

const guard = leakGuard();
const { container, unmount } = renderTest(() => Counter({ start: 0 }));

fire(container.querySelector('button')!, 'click');
expect(container.textContent).toContain('Count: 1');

unmount();
guard(); // throws if any subscription survived teardown

renderTest mounts into a container attached to document.body (so delegated events fire) and cleanup() auto-registers with a global afterEach when one exists. A DOM environment (happy-dom/jsdom/browser) is required.

Development

This is an npm-workspaces monorepo.

npm install
npm run build        # build all packages in dependency order
npm run dev          # tsc --watch (type-check the whole workspace)
npm run lint         # ESLint

Each package builds to dist/ via tsc and auto-cleans its output on every build (scripts/clean.mjs). The release flow is scripted in scripts/release.mjs (npm run release -- <version>).

License

MIT. See LICENSE.

About

No description, website, or topics provided.

Resources

License

Contributing

Stars

Watchers

Forks

Packages

 
 
 

Contributors