-
Notifications
You must be signed in to change notification settings - Fork 2
Rapid UI
An opt-in HTML layer (@tundralibs/rapid/ui): a route names a template,
the handler keeps returning JSON-shaped data, and the same route serves both
representations. Not a React/Vite-class framework — a deliberately minimal
mechanism for server-rendered pages with fragment swaps.
| Request | Representation |
|---|---|
on the api surface (server.api) |
JSON — always; redirect dropped |
rapid-swap header present (our runtime) |
HTML fragment — always |
absent, resolved prefer = 'json' (default) |
JSON, the reply goes out as-is |
absent, resolved prefer = 'html'
|
HTML page (layout ▸ core) |
prefer resolves route → ui.prefer → 'json'. A fragment and a
page are both text/html, so Accept could never tell them apart; ignoring
it entirely makes the outcome readable off the route's declaration and needs
no Vary: Accept on any route (the one place Accept is read is the error
page for an UNMATCHED URL — see Errors). Every templated response
instead carries Vary: rapid-swap. What is given up: a form POST with JavaScript disabled is
a plain navigation and would get JSON — set prefer: 'html' on that route
and the no-JS path returns the page.
import { html, raw, render, template } from '@tundralibs/rapid/ui';
type Users = { status: 'ok'; items: string[] } | { status: 'empty' };
export const UserList = template<Users>((data) =>
data.status !== 'ok'
? html`<p class="muted">No data to view here.</p>`
: html`<ul>${data.items.map((u) => html`<li>${u}</li>`)}</ul>`
);-
html`…`escapes every interpolated value (& < > " '); nestedHtmlcomposes without double-escaping; arrays join with'';null/undefined/falserender as''(socond && html\…`works for a BOOLEANcond).0and''render as text — branch on a value withwhen()(below), nevervalue && …`. -
raw(string)is the ONLY unescaped path — one greppable audit point. The representer never calls it;htmlDocumentuses it on constant markup only, every interpolated value is still escaped. - Templates are pure and synchronous — async work belongs in the handler.
Unit-test with
render(UserList.render(data, view)); no server needed. The route option cannot statically prove the handler'scontentmatches the template's data type — that test is the pairing check. -
Where escaping protects you — and where it can't.
htmlescaping is safe for element TEXT and QUOTED attribute values only. Three contexts stay dangerous with untrusted data even though every value is escaped: an UNQUOTED attribute (src=${v}— spaces break out; always quote), a URL attribute (href="${v}"withjavascript:alert(1)contains no escapable character — validate schemes/allowlist URLs before interpolating request-derived values likeview.query.*), and raw-text elements (<script>/<style>— never interpolate into them).raw()is the single opt-out for TRUSTED markup; these context rules are the other half of the audit.
Templates are TypeScript values, so control flow is expression-form (like JSX) — a big page written as one template turns into nested ternaries. Four habits keep it flat:
-
Compute above the markup. Branches and derived lists are plain variables; the
htmlblock interpolates names, not logic. -
Split into components. A page is a tree of small
template()s (the shipped examples run 5–8 per page); shared ones inviews/, a module's beside it as<Module>.views.ts(see Recipes). -
when/eachfor the two shapes&&/.maphandle worst — a value-truthiness branch (0/''would leak as text) and a list with an empty state:const Cart = template<{ items: Item[]; credit: number }>((d) => html`<ul>${ each(d.items, (i) => html`<li>${i.name}</li>`, () => html`<li class="muted">Your cart is empty</li>`) }</ul> ${when(d.credit, (c) => html`<p>Credit: ${c}</p>`)}` );
Both take callbacks, so only the taken branch is evaluated and
whenhands over the narrowed value. -
Editor support. Extensions that highlight lit-style
html`…`templates (VS Code's lit-plugin family) give HTML colouring, tag matching, and auto-indent inside the tag. Truly static chrome can also live in a real.htmlfile — see the designer-handoff recipe.
// Plain API — every verb helper takes the options slot:
app.get('/users', { template: UserList }, listUsers);
app.get('/page', { template: { render: UserList, prefer: 'html' } }, listUsers);
// Decorators:
@Module('Users', { layout: Shell }) // module-wide layout default
class Users {
@GET('/users', { template: UserList }) // route template
list() {
return { content: { status: 'ok', items: [] } };
}
}A wrong import or shape throws RAPID_CONFIG at registration/mount, never at
first request. template is HTTP-only — ignored on @SOCKET/@JOB, the
same documented rule as the reply envelope's cookies/redirect.
Layouts (RapidTemplate<{ body, title? }>) wrap pages, never
fragments — see The three tiers: resolution is route
(layout either form, false to opt out) → @Module({ layout }) → the
app default → none (straight into the core). The CORE — not a layout —
owns htmlDocument({ lang?, title?, meta?, head?, body }), the
doctype/charset/viewport/<title> preamble every page needs (skip it
and browsers quirks-mode the page); view.runtimePath is where the swap
runtime is served, for the core's script tag.
A throwing template surfaces as RAPID_TEMPLATE_RENDER naming the
template (details.template — the template() factory's name argument
earns its keep here); the underlying throw rides debug — rendered in
DEVELOPMENT, always in the server log — and PRODUCTION collapses the
envelope. HTML-resolving errors render through the error-page registry
ending in the built-in DefaultErrorPage — see Errors.
UI configuration is split by NATURE, typed disjoint, at
Application.initialize:
-
The DATA half — serializable, so a config-driven app sets it in
Application.yamlunderui:(per replica):enabled,runtimePath,live,history,prefer,csrfCookie, and the contract headers (swapHeader/swapUnless/redirectHeader). -
The CODE half — templates and functions YAML can never name
(config names code, never imports it):
core,layout,view,errorTemplate/errorTemplates,assets. Config-driven apps pass it in the factory options; programmatic apps put both halves in oneuibag.
# configs/Application.yaml — the replica-level surface
ui:
enabled: true # false = never emit HTML (every request is the api surface)
prefer: html
live: true
history: trueconst app = await Application.initialize({
path: './configs',
ui: { // the CODE half
core: CoreShell,
layout: PageShape, // app-default module-tier layout
errorTemplates: { 404: NotFound, '5xx': ServerFault },
view: (ctx) => ({ // OPT-IN identity projection
user: ctx.auth ? { name: ctx.auth.name as string } : undefined,
}),
},
});ui.enabled: false means the app never emits HTML: every request is the
api surface (see Surfaces) — page
routes are 404, API-first templated routes serve JSON, the
runtime/live/history routes are not registered. To serve both faces from
one app and decide per request, configure server.api instead.
Configuring the UI registers the client runtime at runtimePath
(default /__rapid/ui.js) — served from a string constant (no file
read: works on Workers and with app.fetch()), strong content-keyed
ETag, cache-control: no-cache (a 304 when unchanged — never a stale
runtime after an upgrade). Configure at most once; a second
configuration (the deprecated app.ui() included) is RAPID_CONFIG.
One instance serves both faces; the request decides which it sees.
server.api names the API surface by host, by path prefix, or both:
server:
api:
hosts: [api.example.com] # api.example.com/users
prefix: /api # example.com/api/users — stripped before routingA request to an api host, or under the prefix, is the api surface;
everything else is ui. Routing is identical on both — /api/v1/users
and api.example.com/v1/users both reach the route registered as
/users (the prefix comes off first, then a path-mode version). What
differs:
On the api surface |
On the ui surface |
|---|---|
any templated route → JSON, swap ignored, redirect dropped |
as documented above |
uiOnly route → 404, as if unregistered |
page / fragment |
errors → the JSON envelope; no Vary: rapid-swap
|
error pages |
server.static, /__rapid/*, the docs() page → 404 |
served |
non-templated routes (a redirect stays a real 3xx) |
identical |
A uiOnly 404 is a true no-match: its route middleware never runs and,
with server.methodNotAllowed, it is absent from Allow — so an API
client (or a spoofed Host) learns nothing about it. Hostnames compare
case-insensitively (punycode, trailing dot and port ignored) against the
URL's host; behind a proxy that rewrites it, set
server.api.trustForwardedHost: true (with server.trustProxy) to read
x-forwarded-host — explicit, because most proxies set x-forwarded-for
but not x-forwarded-host, and a header the proxy doesn't overwrite is
one the client can send.
On the context: ctx.surface ('ui' | 'api'), ctx.basePath ('/api'
when the prefix was stripped, else ''), ctx.path (the routed path —
compare against this, never new URL(ctx.url).pathname), and
ctx.href(path). Rapid never rewrites redirects or links. On a
TEMPLATED route the representer owns redirect: a real 3xx on a ui
navigation, the redirect header on a swap, and dropped on the api surface,
where the reply is its content. Dropped, not sent: an API client's
fetch follows a 3xx transparently (a POST's 302 re-issued as a GET) and
would hand back the TARGET's body — the console page, never the session
the client asked for. So one sign-in route returns
{ content: session, redirect: next } and serves the no-JS form, the swap
runtime and the API client alike. An UNTEMPLATED route's redirect stays
a real 3xx on either surface (a URL shortener's hop); keep an API client on
its prefix explicitly — ctx.redirect(ctx.href('/posts')) — or branch:
ctx.surface === 'api' ? { status: 201, content } : { redirect: '/posts' }.
Scheme-relative targets (//host, /\host) are refused at assignment
(open redirect); cross-origin on purpose is a full URL.
Middleware scopes per side with onlyApi() / onlyUi() — app.use(onlyApi(cors({ origin: [uiOrigin] })), onlyApi(rateLimit())).
A route is reachable on BOTH surfaces unless it says otherwise. /users
answers at /api/users (api) and at /users (ui), and onlyApi() skips
its middleware on the second — so onlyApi() scopes where a middleware
runs and must never be the thing that decides access. A route that belongs
to the API declares it: app.get('/users', { apiOnly: true }, …) or
@GET('/users', { apiOnly: true }). It is then absent from the ui surface
(a 404 there, indistinguishable from a missing URL), which is what closes
the un-prefixed path. apiOnly needs an api surface configured; on an app
with none it is a RAPID_CONFIG error at registration. The mirror is
uiOnly: the api surface otherwise serves every templated route as JSON,
pages included, so a page whose content is not an API contract (a sign-in
form) declares { uiOnly: true } and is a 404 there. The two exclude each
other.
Both RUN on sockets and jobs (no surface — fail-closed, unlike
onlyHTTP). Do not scope csrf() or session() to ui on a
shared host (prefix mode) unless nothing on the api surface
authenticates from a cookie: the cookie jar is one, and an unchecked
cookie-authenticated POST at /api/… is a cross-site hole. A pure
token API (Authorization only) may scope them; a browser SPA that logs
in through /api keeps csrf() unscoped and echoes the token.
ui.enabled: false is the blunt form: every request is api — the same
app code serves JSON only, and its uiOnly routes simply do not exist.
Sockets and jobs have no surface; /ws upgrades under the prefix too.
OpenAPI documents a page as application/json when an api surface exists.
In tests, client(app).get('/users', { host: 'api.rapid.test' })
addresses a host.
Pages compose from exactly three tiers — no deeper chaining exists:
-
The core (
ui: { core }) — the DOCUMENT:<head>(meta, css, js), body open, body-end scripts. App-level, optional, and irreplaceable below the app: every page renders inside it. Its per-page "edits" are its data slots —titleandmeta. -
The module/route layout — the PAGE SHAPE (nav, header, footer, a
content slot), always nesting inside the core. Resolution: route
layout→ the@Module's → the app default → none (straight into the core).layout: falseat route or module level opts out of the tier even when a default exists (the print/embed page inside a chrome-heavy module). -
The content — the route's fragment, composed from view
components: plain typed functions (
Card({ title, body })), no mechanism. Change the component, every consumer follows.
const CoreShell = template<RapidCoreData>((d, view) =>
htmlDocument({
title: d.title ?? 'Acme',
meta: d.meta,
head: html`<link rel="stylesheet" href="${view.asset('/site.css')}">`,
body: html`${d.body}<script src="${view.runtimePath}"></script>`,
})
);
const PageShape = template<{ body: Html; title?: string }>((d, view) =>
html`
<header>
<nav>…</nav>
</header>
<main>${d.body}</main>
`
);title (a string or (data) => string on the route's template options)
flows to BOTH wrapper tiers — the core renders <title>, the layout may
show a heading; either may ignore it. On swap replies it rides the
rapid-title response header instead (the history module syncs
document.title from it). meta (a record or (data) => record —
description, og:*, canonical) reaches the CORE only;
htmlDocument renders it escaped:
@GET('/posts/:id:', {
template: {
render: PostPage,
title: (p) => p.title,
meta: (p) => ({ description: p.summary, 'og:title': p.title }),
},
})A swap always gets the bare fragment — both tiers apply to full pages only.
Every template receives a frozen, read-only view as its second parameter:
{ requestId, runtimePath, path, query, asset, csrfToken?, paging? }
(paging is the reply's result-set window — { page, size, total? } —
present only when the handler set the paging key, and the one field here
that comes from the RESPONSE rather than the request; it exists because a
template can read neither response headers nor ctx, so a server-rendered
pager would otherwise have nothing to count with. csrfToken is
the token valid for THIS response — what csrf() issued or confirmed on the
way in, else the request's csrf cookie — in a per-response MASKED form:
render it verbatim into a hidden field or meta tag and send it back as-is,
csrf() unmasks it; set ui.csrfCookie if you renamed the cookie).
Nothing from ctx.auth is reachable by default — the projection names
exactly which fields cross, so identity exposure is safe by construction,
not by discipline.
Type the projection's fields once and templates consume them cast-free —
template's second generic is the projection shape:
import { html, template } from '@tundralibs/rapid/ui';
type AppView = { user?: { name: string } };
const Nav = template<unknown, AppView>((_data, view) =>
html`<nav>${view.user?.name ?? 'guest'}</nav>`
);Handlers that vary SIDE EFFECTS by representation read ctx.isSwap — the
representer's own decision (config-aware: a renamed swapHeader/
swapUnless keeps it correct; always false on the api surface)
instead of re-deriving header checks.
One divergence to know: the representer runs on the RETURN-VALUE channel
only. A handler (or middleware) assigning ctx.response directly on a
templated route bypasses the template AND the Vary stamp — return the
reply instead.
Static serving is CONFIG — server.static, URL prefix → directory,
served framework-side on ROUTE MISS (before the 404): routes always win
a collision, secureHeaders/cors/compress/logging always apply,
and there is no middleware to mount or position:
server:
static:
/assets:
root: ../public # relative → anchored to the config directory
fingerprint: true
/files: ../uploads-public # string shorthandview.asset() versions asset URLs so they can cache forever, LAZILY —
no boot walk: the first template that references
view.asset('/assets/site.css') reads and content-hashes the file
under its fingerprint: true mount (cached; DEVELOPMENT re-checks the
mtime so an edited file re-hashes on the next render). The rendered URL
(/assets/site.css?v=<hash>) is served with
Cache-Control: public, max-age=31536000, immutable — a changed file
gets a new URL, so nothing is ever stale and repeat loads cost zero
requests. Resolution order: an explicit ui: { assets } manifest entry
(the bundler/Workers path — fingerprintAssets() builds one) → the
lazy hash → passthrough, so templates never branch. The hash is a cache
key, not integrity (SRI is a different feature).
Extract your css/js into these files rather than inlining them in the
core — an inline <style> re-ships with every page; a fingerprinted
stylesheet caches forever.
One delegated click + one submit listener over data-action elements —
no inline handlers anywhere, so script-src 'self' suffices.
| attribute | meaning |
|---|---|
data-action |
URL to fetch |
data-method |
default get; forms default post. A GET form sends its fields as the query string, replacing any on the action, like a native form |
data-target |
selector to swap into (default: the element itself) |
data-swap |
replace (default) | outer | append | prepend
|
data-load |
present → fetch the action on DOM ready / when swapped in (a lazy region; GET only) |
Requests carry rapid-swap: 1 (the only header the representer reads) plus
Accept: text/html as a courtesy. Forms post
application/x-www-form-urlencoded. The csrf cookie is echoed as
x-csrf-token — matching csrf()'s defaults; both names are overridable via
data-csrf-cookie / data-csrf-header on <body>.
-
Redirects: a swap response carrying
redirectbecomes200+rapid-redirect: <url>(afetch()would transparently follow the 3xx and hand back the target's body — the wrong thing to swap); the runtime follows it to relative/same-origin URLs ONLY. A plain navigation keeps the ordinary 301/302 — and that server-sideLocationis the handler's value verbatim: the same-origin guarantee is a SWAP-side property, so a handler building a redirect from request input (?next=) must validate it itself — the SCHEME included: the server-side guard refuses a path that resolves off origin but deliberately allows any explicit scheme, and while the bundled runtime ignores ajavascript:redirect header, a BYO client that followsHX-Redirectverbatim would run it. -
In flight: the target carries
aria-busy="true"from the moment its request leaves until the outcome lands (swap or error) — style the pending state off[aria-busy], no script needed — andrapid:requestfires on it with{ url, method }. -
Events:
rapid:swappedafter a successful swap — detail{ status, url, method, swap, title? }, the full swap identity, so listeners (and the history module) never re-derive it (re-init widgets there — fordata-swap="outer"it fires on the REPLACEMENT node, since the original was detached);rapid:errorwith{ status, body }when the response is not swappable HTML — a non-HTML body (the JSON error envelope) is never swapped into the page. A 2xx non-HTML response (a 204 to a POST) also lands asrapid:error— by design, since there is nothing to swap; checkdetail.statuswhen that is a success for you.rapid:progresson the target while a file upload streams out — detail{ url, loaded, total }(totalis0when the browser can't compute it). It tracks bytes LEAVING the browser, so it reaches 100% before the server has parsed the body and answered — keep the pending state untilrapid:swapped/rapid:error. -
Request hygiene, built in: ONE request per target — a newer swap
aborts an in-flight GET (last write wins, so racing clicks can't land
out of order), while an in-flight non-GET is never aborted (the side
effect is already on the wire) and the newer request is DROPPED
(
swap()resolvesfalse) until it settles — no double-submit from two fast clicks, and the reply (a validation error) always lands; modifier-clicks (ctrl/cmd/shift/alt) are left to the browser; a real<a href>INSIDE adata-actioncontainer keeps its native navigation; a form holding a file input posts realmultipart/form-dataoverXMLHttpRequest(the upload gauntlet applies;rapid:progressreports the upload) and the submit button's own name/value is included; keyboard FOCUS survives a swap (an id-carrying focused element inside the target is re-focused on its replacement). -
View Transitions: when the browser supports
document.startViewTransition, every swap rides the native cross-fade — zero configuration, graceful no-op elsewhere. Opt out (or restyle) in CSS — the default cross-fade lives on the old/new pseudos, not the group:::view-transition-old(root), ::view-transition-new(root) { animation: none }. For a MORPH instead of a cross-fade, give repeated items a stable per-idview-transition-name(style="view-transition-name: t-${id}"): items that persist across a swap then GLIDE to their new position — a list reorder or a kanban move animates for one inline style. The flicker to avoid is the opposite mistake: a CSS entry animation ON the swapped-in items replays for every card on every swap, because to the DOM they are all new. -
Script placement: load the runtime (and your own script) at the end
of
<body>or withdefer— the<body data-…>config overrides are read once at evaluation. -
Programmatic:
window.rapid.swap(url, target, { method?, swap?, body? })—targeta selector or Element; resolvestruewhen the swap happened.window.rapid.refresh(target)re-fetches the last GET fragment swapped intotarget(POST sources are never replayed, andappend/prependswaps never register — a "refresh" would re-append; refresh on a target with no recorded source resolvesfalse). These two functions are the base runtime's whole public API (live.jsaddsrapid.live.*,history.jsrapid.history.push) — they exist for the dynamic-update patterns below. Everything further (polling, history) stays app JS over the runtime's events — the attribute surface is deliberately frozen.
The answer to slow data (what Next calls partial prerendering): render
the page immediately with a skeleton element that carries the region's
URL and data-load; the runtime fetches it once the DOM is ready (or
right after the swap that inserted it) and swaps the result in:
html`<section id="stats" data-action="/dashboard/stats" data-load>
<p class="skeleton">Loading…</p>
<noscript><a href="/dashboard/stats">Open stats</a></noscript>
</section>`;That is one extra request, by design: the region is its own route
(it also serves JSON), caches on its own (ETag/Vary per region),
fails on its own (rapid:error — the page stands), and rapid:swapped
fires so history and multi-region chains apply. Each element loads
ONCE; a response carrying data-load for a DIFFERENT action chains. One
that points back at its own action is skipped with a console warning — poll
with rapid.refresh() on a timer instead. GET only. Without JavaScript the skeleton stays,
so a <noscript> link is the honest fallback — the same route serves
the full page. Pages never stream (see Bytes and streams).
One user action often invalidates more than one region of the page.
rapid deliberately ships NO declarative attribute for this
(data-also-update="…" grows without bound); the runtime emits events
and exposes two functions, and the reaction lives in a few lines of
YOUR page script — visible, debuggable, yours. Three patterns, by how
much the reacting code has to know:
1. Chain a swap off rapid:swapped — when the page script knows the
reacting region's URL. Every successful swap bubbles rapid:swapped
from the swapped node, so one document listener routes on e.target.id:
document.addEventListener('rapid:swapped', (e) => {
if (e.target.id === 'card-users') rapid.swap('/cards/stats', '#card-stats');
});The sales-dashboard example wires its bookings table this way: the log-a-sale POST swaps the form, and the listener fetches fresh orders.
2. rapid.refresh(target) — the same chain, URL-free: it re-fetches
whatever GET fragment last landed in the target, filters and query
intact, so the listener names REGIONS, not routes:
document.addEventListener('rapid:swapped', (e) => {
if (e.target.id === 'board') rapid.refresh('#stats');
});This is also how a region keeps its FILTER through an update: the
dashboard refreshes #dash after a logged sale, and the active
?days= period survives because refresh re-fetches the recorded URL.
A region has a recorded source only after its FIRST swap — on a freshly
loaded page fall back to an explicit rapid.swap when refresh
resolves false (the kanban example's freshen() helper).
3. The live channel — updates this user did NOT initiate: another
tab, another user, a cron job. Broadcast server-side and map
rapid:push to the same refreshes (next section). The kanban example
composes all three: a move button's own swap replaces the board, the
rapid:swapped listener refreshes the stats rail, and the broadcast
refreshes every OTHER window.
Server-side bookkeeping that must vary by representation belongs behind
ctx.isSwap — the dashboard's fragments-served meta stat counts
exactly this way instead of re-deriving header checks.
The swap contract is three header names, all configurable — so a mature client like htmx can drive the same routes while rapid keeps its server model:
# the contract headers are DATA — per replica, in Application.yaml
ui:
swapHeader: hx-request # htmx sends HX-Request: true on every request
swapUnless: [hx-boosted, hx-history-restore-request]
redirectHeader: HX-Redirect # htmx follows this natively-
swapHeader— presence selects the fragment (value ignored). -
swapUnless— names whose presence CANCELS the swap: htmx sends its marker onhx-boosted navigations and history restores too, where it expects the full PAGE — without this, boosted links would swap a bare fragment into<body>. All names joinVaryautomatically. -
redirectHeader— the swap-side redirect (D8) rides this name; htmx performs a full navigation onHX-Redirect, exactly the bundled runtime's semantics.
With that config, hx-get/hx-post/hx-target attributes work against
templated routes as-is; give page routes prefer: 'html' so boosted
navigations and address-bar visits render pages. The bundled runtime
follows renamed headers via data-swap-header / data-redirect-header on
<body> — but if you adopt htmx you simply don't serve it. Runnable:
examples/htmx/main.ts drives a poll
entirely through htmx — hx-swap-oob multi-region responses,
declarative polling, a boosted page proving swapUnless, and a reply
redirect landing as HX-Redirect.
The opt-in live bridge (ui.live: true → /__rapid/live.js, served
exactly like the runtime) turns server broadcasts into DOM events — it renders nothing and
swaps nothing, so fragments stay HTTP-fetched with auth/etag/Vary in the
path:
// page script — the whole live story:
rapid.live.connect('comments'); // channels declared via app.channel()
document.addEventListener('rapid:push', (e) => {
if (e.detail.channel !== 'comments') return;
rapid.swap('/posts/' + e.detail.data.postId + '/comments', '#comments');
});
document.addEventListener('rapid:live', (e) => {
badge.classList.toggle('on', e.detail.connected);
});Server side: app.channel('comments') declares the lane — one-way by
construction, clients can never publish into it — and app.publish() /
ctx.publish() broadcast; a cron job pushing an update is the canonical
pattern. The bridge reconnects with capped backoff and resubscribes (a
refused subscribe is console.warned — undeclared channel, authorize
veto); rapid.live.disconnect() stops it. The socket path defaults to
/ws (data-live-path on <body> overrides). UI_LIVE is exported
for serve-it-yourself setups.
Known limit: the bridge rides the rpc WebSocket, which mounts only
in app.start()'s listening server — on ANY fetch()-only deployment
(Cloudflare Workers included) there is no socket to dial, so
live: true serves a script that can never connect, and the first
fetch() logs a warning saying so. An SSE variant is on the roadmap;
until then, live updates are a listening-server feature.
The opt-in history module (/__rapid/history.js, served like the
runtime) gives swap navigation a working address bar and back button —
with no DOM cache, ever: back/forward RE-FETCHES the recorded URL
into the recorded region (marker-keyed history.state; a full
navigation when the region is gone), so what restore shows is always
what the server would serve, with auth/etag/Vary in the path.
Pushes are per-interaction opt-in, never automatic:
<!-- declarative: push the fetched URL (needs an id on the region) -->
<button data-action="/board?owner=Ada" data-target="#board"
data-swap="outer" data-push>Ada</button>
<!-- or push a different page URL -->
<a data-action="/cards/orders" data-target="#orders"
data-push="/orders">Orders</a>// programmatic: a rapid.swap that also pushes
rapid.history.push('/board?owner=Ada', '#board', { swap: 'outer' });The contract that keeps back/reload safe: only push URLs that are
themselves page routes (prefer: 'html') — the same route then serves
the full page on a plain navigation, so a deep link or reload of a
pushed URL just works. document.title syncs from the rapid-title
response header (stamped on swap replies of routes with a title) on
pushed and restored swaps only — an ordinary widget swap never retitles
the tab. One history-bearing region per page; don't push URLs carrying
secrets (they land in the address bar and browser history).
-
Paged list with a pager — the collection is the body and its window is on
view.paging, so a pager needs nothing from the data type and is reusable across every list. Give the links anhrefas well as adata-actionand they work without JavaScript;data-pushputs the page in the address bar so Back re-fetches it:import { type RapidView, template, withQuery } from '@tundralibs/rapid/ui'; const Pager = (view: RapidView) => { if (view.paging?.total === undefined) return html``; const { page, size, total } = view.paging; const last = Math.ceil(total / size); const to = (n: number) => withQuery(view.path, view.query, { page: n }); const link = (n: number, label: string) => html` <a href="${to(n)}" data-action="${to(n)}" data-target="#posts" data-swap="outer" data-push>${label}</a> `; return html`<nav> ${page > 1 && link(page - 1, 'Prev')} <span>Page ${String(page)} of ${String(last)}</span> ${page < last && link(page + 1, 'Next')} </nav>`; }; const PostList = template<Post[]>((rows, view) => html`<section id="posts">${rows.map(Row)}${Pager(view)}</section>` );
The region needs an
id(the history module keys its entry on it) and the fragment root must be that region, since an outer swap replaces the element it targets. The page always travels in the query string: paging can arrive as a request header, but a link can only carry a URL, and starting fromview.queryis what preserves filters and sort. -
Infinite scroll — the fragment ends with its own next button, which replaces ITSELF (
data-swap="outer", nodata-target) with the next page: rows accumulate, and exactly ONE button ever exists — anappendinto the list would leave every previous button alive and re-clickable:import { withQuery } from '@tundralibs/rapid/ui'; const PostPage = template<Page>((data, view) => html`${data.rows.map(Row)} ${ data.hasMore && html` <button data-action="${withQuery(view.path, view.query, { page: data.page + 1, })}" data-swap="outer" >Load more</button> ` }` );
-
Validated forms — the error arm of a form's union (message, per-field problems, values to re-fill) is a primitive:
formState()runs any.parse-bearing schema and hands back typed data or the render-readyRapidFormError— the template types its union asRapidFormError | { state: 'clean' } | …:import { formState } from '@tundralibs/rapid/ui'; const form = await formState(CreatePostBody, body); if (!form.ok) return { content: form.error }; // 200 — the union's own state posts.add(form.data); return { content: { state: 'added' } };
-
Post/Redirect/Get & no-JS forms — give the form's route
prefer: 'html': with JavaScript the runtime swaps the fragment; without it the POST is a plain navigation, so returnredirecton success (the navigation path keeps the real 302 — PRG) and the error-state union page on failure, values re-filled from the returned data. No framework knob — the D3/D8 rules compose into it. The same route is an API endpoint on the api surface: put the result incontentand the redirect is dropped there, no branch in the handler. -
Strict CSP (nonces) — the projection carries per-request data, so a style/script nonce is just a view field (note the built-in
DefaultErrorPagestyles itself with inlinestyle=attributes, which a nonce cannot cover — under a strictstyle-srcsupply your ownerrorTemplates.default):ui: { view: (ctx) => ({ nonce: mintNonce(ctx) }) }atApplication.initializeandhtml\<style nonce="${view.nonce}">…`— pair with your security middleware emitting the matching header. Prefer external files viaserver.static` where you can. -
i18n — same pattern: negotiate the locale in the projection and hand templates a translator as data —
view: (ctx) => ({ t: makeT(ctx.headers.get('accept-language')) }). Templates stay pure;tis per-request-constant. -
Layout composition — the two wrapper tiers cover page structure; anything fancier is a plain function call. A section wrapper shared by a module's fragments is a component its views apply; a layout variant that extends another passes through it explicitly (thread
titleyourself past the first level):const AdminShape = template<{ body: Html; title?: string }>((d, view) => PageShape.render({ body: html`<aside>${adminNav(view)}</aside>${d.body}`, title: d.title, }, view) );
The doctrine that keeps tiers honest: a module
layout:means "this module owns the page shape"; a module that only wants a strip INSIDE the app's shape shouldn't setlayout:at all — wrap in a component and inherit the default. And module-specific css belongs in the module's layout as a body-level<link rel="stylesheet" href="${view.asset(…)}">(spec-legal) — the core's head stays app-wide. -
View separation — the convention the scaffold generates: shared code (the core, error pages, cross-module components) in
views/; each module's fragments co-located as<Module>.views.tsbeside the module, its layout with them. Templates are plain values — organize freely, but this shape keeps "edit the page" next to "edit the module". -
Designer-handoff shells — a truly static chrome file needs no template language: read it at boot, split on a marker,
raw()the halves (server runtimes only):const src = await readTextFile('./views/shell.html'); const [pre, post] = src.split('<!--body-->'); const FileShell = template<{ body: Html }>((d) => html`${raw(pre!)}${d.body}${raw(post!)}` );
Anything dynamic (title, nav, scripts) enters via composition around it — never as expressions in the file.
-
Accessible swaps — three rules, no new mechanism. (1) The runtime sets
aria-busy="true"on the target for the life of its request, so assistive tech holds announcements until the swap lands; style the pending state off[aria-busy]rather than adding a spinner script. (2) Putaria-live="polite"on regions that change WITHOUT a user action — adata-loadregion, a chained multi-region refresh, arapid:push-driven list — and leave it off the region the user just acted on, where focus restore already conveys the result. Fordata-swap="outer"the attribute must be in the fragment's root markup, since the target element itself is replaced. (3) Move focus only when the swap brought something the user must deal with. A failed form POST is the case: a submit button with anidkeeps focus across the swap, so nothing is announced unless you focus the error summary — give ittabindex="-1"and:document.addEventListener('rapid:swapped', (e) => { if (e.detail.method === 'GET') return; const summary = e.target.querySelector('[role="alert"]'); if (summary) summary.focus(); });
Back/forward with the history module re-fetches the region and moves focus nowhere — page-level focus on navigation is the app's call.
-
Template unit tests —
import { view } from '@tundralibs/rapid/testing'for the frozen bag (render(UserList.render(data, view()))), andclient(app).get('/x', { swap: true })to drive the fragment/page/JSON matrix with the app's RESOLVED swap header (htmx config included).
Error pages resolve through a CLOSED registry — ui: { errorTemplates }
keyed by exact status (400–599), '4xx'/'5xx', or 'default'
(errorTemplate is sugar for { default }; both together is a config
error). Resolution is fixed: exact → class → default → the built-in
DefaultErrorPage — so a UI-configured app never shows a browser a raw
JSON envelope; PRODUCTION ships the collapsed disclosure as HTML.
Dispatch beyond that grammar is a typed branch inside one template:
const ErrorPage = template<Record<string, unknown>>((e, view) =>
(e.status as number) === 404
? NotFound.render(e, view)
: (e.status as number) >= 500
? ServerFault.render(e, view)
: BadRequest.render(e, view)
);Every entry receives exactly the disclosure payload the JSON envelope
would carry (PRODUCTION collapses 5xx, never debug) plus requestId,
status, and mode, and renders only when the representation resolves
to HTML: a swap, a route/app prefer: 'html', or — with
errorTemplates configured — an UNMATCHED request whose Accept
explicitly prefers text/html, so the commonest error of all (a browser
navigating to an unknown URL) gets the 404 page while */*/JSON clients
keep the envelope (Accept joins Vary when consulted; this error path
is the one place Accept is ever read; a MATCHED route — templated or
not — keeps its declared representation, so a JSON API route's errors
stay JSON whatever a browser's Accept says). A swap gets the bare fragment, a
page renders inside the CORE
(the module tier is skipped: errors are not module-scoped, and a module
layout may depend on the very data that failed) with
"{status} {message}" as the core's title. Off-HTML (and on the api
surface), the JSON envelope is sent unchanged. A matched route WITHOUT
a template is never a page, so its errors stay JSON even in a
prefer: 'html' app — only the unmatched-URL 404 consults the app-level
prefer (and Accept, as above).
For failed SECTIONS there is deliberately no error template: recoverable
input problems are the form union's own 200-state (formState), and a
hard swap failure fires rapid:error without touching the region
(swapping an error page over a half-filled form would destroy it) — show
a toast or badge from that event (see the blog example's blog.js).
A template consumes data: a templated route whose HTML representation is
asked of a Uint8Array/stream content is RAPID_RESPONSE_INVALID. Stream
replies belong on non-templated routes (or prefer: 'json', where the reply
passes through untouched).
A structural limit to know: rendering is synchronous and
whole-string — a page is built entirely in memory before the first
byte leaves, so HTML never streams. That is the right trade for this
layer's scope (admin surfaces, CRUD apps, fragments measured in
kilobytes): once half a 200 page is on the wire an error can no
longer become a proper error page, and etag/compress must see the
final HTML. Slow DATA is a different problem, answered by lazy regions
(data-load, above) rather than streaming.
A templated route's 200 lists both application/json and text/html;
a page (prefer: 'html') lists text/html only — application/json on an
app with an api surface, where that is what it serves. The reference page the
docs() endpoint mounts is itself a page of the app — rendered inside your
core/layout — see OpenAPI and the API reference.
Runnable examples: examples/dashboard/main.ts
(a sales dashboard: period chips, both swapped-chain patterns,
ctx.isSwap), examples/kanban/main.ts
(all three dynamic-update patterns, live channel, View-Transition
morphs), and examples/htmx/main.ts (the
same contract driven by htmx) — run any with deno run -A and open the
printed URL.