juris
v0.9.0
Published
JavaScript Unified Reactive Interface Solution - Browser-optimized version for script tags and CDN usage
Maintainers
Readme
Juris
Juris is a framework for server-rendered web apps whose pages stay live. The server renders every page whole, so it can be read before any script has run; the browser then makes that same page interactive, and live queries keep what it shows up to date as the data changes. It has no build step and no runtime dependency: the code a browser runs is the file on disk, and a database driver is something the app passes in.
This file is its contract: what an app may use, what the framework promises, what holds each
promise, and what is not there yet. A change to Juris is written down here first (see "The
boundary"), and tests/framework/readme.test.mjs fails when a module, an export, a reserved state
root or a stored key is missing from it.
Where it stands (2026-10-02; "Open" and "Not yet" re-checked against the code that day: none of
them is closed). Juris runs two real-time sites in production, and app/hello, the
smallest app on it. They work around, in their own code, the
issues this version fixes (F1–F27), and are being moved to it. Until then the framework as described
here runs on the QA server and in the tests. The framework's suite is
npm run test:framework, which runs tests/framework/*.test.mjs: in this checkout boundary,
render, core, live and server. A test file this contract cites that is not among them
belongs to the suite the framework was reviewed with, which this checkout does not carry; where a
behaviour has changed since, the file that holds it now is cited beside it. "Not yet", at the end,
lists what is missing.
Contents
- Your first app:
app/hello, file by file, and what happens on a request - Why it is built this way: the problems Juris was designed around
- The boundary: what may go in
src/, and the test that holds it - The page contract:
#app,#state,#servicesandstart() - Reserved state roots:
$route,$ssr,$local,$async,$server,$live - The public API: components, the component api, and every module
- What the framework stores in a browser:
STORAGE - The browser floor: the oldest browsers it runs in
- Security defaults and invariants: each with its code and its test
- Not yet
Your first app
app/hello is the template for a new app: five files, and none of them plumbing. Its test,
tests/hello.test.mjs, builds the whole app on in-memory services (no database), renders / and a
404, and fails if hello grows an HTTP server, a document, a module matcher or a 500 of its own.
| File | What it holds | Runs |
| --- | --- | --- |
| app/hello/client/app.js | The app itself: its routes, the words around every title, and register(juris), which registers its components. | Both sides: the server renders with it, the browser boots with it |
| app/hello/client/boot.js | The browser entry. The page imports it once the page is idle, and calls its start(). | Browser |
| app/hello/services.js | What the app does, as services. Never mounted, so no browser is served it. | Node |
| app/hello/app.mjs | createApp: one createJurisServer call, with the services passed in, so a test can pass its own. | Node |
| app/hello/server.mjs | The only file that reads the environment and touches the process: it listens, and closes on a signal. | Node |
Run it with node app/hello/server.mjs (development, on http://127.0.0.1:3000), or in production
mode with PROD=1 BUILD=b1 PORT=3001 node app/hello/server.mjs.
The app, the same on both sides (client/app.js, its comments left out):
export const routes = [
{ path: "/", name: "home", component: "Home", preload: () => [["greeting", "world"]], head: { title: "Hello" } },
{ path: "*", name: "missing", component: "NotFound", head: { title: "Not found" } },
];
export const title = { suffix: " · Hello", fallback: "Hello" };
export function register(juris) {
juris.registerComponent("App", () => ({ main: { children: [{ RouterView: {} }] } }));
juris.registerComponent("Home", (props, api) => {
api.live("greeting", "greeting", "world");
return { h1: { textContent: () => api.getState("greeting")?.text ?? "" } };
});
juris.registerComponent("NotFound", () => ({ p: { textContent: "Nothing here." } }));
}Its service (services.js), and the server (app.mjs):
import { fail } from "../../src/errors.js";
export const services = {
async greeting(name) {
if (typeof name !== "string" || !name.trim()) fail("Say who to greet.", { fields: { name: "Required." } });
return { text: `Hello, ${name.trim().slice(0, 60)}` };
},
};
import { createJurisServer } from "../../src/server/kernel.js";
import { routes, register, title } from "./client/app.js";
export const createApp = ({ services, dev = false, build = "dev" }) => createJurisServer({
root: new URL("../..", import.meta.url), dev, build, services, routes, setup: register,
api: { live: { public: true, queries: ["greeting"], touches: {} } },
modules: { mounts: [{ url: "/app/hello/client/", dir: "app/hello/client" }], entry: "/app/hello/client/boot.js" },
page: { title },
});And the browser entry (client/boot.js): export const start = () => hydrate({ routes, register, title });,
with hydrate imported from /src/client.js.
What happens when a browser asks for /.
- The kernel answers every request in one fixed order:
/version,/healthz,/api/, the modules, then pages./is a page. - The path is already in its one spelling, nobody is signed in, and the route is
home. Itspreloadnames one call,["greeting", "world"], and the kernel runs it before rendering, with a context that says it is a preload. Homerenders from that answer:api.livefinds the call preloaded and writes its answer atgreetingat once, so the render never waits. The title is the route'shead.title, "Hello" (thesuffixfollows only a name a page takes from its own data,head.titleFrom).- The kernel writes the document: the markup in
#app, the state it rendered with in#state, the callable service names in#services, and a loader that imports/app/hello/client/boot.js?v=<hash>-<code id>once the page is idle and calls itsstart(). A guest's page could be cached; hello passes no cache. - In the browser,
hydratereads#stateand#services, builds the instance with a stub forgreetingthat posts to/api/greeting, installs the router and the live client, registers the components, draws the page again into#app(the same markup, now live) and binds the title. api.livesubscribes to thegreetingquery over the page's one event stream (/api/events). Hello has no mutation, but a service listed inlive.touchesas touchinggreetingwould make the server run the query again once per change and send each subscribed page only the leaves that changed.
A service's refusal is written for the person reading it: fail("Say who to greet.", { fields })
reaches the caller with its words and its field messages, and any other error reads "request
failed" while its detail goes to the server's log.
Growing it.
- A page is a route record and a component. Its
preloadlists the calls the page makes, argument for argument: a server render answers only what was preloaded, and a call is keyed by its name and the JSON of its arguments (liveKey). - A change is a service.
live.touchesnames the queries each one changes ([]for none), so the pages watching them re-run;failrefuses with words the caller may read. - A page with code of its own gets
loadandregisteron its route (see "A route's own code"). - A plugin (forms, the clock) is installed on both sides, in the same order: in
setupon the server, and inhydrate'spluginsin the browser. - A page cache and a change bus, for several instances, are options of the kernel and the
dispatcher (
server/kernel.js,server/page-cache.js,server/bus/pg-outbox.js).
What hello does not show: signing in. A members app writes these itself today: a request
context (the dispatcher's context(req, res)) that reads its session cookie with parseCookies,
sets and clears that cookie with serializeCookie and appendSetCookie, a rule that a viewer
passed as an argument is believed only on a call of kind "preload" or "live" (callKind), and
live.authorize rules that match such an argument to the session. That is the code that decides
who sees what, and it is not in the framework yet ("Not yet").
Why it is built this way
Juris was designed from pain points rather than from a list of features: each part below is there because its absence cost something. Each is written as the problem, what Juris does about it, where the code and the tests are, and what it costs.
The code a browser runs is the file on disk
The problem. A build step (bundling, tree shaking, transpiling) makes what runs in production something other than what anyone read or tested. The premise Juris starts from is that this step is where most production-only bugs come from: code dropped because it looked unused, one module loaded twice as two copies that each believe they are the only one, a transform that behaves unlike the development server. Such a bug appears after a deploy and cannot be reproduced from the source.
What Juris does.
- No build step. Browser modules are ES modules, served as they are on disk. Development reads the disk on every request; production serves a snapshot of the same files taken at start.
- The only changes on the way out are
minifyJsandminifyCss, which remove comments and whitespace and never rename, fold or drop code (minify: falseturns them off), and import stamping, which rewrites import specifiers and nothing else. - Nothing is shaken out. A module is downloaded whole, so what a test imported is what a browser runs.
- Browser code imports Juris by URL (
/src/client.js), and the URL layout mirrors the disk, so Node and a browser resolve the same relative imports. - Server code cannot reach a browser by accident: only the flat
src/*.jsis served, neversrc/server/, and the browser suite is served only what production serves.
Where. src/server/modules.js (createModuleServer), src/server/minify.js. Tests:
tests/framework/modules.test.mjs ("the served bytes are the file's own, with only the import
specifiers rewritten"), tests/framework/minify.test.mjs (the minifier's cases are in
tests/framework/server.test.mjs now, beside the import stamping's),
tests/framework/boundary.test.mjs, and tests/framework/run-juris.mjs, which serves the browser
suite.
What it costs.
- More requests.
app/hello's page loads 14 modules, each a request of its own: about 38 KB gzipped in all (measured 2026-09-26, file by file, with the framework's own minifier), where a bundler would usually send a few files. The page can be read before any of them arrives, since the loader waits until the page has loaded and the browser is idle. - No dead code removed. A browser downloads code no page of the app uses,
ssr-renderer.jsamong it (about 3 KB gzipped), becausejuris.jsimports both renderers. - The source is written in what the oldest supported browsers run ("The browser floor"): nothing translates newer syntax, and there is no TypeScript or JSX.
A rolling deploy mixes two builds
The problem. Instances restart one at a time, so for a while some serve the old code and some the new, and a page cached or left open before the deploy boots after it. A browser that gets its page from one build and its modules from another runs code nobody tested together, and a module loaded under two URLs is two modules (a component registered twice throws).
What Juris does.
- A boot snapshot. In production the module server reads every module, asset and root file it
serves when it starts and serves only those, so an instance waiting its turn serves the code it
started with, including a file the deploy removed. A module it never had is a 404 (
no-store), never the new checkout's file stamped as this build's. Static files (fonts, pictures, a vendored library) are not in the snapshot: production reads each the first time it is asked for, so mid-roll an instance may serve the new checkout's copy, which a name that carries its version makes harmless (server/modules.js, "Static files", below). - A code id in every module URL.
codeIdis a hash of the snapshot, the build and the transform the server applies (its stamper and minifier), and every import a served module makes is stamped?v=<code id>, in every form a browser follows, so each module has one URL. A module is sentimmutableonly when its?vnames this process's own code id; under any other it is revalidated. - A page contract that never changes between builds (
#app,#state,#services,start()), so a page cached before a deploy boots the next build's entry. - The skew guard.
hydrate'sskewGuardcompares the route the server named the page with the one this build names it; when they differ it reloads once, remembering that insessionStorage, and never twice. - A full load instead of a broken page. A route whose code will not load (a module the other build lacks) loads that address in full instead, at most once per address.
- An update notice is the app's to draw, from what the framework gives it:
/versionanswers the running build (no-store), and a document can carry the build that wrote it (buildMeta). - Names that both builds share never change:
liveKey's output, the live layer's codes (LIVE_CODES) and the words beside them, and the change bus's table, channel and$pagestarget. The live client matches a refusal's code first and its words for one release, so either build's client recovers from either build's server. One build id reaches every worker a process forks (startProcess'senv).
Where. src/server/modules.js, src/server/document.js, src/client.js (skewGuard),
src/router.js (fullLoad), src/server/kernel.js (/version), src/live-protocol.js,
src/remote-services.js. Tests: tests/framework/modules.test.mjs (the snapshot, the 404, one URL
per module, immutable only under its own code id), tests/framework/server.test.mjs (the code id
moves with the transform, and a template import() is stamped), the browser suite's "Client boot:
hydrate" and "Router" (the skew guard, and the full load), tests/framework/sse-client.test.mjs,
tests/framework/lifecycle.test.mjs, tests/framework/document.test.mjs; and, for the site in this
repository, its two-instance journey (tests/two-instances.e2e.mjs).
What it does not prevent: a split boot. A page fetches its modules one by one. If the instance that answered one of them goes away and the balancer sends the next request to an instance on the other build, one page boots from two builds. These outcomes have been seen in a mixed-build check or worked out by reading the code:
- a module asks for an export the other build's copy of a module lacks, and the boot fails to link;
- a module the other build never had answers 404;
- in either case the page stays the server's page, readable and not live, until its next full load;
- in one pairing (a router from one build, a route table from the other) the page's main area was drawn blank until the next full load;
- an old page whose router loads a new page module does one full load.
The window is narrow: two requests a few milliseconds apart, with an instance going down between them. Where it matters, a change is shipped over two releases, the old shape kept for one more.
A page cache that knows what each page read
The problem. Keeping rendered pages is what makes a server-rendered site cheap, but a cache keyed by URL with a timeout can only guess. Too long, and a change is not shown; too short, and it saves nothing. And one page made for a signed-in reader, or one that read something its URL does not carry, served to the next visitor, is a leak.
What Juris does.
- A server render can be traced (
renderRequest({ trace: true })): the services it called, the state roots it read (peekincluded), its live queries, the flags a plugin set (the clock's), any component it could not find, and each service whose preloaded call failed (failed, read or not). decidekeeps a page only when it was rendered for a guest, is not a 404, had no preloaded call fail, drew no unknown component, carries no flag, and read no state root its URL does not key. A page that read nothing is kept until the cache is cleared; one that read services, until a change names one of them. A failed render is that request's failure, not the page, so it also leaves the page kept before it as it was, and hands a stale page's replacement back for the next visitor. The route's own call, which the kernel reads for the status and the title, counts as read by the page whatever its body calls.- Invalidation comes from the services. A mutation's
touchesnames what it changes, and the dispatcher hands those same targets to the cache (live.onInvalidate), on this instance and, over the change bus, on every other. A render that raced a change is refused (snapshot()andput), and so is any page rendered across anEVERYTHINGinvalidation or aclear(). - The pipeline asks the guard before the cache, never serves a viewer the guests' copy (nor asks the
cache about a viewer's page, whose refused put would drop the guests' copy), redirects
every path to one spelling first (
canonicalPath), and renders a page it may keep from its cache key (pageCacheKey), so no visitor's other query parameters travel in it.
Where. src/server/page-cache.js, src/juris.js (renderTrace), src/server/kernel.js
(createPagePipeline), src/server/service-dispatcher.js (live.onInvalidate). Tests:
tests/framework/page-cache.test.mjs, tests/framework/ssr-trace.test.mjs,
tests/framework/kernel.test.mjs, tests/framework/dispatcher.test.mjs ("onInvalidate hears every
invalidation, local or off the bus…"), tests/framework/server.test.mjs (the own call in the trace,
the viewer's page, a put across EVERYTHING).
What it costs. The cache is right only as far as the app declares: touches must name what
each mutation changes (the dispatcher's strict makes every service say), and unkeyedRoots the
state roots that vary per request. The cache is in each process's memory. A page for a signed-in
reader is never cached, so such pages cost a render each time. The kernel takes a cache only beside
a dispatcher the app built and wired to it ("Not yet").
Live queries are pure functions of their arguments
The problem. A live query is shared. Every browser subscribed to the same name and arguments is one group, run once and run again once per change, with no request behind the re-run. A query that read the session would compute one reader's answer and send it to everyone in the group; and a query that takes its reader as an argument answers for whoever a browser names.
What Juris does.
- The key is the call:
liveKey(name, args)isname:JSON(args), and the server refuses a subscribe whose key is not the one it computes. - A dispatcher with live queries cannot be built without
live.authorizeorlive.public: true.authorize(name, args, info)runs on every subscribe, on a direct POST to a declared query, and again while the subscription lives: before a patch is delivered, at most once perrecheckMsfor each client and query, and at once onhandler.invalidateAccess(whateverrecheckMsis), each time with the context captured at subscribe. Nothing re-asks on a clock: a subscription whose query never changes stays registered, holding that context and counting against its caller's ceilings, until its stream closes orinvalidateAccessreaches it. What it is sent is still gated. live.strictQueries: truemakes the declaredlive.queriesthe only names a browser may subscribe to; a name atoucheslist gives is then an invalidation name only.- Every call says what kind it is. A service reads
callKind(this); a live query's re-run has a frozen context that names nobody, and anything that does not say is a stranger's"direct"call. So an app believes a viewer passed as an argument only on"preload"(the server chose it) and"live"(authorizematched it to the session). - Client ids are minted by the server and bound to the caller (
identify); each caller has ceilings; a stream too far behind is dropped.
Where. src/server/service-dispatcher.js, src/live-protocol.js. Tests:
tests/framework/juris-hardening.test.mjs ("live queries are refused until somebody has decided who
may have them", "a direct POST to a live query runs the same authorize as a subscribe", "losing
access stops the patches, without waiting for the client to unsubscribe"),
tests/framework/dispatcher.test.mjs (strictQueries), tests/framework/context-invariant.test.mjs,
tests/framework/live-bus.test.mjs, tests/framework/live.test.mjs (invalidateAccess with
recheckMs: 0, strictQueries with object targets, one stream per client id).
What it costs. Purity is the app's to keep: nothing stops a live query from reading this, so
an app should test that each live query answers a direct call as it answers its live run.
strictQueries is opt-in. A viewer's personal data needs a group of its own, since there is no
per-viewer channel, and taking access away reaches only the instance that acted ("Not yet").
Safe by default
The problem. The unsafe default is the one nobody notices until someone uses it: a sanitiser that misses one spelling, an error message that names a table, a form any site can post, an argument that claims to say who is calling.
What Juris does.
- Refused, not sanitised. Both renderers refuse a tag that runs code, loads a document or reaches
the whole page (
script,iframe,object,embed,base,meta,link,style(SVG's too),plaintext, SVG'ssetandanimate…), a tag name that came from data, anon…or malformed attribute name,srcdoc, a URL whose scheme is not allowed (javascript:andvbscript:never,data:for an image only), control characters stripped first, astyleobject's key that is not a property or value that would end its declaration, andinnerHTML, which is markup, unless the instance saysallowInnerHTML: true. A refused tag or attribute is left out whole (and named on the console indevMode); nothing is cleaned up into something else. An app widens this only by name (allowTags,allowSchemes,allowInnerHTML), for both sides at once, and a list naming anything that is not a tag or a scheme, or anallowInnerHTMLthat is not a boolean, is refused when the instance is built.renderDocumentescapes every value in a page's head and throws on what escaping cannot make safe. - By default, only a
ServiceError's words reach a caller. The defaultexposeErroriserror.expose === true, whichfailsets; any other error reads "request failed" and goes to the log. That holds for a service's answer, a live query's re-run, a failed preload written into a page and the async error logs;/healthzand the 500 answer fixed words and never an error's. - JSON-only calls. A service call must be sent as
application/json(415 otherwise), which a form on another site cannot send without a preflight: that is the defence against cross-site requests. The no-JavaScript form path (forms) takes only the names the app allows, only when the Origin (or the Referer) is the host asked, only up to 16 KiB, through the same pipeline as a JSON call, and redirects only to a path on the same site. - Call kinds, above: a handle in a request's arguments names nobody.
- A page rendered for a reader is never cached, and goes out
private, no-cache. - Nothing a request carries ends the process: a target no URL can be made of is a 400, a malformed cookie is skipped, a malformed service name is a 400, and an app's context that throws is a refusal, never a crash.
- A misspelt option is refused, not ignored (
createJurisServer,createPagePipeline,hydrate,createRouter,serviceDispatcher,sseClient,createLifecycle), and so is a misspelt key inside an option that takes its own (the dispatcher'slive,strictandforms,healthand a job,trustProxy,modules,page,juris, hydrate'stitle,skewGuardandserviceWorker, and the live client'sbackoff): a misspelt guard would otherwise leave every page open, and a misspeltlive.strictQuerieswould leave its switch off without a word. So is a number that is not one (a job'severy,health.timeoutMs,closeTimeoutMs, the live layer's waits and ceilings,remoteServices'timeoutMs,startProcess'sbackoff): a wait longer than a Node timer keeps fired at once, and a string ceiling compared as none.
Where. src/html-safety.js, src/server/document.js, src/errors.js,
src/server/service-dispatcher.js, src/live-protocol.js, src/server/http.js,
src/server/kernel.js. Tests: tests/framework/html-safety.test.mjs,
tests/framework/juris-hardening.test.mjs, tests/framework/document.test.mjs,
tests/framework/errors.test.mjs, tests/framework/service-errors.test.mjs,
tests/framework/dispatcher.test.mjs ("forms: every refusal, before and after the service"),
tests/framework/context-invariant.test.mjs, tests/framework/http.test.mjs,
tests/framework/lifecycle.test.mjs, tests/framework/server.test.mjs,
tests/framework/render.test.mjs, tests/framework/live.test.mjs.
What it costs. A refusal leaves out what a sanitiser would have drawn a cleaned copy of, so
a layout built from data that carries a refused tag draws nothing for it, and a link with a refused
scheme has no href. innerHTML is left out unless the instance says allowInnerHTML: true; with
it, it is written as given, so it is for the app's own markup, never data (a row spread into props,
a row that lands in a child position, or a component that forwards ...rest, such as Link and
Await, carries it through). A style object's value that would end its declaration is left out.
A service's fields travel as the service wrote them. A page's Content-Security-Policy is the app's to
give (page.headers, with the hashes of the page's inline scripts).
One worker per instance, behind a sticky balancer
The problem. A live query uses two connections: the event stream a browser keeps open, and the subscribe calls it posts. Node's cluster hands connections to its workers independently, so a subscribe lands on a worker that never minted that client's id, and every live query answers 403. The streams a server knows are in one process's memory.
What Juris does. startProcess refuses more than one worker beside live queries, before it
forks anything (exit 1, with a message that says why and what to do instead), unless the app says
allow; createJurisServer refuses to serve inside a cluster worker beside live queries unless it
is given allowWorkers. To use more cores, run more instances, each a process on its own port,
behind a balancer that keeps a browser on one instance (a cookie it sets), with a change bus between
them so that a change made on one reaches the pages and live queries of all.
Where. src/server/process.js, src/server/kernel.js, src/server/bus/pg-outbox.js. Tests:
tests/framework/process.test.mjs, tests/framework/server.test.mjs ("a cluster worker is refused
beside live queries unless the app allows it"), tests/framework/pg-bus.test.mjs,
tests/framework/live.test.mjs (a replaced worker's backoff, the Postgres bus's reconnect).
What it costs. An instance uses one core, so more cores mean more instances and a balancer in front. When a browser's instance restarts, its stream breaks and the live client opens a new one on another instance. Taking live access away reaches only the instance that acted, until its peers' next re-check ("Not yet").
The boundary
src/ is everything a second app would otherwise have to write, and nothing that names any one
app.
- Framework terms first. A change is written down here first, as what any app needs rather
than what one page needs. It starts with a failing framework test: for code that runs in a
browser, the browser suite where there is one (
test:juris; not in this checkout, where the render and core suites stand in, through their stand-in DOM), a node suite for code that runs in Node. - No app vocabulary. No route, service, table, cookie, storage key, environment variable, copy, incident story or citation of an app's specification. An app passes those in.
- Two halves. Top-level
src/*.jsis served to browsers exactly as it is on disk, so it imports nothing fromnode:, nothing from npm and nothing fromsrc/server/. Node-only framework code lives insrc/server/, is never served, and reads no environment variable. Only an app's entry reads the environment. - Safe defaults. Error messages are denied unless written for the person reading them, unsafe
tags and
innerHTMLare refused, a live query is refused withoutauthorize, a personal page is never cached, every value written into a document is escaped, and cluster workers are refused beside live queries. An app that wants less says so in its own code ("Security defaults and invariants" says where each is held).
Behaviour that belongs to one app is built on the extension points (services, juris.use(plugin),
extendApi, route records, and the dispatcher's and the kernel's options), never by special-casing
that app inside a framework file. A framework change lands together with its first user in an app.
Held by a test, with no exceptions. tests/framework/boundary.test.mjs enforces rules 2 and
3: a top-level src/*.js imports only its flat siblings and uses no Buffer, process or
require( in its code (comments and string text aside), nothing in src/ reads process, and
nothing in src/ or in the framework's own tests, comments included, uses the app vocabulary the
test lists. It also refuses an import cycle anywhere in src/, which loads only while no module in
it uses an import during its own evaluation: what two modules both need goes in a leaf they each
import, as FallbackSignal did into errors.js; and it holds the browser floor (below). Its
allow-list is empty. It started with eight breaches: seven comments that told one app's stories or
cited its documents, now written as the rules they guard, and a read of NODE_ENV for the
development-mode warning, now gone (the warning keys off devMode, which the app passes). A breach
found now is fixed, not listed. A pattern cannot see every story a comment tells, so review still
reads for them. tests/framework/readme.test.mjs holds rule 1's half that a test can: this file
names every module and export. The framework's tests are in tests/framework/, apart from the
app's (npm run test:framework runs them alone). The suite the framework was reviewed with also
served a browser suite only the flat src/*.js and tests/framework/*.js (test:juris), and held
the whole boundary at once (test:extraction: src/ and tests/framework/ copied to a directory of
their own, every import checked to land inside the copy, every suite run there with a loader that
refuses anything of the app's). Neither script is in this checkout ("Not yet"); here
tests/framework/boundary.test.mjs holds the boundary in place. A skipped test is reported and does
not count as a pass.
The page contract
A page the kernel writes (renderDocument) and the browser entry that boots it (hydrate) agree on
four names. They do not change from one build to the next, because a page kept in a cache, or left
open in a tab, boots the entry of whichever build serves it next.
| Name | What it is | Written by | Read by |
| --- | --- | --- | --- |
| #app | The element holding the server-rendered body. The browser draws root ({ App: {} }, so the app registers an App component) into it again, replacing the server's markup. | renderDocument, rootId | hydrate, rootId |
| #state | <script type="application/json" id="state">: the state the page was rendered with, as JSON with < escaped and the private roots left out (serializeState). | renderDocument, stateId | hydrate, stateId |
| #services | <script type="application/json" id="services">: the names of the services the browser may call, JSON with < escaped. Written after the state; absent when the app turns it off. | renderDocument's serviceNames: the dispatcher's names, unless page.serviceNames gives a list or false | hydrate, when not given services |
| start() | The browser entry's export. The document's loader imports the entry once the page has loaded and the browser is idle (requestIdleCallback, or a timer where there is none) and calls it. | The app's entry module | The loader renderDocument writes |
Only the server's JSON script is read (JR-F15). hydrate reads the state, and the service
names, from a <script type="application/json"> with that id and from nothing else. What a page's
content draws comes earlier in the document than the server's scripts, and a lookup by id alone
once took an element a user's content drew with id="state" for the page's state: the page booted
on a state of that user's choosing. The renderers never draw a <script> from a layout (a refused
tag), so content drawn from layouts cannot be one. Two things an app can do reopen it, and neither
should ever carry data: allowTags: ["script"], and innerHTML, which both renderers write as
markup once the instance says allowInnerHTML: true.
Where. src/server/document.js, src/client.js. Tests: tests/framework/document.test.mjs ("a
whole document, byte for byte…", "the loader imports the entry once the page is idle and calls its
start()"), tests/framework/render.test.mjs (the loader asks whether requestIdleCallback is a
function, so an element of that id cannot stand in for it), and the browser suite's "Client boot:
hydrate" ("reads its state and service names from the server's JSON scripts, never from an element
the page's content drew with the same id").
Reserved state roots
The framework keeps its own state in the same tree as the app's, under the roots below. An app reads
them (through the api where there is one), and writes none of them by hand. An app's own roots
should not start with $: nothing enforces it, and it keeps a later framework root from colliding
with one of the app's. A root is written into #state unless it is private; an app adds private
roots of its own with privatePaths.
| Root | Kept by | What is under it | In #state | Named by |
| --- | --- | --- | --- | --- |
| $route | The router | path, query and hash, raw (writing path is navigating), and, derived from them, name, params, matched and meta; in a browser also history, { index, length }, the tab's trail. | Yes: the browser adopts the route the server rendered | createRouter({ routePath }) |
| $ssr | The core | calls: each preloaded call's answer under its key (liveKey), or { $error } for one that failed, with the words exposeError allows. | Yes, and the browser drops it after its first mount (releasePreloaded) | new Juris({ ssrPath }) |
| $local | The core (useState) | $local.<Component>.<n>.<key>, component-local state, numbered in render order; $local.$client.… under a component with ssr: false. | Yes, so a component adopts its own value when the page boots: nothing secret goes in it | new Juris({ localPath }) |
| $async | The state manager | pending, slow, started, settled, errors, and groups.<name> with its own pending and errors; and live.<path>, each live path's status and message where no live client keeps them (a server, or a browser without one: api.liveState). | Never (private), and a server's clearState empties it | new Juris({ asyncPath }) |
| $server | Nothing in the framework | Reserved for what a server render needs and must never leave the server. | Never (private) | Not renamed |
| $live | The live client (sseClient) | connected; generation, one more on every hello from the server; reconnects, one more on every hello that brings back a stream that was lost; and paths.<path>, each live path's status ("live", "pending", "failed", "offline") and, while it is failed, its message, the path written as one segment (pathSegment in state-manager.js: %, . and * escaped), gone once nothing is subscribed there (api.liveState). | No: only a browser has a live client | sseClient({ statePath }) |
Two names that look like roots are not: $error is the key of a failed call inside $ssr.calls,
and $pages is the page cache's every-page invalidation target (EVERYTHING, ALL), never state.
The public API
What an app imports, and from where. Browser code imports Juris by absolute URL: the module
server mounts the flat src/ at /src/ and stamps every import. Node code imports it by relative
path. There are no bare specifiers and no import map: a browser cannot resolve import "juris"
without one.
| Where | An app imports | For |
| --- | --- | --- |
| The browser entry | /src/client.js | hydrate, the whole client boot |
| The app's own shared modules, when they need it | /src/forms.js, /src/clock.js, /src/live-data.js, /src/router.js, /src/errors.js, /src/storage.js | Plugins, helpers, and what a page names |
| The server's entry and configuration | src/server/process.js (startProcess), src/server/kernel.js (createJurisServer) | Starting, and the whole server |
| Services | src/errors.js | fail |
| Beside the kernel, when an app needs them | src/server/page-cache.js, src/server/bus/pg-outbox.js, src/server/db.js, src/server/dev-reload.js, src/server/http.js, src/server/service-dispatcher.js | A page cache, a change bus, a driver adapter, development reload, cookies, a dispatcher of the app's own |
A component module reaches Juris through its api and imports nothing from src/, so only an app's
entry points (and the one module that wraps a framework helper for its pages, if it has one) name
where Juris lives. That keeps moving the framework cheap.
Components and layouts
A component is a function (props, api) => layout, registered by name on the instance:
juris.registerComponent("Home", (props, api) => …). registerComponent(name, definition, { ssr:
false }) registers one the server never renders (it writes a placeholder, and the browser draws the
component). A definition may also return a function, () => layout, which is drawn again whenever
what it reads changes. A name is registered once per instance: registerComponent refuses a second
definition, and juris.install(key, registrar) is how a module whose components several pages draw
registers them once.
A layout is a plain object with one key, the tag, whose value is its content:
{ div: { className: "card", children: [ … ] } }. A string or a number as the content is its text
({ p: "Nothing here." }), and null an empty element. In the content:
- A capitalised tag names a component, and its content is the component's props:
{ Home: { id: 7 } }. childrenis a layout, a list of layouts, text, or a function (a seat: drawn again whenever what it reads changes). A component returns one layout, never a list. A seat's function, or a definition's, may return a function in turn: it is bound in its place, drawing into the same node, and goes when the function above it gives something else, so the chain draws what the server draws for it (every function evaluated in turn).textContentis text, always escaped.innerHTMLis markup: both renderers leave it out (named on the console indevMode) unless the instance is givenallowInnerHTML: true, and then write it as given, so it is for the app's own markup and never for data.enhancekeeps its own rule: inside an enhancement it throws unless the call says{ allowHtml: true }, which opens it there.keygives an element its identity in a list, so a keyed list moves elements instead of redrawing them. An element keeps its tag: an item whose key stays and whose tag changes is drawn anew, as a fresh draw would draw it (a component takes the element over). A keyed component whose root is not an element (it drawsnull, or text) keeps its place and its state in a keyed list, as an element does.classorclassName,classList({ name: boolean }),style(an object, or a string),htmlFor, andvalueare what they are in the DOM; a boolean attribute (disabled,hidden,checked, …) is present when its value is truthy. AclassListkey is one or more class names, split on whitespace ({ "btn active": on }); an empty key is nothing. Astyleobject's keys are camelCase properties (marginTop,WebkitTransform,cssFloatforfloat) or custom properties ("--accent"), which both renderers write alike;cssTextand anything else is left out. A value is a string or a number with no;,{,},<,\,/*or control character (newline included); any other value is left out on both sides (named indevMode), and on the client it takes off the value before it. A key whose value isnull,undefinedor""is left out by the server and taken off by the client, as is a key a re-draw no longer gives.- An
on…key with a function is a listener:onclick: (event) => …. Anon…attribute with a string value is never written, nor is any other attribute or taghtml-safety.jsrefuses. - Any value may be a function. It is run to give the value, and run again when the state it read
changes, updating that attribute or that text alone. That is how Juris is reactive: a function
that reads
api.getState(path)is subscribed to that path. - Children of
svgandmathare drawn in those namespaces, and HTML again where the browser's parser reads HTML inside them (its integration points), so the page the server wrote is the page the client draws: inside SVG'sforeignObject,titleanddesc; inside MathML'smi,mo,mn,msandmtext(but formglyphandmalignmark, which stay MathML); and inside anannotation-xmlwhoseencodingistext/htmlorapplication/xhtml+xml, in any case. Anannotation-xmlof another encoding holds MathML, but for ansvg.
A prop may be a plain value, a getter (a function whose name does not start with on, read with
api.prop(value) inside a binding, so the read is tracked), or a ref to a state path
(api.ref(path)). The components every instance has are Await ({ Await: { group, fallback,
onError, tag, children } }: fallback while an async group is pending, the children kept running
behind it), and, with the router installed, RouterView and Link.
The component api
Every component is given api, the same object on the server and in the browser:
- State.
getState(path, default, { track })is a tracked read;peek(path)is an untracked one.setValue(path, value),updateState(path, updater),deleteState(path),batch(fn),assign(path, value)(writes leaf by leaf, so readers of unchanged leaves are not woken),compute(path, resolver),clearState(next),toRaw(value)andbindState(resolver, apply). - Local state.
useState(key, initial)answers[get, set](see "Local state"). - Services.
call(name, ...args)answers a promise, or, for a call that was preloaded, a settled value that runs its callbacks at once.live(path, name, ...args)calls a live query, writes its answer atpathand keeps it written as the server pushes changes; inside a component the subscription ends with the component.liveState(path)says, tracked, whether what is at a live path is current:"live"(the latest answer is on screen, a preloaded one included),"pending"(subscribed, the first answer not here yet: what is shown is older, or nothing),"failed"(the subscribe was refused, or the server could not run the query again; or, with no live client, the call rejected),"offline"(the stream is down), ornullwhere nothing is live;liveMessage(path)is a failed path's words, ornull. On a server a preloaded live path is"live", and so is the browser's first render of it, so the two draw alike.preloaded(name, ...args)reads a preloaded answer without calling or subscribing.invalidate(name, ...args)drops the browser's call cache.group(name)counts a group's pending promises.servicesis the instance's services. - Lifecycle, during a definition only:
onMount(fn),onCleanup(fn), andonError(handler), which makes the component an error boundary. - Drawing.
render,mount,renderToString,enhance,unenhance,registerComponent,disposeTree. - Props.
ref(path)andprop(value, default). isServer, andmarkRender(flag)for a plugin that must tell a page cache a render is volatile.
A plugin adds its own names, and the api takes each name once:
- the router:
router,route(),navigate(to, { replace }),back(),forward(),canBack(),canForward(),link(to)andisActive(to, { exact }); - the live client:
liveStatus(); clock():now();forms():form(path, options).
The Juris instance
An app on the kernel and the client boot never constructs one: createJurisServer and hydrate
each build the page's instance, and an app gives both the same juris options (see "What the
server and the browser are given alike"). The instance's options, for a test or a page that builds
its own: state, services, isServer (true where there is no document), requirePreload,
exposeError, reportError(error, name, args), privatePaths, ssrPath, localPath,
asyncPath, pendingDelay (200 ms before a pending value counts as slow), callCache
({ dedupe, ttl }, browser only), allowTags, allowSchemes, allowInnerHTML (a boolean, false
unless given; anything else refused with a TypeError), unknownComponents, devMode and
onChange. What an app calls on an instance: use(plugin, options), extendApi(extension),
registerComponent, hasComponent, install, renderRequest, mount(target, layout,
{ release }), preload(entries), serializeState(), callKey(name, args), renderTrace(), api,
and, in devMode, componentTree(), findComponents(name) and onTreeChange(fn).
privatePaths and asyncPath name roots of the state: a name with a . in it (or an empty one, or
a privatePaths that is not a list) is refused at construction with a TypeError. A nested name
used to match no root, so what it named was serialised into the page as if it had never been given;
nested private paths are refused, not supported.
Modules a browser downloads
Top-level src/*.js, served as they are on disk at /src/.
juris.js
Exports: Juris (default), FallbackSignal, BUILTIN_COMPONENTS, SHARED_OPTIONS,
sharedOptions.
The core: components, state, call and live, plugins (use, extendApi), renderRequest,
mount. BUILTIN_COMPONENTS names the components every instance registers (Await), and
FallbackSignal is re-exported from errors.js. Its callKey is the live protocol's liveKey.
- Preloaded answers are read through two accessors, never by their place in the state (
$ssr, where they travel to the browser) or their keys.renderRequestanswers{ html, json, result, cache }(andtracewhen asked:renderTrace()'s, withfailed, each service whose preloaded call failed), andresult(name, ...args)is what that call answered in this render (nullincluded), orundefinedwhen it was not preloaded or failed, whatever the instance renders next: a server reads the page's status and title from it.api.preloaded(name, ...args)is the same answer to a component, in a server render and in the browser's first render, for one that must show what another on the page will load before that one has written it. It is a read, not a call: no service is reached,requirePreloadhas nothing to refuse, and nothing is subscribed, so it runs nothing again when the transfer is released after the first mount, and answersundefinedfrom then on. In a traced render a preloaded call it read is in the trace'scalls.cache, the calls as the preload keeps them, is returned for one more release. Each answer is kept as the page carries it, a JSON round trip, so a component that writes under a preloaded answer during a render writes into the request's copy, never into the object the service answered (which a service that caches, master data, hands every request). An answer JSON cannot carry (a cycle, aBigInt) is recorded as that call's failure.renderRequestcopies the plain objects and arrays of thestateit is given, for the same reason (other values, aDateor a class instance under$server, are kept as they are). - Live status.
api.liveState(path)andapi.liveMessage(path)("The component api") read where the live client keeps a path's status ($live.paths) or, without one, where the core keeps it ($async.live). Without a live client (a server, or a browser booted withlive: false)live()is one call whose answer is assigned; only the latest call at a path writes it, so an older answer that arrives late (the arguments moved, or on a server it belongs to a request already rendered) is dropped rather than assigned over the newer one. A service that answers synchronously is taken too. - Registering once.
hasComponent(name)says whether a component is registered on the instance (the built-ins included).install(key, registrar)runsregistrar(juris)the first time the instance is givenkey, a non-empty string, and does nothing for that key again, answeringtruewhen it ran andfalsewhen it had already:registerComponentrefuses a name it already has, so a module whose components several pages draw registers them once, and the instance keeps the "once". The key is a name the module gives itself, never the function, because the same module loaded under two addresses is two modules with two functions. A registrar may install others, and one that reaches its own key while it is still running is not run again; one that throws is remembered, and its error is thrown again to every later caller for that key, so a half-registered module never looks done. - Unknown components. A capitalised tag names a component, and when none is registered under
it both renderers ask the core (
unknownComponent) before drawing it. TheunknownComponentsoption says what then:"element"(the default) draws an element of that name, as a browser does with a tag it does not know;"warn"also says so on the console, once per name;"throw"refuses to draw it (an error like any other, which a boundary may catch; tests use it). Whichever it is, a traced server render lists the name in its trace'sunknown, andpage-cache.jsnever keeps such a page. A capitalised tag both renderers refuse as an element (Portal,Script:html-safety.js) is unknown too, and is still not drawn: its warning says nothing is drawn for it, unlessallowTagslets it be drawn. Lower-case tags, and any tag inside<svg>or<math>(namespaces that name their own elements), are elements, never unknown; inside<foreignObject>and the other places HTML is drawn again (thesvgandmathbullet above) the children are HTML, and so is the rule. - Options both sides share.
SHARED_OPTIONSnames the options an app gives its instance through the kernel and the client boot, which build it themselves (allowTags,allowSchemes,allowInnerHTML), andsharedOptions(value, where)checks such an object for either side and answers what to hand the instance ("What the server and the browser are given alike").
errors.js
Exports: FallbackSignal, ServiceError, fail, defaultExposeError, mayExpose,
publicMessage.
The framework's error classes, in a module that imports nothing, so every other module can import them without importing each other.
FallbackSignalis thrown through a component tree when an error boundary still rendering wants its fallback drawn: the core throws it and both renderers catch it, byinstanceof, so all three import this one class.ServiceError(message, { status = 400, fields, field, code })is a refusal written for the person who reads it. It setsexpose, which the defaultexposeErrorreads, keeps a singularfieldand folds it intofields(unlessfieldsalready says something for it), and hasfieldsandcodeonly when given.fail(message, options)throws one. The browser's stubs throw one too, so a caller reads the same shape on either side.defaultExposeError(error)is the rule a caller's reading is judged by when an app gives none,error.expose === true, and the default of the dispatcher's, the kernel's and the core'sexposeError.mayExpose(error, exposeError = defaultExposeError)says whether a rule lets a caller read an error's words; a rule that throws or answers anything buttruesays no.publicMessage(error, exposeError, fallback = "request failed")is those words, orfallback. The dispatcher (a refusal, a live query's failed re-run) and the core (a failed preload, a server's async error logs) both read a failure through these two.
live-protocol.js
Exports: liveKey, canAddress, diff, LIVE_CODES, CALL_KIND, CALL_KINDS, callKind,
internal.
What both ends of a live query must compute identically, defined once in a module that imports nothing.
liveKey(name, args)is a call's key,name:JSON(args). It names a preloaded call, a browser's subscription and the server's group, and the server refuses a subscribe whose key is not the one it computes, so its output never changes: one byte of difference between two builds is a refused subscribe from every open tab during a deploy.canAddress(value)says whether every own key of an object can be one segment of a state path (no., no__proto__, and not the empty key, whose path would be its parent's: at the top, a patch for it replaced or deleted the whole result); an object that fails it is sent bydiffand written byassignwhole.diff(previous, next)is the leaf patch the server sends; a key is deleted whennexthas no own key by that name, so a removed field namedconstructorortoStringis deleted like any other.CALL_KIND,CALL_KINDS,callKind(thisValue)andinternal(reason)are what a service is told about who is calling ("Who is calling", below).LIVE_CODESnames the codes the live layer's refusals carry beside their words, which a program matches where the words may change:CLIENT_UNKNOWN(live.client-unknown, 403: a client id the server never minted, or minted for another owner, so the answer is a new stream) andNOT_A_QUERY(live.not-a-query). They are wire names and never change.
state-manager.js
Exports: StateManager (default), settled, settledError, isSettled, pathSegment,
MAX_ERROR_LOG.
The reactive state tree, path subscriptions and settled async values. Juris constructs its
StateManager, and an app reaches it through the component api. settled(value) and
settledError(error) are values shaped like a promise (then, catch, finally) that are already
resolved or rejected and run their callbacks at once, which is what a preloaded api.call answers,
so the same component code runs synchronously on the server and in the browser's first render;
isSettled(value) tells one apart.
pathSegment(text) makes any text one segment of a state path (%, . and * escaped,
__proto__ escaped whole), distinct texts giving distinct segments; the live status is kept under
it. The async error logs (errors under the async root, and each group's) keep the last
MAX_ERROR_LOG (50) entries: in a browser they used to grow for the life of the page. The async
counters never go below 0. clearState(next) skips an own __proto__ key in next (JSON.parse
makes one), so a parsed state cannot set the root's prototype.
component-manager.js
Exports: ComponentManager (default).
Component registration and lifecycle, and in devMode the inspectable tree of live instances.
Juris constructs it; an app registers through registerComponent.
dom-renderer.js
Exports: DOMRenderer (default).
The browser renderer: bindings, keyed lists, the reuse of server-rendered elements, and enhance,
which adopts markup Juris did not draw. Juris constructs it. What is drawn inside an element is
drawn in that element's namespace, on every draw and not only the first: SVG inside <svg>, MathML
inside <math>, HTML again at the parser's integration points (<foreignObject>, an SVG <title>
or <desc>, a MathML token element, an HTML annotation-xml: "Components and layouts"), however it
got there (a re-draw that kept the element, a list or a seat bound inside it, enhance). juris.render(target, layout, namespace) and
juris.mount(target, layout, { namespace }) (the renderer's own mount takes the namespace as its
third argument) draw inside the target in its own namespace unless given one (null is HTML).
An element that is already there (server markup, a previous draw, what enhance adopts) is taken
for a layout only when its tag is one the layout could draw (reusableElement): otherwise it is
replaced (by the empty text node a refused tag draws), and enhance throws on an element, or a
component adopting one, whose tag is refused unless allowTags names it (so enhancing a <link>, a
<meta> or an <iframe> needs allowTags to name it). On an SVG or MathML element, xlink:…,
xml:… and xmlns/xmlns:… attributes are set in their namespaces, as the HTML parser sets them.
An element reused for another layout of the same tag keeps only what that layout gives it: a property
it no longer names is cleared, and so is what the element held inside, its children or its text, when
the layout names neither children, textContent nor innerHTML (F27). So a placeholder such as
cond ? { span: { className: "badge", children: […] } } : { span: {} } is an empty span when cond
turns false, not a span that still shows the old text without its class. An element Juris has not
drawn into before (server markup on its first hydration) has no record of what it held, and is left
as it is.
ssr-renderer.js
Exports: SSRRenderer (default), escapeText, escapeAttribute.
renderToString with an optional trace of what the render depended on. Juris constructs its
SSRRenderer. escapeText escapes &, < and >, and escapeAttribute escapes both quotes as
well, so its output is safe in a single- or double-quoted attribute; the document writer uses the
same two. An HTML <style> (drawn only with allowTags: ["style"]) writes its text as given, the
CSS the parser reads raw, and is left out when the text contains </style; inside <svg> a
<style>'s text is ordinary text and is escaped. A <script> drawn with allowTags: ["script"]
still has its text escaped ("Open").
html-safety.js
Exports: safeAttributeName, allowedTags, safeTagName, checkTag, reusableElement,
checkInnerHTML, classTokens, checkStyle, allowedSchemes, safeUrl, checkAttribute.
The tag, attribute, markup, class, style and URL-scheme refusals both renderers apply, so the two never disagree.
checkTag(tag, allowed)andcheckAttribute(name, value, schemes)are each renderer's decision for one element and one attribute, answering{ ok, reason };safeTagName,safeAttributeNameandsafeUrlare their parts.- The refused tags are those that run code, load a document or reach the whole page:
script,iframe,frame,frameset,object,embed,applet,portal,base,meta,link, SVG'sset,animate,animateMotionandanimateTransform,plaintext(the parser never closes it: the rest of the document, state and loader included, becomes its text) andstyle(page-wide CSS from data: attribute-value exfiltration through selectors andurl(),@import), SVG's included.allowTagsopens any of them by name (allowTags: ["style"]). reusableElement(localName, tag, allowed)says whether the DOM renderer may take an element that is already there as the onetagdraws: the same tag, and onecheckTagallows.checkInnerHTML(allowed)refusesinnerHTMLunlessallowed === true(the instance'sallowInnerHTML).classTokens(key)is aclassListkey's class names, split on ASCII whitespace, empty ones skipped.checkStyle(key, value)answers{ ok, name, reason }for one style key:nameis the CSS property (margin-top,--accent,-webkit-transform,float), given whenever the key is a property;null,undefinedand""are a removal, never refused. The rule is "Components and layouts"'style.- A URL attribute (
href,src,action,formaction, …) may be relative, a fragment,http,https,mailto,tel,sms,geoorftp, ordata:for an image. Juris'sallowTagsandallowSchemesadd tags and schemes on the instance given them (allowTags: ["iframe"],allowSchemes: ["magnet", "web+app"]: names, the schemes' without the colon, lower-cased once, added to the list and never taking from it).allowedTagsrefuses a list naming anything that is not a tag's name (a letter, then letters, digits or-), which would open nothing while the app believed it had.allowedSchemesrefuses a list naming anything that is not a scheme's name, orjavascript,vbscriptordata(which keeps its own rule, an image only), andsafeUrlrefuses those three on every URL again, however the value spells them, whatever set it is given. No option openssrcdoc.server/document.jschecks a page's head against the defaults alone.
router.js
Exports: createRouter (also the default), Router, titleOf, ROUTER_COMPONENTS,
parseQuery, formatQuery, splitUrl, joinUrl.
Routes, $route, links, navigate, the Back and Forward trail, and a route's own code.
createRouter(options) makes a Router, installed with juris.use(router).
- Options:
routes,mode("history", or"hash"),base,routePath("$route"),scroll("top", orfalse),fullLoad(url),guard(to, ctx),context(api)andhistory. Anything else is refused, as is aguard,contextorfullLoadthat is not a function: a misspeltguardwould leave every route open.routePathis a root of the state: a name with a.is refused. - A route record:
path(/lots/:idnames a parameter,*resta named rest parameter, a bare*the catch-all),name,component,props,children,redirect,beforeEnter,preload(location),loadandregister("A route's own code"),metaandhead. The deepest matching record wins, a catch-all only when nothing else matches, and a tie goes to the record registered first, so a literal route (/items/new) is registered before its parameterised sibling (/items/:id). resolve(url, extra, ctx)gives a server everything for a request: the state, the preload list (each matched record'spreload({ ...location, params, ...extra })), the table'sredirect, and the route the address lands on, so a server never reads the address a second time:name(null when it has none),params, the mergedmeta,head,matched, andnotFound, true for the catch-all or when no record matches.guard(to, ctx)decides who may open a route, one function on both sides:resolveasks it with the context it is given (who is asking, on a server; nobody's when none is given), andnavigatewith the router'scontext(api)(() => ({})unless given), before any record'sbeforeEnter. It answers nothing (go on) or a location to send the visitor to instead, whichresolveanswers as itsredirect; anything else,falseor a promise included, is refused with aTypeError, so it cannot be async. It must read nothing but its two arguments: a server renders every request on one instance, whose state is whichever request rendered last. Back a
