SSR / foldkit-ssr

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:

ts
// 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),
})
ts
// 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)
ts
// 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:

ts
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:

ts
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.

ts
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:

ts
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.