SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
5.4 KB · 130 lines typescript
Raw Blame History
1/**2 * Small RFC 6265-ish cookie jar used within a single fetch (across redirect hops) and, for sticky3 * sessions, persisted between requests. No dependency; ignores SameSite (irrelevant server-side).4 */5export interface JarCookie {6  name: string;7  value: string;8  domain: string; // without leading dot, lower-case9  hostOnly: boolean;10  path: string;11  secure: boolean;12  expires: number | null; // epoch ms, null = session13}1415export class CookieJar {16  private cookies: JarCookie[] = [];1718  static fromSerialized(list: Array<{ name: string; value: string; domain?: string; path?: string; secure?: boolean; expires?: number | null }> | null | undefined, fallbackHost?: string): CookieJar {19    const jar = new CookieJar();20    for (const c of list ?? []) {21      const domain = (c.domain ?? fallbackHost ?? "").toLowerCase().replace(/^\./, "");22      if (!domain) continue;23      jar.cookies.push({ name: c.name, value: c.value, domain, hostOnly: !c.domain, path: c.path ?? "/", secure: Boolean(c.secure), expires: c.expires ?? null });24    }25    return jar;26  }2728  size(): number {29    return this.cookies.length;30  }3132  serialize(): Array<{ name: string; value: string; domain: string; path: string; secure: boolean; expires: number | null }> {33    const now = Date.now();34    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 }));35  }3637  /** Cookies exposed to the customer: name/value/domain/path only. */38  publicList(): Array<{ name: string; value: string; domain?: string; path?: string }> {39    return this.serialize().map((c) => ({ name: c.name, value: c.value, domain: c.domain.replace(/^\./, ""), path: c.path }));40  }4142  /** Store every Set-Cookie header value received from `url`. Accepts the flattened ", " joined form too. */43  storeFromHeaders(setCookie: string | string[] | undefined, url: URL): void {44    if (!setCookie) return;45    const values = Array.isArray(setCookie) ? setCookie : splitSetCookie(setCookie);46    for (const v of values) this.store(v, url);47  }4849  store(setCookieValue: string, url: URL): void {50    const segs = setCookieValue.split(";").map((s) => s.trim());51    const nv = segs.shift();52    if (!nv) return;53    const eq = nv.indexOf("=");54    if (eq <= 0) return;55    const name = nv.slice(0, eq).trim();56    const value = nv.slice(eq + 1).trim();57    const host = url.hostname.toLowerCase();58    let domain = host;59    let hostOnly = true;60    let path = defaultPath(url.pathname);61    let secure = false;62    let expires: number | null = null;63    for (const a of segs) {64      const [k0, ...rest] = a.split("=");65      const k = (k0 ?? "").trim().toLowerCase();66      const val = rest.join("=").trim();67      if (k === "domain" && val) {68        const d = val.toLowerCase().replace(/^\./, "");69        // reject cookies for unrelated domains (and public suffix-ish single labels)70        if (d && (host === d || host.endsWith(`.${d}`)) && d.includes(".")) {71          domain = d;72          hostOnly = false;73        }74      } else if (k === "path" && val.startsWith("/")) path = val;75      else if (k === "secure") secure = true;76      else if (k === "max-age") {77        const n = Number(val);78        if (Number.isFinite(n)) expires = n <= 0 ? 0 : Date.now() + n * 1000;79      } else if (k === "expires" && expires === null) {80        const t = Date.parse(val);81        if (!Number.isNaN(t)) expires = t;82      }83    }84    // remove existing85    this.cookies = this.cookies.filter((c) => !(c.name === name && c.domain === domain && c.path === path));86    if (expires !== null && expires <= Date.now()) return; // deletion87    this.cookies.push({ name, value, domain, hostOnly, path, secure, expires });88    if (this.cookies.length > 300) this.cookies.splice(0, this.cookies.length - 300);89  }9091  /** Cookie header value for `url` (or null). */92  headerFor(url: URL): string | null {93    const host = url.hostname.toLowerCase();94    const path = url.pathname || "/";95    const secure = url.protocol === "https:";96    const now = Date.now();97    const matches = this.cookies.filter((c) => {98      if (c.expires !== null && c.expires <= now) return false;99      if (c.secure && !secure) return false;100      if (c.hostOnly ? host !== c.domain : !(host === c.domain || host.endsWith(`.${c.domain}`))) return false;101      return path === c.path || (path.startsWith(c.path) && (c.path.endsWith("/") || path[c.path.length] === "/"));102    });103    if (!matches.length) return null;104    matches.sort((a, b) => b.path.length - a.path.length);105    return matches.map((c) => `${c.name}=${c.value}`).join("; ");106  }107108  /** Merge a raw caller-provided Cookie header (name=value; …) as host-only cookies for `url`. */109  addRawCookieHeader(header: string | undefined, url: URL): void {110    if (!header) return;111    for (const part of header.split(";")) {112      const t = part.trim();113      const eq = t.indexOf("=");114      if (eq <= 0) continue;115      this.store(`${t.slice(0, eq)}=${t.slice(eq + 1)}; Path=/`, url);116    }117  }118}119120function defaultPath(p: string): string {121  if (!p || !p.startsWith("/")) return "/";122  const i = p.lastIndexOf("/");123  return i <= 0 ? "/" : p.slice(0, i);124}125126/** Split a flattened Set-Cookie header (joined with ", ") on cookie boundaries (not on Expires dates). */127export function splitSetCookie(raw: string): string[] {128  return raw.split(/,(?=\s*[A-Za-z0-9_\-!#$%&'*+.^`|~]+=)/).map((s) => s.trim()).filter(Boolean);129}130