/** * @remarks * Sessions store cookies, default headers, and profile preferences across multiple requests. They * are persisted in memory by default and optionally saved to SQLite when `saveSession` is set. * @file Lightweight session management for static HTTP requests. */ import { openStorageDb } from "../storage/db/open.ts"; import type { ResolveStorageOptions } from "../storage/paths.ts"; export interface FetchSession { id: string; createdAt: string; lastUsedAt: string; cookies: SerializedCookie[]; defaultHeaders?: Record; defaultBrowserProfile?: string; defaultOsProfile?: string; defaultProxy?: string; defaultMode?: string; } interface SerializedCookie { name: string; value: string; domain?: string; hostOnly?: boolean; path?: string; expires?: string; httpOnly?: boolean; secure?: boolean; sameSite?: string; } const memorySessions = new Map>(); interface SessionPoolOptions { maxPoolSize?: number; maxIdleMs?: number; } let sessionPoolOptions: SessionPoolOptions = {}; /** Configure in-memory session pool limits. */ export function configureSessionPool(options: SessionPoolOptions): void { sessionPoolOptions = options; } /** Get an existing session from memory, load from SQLite, or create a new empty one. */ export async function getOrCreateSession( id: string, options: ResolveStorageOptions = {}, ): Promise { let promise = memorySessions.get(id); if (!promise) { promise = loadOrCreateSession(id, options); memorySessions.set(id, promise); promise.catch(() => memorySessions.delete(id)); } return await promise; } async function loadOrCreateSession( id: string, options: ResolveStorageOptions, ): Promise { let session = await loadPersistedSession(id, options); if (!session) { session = { id, createdAt: new Date().toISOString(), lastUsedAt: new Date().toISOString(), cookies: [], }; const maxSize = sessionPoolOptions.maxPoolSize ?? 100; if (memorySessions.size >= maxSize) { evictOldestSession(); } } session.lastUsedAt = new Date().toISOString(); return session; } function evictOldestSession(): void { const firstKey = memorySessions.keys().next().value; if (firstKey) { memorySessions.delete(firstKey); } } /** Persist a memory session to SQLite. */ export async function saveSessionToStorage( id: string, options: ResolveStorageOptions = {}, ): Promise { const promise = memorySessions.get(id); if (!promise) return; const session = await promise; await persistSession(session, options); } /** Delete a session from memory and from SQLite. */ export async function deleteSessionAndStorage( id: string, options: ResolveStorageOptions = {}, ): Promise { memorySessions.delete(id); try { const db = await openStorageDb(options); db.prepare("DELETE FROM http_sessions WHERE id = ?").run(id); } catch { /* ignore storage errors on delete */ } } /** Delete a session from memory only. */ export function deleteSession(id: string): void { memorySessions.delete(id); } /** List active session IDs in memory. */ export function listSessions(): string[] { return [...memorySessions.keys()]; } function hostDomainMatches(host: string, cookieDomain: string): boolean { const h = host.toLowerCase(); const d = cookieDomain.toLowerCase(); return h === d || h.endsWith("." + d); } /** RFC 6265 §5.1.4 default-path: directory of the request URI path. */ function defaultCookiePath(uriPath: string): string { if (uriPath[0] !== "/") return "/"; const lastSlash = uriPath.lastIndexOf("/"); if (lastSlash === 0) return "/"; return uriPath.slice(0, lastSlash); } /** * Parse a raw Set-Cookie header into a serialized cookie. Rejects cross-origin Domain attributes * per RFC 6265 §5.3 step 6; defaults missing Path per §5.1.4. */ export function parseSetCookie( setCookie: string, host: string, requestPath: string, ): SerializedCookie | undefined { const parts = setCookie.split(";").map((p) => p.trim()); const [nameValue] = parts; const eq = nameValue.indexOf("="); const name = eq >= 0 ? nameValue.slice(0, eq).trim() : nameValue; const value = eq >= 0 ? nameValue.slice(eq + 1).trim() : ""; const cookie: SerializedCookie = { name, value }; for (const part of parts.slice(1)) { const [key, val] = part.split("=").map((s) => s.trim()); const lower = key.toLowerCase(); if (lower === "domain") cookie.domain = val.toLowerCase().replace(/^\./u, ""); // RFC 6265 §5.2.4: take the last Path attribute; an invalid (empty or non-"/") value // resets cookie.path so the default-path fallback below applies. if (lower === "path") cookie.path = val.startsWith("/") ? val : undefined; if (lower === "expires") cookie.expires = val; if (lower === "httponly") cookie.httpOnly = true; if (lower === "secure") cookie.secure = true; if (lower === "samesite") cookie.sameSite = val; } if (cookie.domain) { if (!hostDomainMatches(host, cookie.domain)) return undefined; cookie.hostOnly = false; } else { cookie.domain = host.toLowerCase(); cookie.hostOnly = true; } cookie.path ??= defaultCookiePath(requestPath); return cookie; } /** Build a Cookie header string from stored cookies matching the target host, path, and scheme. */ export function buildCookieHeader( session: FetchSession, host: string, path: string, scheme: "http" | "https", ): string { const now = new Date(); const normalizedHost = host.toLowerCase(); const matching = session.cookies.filter((c) => { if (c.expires) { const expiry = new Date(c.expires); if (expiry <= now) return false; } // Domain match (RFC 6265 §5.1.3) if (c.domain) { if (c.hostOnly) { if (normalizedHost !== c.domain.toLowerCase()) return false; } else if (!hostDomainMatches(normalizedHost, c.domain)) { return false; } } // Path match (RFC 6265 §5.1.4) if (c.path) { const cp = c.path; const exact = path === cp; const prefix = path.startsWith(cp); const boundary = cp.endsWith("/") || (prefix && path.length > cp.length && path[cp.length] === "/"); if (!exact && !boundary) return false; } // Secure (RFC 6265 §5.4 step 2) if (c.secure && scheme !== "https") return false; return true; }); if (matching.length === 0) return ""; return matching.map((c) => `${c.name}=${c.value}`).join("; "); } /** Merge session cookies (as outgoing Cookie header) with request headers. */ export function mergeSessionHeaders( session: FetchSession | undefined, host: string, path: string, scheme: "http" | "https", headers: Record | undefined, ): Record { if (!session) return headers ?? {}; const merged = { ...session.defaultHeaders, ...headers }; const cookieHeader = buildCookieHeader(session, host, path, scheme); if (cookieHeader) { merged["cookie"] = cookieHeader; } return merged; } /** Update session with Set-Cookie headers from a response. */ export function updateSessionCookies( session: FetchSession, setCookieHeaders: string[], host: string, requestPath: string, ): void { for (const header of setCookieHeaders) { const cookie = parseSetCookie(header, host, requestPath); // Rejected by origin validation if (!cookie) continue; // Remove old cookie with same name/domain/hostOnly/path session.cookies = session.cookies.filter( (c) => !( c.name === cookie.name && c.domain === cookie.domain && c.hostOnly === cookie.hostOnly && c.path === cookie.path ), ); session.cookies.push(cookie); } } /** Persist session metadata to SQLite. */ export async function persistSession( session: FetchSession, options: ResolveStorageOptions = {}, ): Promise { const db = await openStorageDb(options); db.db.exec(` CREATE TABLE IF NOT EXISTS http_sessions ( id TEXT PRIMARY KEY, created_at TEXT NOT NULL, last_used_at TEXT NOT NULL, cookies_json TEXT NOT NULL, default_headers_json TEXT, default_browser_profile TEXT, default_os_profile TEXT, default_proxy TEXT, default_mode TEXT ); `); const stmt = db.prepare(` INSERT OR REPLACE INTO http_sessions (id, created_at, last_used_at, cookies_json, default_headers_json, default_browser_profile, default_os_profile, default_proxy, default_mode) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `); stmt.run( session.id, session.createdAt, session.lastUsedAt, JSON.stringify(session.cookies), JSON.stringify(session.defaultHeaders ?? {}), session.defaultBrowserProfile ?? null, session.defaultOsProfile ?? null, session.defaultProxy ?? null, session.defaultMode ?? null, ); } /** Load session metadata from SQLite into memory if not already present. */ function normalizeLoadedCookies(cookies: SerializedCookie[]): SerializedCookie[] { return cookies.filter((c) => { if (c.hostOnly === undefined) { // legacy host-only with no recoverable host — drop if (!c.domain) return false; c.hostOnly = false; } return true; }); } export async function loadPersistedSession( id: string, options: ResolveStorageOptions = {}, ): Promise { const db = await openStorageDb(options); const row = db.prepare(`SELECT * FROM http_sessions WHERE id = ?`).get(id) as | { id: string; created_at: string; last_used_at: string; cookies_json: string; default_headers_json: string; default_browser_profile: string; default_os_profile: string; default_proxy: string; default_mode: string; } | undefined; if (!row) return; const session: FetchSession = { id: row.id, createdAt: row.created_at, lastUsedAt: row.last_used_at, cookies: normalizeLoadedCookies(JSON.parse(row.cookies_json) as SerializedCookie[]), }; if (row.default_headers_json) { try { session.defaultHeaders = JSON.parse(row.default_headers_json); } catch { /* ignore */ } } if (row.default_browser_profile) session.defaultBrowserProfile = row.default_browser_profile; if (row.default_os_profile) session.defaultOsProfile = row.default_os_profile; if (row.default_proxy) session.defaultProxy = row.default_proxy; if (row.default_mode) session.defaultMode = row.default_mode; return session; } export interface SessionNoticeParams { sessionId?: string; saveSession?: boolean; clearSession?: boolean; } /** Build a compact session status for tool renderers. */ export function buildSessionNotice(params: SessionNoticeParams): string { if (!params.sessionId) return ""; if (params.saveSession) return `● session "${params.sessionId}" created & persisted`; if (params.clearSession) return `● session "${params.sessionId}" cleared`; return `● session "${params.sessionId}" active`; } /** Build a human-readable session status line for fallback tool result text. */ export function buildSessionText(params: SessionNoticeParams): string { const notice = buildSessionNotice(params); return notice ? `\n\n---\n${notice}` : ""; }