Files
WRNexusJS/packages/core/src/storage.ts
T
2026-07-12 15:55:18 +05:30

390 lines
12 KiB
TypeScript

import type { Context, Middleware } from "./context.ts";
export interface CookieOptions {
path?: string;
domain?: string;
maxAge?: number;
expires?: Date | string;
httpOnly?: boolean;
secure?: boolean;
sameSite?: "Strict" | "Lax" | "None" | "strict" | "lax" | "none";
}
export interface CookieStore {
get(name: string): string | undefined;
getAll(): Record<string, string>;
has(name: string): boolean;
set(name: string, value: string, options?: CookieOptions): void;
delete(name: string, options?: CookieOptions): void;
headers(): string[];
}
export interface SessionStore {
id(): string;
get<T = unknown>(key: string): T | undefined;
getAll(): Record<string, unknown>;
set(key: string, value: unknown): void;
delete(key: string): void;
/** Issue a fresh session id, keeping the data — defends against fixation. */
regenerate(): void;
clear(): void;
}
export interface LocalStorageSnapshot {
get(key: string): string | undefined;
getAll(): Record<string, string>;
has(key: string): boolean;
}
const SESSION_COOKIE = "wrnexus.sid";
/** Idle timeout: a session expires this long after its last access. */
const SESSION_TTL_MS = 1000 * 60 * 60 * 24; // 24 hours
/** Run a background sweep after this many new sessions (bounds memory). */
const SESSION_GC_EVERY = 500;
/** A stored session: its data plus an absolute expiry timestamp (ms). */
export interface SessionEntry {
data: Record<string, unknown>;
expiresAt: number;
}
/**
* Pluggable session persistence. The default is process-local memory; swap in a
* shared backend (Redis, SQL, etc.) via `setSessionBackend` so sessions survive
* restarts and work across multiple instances. Methods are synchronous, so a
* backend must be sync (e.g. `bun:sqlite`); async stores need a load/save
* wrapper around the request (future work).
*/
export interface SessionBackend {
get(id: string): SessionEntry | undefined;
set(id: string, entry: SessionEntry): void;
delete(id: string): void;
/** Optional: drop expired entries. Called periodically by the store. */
gc?(now: number): void;
}
function createMemorySessionBackend(): SessionBackend {
const map = new Map<string, SessionEntry>();
return {
get: (id) => map.get(id),
set: (id, entry) => void map.set(id, entry),
delete: (id) => void map.delete(id),
gc: (now) => {
for (const [key, entry] of map) if (entry.expiresAt <= now) map.delete(key);
},
};
}
let sessionBackend: SessionBackend = createMemorySessionBackend();
let sessionsSinceGc = 0;
/** Replace the session persistence backend (call once at startup). */
export function setSessionBackend(backend: SessionBackend): void {
sessionBackend = backend;
}
/**
* An ASYNC session store (Redis, a remote DB). Use it via the `loadSession`
* middleware, which loads the session before the request and saves it after —
* keeping the `ctx.session` API synchronous while persistence is shared across
* instances.
*/
export interface AsyncSessionBackend {
load(id: string): Promise<SessionEntry | undefined>;
save(id: string, entry: SessionEntry): Promise<void>;
destroy(id: string): Promise<void>;
}
/**
* Back `ctx.session` with an async store. Register early (before anything reads
* `ctx.session`). Loads once at the start of the request and saves once at the
* end; regenerate/clear destroy the old id.
*/
export function loadSession(
backend: AsyncSessionBackend,
options: { ttlMs?: number } = {},
): Middleware {
const ttlMs = options.ttlMs ?? SESSION_TTL_MS;
return async (ctx: Context, next) => {
let id = ctx.cookies.get(SESSION_COOKIE);
let entry = id ? await backend.load(id) : undefined;
if (id && entry && entry.expiresAt <= Date.now()) {
await backend.destroy(id);
entry = undefined;
id = undefined;
} else if (id && !entry) {
id = undefined; // unknown/expired id → anonymous
}
const destroys = new Set<string>();
const ensure = (): Record<string, unknown> => {
if (!id) {
id = randomId();
ctx.cookies.set(SESSION_COOKIE, id, sessionCookieOptions(ctx.url.protocol === "https:"));
}
if (!entry) entry = { data: {}, expiresAt: Date.now() + ttlMs };
return entry.data;
};
ctx.session = {
id() {
ensure();
return id!;
},
get<T = unknown>(key: string): T | undefined {
return (entry?.data[key] as T | undefined) ?? undefined;
},
getAll() {
return entry ? { ...entry.data } : {};
},
set(key, value) {
ensure()[key] = value;
},
delete(key) {
if (entry) delete entry.data[key];
},
regenerate() {
const data = entry?.data ?? {};
if (id) destroys.add(id);
id = randomId();
entry = { data, expiresAt: Date.now() + ttlMs };
ctx.cookies.set(SESSION_COOKIE, id, sessionCookieOptions(ctx.url.protocol === "https:"));
},
clear() {
if (id) destroys.add(id);
entry = undefined;
id = undefined;
ctx.cookies.delete(SESSION_COOKIE, sessionCookieOptions(ctx.url.protocol === "https:"));
},
};
try {
return await next();
} finally {
for (const gone of destroys) if (gone !== id) await backend.destroy(gone);
if (id && entry) {
entry.expiresAt = Date.now() + ttlMs;
await backend.save(id, entry);
}
}
};
}
const COOKIE_NAME = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
/** Read a live (non-expired) session entry, sliding its expiry forward. */
function readSessionEntry(id: string): SessionEntry | undefined {
const entry = sessionBackend.get(id);
if (!entry) return undefined;
if (entry.expiresAt <= Date.now()) {
sessionBackend.delete(id);
return undefined;
}
entry.expiresAt = Date.now() + SESSION_TTL_MS; // sliding idle expiry
sessionBackend.set(id, entry); // persist the slide (matters for external backends)
return entry;
}
export function createCookieStore(req: Request): CookieStore {
const incoming = parseCookieHeader(req.headers.get("cookie") ?? "");
const outgoing: string[] = [];
return {
get(name) {
return incoming[name];
},
getAll() {
return { ...incoming };
},
has(name) {
return Object.prototype.hasOwnProperty.call(incoming, name);
},
set(name, value, options) {
incoming[name] = value;
outgoing.push(serializeCookie(name, value, { path: "/", ...options }));
},
delete(name, options) {
delete incoming[name];
outgoing.push(
serializeCookie(name, "", {
path: "/",
...options,
expires: new Date(0),
maxAge: 0,
}),
);
},
headers() {
return [...outgoing];
},
};
}
export function createSessionStore(
cookies: CookieStore,
req: Request,
cookieName = SESSION_COOKIE,
secure = new URL(req.url).protocol === "https:",
): SessionStore {
let id = cookies.get(cookieName);
let entry = id ? readSessionEntry(id) : undefined;
if (id && !entry) id = undefined; // expired or unknown → treat as anonymous
const persist = (): void => {
if (id && entry) sessionBackend.set(id, entry);
};
const ensure = (): Record<string, unknown> => {
if (!id) {
id = randomId();
cookies.set(cookieName, id, sessionCookieOptions(secure));
}
entry = readSessionEntry(id);
if (!entry) {
if (++sessionsSinceGc >= SESSION_GC_EVERY) {
sessionsSinceGc = 0;
sessionBackend.gc?.(Date.now());
}
entry = { data: {}, expiresAt: Date.now() + SESSION_TTL_MS };
sessionBackend.set(id, entry);
}
return entry.data;
};
return {
id() {
ensure();
return id!;
},
get<T = unknown>(key: string): T | undefined {
return (entry?.data[key] as T | undefined) ?? undefined;
},
getAll() {
return entry ? { ...entry.data } : {};
},
set(key, value) {
ensure()[key] = value;
persist();
},
delete(key) {
if (entry) {
delete entry.data[key];
persist();
}
},
regenerate() {
// Session fixation defense: move existing data under a brand-new id and
// reissue the cookie, so any pre-login id an attacker planted is void.
const data = entry?.data ?? {};
if (id) sessionBackend.delete(id);
id = randomId();
entry = { data, expiresAt: Date.now() + SESSION_TTL_MS };
sessionBackend.set(id, entry);
cookies.set(cookieName, id, sessionCookieOptions(secure));
},
clear() {
if (id) sessionBackend.delete(id);
entry = undefined;
id = undefined;
cookies.delete(cookieName, sessionCookieOptions(secure));
},
};
}
export function createLocalStorageSnapshot(req: Request): LocalStorageSnapshot {
const values = parseLocalStorageHeader(req.headers.get("x-wrnexus-local-storage"));
return {
get(key) {
return values[key];
},
getAll() {
return { ...values };
},
has(key) {
return Object.prototype.hasOwnProperty.call(values, key);
},
};
}
export function applyCookieHeaders(ctx: { cookies?: CookieStore }, headers: Headers): void {
for (const value of ctx.cookies?.headers() ?? []) {
headers.append("Set-Cookie", value);
}
}
function parseCookieHeader(header: string): Record<string, string> {
const out: Record<string, string> = {};
for (const part of header.split(";")) {
const index = part.indexOf("=");
if (index < 0) continue;
const name = part.slice(0, index).trim();
if (!name) continue;
out[name] = safeDecode(part.slice(index + 1).trim());
}
return out;
}
function serializeCookie(name: string, value: string, options: CookieOptions): string {
if (!COOKIE_NAME.test(name)) throw new Error(`Invalid cookie name: ${name}`);
const parts = [`${name}=${encodeURIComponent(value)}`];
if (options.maxAge !== undefined) parts.push(`Max-Age=${Math.floor(options.maxAge)}`);
if (options.domain) parts.push(`Domain=${options.domain}`);
if (options.path) parts.push(`Path=${options.path}`);
if (options.expires) {
const expires = options.expires instanceof Date ? options.expires : new Date(options.expires);
parts.push(`Expires=${expires.toUTCString()}`);
}
if (options.httpOnly) parts.push("HttpOnly");
if (options.secure) parts.push("Secure");
if (options.sameSite) parts.push(`SameSite=${normalizeSameSite(options.sameSite)}`);
return parts.join("; ");
}
function sessionCookieOptions(secure: boolean): CookieOptions {
return {
httpOnly: true,
path: "/",
sameSite: "Lax",
secure,
};
}
function parseLocalStorageHeader(header: string | null): Record<string, string> {
if (!header) return {};
try {
const parsed = JSON.parse(decodeURIComponent(header)) as unknown;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(parsed)) {
if (typeof value === "string") out[key] = value;
}
return out;
} catch {
return {};
}
}
function normalizeSameSite(value: NonNullable<CookieOptions["sameSite"]>): string {
const lower = value.toLowerCase();
return lower === "strict" ? "Strict" : lower === "none" ? "None" : "Lax";
}
function safeDecode(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
/** A 256-bit cryptographically-random session id (no weak fallback). */
function randomId(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
let out = "";
for (const b of bytes) out += b.toString(16).padStart(2, "0");
return out;
}