SSR / foldkit-ssr

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:

ts
const Todos = SSR.plan(App, {
  id: 'todos',
  state: Projection.pick(App.model.draft, App.model.todos),
  surfaces: [Surface.at(TodoList, undefined)],
  fallback: 'server',
})
ts
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 is taken when its Sec-Fetch-Site, which no page or form can set, says same-origin. 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.