Mirror / foldkit-mirror

Keep what a server renders in a cookie with Mirror.cookie

A key-value store lives in the browser, so a server rendering the page cannot read it, and a page that works without JavaScript has no store at all. A cookie travels with every request: the server reads it for the first render, a form posted without scripts can set it in the answer, and the scripted page writes it as the Model changes. This extends the Model with cart, a list of names:

ts
const Cart = Mirror.cookie(App, {
  name: 'cart',
  fields: [App.model.cart],
})

The cookie is named cart and holds the mirror's keys form-encoded (cart=cart=%5B%22acme.dev%22%5D), so no character of the slice can end the value or the header. Its three seams:

ts
// On the server, before the first render: the request's cookie into the Model.
const opened = Mirror.bootstrap(initial, Cart.bootstrap(Cart.keysOf(request.headers.get('cookie'))))

// In a server's answer that changed the slice: `reached` is the Model a form
// posted without scripts reached through `update`.
const setCookie: Option.Option<string> = Cart.setCookie(reached, request)

In the browser, Cart.wiring() (or Cart.subscriptions) writes document.cookie whenever the encoded slice changes, at once by default. It reads nothing at startup: the server's render already holds the slice, and a page with no server reads it with Cart.bootstrap(Cart.keysOf(document.cookie)) in its init.

What each call does, and does not do:

  • keysOf(header) picks the mirror's cookie out of a Cookie header and its own keys out of that cookie. A cookie whose name=value is past maxBytes (4096 by default) is read as holding nothing. It decodes nothing.
  • bootstrap(keys) decodes each key through its codec, the field's Schema: a key that does not decode leaves its field at the initial value, and Cart.decode(keys).issues names it.
  • setCookie(model, request) is the Set-Cookie value for the slice of model, or one that clears the cookie when the slice is at its defaults. It sets nothing itself; the answer's headers carry it. It is None when the slice encodes past maxBytes, and the browser keeps what it held; a browser write past it is skipped and reported as CookieTooLarge.

A cookie is the visitor's to edit. Decoding proves its shape, not its truth: a cart that names a domain is not a price for it, so the server checks again what it would not take from a form (domains-plus prices every name in its cart cookie again). A cookie may keep less than its field: domains-plus's cart codec (keys: { cart: { codec } }) writes the cart's names alone and refuses to decode them, since a name holds no price, and its server reads the names from keysOf and prices them. The browser writes the cookie, so it is never HttpOnly; keep nothing secret in it.

The attributes default to Path=/, SameSite=Lax, a Max-Age of 365 days, and Secure on an https page or for an https request; attributes changes any of them, and a name, path or domain a header could not carry is refused when the mirror is declared. Behind a proxy that terminates TLS (nginx, Fly, Render) the server sees an http request, so secure: 'auto' leaves Secure off every cookie the server sets: set secure: true in production there, as here:

ts
const Theme = Mirror.cookie(App, {
  name: 'theme',
  fields: [App.model.theme],
  attributes: { sameSite: 'Strict', maxAge: '30 days', secure: true },
})