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:
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:
// 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 aCookieheader and its own keys out of that cookie. A cookie whosename=valueis pastmaxBytes(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, andCart.decode(keys).issuesnames it.setCookie(model, request)is theSet-Cookievalue for the slice ofmodel, or one that clears the cookie when the slice is at its defaults. It sets nothing itself; the answer's headers carry it. It isNonewhen the slice encodes pastmaxBytes, and the browser keeps what it held; a browser write past it is skipped and reported asCookieTooLarge.
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:
const Theme = Mirror.cookie(App, {
name: 'theme',
fields: [App.model.theme],
attributes: { sameSite: 'Strict', maxAge: '30 days', secure: true },
})