From 2f5993b412032b043dacc3dcf3a3badd055c302c Mon Sep 17 00:00:00 2001 From: saripovdenis Date: Tue, 28 Apr 2026 17:19:58 +0800 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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;%" }); });