No matching items

Rouse

Rouse (rousejs on npm) is a directive-driven reactivity and state-sync layer for server-rendered HTML. The server sends the markup; data-rz-* attributes bind it to reactive state, events, and network requests. Logic lives in plain JavaScript scopes and stores, never in attribute expressions. Rouse is pre-release. Source: github.com/dehkr/rouse.

Setup

Create an app, register its scopes, stores, interceptors, and listeners, then start it. Starting scans the page and binds every directive.

import { rouse } from 'rousejs';

// Creates the app with config; config is read-only after creation
const app = rouse({
  root: '#app',
  baseUrl: '/api',
  headers: { 'X-Tenant': '42' },
});

app.scope('counter', () => { /* ... */ }); // defines the API inside data-rz-scope="counter"
app.store('prefs', { theme: 'dark' });     // app-wide state, addressed as @prefs in HTML
app.interceptor('request', addAuth);       // edits every request before it's sent
app.on('rz:fetch:error', showToast);       // hears lifecycle events from the whole app

app.start(); // scans the root and binds directives

start() scans the DOM once and then watches for added nodes. Registration applies to DOM scanned afterward and never retroactively. <script data-rz-store> stores don't exist until start(). destroy() is terminal.

Install with npm install rousejs, or skip the install and import from a CDN in a <script type="module">: https://cdn.jsdelivr.net/npm/rousejs@0.15.0/dist/rouse.js (development) or https://cdn.jsdelivr.net/npm/rousejs@0.15.0/dist/rouse.min.js (production). Rouse ships as ES modules only.

Config options

Options passed to rouse(). Every key is optional.

Config key Default Meaning
root document.documentElement Element or selector the app owns
baseUrl '' Prefixed to relative URLs in rz-fetch, rz-push, rz-pull, rz-sse, app.fetch, app.sse
headers {} Sent with every request; a null value removes a header
credentials browser's (same-origin) fetch credentials for every request
timeout 0 (none) Abort every request after this many ms; a per-request timeout overrides it
wake ready Default scope activation; rz-wake overrides it per scope

Syntax

Directives are data-rz-* attributes whose values name state, methods, and triggers. Values are literal text, never expressions.

<div data-rz-scope="profile">
  <!-- Bare path: a key on the nearest scope -->
  <h2 data-rz-text="user.name"></h2>
  <!-- @store.path: a field in a store; @store::status: its read-only sync status -->
  <span data-rz-text="@cart.count" data-rz-class="busy: @cart::status.syncing"></span>
  <!-- Pairs: comma-space between them, colon-space inside each -->
  <a data-rz-attr="href: profileUrl, aria-current: isCurrent">Profile</a>
  <ul>
    <template data-rz-render="@cart.items">
      <!-- %.field: a field on the current rz-render item -->
      <li data-rz-text="%.title"></li>
    </template>
  </ul>
</div>

Directives

Each directive does one job: bind state to the DOM, handle an event, make a request, or route its response.

<!--
The todos scope activates on the first hover or focus inside the section.
The button starts disabled, so nothing submits until the scope binds isEmpty.
On submit, POST the form's fields to its action (/todos) and append the returned
HTML to #todo-list. The fieldset is disabled while the request runs.
-->
<section data-rz-scope="todos" data-rz-wake="interact">
  <form
    action="/todos"
    method="post"
    data-rz-fetch="submit"
    data-rz-place="beforeend: #todo-list"
    data-rz-disable="#todo-fields"
  >
    <fieldset id="todo-fields">
      <!-- todos exposes title and isEmpty -->
      <input name="title" data-rz-model="title">
      <button disabled data-rz-attr="disabled: isEmpty">Add</button>
    </fieldset>
  </form>
  <ul id="todo-list"></ul>
</section>

All directives

Every directive, its job, and a typical value.

Directive Purpose Example
rz-scope Bind a registered scope to a region; empty value = anonymous boundary data-rz-scope="search"
rz-wake Defer scope activation until all listed triggers fire (AND) data-rz-wake="intersect timeout-[2s]"
rz-text Write state as text data-rz-text="@user.name"
rz-html Write state as HTML (unsanitized) data-rz-html="previewHtml"
rz-attr Set attributes; false/null removes, true sets empty; on aria-*, booleans write "true"/"false". Not class or style: use rz-class / rz-style data-rz-attr="href: profileUrl, aria-expanded: open"
rz-prop Set DOM properties, type preserved data-rz-prop="checked: done, items: @cart.items"
rz-class Toggle the classes on the left by the state on the right, or write a class string or { class: bool } object from a bare path data-rz-class="active selected: isChosen", data-rz-class="themeClass"
rz-style Toggle a quoted declaration, set one property, or write a declaration string or object from a bare path data-rz-style="'color: red; font-weight: bold': hasError, background: @theme.color"
rz-model Two-way bind an input, textarea, select (multiple gives an array), checkbox, radio, or contenteditable. Default trigger, resolved at bind: input for text, textarea, and contenteditable; change for select, checkbox, and radio. Custom elements bind .value and need an explicit trigger data-rz-model="query", data-rz-model="value-changed: @names.list.0"
rz-on Call a scope or store method on a trigger data-rz-on="click: save, submit: send"
rz-render On a <template>: render an array (per item), number (n copies), object (once), or boolean (if) <template data-rz-render="@todos.items">
rz-key Stable key for rz-render lists that reorder or refetch data-rz-key="id"
rz-fetch Request on a trigger; subject is [METHOD] URL (default GET), either part optional (submit: PUT keeps the form's action) data-rz-fetch="click: POST /todos"
rz-place Where an HTML response goes: position: selector, a selector, a position, or null. Default: host innerHTML data-rz-place="beforeend: #log"
rz-deposit Merge a JSON response into whole stores data-rz-deposit="@results"
rz-send Payload: scope paths, @store/@store.field, %/%.field, inline JSON, selector: <css> data-rz-send='query, @filters, {"page": 2}'
rz-headers Request headers for a fetch, or on a store script for its sync; null removes data-rz-headers="X-Tenant: 42"
rz-request Fetch options: timeout, abort-key, credentials, keepalive, redirect, cache data-rz-request="timeout: 5s, keepalive"
rz-indicator Elements that get the rouse-request class in flight (default: the trigger; null = none) data-rz-indicator="#spinner"
rz-disable Elements disabled in flight; empty = the host. A <form> can't be disabled: name its <fieldset> data-rz-disable
rz-store On <script type="application/json">: declare a store, optional sync endpoint data-rz-store="cart: /api/cart"
rz-push Send the whole store (PATCH, JSON Merge Patch body) data-rz-push="click: @cart"
rz-pull Refresh the whole store (GET) data-rz-pull="ready interval-[30s]: @cart"
rz-sse Open an EventSource; one stream per element data-rz-sse="ready: /events"
rz-close Triggers that close that element's stream (OR) data-rz-close="sse-[done]"

Method is only ever in the rz-fetch subject (or a form's method / a button's formmethod), never in rz-request. rz-headers, rz-request, and rz-send apply to fetch triggers only.

Trigger grammar

Event-driven directives pair a trigger (when) with a subject (what): [trigger]: [subject]. rz-on, rz-fetch, rz-push, rz-pull, rz-sse, and rz-model take it.

<!-- Call save on click -->
<button data-rz-on="click: save">Save</button>

<!-- Call search 300 ms after typing stops; a | starts the modifiers -->
<input data-rz-on="input|debounce-[300ms]: search">

<!-- Two pairs: Escape anywhere clears; Ctrl+Enter submits -->
<input data-rz-on="keydown|window.key-[escape]: clear, keydown|ctrl.key-[enter]: submit">

<!-- Two triggers sharing one subject: either fires it -->
<a href="/panel" data-rz-on="pointerover focusin: preload">Panel</a>

Modifiers

Modifiers follow the | and chain with dots. They filter a trigger, time it, or change where it listens.

Modifier Effect Example
once Remove after the first fire that passes the filters click|once
prevent preventDefault() submit|prevent
stop stopPropagation() click|stop
stop-immediate stopImmediatePropagation() click|stop-immediate
capture Listen in the capture phase focus|capture
passive Passive listener scroll|passive
self Only when the element itself is the target click|self
outside Only for events from outside the element (listens on document) click|outside
window Listen on window; filters still apply to the element resize|window
document Listen on document visibilitychange|document
root Listen on app.root rz:fetch:success|root
debounce, debounce-[wait] Fire after events stop for wait (default 300 ms) input|debounce-[300ms]
throttle, throttle-[wait] Fire at most once per wait (default 150 ms) scroll|throttle-[100]
leading Fire on the leading edge click|throttle.leading
trailing Fire on the trailing edge click|debounce.leading.trailing
key-[key] Match a KeyboardEvent.key, lowercase; repeatable keydown|key-[enter]
ctrl, alt, shift, meta Require the key held keydown|ctrl.key-[s]
loose Allow held keys the trigger didn't name keydown|key-[enter].loose

Trigger sources

Triggers that aren't DOM events:

Source Fires Example
ready Once the app is up; immediately for anything bound later ready: @cart
wake When the enclosing scope activates (see rz-wake), and on each reconnect wake: /panel
none Never; binds nothing none
timeout-[delay] Once, after delay timeout-[5s]
interval-[delay] Every delay interval-[30s]
media-[query] When the media query matches, now or later media-[(min-width: 640px)]
intersect, intersect-[ratio] Each time the element enters the viewport, at least ratio visible (0–1, default 0) intersect-[0.5]|once
interact On each pointerover or focusin on the element; add once for the first only interact|once
idle Once, when the browser is idle idle
edit-[@store], edit-[@store.key] When the user edits the store, or that top-level key; framework writes don't count edit-[@cart]|debounce-[500ms]
sse-[name] On a server-sent event named name from this element's stream or its descendants'; sse-[message] for unnamed messages sse-[done], sse-[tick]|root

On a source only once, timing, and its argument apply (sse also takes root); other modifiers are ignored without a warning.

Scopes

A scope is a synchronous setup function that returns a plain object: the keys that bare paths in its region resolve against. A scope is not a component: no template, props, or re-render. Its return value is not made reactive; reactivity comes from the signal and reactive state the setup creates. Expose a signal through a getter, with a setter if rz-model writes it. A reactive object or array can be returned as-is.

import { reactive, signal } from 'rousejs';

app.scope('search', ({ host, app, term }) => {
  // A signal is read through a getter; add a setter if rz-model writes it
  const query = signal('');
  // Reactive objects and arrays track themselves; return them as-is
  const results = reactive([]);

  async function run() {
    // signal: term aborts the request if the scope is torn down mid-flight
    const { data, error } = await app.fetch(`/search?q=${encodeURIComponent(query())}`, {
      signal: term,
    });
    if (!error) {
      results.splice(0, results.length, ...data.items);
    }
  }

  // Search once when half the region scrolls into view
  app.on(host, { intersect: 0.5 }, run, { once: true, signal: term });

  return {
    // Getter for reading, setter so rz-model can write
    get query() { return query(); },
    set query(v) { query(v); },
    results,
    run,
    clear() { query(''); },
    // Handlers get { el, e, render }; render.index is the clicked row
    remove({ render }) { results.splice(render.index, 1); },
    // Optional lifecycle hooks
    connect() {},
    disconnect() {},
  };
});
<div data-rz-scope="search">
  <!-- Typing writes query; run fires 300 ms after typing stops; Escape anywhere clears -->
  <input 
    data-rz-model="query"
    data-rz-on="input|debounce-[300ms]: run, keydown|window.key-[escape]: clear"
  >
  <ul>
    <!-- One <li> per result, matched by id across re-renders -->
    <template data-rz-render="results" data-rz-key="id">
      <li>
        <span data-rz-text="%::position"></span>. <span data-rz-text="%.title"></span>
        <button data-rz-on="click: remove">x</button>
      </li>
    </template>
  </ul>
</div>

Lifecycle hooks

connect() and disconnect() are optional methods on the returned object. Both are called with this as that object and no arguments.

Hook Runs Order
connect() Once per instance, after the region's directives bind setup -> rz:scope:init -> bind -> connect() -> rz:scope:connect
disconnect() On teardown: the host leaves the DOM, or app.destroy() term aborts -> bindings detach -> disconnect() -> rz:scope:disconnect -> rz:scope:destroy

Stores

A store is one flat reactive object: data, getters (cached computeds; keep them pure), and methods (this is the store). Only plain data syncs. The root must be an object.

const cart = app.store('cart', {
  items: [],
  get total() { return this.items.reduce((s, i) => s + i.price, 0); },
  clear() { this.items = []; },
}, { url: '/api/cart' });

cart.items.push({ id: 1, price: 5 }); // local only; the store now reads dirty
await app.stores.push('cart');        // PATCH /api/cart with the whole store

// or save on every edit instead of calling push yourself:
app.on({ edit: 'cart' }, () => app.stores.push('cart'), { debounce: 500 });
<!-- Autosaves 500 ms after the last edit; the button saves on demand -->
<script
  type="application/json"
  data-rz-store="user: /api/user"
  data-rz-push="edit-[@user]|debounce-[500ms]: @user"
>{ "name": "Ada" }</script>

<input data-rz-model="@user.name">
<button
  data-rz-push="click: @user"
  data-rz-attr="disabled: @user::status.syncing"
>Save</button>
<span data-rz-class="unsaved: @user::status.dirty">Unsaved changes</span>

Declarative stores

A <script type="application/json" data-rz-store="name: /endpoint"> declares a store when it's scanned: at start(), or when inserted later. The body is the initial data and must be a JSON object; an empty body starts the store as {}. Invalid JSON logs an error, in production too, and creates no store.

The script element is the store's declaration. Replacing it with a new script of the same name re-declares the store:

<!-- A server re-render swaps the script; the store re-seeds from the new body -->
<script type="application/json" data-rz-store="cart: /api/cart">
  { "items": [] }
</script>

Store manager methods

app.stores manages every store by name. It's a manager, not a record of stores.

Method Returns Notes
get(name) The store, or undefined The same reactive object @name reads in markup and app.store() returns. Writes through it update bindings and mark the store dirty
has(name) true if the store exists A <script data-rz-store> store exists only after start()
status(name) { syncing, error, lastSync, dirty, dirtyRoots }, or undefined The same reactive object as @name::status. Its dirtyRoots is an object keyed by root (dirtyRoots.items), not an array
isDirty(name, root?) true if the store, or one top-level key, has unsynced edits root is a top-level key ('items'), not a path. Updates a microtask after an edit
dirtyRoots(name) Array of top-level keys with unsynced edits; empty when clean A copy: changing it doesn't affect the status
snapshot(name) Plain, non-reactive deep copy of the current data Data fields only: getters and methods are dropped, and non-plain objects (Date, Map) become {} with a dev warning. Safe to serialize
baseline(name) Plain, non-reactive deep copy of the last synced data What revert() restores and dirty checks compare against. Same copy rules as snapshot()
push(name) / pull(name) Promise<void>; never rejects Syncs now; check status(name).error for the outcome. A push during another push waits and sends once
revert(name, path?) true if anything changed Restores the last synced data. With path, restores one field or branch
reset(name) Restores the initial data: from creation, the last update(), or the last re-declared script. The store then reads clean
commit(name) Marks the current data synced; sends nothing. Use it when the data reached the server another way
update(name, state) The store Replaces the data; it becomes the new reset() and revert() target, and the store reads clean. Sends nothing
deposit(name, payload) false if the store is missing or a listener canceled Merges a payload as JSON Merge Patch, the way rz-deposit does. The payload's keys become synced; other unsaved edits stay
config(name, partial) Merges partial into the sync config (url, headers, timeout, …)
remove(name) Deletes the store. Existing references keep working but stop syncing and firing edit triggers

Render

rz-render repeats a <template> over a value. In each instance, % is that instance's item; bare paths and @store paths resolve as they do outside the template.

<!-- A keyed list from a store; each row reads its item through % -->
<template data-rz-render="@todos.items" data-rz-key="id">
  <li>
    <span data-rz-text="%::position"></span>. <span data-rz-text="%.title"></span>
    <!-- Stores are reachable and bare paths fall through to the enclosing scope -->
    <span data-rz-text="@todos.owner"></span>
  </li>
</template>

<!-- Boolean mode: renders the instance while the value is true -->
<template data-rz-render="@todos.isEmpty"><p>Nothing to do.</p></template>

Render modes

What rz-render renders for each kind of value, and what % holds in each instance.

Value Renders %
Array One instance per item The item, of any type
Plain object One instance The object
Number n instances, floored; negative or non-finite renders none The number, in every instance
Anything else (boolean mode) One instance while truthy, none while falsy The value

Render targets

An item with a renderTarget field renders its instance there instead of beside the template.

<!-- Each card renders into the column its renderTarget names -->
<template data-rz-render="@board.cards" data-rz-key="id">
  <article data-rz-text="%.title"></article>
</template>
<section id="todo"></section>
<section id="done"></section>

<!-- @board.cards: [{ "id": 1, "title": "Write docs", "renderTarget": "#done" }] -->

Requests

An rz-fetch sends its element's data. Payload precedence: rz-send -> the element's own field -> a form's or fieldset's enabled fields. GET and HEAD send it as query parameters; other methods send a body in the encoding you declare.

<!-- GET /search?q=…: a field sends its own name and value -->
<input name="q" data-rz-fetch="input|debounce-[300ms]: /search" data-rz-place="#results">

<!-- POST a JSON body built from scope and store state; the Content-Type picks the encoding -->
<button
  data-rz-fetch="click: POST /notes"
  data-rz-send="title, @prefs"
  data-rz-headers="Content-Type: application/json"
>Save</button>

<!-- Files need multipart, declared the HTML way -->
<form action="/avatar" method="post" enctype="multipart/form-data" data-rz-fetch="submit">
  <input type="file" name="avatar"><button>Upload</button>
</form>

Overlapping requests

Every request has an abort key: the trigger element's own by default, or a shared one from rz-request="abort-key: search" (abortKey in app.fetch). An app.fetch without triggerEl has none unless you set it.

Server responses

The server answers a Rouse request with HTML or JSON, and can steer where it goes with Rouse-* response headers.

HTTP/1.1 200 OK
Content-Type: text/html
Rouse-Place: beforeend: #log
Rouse-Trigger: entry-added

<li>Saved at 10:42</li>

Rouse-Place appends the HTML to #log, overriding the trigger's rz-place. Rouse-Trigger dispatches entry-added from the trigger after rz:fetch:success. A data-rz-on="entry-added: refresh" on the trigger or an ancestor hears it.

Requests identify themselves: a fetch sends Rouse-Request: true; a push or pull sends Rouse-Sync: push | pull and Rouse-Store: <name>. A null header value in rz-headers drops any of them.

A fetch response is routed by content type:

Unnamed rz-sse messages route the same way, without headers.

Response headers

The server can override the client with response headers. The header wins over the markup.

Header Effect Applies to
Rouse-Place Replaces rz-place for this response, same grammar (beforeend: #log, delete). Beats rz-place="null" fetch
Rouse-Deposit Replaces rz-deposit for this response (@results) fetch
Rouse-Trigger Dispatches the named event from the trigger element after :success; detail is the response fetch, push, pull
Rouse-Redirect Navigates the page to the URL; the rest of the response is ignored fetch
Rouse-Push-Url history.pushState to the URL; same origin only fetch
Rouse-Replace-Url history.replaceState to the URL; same origin only. Rouse-Push-Url wins if both are sent fetch

A redirect the browser follows (3xx) navigates the page to the final URL when it's same-origin, and fails with REDIRECTED when it isn't.

Errors

A failed request never rejects. It resolves with error set and fires rz:fetch:error (rz:push:error, rz:pull:error for sync). Error responses skip rz-place and rz-deposit: a 4xx/5xx body reaches the page only if the server routes it with a header or your code renders it.

Error codes

error is null on success, else { code, message, body?, parseError?, original? }. code identifies the failure.

code Means
HTTP status (number) Non-2xx response. body is the parsed error body; parseError is set if it didn't parse
TIMEOUT The timeout elapsed
CANCELED Aborted by a newer request or a signal (fires :abort, not :error), prevented in :config, or dropped by a guard
NETWORK_ERROR No response, after the retry for reads; original is the thrown error
PARSE_ERROR A 2xx body that couldn't be parsed: malformed JSON, or an event stream (open it with rz-sse)
REDIRECTED The browser followed a redirect to another origin
INTERNAL_ERROR Rouse threw unexpectedly

Handling strategies

Pick by who owns the error UI. Strategies can be combined: a server-routed form error and an app-wide toast can handle the same response.

Strategy Mechanism Fits
Server-routed Rouse-Place or Rouse-Deposit on the error response Server-rendered error HTML, field validation; no client code
Trigger-local data-rz-on="rz:fetch:error: showError" on the trigger or an ancestor One form or widget
App-wide app.on('rz:fetch:error', fn) Toasts, logging, redirect to login on 401
Interceptor app.interceptor('error', fn) Normalize or rewrite error for every fetch, push, and pull
Inline const { error } = await app.fetch(…) Code that calls app.fetch
Store sync @name::status.error, rz:push:error, rz:pull:error Failed push or pull
<!-- Field validation: a 422 JSON body deposits per-field messages -->
<script type="application/json" data-rz-store="form-errors">{}</script>

<form action="/signup" method="post" data-rz-fetch="submit">
  <input name="email" data-rz-class="invalid: @form-errors.email">
  <p data-rz-text="@form-errors.email"></p>
  <button>Sign up</button>
</form>
HTTP/1.1 422 Unprocessable Content
Content-Type: application/json
Rouse-Deposit: @form-errors

{ "email": "Enter a valid email address." }
// App-wide: e.detail is the full response; data is the parsed error body
app.on('rz:fetch:error', (e) => {
  const { error, data } = e.detail;
  if (error.code === 401) location.assign('/login');
  else toast(error.message);
});

// Interceptor: listeners and app.fetch callers see the returned error
app.interceptor('error', (error) => {
  if (error.code === 'TIMEOUT') {
    error.message = 'The server took too long to respond.';
  }
  return error;
});

Programmatic API

Everything markup does is also callable from JavaScript, through the app and a few package exports.

// Request through the app's config and interceptors; never throws
const { data, error } = await app.fetch('/api/user', { timeout: 5000 });

// Listen with the same modifiers as markup: keyup|ctrl.key-[arrowup]
app.on('keyup', bump, { ctrl: true, key: 'ArrowUp' });

// Insert HTML the way rz-place would
app.place('#log', '<li>Done</li>', { position: 'beforeend' });

// Add a header to every request
app.interceptor('request', (config) => {
  config.headers['X-Trace'] = crypto.randomUUID();
  return config;
});

App members

Methods and properties on the app that rouse() returns.

Member Returns Notes
app.scope(name, setup) The app Registers a scope; chainable
app.store(name, data, config?) The store Creates a store; see Stores
app.start() Scans and binds; a second call warns and does nothing
app.destroy() Unbinds everything, fires rz:app:destroy, and ends every listener and request. Terminal
app.fetch(url, options?) Promise<{ data, error, status, headers, response, config }>; never rejects Sends a request through the app's config and interceptors
app.on([target,] event, callback, options?) A teardown function Listens until the app is destroyed or options.signal aborts
app.place(target, html, options?) true if anything was placed Inserts HTML the way rz-place does
app.sse(url, options?) A close function Opens a stream, or joins one already open at that URL
app.interceptor(phase, fn, options?) A teardown function Adds an interceptor; phase is 'request', 'response', or 'error'
app.stores The store manager See Store manager methods
app.root The root element Resolved from config.root at creation. Every event bubbles to it, and app.on listens on it by default
app.config The resolved config Read-only after creation. Per-request options go in rz-request, app.fetch options, or a request interceptor
app.isReady true once rz:app:ready has fired false until the frame after the first scan. Once it's true, a ready trigger fires immediately

Package exports

Named exports from rousejs.

Export Returns Notes
rouse(config?) An app (RouseApp) Also the default export. One app per root
signal(value) A signal: s() reads, s(v) writes One reactive value. Use it for scope state, exposed through a getter
computed(fn) A cached derived value, read as c() Recomputes only after a value fn read changes. Keep fn pure: no writes or side effects
effect(fn) A stop function Runs fn now and whenever what it read changes. Created in a scope setup, it stops with the scope
reactive(object) A deeply reactive proxy of the object or array Stores are already reactive; a scope's returned object isn't. Mutate the proxy in place
dispatch(el, name, detail?, init?) The CustomEvent Dispatches a bubbling event from el. Use it to signal between nested scopes
createKey(prefix?) A key unique for the page's lifetime For rz-key on client-created items. Assign it once at creation, never in a getter

Interceptor phases

Interceptors run on every fetch, push, and pull the app makes, in registration order, and may be async. skipInterceptors: true in app.fetch opts one call out; markup can't. rz-sse doesn't use them. Each app.interceptor call returns a teardown and takes { signal }.

Phase Function Runs
request (config) => config After :config listeners, before sending. The return is merged into the config; a throw fails the request through the error phase
response (data, response, config) => data On a 2xx response, before routing. The return replaces data
error (error, config) => error On any failure, including HTTP errors, timeouts, and cancellations, after the retry. The return replaces error

TypeScript

Rouse ships its own types. The JavaScript API is typed end to end; markup is not: directive values are strings, so nothing checks data-rz-text="usr.name" against the scope.

import type { LifecycleEventMap, RenderHandlerCtx, ScopeSetup } from 'rousejs';

type Todo = { id: string; title: string };

// The type argument types host
const todos: ScopeSetup<HTMLUListElement> = ({ host }) => ({
  get count() { return host.children.length; },
  // Inside rz-render, render.item is the row's Todo
  remove({ render }: RenderHandlerCtx<Todo>) { /* render.item.id */ },
});

// app.on infers the detail from the event name
app.on('rz:fetch:error', (e) => console.warn(e.detail.status));

// Elsewhere, index LifecycleEventMap
el.addEventListener('rz:store:patch', (e) => {
  const { storeName } = (e as CustomEvent<LifecycleEventMap['rz:store:patch']>).detail;
});

Exported types

Import these with import type { … } from 'rousejs'.

Type For
RouseConfig Options for rouse()
ScopeSetup<E> A setup function; E types host
ScopeCtx<E> The setup's argument: { host, app, term, scan }
HandlerCtx<E> A handler's argument: { el, e, render }
RenderHandlerCtx<Item, E> A handler's argument inside rz-render; render.item is Item
BindableValue What a binding can write
LifecycleEvent Union of every lifecycle event name
LifecycleEventMap Lifecycle event name → detail type

Lifecycle events

Rouse dispatches rz:* events as an app, scope, request, store, or stream changes state. Listen with app.on or use one as a trigger in markup.

// Report failed requests from anywhere in the app
app.on('rz:fetch:error', (e) => toast(`Request failed: ${e.detail.status}`));

// Add a header to fetches from one form; config is the object that gets sent
app.on(form, 'rz:fetch:config', (e) => {
  e.detail.config.headers['X-Form'] = form.id;
});

// Cancelable: veto an HTML placement
app.on('rz:dom:place:before', (e) => {
  if (!e.detail.payload.trim()) e.preventDefault();
});

Every event bubbles to app.root. Each fires from the element it describes:

All lifecycle events

Every rz:* event, when it fires, and its detail.

Event When Detail
rz:app:start start() begins, before the scan { app }
rz:app:ready The frame after the first scan { app }
rz:app:destroy destroy() { app }
rz:scope:init Setup returned { context, instance }
rz:scope:connect Bindings attached, after rz-wake { instance }
rz:scope:disconnect Bindings detached { instance }
rz:scope:destroy Torn down; fires last. instance is for recognizing the scope, not calling it { instance }
rz:fetch:config Before sending. Cancelable: prevented, nothing else fires. Edit config in place (absolute url, method, headers); it's the object sent { config }; push/pull add storeName
rz:fetch:start Sent as :config
rz:fetch:success 2xx response. Exactly one of :success, :error, :abort fires Fetch: the response { data, error, status, headers, response, config }. Push/pull: { storeName, result }
rz:fetch:success:{html,json,file} Right after :success, per content type; what rz-place and rz-deposit route the response
rz:fetch:error Error status, network failure, or timeout as :success
rz:fetch:error:{html,json,file} Right after :error, per content type the response
rz:fetch:abort Aborted by a newer request or a signal as :config
rz:fetch:end Always last as :config
rz:store:patch:before Before a payload is applied. Cancelable; payload can be rewritten { storeName, operation, data, payload }
rz:store:patch Payload applied { storeName, operation, data, payload, response }
rz:store:patch:skipped An edit made during the request kept the response from applying. A deposit never skips { storeName, operation, localData, serverData, response }
rz:dom:place:before Before placing. Cancelable; payload can be rewritten { target, position, payload, source }
rz:dom:place Placed { target, position, payload, source }
rz:sse:config Before the first connection, once. Cancelable; edit config in place { config }
rz:sse:open Each successful (re)connection { config }
rz:sse:message:{html,json} Unnamed messages, before :message; what rz-place and rz-deposit route as :message
rz:sse:message Every message { config, event, data, raw, lastEventId }
rz:sse:error Connection lost. Cancelable: prevented, no reconnect { config, attempt }
rz:sse:close Stream closed { config, reason }

Multiple apps

Each rouse({ root }) is self-contained: its own stores, scopes, interceptors, listeners, and config, so @cart in one app never reaches another app's cart. Use separate apps for micro-frontends or widgets that must stay isolated. One app per root; after destroy(), a new app can mount on the same element.

// The page app owns the whole document
const page = rouse();
page.start();

// Later, a widget with its own app. Constructing it first marks the root,
// so the page app skips the widget's markup when it's inserted
const root = document.createElement('div');
const widget = rouse({ root, baseUrl: '/widget-api' });
widget.store('cart', { items: [] }); // a different cart from the page's @cart
document.querySelector('#sidebar').append(root);
widget.start();

Silent pitfalls

These fail without an error, at most a dev warning. Most do nothing; some do the wrong thing.