Skip to content

Commit 61b395e

Browse files
DavidDeSlooverepi0
andauthored
feat: support chunked cookies and use for session (#1102)
Co-authored-by: Pooya Parsa <[email protected]>
1 parent 55a2c9b commit 61b395e

File tree

5 files changed

+262
-6
lines changed

5 files changed

+262
-6
lines changed

docs/2.utils/3.cookie.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,18 @@ icon: material-symbols:cookie-outline
88
99
<!-- automd:jsdocs src="../../src/utils/cookie.ts" -->
1010

11+
### `deleteChunkedCookie(event, name, serializeOptions?)`
12+
13+
Remove a set of chunked cookies by name.
14+
1115
### `deleteCookie(event, name, serializeOptions?)`
1216

1317
Remove a cookie by name.
1418

19+
### `getChunkedCookie(event, name)`
20+
21+
Get a chunked cookie value by name. Will join chunks together.
22+
1523
### `getCookie(event, name)`
1624

1725
Get a cookie value by name.
@@ -20,6 +28,10 @@ Get a cookie value by name.
2028

2129
Parse the request to get HTTP Cookie header string and returning an object of all cookie name-value pairs.
2230

31+
### `setChunkedCookie(event, name, value, options?)`
32+
33+
Set a cookie value by name. Chunked cookies will be created as needed.
34+
2335
### `setCookie(event, name, value, options?)`
2436

2537
Set a cookie value by name.

src/utils/cookie.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import {
66
parseSetCookie,
77
} from "cookie-es";
88

9+
const CHUNKED_COOKIE = "__chunked__";
10+
11+
// The limit is approximately 4KB, but may vary by browser and server. We leave some room to be safe.
12+
const CHUNKS_MAX_LENGTH = 4000;
13+
914
/**
1015
* Parse the request to get HTTP Cookie header string and returning an object of all cookie name-value pairs.
1116
* @param event {HTTPEvent} H3 event or req passed by h3 handler
@@ -96,6 +101,114 @@ export function deleteCookie(
96101
});
97102
}
98103

104+
/**
105+
* Get a chunked cookie value by name. Will join chunks together.
106+
* @param event {HTTPEvent} { req: Request }
107+
* @param name Name of the cookie to get
108+
* @returns {*} Value of the cookie (String or undefined)
109+
* ```ts
110+
* const authorization = getCookie(request, 'Session')
111+
* ```
112+
*/
113+
export function getChunkedCookie(
114+
event: HTTPEvent,
115+
name: string,
116+
): string | undefined {
117+
const mainCookie = getCookie(event, name);
118+
if (!mainCookie || !mainCookie.startsWith(CHUNKED_COOKIE)) {
119+
return mainCookie;
120+
}
121+
122+
const chunksCount = getChunkedCookieCount(mainCookie);
123+
if (chunksCount === 0) {
124+
return undefined;
125+
}
126+
127+
const chunks = [];
128+
for (let i = 1; i <= chunksCount; i++) {
129+
const chunk = getCookie(event, chunkCookieName(name, i));
130+
if (!chunk) {
131+
return undefined;
132+
}
133+
chunks.push(chunk);
134+
}
135+
136+
return chunks.join("");
137+
}
138+
139+
/**
140+
* Set a cookie value by name. Chunked cookies will be created as needed.
141+
* @param event {H3Event} H3 event or res passed by h3 handler
142+
* @param name Name of the cookie to set
143+
* @param value Value of the cookie to set
144+
* @param options {CookieSerializeOptions} Options for serializing the cookie
145+
* ```ts
146+
* setCookie(res, 'Session', '<session data>')
147+
* ```
148+
*/
149+
export function setChunkedCookie(
150+
event: H3Event,
151+
name: string,
152+
value: string,
153+
options?: CookieSerializeOptions & { chunkMaxLength?: number },
154+
): void {
155+
const chunkMaxLength = options?.chunkMaxLength || CHUNKS_MAX_LENGTH;
156+
const chunkCount = Math.ceil(value.length / chunkMaxLength);
157+
158+
// delete any prior left over chunks if the cookie is updated
159+
const previousCookie = getCookie(event, name);
160+
if (previousCookie?.startsWith(CHUNKED_COOKIE)) {
161+
const previousChunkCount = getChunkedCookieCount(previousCookie);
162+
if (previousChunkCount > chunkCount) {
163+
for (let i = chunkCount; i <= previousChunkCount; i++) {
164+
deleteCookie(event, chunkCookieName(name, i), options);
165+
}
166+
}
167+
}
168+
169+
if (chunkCount <= 1) {
170+
// If the value is small enough, just set it as a normal cookie
171+
setCookie(event, name, value, options);
172+
return;
173+
}
174+
175+
// If the value is too large, we need to chunk it
176+
const mainCookieValue = `${CHUNKED_COOKIE}${chunkCount}`;
177+
setCookie(event, name, mainCookieValue, options);
178+
179+
for (let i = 1; i <= chunkCount; i++) {
180+
const start = (i - 1) * chunkMaxLength;
181+
const end = start + chunkMaxLength;
182+
const chunkValue = value.slice(start, end);
183+
setCookie(event, chunkCookieName(name, i), chunkValue, options);
184+
}
185+
}
186+
187+
/**
188+
* Remove a set of chunked cookies by name.
189+
* @param event {H3Event} H3 event or res passed by h3 handler
190+
* @param name Name of the cookie to delete
191+
* @param serializeOptions {CookieSerializeOptions} Cookie options
192+
* ```ts
193+
* deleteCookie(res, 'Session')
194+
* ```
195+
*/
196+
export function deleteChunkedCookie(
197+
event: H3Event,
198+
name: string,
199+
serializeOptions?: CookieSerializeOptions,
200+
): void {
201+
const mainCookie = getCookie(event, name);
202+
deleteCookie(event, name, serializeOptions);
203+
204+
const chunksCount = getChunkedCookieCount(mainCookie);
205+
if (chunksCount >= 0) {
206+
for (let i = 0; i < chunksCount; i++) {
207+
deleteCookie(event, chunkCookieName(name, i + 1), serializeOptions);
208+
}
209+
}
210+
}
211+
99212
/**
100213
* Cookies are unique by "cookie-name, domain-value, and path-value".
101214
*
@@ -104,3 +217,14 @@ export function deleteCookie(
104217
function _getDistinctCookieKey(name: string, options: Partial<SetCookie>) {
105218
return [name, options.domain || "", options.path || "/"].join(";");
106219
}
220+
221+
function getChunkedCookieCount(cookie: string | undefined): number {
222+
if (!cookie?.startsWith(CHUNKED_COOKIE)) {
223+
return Number.NaN;
224+
}
225+
return Number.parseInt(cookie.slice(CHUNKED_COOKIE.length));
226+
}
227+
228+
function chunkCookieName(name: string, chunkNumber: number): string {
229+
return `${name}.${chunkNumber}`;
230+
}

src/utils/session.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
unseal,
44
defaults as sealDefaults,
55
} from "./internal/iron-crypto.ts";
6-
import { getCookie, setCookie } from "./cookie.ts";
6+
import { getChunkedCookie, setChunkedCookie } from "./cookie.ts";
77
import {
88
DEFAULT_SESSION_NAME,
99
DEFAULT_SESSION_COOKIE,
@@ -43,7 +43,7 @@ export interface SessionConfig {
4343
/** default is h3 */
4444
name?: string;
4545
/** Default is secure, httpOnly, / */
46-
cookie?: false | CookieSerializeOptions;
46+
cookie?: false | (CookieSerializeOptions & { chunkMaxLength?: number });
4747
/** Default is x-h3-session / x-{name}-session */
4848
sessionHeader?: false | string;
4949
seal?: SealOptions;
@@ -128,7 +128,7 @@ export async function getSession<T extends SessionData = SessionData>(
128128
}
129129
// Fallback to cookies
130130
if (!sealedSession) {
131-
sealedSession = getCookie(event, sessionName);
131+
sealedSession = getChunkedCookie(event, sessionName);
132132
}
133133
if (sealedSession) {
134134
// Unseal session data from cookie
@@ -185,7 +185,7 @@ export async function updateSession<T extends SessionData = SessionData>(
185185
// Seal and store in cookie
186186
if (config.cookie !== false && (event as H3Event).res) {
187187
const sealed = await sealSession(event, config);
188-
setCookie(event as H3Event, sessionName, sealed, {
188+
setChunkedCookie(event as H3Event, sessionName, sealed, {
189189
...DEFAULT_SESSION_COOKIE,
190190
expires: config.maxAge
191191
? new Date(session.createdAt + config.maxAge * 1000)
@@ -256,7 +256,7 @@ export function clearSession(
256256
delete context.sessions![sessionName];
257257
}
258258
if ((event as H3Event).res && config.cookie !== false) {
259-
setCookie(event as H3Event, sessionName, "", {
259+
setChunkedCookie(event as H3Event, sessionName, "", {
260260
...DEFAULT_SESSION_COOKIE,
261261
...config.cookie,
262262
});

test/cookies.test.ts

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { getCookie, parseCookies, setCookie } from "../src/utils/cookie.ts";
1+
import {
2+
getCookie,
3+
parseCookies,
4+
setCookie,
5+
getChunkedCookie,
6+
setChunkedCookie,
7+
} from "../src/utils/cookie.ts";
28
import { describeMatrix } from "./_setup.ts";
39

410
describeMatrix("cookies", (t, { it, expect, describe }) => {
@@ -106,4 +112,99 @@ describeMatrix("cookies", (t, { it, expect, describe }) => {
106112
]);
107113
expect(await result.text()).toBe("200");
108114
});
115+
116+
describeMatrix("chunked", (t, { it, expect, describe }) => {
117+
const CHUNKED_COOKIE = "__chunked__";
118+
119+
describe("getChunkedCookie", () => {
120+
it("can parse cookie that is chunked", async () => {
121+
t.app.get("/", (event) => {
122+
const authorization = getChunkedCookie(event, "Authorization");
123+
expect(authorization).toEqual("123456789");
124+
return "200";
125+
});
126+
127+
const result = await t.fetch("/", {
128+
headers: {
129+
Cookie: [
130+
`Authorization=${CHUNKED_COOKIE}3`,
131+
"Authorization.1=123",
132+
"Authorization.2=456",
133+
"Authorization.3=789",
134+
].join("; "),
135+
},
136+
});
137+
138+
expect(await result.text()).toBe("200");
139+
});
140+
141+
it("can parse cookie that is not chunked", async () => {
142+
t.app.get("/", (event) => {
143+
const authorization = getChunkedCookie(event, "Authorization");
144+
expect(authorization).toEqual("not-chunked");
145+
return "200";
146+
});
147+
148+
const result = await t.fetch("/", {
149+
headers: {
150+
Cookie: ["Authorization=not-chunked"].join("; "),
151+
},
152+
});
153+
154+
expect(await result.text()).toBe("200");
155+
});
156+
});
157+
158+
describe("setChunkedCookie", () => {
159+
it("can set-cookie with setChunkedCookie", async () => {
160+
t.app.get("/", (event) => {
161+
setChunkedCookie(event, "Authorization", "1234567890ABCDEFGHIJXYZ", {
162+
chunkMaxLength: 10,
163+
});
164+
return "200";
165+
});
166+
const result = await t.fetch("/");
167+
expect(result.headers.getSetCookie()).toMatchInlineSnapshot(`
168+
[
169+
"Authorization=__chunked__3; Path=/",
170+
"Authorization.1=1234567890; Path=/",
171+
"Authorization.2=ABCDEFGHIJ; Path=/",
172+
"Authorization.3=XYZ; Path=/",
173+
]
174+
`);
175+
expect(await result.text()).toBe("200");
176+
});
177+
178+
it("smaller set-cookie removes superfluous chunks", async () => {
179+
// set smaller cookie with fewer chunks, should have deleted superfluous chunks
180+
t.app.get("/", (event) => {
181+
setChunkedCookie(event, "Authorization", "0000100002", {
182+
chunkMaxLength: 5,
183+
});
184+
return "200";
185+
});
186+
const result = await t.fetch("/", {
187+
headers: {
188+
Cookie: [
189+
`Authorization=${CHUNKED_COOKIE}4; Path=/`,
190+
"Authorization.1=00001; Path=/",
191+
"Authorization.2=00002; Path=/",
192+
"Authorization.3=00003; Path=/",
193+
"Authorization.4=00004; Path=/",
194+
].join("; "),
195+
},
196+
});
197+
expect(result.headers.getSetCookie()).toMatchInlineSnapshot(`
198+
[
199+
"Authorization.3=; Max-Age=0; Path=/",
200+
"Authorization.4=; Max-Age=0; Path=/",
201+
"Authorization=__chunked__2; Path=/",
202+
"Authorization.1=00001; Path=/",
203+
"Authorization.2=00002; Path=/",
204+
]
205+
`);
206+
expect(await result.text()).toBe("200");
207+
});
208+
});
209+
});
109210
});

test/session.test.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,4 +81,23 @@ describeMatrix("session", (t, { it, expect }) => {
8181
sessions: [1, 2, 3].map(() => ({ id: "1", data: { foo: "bar" } })),
8282
});
8383
});
84+
85+
it("stores large data in chunks", async () => {
86+
const token = Array.from({ length: 5000 /* ~4k + one more */ })
87+
.fill("x")
88+
.join("");
89+
const res = await t.fetch("/", {
90+
method: "POST",
91+
headers: { Cookie: cookie },
92+
body: JSON.stringify({ token }),
93+
});
94+
95+
const cookies = res.headers.getSetCookie();
96+
const cookieNames = cookies.map((c) => c.split("=")[0]);
97+
expect(cookieNames.length).toBe(3 /* head + 2 */);
98+
expect(cookieNames).toMatchObject(["h3-test", "h3-test.1", "h3-test.2"]);
99+
100+
const body = await res.json();
101+
expect(body.session.data.token).toBe(token);
102+
});
84103
});

0 commit comments

Comments
 (0)