From 2f5993b412032b043dacc3dcf3a3badd055c302c Mon Sep 17 00:00:00 2001 From: saripovdenis Date: Tue, 28 Apr 2026 17:19:58 +0800 Subject: [PATCH 01/12] perf: skip encoding safe cookie values --- src/index.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index a95a2e9..33753c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,6 +68,8 @@ const pathValueRegExp = /^[\u0020-\u003A\u003D-\u007E]*$/; */ const maxAgeRegExp = /^-?\d+$/; +const noEncodeRegExp = /^[\w.!~*'()-]*$/; + const __toString = Object.prototype.toString; const NullObject = /* @__PURE__ */ (() => { @@ -157,7 +159,7 @@ export function stringifyCookie( cookie: Cookies, options?: StringifyOptions, ): string { - const enc = options?.encode || encodeURIComponent; + const enc = options?.encode || encode; const keys = Object.keys(cookie); let str = ""; @@ -534,6 +536,13 @@ function decode(str: string): string { } } +/** + * URL-encode string value. Optimized to skip native call when no escaping is needed. + */ +function encode(str: string): string { + return noEncodeRegExp.test(str) ? str : encodeURIComponent(str); +} + /** * Determine if value is a Date. */ From 72ffbeb778f2dbaaac509fb520a49ff1454ed712 Mon Sep 17 00:00:00 2001 From: saripovdenis Date: Tue, 28 Apr 2026 22:35:15 +0800 Subject: [PATCH 02/12] test: verify default cookie encoding --- src/stringify-cookie.spec.ts | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/stringify-cookie.spec.ts b/src/stringify-cookie.spec.ts index 9edcfde..c0206d5 100644 --- a/src/stringify-cookie.spec.ts +++ b/src/stringify-cookie.spec.ts @@ -36,6 +36,33 @@ describe("cookie.stringifyCookie", () => { ); }); + it("should match encodeURIComponent for default encoding", () => { + const mismatches: string[] = []; + + for (let code = 0; code <= 0xffff; code++) { + if (code >= 0xd800 && code <= 0xdfff) continue; + + const value = String.fromCharCode(code); + const actual = stringifyCookie({ key: value }); + const expected = `key=${encodeURIComponent(value)}`; + + if (actual !== expected) { + mismatches.push(`${code}: ${actual} !== ${expected}`); + } + } + + for (const value of ["😄", "𝌆", "𠜎"]) { + const actual = stringifyCookie({ key: value }); + const expected = `key=${encodeURIComponent(value)}`; + + if (actual !== expected) { + mismatches.push(`${value}: ${actual} !== ${expected}`); + } + } + + expect(mismatches).toEqual([]); + }); + it("should error on invalid keys", () => { expect(() => stringifyCookie({ "test=": "" })).toThrow( /cookie name is invalid/, From b3cb3eff0362cf48c0531d8cd49e79d3d22aeed8 Mon Sep 17 00:00:00 2001 From: saripovdenis Date: Tue, 28 Apr 2026 23:05:10 +0800 Subject: [PATCH 03/12] bench: add encoded stringify cookie cases --- src/stringify-cookie.bench.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/stringify-cookie.bench.ts b/src/stringify-cookie.bench.ts index 9da3464..67d96a6 100644 --- a/src/stringify-cookie.bench.ts +++ b/src/stringify-cookie.bench.ts @@ -10,6 +10,10 @@ describe("cookie.stringifyCookie", () => { cookie.stringifyCookie({ foo: "bar" }); }); + bench("encode", () => { + cookie.stringifyCookie({ foo: "bar baz;%" }); + }); + bench("undefined values", () => { cookie.stringifyCookie({ foo: "bar", @@ -19,6 +23,14 @@ describe("cookie.stringifyCookie", () => { }); }); + bench("mixed encode", () => { + cookie.stringifyCookie({ + foo: "bar", + baz: "quux zap", + qux: "quux", + }); + }); + const cookies10 = genCookies(10); bench("10 cookies", () => { cookie.stringifyCookie(cookies10); From e113fdc988bfb1958fa964c2f0e0bb70c8bc1408 Mon Sep 17 00:00:00 2001 From: saripovdenis Date: Sun, 3 May 2026 00:56:26 +0800 Subject: [PATCH 04/12] perf: skip encoding valid cookie octets --- README.md | 6 +++--- src/index.ts | 14 +++++++++----- src/stringify-cookie.spec.ts | 26 ++++++++++++++++++++++---- src/stringify-set-cookie.spec.ts | 10 +++++++++- 4 files changed, 43 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 09cb444..2feb3c3 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ const cookieHeader = cookie.stringifyCookie({ a: "foo", b: "bar" }); #### Options -- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to [`encodeURIComponent`](#encode-and-decode). +- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to preserving valid cookie-octet values and using [`encodeURIComponent`](#encode-and-decode) otherwise. ### cookie.parseSetCookie(str, options) @@ -88,7 +88,7 @@ const setCookieHeader = cookie.stringifySetCookie({ #### Options -- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to [`encodeURIComponent`](#encode-and-decode). +- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to preserving valid cookie-octet values and using [`encodeURIComponent`](#encode-and-decode) otherwise. ## Cookie object @@ -178,7 +178,7 @@ More information about enforcement levels can be found in [the specification](ht Cookie accepts `encode` or `decode` options for processing a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Since the value of a cookie has a limited character set (and must be a simple string), these functions are used to transform values into strings suitable for a cookies value. -The default `encode` function is the global `encodeURIComponent`. +The default `encode` function preserves valid RFC 6265 cookie-octet values and uses the global `encodeURIComponent` otherwise. The default `decode` function is the global `decodeURIComponent`, wrapped in a `try..catch`. If an error is thrown it will return the cookie's original value. If you provide your own encode/decode diff --git a/src/index.ts b/src/index.ts index 33753c9..169953b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -68,7 +68,10 @@ const pathValueRegExp = /^[\u0020-\u003A\u003D-\u007E]*$/; */ const maxAgeRegExp = /^-?\d+$/; -const noEncodeRegExp = /^[\w.!~*'()-]*$/; +/** + * RegExp to match RFC 6265 cookie-octet values that need no URL encoding. + */ +const cookieOctetRegExp = /^[!#$%&'()*+\-.\/0-9:<=>?@A-Z[\]\^_`a-z{|}~]*$/; const __toString = Object.prototype.toString; @@ -146,8 +149,9 @@ export interface StringifyOptions { * Specifies a function that will be used to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). * Since value of a cookie has a limited character set (and must be a simple string), this function can be used to encode * a value into a string suited for a cookie's value, and should mirror `decode` when parsing. + * The default function preserves valid RFC 6265 cookie-octet values and uses `encodeURIComponent` otherwise. * - * @default encodeURIComponent + * @default encode */ encode?: (str: string) => string; } @@ -300,7 +304,7 @@ export function stringifySetCookie( ? _name : { ..._opts, name: _name, value: String(_val) }; const options = typeof _val === "object" ? _val : _opts; - const enc = options?.encode || encodeURIComponent; + const enc = options?.encode || encode; if (!cookieNameRegExp.test(cookie.name)) { throw new TypeError(`argument name is invalid: ${cookie.name}`); @@ -537,10 +541,10 @@ function decode(str: string): string { } /** - * URL-encode string value. Optimized to skip native call when no escaping is needed. + * URL-encode string value. Optimized to skip native call for RFC 6265 cookie-octet values. */ function encode(str: string): string { - return noEncodeRegExp.test(str) ? str : encodeURIComponent(str); + return cookieOctetRegExp.test(str) ? str : encodeURIComponent(str); } /** diff --git a/src/stringify-cookie.spec.ts b/src/stringify-cookie.spec.ts index c0206d5..11f89fa 100644 --- a/src/stringify-cookie.spec.ts +++ b/src/stringify-cookie.spec.ts @@ -28,15 +28,30 @@ describe("cookie.stringifyCookie", () => { expect(stringifyCookie({ a: "", b: "" })).toEqual("a=; b="); }); - it("should URL-encode values by default", () => { + it("should encode values with non-cookie-octet chars by default", () => { expect(stringifyCookie({ foo: "bar baz" })).toEqual("foo=bar%20baz"); - expect(stringifyCookie({ foo: "a=b" })).toEqual("foo=a%3Db"); expect(stringifyCookie({ foo: "hello;world" })).toEqual( "foo=hello%3Bworld", ); + expect(stringifyCookie({ foo: 'hello"world' })).toEqual( + "foo=hello%22world", + ); + expect(stringifyCookie({ foo: "foo,bar" })).toEqual("foo=foo%2Cbar"); + expect(stringifyCookie({ foo: "foo\\bar" })).toEqual("foo=foo%5Cbar"); }); - it("should match encodeURIComponent for default encoding", () => { + it("should pass through cookie-octet values by default", () => { + const value = + "!#$%&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + + "^_`abcdefghijklmnopqrstuvwxyz{|}~"; + + expect(stringifyCookie({ foo: value })).toEqual(`foo=${value}`); + }); + + it("should match cookie-octet default encoding", () => { + const cookieOctets = + "!#$%&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + + "^_`abcdefghijklmnopqrstuvwxyz{|}~"; const mismatches: string[] = []; for (let code = 0; code <= 0xffff; code++) { @@ -44,7 +59,10 @@ describe("cookie.stringifyCookie", () => { const value = String.fromCharCode(code); const actual = stringifyCookie({ key: value }); - const expected = `key=${encodeURIComponent(value)}`; + const encoded = cookieOctets.includes(value) + ? value + : encodeURIComponent(value); + const expected = `key=${encoded}`; if (actual !== expected) { mismatches.push(`${code}: ${actual} !== ${expected}`); diff --git a/src/stringify-set-cookie.spec.ts b/src/stringify-set-cookie.spec.ts index 92a56ea..3b718f2 100644 --- a/src/stringify-set-cookie.spec.ts +++ b/src/stringify-set-cookie.spec.ts @@ -10,12 +10,20 @@ describe("cookie.stringifySetCookie", function () { expect(cookie.stringifySetCookie("foo", "bar")).toEqual("foo=bar"); }); - it("should URL-encode value", function () { + it("should encode values with non-cookie-octet chars", function () { expect(cookie.stringifySetCookie("foo", "bar +baz")).toEqual( "foo=bar%20%2Bbaz", ); }); + it("should pass through cookie-octet values", function () { + const value = + "!#$%&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + + "^_`abcdefghijklmnopqrstuvwxyz{|}~"; + + expect(cookie.stringifySetCookie("foo", value)).toEqual(`foo=${value}`); + }); + it("should serialize empty value", function () { expect(cookie.stringifySetCookie("foo", "")).toEqual("foo="); }); From f7e1bc905e0a89fc6d4036a56de5191c1a0574e0 Mon Sep 17 00:00:00 2001 From: saripovdenis Date: Sun, 3 May 2026 01:06:34 +0800 Subject: [PATCH 05/12] bench: add RFC cookie-octet stringify case --- src/stringify-cookie.bench.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/stringify-cookie.bench.ts b/src/stringify-cookie.bench.ts index 67d96a6..a882aaf 100644 --- a/src/stringify-cookie.bench.ts +++ b/src/stringify-cookie.bench.ts @@ -10,6 +10,10 @@ describe("cookie.stringifyCookie", () => { cookie.stringifyCookie({ foo: "bar" }); }); + bench("rfc cookie-octets", () => { + cookie.stringifyCookie({ foo: "a=b+c/d?x%20" }); + }); + bench("encode", () => { cookie.stringifyCookie({ foo: "bar baz;%" }); }); From 911ec0f1fd9b40cd87a68faca29ef4cf90072c66 Mon Sep 17 00:00:00 2001 From: saripovdenis Date: Tue, 12 May 2026 01:01:52 +0800 Subject: [PATCH 06/12] fix: remove % from cookieOctetRegExp to preserve decode(encode(%20)) roundtrip --- README.md | 6 +++--- src/index.ts | 8 ++++---- src/stringify-cookie.bench.ts | 2 +- src/stringify-cookie.spec.ts | 19 +++++++++++++++---- src/stringify-set-cookie.spec.ts | 4 ++-- 5 files changed, 25 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 2feb3c3..1182c2a 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ const cookieHeader = cookie.stringifyCookie({ a: "foo", b: "bar" }); #### Options -- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to preserving valid cookie-octet values and using [`encodeURIComponent`](#encode-and-decode) otherwise. +- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to preserving roundtrip-safe cookie-octet values and using [`encodeURIComponent`](#encode-and-decode) otherwise. ### cookie.parseSetCookie(str, options) @@ -88,7 +88,7 @@ const setCookieHeader = cookie.stringifySetCookie({ #### Options -- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to preserving valid cookie-octet values and using [`encodeURIComponent`](#encode-and-decode) otherwise. +- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to preserving roundtrip-safe cookie-octet values and using [`encodeURIComponent`](#encode-and-decode) otherwise. ## Cookie object @@ -178,7 +178,7 @@ More information about enforcement levels can be found in [the specification](ht Cookie accepts `encode` or `decode` options for processing a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Since the value of a cookie has a limited character set (and must be a simple string), these functions are used to transform values into strings suitable for a cookies value. -The default `encode` function preserves valid RFC 6265 cookie-octet values and uses the global `encodeURIComponent` otherwise. +The default `encode` function preserves roundtrip-safe cookie-octet values and uses the global `encodeURIComponent` otherwise. The default `decode` function is the global `decodeURIComponent`, wrapped in a `try..catch`. If an error is thrown it will return the cookie's original value. If you provide your own encode/decode diff --git a/src/index.ts b/src/index.ts index 169953b..33ce704 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,9 +69,9 @@ const pathValueRegExp = /^[\u0020-\u003A\u003D-\u007E]*$/; const maxAgeRegExp = /^-?\d+$/; /** - * RegExp to match RFC 6265 cookie-octet values that need no URL encoding. + * RegExp to match RFC 6265 cookie-octet values (without % to preserve round-trip) that need no URL encoding. */ -const cookieOctetRegExp = /^[!#$%&'()*+\-.\/0-9:<=>?@A-Z[\]\^_`a-z{|}~]*$/; +const cookieOctetRegExp = /^[!#$&'()*+\-.\/0-9:<=>?@A-Z[\]\^_`a-z{|}~]*$/; const __toString = Object.prototype.toString; @@ -149,7 +149,7 @@ export interface StringifyOptions { * Specifies a function that will be used to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). * Since value of a cookie has a limited character set (and must be a simple string), this function can be used to encode * a value into a string suited for a cookie's value, and should mirror `decode` when parsing. - * The default function preserves valid RFC 6265 cookie-octet values and uses `encodeURIComponent` otherwise. + * The default function preserves roundtrip-safe cookie-octet values and uses `encodeURIComponent` otherwise. * * @default encode */ @@ -541,7 +541,7 @@ function decode(str: string): string { } /** - * URL-encode string value. Optimized to skip native call for RFC 6265 cookie-octet values. + * URL-encode string value. Optimized to skip native call for roundtrip-safe cookie-octet values. */ function encode(str: string): string { return cookieOctetRegExp.test(str) ? str : encodeURIComponent(str); diff --git a/src/stringify-cookie.bench.ts b/src/stringify-cookie.bench.ts index a882aaf..f078ab7 100644 --- a/src/stringify-cookie.bench.ts +++ b/src/stringify-cookie.bench.ts @@ -11,7 +11,7 @@ describe("cookie.stringifyCookie", () => { }); bench("rfc cookie-octets", () => { - cookie.stringifyCookie({ foo: "a=b+c/d?x%20" }); + cookie.stringifyCookie({ foo: "a=b+c/d?x~" }); }); bench("encode", () => { diff --git a/src/stringify-cookie.spec.ts b/src/stringify-cookie.spec.ts index 11f89fa..ac310f9 100644 --- a/src/stringify-cookie.spec.ts +++ b/src/stringify-cookie.spec.ts @@ -38,22 +38,25 @@ describe("cookie.stringifyCookie", () => { ); expect(stringifyCookie({ foo: "foo,bar" })).toEqual("foo=foo%2Cbar"); expect(stringifyCookie({ foo: "foo\\bar" })).toEqual("foo=foo%5Cbar"); + expect(stringifyCookie({ foo: "100%" })).toEqual("foo=100%25"); }); - it("should pass through cookie-octet values by default", () => { + it("should pass through roundtrip-safe cookie-octet values without encoding", () => { const value = - "!#$%&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + + "!#$&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + "^_`abcdefghijklmnopqrstuvwxyz{|}~"; expect(stringifyCookie({ foo: value })).toEqual(`foo=${value}`); }); - it("should match cookie-octet default encoding", () => { + it("should match default encoding for all valid BMP characters", () => { const cookieOctets = - "!#$%&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + + "!#$&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + "^_`abcdefghijklmnopqrstuvwxyz{|}~"; const mismatches: string[] = []; + // Check every valid BMP character. Skip surrogate code units because + // encodeURIComponent throws on unpaired surrogates. for (let code = 0; code <= 0xffff; code++) { if (code >= 0xd800 && code <= 0xdfff) continue; @@ -159,6 +162,14 @@ describe("cookie.stringifyCookie", () => { expect(parseCookie(str)).toEqual(cookies); }); + it("should roundtrip percent-encoded-looking values", () => { + const cookies = { foo: "%20" }; + const str = stringifyCookie(cookies); + + expect(str).toEqual("foo=%2520"); + expect(parseCookie(str)).toEqual(cookies); + }); + it("should roundtrip empty values", () => { const cookies = { a: "", b: "value" }; const str = stringifyCookie(cookies); diff --git a/src/stringify-set-cookie.spec.ts b/src/stringify-set-cookie.spec.ts index 3b718f2..b33c329 100644 --- a/src/stringify-set-cookie.spec.ts +++ b/src/stringify-set-cookie.spec.ts @@ -16,9 +16,9 @@ describe("cookie.stringifySetCookie", function () { ); }); - it("should pass through cookie-octet values", function () { + it("should pass through roundtrip-safe cookie-octet values without encoding", function () { const value = - "!#$%&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + + "!#$&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + "^_`abcdefghijklmnopqrstuvwxyz{|}~"; expect(cookie.stringifySetCookie("foo", value)).toEqual(`foo=${value}`); From d7b2a910bd0145947774005b04a6231aaf730ae3 Mon Sep 17 00:00:00 2001 From: saripovdenis Date: Tue, 12 May 2026 01:12:09 +0800 Subject: [PATCH 07/12] test: add % test to stringifySetCookie --- src/stringify-set-cookie.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/stringify-set-cookie.spec.ts b/src/stringify-set-cookie.spec.ts index b33c329..68d7d61 100644 --- a/src/stringify-set-cookie.spec.ts +++ b/src/stringify-set-cookie.spec.ts @@ -14,6 +14,7 @@ describe("cookie.stringifySetCookie", function () { expect(cookie.stringifySetCookie("foo", "bar +baz")).toEqual( "foo=bar%20%2Bbaz", ); + expect(cookie.stringifySetCookie("foo", "100%")).toEqual("foo=100%25"); }); it("should pass through roundtrip-safe cookie-octet values without encoding", function () { From 9d327b0df7ec3e92ff45a8881c3a7d468f7c12b4 Mon Sep 17 00:00:00 2001 From: saripovdenis Date: Fri, 15 May 2026 00:11:53 +0800 Subject: [PATCH 08/12] doc: revert stringifyCookie and stringifySetCookie readme --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1182c2a..74f4b29 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ const cookieHeader = cookie.stringifyCookie({ a: "foo", b: "bar" }); #### Options -- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to preserving roundtrip-safe cookie-octet values and using [`encodeURIComponent`](#encode-and-decode) otherwise. +- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to [`encodeURIComponent`](#encode-and-decode). ### cookie.parseSetCookie(str, options) @@ -88,7 +88,7 @@ const setCookieHeader = cookie.stringifySetCookie({ #### Options -- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to preserving roundtrip-safe cookie-octet values and using [`encodeURIComponent`](#encode-and-decode) otherwise. +- `encode` Specifies the function to encode a [cookie-value](https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.1). Defaults to [`encodeURIComponent`](#encode-and-decode). ## Cookie object From 17e7c75f59b8bbeb270ec05e26c059410418df4d Mon Sep 17 00:00:00 2001 From: saripovdenis Date: Sun, 17 May 2026 16:02:04 +0800 Subject: [PATCH 09/12] chore: clean up tests --- src/stringify-cookie.spec.ts | 83 ++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/src/stringify-cookie.spec.ts b/src/stringify-cookie.spec.ts index ac310f9..f372978 100644 --- a/src/stringify-cookie.spec.ts +++ b/src/stringify-cookie.spec.ts @@ -1,6 +1,38 @@ import { describe, expect, it } from "vitest"; import { stringifyCookie, parseCookie } from "./index.js"; +const roundtripSafeCookieOctets = + "!#$&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + + "^_`abcdefghijklmnopqrstuvwxyz{|}~"; + +const defaultBmpEncodingCases: Array<[string, string, string]> = []; + +for (let code = 0; code <= 0xffff; code++) { + // encodeURIComponent throws on unpaired surrogates. + if (code >= 0xd800 && code <= 0xdfff) continue; + + const value = String.fromCharCode(code); + const encoded = roundtripSafeCookieOctets.includes(value) + ? value + : encodeURIComponent(value); + + defaultBmpEncodingCases.push([ + `U+${code.toString(16).toUpperCase().padStart(4, "0")}`, + value, + `key=${encoded}`, + ]); +} + +const defaultAstralEncodingCases: Array<[string, string, string]> = []; + +for (const value of ["😄", "𝌆", "𠜎"]) { + defaultAstralEncodingCases.push([ + `U+${value.codePointAt(0)!.toString(16).toUpperCase()}`, + value, + `key=${encodeURIComponent(value)}`, + ]); +} + describe("cookie.stringifyCookie", () => { it("should stringify object", () => { expect(stringifyCookie({ key: "value" })).toEqual("key=value"); @@ -42,47 +74,24 @@ describe("cookie.stringifyCookie", () => { }); it("should pass through roundtrip-safe cookie-octet values without encoding", () => { - const value = - "!#$&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + - "^_`abcdefghijklmnopqrstuvwxyz{|}~"; + const value = roundtripSafeCookieOctets; expect(stringifyCookie({ foo: value })).toEqual(`foo=${value}`); }); - it("should match default encoding for all valid BMP characters", () => { - const cookieOctets = - "!#$&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + - "^_`abcdefghijklmnopqrstuvwxyz{|}~"; - const mismatches: string[] = []; - - // Check every valid BMP character. Skip surrogate code units because - // encodeURIComponent throws on unpaired surrogates. - for (let code = 0; code <= 0xffff; code++) { - if (code >= 0xd800 && code <= 0xdfff) continue; - - const value = String.fromCharCode(code); - const actual = stringifyCookie({ key: value }); - const encoded = cookieOctets.includes(value) - ? value - : encodeURIComponent(value); - const expected = `key=${encoded}`; - - if (actual !== expected) { - mismatches.push(`${code}: ${actual} !== ${expected}`); - } - } - - for (const value of ["😄", "𝌆", "𠜎"]) { - const actual = stringifyCookie({ key: value }); - const expected = `key=${encodeURIComponent(value)}`; - - if (actual !== expected) { - mismatches.push(`${value}: ${actual} !== ${expected}`); - } - } - - expect(mismatches).toEqual([]); - }); + it.each(defaultBmpEncodingCases)( + "should match default encoding for BMP char %s", + (_name, value, expected) => { + expect(stringifyCookie({ key: value })).toEqual(expected); + }, + ); + + it.each(defaultAstralEncodingCases)( + "should match default encoding for astral char %s", + (_name, value, expected) => { + expect(stringifyCookie({ key: value })).toEqual(expected); + }, + ); it("should error on invalid keys", () => { expect(() => stringifyCookie({ "test=": "" })).toThrow( From c07a99025bd3e87786362502ac31d05fd9fe6fa4 Mon Sep 17 00:00:00 2001 From: saripovdenis Date: Sun, 17 May 2026 16:19:55 +0800 Subject: [PATCH 10/12] chore: polish encode docs and tests --- src/index.ts | 10 ++++------ src/stringify-set-cookie.spec.ts | 8 +++++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/index.ts b/src/index.ts index 33ce704..b2f233e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,7 +69,7 @@ const pathValueRegExp = /^[\u0020-\u003A\u003D-\u007E]*$/; const maxAgeRegExp = /^-?\d+$/; /** - * RegExp to match RFC 6265 cookie-octet values (without % to preserve round-trip) that need no URL encoding. + * RegExp to match RFC 6265 cookie-octet values (without % to preserve roundtrip) that need no URL encoding. */ const cookieOctetRegExp = /^[!#$&'()*+\-.\/0-9:<=>?@A-Z[\]\^_`a-z{|}~]*$/; @@ -150,8 +150,6 @@ export interface StringifyOptions { * Since value of a cookie has a limited character set (and must be a simple string), this function can be used to encode * a value into a string suited for a cookie's value, and should mirror `decode` when parsing. * The default function preserves roundtrip-safe cookie-octet values and uses `encodeURIComponent` otherwise. - * - * @default encode */ encode?: (str: string) => string; } @@ -163,7 +161,7 @@ export function stringifyCookie( cookie: Cookies, options?: StringifyOptions, ): string { - const enc = options?.encode || encode; + const enc = options?.encode || defaultEncode; const keys = Object.keys(cookie); let str = ""; @@ -304,7 +302,7 @@ export function stringifySetCookie( ? _name : { ..._opts, name: _name, value: String(_val) }; const options = typeof _val === "object" ? _val : _opts; - const enc = options?.encode || encode; + const enc = options?.encode || defaultEncode; if (!cookieNameRegExp.test(cookie.name)) { throw new TypeError(`argument name is invalid: ${cookie.name}`); @@ -543,7 +541,7 @@ function decode(str: string): string { /** * URL-encode string value. Optimized to skip native call for roundtrip-safe cookie-octet values. */ -function encode(str: string): string { +function defaultEncode(str: string): string { return cookieOctetRegExp.test(str) ? str : encodeURIComponent(str); } diff --git a/src/stringify-set-cookie.spec.ts b/src/stringify-set-cookie.spec.ts index 68d7d61..04e5780 100644 --- a/src/stringify-set-cookie.spec.ts +++ b/src/stringify-set-cookie.spec.ts @@ -1,6 +1,10 @@ import { describe, it, expect } from "vitest"; import * as cookie from "./index.js"; +const roundtripSafeCookieOctets = + "!#$&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + + "^_`abcdefghijklmnopqrstuvwxyz{|}~"; + describe("cookie.stringifySetCookie", function () { it("should have backward compatible export", function () { expect(cookie.serialize).toBe(cookie.stringifySetCookie); @@ -18,9 +22,7 @@ describe("cookie.stringifySetCookie", function () { }); it("should pass through roundtrip-safe cookie-octet values without encoding", function () { - const value = - "!#$&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + - "^_`abcdefghijklmnopqrstuvwxyz{|}~"; + const value = roundtripSafeCookieOctets; expect(cookie.stringifySetCookie("foo", value)).toEqual(`foo=${value}`); }); From 6451a64bc40837c1e5ea0a4f808e480c94ddc83f Mon Sep 17 00:00:00 2001 From: saripovdenis Date: Sun, 17 May 2026 16:27:33 +0800 Subject: [PATCH 11/12] chore: rename test constants --- src/stringify-cookie.spec.ts | 18 +++++++++--------- src/stringify-set-cookie.spec.ts | 4 ++-- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/src/stringify-cookie.spec.ts b/src/stringify-cookie.spec.ts index f372978..84ca554 100644 --- a/src/stringify-cookie.spec.ts +++ b/src/stringify-cookie.spec.ts @@ -1,32 +1,32 @@ import { describe, expect, it } from "vitest"; import { stringifyCookie, parseCookie } from "./index.js"; -const roundtripSafeCookieOctets = +const cookieOctets = "!#$&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + "^_`abcdefghijklmnopqrstuvwxyz{|}~"; -const defaultBmpEncodingCases: Array<[string, string, string]> = []; +const bmpEncodingCases: Array<[string, string, string]> = []; for (let code = 0; code <= 0xffff; code++) { // encodeURIComponent throws on unpaired surrogates. if (code >= 0xd800 && code <= 0xdfff) continue; const value = String.fromCharCode(code); - const encoded = roundtripSafeCookieOctets.includes(value) + const encoded = cookieOctets.includes(value) ? value : encodeURIComponent(value); - defaultBmpEncodingCases.push([ + bmpEncodingCases.push([ `U+${code.toString(16).toUpperCase().padStart(4, "0")}`, value, `key=${encoded}`, ]); } -const defaultAstralEncodingCases: Array<[string, string, string]> = []; +const astralEncodingCases: Array<[string, string, string]> = []; for (const value of ["😄", "𝌆", "𠜎"]) { - defaultAstralEncodingCases.push([ + astralEncodingCases.push([ `U+${value.codePointAt(0)!.toString(16).toUpperCase()}`, value, `key=${encodeURIComponent(value)}`, @@ -74,19 +74,19 @@ describe("cookie.stringifyCookie", () => { }); it("should pass through roundtrip-safe cookie-octet values without encoding", () => { - const value = roundtripSafeCookieOctets; + const value = cookieOctets; expect(stringifyCookie({ foo: value })).toEqual(`foo=${value}`); }); - it.each(defaultBmpEncodingCases)( + it.each(bmpEncodingCases)( "should match default encoding for BMP char %s", (_name, value, expected) => { expect(stringifyCookie({ key: value })).toEqual(expected); }, ); - it.each(defaultAstralEncodingCases)( + it.each(astralEncodingCases)( "should match default encoding for astral char %s", (_name, value, expected) => { expect(stringifyCookie({ key: value })).toEqual(expected); diff --git a/src/stringify-set-cookie.spec.ts b/src/stringify-set-cookie.spec.ts index 04e5780..e9c2983 100644 --- a/src/stringify-set-cookie.spec.ts +++ b/src/stringify-set-cookie.spec.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; import * as cookie from "./index.js"; -const roundtripSafeCookieOctets = +const cookieOctets = "!#$&'()*+-./0123456789:<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]" + "^_`abcdefghijklmnopqrstuvwxyz{|}~"; @@ -22,7 +22,7 @@ describe("cookie.stringifySetCookie", function () { }); it("should pass through roundtrip-safe cookie-octet values without encoding", function () { - const value = roundtripSafeCookieOctets; + const value = cookieOctets; expect(cookie.stringifySetCookie("foo", value)).toEqual(`foo=${value}`); }); From 4a41e824c74706b39c0b8c5acbcdb6d94de49ad9 Mon Sep 17 00:00:00 2001 From: saripovdenis Date: Sun, 17 May 2026 16:43:09 +0800 Subject: [PATCH 12/12] chore: simplify astral encoding cases --- src/stringify-cookie.spec.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/stringify-cookie.spec.ts b/src/stringify-cookie.spec.ts index 84ca554..0e4c634 100644 --- a/src/stringify-cookie.spec.ts +++ b/src/stringify-cookie.spec.ts @@ -23,15 +23,15 @@ for (let code = 0; code <= 0xffff; code++) { ]); } -const astralEncodingCases: Array<[string, string, string]> = []; - -for (const value of ["😄", "𝌆", "𠜎"]) { - astralEncodingCases.push([ - `U+${value.codePointAt(0)!.toString(16).toUpperCase()}`, - value, - `key=${encodeURIComponent(value)}`, - ]); -} +const astralEncodingCases: Array<[string, string, string]> = [ + "😄", + "𝌆", + "𠜎", +].map((value) => [ + `U+${value.codePointAt(0)!.toString(16).toUpperCase()}`, + value, + `key=${encodeURIComponent(value)}`, +]); describe("cookie.stringifyCookie", () => { it("should stringify object", () => {