W WRNexusJS
Frontend · Package reference

@wrnexus/csr

Reactive, navigation, and realtime browser runtimes.

v0.8.7Private registryFrontend

Install the package

After WorkRoot approves private registry access, install the release-aligned package:

bun add @wrnexus/csr@0.8.7

Request preview access. Never put registry tokens in source control.

Pages can opt into restoration across client navigation:

page Users {
  navigation {
    preserve = ["filters", "pagination", "scroll", "tabs", "expanded"]
  }
}

Form-like categories restore named inputs, selects, and textareas. Password, file, hidden, CSRF/token/secret/credential fields, and elements marked data-no-preserve are never saved. For tab, expanded, or component UI state, mark stable elements with data-wrn-preserve="key"; their value and ARIA selected/expanded state are restored. State is scoped to pathname plus query.

Typed server actions

createActionClient<Input, Output>(route, name) supports programmatic calls. Schema-backed WRN actions also export __wrnexusActionClients, whose input and output are inferred automatically. Enhanced forms expose data-wrn-action-state="pending|success|error" and dispatch bubbling wrnexus:action:optimistic, :pending, :success, and :error events. Success details contain returned data and invalidated cache tags; error details contain field errors. Without JavaScript, the same form posts to its page and receives a 303 redirect or accessible validation response.

The browser-side client runtime for WRNexusJS — generic, self-contained JS that hydrates server-rendered pages with reactivity, client-side navigation, and realtime rooms.

Part of the WRNexusJS framework — an SSR-first, Bun-native full-stack web framework.

Overview

@wrnexus/csr holds the three client runtimes that WRNexusJS serves to the browser. Components are authored as .wrn files and rendered on the server; this package provides the single, generic runtime that hydrates that HTML in the browser — there are no per-component browser bundles. Each runtime is exported as a plain-JS string (no build step, no imports) intended to be served verbatim from a well-known URL:

  • reactive at /__wrnexus/reactive.js — reactive directives (data-scope, data-text, data-for, …)
  • nav at /__wrnexus/nav.js — SPA-style client navigation with graceful fallback
  • realtime at /__wrnexus/realtime.js — WebSocket "rooms", declarative or programmatic

The package itself runs on the server (it just returns strings); the strings it returns run in the browser. A dev/prod server (see @wrnexus/core) is responsible for actually serving them.

bun add @wrnexus/csr
Private package — the machine must be authenticated to the wrnexus npm org
(a read token in ~/.npmrc). Requires Bun (Node is not supported).

API

All exports come from the package root (@wrnexus/csr). The runtime source is delivered as strings, so the "API" on the server side is small; the real surface is the browser directives/globals each string installs.

Runtime strings

ExportTypeServed atContents
REACTIVE_RUNTIMEstring/__wrnexus/reactive.jsReactive directive runtime
NAV_RUNTIMEstring/__wrnexus/nav.jsClient-side navigation runtime
REALTIME_RUNTIMEstring/__wrnexus/realtime.jsRealtime rooms runtime

Accessor functions

Convenience getters that return the same strings.

getReactiveRuntime(): string   // → REACTIVE_RUNTIME
getNavRuntime(): string        // → NAV_RUNTIME
getRealtimeRuntime(): string   // → REALTIME_RUNTIME

Browser: reactive directives

Applied to any subtree containing data-scope. Expressions are parsed by a tiny eval-free evaluator, so a strict CSP with no unsafe-eval works.

DirectivePurpose
data-scope="count: 0, name: 'x'"Declare reactive state on a subtree
data-on-<event>="count++"Run a statement in scope on a DOM event
data-text="expr"Bind an element's textContent to an expression
data-show="expr"Toggle visibility while preserving interactive state

Compiled conditional rendering and dynamic component cases omit inactive elements from the live DOM. data-show is a visibility directive for stateful controls and keeps its element mounted. Neither mechanism is authorization: never place secrets in client-rendered branches. Authorize on the server and return only data the current request may access.

data-for="item in list" (opt. index and key item.id)Per-item rendering; stable keys preserve DOM identity during reorder
data-key="item.id"Alternative key declaration for data-for templates
{{expr}} or {expr}Interpolation inside text nodes and attribute values
data-wrnexus-csr="id"Target for a generated CSR fetch binding (fetches /__wrnexus/csr?...)

Supported expression features: literals, identifiers, member access (a.b, a[b]), function/method calls, arrays, objects, arithmetic, comparison, equality, logical (&& ||), unary (! - +), and ternary. Statements support ++/--, assignment operators (= += -= *= /= %=), and bare expression/method calls. Rendering is dependency-tracked: a signal change only re-runs the renderers that actually read it.

Browser globals installed: window.__wrnexusHydrateScopes(root) and window.__wrnexusHydrateCsrFetches(root) — both idempotent, so re-running after a DOM swap or HMR morph is safe. Both run automatically on DOMContentLoaded.

Browser: navigation

Intercepts same-origin <a> clicks, fetches the target page, and swaps the #app container in place (via importNode — not innerHTML — so it works under a Trusted-Types CSP), updating history, title, and scroll, then re-hydrates. Cross-origin links, modified clicks, download/data-no-nav/rel="external"/target links, non-HTML responses, or a missing #app fall back to a full browser navigation.

  • Programmatic navigation: window.__wrnexusNavigate(url)
  • Emits a wrnexus:navigated CustomEvent (detail.url) after each swap
  • Sends x-wrnexus-nav: 1 on fetches so the server can return the page fragment
  • Appends any /__wrnexus/* runtime scripts the incoming page needs but the current document lacks

Browser: realtime rooms

Connects to /realtime/<name> over WebSocket (ws/wss chosen from location.protocol). Two usage modes.

Programmatic API via window.wire:

wire.room(name): Room          // open (or reuse) a room connection
wire.bindRooms(root?)          // (re)bind declarative [data-room] containers

interface Room {
  name: string;
  send(obj: object | string): Room;         // JSON-stringifies objects; queues until open
  on(type: string, cb): Room;               // filter by msg.type; "*" or a fn = all messages
  on(cb): Room;
  close(): Room;
}

Internal lifecycle messages are emitted to listeners as { type }: __open, __close, __error, and __raw (non-JSON frames, with data). Reconnect uses exponential backoff capped at 5s; queued sends flush on reconnect.

Declarative binding (zero JS) on a data-room="<name>" container:

AttributeOnPurpose
data-room="<name>"containerConnect to room <name>
data-room-user="<id>"containerIdentify the connection (?user=<id>)
data-room-logelementWhere incoming messages are appended
<template data-room-item="<type>">templateRow template for messages of that type (empty = fallback)
%field%inside templatePlaceholder filled from the message field (text/attr only, HTML-escaped)
data-room-statuselementReflects connection state text (connected/disconnected/error)
data-room-status-classstatus elementBase class; a state variant (is-connected, …) is appended
<form data-room-send>formSubmits named fields as a JSON message
data-room-resetform fieldClears that field after send

Rebinds on wrnexus:navigated and closes rooms whose container has left the page.

Usage

Server side — serve the runtime strings from your router (example with Bun.serve):

import { getReactiveRuntime, getNavRuntime, getRealtimeRuntime } from "@wrnexus/csr";

const routes: Record<string, string> = {
  "/__wrnexus/reactive.js": getReactiveRuntime(),
  "/__wrnexus/nav.js": getNavRuntime(),
  "/__wrnexus/realtime.js": getRealtimeRuntime(),
};

Bun.serve({
  fetch(req) {
    const body = routes[new URL(req.url).pathname];
    if (body) {
      return new Response(body, {
        headers: { "content-type": "text/javascript; charset=utf-8" },
      });
    }
    return new Response("Not found", { status: 404 });
  },
});

Browser side — server-rendered HTML that the reactive runtime hydrates:

<div data-scope="count: 0, showPassword: false">
  <button data-on-click="count++">+1</button>
  <span data-text="count"></span>
  <p>Total: {{count}}</p>
  <input type="{showPassword ? 'text' : 'password'}" />
  <button
    data-on-click="showPassword = !showPassword"
    aria-label="{showPassword ? 'Hide password' : 'Show password'}"
  >
    Toggle password
  </button>
</div>
<script src="/__wrnexus/reactive.js"></script>

State interpolation in ordinary attributes is reactive. The compiler keeps the initial SSR value and emits an internal binding so attributes such as type, aria-label, aria-pressed, class, and href update after state changes.

A realtime chat, fully declarative:

<div data-room="lobby" data-room-user="ada">
  <div data-room-status></div>
  <ul data-room-log></ul>
  <template data-room-item="chat"><li>%user%: %text%</li></template>
  <form data-room-send>
    <input name="text" data-room-reset />
    <input type="hidden" name="type" value="chat" />
    <button>Send</button>
  </form>
</div>
<script src="/__wrnexus/realtime.js"></script>

Or drive a room from code:

const room = wire.room("lobby");
room.on("chat", (msg) => console.log(msg.user, msg.text));
room.send({ type: "chat", user: "ada", text: "hi" });

Requirements / Notes

  • Bun-only on the server (the package integrates with Bun-based WRNexusJS servers); the emitted strings are plain browser JS with no dependencies.
  • Browser runtimes are self-contained (no imports, no build step) and idempotent, so re-hydration after navigation or HMR is safe.
  • Designed for a strict CSP: the reactive expression evaluator avoids eval/new Function (no unsafe-eval), and DOM swaps use importNode/attribute writes rather than innerHTML (Trusted-Types friendly).
  • Peer packages: rendered .wrn components and the serving layer come from @wrnexus/core (the sole dependency); pages are rendered by the WRNexusJS dev/prod server.

Complete TypeScript API

Generated from the exact installed package declarations.

/**
 * Browser reactive runtime (Point 2: reactive directives).
 *
 * Served verbatim at `/__wrnexus/reactive.js` for any page that contains a
 * `data-scope`. It is plain browser JS (no build step) and self-contained: it
 * inlines a tiny `signal()` so it has no imports to resolve.
 *
 * Supported directives (this is exactly what the `.wrn` compiler emits):
 *   data-scope="count: 0, name: 'x'"   declare reactive state on a subtree
 *   data-on-<event>="count++"          run a statement in scope on an event
 *   data-text="expr"                   element textContent follows an expression
 *   data-wrn-loop-locals="base64-json"   preserves SSR {#each} item/index values
 *   data-wrnexus-csr="id"                 target for generated CSR fetch bindings
 *   {{expr}} or {expr}                 interpolation inside text nodes
 *
 * Expressions are evaluated by a tiny parser instead of `eval`/`new Function`,
 * so production can use a strong CSP without `unsafe-eval`.
 */
declare const REACTIVE_RUNTIME: string;

/**
 * Client-side navigation runtime, served at `/__wrnexus/nav.js`.
 *
 * Progressive enhancement over normal links: intercepts same-origin `<a>`
 * clicks, fetches the target page's HTML, swaps the `#app` container in place,
 * updates history/title/scroll, ensures any framework runtimes the new page
 * needs are present, and re-hydrates.
 *
 * Before replacing the current page, component lifecycle behaviors are
 * explicitly disposed. This ensures `unmount` hooks and watcher cleanups run
 * before the old DOM is removed.
 *
 * Programmatic navigation is exposed as:
 *
 *   window.__wrnexusNavigate(url)
 */
declare const NAV_RUNTIME: string;

/**
 * Client realtime runtime, served at `/__wrnexus/realtime.js`.
 *
 * Two ways to use it — no hand-written WebSocket code either way:
 *
 * 1. Declarative (zero JS). Put `data-room="<name>"` on a container; the runtime
 *    connects, appends incoming messages to `[data-room-log]` using a
 *    `<template data-room-item="<type>">` (fields via `%field%`, HTML-escaped),
 *    reflects connection state on `[data-room-status]`, and sends a
 *    `<form data-room-send>`'s named fields as JSON on submit (fields marked
 *    `data-room-reset` clear after send). Optional `data-room-user` identifies
 *    the connection.
 *
 * 2. Programmatic: `const room = wire.room("chat"); room.on("chat", fn);
 *    room.send({ type: "chat", text })`. Handles connect, JSON, reconnect.
 *
 * Rebinds on `wrnexus:navigated` (client-side nav) and closes rooms whose
 * container has left the page.
 */
declare const REALTIME_RUNTIME: string;

declare const ACTION_RUNTIME: string;

type OutputHandler<T = unknown> = (payload: T) => void | Promise<void>;
interface OutputHost extends HTMLElement {
    __wrnexusOutputHandlers?: Map<string, Set<OutputHandler>>;
}
declare function registerOutputHandler<T>(host: OutputHost, name: string, handler: OutputHandler<T>): () => void;
declare function invokeOutput<T>(host: OutputHost, name: string, payload?: T): Promise<void>;
declare function createOutputProxy<T extends Record<string, (...args: any[]) => void>>(host: OutputHost): T;

interface ServerCallOptions {
    endpoint?: string;
    signal?: AbortSignal;
    headers?: HeadersInit;
    csrfToken?: string;
}
declare class WrnServerCallError extends Error {
    readonly code: string;
    readonly status: number;
    readonly details?: unknown | undefined;
    constructor(message: string, code: string, status: number, details?: unknown | undefined);
}
declare function callServerFunction<TInput extends unknown[], TOutput>(component: string, functionName: string, args: TInput, options?: ServerCallOptions): Promise<TOutput>;
declare function createServerProxy<T extends Record<string, (...args: any[]) => Promise<any>>>(component: string, options?: ServerCallOptions): T;

declare function collectRefs(root: ParentNode): Record<string, Element>;

interface ClientModuleScope {
    output: Record<string, (payload?: unknown) => void>;
    server: Record<string, (...args: unknown[]) => Promise<unknown>>;
    props: Readonly<Record<string, unknown>>;
    refs: Record<string, Element>;
}
declare function loadClientFunctions(url: string, scope: ClientModuleScope): Promise<Record<string, (...args: unknown[]) => unknown>>;
declare function invalidateClientModule(url: string): void;

interface ActionClientOptions<I> {
    signal?: AbortSignal;
    csrfToken?: string;
    headers?: HeadersInit;
    serialize?: (input: I) => BodyInit;
}
interface ActionResult<O> {
    data: O;
    invalidated: string[];
}
declare class ActionClientError extends Error {
    readonly status: number;
    readonly errors?: Record<string, string> | undefined;
    constructor(status: number, errors?: Record<string, string> | undefined);
}
declare function createActionClient<I, O>(route: string, name: string): (input: I, options?: ActionClientOptions<I>) => Promise<ActionResult<O>>;

interface HydrationScopeApi {
    get(name: string): unknown;
    set(name: string, value: unknown): void;
    call(name: string, ...args: unknown[]): unknown;
    snapshot(): Readonly<Record<string, unknown>>;
    dispose(): void;
}
interface WrnexusBrowserGlobals {
    __wrnexusHydrateScopes?(root?: ParentNode): void;
    __wrnexusDisposeBehaviors?(root?: ParentNode): void;
}

/**
 * @wrnexus/csr — the browser reactive runtime.
 *
 * Components are `.wrn` files rendered on the SERVER (see @wrnexus/dev-server)
 * and hydrated in the browser by this single, generic runtime — served once at
 * `/__wrnexus/reactive.js` for any page that contains a `data-scope`. There are
 * no per-component browser bundles: SSR stays cleanly separated from CSR.
 */

/** The reactive runtime served at `/__wrnexus/reactive.js` (plain browser JS). */
declare function getReactiveRuntime(development?: boolean): string;
/** Component-specific controllers, loaded only when their marker is present. */
declare function getComponentControllerRuntime(development?: boolean): string;
/** The client-side navigation runtime served at `/__wrnexus/nav.js`. */
declare function getNavRuntime(): string;
/** The realtime client runtime served at `/__wrnexus/realtime.js`. */
declare function getRealtimeRuntime(): string;
declare function getActionRuntime(): string;

export { ACTION_RUNTIME, ActionClientError, type ActionClientOptions, type ActionResult, type ClientModuleScope, type HydrationScopeApi, NAV_RUNTIME, type OutputHandler, type OutputHost, REACTIVE_RUNTIME, REALTIME_RUNTIME, type ServerCallOptions, WrnServerCallError, type WrnexusBrowserGlobals, callServerFunction, collectRefs, createActionClient, createOutputProxy, createServerProxy, getActionRuntime, getComponentControllerRuntime, getNavRuntime, getReactiveRuntime, getRealtimeRuntime, invalidateClientModule, invokeOutput, loadClientFunctions, registerOutputHandler };

Examples

Copy-ready examples from the installed package documentation.

Server side — serve the runtime strings from your router (example with Bun.serve)

import { getReactiveRuntime, getNavRuntime, getRealtimeRuntime } from "@wrnexus/csr";

const routes: Record<string, string> = {
  "/__wrnexus/reactive.js": getReactiveRuntime(),
  "/__wrnexus/nav.js": getNavRuntime(),
  "/__wrnexus/realtime.js": getRealtimeRuntime(),
};

Bun.serve({
  fetch(req) {
    const body = routes[new URL(req.url).pathname];
    if (body) {
      return new Response(body, {
        headers: { "content-type": "text/javascript; charset=utf-8" },
      });
    }
    return new Response("Not found", { status: 404 });
  },
});

Browser side — server-rendered HTML that the reactive runtime hydrates

<div data-scope="count: 0, showPassword: false">
  <button data-on-click="count++">+1</button>
  <span data-text="count"></span>
  <p>Total: {{count}}</p>
  <input type="{showPassword ? 'text' : 'password'}" />
  <button
    data-on-click="showPassword = !showPassword"
    aria-label="{showPassword ? 'Hide password' : 'Show password'}"
  >
    Toggle password
  </button>
</div>
<script src="/__wrnexus/reactive.js"></script>

A realtime chat, fully declarative

<div data-room="lobby" data-room-user="ada">
  <div data-room-status></div>
  <ul data-room-log></ul>
  <template data-room-item="chat"><li>%user%: %text%</li></template>
  <form data-room-send>
    <input name="text" data-room-reset />
    <input type="hidden" name="type" value="chat" />
    <button>Send</button>
  </form>
</div>
<script src="/__wrnexus/realtime.js"></script>

Or drive a room from code

const room = wire.room("lobby");
room.on("chat", (msg) => console.log(msg.user, msg.text));
room.send({ type: "chat", user: "ada", text: "hi" });