alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
149 lines (139 loc) • 6.06 kB
text/typescript
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,
};
}
}