UNPKG

alepha

Version:

Easy-to-use modern TypeScript framework for building many kind of applications.

149 lines (139 loc) 6.06 kB
import { $hook, $inject, Alepha, type Atom, type State } from "alepha"; import type { DurationLike } from "alepha/datetime"; import { $logger } from "alepha/logger"; import type { Cookies } from "../primitives/$cookie.ts"; import { ServerCookiesProvider } from "./ServerCookiesProvider.ts"; /** * Binds every atom declared with `persist: "cookie"` to an HTTP cookie. * * - `server:onRequest` — seeds the request-scoped state from the cookie, so * SSR renders with the persisted value. * - `state:mutate` — writes the new value back as a Set-Cookie header. * - `state:register` — an atom registering lazily *during* a request (first * touched by an SSR render, after `server:onRequest` already ran) reads * its cookie right away, so that render still sees the persisted value. * * Atoms are resolved from the `StateManager` registry * (`alepha.store.listAtoms()`) on every request and every mutation, never * from a map built up from `state:register` events. That event fires exactly * once per atom and is never replayed, while `$module.register()` registers * `atoms[]` BEFORE it wires `imports[]` and injects `services[]` — so the * documented `$module({ atoms, imports: [AlephaServerCookies], services })` * shape registers every atom before this provider exists. An event-sourced * map would stay empty forever there, making `persist: "cookie"` a silent * no-op in both directions. Reading the registry on demand is * order-independent. * * The cookie is named after the atom key, lives 365 days, SameSite=lax, * path "/". For custom cookie options (encryption, signing, custom TTL), * declare an explicit `$cookie({ key: atom.key, ... })` binding instead. */ export class AtomCookiePersistence { protected readonly alepha = $inject(Alepha); protected readonly log = $logger(); protected readonly serverCookies = $inject(ServerCookiesProvider); protected readonly onRequest = $hook({ on: "server:onRequest", // Explicit dependency instead of relying on `services` array order in // `AlephaServerCookies`: this hook needs `request.cookies`, which is // only populated by `ServerCookiesProvider.onRequest`. If that ever ran // second, `getCookiesFromContext` would throw, get caught, and log at // debug — cookies silently ignored in production with no visible error. after: [this.serverCookies], handler: ({ request }) => { // `alepha.http.request` is not set in the store yet at this point in // the request lifecycle (`ServerRouterProvider` sets it only after // `server:onRequest` resolves), so the request-scoped cookie jar must // be passed explicitly rather than resolved from context. for (const atom of this.cookieAtoms()) { this.read(atom, request.cookies); } }, }); protected readonly onRegister = $hook({ on: "state:register", handler: ({ atom }) => { if (atom.options.persist !== "cookie") { return; } // Only the lazy, mid-request case is handled here — every atom already // registered when the request started was swept by `onRequest` above. if (this.alepha.store.get("alepha.http.request")) { this.read(atom); } }, }); protected readonly onMutate = $hook({ on: "state:mutate", handler: ({ key, value }) => { const atom = this.alepha.store.getAtom(String(key)); if (!atom || atom.options.persist !== "cookie") { return; } try { this.serverCookies.setCookie(atom.key, this.cookieOptions(atom), value); } catch (error) { // Outside a request cycle there is no response to attach the // Set-Cookie header to; browser-side writes are handled by the // browser variant of this provider. this.log.debug(`Cannot persist atom "${atom.key}" to cookie`, { error, }); } }, }); /** * Every registered atom declared with `persist: "cookie"`, read live from * the state manager's registry. */ protected cookieAtoms(): Array<Atom<any, any>> { return this.alepha.store .listAtoms() .filter((atom) => atom.options.persist === "cookie"); } protected read(atom: Atom<any, any>, cookies?: Cookies): void { // Safety net, not a live bug today: an ALS provider is always installed // by `core/index.ts` / `core/index.workerd.ts`, so every request already // runs inside its own fork. But `store.set(..., { skipEvents: true })` // below has no `skipContext`, so if no fork were active, // `StateManager.set()` would fall through to writing the APP-LEVEL // store — turning one user's cookie value into what every subsequent // cookieless request reads. Degrade to "cookie ignored" instead. if (!this.alepha.context.exists()) { this.log.debug( `Skipping cookie read for atom "${atom.key}": no active request context.`, ); return; } try { const value = this.serverCookies.getCookie( atom.key, this.cookieOptions(atom), cookies, ); if (value !== undefined) { this.alepha.store.set(atom.key as keyof State, value, { skipEvents: true, }); } } catch (error) { this.log.debug(`Cannot read cookie for atom "${atom.key}"`, { error }); } } protected cookieOptions(atom: Atom<any, any>) { return { schema: atom.schema, path: "/", sameSite: "lax" as const, ttl: [365, "days"] as DurationLike, // The browser variant of this adapter cannot see APP_NAME (not baked // into the client bundle, not part of SSR hydration — see // AtomCookiePersistence.browser.ts), so it always reads/writes the // bare atom key. Skip the APP_NAME prefix here too — specifically for // atom-persisted cookies — so both sides agree on the same cookie // name. Every other server cookie ($cookie primitive) still gets the // namespace; see ServerCookiesProvider.prefixName. prefix: false, }; } }