/** * Small RFC 6265-ish cookie jar used within a single fetch (across redirect hops) and, for sticky * sessions, persisted between requests. No dependency; ignores SameSite (irrelevant server-side). */ export interface JarCookie { name: string; value: string; domain: string; // without leading dot, lower-case hostOnly: boolean; path: string; secure: boolean; expires: number | null; // epoch ms, null = session } export class CookieJar { private cookies: JarCookie[] = []; static fromSerialized(list: Array<{ name: string; value: string; domain?: string; path?: string; secure?: boolean; expires?: number | null }> | null | undefined, fallbackHost?: string): CookieJar { const jar = new CookieJar(); for (const c of list ?? []) { const domain = (c.domain ?? fallbackHost ?? "").toLowerCase().replace(/^\./, ""); if (!domain) continue; jar.cookies.push({ name: c.name, value: c.value, domain, hostOnly: !c.domain, path: c.path ?? "/", secure: Boolean(c.secure), expires: c.expires ?? null }); } return jar; } size(): number { return this.cookies.length; } serialize(): Array<{ name: string; value: string; domain: string; path: string; secure: boolean; expires: number | null }> { const now = Date.now(); return this.cookies.filter((c) => c.expires === null || c.expires > now).map((c) => ({ name: c.name, value: c.value, domain: c.hostOnly ? c.domain : `.${c.domain}`, path: c.path, secure: c.secure, expires: c.expires })); } /** Cookies exposed to the customer: name/value/domain/path only. */ publicList(): Array<{ name: string; value: string; domain?: string; path?: string }> { return this.serialize().map((c) => ({ name: c.name, value: c.value, domain: c.domain.replace(/^\./, ""), path: c.path })); } /** Store every Set-Cookie header value received from `url`. Accepts the flattened ", " joined form too. */ storeFromHeaders(setCookie: string | string[] | undefined, url: URL): void { if (!setCookie) return; const values = Array.isArray(setCookie) ? setCookie : splitSetCookie(setCookie); for (const v of values) this.store(v, url); } store(setCookieValue: string, url: URL): void { const segs = setCookieValue.split(";").map((s) => s.trim()); const nv = segs.shift(); if (!nv) return; const eq = nv.indexOf("="); if (eq <= 0) return; const name = nv.slice(0, eq).trim(); const value = nv.slice(eq + 1).trim(); const host = url.hostname.toLowerCase(); let domain = host; let hostOnly = true; let path = defaultPath(url.pathname); let secure = false; let expires: number | null = null; for (const a of segs) { const [k0, ...rest] = a.split("="); const k = (k0 ?? "").trim().toLowerCase(); const val = rest.join("=").trim(); if (k === "domain" && val) { const d = val.toLowerCase().replace(/^\./, ""); // reject cookies for unrelated domains (and public suffix-ish single labels) if (d && (host === d || host.endsWith(`.${d}`)) && d.includes(".")) { domain = d; hostOnly = false; } } else if (k === "path" && val.startsWith("/")) path = val; else if (k === "secure") secure = true; else if (k === "max-age") { const n = Number(val); if (Number.isFinite(n)) expires = n <= 0 ? 0 : Date.now() + n * 1000; } else if (k === "expires" && expires === null) { const t = Date.parse(val); if (!Number.isNaN(t)) expires = t; } } // remove existing this.cookies = this.cookies.filter((c) => !(c.name === name && c.domain === domain && c.path === path)); if (expires !== null && expires <= Date.now()) return; // deletion this.cookies.push({ name, value, domain, hostOnly, path, secure, expires }); if (this.cookies.length > 300) this.cookies.splice(0, this.cookies.length - 300); } /** Cookie header value for `url` (or null). */ headerFor(url: URL): string | null { const host = url.hostname.toLowerCase(); const path = url.pathname || "/"; const secure = url.protocol === "https:"; const now = Date.now(); const matches = this.cookies.filter((c) => { if (c.expires !== null && c.expires <= now) return false; if (c.secure && !secure) return false; if (c.hostOnly ? host !== c.domain : !(host === c.domain || host.endsWith(`.${c.domain}`))) return false; return path === c.path || (path.startsWith(c.path) && (c.path.endsWith("/") || path[c.path.length] === "/")); }); if (!matches.length) return null; matches.sort((a, b) => b.path.length - a.path.length); return matches.map((c) => `${c.name}=${c.value}`).join("; "); } /** Merge a raw caller-provided Cookie header (name=value; …) as host-only cookies for `url`. */ addRawCookieHeader(header: string | undefined, url: URL): void { if (!header) return; for (const part of header.split(";")) { const t = part.trim(); const eq = t.indexOf("="); if (eq <= 0) continue; this.store(`${t.slice(0, eq)}=${t.slice(eq + 1)}; Path=/`, url); } } } function defaultPath(p: string): string { if (!p || !p.startsWith("/")) return "/"; const i = p.lastIndexOf("/"); return i <= 0 ? "/" : p.slice(0, i); } /** Split a flattened Set-Cookie header (joined with ", ") on cookie boundaries (not on Expires dates). */ export function splitSetCookie(raw: string): string[] { return raw.split(/,(?=\s*[A-Za-z0-9_\-!#$%&'*+.^`|~]+=)/).map((s) => s.trim()).filter(Boolean); }