npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

foldkit-ssr

v0.7.1

Published

Server rendering for Foldkit Plus: render once on the server, hand the Model over, and hydrate without rerunning init.

Readme

foldkit-ssr

Server rendering for a Foldkit application that hands the browser the Model the server reached, not the inputs to rebuild it. init runs once, on the server. The browser adopts the server's HTML and starts from the slice of the Model it owns.

Early. The API below is built and tested; as a 0.x package it may still change between minor versions.

pnpm add foldkit-ssr foldkit-surface foldkit effect
// Which part of the Model the browser owns.
const Post = SSR.plan(App, {
  id: 'post',
  state: Projection.pick(App.model.route, App.model.draft),
})

// Server: render, and write that slice into the page.
const html = SSR.page(template, await Effect.runPromise(SSR.render(config, Post, { buildId, url })))

// Browser (from 'foldkit-ssr/client'): take the page over from the slice, without running init.
SSR.hydrate(config, Post, { buildId })

Why you would use it

Foldkit already renders on the server. What its page carries is the input: the Flags that produced the first Model, which the browser decodes and feeds to init again. That is simple and right when init is cheap and its Flags are small.

It stops being right when init does real work: it loads a post, reads a session, computes something expensive. Then the browser either repeats that work or has to receive everything init read, as Flags, including data the page never shows. foldkit-ssr sends the result instead: the fields the browser owns, and nothing else.

| | Foldkit's own (renderToString + Runtime.hydrate) | foldkit-ssr (SSR.render + SSR.hydrate) | | ------------------------------- | ---------------------------------------------------- | --------------------------------------------------------------- | | The page carries | The Flags | The plan's slice of the Model; no Flags | | init runs | On the server, then again in the browser | On the server only | | Server-only data | Crosses if init needs it | Stays on the server unless the slice includes it | | A view that reads unsent state | Rebuilt in the browser; only development warns | Refused by SSR.render, naming the field, before it is served | | What you write | Nothing beyond the config | A plan |

Beyond the handover it offers three things you can adopt one at a time: server-owned static regions that never render in the browser, resumable pages that answer clicks and typing before the runtime has booted, and forms that work with scripts off, answered by running update on the server.

What it owns, and what it does not

It owns the handover: which slice crosses, how it is written into the page, and the checks that the browser can start from it.

  • Rendering and adoption stay Foldkit's. SSR.render calls Foldkit's renderToString and SSR.hydrate calls Runtime.hydrate. Foldkit still refuses a page from another build and still adopts the server's nodes.
  • The Model stays the application's. A plan names fields of it; it adds no state of its own, and every change after boot goes through update.
  • Data belongs to its package. Remote's store crosses through Remote's own part (Remote.resume), which decides what to capture.

The mental model

server   init ──▶ Model ──┬── plan.state ──▶ slice ──▶ envelope   (JSON on the stamped root)
                          └── view ──▶ HTML                        (checked, see below)

browser  envelope ──▶ baseline with the slice set on it ──▶ Model
         Model + the server's HTML ──▶ Runtime.hydrate adopts ──▶ plan.boot Commands

The one rule everything else serves: the browser's first Model renders the same page the server sent. The browser's Model is the plan's baseline, by default the application's initial, with the slice set onto it. So SSR.render renders the view twice, once from the server's Model and once from the browser's, and refuses the plan if the two differ. A view that reads a field the plan does not send is caught there, on the server, instead of being silently rebuilt in the browser.

A first page

The examples in this README share one application, a post page, sketched at the end. App is its Surface application from foldkit-surface, and config is what you would pass to makeApplication, the same object on both sides: any of its four shapes, with Flags or routing or neither, and routing's callbacks typed from update's Message as Foldkit types them.

The code is in three modules, because the server and the browser import different things:

// post.ts, read by both: the plan. Which part of the Model the browser owns;
// the rest starts from the baseline, by default the application's initial Model.
import { Projection } from 'foldkit-surface'
import { SSR } from 'foldkit-ssr/client'

export const Post = SSR.plan(App, {
  id: 'post',
  state: Projection.pick(App.model.route, App.model.draft),
})
// server.ts, per request.
import { Effect } from 'effect'
import { SSR } from 'foldkit-ssr'

const result = await Effect.runPromise(SSR.render(config, Post, { buildId, url: request.url }))
const html = SSR.page(template, result)
// client.ts, the page's script, instead of Runtime.hydrate.
import { SSR } from 'foldkit-ssr/client'

SSR.hydrate(config, Post, { buildId })

foldkit-ssr/client is the browser's part: SSR.plan, SSR.resume, SSR.hydrate, SSR.static, SSR.serving, Resume, and the attribute names, Foldkit's root stamp (FOLDKIT_APP_ATTRIBUTE) among them. It imports nothing of foldkit/experimental/server, whose renderer and HTML parser foldkit-ssr brings along: about 200 kB minified (60 kB gzipped) off a page's script in the SSR example. foldkit-ssr has all of it and the server's calls, the same functions, so a plan made from either works with both. Import foldkit-ssr/client in every module the page loads, the plan and the view included, and foldkit-ssr only where the server alone runs.

What each call does:

  • SSR.plan is a declaration. It does no I/O and holds no state. It throws only for a plan that could never work, such as two parts with one id.
  • SSR.render is an Effect. It runs init once, renders the view twice, checks the plan, and returns the rendered application and the envelope it carries on the stamped root. It fails with ResumeUnsafe when the browser could not start where the server did; the refusals say why and how to fix each.
  • SSR.page is pure: the rendered application in the template, through Foldkit's injectIntoTemplate. The envelope rides on the rendered root, so the template needs no place for it. An optional head adds markup before </head>, given what was rendered.
  • SSR.hydrate reads the envelope, starts Foldkit's runtime from the resumed Model, and runs the plan's boot Commands. init does not run. A page with no server render at all starts on the client as usual.

An application with Flags passes flags to SSR.render. They shape the server's init and never reach the page.

Commands init would have run: boot

The browser does not run init, so the Commands it returns would run nowhere. SSR.render refuses a plan that would drop them, naming them. Say what the browser runs on load in boot:

const Post = SSR.plan(App, {
  id: 'post',
  state: Projection.pick(App.model.route, App.model.draft),
  boot: () => [LoadPreferences()],
})

An application assembled with foldkit-bundle runs its assembly's startup Commands, which is where a Mirror.kv restores what the user saved:

boot: model => assembly.init(model).commands ?? []

Styles in the first paint

A generated page styled with foldkit-mixins can ship the CSS its markup uses in the head, so it is styled before any script runs. head is given the rendered application; Style.usedIn returns the CSS of every compiled class the markup carries. In the browser, Styles bring their rules as they draw and skip what this sheet already carries, so nothing is added twice.

import { Style } from 'foldkit-mixins'

const html = SSR.page(template, result, {
  head: rendered => `<style>${Style.usedIn(rendered.html)}</style>`,
})

A served page has no template head to write to, so SSR.render, SSR.entry and SSR.handle take the same function as styles instead, carried as the rendered root's last child. Keep it to <style>: hydration adopts the nodes around it and drops it on its first patch, and the browser's Styles skip what it already carries. A void root holds no children, so styles for one are refused with VoidRootWithStyles, naming the tag. Anything a head owns (meta, links) stays with SSR.generate.

SSR.generate takes the same head, and answers a head that throws 500, as it does a render that fails. SSR.entry takes no head: the host owns the template in dynamic serving, so a served page carries no per-page head markup, and the script brings what the first paint needs.

The head is part of the view

title, lang, dir, canonical and ogUrl are checked like the body. Since Foldkit 0.163 nothing gives canonical a default from the URL, so a page that wants one derives it from the route in its Model, as it does its title, and sends the route in state.

Foldkit fills canonical and og:url only into tags the template already has and adds none, so a render that sets either into a template without <link rel="canonical" href=""> or <meta property="og:url" content=""> is refused, naming the tag.

What a page says of itself: meta

A search result and a link preview read more than Foldkit's Document: a description, an image, Open Graph, a post's dates and structured data. The plan's meta gives them from the Model:

const Post = SSR.plan(App, {
  id: 'post',
  state: Projection.pick(App.model.route, App.model.post),
  meta: model => ({
    description: model.post.excerpt,
    type: 'article',
    article: { published: model.post.publishedAt },
    jsonLd: [{ '@context': 'https://schema.org', '@type': 'BlogPosting', headline: model.post.title }],
  }),
})

SSR.page and SSR.generate write the tags (description, og:*, twitter:card, article:*, robots, link rel="alternate", and one JSON-LD script per entry) before </head>, every value escaped, and SSR.render checks them like the view: a meta that reads a field state does not send is ViewDependsOnUnsentState. SSR.hydrate keeps them in step with the Model, so a move to another post replaces the description too. Each carries data-foldkit-meta (META_ATTRIBUTE), which is how the browser finds the ones to replace; leave such tags out of the template.

Serving pages

Through Foldkit's fetch handler

Foldkit's server entry is one function, renderPage(request), which handleRequest calls for every request that is not a static file, on Node and on Workers alike. SSR.entry is that function for a plan. The host owns the template: the entry answers Rendered, with the envelope on the rendered root, and the host injects it into its own template, in development through the foldkit Vite plugin and in production through the fetch handler a vite build emits.

import { handleRequest } from 'foldkit/experimental/server'

// The server entry.
export const { renderPage } = SSR.entry(config, Post, { buildId })

// A Worker, or any host that hands you a Web Request.
export default {
  fetch: (request: Request) => handleRequest(request, { renderPage, template }),
}
// vite.config.ts, for the dev server and the build id both entries compile in.
import { foldkit } from '@foldkit/vite-plugin'

export default defineConfig({
  plugins: [foldkit({ ssr: { serverEntry: '/src/entry.server.ts' } })],
})
// entry.server.ts and the browser's entry.ts alike.
buildId: import.meta.env.FOLDKIT_BUILD_ID,

FOLDKIT_BUILD_ID names the deployment, from the environment: a commit or a release tag, public and unique per deployment. The plugin compiles it into both bundles; a script outside Vite, such as a prerender, reads process.env.FOLDKIT_BUILD_ID, the same value the build saw.

Set it even though @foldkit/vite-plugin can now generate an id on its own. The generated one is compiled into Foldkit alone, where foldkit-ssr cannot read it, and the client must compare builds before it reads a page's payload, so a Model serialized by another deployment is never decoded. Asking Foldkit to expose that id is proposal 4.

GET and HEAD render the page for the request's URL; an application with Flags passes flags: request => .... POST is handled only for a plan with a server fallback. OPTIONS is answered 204 and any other method 405, each with the methods the entry answers in allow. A render that fails or throws, flags that reject, and a plan the render refuses are answered 500 with the reason logged, never with a page the browser could not resume.

headers: request => ... rides on the Rendered result, set over the host's own: a cache policy for a page that is one visitor's, or the CORS answer to a preflight, which reaches the entry and not the host.

SSR.entry(config, Post, {
  buildId,
  headers: () => ({ 'cache-control': 'private, no-store', vary: 'cookie' }),
})

A headers that throws is answered 500, as a failed render is.

What the page says of itself (meta) and per-page head markup are written into a template's head, which the host owns here: a plan with meta is refused when the entry is made, and served with SSR.generate below, which owns its template. foldkit#1448 asks Foldkit for a per-request head channel beside the envelope.

At build time

import { mkdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'

const pages = await Effect.runPromise(
  SSR.generate(config, Post, {
    buildId,
    template,
    origin: 'https://example.com',
    paths: ['/', '/about', '/docs/intro'],
  }),
)
for (const page of pages) {
  await mkdir(dirname(join('dist', page.file)), { recursive: true })
  await writeFile(join('dist', page.file), page.html)
}

Each path becomes index.html in its own folder (/about is about/index.html); a path ending in .html keeps its name. origin makes each path the full URL a routing application parses, and flags: path => ... gives each its own Flags. The pages come back in the order of paths, typed as a tuple, so const [home, about] = pages needs no check.

A static host serves one file for every query, and for /about and /about/ alike, so a generated page records its path alone: /about?ref=mail resumes the page generated for /about, and /other is refused. A server-rendered page records its path and query, and resumes only there.

A generated site also wants a sitemap and a robots.txt that names it. SSR.sitemap takes the pages, with a modified date where one is known, and SSR.robots the origin:

await writeFile(
  'dist/sitemap.xml',
  SSR.sitemap(
    pages.map(page => ({ path: page.path, modified: '2026-09-27' })),
    { origin: 'https://example.com' },
  ),
)
await writeFile('dist/robots.txt', SSR.robots({ origin: 'https://example.com' }))

modified is any date Date reads, written as its UTC day. A path listed twice, one not starting with /, or a date Date cannot read is refused rather than written into a sitemap a search engine would reject. robots takes disallow paths and a sitemap path other than /sitemap.xml.

As a Vite plugin: foldkit-ssr/vite

foldkit-ssr/vite runs the same static build as a closeBundle step on the application's own vite build, so a site is generated without a second script. The application describes its site in a module (src/site.ts) and the plugin evaluates it through a server, so vite.config.ts names a file, never application code:

// vite.config.ts
import { staticSite } from 'foldkit-ssr/vite'
export default defineConfig({
  plugins: [foldkit(), staticSite({ site: { module: '/src/site.ts' } })],
})

src/site.ts exports a SiteModule: what SSR.generate takes, plus where the pages land. config may be one config or a function of the path (a site whose Model is prepared per page returns its own); paths may be a thunk that seeds and lists; head is the same head; and sitemap/robots write their files from the generated pages. files is the host's layout — 'directory' (/about as about/index.html, the default) or 'flat' (about.html, for a host with clean addresses and no directory indexes):

// src/site.ts
import type { SiteModule } from 'foldkit-ssr/vite'
export const site = {
  origin: 'https://example.com',
  paths: ['/', '/about'],
  config: { Model, init, update, view, container: null, routing },
  plan,
  head: rendered => `<style>${Style.usedIn(rendered.html)}</style>`,
  files: 'directory',
  sitemap: true,
  robots: true,
} satisfies SiteModule

generateStaticSite(site) is the same build without Vite: it renders every path and returns { pages, sitemap?, robots? }. fileFor(path, layout) maps one path to the file a host serves it from, so a caller writes the files itself; generateStaticSite refuses two paths that would claim one file.

Checking what the browser will read

The view check sees what the first render shows. A Surface can read a field the first view never shows: a like button's state, a menu's contents. Name the Surfaces the browser may activate, and the fields that may start from the baseline on purpose:

const Post = SSR.plan(App, {
  id: 'post',
  state: Projection.pick(App.model.route, App.model.post.id, App.model.post.liked),
  local: [App.model.menuOpen], // starts from the baseline, on purpose
  surfaces: [
    Surface.when(PostActions, App.model.route, AppRoute.Post, route => ({ id: route.id })),
    Surface.at(Menu, undefined),
  ],
})

SSR.render then refuses the plan, naming the Surface and the field, when an active Surface reads a field that is in neither state nor local, is activated by one, reads data no part of the plan resumes, or would activate differently from the Model the browser starts from. The check runs for the server's Model, because a Surface's reads follow its params. SSR.inspect(plan, model) returns the same findings as data, for a test or a debugging session: for each Surface, whether it is active, and where each read comes from, state, local or missing.

A Surface.each family is one entry of surfaces: the envelope carries its parent's reads with its instances', and coverage inspects each instance by key (Member[u1]), saying which parent reveals it, so a gap names the instance.

State the slice cannot carry

Parts: another package's state

Some state is not a field to pick. Remote's normalized store holds whatever the server read, most of which the page never shows. A part is a package's own contribution to the envelope: it captures what the plan's active Surfaces read, and restores it in the browser.

const Post = SSR.plan(App, {
  id: 'post',
  state: Projection.pick(App.model.route),
  surfaces: [PostPageAt],
  parts: [Remote.resume(Data)],
})

Remote.resume sends each field the Surfaces select, through relations, each connection with its boundaries, and the live cursors of what it sends, and nothing else of the store. The browser requests none of it again, and the coverage check counts those reads as sent. Every part the plan names must be in the page and restore, or the page is refused whole. The server restores each part from its own capture before serving, so a part that could not is refused there (UnrestorablePart), not in a user's browser.

Static regions: parts of the page the server owns

An article body, a product description, highlighted code: a part of the page no Message changes need not render in the browser at all, and what it reads need not be sent. Mark it with SSR.static:

const view = (model: Model, h: HtmlBuilder<Message>) => ({
  title: 'Post', // not the post's title: the browser does not have it
  body: h.main(
    [],
    [
      SSR.static('post-copy', ih => [
        ih.h1([], [model.post.title]),
        ih.p([], [model.post.body]),
      ]),
      PostActionsView(model, h), // the part the browser owns
    ],
  ),
})

The region renders once, on the server, with Foldkit's inert builder, so it cannot attach a handler. The browser adopts the server's markup as it is and never runs the render, so this plan sends neither post.title nor post.body and the view check still passes. A region changes only with a new document; content a Message should change belongs in a Surface. A region the browser needs that is not in the page, say after a Message shows one, is logged and rendered in the browser.

Markup the browser adopts: SSR.serving

SSR.serving() is true while the view call in progress is the server rendering a page (both of render's passes), and false while the browser resumes and outside any render. It is for a view whose element hydration leaves alone, such as a custom element's light DOM, and which sends markup there that the browser's own code then takes over. The rich-text editor's host is one: pass serverRendered: SSR.serving to foldkit-richtext-dom's editorAt, and the page carries the document instead of an empty editor until the script runs.

Resumable pages: answering before the runtime boots

Everything above boots Foldkit's runtime as soon as the page loads. A resumable page waits: it answers clicks and typing from the server's markup, and boots only when it must, so the first interaction does not wait for the application's code to start.

server   each handler ──▶ its Message, encoded ──▶ a marker on the element + an entry in the envelope

browser  event ──▶ the markers name its Messages ──▶ queued ──▶ the runtime boots
                                                             ──▶ update runs the queue first

It rests on the fact that a Foldkit handler is already a Message value. Resume.builder(h) is h that writes those values into the server's markup:

const view = (model: Model, h: HtmlBuilder<Message>) => {
  const rh = Resume.builder(h)
  return {
    title: 'Post',
    body: rh.main(
      [],
      [
        rh.button([rh.OnClick(Message.Liked({ id: model.post.id }))], ['Like']),
        // A Message with a hole: the event fills `value`.
        rh.input([rh.Value(model.draft), rh.OnInput(Message.ChangedDraft)]),
        // Fixed fields beside the hole: the event fills `title`.
        rh.input([rh.OnChange(Message.Renamed, { id: model.post.id })]),
        // A key event fills `key` and `modifiers`.
        rh.div([rh.OnKeyDown(Message.Pressed)], []),
      ],
    ),
  }
}

rh differs from h in one place: OnInput, OnChange, OnKeyDown and OnKeyUp also take a Message's own constructor, and the event fills the field it leaves open. The types check it where it is written: the member must be one of the view's Messages, and leave exactly one string field, or exactly key and modifiers. A closure still works. It is not data, so the page cannot answer that event itself and boots on it instead, letting the live page answer. SSR.render names each such element and event in its result's unnamed. So does a hole form whose field has checks the empty placeholder fails, such as Schema.isMinLength(1): it cannot be written into the page as data, so it is treated as a closure.

A Surface renderer gets the same builder through Resume.view, and its rh makes only that Surface's Messages:

const LikeView = Surface.rootView(
  Like,
  undefined,
  Resume.view((like, rh) => rh.button([rh.OnClick(Message.Liked({ id: like.id }))], ['Like'])),
)

Then say when the runtime starts:

const Post = SSR.plan(App, {
  id: 'post',
  state: Projection.pick(App.model.post),
  surfaces: [PostActionsAt],
  start: 'on-interaction', // or 'idle'; 'now', the default, boots on load
})

The event that boots the page is not lost and does not count twice. The runtime starts from the resumed Model, so its first render is the served markup and every node is adopted, the input typed into included. The Messages answered before boot then go through the same update, in order, ahead of anything the live page answers, even an event dispatched in the task that boots it, so the Model ends where an eager boot would have taken it.

What a resumable page must declare

  • Its Surfaces. A Surface's messages list is what the page may dispatch before boot. A binding whose Message no active Surface lists is refused on the server, naming the element and the Message, and the browser refuses a page that carries one.

  • What may start late. A Subscription or Managed Resource that starts at boot starts later on a deferred page, which can change what the application does. SSR.render refuses a deferred plan while one would be active, naming each, until the plan declares it:

    const Post = SSR.plan(App, {
      id: 'post',
      state: Projection.pick(App.model.post),
      parts: [Remote.resume(Data)], // Remote's entries may start late; its part says so
      start: 'idle',
      deferrable: ['tick'], // this application's own clock may start late
    })

    For the check to see them, pass the application's subscriptions and managedResources in the config given to SSR.render, as you would to Foldkit. boot Commands run at boot, so a deferred page's Mirror.kv restore waits for the first interaction.

Bundles whose code loads on demand

A Bundle.lazy from foldkit-bundle keeps a bundle's update and view out of the boot chunk. Name each in the config's lazy list:

const config = {
  Model,
  init: () => placements.init({ title: 'Uploads', upload }),
  update: placements.update(),
  view: (model: Model, h: HtmlBuilder<Message>) => {
    const rh = Resume.builder(h)
    return { title: model.title, body: rh.main([], [PlacedUpload.view(model, rh)]) }
  },
  container: null,
  subscriptions: placements.subscriptions(),
  lazy: [Upload],
}

The server loads the code before it renders, so the page carries the real view. The browser loads it before it boots and answers from the markers meanwhile, whatever start says. A binding inside a placement dispatches the parent's Message, as Foldkit's own handlers do, so the plan's Surface lists the wrapper variant, Message.GotUploadMessage.

Forms that work without scripts

A form whose OnSubmit names a Message can be answered by the server, before any script has run or with scripts blocked, because update is pure and the server can run it. The plan opts in:

const Todos = SSR.plan(App, {
  id: 'todos',
  state: Projection.pick(App.model.draft, App.model.todos),
  surfaces: [Surface.at(TodoList, undefined)],
  fallback: 'server',
})
rh.form(
  [rh.OnSubmit(Message.Added({ title: model.draft }))],
  [rh.input([rh.Name('title'), rh.Value(model.draft), rh.OnInput(Message.Typed)])],
)

The Message carries the title because a post without scripts fills it: the server starts from init, whose draft is empty. With scripts on, it is the Message as drawn, so a submit within a frame of the typing sends the text drawn before it (docs/state-model.md). A form that need not work without scripts sends Added() and reads model.draft in update instead.

With scripts on, nothing changes: the page answers the submit and the form never posts. Without them, the server wrote the form to post the Message to the page's own URL, and SSR.entry hands that post to SSR.handle:

POST ──▶ decode the Message ──▶ set the posted fields into it (the typed `title`)
     ──▶ init, then boot and its Commands ──▶ update, and every Command after it
     ──▶ the page, rendered

The Message must be one the page's active Surfaces list. Commands run under the config's resources Layer, as Foldkit's runtime would provide them, and under the post's own, which SSR.entry's resources: request => Layer builds once for it: who is asking, a client bound to them. Where both provide a service, the post's is the one its Commands get, so a client bound to the request replaces one bound to nobody. A render runs no Command; it reads the request through flags. A post the server cannot use is answered 400 with the reason (FallbackRefused); a Command that fails is answered 500. The browser's loop runs for the life of the page, so a Command that reschedules itself, a poll or a tick, is ordinary there; on the server a post that has not settled within 100 Messages and Commands is answered 500, naming the last Command. SSR.handle(request, config, plan, { buildId, flags?, resources? }) can also be called from an entry of your own.

A form a cookie session authorizes is a cross-site request forgery target, and a form cannot carry a CSRF header. SSR.entry's origins names the origins a form may be posted from; a post whose Origin (every browser sends one on a POST) is not among them is answered 403 before its body is read or flags and resources run. A page served with Referrer-Policy: no-referrer posts Origin: null even to its own site; such a post, or one with no Origin at all, is taken when its Fetch Metadata Sec-Fetch-Site, which no page or form can set, says same-origin, and refused without it. A plan with a server fallback requires origins, since a post runs flags, the config's resources and its own as whoever the cookie says posted: an entry made without them throws. An entry of your own that calls SSR.handle checks Origin itself.

When a page is refused

A refusal never shows a user a half-working page. On the server it is an error before anything is served; in the browser the page is contained, as Foldkit contains a page from another build, and the reason is logged.

On the server, SSR.render fails with ResumeUnsafe, whose reason is one of:

| reason | What it means | The fix | | -------------------------- | ----------------------------------------------------------------------------- | ------------------------------------------------------------------- | | ViewDependsOnUnsentState | The view, or its head, renders differently from the browser's Model | Add the field the message names to state, or stop the view reading it | | UndeclaredStartup | init returned Commands and the plan has no boot | Name them in boot | | Uncovered | An active Surface reads or is activated by an unsent field, or a binding sends a Message no active Surface lists | Send the field, name it local, or list the Message on the Surface | | UnrestorablePart | A part cannot restore its own capture | A bug in that part | | DuplicateStaticRegion | Two SSR.static regions share an id | Give each its own id | | BindingInStaticRegion | A handler inside an SSR.static region | Move the element out, into a Surface | | UndeclaredSurfaces | The page has bindings and the plan no surfaces | Name the Surfaces the page may activate | | UnencodableBinding | The plan has no Message Schema, or a binding's Message does not encode through it | Make the plan from the application; build the Message with its constructor | | EagerStartRequired | A deferred plan would start a Subscription or Managed Resource late | Declare it in deferrable, or start 'now' | | VoidRootWithStyles | styles were given for a view whose root is void and holds nothing | Put the styles in the template's head with SSR.generate, or return an element root | | UngeneratablePath | SSR.generate was given a path no file can be served at | Leave out the query and fragment; keep paths to distinct files |

In the browser, SSR.hydrate checks in Foldkit's order: the build id first, then the envelope. SSR.resume reads the envelope off the stamped root and returns its refusal as ResumeRefused: a page with no stamped root or more than one, a root with no envelope (Missing, also for a second root as Duplicate), Unreadable JSON, another Protocol version or Plan, state, parts or bindings that are Invalid, or a Route other than the one the page was rendered for. A page is resumed whole or not at all.

A page the browser does not take over

A page can be declined before anything is adopted: when(document) says whether to take it over (a page rendered for a visitor, opened by an author), and fresh(plan.version) whether its data is still current. A declined or stale page is contained, as a refused one is, unless otherwise: 'render' runs the application afresh in its place.

Drawn afresh at once, the page would show the fresh application's first draw, usually its loading state, until its reads answer: the page blinks to "Loading" and back. until(model) keeps the served page in view meanwhile. The fresh application draws in a hidden element beside it, and when a Model it has drawn satisfies until, it takes the page's place in one step, with the served page's id. It never waits longer than UNTIL_LIMIT_MS (3 s). The CMS site (examples/cms/src/ssr/sitePlan.ts) declines a page opened by an author, and waits for the route's read and its Blocks' reads, which its Site.answered checks with RemoteData.answered and QueryBlock.active(...).answered:

SSR.hydrate(config, plan, { buildId, when, otherwise: 'render', until: Site.answered })

The served page stays as it was served while it waits: its links are plain links, and nothing it shows becomes live.

Costs and limits

  • SSR.render renders the view twice. That is the view check. A static region's render runs once.
  • SSR.hydrate relies on how Foldkit hydrates today. It gives Foldkit a config whose init returns the resumed Model, and removes the Flags key, which Foldkit would otherwise require a payload for. The tests pin both, and fail the day Foldkit changes. foldkit#1449 asks whether Foldkit would support this directly.
  • A resumable page leans on two Foldkit behaviours, each pinned by a test here and by Foldkit's own: the first patch removes attributes the browser's view does not assert, and a control's value is re-asserted to the Model's. It does not depend on Runtime.hydrate rendering its first frame before it returns, which Foldkit does today without promising it: until that frame commits (Render.afterCommit), the page keeps answering from its markers and sends the live page what only it can answer.
  • Development is not production. Under Vite's dev server, Foldkit's model preservation restores the previous Model after a reload and skips adoption.
  • A closure handler makes its event wait for boot. SSR.render's result lists each under unnamed ({ element: 'button#point', event: 'pointerdown' }), and on a page that waits to boot SSR.entry and SSR.generate warn about each once per process, naming the fix.

Lower-level API

SSR.render and SSR.hydrate are built from pieces usable on their own:

  • SSR.envelope(plan, model, { route? }) returns the envelope for a Model, as JSON, and SSR.resume(plan, document, { route? }) reads it back as a Result. SSR.render carries it on the stamped root, beside Foldkit's own stamps; the JSON escapes < as Foldkit escapes its Flags, and U+2028 and U+2029, and the attribute escapes & and " besides, so no string in the Model can break out of the value. Hydration drops the attribute on its first patch, so the browser reads it before it boots.
  • SSR.inspect(plan, model): the plan's coverage as data.
  • Resume.bindings(plan, document, root, model) decodes and checks a page's bindings, and Resume.listen(root, { bindings, onAnswer, events? }) answers events from the markers, one { event, messages, unnamed? } per event. events is the envelope's list of every event the markers name; without it listen reads every element's attributes to find them. SSR.hydrate uses both, passing the list; they are there for a custom boot.
  • Attribute names, for tools and tests: RESUME_ATTRIBUTE (the envelope on the stamped root), STATIC_ATTRIBUTE, BINDING_ATTRIBUTE (a prefix, followed by the event), SLOT_ATTRIBUTE (a placement's root while the server renders) and FALLBACK_FIELD (the posted Message).

The application in these examples

A post page. App is Surface.application({ Model, Message, initial, update }). The Surfaces (PostActions, Menu, Like), Data (a Remote.make domain over App.model.remote) and LoadPreferences (a Command.define) are the application's own. PostActionsAt and PostPageAt are Surfaces placed for a plan, as Surface.when(PostActions, App.model.route, AppRoute.Post, route => ({ id: route.id })) places one in the coverage example.

const AppRoute = defineRouteUnion({ Home: {}, Post: { id: Schema.String } })

const Model = Schema.Struct({
  route: AppRoute,
  post: Schema.Struct({
    id: Schema.String,
    title: Schema.String,
    body: Schema.String,
    liked: Schema.Boolean,
  }),
  draft: Schema.String,
  menuOpen: Schema.Boolean,
  remote: Remote.Model,
})

const Modifiers = Schema.Struct({
  shiftKey: Schema.Boolean,
  ctrlKey: Schema.Boolean,
  altKey: Schema.Boolean,
  metaKey: Schema.Boolean,
})

const Message = defineMessageUnion({
  ...Remote.messages,
  Liked: { id: Schema.String },
  ChangedDraft: { value: Schema.String },
  Renamed: { id: Schema.String, title: Schema.String },
  Pressed: { key: Schema.String, modifiers: Modifiers },
})

See also