diff --git a/LICENSE b/LICENSE index 058b6b4..db7eac1 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,28 @@ -(The MIT License) +MIT License +Cookie-es copyright (c) Pooya Parsa + +Cookie parsing based on https://github.com/jshttp/cookie Copyright (c) 2012-2014 Roman Shtylman Copyright (c) 2015 Douglas Christopher Wilson -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Set-Cookie parsing based on https://github.com/nfriedly/set-cookie-parser +Copyright (c) 2015 Nathan Friedly (http://nfriedly.com/) -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 98c5de7..91916f6 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -# cookie-es +# 🍪 cookie-es @@ -9,7 +9,7 @@ -ESM build of [cookie](https://www.npmjs.com/package/cookie) with bundled types. +🍪 [`Cookie`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cookie) and [`Set-Cookie`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) parser and serializer based on [cookie](https://github.com/jshttp/cookiee) and [set-cookie-parser](https://github.com/nfriedly/set-cookie-parser) with dual ESM/CJS exports and bundled types. 🎁 ## Usage @@ -44,19 +44,34 @@ Import: **ESM** (Node.js, Bun) ```js -import { parse, serialize } from "cookie-es"; +import { + parse, + serialize, + parseSetCookie, + splitSetCookieString, +} from "cookie-es"; ``` **CommonJS** (Legacy Node.js) ```js -const { parse, serialize } = require("cookie-es"); +const { + parse, + serialize, + parseSetCookie, + splitSetCookieString, +} = require("cookie-es"); ``` **CDN** (Deno, Bun and Browsers) ```js -import { parse, serialize } from "https://esm.sh/cookie-es"; +import { + parse, + serialize, + parseSetCookie, + splitSetCookieString, +} from "https://esm.sh/cookie-es"; ``` diff --git a/eslint.config.mjs b/eslint.config.mjs index 6023d4a..714f29b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -3,5 +3,7 @@ import unjs from "eslint-config-unjs"; // https://github.com/unjs/eslint-config export default unjs({ ignores: [], - rules: {}, -}); \ No newline at end of file + rules: { + "unicorn/no-array-callback-reference": 0 + }, +}); diff --git a/package.json b/package.json index 7520595..57cd24c 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ ], "scripts": { "build": "unbuild", - "dev": "vitest", + "dev": "vitest --coverage", "lint": "eslint --cache . && prettier -c src test", "lint:fix": "automd && eslint --cache . --fix && prettier -c src test -w", "release": "pnpm test && pnpm build && changelogen --release --push && npm publish", diff --git a/src/cookie/parse.ts b/src/cookie/parse.ts new file mode 100644 index 0000000..22a3060 --- /dev/null +++ b/src/cookie/parse.ts @@ -0,0 +1,78 @@ +// Based on https://github.com/jshttp/cookie (MIT) +// Copyright (c) 2012-2014 Roman Shtylman +// Copyright (c) 2015 Douglas Christopher Wilson +// Last sync: 84a156749b673dbfbf43679829b15be09fbd8988 + +import type { CookieParseOptions } from "./types"; +/** + * Parse an HTTP Cookie header string and returning an object of all cookie + * name-value pairs. + * + * @param str the string representing a `Cookie` header value + * @param [options] object containing parsing options + */ +export function parse( + str: string, + options?: CookieParseOptions, +): Record { + if (typeof str !== "string") { + throw new TypeError("argument str must be a string"); + } + + const obj = {}; + const opt = options || {}; + const dec = opt.decode || decode; + + let index = 0; + while (index < str.length) { + const eqIdx = str.indexOf("=", index); + + // no more cookie pairs + if (eqIdx === -1) { + break; + } + + let endIdx = str.indexOf(";", index); + + if (endIdx === -1) { + endIdx = str.length; + } else if (endIdx < eqIdx) { + // backtrack on prior semicolon + index = str.lastIndexOf(";", eqIdx - 1) + 1; + continue; + } + + const key = str.slice(index, eqIdx).trim(); + + // only assign once + if (undefined === obj[key as keyof typeof obj]) { + let val = str.slice(eqIdx + 1, endIdx).trim(); + + // quoted values + if (val.codePointAt(0) === 0x22) { + val = val.slice(1, -1); + } + + (obj as any)[key] = tryDecode(val, dec); + } + + index = endIdx + 1; + } + + return obj; +} + +function decode(str: string) { + return str.includes("%") ? decodeURIComponent(str) : str; +} + +function tryDecode( + str: string, + decode: Exclude, +) { + try { + return decode(str); + } catch { + return str; + } +} diff --git a/src/cookie/serialize.ts b/src/cookie/serialize.ts new file mode 100644 index 0000000..75bdc43 --- /dev/null +++ b/src/cookie/serialize.ts @@ -0,0 +1,159 @@ +// Based on https://github.com/jshttp/cookie (MIT) +// Copyright (c) 2012-2014 Roman Shtylman +// Copyright (c) 2015 Douglas Christopher Wilson +// Last sync: 84a156749b673dbfbf43679829b15be09fbd8988 + +import type { CookieSerializeOptions } from "./types"; +export type { CookieParseOptions, CookieSerializeOptions } from "./types"; + +/** + * RegExp to match field-content in RFC 7230 sec 3.2 + * + * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] + * field-vchar = VCHAR / obs-text + * obs-text = %x80-FF + */ +// eslint-disable-next-line no-control-regex +const fieldContentRegExp = /^[\u0009\u0020-\u007E\u0080-\u00FF]+$/; + +/** + * Serialize a cookie name-value pair into a `Set-Cookie` header string. + * + * @param name the name for the cookie + * @param value value to set the cookie to + * @param [options] object containing serialization options + * @throws {TypeError} when `maxAge` options is invalid + */ +export function serialize( + name: string, + value: string, + options?: CookieSerializeOptions, +): string { + const opt = options || {}; + const enc = opt.encode || encodeURIComponent; + + if (typeof enc !== "function") { + throw new TypeError("option encode is invalid"); + } + + if (!fieldContentRegExp.test(name)) { + throw new TypeError("argument name is invalid"); + } + + const encodedValue = enc(value); + + if (encodedValue && !fieldContentRegExp.test(encodedValue)) { + throw new TypeError("argument val is invalid"); + } + + let str = name + "=" + encodedValue; + + if (undefined !== opt.maxAge && opt.maxAge !== null) { + const maxAge = opt.maxAge - 0; + + if (Number.isNaN(maxAge) || !Number.isFinite(maxAge)) { + throw new TypeError("option maxAge is invalid"); + } + + str += "; Max-Age=" + Math.floor(maxAge); + } + + if (opt.domain) { + if (!fieldContentRegExp.test(opt.domain)) { + throw new TypeError("option domain is invalid"); + } + + str += "; Domain=" + opt.domain; + } + + if (opt.path) { + if (!fieldContentRegExp.test(opt.path)) { + throw new TypeError("option path is invalid"); + } + + str += "; Path=" + opt.path; + } + + if (opt.expires) { + if (!isDate(opt.expires) || Number.isNaN(opt.expires.valueOf())) { + throw new TypeError("option expires is invalid"); + } + + str += "; Expires=" + opt.expires.toUTCString(); + } + + if (opt.httpOnly) { + str += "; HttpOnly"; + } + + if (opt.secure) { + str += "; Secure"; + } + + if (opt.priority) { + const priority = + typeof opt.priority === "string" + ? opt.priority.toLowerCase() + : opt.priority; + + switch (priority) { + case "low": { + str += "; Priority=Low"; + break; + } + case "medium": { + str += "; Priority=Medium"; + break; + } + case "high": { + str += "; Priority=High"; + break; + } + default: { + throw new TypeError("option priority is invalid"); + } + } + } + + if (opt.sameSite) { + const sameSite = + typeof opt.sameSite === "string" + ? opt.sameSite.toLowerCase() + : opt.sameSite; + + switch (sameSite) { + case true: { + str += "; SameSite=Strict"; + break; + } + case "lax": { + str += "; SameSite=Lax"; + break; + } + case "strict": { + str += "; SameSite=Strict"; + break; + } + case "none": { + str += "; SameSite=None"; + break; + } + default: { + throw new TypeError("option sameSite is invalid"); + } + } + } + + if (opt.partitioned) { + str += "; Partitioned"; + } + + return str; +} + +function isDate(val: unknown) { + return ( + Object.prototype.toString.call(val) === "[object Date]" || + val instanceof Date + ); +} diff --git a/src/types.ts b/src/cookie/types.ts similarity index 100% rename from src/types.ts rename to src/cookie/types.ts diff --git a/src/index.ts b/src/index.ts index d036975..fb8b93c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,260 +1,12 @@ -import type { CookieParseOptions, CookieSerializeOptions } from "./types"; -export type { CookieParseOptions, CookieSerializeOptions } from "./types"; - -/** - * RegExp to match field-content in RFC 7230 sec 3.2 - * - * field-content = field-vchar [ 1*( SP / HTAB ) field-vchar ] - * field-vchar = VCHAR / obs-text - * obs-text = %x80-FF - */ -// eslint-disable-next-line no-control-regex -const fieldContentRegExp = /^[\u0009\u0020-\u007E\u0080-\u00FF]+$/; - -/** - * Parse an HTTP Cookie header string and returning an object of all cookie - * name-value pairs. - * - * @param str the string representing a `Cookie` header value - * @param [options] object containing parsing options - */ -export function parse( - str: string, - options?: CookieParseOptions, -): Record { - if (typeof str !== "string") { - throw new TypeError("argument str must be a string"); - } - - const obj = {}; - const opt = options || {}; - const dec = opt.decode || decode; - - let index = 0; - while (index < str.length) { - const eqIdx = str.indexOf("=", index); - - // no more cookie pairs - if (eqIdx === -1) { - break; - } - - let endIdx = str.indexOf(";", index); - - if (endIdx === -1) { - endIdx = str.length; - } else if (endIdx < eqIdx) { - // backtrack on prior semicolon - index = str.lastIndexOf(";", eqIdx - 1) + 1; - continue; - } - - const key = str.slice(index, eqIdx).trim(); - if (opt?.filter && !opt?.filter(key)) { - index = endIdx + 1; - continue; - } - - // only assign once - if (undefined === obj[key]) { - let val = str.slice(eqIdx + 1, endIdx).trim(); - - // quoted values - if (val.codePointAt(0) === 0x22) { - val = val.slice(1, -1); - } - - obj[key] = tryDecode(val, dec); - } - - index = endIdx + 1; - } - - return obj; -} - -/** - * Serialize a cookie name-value pair into a `Set-Cookie` header string. - * - * @param name the name for the cookie - * @param value value to set the cookie to - * @param [options] object containing serialization options - * @throws {TypeError} when `maxAge` options is invalid - */ -export function serialize( - name: string, - value: string, - options?: CookieSerializeOptions, -): string { - const opt = options || {}; - const enc = opt.encode || encode; - - if (typeof enc !== "function") { - throw new TypeError("option encode is invalid"); - } - - if (!fieldContentRegExp.test(name)) { - throw new TypeError("argument name is invalid"); - } - - const encodedValue = enc(value); - - if (encodedValue && !fieldContentRegExp.test(encodedValue)) { - throw new TypeError("argument val is invalid"); - } - - let str = name + "=" + encodedValue; - - if (undefined !== opt.maxAge && opt.maxAge !== null) { - const maxAge = opt.maxAge - 0; - - if (Number.isNaN(maxAge) || !Number.isFinite(maxAge)) { - throw new TypeError("option maxAge is invalid"); - } - - str += "; Max-Age=" + Math.floor(maxAge); - } - - if (opt.domain) { - if (!fieldContentRegExp.test(opt.domain)) { - throw new TypeError("option domain is invalid"); - } - - str += "; Domain=" + opt.domain; - } - - if (opt.path) { - if (!fieldContentRegExp.test(opt.path)) { - throw new TypeError("option path is invalid"); - } - - str += "; Path=" + opt.path; - } - - if (opt.expires) { - if (!isDate(opt.expires) || Number.isNaN(opt.expires.valueOf())) { - throw new TypeError("option expires is invalid"); - } - - str += "; Expires=" + opt.expires.toUTCString(); - } - - if (opt.httpOnly) { - str += "; HttpOnly"; - } - - if (opt.secure) { - str += "; Secure"; - } - - if (opt.priority) { - const priority = - typeof opt.priority === "string" - ? opt.priority.toLowerCase() - : opt.priority; - - switch (priority) { - case "low": { - str += "; Priority=Low"; - break; - } - case "medium": { - str += "; Priority=Medium"; - break; - } - case "high": { - str += "; Priority=High"; - break; - } - default: { - throw new TypeError("option priority is invalid"); - } - } - } - - if (opt.sameSite) { - const sameSite = - typeof opt.sameSite === "string" - ? opt.sameSite.toLowerCase() - : opt.sameSite; - - switch (sameSite) { - case true: { - str += "; SameSite=Strict"; - break; - } - case "lax": { - str += "; SameSite=Lax"; - break; - } - case "strict": { - str += "; SameSite=Strict"; - break; - } - case "none": { - str += "; SameSite=None"; - break; - } - default: { - throw new TypeError("option sameSite is invalid"); - } - } - } - - if (opt.partitioned) { - str += "; Partitioned"; - } - - return str; -} - -/** - * Determine if value is a Date. - * - * @param {*} val - * @private - */ - -function isDate(val) { - return ( - Object.prototype.toString.call(val) === "[object Date]" || - val instanceof Date - ); -} - -/** - * Try decoding a string using a decoding function. - * - * @param {string} str - * @param {function} decode - * @private - */ -function tryDecode(str, decode) { - try { - return decode(str); - } catch { - return str; - } -} - -/** - * URL-decode string value. Optimized to skip native call when no %. - * - * @param {string} str - * @returns {string} - */ - -function decode(str) { - return str.includes("%") ? decodeURIComponent(str) : str; -} - -/** - * URL-encode value. - * - * @param {string} str - * @returns {string} - */ - -function encode(val) { - return encodeURIComponent(val); -} +// Cookie +export { parse } from "./cookie/parse"; +export { serialize } from "./cookie/serialize"; +export type { + CookieParseOptions, + CookieSerializeOptions, +} from "./cookie/types"; + +// Set-Cookie +export { parseSetCookie } from "./set-cookie/parse"; +export { splitSetCookieString } from "./set-cookie/split"; +export type { SetCookieParseOptions, SetCookie } from "./set-cookie/types"; diff --git a/src/set-cookie/parse.ts b/src/set-cookie/parse.ts new file mode 100644 index 0000000..224ce5f --- /dev/null +++ b/src/set-cookie/parse.ts @@ -0,0 +1,88 @@ +// Based on https://github.com/nfriedly/set-cookie-parser (MIT) +// Copyright (c) 2015 Nathan Friedly (http://nfriedly.com/) +// Last sync: v2.6.0 830debeeeec2ee21a36256bdef66485879dd18cd + +import type { SetCookie, SetCookieParseOptions } from "./types"; + +/** + * Parse a [Set-Cookie](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie) header string into an object. + */ +export function parseSetCookie( + setCookieValue: string, + options?: SetCookieParseOptions, +): SetCookie { + const parts = (setCookieValue || "") + .split(";") + .filter((str) => typeof str === "string" && !!str.trim()); + + const nameValuePairStr = parts.shift() || ""; + const parsed = _parseNameValuePair(nameValuePairStr); + + const name = parsed.name; + + let value = parsed.value; + try { + value = + options?.decode === false + ? value + : (options?.decode || decodeURIComponent)(value); + } catch { + // Fallback to undecoded value + } + + const cookie: SetCookie = { + name: name, + value: value, + }; + + for (const part of parts) { + const sides = part.split("="); + const partKey = (sides.shift() || "").trimStart().toLowerCase(); + const partValue = sides.join("="); + switch (partKey) { + case "expires": { + cookie.expires = new Date(partValue); + break; + } + case "max-age": { + cookie.maxAge = Number.parseInt(partValue, 10); + break; + } + case "secure": { + cookie.secure = true; + break; + } + case "httponly": { + cookie.httpOnly = true; + break; + } + case "samesite": { + cookie.sameSite = partValue; + break; + } + default: { + cookie[partKey] = partValue; + } + } + } + + return cookie; +} + +// --- Internal Utils --- + +/** Parses name-value-pair according to rfc6265bis draft */ +function _parseNameValuePair(nameValuePairStr: string) { + let name = ""; + let value = ""; + const nameValueArr = nameValuePairStr.split("="); + if (nameValueArr.length > 1) { + name = nameValueArr.shift()!; + // Everything after the first =, joined by a "=" if there was more than one part + value = nameValueArr.join("="); + } else { + value = nameValuePairStr; + } + + return { name: name, value: value }; +} diff --git a/src/set-cookie/split.ts b/src/set-cookie/split.ts new file mode 100644 index 0000000..53445bc --- /dev/null +++ b/src/set-cookie/split.ts @@ -0,0 +1,84 @@ +// Based on https://github.com/nfriedly/set-cookie-parser (MIT) +// Copyright (c) 2015 Nathan Friedly (http://nfriedly.com/) +// Last sync: v2.6.0 830debeeeec2ee21a36256bdef66485879dd18cd + +/** + * Set-Cookie header field-values are sometimes comma joined in one string. This splits them without choking on commas + * that are within a single set-cookie field-value, such as in the Expires portion. + * + * See https://tools.ietf.org/html/rfc2616#section-4.2 + */ +export function splitSetCookieString( + cookiesString: string | string[], +): string[] { + if (Array.isArray(cookiesString)) { + return cookiesString.flatMap((c) => splitSetCookieString(c)); + } + + if (typeof cookiesString !== "string") { + return []; + } + + const cookiesStrings: string[] = []; + let pos: number = 0; + let start: number; + let ch: string; + let lastComma: number; + let nextStart: number; + let cookiesSeparatorFound: boolean; + + const skipWhitespace = () => { + while (pos < cookiesString.length && /\s/.test(cookiesString.charAt(pos))) { + pos += 1; + } + return pos < cookiesString.length; + }; + + const notSpecialChar = () => { + ch = cookiesString.charAt(pos); + return ch !== "=" && ch !== ";" && ch !== ","; + }; + + while (pos < cookiesString.length) { + start = pos; + cookiesSeparatorFound = false; + + while (skipWhitespace()) { + ch = cookiesString.charAt(pos); + if (ch === ",") { + // ',' is a cookie separator if we have later first '=', not ';' or ',' + lastComma = pos; + pos += 1; + + skipWhitespace(); + nextStart = pos; + + while (pos < cookiesString.length && notSpecialChar()) { + pos += 1; + } + + // currently special character + if (pos < cookiesString.length && cookiesString.charAt(pos) === "=") { + // we found cookies separator + cookiesSeparatorFound = true; + // pos is inside the next cookie, so back up and return it. + pos = nextStart; + cookiesStrings.push(cookiesString.slice(start, lastComma)); + start = pos; + } else { + // in param ',' or param separator ';', + // we continue from that comma + pos = lastComma + 1; + } + } else { + pos += 1; + } + } + + if (!cookiesSeparatorFound || pos >= cookiesString.length) { + cookiesStrings.push(cookiesString.slice(start, cookiesString.length)); + } + } + + return cookiesStrings; +} diff --git a/src/set-cookie/types.ts b/src/set-cookie/types.ts new file mode 100644 index 0000000..134af60 --- /dev/null +++ b/src/set-cookie/types.ts @@ -0,0 +1,62 @@ +export interface SetCookieParseOptions { + /** + * Custom decode function to use on cookie values. + * + * By default, `decodeURIComponent` is used. + * + * **Note:** If decoding fails, the original (undecoded) value will be used + */ + decode?: false | ((value: string) => string); +} + +export interface SetCookie { + /** + * Cookie name + */ + name: string; + + /** + * Cookie value + */ + value: string; + + /** + * Cookie path + */ + path?: string | undefined; + + /** + * Absolute expiration date for the cookie + */ + expires?: Date | undefined; + + /** + * Relative max age of the cookie in seconds from when the client receives it (integer or undefined) + * + * Note: when using with express's res.cookie() method, multiply maxAge by 1000 to convert to milliseconds + */ + maxAge?: number | undefined; + + /** + * Domain for the cookie, + * May begin with "." to indicate the named domain or any subdomain of it + */ + domain?: string | undefined; + + /** + * Indicates that this cookie should only be sent over HTTPs + */ + secure?: boolean | undefined; + + /** + * Indicates that this cookie should not be accessible to client-side JavaScript + */ + httpOnly?: boolean | undefined; + + /** + * Indicates a cookie ought not to be sent along with cross-site requests + */ + sameSite?: string | undefined; + + [key: string]: unknown; +} diff --git a/test/parse.test.ts b/test/cookie-parse.test.ts similarity index 89% rename from test/parse.test.ts rename to test/cookie-parse.test.ts index eaa7c03..a71dbab 100644 --- a/test/parse.test.ts +++ b/test/cookie-parse.test.ts @@ -1,3 +1,8 @@ +// Based on https://github.com/jshttp/cookie (MIT) +// Copyright (c) 2012-2014 Roman Shtylman +// Copyright (c) 2015 Douglas Christopher Wilson +// Last sync: 84a156749b673dbfbf43679829b15be09fbd8988 + import { describe, it, expect } from "vitest"; import { parse } from "../src"; @@ -64,7 +69,7 @@ describe("cookie.parse(str, options)", () => { expect( parse('foo="YmFy"', { decode: (v) => { - return Buffer.from(v, "base64").toString(); + return atob(v); }, }), ).toMatchObject({ foo: "bar" }); diff --git a/test/serialize.test.ts b/test/cookie-serialize.test.ts similarity index 96% rename from test/serialize.test.ts rename to test/cookie-serialize.test.ts index 5ad32c5..21be353 100644 --- a/test/serialize.test.ts +++ b/test/cookie-serialize.test.ts @@ -1,3 +1,8 @@ +// Based on https://github.com/jshttp/cookie (MIT) +// Copyright (c) 2012-2014 Roman Shtylman +// Copyright (c) 2015 Douglas Christopher Wilson +// Last sync: 84a156749b673dbfbf43679829b15be09fbd8988 + import { describe, it, expect } from "vitest"; import { serialize } from "../src"; @@ -49,7 +54,7 @@ describe("serialize(name, value, options)", () => { expect( serialize("foo", "bar", { encode: (v) => { - return Buffer.from(v, "utf8").toString("base64"); + return btoa(v); }, }), ).toBe("foo=YmFy"); diff --git a/test/set-cookie-parser.test.ts b/test/set-cookie-parser.test.ts new file mode 100644 index 0000000..8c1cbd0 --- /dev/null +++ b/test/set-cookie-parser.test.ts @@ -0,0 +1,87 @@ +// Based on https://github.com/nfriedly/set-cookie-parser (MIT) +// Copyright (c) 2015 Nathan Friedly (http://nfriedly.com/) +// Last sync: v2.6.0 830debeeeec2ee21a36256bdef66485879dd18cd + +import { describe, it, expect } from "vitest"; +import { parseSetCookie } from "../src"; + +describe("parseSetCookie", () => { + it("should parse a simple set-cookie header", () => { + expect(parseSetCookie("foo=bar;")).toStrictEqual({ + name: "foo", + value: "bar", + }); + }); + + it("should parse a complex set-cookie header", () => { + expect( + parseSetCookie( + "foo=bar; Max-Age=1000; Domain=.example.com; Path=/; Expires=Tue, 01 Jul 2025 10:01:11 GMT; HttpOnly; Secure", + ), + ).toStrictEqual({ + name: "foo", + value: "bar", + path: "/", + expires: new Date("Tue Jul 01 2025 06:01:11 GMT-0400 (EDT)"), + maxAge: 1000, + domain: ".example.com", + secure: true, + httpOnly: true, + }); + }); + + it("should parse a weird but valid cookie", () => { + expect( + parseSetCookie( + "foo=bar=bar&foo=foo&John=Doe&Doe=John; Max-Age=1000; Domain=.example.com; Path=/; HttpOnly; Secure", + ), + ).toStrictEqual({ + name: "foo", + value: "bar=bar&foo=foo&John=Doe&Doe=John", + path: "/", + maxAge: 1000, + domain: ".example.com", + secure: true, + httpOnly: true, + }); + }); + + it("should parse a cookie with percent-encoding in the data", () => { + const cookieStr = + "foo=asdf%3Basdf%3Dtrue%3Basdf%3Dasdf%3Basdf%3Dtrue%40asdf"; + + expect(parseSetCookie(cookieStr)).toStrictEqual({ + name: "foo", + value: "asdf;asdf=true;asdf=asdf;asdf=true@asdf", + }); + + expect(parseSetCookie(cookieStr, { decode: false })).toStrictEqual({ + name: "foo", + value: "asdf%3Basdf%3Dtrue%3Basdf%3Dasdf%3Basdf%3Dtrue%40asdf", + }); + }); + + it("should handle the case when value is not UTF-8 encoded", () => { + expect( + parseSetCookie( + "foo=R%F3r%EB%80%8DP%FF%3B%2C%23%9A%0CU%8E%A2C8%D7%3C%3C%B0%DF%17%60%F7Y%DB%16%8BQ%D6%1A", + {}, + ), + ).toStrictEqual({ + name: "foo", + value: + "R%F3r%EB%80%8DP%FF%3B%2C%23%9A%0CU%8E%A2C8%D7%3C%3C%B0%DF%17%60%F7Y%DB%16%8BQ%D6%1A", + }); + }); + + it("should have empty name string, and value is the name-value-pair if the name-value-pair string lacks a = character", () => { + expect(parseSetCookie("foo;")).toStrictEqual({ name: "", value: "foo" }); + + expect(parseSetCookie("foo;SameSite=None;Secure")).toStrictEqual({ + name: "", + value: "foo", + sameSite: "None", + secure: true, + }); + }); +}); diff --git a/test/split-cookies-string.test.ts b/test/split-cookies-string.test.ts new file mode 100644 index 0000000..7e1890e --- /dev/null +++ b/test/split-cookies-string.test.ts @@ -0,0 +1,200 @@ +// Based on https://github.com/nfriedly/set-cookie-parser (MIT) +// Copyright (c) 2015 Nathan Friedly (http://nfriedly.com/) +// Last sync: v2.6.0 830debeeeec2ee21a36256bdef66485879dd18cd + +import { describe, it, expect } from "vitest"; +import { splitSetCookieString } from "../src"; + +const array = ["a", "b"]; + +const cookieNoParams = "sessionid=6ky4pkr7qoi4me7rwleyvxjove25huef"; +const cookieWithParams = `${cookieNoParams}; HttpOnly; Path=/`; +const cookieWithExpires = + "cid=70125eaa-399a-41b2-b235-8a5092042dba; expires=Thu, 04-Jun-2020 12:17:56 GMT; Max-Age=63072000; Path=/; HttpOnly; Secure"; +const cookieWithExpiresAtEnd = + "client_id=70125eaa-399a-41b2-b235-8a5092042dba; Max-Age=63072000; Path=/; expires=Thu, 04-Jun-2020 12:17:56 GMT"; +const jsonCookie = `myJsonCookie=${JSON.stringify({ + foo: "bar", + arr: [1, 2, 3], +})}`; +const jsonCookieWithParams = `${jsonCookie}; expires=Thu, 04-Jun-2020 12:17:56 GMT; Max-Age=63072000; Path=/; HttpOnly; Secure`; + +const firstWithParamSecondNoParam = `${cookieWithParams}, ${cookieNoParams}`; +const threeNoParams = `${cookieNoParams}, ${cookieNoParams}, ${cookieNoParams}`; +const threeWithParams = `${cookieWithParams}, ${cookieWithParams}, ${cookieWithParams}`; +const firstWithExpiresSecondNoParam = `${cookieWithExpires}, ${cookieNoParams}`; +const firstWithExpiresSecondWithParam = `${cookieWithExpires}, ${cookieWithParams}`; +const firstWithExpiresAtEndSecondNoParam = `${cookieWithExpiresAtEnd}, ${cookieNoParams}`; +const firstWithExpiresAtEndSecondWithParam = `${cookieWithExpiresAtEnd}, ${cookieWithParams}`; +const firstWithExpiresSecondWithExpires = `${cookieWithExpires}, ${cookieWithExpires}`; +const firstWithExpiresSecondWithExpiresAtEnd = `${cookieWithExpires}, ${cookieWithExpiresAtEnd}`; +const firstWithExpiresAtEndSecondWithExpires = `${cookieWithExpiresAtEnd}, ${cookieWithExpires}`; +const firstWithExpiresAtEndSecondWithExpiresAtEnd = `${cookieWithExpiresAtEnd}, ${cookieWithExpiresAtEnd}`; +const firstWithExpiresSecondWithExpiresAtEndThirdWithExpires = `${cookieWithExpires}, ${cookieWithExpiresAtEnd}, ${cookieWithExpires}`; +const firstWithExpiresSecondWithExpiresAtEndThirdWithExpiresAtEnd = `${cookieWithExpires}, ${cookieWithExpiresAtEnd}, ${cookieWithExpiresAtEnd}`; +const threeWithExpires = `${cookieWithExpires}, ${cookieWithExpires}, ${cookieWithExpires}`; +const threeWithExpiresAtEnd = `${cookieWithExpiresAtEnd}, ${cookieWithExpiresAtEnd}, ${cookieWithExpiresAtEnd}`; + +describe("splitSetCookieString", function () { + it("should return array if Array", function () { + const actual = splitSetCookieString(array); + const expected = array; + expect(actual).toStrictEqual(expected); + }); + + it("should return empty array on non string type", function () { + const actual = splitSetCookieString(1); + const expected = []; + expect(actual).toStrictEqual(expected); + }); + + it("should parse empty string", function () { + const actual = splitSetCookieString(""); + const expected = []; + expect(actual).toStrictEqual(expected); + }); + + it("should parse single cookie without params", function () { + const actual = splitSetCookieString(cookieNoParams); + const expected = [cookieNoParams]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse single cookie with params", function () { + const actual = splitSetCookieString(cookieWithParams); + const expected = [cookieWithParams]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse three cookies without params", function () { + const actual = splitSetCookieString(threeNoParams); + const expected = [cookieNoParams, cookieNoParams, cookieNoParams]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse Three with params", function () { + const actual = splitSetCookieString(threeWithParams); + const expected = [cookieWithParams, cookieWithParams, cookieWithParams]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse first with params, second without params", function () { + const actual = splitSetCookieString(firstWithParamSecondNoParam); + const expected = [cookieWithParams, cookieNoParams]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse single with expires", function () { + const actual = splitSetCookieString(cookieWithExpires); + const expected = [cookieWithExpires]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse single with expires at end", function () { + const actual = splitSetCookieString(cookieWithExpiresAtEnd); + const expected = [cookieWithExpiresAtEnd]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse first with expires, second without params", function () { + const actual = splitSetCookieString(firstWithExpiresSecondNoParam); + const expected = [cookieWithExpires, cookieNoParams]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse first with expires, second with params", function () { + const actual = splitSetCookieString(firstWithExpiresSecondWithParam); + const expected = [cookieWithExpires, cookieWithParams]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse first with expires at end, second without params", function () { + const actual = splitSetCookieString(firstWithExpiresAtEndSecondNoParam); + const expected = [cookieWithExpiresAtEnd, cookieNoParams]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse first with expires at end, second with params", function () { + const actual = splitSetCookieString(firstWithExpiresAtEndSecondWithParam); + const expected = [cookieWithExpiresAtEnd, cookieWithParams]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse first with expires, second with expires", function () { + const actual = splitSetCookieString(firstWithExpiresSecondWithExpires); + const expected = [cookieWithExpires, cookieWithExpires]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse first with expires, second with expires at end", function () { + const actual = splitSetCookieString(firstWithExpiresSecondWithExpiresAtEnd); + const expected = [cookieWithExpires, cookieWithExpiresAtEnd]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse first with expires at end, second with expires", function () { + const actual = splitSetCookieString(firstWithExpiresAtEndSecondWithExpires); + const expected = [cookieWithExpiresAtEnd, cookieWithExpires]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse first with expires at end, second with expires at end", function () { + const actual = splitSetCookieString( + firstWithExpiresAtEndSecondWithExpiresAtEnd, + ); + const expected = [cookieWithExpiresAtEnd, cookieWithExpiresAtEnd]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse first with expires, second with expires at end, third with expires", function () { + const actual = splitSetCookieString( + firstWithExpiresSecondWithExpiresAtEndThirdWithExpires, + ); + const expected = [ + cookieWithExpires, + cookieWithExpiresAtEnd, + cookieWithExpires, + ]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse first with expires, second with expires at end, third with expires at end", function () { + const actual = splitSetCookieString( + firstWithExpiresSecondWithExpiresAtEndThirdWithExpiresAtEnd, + ); + const expected = [ + cookieWithExpires, + cookieWithExpiresAtEnd, + cookieWithExpiresAtEnd, + ]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse three with expires", function () { + const actual = splitSetCookieString(threeWithExpires); + const expected = [cookieWithExpires, cookieWithExpires, cookieWithExpires]; + expect(actual).toStrictEqual(expected); + }); + + it("should parse three with expires at end", function () { + const actual = splitSetCookieString(threeWithExpiresAtEnd); + const expected = [ + cookieWithExpiresAtEnd, + cookieWithExpiresAtEnd, + cookieWithExpiresAtEnd, + ]; + expect(actual).toStrictEqual(expected); + }); + + it("should not split json", function () { + const actual = splitSetCookieString(jsonCookie); + const expected = [jsonCookie]; + expect(actual).toStrictEqual(expected); + }); + + it("should not split json with params", function () { + const actual = splitSetCookieString(jsonCookieWithParams); + const expected = [jsonCookieWithParams]; + expect(actual).toStrictEqual(expected); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..06c6a5e --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "Preserve", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "skipLibCheck": true, + "allowJs": true, + "checkJs": true, + "strict": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitOverride": true, + "noEmit": true + }, + "include": ["src", "test"] +}