@verajs/ssr
v0.2.1
Published
Vera-native server-side rendering. Node-only, plain ESM, zero dependencies.
Maintainers
Readme
@verajs/ssr
Vera-native server-side rendering. Node-only, plain ESM, zero dependencies — no wcc, no lit, no acorn, no parse5.
Nested components are found by walking the emitted markup for tags the registry knows. The walk is
state-aware, not a regex: it respects quoted attribute values (a > is legal inside one), and it
leaves the contents of comments, <script>, <style>, <textarea> and <title> alone, because
those are text and a scan for elements has no business reading them.
import { renderToString } from '@verajs/ssr';
const { html, styles, title } = await renderToString(new URL('./components/app.js', import.meta.url), {
attributes: { 'user-id': id }, // an object — values are escaped
props: { rows }, // structured data; an attribute can only carry a string
children: '<p>slotted</p>', // what a <slot> in the component renders
location: request.url, // this request's URL — see below
seen, // a Set carried across renders — see below
});title is document.title as that render left it — a component setting it is how a shell names its
page — and it is returned rather than left on the global, for the same reason location is
passed rather than assigned: a process global cannot belong to one request. The document's own title
is restored afterwards, so a concurrent render never sees it.
For a shell assembled from several islands, carry one Set through every call. Each render returns
the styles of what it rendered, so two islands sharing a component would otherwise each carry that
component's CSS and the page would ship it twice.
Pass location rather than assigning to globalThis.location. A component that reads the URL —
any routed shell does — needs the request's, and globalThis.location is process-global while a
request is not. Assigning to it and then calling renderToString is safe only until two requests
overlap: the call awaits import(), and on a module's first import that await yields, so whichever
request assigned last wins for every render after it. Measured with three concurrent first-time
imports, two of three rendered another request's path. The option applies the URL after every
await and restores it afterwards, in a stretch that is synchronous end to end and therefore cannot
interleave. A path or a full URL both work.
children, and the string form of attributes, are raw markup. Both are written through
untouched — that is what they are for — so neither may carry anything from a request without being
sanitized first. Everything else is checked at the render boundary: the object form of
attributes cannot escape the tag it describes and cannot add a second attribute inside it — a
name carrying a space, a quote, /, = or > is refused, which is the same set setAttribute
refuses in the browser — and every interpolated value in a template is escaped as it is written. A
__proto__ key in props is skipped rather than assigned, so handing the option a parsed request
body cannot replace the component's prototype. Reach for the string form of attributes only when you must produce markup an object cannot
describe.
Node resolves the component's module graph natively (the
.ts-via-.jsconvention included); execution registers classes throughcustomElements.define— no AST walking.The lifecycle runs the way it does in a browser.
attributeChangedCallbackfires on upgrade for every present observed attribute and again on every later change; there is no animation frame to wait for — frames are queued and drained onceconnectedCallbackreturns, and again for whatever those schedule, so a re-render and everyuseEffectland before the markup is serialized, coalesced exactly as a browser coalesces them. An endless animation loop is bounded rather than run forever.tests/lifecycle-parity.test.mjsrenders each case on both sides and compares the DOM.A failure during a render rejects — it is never markup. Core isolates a hook error so one bad effect cannot take out the hooks beside it, which is right in a browser because the next render can recover. There is no next render here, so
renderToStringcollects those failures and throws, naming the component. Catch it to fall back to a client-rendered shell, as you would with React or Vue.renderToStringAsyncawaits the component's lifecycle.renderToStringis synchronous end to end, so anasync connectedCallbackis refused — its markup would be empty, and saying so beats shipping it.renderToStringAsyncwaits: for the callback, and for promises to settle between frame rounds. That is what a component loading data needs, and what puts a routed component's first view in the first response rather than only after hydration.Everything that decides what to emit is shared with
renderToString— the scanner, the serializer, the instance preparation, the page assembly — andtests/ssr-async-parity.test.mjsrenders every fixture through both and compares. The scan itself stays synchronous in both: a component tag becomes a placeholder and its render a promise, and the placeholders are substituted once everything settles. Awaiting inside the scan would have meant a second copy of the parser, and an async recursion measures 2.45x even when nothing suspends — which the synchronous path is not going to pay for a feature it never uses.Asynchronous renders take a turn each. The per-render bookkeeping is module-level, and being synchronous end to end is what makes concurrent
renderToStringcalls safe; a render that pauses does not have that protection, so two overlapping ones would read each other's. One at a time is the version that cannot be wrong — and the turn covers both entry points, because a synchronous render fired inside an asynchronous one's suspension window would run to completion on the shared state and the async render would resume into the wreckage. Every caller already awaits, so the turn is invisible; when nothing is in flight it costs one microtask.static: truerenders a page that will not be interactive, about 3x faster. A server render is one shot — the subscriptions built while it runs are never fired afterwards — so tracking every property read to create them is pure cost. Measured on a component rendering twenty rows, the proxy behindcreateStoreis the entire reactivity overhead: about 40 µs against a 15 µs baseline, where effects and the scheduler cost nothing detectable. Withstaticon,createStorehands back a plain object and reads are ordinary property access.The markup is identical — that is the whole safety of it, and it is not asserted on an example:
tests/ssr-static-mode.test.mjsrenders every fixture in the suite both ways and compares markup, styles and title. A mode that cannot drift is why this is a flag rather than a second renderer.A store written to during a static render throws, naming the option, rather than rendering markup that reflects none of the writes. That guard is not development-only, unlike most of this framework's diagnostics: a server runs the production build, so folding it away would remove it from the only place it matters.
Events are real, and they propagate —
EventTargetsemantics on elements, shadow roots,documentandwindow, includingonce,handleEventobjects,event.targetand adispatchEventreturn value that reflectspreventDefault— plus the capture, target and bubble phases,stopPropagation,stopImmediatePropagation,composedPath(), and a shadow boundary crossed only by acomposedevent. Bubbling used to be absent because this DOM held children as a string and there was no ancestor chain to walk; children are nodes now, so there is. The walk is over the node tree — an event does not continue intodocumentorwindow, which are not part of it here.The properties only some elements have are there too, and reach the markup.
input.disabled,a.href,td.colSpan,option.selected— 273 of them across 44 tags, measured from Chromium, Firefox and WebKit rather than written from memory (scripts/measure-element-reflections.mjs). Each tag gets a prototype carrying exactly its own interface, so'disabled' in paragraphstaysfalse: an element answering for members it does not have is the same lie as one missing the members it does. Without this layerbutton.disabled = truestored a plain JavaScript property — it read backtrue, wrote no attribute, and served a button that was not disabled until the bundle landed. A property is only in the table when all three engines agree on every measured cell and reading the attribute back gives what was written, which excludes the ones resolved against a document URL (form.action), read out of layout (input.width) or clamped (meter.value); those are listed with the measurement that produced each.value,checkedandselectedare the deliberate exception — a browser keeps them off the markup, and on a server the markup is the whole output, so they are mirrored exactly asserializer.jsalready mirrors the same three for template bindings.A parsed
<template>'s content is opaque, andtemplate.contentisundefinedhere. The element itself is modelled — found byquerySelector, styled, serialised back byte for byte — and a query on the host correctly does not descend into it, which is what a real DOM does too. What is missing is reaching INTO one:template.content.querySelector(…)answers nothing on the server and a fragment in the browser. It used to be far worse. Markup containing a<template>was refused outright, so the host had no nodes at all and EVERY query on it answered emptily — one empty<template>turnedquerySelectorAll('*')from three elements into none, silently, and cost light-DOM slots their whole server render. Keeping the interior opaque, exactly as<svg>already was, fixed that. Implementingcontentis a contained follow-up if anything needs it: the interior is already retained verbatim, so the getter is a lazyparseFragmentof the string it already holds.The server DOM is complete, and checked twice. Every member a real element, shadow root, document,
CSSStyleSheet,DOMTokenListor window exposes in Chromium, Firefox and WebKit is either implemented or listed as out of scope with a reason — and every member that is implemented is then compared against a real DOM, member by member, so one that exists and answers differently fails too. That second check is the one that earns its keep: enumerating presence found a single gap, while comparing behaviour foundtabIndexdefaulting to 0,draggabledefaulting to true,roleanswering''where the platform answersnull,textContent = nullwriting the word "null", and a closed shadow root handed straight back. That comparison checks a member's shape — the type it answers with — not its answer to every input, so it is a net rather than a proof: an empty array and a full one look the same to it. Three separate classes of defect were found by going around it deliberately — passing values nobody would pass (a symbol, where the platform throws), checking the members jsdom does not implement at all (which it must skip), and asking whether a member that answers emptily should have answered at all. The window's ~700 interface constructors are covered by a rule rather than a list: every interface this DOM implements is exposed, soinstanceofanswers for anything it hands you — the list is checked in (tests/dom-surface.mjs, no dependency involved) and both halves are enforced, so a gap fails a test instead of a render. That includes the sixty reflected properties (id,className,hidden,tabIndex,role, the wholearia*family), which are views of an attribute and therefore reach the markup, andattachInternals(), so a form-associated custom element runs, and the objects those members hand back —classList,style,dataset— are held to the same list rather than assumed complete once the property exists.attachShadow({ mode: 'closed' })behaves as it does in a browser:element.shadowRootisnull, and the root is serialized anyway, because declarative shadow DOM expressesclosedand the client re-creates it just as hidden. Where the platform throws, this throws — an attribute or tag name that cannot be written, a secondattachShadoworattachInternals, an invalid custom-element name,appendChildof a non-node. A server that is lenient about an error does not make anything work; it moves the failure to the client and strips the context. The exceptions are deliberate: a selector this DOM cannot answer honestly throws rather than answeringnull. It matches type, class, id, every attribute operator,:not()and all four combinators — descendant,>,+and~. Everything else raises instead of quietly reporting no match, for two different reasons::hover,:checked,:visibleand:rootneed user state, layout or a document a server does not have, while:first-child,:nth-child(),:empty,:first-of-type,:is()and:has()are answerable here and simply are not implemented. That second group used to be covered by the first reason, which made a limit of this matcher read as a property of servers — and predicted, wrongly, that:first-childwould work; A collection is a plain array, not a liveNodeListorHTMLCollection—childNodes,children,querySelectorAlland thegetElementsBy*family all answer with one. There is nothing to be live over while a render is a single pass, and an array is more useful to a caller than a collection they have to spread.item()andnamedItem()are provided anyway, because losing them was a side effect of that choice rather than part of it:list.item(0)is ordinary code, and threw.checkVisibility()is alwaysfalse, since nothing here is laid out and a server cannot know what CSS will do, a constructed sheet holds its CSS as text rather than a parsed rule list — socssRulesis empty whatever the sheet contains, which is all the markup needs and is whydeleteRulesays so rather than pretending, andinsertAdjacentHTMLwithbeforebeginorafterendraises a message explaining that a server-rendered component has no parent, which is more use than the platform's bareSyntaxError. Astylevalue is stored as it was written rather than re-serialized, sourl("data:…")keeps its quotes where a browser's CSS serializer drops them — equivalent CSS, and a semicolon inside a value does not split the declaration, which is what matters for an inlinedata:URI. Layout reads as zero because that is what a detached element answers in a browser too. Names fold the way the platform folds them: an HTML element lower-cases its tag and its attribute names, sosetAttribute('Data-Flag', …)andgetAttribute('data-flag')are one attribute and anattributesentry spelledUser-IDstill matches anobservedAttributesentry spelleduser-id; an element created throughcreateElementNSoutside the HTML namespace keeps its case, so an SVGviewBoxsurvives.A component can build another component.
document.createElement('my-comp')constructs the registered class, so its field initialisers have run andinstanceofanswers, and appending it renders that instance — everything the parent assigned to it,kid.rows = dataincluded, survives. The nested-component scan used to re-create the child from its markup, where an attribute is the only thing that can carry a value.render()owns its own range and nothing else, exactly as it does in a browser. Content already in the container stays before the rendered range, a node the component appends to its own root stays after it, and both survive every re-render — so a component that mixesrender()with its ownappendChildproduces the same DOM on both sides. It also meanschildrenreach a light-DOM component and are still there after it renders.A
<select>'s value is served as<option selected>. And a value matching no option cannot be served — see the exception below, which has no fix. A<select>has novaluecontent attribute — assigning the property selects an option — so the only thing markup can say is which option is chosen, and that is what the serializer writes (React's server renderer does the same;@lit-labs/ssrdrops the binding and serves a control showing its first option). Matching follows the platform: thevalueattribute verbatim if an option has one, otherwise the option's text stripped and collapsed, first match wins, and aselectedthe author wrote is cleared because a property assignment overrides markup. All of it is asserted against Chromium, Firefox and WebKit intests/browser/select-value.test.js.The exception is real and has no fix. When the value matches no option the client leaves
selectedIndexat-1with nothing showing, and a parsed<select>whose options carry noselectedtakes its first — there is no markup for "none of them", and inserting a hidden placeholder would change the control the author wrote.Component nesting is capped at 256 levels, and the client has no such cap. A component that renders itself recurses without bound, which on a server is a hung request rather than a hung tab, so
renderToStringrefuses past 256 and says so. This is a real divergence, and 256 is chosen to sit below where the client breaks: the client managed about 340 levels beforeRangeError(reported through the'error'insert), so the server still fails first and fails with a sentence. That ~340 is engine-dependent, which is why the server does not wait for it.A carriage return survives, as
— everywhere except<style>and<script>. The HTML input-stream preprocessor collapses CR and CRLF to a single LF before tokenization, so a raw\rwritten into markup does not come back — the server would rendera\r\nband the client reada\nb, which is a silent hydration mismatch on every render of a<textarea>value, a CSV cell, or any string from a Windows-authored source. Character references are resolved after preprocessing, so the escaped form does survive; verified identical in Chromium, Firefox and WebKit.RAWTEXT is the exception, and it is not fixable. A browser does not decode a character reference inside
<style>or<script>— that is what makes them RAWTEXT — so there is the literal six characters, while the preprocessor still collapses the raw CR. There is no spelling of a carriage return that survives in those two elements.<title>and<textarea>are RCDATA, which does decode references, which is why they round-trip correctly. All three behaviours are asserted against Chromium, Firefox and WebKit intests/browser/rawtext-carriage-return.test.js.In practice this reaches an interpolated stylesheet or inline script whose source has Windows line endings — a repository checked out with
core.autocrlf=trueputs CRLF inside every template literal,cssblocks included. CR and LF are interchangeable whitespace to both CSS and JavaScript, so nothing renders wrongly; the two sides simply hold different strings.Three things cannot survive a server round trip, and are the only three. Two are characters and one is a character in a position — see the carriage return above, which round-trips everywhere except inside
<style>and<script>.- NUL (
\u0000) is dropped in text, rewritten to U+FFFD in an attribute and inside RAWTEXT, and�is a parse error that also yields U+FFFD. No spelling round-trips, so it is left alone rather than silently turned into U+FFFD — that would make the markup lie about what the component rendered without making the two sides agree. - A lone surrogate (
\uD800with no pair) is not encodable in UTF-8, so the transport replaces it with U+FFFD — a real HTTP response does exactly whatBuffer.toString('utf8')does. Nothing server-side can prevent that.
Both are covered by
tests/ssr-text-boundary.test.mjs, alongside astral pairs, combining marks, bidi controls, noncharacters and 20 other cases that do round-trip exactly.- NUL (
What a component does to itself in
connectedCallbackreaches the markup — asetAttribute, anaria-*, a class, a reflected property.<style>and<script>content is written raw, and their own end tags are neutralised (<\/style,<\/script— valid CSS and JavaScript, invisible to the tokenizer). A browser does not decode a character reference inside either, so escaping there protects nothing and corrupts the content: an interpolated.a > .bused to serve.a > .b, a selector matching nothing, while the client rendered it correctly.<title>and<textarea>are RCDATA rather than RAWTEXT — references are decoded there — so those keep ordinary escaping, which is also what the client produces for them.Templates flatten through a sigil-aware serializer with per-template-identity plan caching:
?boolresolved by truthiness,.value/.checked/.selectedmirrored to attributes on form controls,@event/&refstripped without residue, every interpolated value escaped at the boundary.A property bound on a rendered component tag is DELIVERED, not dropped: written (
<props-row .item=${row}>) or spread (props({ item })), the value reaches the instance the nested-component scan renders — by identity, before its lifecycle, exactly whererenderToString's ownpropsoption puts the entry component's. The markup never changes (a property is not an attribute), so the child's server output comes from the same data its client render will get, which is the hydration contract. An unregistered dashed tag passes through untouched — its properties stay the client's to apply. Values that cannot exist server-side (a DOM node, a callback into browser state) are the component's to guard, as in every SSR framework.Output is declarative shadow DOM with zero framework comments; light-DOM
@scopestyles are returned separately for the page shell.Client-side,
@verajs/renderer/hydrateadopts the server DOM markerlessly (swap one import).Measured (
node bench/ssr.mjs, fastest of 7 rounds), against lit on both comparisons it supports — µs per render, small component / 100-row table:| | small | table | | --- | --- | --- | | template serialization —
serializeTemplatevs@lit-labs/ssron a template | 0.3 | 35 | | | lit 2.4 | lit 312 | | whole component —renderToStringvs a realLitElement| 4.3 | 49 | | | lit 5.7 | lit 414 |Vue's compiled SSR is 7.9 / 61 µs and React 6.2 / 453 µs, neither of which renders a component. The
lit elementrow runs in a separate process because@lit-labs/ssrand this package both install DOM globals and cannot share one.Most of a component render is core's lifecycle, not this package. Rendering the same component with its
connectedCallbackemptied — which removes core'sinit, store, hooks and re-render and leaves the shim, the serializer and the nested scan — costs 1.0 µs of a 6.4 µs render, so everything this package does is about a sixth of it and the component's own lifecycle is the rest. (Those two figures come from a plainawaitloop rather thanbench/ssr.mjs's batched rounds, so they are higher than the table above and only their ratio is comparable.)
examples/ssr-node/server-native.mjs is a complete server on bare node:http, serving the whole
round trip — the page it returns ships a client module that imports @verajs/renderer/hydrate and
adopts the markup in place.
No streaming. renderToString returns a string, where @lit-labs/ssr yields a stream. That
buys time-to-first-byte in proportion to how long a render takes, and a 100-row table here is 47 µs
— the response is built before a streaming implementation would have flushed its first chunk. It is
a real difference in shape, and worth revisiting for a page big enough that it stops being one.
Importing @verajs/ssr installs a DOM on globalThis. That is what it is for, and it means the
import is not passive: document, customElements, HTMLElement and the rest are replaced, so a
process that already has a DOM — jsdom in a test, say — loses it the moment this module is loaded,
however late. A component defined afterwards is never upgraded and nothing says why. Exercise both
sides in separate processes, which is what this repo's own tests do; tests/lifecycle-parity.test.mjs
renders the server half in a subprocess for exactly this reason.
And import it first, before anything that imports @verajs/renderer.
The module that actually needs the shims is the renderer, not core: it builds two shared
TreeWalkers at import time, so importing it against a bare Node global object throws before your
component ever runs. A component reaches it through keyed or hold, which is why the rule reads
as "import this first" — measured, core, @verajs/styles and @verajs/router are all order-
independent, and only @verajs/renderer is not.
The entry component is found by matching the module's exports against the registry, so export
the class (export default class …) or pass { tag }. It used to guess by diffing the registry
around the import, which two concurrent renders could not share: both saw both modules' new
registrations, and a request could be answered with another component's markup.
renderToString executes the module you name, so pass base whenever any part of the URL came
from a request:
renderToString(new URL(`${page}.js`, components), { base: components });Anything resolving outside it is refused. new URL applies ../ before renderToString sees the
string, so without this the traversal has already happened by the time the call is made — and
mapping a route to a component file is the obvious way to use a server renderer. Same containment,
and the same wording, as @verajs/autoloader uses for the URLs it derives.
It is opt-in because most calls name a constant, and a check that is always trivially satisfied stops being read.
The globals a component reaches for are here too — matchMedia (matching nothing, as every server
renderer answers), getComputedStyle (empty, as a detached element gives in a browser),
IntersectionObserver/ResizeObserver/MutationObserver/PerformanceObserver (inert, because
they observe things a server does not have, but constructing one must not throw), requestIdleCallback
(which joins the frame queue), and the DOM interfaces themselves so instanceof Node answers rather
than throwing.
localStorage, sessionStorage, indexedDB and caches are deliberately absent. They are one
browser's state, and a server that invented an empty one would render a logged-out shell that the
client immediately replaces, with nothing failing anywhere. typeof localStorage === 'undefined' is
the guard the ecosystem already writes, and it only works if this does not lie. The same list is
enforced in tests/ssr-dom-surface.test.mjs, in both directions.
Known limits:
connectedCallbackmust be synchronous. Rendering recurses insideString.replace, which cannot await, so anasync connectedCallbackis refused with an error rather than rendered empty. Load data beforerenderToStringand pass it in as attributes.useLayoutEffectruns, but too late to reach the template it sits beside. It is coalesced on a microtask, andrenderToStringis asynchronous, so it does execute — asetAttributeor an API call inside one happens on the server, which is worth knowing before you put one there. What it cannot do is change what the template already rendered: state settled in a layout effect is not in the markup. Settle it beforerender(), or useuseEffect, whose frame is drained repeatedly and does reach the markup.tests/lifecycle-parity.test.mjspins both halves of that.keyed/holdare client constructs; use plain.mapin SSR templates.- A routed component renders its shell, not its route.
initRouterworks server-side — the shim provides enoughwindowfor it — so the nav and the[view]outlet reach the markup and the client fills the outlet on hydration. The route's own content does not, for the same reason anasync connectedCallbackis refused: rendering is synchronous end to end andnavigateisasync. The initial navigation is scheduled on a frame, the frame runs,navigateis called and returns a promise — and the markup is serialized before that promise settles, so the component behind the route is never called. The outlet itself is found, and awaitingnavigateoutside a render works. Render the route yourself and pass it aschildrenif it has to be in the first response. - A dynamic attribute name is refused.
<b ${name}="x">is malformed on both sides: the client hands the template to the platform's parser and a marker is not a name, and this serializer used to emit<b="x">, which is not an attribute either. Rather than write markup no browser would produce, it throws and names the alternative —@verajs/renderer/spread, which exists for names that are not known until runtime and which this serializer understands. slotAssignmentcannot be server-rendered. Declarative shadow DOM can expressmode,delegatesFocus,clonableandserializable— all of which are serialized — but has no form for manual slot assignment, andattachShadowignores the options it is handed when it reuses a declarative root, so the client cannot repair what the markup left out.- A function interpolated at a text position renders as nothing here and as its source on the
client — put functions in
@eventbindings, where both sides drop them.
The pre-native strategies (wcc fork, lit-labs renderer, Astro sketch, Reef-era diff renderer) are retired; strategy 4 is the only one shipped.
Also exported
registry is the Map of tag → class this process has seen through customElements.define, and
serializeTemplate is the sigil-aware template flattener — the two seams an advanced integration
(a custom scanner, a fixture builder) reaches for. Everything an ordinary server needs is
renderToString / renderToStringAsync; these are listed so their presence is a decision rather
than an accident.
import { renderToString, renderToStringAsync, registry, serializeTemplate } from '@verajs/ssr';
const { html: markup } = await renderToString(new URL('./components/app.js', import.meta.url));
const page = await renderToStringAsync(entry); // same signature, awaits async lifecycles
registry.has('app-shell'); // true once the component module has run
const fragment = serializeTemplate(html`<p>${x}</p>`); // one template to markup, no component scanFor AI assistants — and anyone who wants the whole API on one page
The repository root's llms.txt is the complete, hand-maintained API
reference for every package, written to be pasted into a model's context window: full export
tables, the buildless CDN and JSX recipes, semantics that differ from other frameworks, and the
mistakes that come up most. Its recipes are executed by the test suite, so they stay honest.
