import fs from "node:fs/promises"; import cookieDecorator from "fetch-cookie"; import type { Callback, Cookie, CreateCookieOptions, ErrorCallback, Nullable } from "tough-cookie"; import LockFile from "./lockfile.js"; const lockFileName = (fileName: string) => `${fileName}.lock`; function isString(str: unknown): str is string { return typeof str === "string" || str instanceof String; } interface FileCookieStoreOptions { force_parse?: boolean; lockfile?: boolean; mode?: number; http_only_extension?: boolean; lockfile_retries?: number; auto_sync?: boolean; no_file_error?: boolean; } type CookieIndex = Record>>; export class FileCookieStore extends cookieDecorator.toughCookie.Store { private file: string; private mode: number; private lockfileRetries: number; private idx: CookieIndex | undefined; constructor(file: string, opt: FileCookieStoreOptions = {}) { super(); if (!file || !isString(file)) { throw new Error("Unknown file for read/write cookies"); } this.file = file; this.mode = opt.mode ?? 0o666; this.lockfileRetries = opt.lockfile_retries ?? 200; } inspect(): string { return `{ idx: ${JSON.stringify(this.idx, null, 2)} }`; } private async _readFile(): Promise { try { const data = await fs.readFile(this.file, "utf8"); if (data) { this.deserialize(data); } } catch (err) { // @ts-expect-error if (err.code === "ENOENT" && !this.idx) { // If the file does not exist, we can just create an empty index this.idx = {}; // @ts-expect-error } else if (err.code === "EACCES") { throw new Error(`Permission denied to read cookie file: ${this.file}`); } else { throw err; } } } private async _write(): Promise { let err: Error | null = null; try { await LockFile.lock(lockFileName(this.file), { retries: this.lockfileRetries, retryWait: 15, wait: 100 }); const data = JSON.stringify(this.idx); await fs.writeFile(this.file, data, { mode: this.mode }); } catch (e) { err = e as Error; } await LockFile.unlock(lockFileName(this.file)); if (err) throw err; } private async _update(updateFunc: (cookieIndex: CookieIndex) => void): Promise { let err: Error | null = null; try { let cookieIndex: CookieIndex = await this._getCookieIndex(); updateFunc(cookieIndex); await this._write(); } catch (e) { err = e as Error; } if (err) throw err; } private deserialize(raw_data: string): void { this.idx = JSON.parse(raw_data) as CookieIndex; } private async _getCookieIndex(): Promise { if (!this.idx) { await this._readFile(); if (!this.idx) { this.idx = {}; } } return this.idx as CookieIndex; } private _addCookie(cookieIndex: CookieIndex, cookie: Cookie): void { let domain = cookie.canonicalizedDomain(); if (!domain) return; let path = cookie.path || "/"; if (!cookieIndex[domain]) cookieIndex[domain] = {}; if (!cookieIndex[domain][path]) cookieIndex[domain][path] = {}; cookieIndex[domain][path][cookie.key] = cookie; } private async putCookie_(cookie: Cookie): Promise { await this._update((index) => this._addCookie(index, cookie)); } putCookie(cookie: Cookie, callback?: ErrorCallback): Promise { let promise = this.putCookie_(cookie); if (callback) { promise.then( () => callback(null), (error) => callback(error), ); } return promise; } private async updateCookie_(_oldCookie: Cookie, newCookie: Cookie): Promise { await this.putCookie_(newCookie); } async updateCookie(_oldCookie: Cookie, newCookie: Cookie, callback?: ErrorCallback): Promise { let promise = this.updateCookie_(_oldCookie, newCookie); if (callback) { promise.then( () => callback(null), (error) => callback(error), ); } return promise; } private async removeCookie_(domain: string, path: string, key: string): Promise { return await this._update((index) => { const d = cookieDecorator.toughCookie.canonicalDomain(domain); if (!d || !index[d]) return; if (index[d]?.[path]?.[key]) delete index[d][path][key]; }); } async removeCookie(domain: string, path: string, key: string, callback?: ErrorCallback): Promise { let promise = this.removeCookie_(domain, path, key); if (callback) { promise.then( () => callback(null), (error) => callback(error), ); } return promise; } private async removeCookies_(domain: string, path: string | null): Promise { await this._update((index) => { const d = cookieDecorator.toughCookie.canonicalDomain(domain); if (!d || !index[d]) return; if (path) delete index[d][path]; else delete index[d]; }); } async removeCookies(domain: string, path: string | null, callback?: ErrorCallback): Promise { let promise = this.removeCookies_(domain, path); if (callback) { promise.then( () => callback(null), (error) => callback(error), ); } return promise; } private getCookieFromSerializedOptions(cookieOptions: CreateCookieOptions | undefined): Cookie | undefined { if (!cookieOptions || !cookieOptions.key) return undefined; if (typeof cookieOptions.expires === "string") { cookieOptions.expires = new Date(cookieOptions.expires); } if (typeof cookieOptions.creation === "string") { cookieOptions.creation = new Date(cookieOptions.creation); } if (typeof cookieOptions.lastAccessed === "string") { cookieOptions.lastAccessed = new Date(cookieOptions.lastAccessed); } return new cookieDecorator.toughCookie.Cookie(cookieOptions); } private async findCookie_( domain: Nullable, path: Nullable, key: Nullable, ): Promise { let cookieIndex: CookieIndex = await this._getCookieIndex(); let cdomain = cookieDecorator.toughCookie.canonicalDomain(domain); let cookieOptions = cookieIndex[cdomain ?? ""]?.[path ?? "/"]?.[key ?? ""]; return this.getCookieFromSerializedOptions(cookieOptions); } findCookie( domain: Nullable, path: Nullable, key: Nullable, callback?: Callback, ): Promise { let promise = this.findCookie_(domain, path, key); if (callback) { promise.then( (cookie) => callback?.(null, cookie), (error) => callback?.(error, undefined), ); } return promise; } private async findCookies_(domain: string, path: string, _: any): Promise { let results: Cookie[] = []; let cdomain = cookieDecorator.toughCookie.canonicalDomain(domain); if (!cdomain) return results; let paths = path ? cookieDecorator.toughCookie.permutePath(path) : null; let domains = cookieDecorator.toughCookie.permuteDomain(cdomain); if (!domains) return results; let cookieIndex: CookieIndex = await this._getCookieIndex(); for (const dom of domains) { const domainIndex = cookieIndex[dom]; if (!domainIndex) { continue; } if (!path || !paths) { for (const p in domainIndex) { for (const k in domainIndex[p]) { let cookie = this.getCookieFromSerializedOptions(domainIndex[p][k]); if (cookie) { results.push(cookie); } } } } else { for (const p of paths) { const pathIndex = domainIndex[p]; if (pathIndex) { for (const k in pathIndex) { let cookie = this.getCookieFromSerializedOptions(pathIndex[k]); if (cookie) { results.push(cookie); } } } } } } return results; } findCookies(domain: string, path: string, _: any, callback?: Callback): Promise { let promise = this.findCookies_(domain, path, _); if (callback) { promise.then( (cookies) => callback?.(null, cookies), (error) => callback?.(error, []), ); } return promise; } async getAllCookies(): Promise { let cookies = await this.findCookies("", "", null); cookies.sort((a, b) => (a.creationIndex || 0) - (b.creationIndex || 0)); return cookies; } } export default FileCookieStore;