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:
// 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.planis 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.renderis an Effect. It runsinitonce, renders the view twice, checks the plan, and returns the rendered application and the envelope it carries on the stamped root. It fails withResumeUnsafewhen the browser could not start where the server did; the refusals say why and how to fix each.SSR.pageis pure: the rendered application in the template, through Foldkit'sinjectIntoTemplate. The envelope rides on the rendered root, so the template needs no place for it. An optionalheadadds markup before</head>, given what was rendered.SSR.hydratereads the envelope, starts Foldkit's runtime from the resumed Model, and runs the plan'sbootCommands.initdoes 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.