From a8a8701739354bdb441de17deb39fcbc8fa9c212 Mon Sep 17 00:00:00 2001 From: Flummi Date: Wed, 19 Mar 2025 09:55:21 +0100 Subject: [PATCH] feat: implement CookieManager for enhanced cookie handling --- dist/cookieManager.js | 74 ++++++++++++++++++++++++++++++++++ dist/index.js | 23 +++-------- src/cookieManager.ts | 94 +++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 30 ++++---------- 4 files changed, 180 insertions(+), 41 deletions(-) create mode 100644 dist/cookieManager.js create mode 100644 src/cookieManager.ts diff --git a/dist/cookieManager.js b/dist/cookieManager.js new file mode 100644 index 0000000..6c4e6a8 --- /dev/null +++ b/dist/cookieManager.js @@ -0,0 +1,74 @@ +import fs from "fs"; +export default class CookieManager { + cookies = {}; + parse(headers, domain) { + if (!this.cookies[domain]) + this.cookies[domain] = {}; + headers?.forEach(header => { + const [cookiePart, ...attributes] = header.split(';').map(part => part.trim()); + const [name, value] = cookiePart.split('='); + const cookie = { value: value || "", domain }; + attributes.forEach(attr => { + const [key, val] = attr.split('='); + const lowerKey = key.toLowerCase(); + switch (lowerKey) { + case 'path': + cookie.path = val; + break; + case 'expires': + cookie.expires = val ? new Date(val) : undefined; + break; + case 'max-age': + cookie.maxAge = parseInt(val, 10); + break; + case 'httponly': + cookie.httpOnly = true; + break; + case 'secure': + cookie.secure = true; + break; + case 'samesite': + cookie.sameSite = val; + break; + } + }); + Object.assign(this.cookies[domain], { [name]: cookie }); + }); + } + format(domain) { + this.cleanupExpiredCookies(); + return Object.entries(this.cookies[domain] || {}) + .map(([key, value]) => `${key}=${value.value}`) + .join('; '); + } + getCookies(domain) { + this.cleanupExpiredCookies(); + return this.cookies[domain] || {}; + } + cleanupExpiredCookies() { + const now = new Date(); + Object.keys(this.cookies).forEach(domain => { + Object.keys(this.cookies[domain]).forEach(key => { + if (this.cookies[domain][key].expires && this.cookies[domain][key].expires < now) + delete this.cookies[domain][key]; + }); + }); + } + saveToFile(filePath) { + this.cleanupExpiredCookies(); + fs.writeFileSync(filePath, JSON.stringify(this.cookies, null, 2), "utf8"); + } + loadFromFile(filePath) { + if (!fs.existsSync(filePath)) + return console.warn(`The file ${filePath} does not exist.`); + const fileContent = fs.readFileSync(filePath, "utf8"); + const loadedCookies = JSON.parse(fileContent); + Object.keys(loadedCookies).forEach(domain => { + Object.keys(loadedCookies[domain]).forEach(cookieName => { + const cookie = loadedCookies[domain][cookieName]; + cookie.expires = cookie.expires ? new Date(cookie.expires) : undefined; + }); + }); + this.cookies = loadedCookies; + } +} diff --git a/dist/index.js b/dist/index.js index 5ca4676..fb959d7 100644 --- a/dist/index.js +++ b/dist/index.js @@ -3,23 +3,10 @@ import https from "https"; import { URL } from "url"; import querystring from "querystring"; import zlib from "zlib"; +import CookieManager from './cookieManager.js'; +export const cookieManager = new CookieManager(); class fetch { - cookies = {}; constructor() { } - parseCookies(cookieHeader) { - if (!cookieHeader) - return; - cookieHeader.split(";").forEach(cookie => { - const [key, value] = cookie.split("="); - if (key && value) - this.cookies[key.trim()] = value.trim(); - }); - } - formatCookies() { - return Object.entries(this.cookies) - .map(([key, value]) => `${key}=${value}`) - .join("; "); - } decompress(data, encoding) { return encoding === "br" ? zlib.brotliDecompressSync(data) : encoding === "gzip" ? zlib.gunzipSync(data) : @@ -62,14 +49,14 @@ class fetch { headers: { ...options.headers, ...(body ? { "Content-Length": Buffer.byteLength(body) } : {}), - "Cookie": this.formatCookies(), + "Cookie": cookieManager.format(hostname), "Accept-Encoding": "br, gzip, deflate", } }; return new Promise((resolve, reject) => { const requester = protocol === "https:" ? https : http; const req = requester.request(requestOptions, res => { - this.parseCookies(res.headers["set-cookie"]?.join("; ")); + cookieManager.parse(res.headers["set-cookie"], hostname); if (options.followRedirects && res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { req.destroy(); if (redirectCount >= (options.maxRedirects || 5)) @@ -82,7 +69,7 @@ class fetch { return resolve({ body: res, headers: res.headers, - cookies: this.cookies, + cookies: cookieManager.getCookies(hostname), text: () => this.readData(res, "text"), json: () => this.readData(res, "json"), buffer: () => this.readData(res, "buffer"), diff --git a/src/cookieManager.ts b/src/cookieManager.ts new file mode 100644 index 0000000..0cc6262 --- /dev/null +++ b/src/cookieManager.ts @@ -0,0 +1,94 @@ +import fs from "fs"; + +export type Cookie = { + value: string; + domain?: string; + path?: string; + expires?: Date; + maxAge?: number; + httpOnly?: boolean; + secure?: boolean; + sameSite?: string; +} +export type Cookies = Record; + +export default class CookieManager { + private cookies: Record = {}; + + parse( + headers: string[] | undefined, + domain: string + ): void { + /* todo + * key -> domain | use "domain" only if cookie does not have one + * key -> expires | ??? + */ + if(!this.cookies[domain]) + this.cookies[domain] = {}; + + headers?.forEach(header => { + const [cookiePart, ...attributes] = header.split(';').map(part => part.trim()); + const [name, value] = cookiePart.split('='); + + const cookie: Cookie = { value: value || "", domain }; + attributes.forEach(attr => { + const [key, val] = attr.split('='); + const lowerKey = key.toLowerCase(); + switch(lowerKey) { + case 'path': cookie.path = val; break; + case 'expires': cookie.expires = val ? new Date(val) : undefined; break; + case 'max-age': cookie.maxAge = parseInt(val, 10); break; + case 'httponly': cookie.httpOnly = true; break; + case 'secure': cookie.secure = true; break; + case 'samesite': cookie.sameSite = val; break; + } + }); + + Object.assign(this.cookies[domain], { [name]: cookie }); + }); + } + + format(domain: string): string { + this.cleanupExpiredCookies(); + return Object.entries(this.cookies[domain] || {}) + .map(([key, value]) => `${key}=${value.value}`) + .join('; '); + } + + getCookies(domain: string): Cookies { + this.cleanupExpiredCookies(); + return this.cookies[domain] || {}; + } + + cleanupExpiredCookies(): void { + const now = new Date(); + Object.keys(this.cookies).forEach(domain => { + Object.keys(this.cookies[domain]).forEach(key => { + if(this.cookies[domain][key].expires && this.cookies[domain][key].expires < now) + delete this.cookies[domain][key]; + }); + }); + } + + saveToFile(filePath: string): void { + this.cleanupExpiredCookies(); + fs.writeFileSync(filePath, JSON.stringify(this.cookies, null, 2), "utf8"); + } + + loadFromFile(filePath: string): void { + if(!fs.existsSync(filePath)) + return console.warn(`The file ${filePath} does not exist.`); + + const fileContent = fs.readFileSync(filePath, "utf8"); + const loadedCookies: Record = JSON.parse(fileContent); + + Object.keys(loadedCookies).forEach(domain => { + Object.keys(loadedCookies[domain]).forEach(cookieName => { + const cookie = loadedCookies[domain][cookieName]; + cookie.expires = cookie.expires ? new Date(cookie.expires) : undefined; + }); + }); + + this.cookies = loadedCookies; + } +} diff --git a/src/index.ts b/src/index.ts index 44d9bd7..eb9ad58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,29 +3,13 @@ import https from "https"; import { URL } from "url"; import querystring from "querystring"; import zlib from "zlib"; +import CookieManager, { Cookies } from './cookieManager.js'; + +export const cookieManager = new CookieManager(); class fetch { - private cookies: Record = {}; - constructor() { } - private parseCookies(cookieHeader: string | undefined): void { - if(!cookieHeader) - return; - - cookieHeader.split(";").forEach(cookie => { - const [key, value] = cookie.split("="); - if(key && value) - this.cookies[key.trim()] = value.trim(); - }); - } - - private formatCookies(): string { - return Object.entries(this.cookies) - .map(([key, value]) => `${key}=${value}`) - .join("; "); - } - private decompress( data: Buffer, encoding: string | undefined @@ -75,7 +59,7 @@ class fetch { ): Promise<{ body: http.IncomingMessage; headers: http.IncomingHttpHeaders; - cookies: Record; + cookies: Cookies; text: () => Promise; json: () => Promise; buffer: () => Promise; @@ -97,7 +81,7 @@ class fetch { headers: { ...options.headers, ...(body ? { "Content-Length": Buffer.byteLength(body) } : {}), - "Cookie": this.formatCookies(), + "Cookie": cookieManager.format(hostname), "Accept-Encoding": "br, gzip, deflate", } }; @@ -105,7 +89,7 @@ class fetch { return new Promise((resolve, reject) => { const requester = protocol === "https:" ? https : http; const req = requester.request(requestOptions, res => { - this.parseCookies(res.headers["set-cookie"]?.join("; ")); + cookieManager.parse(res.headers["set-cookie"], hostname); if(options.followRedirects && res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { req.destroy(); if(redirectCount >= (options.maxRedirects || 5)) @@ -120,7 +104,7 @@ class fetch { return resolve({ body: res, headers: res.headers, - cookies: this.cookies, + cookies: cookieManager.getCookies(hostname), text: () => this.readData(res, "text"), json: () => this.readData(res, "json"), buffer: () => this.readData(res, "buffer"),