@azerothjs/kit
v2.1.0
Published
The assembled car: per-route SSR, static prerendering, hydration, and one-command production builds over the AzerothJS router, renderer, and HTTP server.
Downloads
393
Maintainers
Readme
@azerothjs/kit
Per-route SSR, static prerendering, hydration, and one-command production builds over the AzerothJS router, renderer, and HTTP server.
Part of AzerothJS - the fine-grained fullstack framework. This is the assembled car: per-route SSR, static prerendering, and hydration wired over the pieces you already have - the router's route table, the renderer, and @azerothjs/http. The kit invents nothing: no new routing system, no new data layer, no config format. It replaces the ~10 lines of hand-wiring every fullstack app otherwise repeats.
- Per-route rendering modes -
server(SSR per request),static(prerendered at build), orclient(SPA shell), chosen with one field on the route you already declared. - Server-correct guards and loaders - a redirecting guard becomes a real 302, a vetoed route serves the shell with the guard's status, and parallel loaders stream into the hydration handoff.
- One-command production -
mountPagesregisters pages plus asset fallback on an@azerothjs/httpapp,azeroth-kit-prerenderwrites the static pages, andbootClienthydrates or renders in the browser.
Install
[!NOTE] ESM-only, Node >= 22.
azerothjsis a required peer:
npm install @azerothjs/kit azerothjsThe whole idea
Your router's route table is the manifest. Add one optional field per route and the kit does the rest:
import { defineRoute } from 'azerothjs';
import type { PageRoute } from '@azerothjs/kit';
export const routes: PageRoute[] = [
{ path: '/', component: Home, render: 'static' }, // prerendered at build
{ path: '/guestbook', component: Guestbook }, // SSR per request (the default)
{ path: '/settings', component: Settings, render: 'client' } // SPA shell; browser renders
];render: 'server'- SSR per request. Loader guards become real HTTP 302s, parallel loaders stream into the hydration handoff, the client picks up without refetching. The default when a renderer is provided.render: 'static'- rendered once at build byazeroth-kit-prerender, served as a file.render: 'stream'- streaming SSR: the shell flushes immediately and each<Suspense>boundary follows as it settles.render: 'client'- the pristine SPA shell; the browser renders. The default with no renderer.
Modes inherit down children. A parameterized path (/users/:id) needs staticParams to be
'static' - that enumerates which param sets get prerendered, and any value it does not list
falls through to a live render. Without staticParams a parameterized static route is a build
error, because one pattern cannot become one file.
The three calls
Server entry (entry.server.ts, built with vite build --ssr):
import { createPageRenderer } from '@azerothjs/kit/ssr';
import { App } from './App.azeroth';
import { routes } from './routes.ts';
export { routes };
export const renderPage = createPageRenderer(App, routes);Client entry (main.ts):
import { bootClient } from '@azerothjs/kit/client';
import { App } from './App.azeroth';
bootClient(App); // hydrates SSR/static pages, renders client pages - it checks the rootHTTP server:
import { App } from '@azerothjs/http';
import { mountPages } from '@azerothjs/kit';
import { renderPage, routes } from '../application/dist-server/entry.server.js';
const app = new App();
// ...your API routes first; they keep priority...
mountPages(app, { routes, clientDir: 'application/dist', renderer: renderPage });That is the entire integration. mountPages registers each page in its mode plus asset fallback; register it last.
What a render: 'server' loader can do
A per-request render answers a real Request, and the kit hands it to everything that runs for
that one visitor: every server guard walk, every level's loader, and the render itself, where
useRequest() reads the same object. A loader can therefore read the visitor's cookie or
authorization header and derive from it, instead of shipping a page that fetches its own data
after hydration.
It can also call the app's own api without a socket. When the pages are mounted on an
@azerothjs/http App that has registered an api, a typed client with a relative baseUrl
dispatches through that App in process, carrying the visitor's identity:
{ path: '/guestbook', component: GuestBook, render: 'server', loader: () => client.guestbook.list() }The rules that govern it - GET and HEAD only, which headers are forwarded, the nested request
root, the signal and the deadline - belong to @azerothjs/http and are written up in its README
under "Calling your own api in process". One of them decides how you compose your server: an
in-process call enters at app.handle, so an outer pipeline(app, requestId(), securityHeaders(),
csrfCookie(), rateLimit()) does not run for it. Keep rateLimit in that pipeline rather than in
app.use - installed with app.use it runs on the in-process leg too, where its default key
throws 500 rate-limit-key-unavailable because the request has no peer.
Reading identity makes a page private
A render that consulted the request is a function of (URL, visitor), not of the URL alone, so the
kit answers it private, no-store and never caches, persists or shares it - the same treatment a
guarded page gets, for the same reason. Reading args.request at all counts, destructuring the
loader arguments included, as does a non-null useRequest() and an in-process api call. The
over-approximation is deliberate: a page can lose shared cacheability it might have kept, never
its correctness.
One residual worth knowing on a streamed page: its headers leave with the shell, so a read inside
a <Suspense> continuation comes too late to mark it. Loader-phase and main-pass reads are
complete before the first byte and do mark it.
Shared renders never see a request
A render whose output is written to disk or can be handed to more than one visitor is given no
request at all: the build-time prerender, ISR's background regeneration, and ISR's first render
of a page it has not cached yet, the one whose output seeds the cache. In those args.request
and useRequest() are both null and no in-process api exists, which is what keeps one visitor's
identity out of a cached entry or a build artifact.
That has a consequence to plan for. A static page whose loader needs identity or the api fails
the build loudly, with the path named and nothing written. An ISR page the build never
enumerated, a parameterised or wildcard path with revalidate and no staticParams, fails at
its first live render and at every one after it, answering 500 private, no-store and caching
nothing, exactly as it does today with a loader that rejects. A page that needs the visitor is
render: 'server'.
Development - @azerothjs/kit/dev
devPages is that same mount, over the same route table and the same renderer, fed by vite
instead of by a build. One process and one origin: your API, your pages and the HMR socket all
answer on the server you already run, so dev serves the page contract production serves -
guards as status, page actions, locale negotiation, ISR, streaming, the CSP nonce and the
manifest splice included.
import { devPages } from '@azerothjs/kit/dev';
import { SSR_SOURCE_ENTRY } from '@azerothjs/kit/dev/entry';
const session = await devPages({
root: appRoot, // the application half - vite's root and its config
entry: SSR_SOURCE_ENTRY, // 'src/entry.server.ts', loaded from source
pages: { manifest, csrf }, // KitOptions without routes, renderer, shell, clientDir
routes: (app) => registerApi(app), // your api routes, registered once
app: { dev: true, observe, onError }
});
const served = await serve(pipeline(session.app, requestId()), { before: session.before });
session.attach(served.server); // the HMR socket rides this server
// session.close() from your shutdown handlerThe entry module exports routes and renderPage - the two names the prerender bin and a
production mount already read - and the session reloads it, re-reads index.html, re-runs
transformIndexHtml over it and re-mounts the pages on the first request after a change. The
mount runs with shell (that transformed html) in place of clientDir, which is the one thing
mountPages needs different without a built client. A first build that fails answers an html
navigation with a readable dev error page naming the file, the line and the frame, and every
other request with the app's own error envelope; a LATER failure keeps the previous build
serving and prints one line saying so. serverAnchor names the module the single-instance check
resolves azerothjs from (it defaults to the kit's own). npm create azeroth@latest wires all
of this into the fullstack template's server/src/main.ts.
HMR rides your server. Vite is given no socket of its own, so the client dials the page's
own origin: no second port to bind, nothing to collide with, and a tab survives the server's own
restart - it reconnects and reloads itself. attach(server) subscribes one 'upgrade' listener
and an upgrade nobody claims still answers 404, so a WebSocket endpoint and the devtools bridge
coexist on the same server (give attachWebSockets its own path).
Refused at startup, loudly, rather than failing silently later: a server.proxy (the session
IS that seam), a base other than /, server.watch: null (no watcher exists at all, so nothing
would ever rebuild - false is a real watcher and is fine), and port, clientPort or host
under server.ws or its deprecated server.hmr spelling - each pins a socket target into the
page that nothing listens on, or that only one machine can reach, and vite warns about none of
them. A server.ws.path is honoured, and every other vite setting is yours.
One reach is missing by construction: a plugin that mounts its OWN middleware is not seen, since
only vite's own urls and the files under the root and publicDir are handed to it.
Vite's host check still guards vite's own urls. Under a hostname that is not an IP literal or
*.localhost, put it in server.allowedHosts or the page loads from your server while vite
answers 403 for the client script, your modules and the stylesheet.
Divergences from production, all of them:
render: 'static'pages render live - there is no build to serve a file from - and arevalidatepage re-renders per request rather than serving a cached copy.cacheis ignored.imagesis a mount error: it needs a built client. Register/_imageyourself, in theroutescallback, withimageHandler({ root: 'application/public' }).- The per-request data cache is per request ROOT, so one
cached()key read by an api route and again by a page render fetches twice. - The seam registers GET and POST catch-alls, so a routed page with no
actionanswersAllow: GET, HEAD, POSTwhere production answersGET, HEAD. /index.htmlanswers your app's 404; production serves the built shell.- An api route registered at a page's own path (in the
routescallback) wins over the page, silently: the api routes live on the outer App and the pages behind its catch-all, where a production mount registers both on one App and refuses the conflict at startup.
The kit declares nothing about vite - no dependency, and no peer, optional or otherwise - so
import('vite') loads the copy your own app installs and a production image built with
npm ci --omit=dev carries none. Declare vite in the half that runs the session, as a
devDependency, at the same range the application declares; the session refuses a loaded vite
whose major is not 8, naming the path and the version it found.
Prerendering
vite build && vite build --ssr src/entry.server.ts --outDir dist-server
azeroth-kit-prerender --entry dist-server/entry.server.js --client distThe bin renders every render: 'static' page through your real loaders and writes it into the client dist, preserving the untouched shell as shell.html for client pages. A static page whose route chain carries a guard is a loud build error - declaration-based, even when the guard would pass at build time: a prerendered file is served without ever running guards, so the combination cannot exist. The same rule refuses the page at mountPages, so a server-only upgrade against an old dist fails the deploy rather than serving stale guarded files. Move the guard into a server-rendered subtree, throw redirect() from a loader (live-rendered requests only; it never runs for prerendered bytes), or use render: 'server'. A static page that redirects during the build is equally a build error, and so is a parameterized static route with no staticParams to enumerate - one pattern cannot become one file. A failed build rolls back every file it had already written, so a partial site is never published. The pass is also available programmatically as prerender() from @azerothjs/kit/prerender.
Enumerated static routes - staticParams
A parameterized route prerenders by enumerating its param sets; anything the enumeration did not list falls through to live SSR at request time:
{
path: '/blog/:slug', component: Post, render: 'static',
staticParams: () => Promise.resolve([{ slug: 'hello' }, { slug: 'world' }])
}The route table ships to the browser, so keep the closure browser-safe: inline data, or a
dynamic import the client bundle never follows eagerly. Invalid param values (empty, /,
dot segments) fail the BUILD, never become a path.
Page actions - action
A route may declare an action: what a plain <form method="post"> on that page posts to, with
no client JavaScript. mountPages registers the POST for the page's own path (and, under prefix
routing, for every language-prefixed one). The body is read first because the CSRF token travels
in a hidden _csrf field, the token is verified, then the page's route-chain guards run through
the same walk its GET runs, and only then does the action - so a write is gated by exactly what
gates the page, and a guard's veto answers with the page's own blocked UI. Returning nothing is a
303 back to the page; returning a value re-renders it at 422 with the value in useActionResult();
throwing redirect(...) goes elsewhere, judged by the same off-origin rule as every redirect.
An action is refused on a render: 'static' page, which has no request to mint a token for, and
on a route with children. The router guide covers the page side and <Form>.
ISR - revalidate
A static page with revalidate serves from a page cache: fresh within the window, past it
the stale copy answers instantly while exactly ONE background render replaces it, in the
language the copy is filed under. Build output seeds the cache through file mtimes; a failed
regeneration keeps the old copy, holds the key for one window so a fast-failing loader renders
at most once per window, and reports one notice per distinct fault through onError with the
failing levels as its cause; an outcome that stopped being static content (a redirect, a
veto, a 404) drops the entry so a guard is never masked.
A page whose route chain carries a guard never enters the cache at all: mounting it as
ISR is refused outright, and a URL that reaches an ISR or a static-file handler but matches a
guarded chain elsewhere in the table renders live per request with cache-control: private,
no-store and x-azeroth-cache: live (without a renderer, the guard walk itself answers).
The prerender pass refuses to write a file for such a url, and a bare shell for one is never
cacheable. A guard makes the page a function of the request's
identity, and an identity-dependent page in a shared cache serves one visitor's data to
the next - so the combination does not exist. Note the boundary: a LOADER that reads
request identity without a guard is invisible to this rule; keep personalized loaders off
ISR pages.
{ path: '/', component: Home, render: 'static', revalidate: 300 }Responses carry age and x-azeroth-cache: hit | stale | miss (live marks a guarded URL answered per request, never cached). The cache is pluggable
(KitOptions.cache): MemoryPageCache by default, FilePageCache to survive restarts,
or your own PageCache. Background failures surface through KitOptions.onError.
MemoryPageCache is process-local, which is the right default for one instance and the
wrong one for several: behind a load balancer each instance keeps its own copy, so a page can
be fresh on one and stale on another, and revalidate regenerates once per instance rather
than once per deployment. FilePageCache fixes that only for instances sharing a filesystem.
Across machines, supply a PageCache backed by whatever store they already share.
A write invalidates the page it lands on
A page action that sends the visitor to an ISR page marks that page before the response leaves:
the redirect it throws, the { ok, redirect } an enhanced submit reads, and a wildcard action
answering at a cached sibling's url all count, so the visitor's next GET renders instead of being
handed the copy made before the write. Nothing else marks - a guard redirect that fires BEFORE
the action ran, a 422 re-render, a thrown fault and an unsafeUrl() target off this origin leave
the ledger alone.
For a write that changes what ANOTHER ISR page shows - an api route that edits what /book
renders - mark it yourself:
import { revalidate } from '@azerothjs/kit';
revalidate('/book');Import it explicitly: azerothjs exports a revalidate of its own, which refreshes a cached()
fetcher and throws on a string. The path is the app's own in any spelling - decoded or encoded,
with or without a trailing slash, params filled in, no language prefix, a query ignored - because
the unit of invalidation is the PAGE: one mark covers every key of it, both slash spellings, every
query variant, and every language in both routing modes.
A mark costs one render per key on that key's next read, two for a query-bearing key (which
re-earns its cache slot exactly as a cold one does), and nothing at all for a key nobody reads
again; a mark landing inside a render already in flight costs one more, because that render read
the data before the write and is answered only to the readers already waiting on it. Two standing
effects are worth knowing. From the mark until the first successful render the page has no
stale-on-error: a failing loader answers 500 private, no-store, because serving the kept copy
would show the writer their pre-write page. And a marked page never seeds from its prerendered
file while its mark stands - that file was written before the write by construction - so
revalidate() at boot turns the seed off for that path. The ledger keeps the newest 4096 marked
PAGES (marking a page again counts once, and moves it to the newest end): a page marked before
4096 other distinct pages drops out of it, which means it seeds again if nobody has read it since
the write, and a copy of it made before the write and never re-read answers hit again until
its window expires. Below that many written pages the bound is never reached.
The ledger is process-local in exactly the same way as MemoryPageCache: a write marks the
instance it landed on. With per-instance caches the others keep serving their own copies until
the window expires. With a SHARED cache there is one entry and the last writer wins, which makes
the common case better than that - the marked instance's fresh render is what everyone gets - and
the race case worse: a sibling instance whose render started before the write can land pre-write
bytes stamped after the mark, which the dirty test cannot tell from a fresh copy, so that page
answers hit with them for up to one window. That is a property of sharing a cache, not of the
ledger. For a write that must be seen everywhere at once, fan revalidate() out to every instance.
The query string is part of the key
/search?q=shoes and /search?q=hats are different pages and get different entries. Parameter
ORDER is not: ?a=1&b=2 and ?b=2&a=1 normalise to one entry. The renderer still receives the
URL exactly as requested, so useSearch() reads what the visitor actually sent.
This makes cache cardinality a function of your traffic, not your route table - and campaign
tags (?utm_source=...) each cost a render and an entry. Both shipped caches are bounded
(maxEntries, default 1000; FilePageCache evicts oldest-first by mtime), so the ceiling is
memory- and disk-safe, but a route that must ignore tracking parameters should strip them in a
middleware before the kit sees the request. Only a request with NO query can be seeded from the
prerendered file on disk, because that file was rendered without one.
The key is built from the path as sent, never a percent-decoded copy of it. That matters for
any route whose parameter can carry a delimiter: /article/a%3Fb is a slug containing ?, and
decoding before keying would make it collide with /article/a?b, a different page entirely. The
cost is that two spellings of the same page (/tag/caf%C3%A9 and /tag/café) occupy two entries
rather than one. That is a duplicate, never a wrong page, and the entry cap bounds it.
A cache bounds REPEAT cost, not FIRST-render cost
Worth stating plainly, because the cache invites the opposite assumption: nothing above puts a
ceiling on how much rendering a single client can ask for. A cold key must be rendered before
it can be served, distinct query strings are distinct cold keys, and render: 'server' is
uncached by design and renders on EVERY request - it is also the default mode whenever a
renderer is configured. The entry cap bounds what the cache KEEPS; it does not bound what the
server RENDERS.
Bounding that is an arrival-rate decision about your traffic and your identities, which is why the kit does not guess at it. Put a limit at the edge:
import { rateLimit } from '@azerothjs/http';
app.use(rateLimit({ limit: 60, windowMs: 60_000 }));It keys on the client IP through the same trusted-proxy boundary as clientIp, answers 429
with Retry-After, and stamps RateLimit-* headers so a well-behaved client paces itself
before it is ever refused. Size it against your render budget, not just your cache hit rate.
That limit is load-bearing for ISR, not a nicety, and the reason is worth being precise about. It is tempting to assume open connections bound concurrent renders: a client waiting for a response cannot ask for another until it arrives. They do not. Measured at a fixed budget of 8 sockets over one second against a 60 ms render: a client that WAITS gets 104 renders and pins peak concurrency to 8, exactly its connection count. A client that issues the request and immediately drops the socket gets 1568 renders and 202 concurrent - about 15x the renders and 25x the concurrency from the same 8 sockets. Dropping the connection does not buy the same work more cheaply; it removes the only bound the server had, because a production outlives the request that started it and nothing caps how many run at once. Budget the limiter against that number, not against the polite one.
An abandoned production is deliberately NOT cancelled, and cancelling it would not help anyway:
- On a cold path its output is exactly what the next visitor wants. Measured: five waiters
abort, the render completes, and the next request is a
hitwith no second render. Aborting there would turn useful work into repeated work. - The loader fan-out cannot be un-issued.
matchAndLoaddispatches every level of the chain synchronously, and a socket reset is not observable until after that has happened - measured, the fan-out goes out around 0.8 ms and the abort lands around 1.9 ms. No cancellation signal can beat it, so the bound has to be arrival rate, at the edge.
What the kit does cancel is work with exactly one waiter and no cache value: a render: 'server'
page and a guarded live render both receive the request's disconnect signal. A coalesced ISR
production does not, because it is shared with every request queued on the same key, and a
background regeneration does not, because it has no waiting client at all.
A deploy makes a shared cache go cold
Every entry is stamped with a build identity - the client shell's content hash - and an entry
from a different build is treated as stale, because its HTML references content-hashed assets the
new build deleted. That is the correct call for correctness, and it has an operational
consequence worth planning for: during a rolling deploy, instances on the new build reject every
entry the old build wrote, so a shared FilePageCache (or your own PageCache) goes fully
cold and every cached page regenerates once. Size the origin for that spike, or warm the cache
after a deploy.
The same mechanism means two applications pointed at one cache directory cannot serve each other's HTML - their shells hash differently, so each rejects the other's entries. They will, however, evict each other under the entry cap, so give each app its own directory.
Streaming SSR - render: 'stream'
The shell (loader handoff included) flushes before slow data resolves; each pending
<Suspense> boundary streams its settled children as an out-of-order chunk that swaps in
and hydrates without refetching. Redirects, guard vetoes, and HEAD stay buffered - they
resolve before any byte exists.
{ path: '/dashboard', component: Dashboard, render: 'stream' }Under a strict Content-Security-Policy - scriptNonce
A page emits two kinds of inline tag: the swap-runtime scripts a streamed page needs, and the
scoped-CSS <style> a server-rendered page carries. Both are refused by a policy without
'unsafe-inline', so pass the request's nonce and name it in both directives:
mountPages(app, { routes, clientDir, renderer, scriptNonce: (context) => nonceFor(context) });default-src 'self'; script-src 'self' 'nonce-<N>'; style-src 'self' 'nonce-<N>'Listing the nonce only under script-src is the trap: style-src then falls back to
default-src, the stylesheet is refused, and the page paints unstyled while looking correct in
the DOM. Getting the script side wrong is louder - every <Suspense> boundary sits on its
fallback until hydration refetches, and the streamed bytes are wasted.
css() on the client does not need a nonce: it adds a constructable stylesheet, which CSP does
not govern.
The image endpoint - /_image
KitOptions.images: true serves <Image optimize>'s URLs over the client dist:
content-hash cache keys, a year of immutable caching, ETag revalidation, and the same
path containment static serving uses (one shared rule: a hidden name is refused under every
spelling the filesystem answers to, an 8.3 alias included, and .well-known is public). The framework ships NO codec: without an adapter
the endpoint is a caching passthrough of original bytes; implement ImageAdapter (one
transform method) with whatever you trust to add resizing and AVIF/WebP negotiation.
A throwing adapter degrades to original bytes - never a blank image. One consequence
worth knowing before writing an adapter: real browsers always advertise image/avif, so
with an adapter present the endpoint always negotiates a format - an adapter that THROWS
on formats it cannot encode therefore falls back to the original bytes on every browser
request, resize included. Produce what you can instead and say so through contentType
(the adapter controls it for exactly this reason): a resized PNG answered to a webp ask
is correct, cache-keyed separately, and keeps the resize win. Replacing an image
in place keeps its URL, so browsers may cache the old bytes for up to a year; rename the
file (hashed builds do) or lower cacheControl when images mutate.
Languages - locales
Declare the languages the site publishes and every page is served in the reader's own, with its
<html lang> and <html dir>, its message catalogue, and what it varies on told to every cache in
front of it:
mountPages(app, {
routes, clientDir, renderer,
locales: { supported: ['en', 'fa'], default: 'fa', acceptLanguage: false }
});supported and default are the LocaleConfig every handler can negotiate with; cookie: false
and acceptLanguage: false switch a source off (the latter makes every first visit land in the
site's own language, with a reader's choice still winning). routing: 'prefix' gives every
language its own url with hreflang between them, which is what a site with search-engine
ambitions and a CDN wants. The full story - negotiation order, catalogues, the two routing modes
and what each tells a shared cache - is the i18n guide.
Under routing: 'prefix' a prerendered page is served from its file for every spelling of its
url, and the prerender pass writes the page's hreflang set into each artifact when it is told the
mode (prerender({ ..., locales, routing: 'prefix' })).
The prefix reaches the client the way the language does. Every response that lives under one
carries <html data-azeroth-base="/fa">, the render is pinned to it, and a router created with no
base adopts it, so /fa/about matches your /about route on both sides, the served anchors
already read /fa/..., <Link to="/about"> and <Form> stay in the language, and a server
redirect to /login answers /fa/login. Your route table stays written as /about; nothing in
the app names the prefix. A per-language artifact served under a prefix must carry that stamp,
which the prerender writes when it is told the mode; a build that ran without routing:
'prefix' answers 500 for that file and names the option through onError, rather than serving
a page that hydrates into the fallback. setLocale() in a prefixed document navigates to the
sibling url, since the url is the language there.
What it deliberately is not
- Not a router. The table above is
azerothjs's own router table - loaders,lazy:, typeddefineRoutehandles all work unchanged. Guards work unchanged on server-rendered pages, and are REFUSED on static ones (see Prerendering and ISR above). - Not a data layer. Route loaders ARE the data story; the kit just carries their results across the wire as the hydration handoff.
- Not a bundler. Vite builds both halves; the kit consumes the output.
npm create azeroth@latest scaffolds the fullstack template with all of this wired.
Related
- AzerothJS - the monorepo overview and the full package list.
azerothjs- the framework runtime, router, and renderer (required peer).@azerothjs/http- the web-standard HTTP servermountPagesregisters onto.@azerothjs/compiler- the.azerothcompiler and Vite plugin that build both halves.
