alepha
Version:
Easy-to-use modern TypeScript framework for building many kind of applications.
153 lines (141 loc) • 6.34 kB
text/typescript
import type { z as zod } from "zod";
import { KIND } from "../constants/KIND.ts";
import { AlephaError } from "../errors/AlephaError.ts";
import type { Static, TObject } from "../providers/TypeProvider.ts";
import type { ZType } from "../providers/ZodProvider.ts";
/**
* Define an atom for state management.
*
* Atom lets you define a piece of state with a name, schema, and default value.
*
* By default, Alepha state is just a simple key-value store.
* Using atoms allows you to have type safety, validation, and default values for your state.
*
* You control how state is structured and validated.
*
* Features:
* - Schema validation on every write (invalid writes throw)
* - Default value for initial state
* - Automatic getter access in services with {@link $state}
* - SSR support (server state automatically serialized and hydrated on client)
* - React integration (useStore / useSelector hooks for automatic re-renders)
* - Derived values with {@link $computed} (useComputed hook)
* - Persistence adapters: cookie (SSR-safe), localStorage, sessionStorage
* - `serverOnly` flag to keep an atom out of the hydration payload
* - reset / watch helpers on the state manager
* - Documentation generation & devtools integration (mutation log)
*
* Common use cases:
* - user preferences
* - feature flags
* - configuration options
* - session data
*
* Atom must contain only serializable data.
* Avoid storing complex objects like class instances, functions, or DOM elements.
* If you need to store complex data, consider using identifiers or references instead.
*/
export const $atom = <T extends ZType, N extends string>(
options: AtomOptions<T, N>,
): Atom<T, N> => {
if (options.serverOnly && options.persist) {
throw new AlephaError(
`Atom "${options.name}" declares both serverOnly and persist: "${options.persist}", ` +
"which is a contradiction: serverOnly only guarantees exclusion from the SSR " +
"hydration payload, while every persist adapter (cookie, localStorage, " +
"sessionStorage) targets the browser by definition — cookie persistence ships " +
"the value in a Set-Cookie response header, and web-storage persistence IS the " +
"browser. Combining them silently leaks the value through the persistence " +
"channel instead of the hydration payload. Remove `serverOnly` or `persist` from " +
`atom "${options.name}".`,
);
}
return new Atom<T, N>(options);
};
/**
* Persistence adapter for an atom.
*/
export type AtomPersist = "cookie" | "localStorage" | "sessionStorage";
export type AtomOptions<T extends ZType, N extends string> = {
name: N;
schema: T;
description?: string;
/**
* Persist this atom outside the in-memory store.
*
* - `"cookie"` — synced to an HTTP cookie by the `alepha/server/cookies`
* module. Works on the server AND in the browser, so it is the only
* correct adapter for SSR apps: the server reads it while rendering and
* the first paint matches the persisted state.
* - `"localStorage"` / `"sessionStorage"` — browser Web Storage. Fine for
* pure SPA apps. The server cannot see these values; registering such
* an atom during SSR logs a warning.
*
* Values are validated against the schema when read back; invalid or
* corrupted entries are discarded and the default is used.
*
* **Security: cookie-persisted atoms are attacker-controlled.** The
* cookie written by `persist: "cookie"` is unsigned, unencrypted, and
* freely writable by any client-side script (`AtomCookiePersistence`'s
* `cookieOptions()` sets no `sign`, `encrypt`, or `httpOnly`) — a request
* can present any value it wants for it. Never persist trust-bearing
* state — user ids, roles, entitlements, permissions — in a
* `persist: "cookie"` atom. If you need a signed, encrypted, and/or
* `httpOnly` cookie, declare an explicit
* `$cookie({ key: atom.key, sign: true, encrypt: true, httpOnly: true, ... })`
* binding instead — that is the escape hatch for trust-bearing values.
*/
persist?: AtomPersist;
/**
* Exclude this atom from the SSR hydration export
* (`StateManager.exportAtoms()`).
*
* This is the ONLY guarantee `serverOnly` makes: the value is never
* written into the `<script id="__ssr">` JSON block serialized into the
* HTML payload. It says nothing about any other channel a value can
* reach the browser through.
*
* **Cannot be combined with `persist`.** Every persistence adapter
* (`"cookie"`, `"localStorage"`, `"sessionStorage"`) targets the browser
* by definition — cookie persistence ships the value in a `Set-Cookie`
* response header, and web-storage persistence IS the browser. Declaring
* `serverOnly: true` together with any `persist` value throws
* `AlephaError` at `$atom()` call time, because the persistence channel
* would leak the exact value `serverOnly` is meant to keep server-side.
*
* Use `serverOnly` for atoms that may hold secrets or internal
* request-scoped state that must never leave the server, and never pair
* it with `persist`.
*
* **Never make a `serverOnly` atom a dependency of a `$computed` that the
* browser reads through `useComputed`.** A computed is re-derived, never
* serialized: the server derives it from the atom's real value, and the
* browser — which never received that value — re-derives it from the
* atom's default. The two disagree, producing a React hydration mismatch
* and a silently wrong value from then on. Keep such a computed
* server-side (`alepha.store.get`), or derive it from atoms that ship.
*/
serverOnly?: boolean;
} & (T extends zod.ZodOptional<any>
? {
default?: Static<T>;
}
: {
default: Static<T>;
});
export class Atom<T extends ZType = TObject, N extends string = string> {
public readonly options: AtomOptions<T, N>;
public get schema(): T {
return this.options.schema;
}
public get key(): N {
return this.options.name;
}
constructor(options: AtomOptions<T, N>) {
this.options = options;
}
}
$atom[KIND] = "atom";
export type TAtomObject = ZType;
export type AtomStatic<T extends ZType> =
T extends zod.ZodOptional<any> ? Static<T> | undefined : Static<T>;