diff --git a/README.md b/README.md index efe7aded..e0af567a 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,26 @@ async function Profile() { } ``` +**For large session data (>4KB), enable cookie chunking:** + +```ts +import { getIronSession } from 'iron-session'; + +export async function POST(req, res) { + const session = await getIronSession(req, res, { + password: "...", + cookieName: "...", + chunking: { + enabled: true, + chunkSize: 3500 // optional + } + }); + + session.largeData = { /* your large session data */ }; + await session.save(); // Automatically splits into multiple cookies if needed +} +``` + ## Examples We have many different patterns and examples on the online demo, have a look: https://get-iron-session.vercel.app/. @@ -130,6 +150,22 @@ Two options are required: `password` and `cookieName`. Everything else is automa - `password`, **required**: Private key used to encrypt the cookie. It has to be at least 32 characters long. Use to generate strong passwords. `password` can be either a `string` or an `object` with incrementing keys like this: `{2: "...", 1: "..."}` to allow for password rotation. iron-session will use the highest numbered key for new cookies. - `cookieName`, **required**: Name of the cookie to be stored - `ttl`, _optional_: In seconds. Default to the equivalent of 14 days. You can set this to `0` and iron-session will compute the maximum allowed value by cookies. +- `chunking`, _optional_: Enable cookie chunking for large session data that exceeds browser cookie size limits (~4KB). When enabled, the session cookie is automatically split into multiple smaller cookies. Default to `undefined` (disabled). Options: + - `enabled`: Boolean to enable/disable chunking + - `chunkSize`: Maximum size of each chunk in bytes. Default to `3500` + + Example: + ```js + { + chunking: { + enabled: true, + chunkSize: 3500 // optional, defaults to 3500 + } + } + ``` + + When chunking is enabled and the session data exceeds the chunk size, cookies are stored as `{cookieName}.0`, `{cookieName}.1`, etc. The reconstruction happens automatically on read. Old chunks are cleaned up when the session is saved or destroyed. + - `cookieOptions`, _optional_: Any option available from [jshttp/cookie#serialize](https://github.com/jshttp/cookie#cookieserializename-value-options) except for `encode` which is not a Set-Cookie Attribute. See [Mozilla Set-Cookie Attributes](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#attributes) and [Chrome Cookie Fields](https://developer.chrome.com/docs/devtools/application/cookies/#fields). Default to: ```js @@ -220,6 +256,14 @@ Not so much: Depending on your own needs and preferences, `iron-session` may or may not fit you. +### What if my session data exceeds cookie size limits? + +Browsers typically limit cookies to around 4KB. If your session data is larger, you have two options: + +1. **Enable cookie chunking** (recommended for moderate sizes): Set `chunking: { enabled: true }` in your session options. This will automatically split large cookies into multiple smaller cookies (e.g., `session.0`, `session.1`, etc.) and reconstruct them transparently when reading. + +2. **Store large data elsewhere**: For very large session data, consider storing it in a database or cache (like Redis) and only keep a session ID in the cookie. + ## Credits - [Eran Hammer and hapi.js contributors](https://github.com/hapijs/iron/graphs/contributors) diff --git a/src/core.ts b/src/core.ts index 91902ec5..142cb77a 100644 --- a/src/core.ts +++ b/src/core.ts @@ -96,6 +96,33 @@ export interface SessionOptions { * @see https://github.com/jshttp/cookie#options-1 */ cookieOptions?: CookieOptions; + + /** + * Configure cookie chunking for large sessions that exceed browser cookie size limits. + * + * When enabled, iron-session will automatically split large cookies into multiple + * smaller chunks (named `{cookieName}.0`, `{cookieName}.1`, etc.) and reassemble + * them when reading. This allows sessions larger than the typical 4096-byte browser + * limit. + * + * @example { enabled: true, chunkSize: 3500 } + */ + chunking?: { + /** + * Enable cookie chunking. When `false` or `undefined`, the traditional 4096-byte + * limit check will be enforced. + * + * @default false + */ + enabled: boolean; + /** + * Maximum size in bytes for each cookie chunk. Should be less than 4096 to allow + * room for cookie name and attributes. + * + * @default 3500 + */ + chunkSize?: number; + }; } export type IronSession = T & { @@ -199,6 +226,89 @@ function setCookie(res: ResponseType, cookieValue: string): void { ]); } +/** + * Splits a large cookie value into multiple chunks for browsers that enforce + * size limits. Each chunk is named `{baseName}.{index}`. + */ +function splitCookieIntoChunks( + value: string, + chunkSize: number, +): string[] { + const chunks: string[] = []; + for (let i = 0; i < value.length; i += chunkSize) { + chunks.push(value.slice(i, i + chunkSize)); + } + return chunks; +} + +/** + * Reconstructs a cookie value from chunks. Tries direct cookie first, + * then attempts to reassemble from indexed chunks if not found. + * Works transparently for both chunked and non-chunked cookies. + */ +function reconstructCookie( + req: RequestType, + cookieName: string, +): string { + // Try direct cookie first (for non-chunked or old sessions) + const directCookie = getCookie(req, cookieName); + if (directCookie) { + return directCookie; + } + + // Try to reconstruct from chunks + const chunks: string[] = []; + let chunkIndex = 0; + + while (true) { + const chunkName = `${cookieName}.${chunkIndex}`; + const chunk = getCookie(req, chunkName); + + if (!chunk) { + break; + } + + chunks.push(chunk); + chunkIndex++; + } + + return chunks.join(""); +} + +/** + * Reconstructs a cookie value from chunks using CookieStore. + * For use with Next.js cookies() and similar APIs. + * Works transparently for both chunked and non-chunked cookies. + */ +function reconstructCookieWithStore( + cookieHandler: CookieStore, + cookieName: string, +): string { + // Try direct cookie first + const directCookie = getServerActionCookie(cookieName, cookieHandler); + if (directCookie) { + return directCookie; + } + + // Try to reconstruct from chunks + const chunks: string[] = []; + let chunkIndex = 0; + + while (true) { + const chunkName = `${cookieName}.${chunkIndex}`; + const chunk = getServerActionCookie(chunkName, cookieHandler); + + if (!chunk) { + break; + } + + chunks.push(chunk); + chunkIndex++; + } + + return chunks.join(""); +} + export function createSealData(_crypto: Crypto) { return async function sealData( data: unknown, @@ -271,7 +381,7 @@ export function createUnsealData(_crypto: Crypto) { function getSessionConfig( sessionOptions: SessionOptions, -): Required { +): Required> & Pick { const options = { ...defaultOptions, ...sessionOptions, @@ -361,7 +471,7 @@ export function createGetIronSession( let sessionConfig = getSessionConfig(sessionOptions); - const sealFromCookies = getCookie(req, sessionConfig.cookieName); + const sealFromCookies = reconstructCookie(req, sessionConfig.cookieName); const session = sealFromCookies ? await unsealData(sealFromCookies, { password: passwordsMap, @@ -387,19 +497,72 @@ export function createGetIronSession( password: passwordsMap, ttl: sessionConfig.ttl, }); - const cookieValue = serialize( - sessionConfig.cookieName, - seal, - sessionConfig.cookieOptions, - ); - if (cookieValue.length > 4096) { - throw new Error( - `iron-session: Cookie length is too big (${cookieValue.length} bytes), browsers will refuse it. Try to remove some data.`, + // Check if chunking is enabled + if (sessionConfig.chunking?.enabled) { + const chunkSize = sessionConfig.chunking.chunkSize ?? 3500; + + // Clean up old chunks first + let oldChunkIndex = 0; + while (true) { + const oldChunkName = `${sessionConfig.cookieName}.${oldChunkIndex}`; + const oldChunk = getCookie(req, oldChunkName); + if (!oldChunk) break; + + const cleanupCookie = serialize(oldChunkName, "", { + ...sessionConfig.cookieOptions, + maxAge: 0, + }); + setCookie(res, cleanupCookie); + oldChunkIndex++; + } + + // If seal is small enough, use single cookie + const testCookieValue = serialize( + sessionConfig.cookieName, + seal, + sessionConfig.cookieOptions, ); - } - setCookie(res, cookieValue); + if (testCookieValue.length <= 4096) { + setCookie(res, testCookieValue); + return; + } + + // Split into chunks + const chunks = splitCookieIntoChunks(seal, chunkSize); + chunks.forEach((chunk, index) => { + const chunkName = `${sessionConfig.cookieName}.${index}`; + const chunkCookieValue = serialize( + chunkName, + chunk, + sessionConfig.cookieOptions, + ); + setCookie(res, chunkCookieValue); + }); + + // Delete the main cookie if it exists (we're using chunks now) + const deleteCookie = serialize(sessionConfig.cookieName, "", { + ...sessionConfig.cookieOptions, + maxAge: 0, + }); + setCookie(res, deleteCookie); + } else { + // Original behavior - no chunking + const cookieValue = serialize( + sessionConfig.cookieName, + seal, + sessionConfig.cookieOptions, + ); + + if (cookieValue.length > 4096) { + throw new Error( + `iron-session: Cookie length is too big (${cookieValue.length} bytes), browsers will refuse it. Try to remove some data.`, + ); + } + + setCookie(res, cookieValue); + } }, }, @@ -408,12 +571,30 @@ export function createGetIronSession( Object.keys(session).forEach((key) => { delete (session as Record)[key]; }); + + // Delete main cookie const cookieValue = serialize(sessionConfig.cookieName, "", { ...sessionConfig.cookieOptions, maxAge: 0, }); - setCookie(res, cookieValue); + + // Also delete chunks if chunking was enabled + if (sessionConfig.chunking?.enabled) { + let chunkIndex = 0; + while (true) { + const chunkName = `${sessionConfig.cookieName}.${chunkIndex}`; + const chunk = getCookie(req, chunkName); + if (!chunk) break; + + const chunkCookieValue = serialize(chunkName, "", { + ...sessionConfig.cookieOptions, + maxAge: 0, + }); + setCookie(res, chunkCookieValue); + chunkIndex++; + } + } }, }, }); @@ -445,10 +626,8 @@ async function getIronSessionFromCookieStore( } let sessionConfig = getSessionConfig(sessionOptions); - const sealFromCookies = getServerActionCookie( - sessionConfig.cookieName, - cookieStore, - ); + + const sealFromCookies = reconstructCookieWithStore(cookieStore, sessionConfig.cookieName); const session = sealFromCookies ? await unsealData(sealFromCookies, { password: passwordsMap, @@ -469,22 +648,70 @@ async function getIronSessionFromCookieStore( ttl: sessionConfig.ttl, }); - const cookieLength = - sessionConfig.cookieName.length + - seal.length + - JSON.stringify(sessionConfig.cookieOptions).length; + // Check if chunking is enabled + if (sessionConfig.chunking?.enabled) { + const chunkSize = sessionConfig.chunking.chunkSize ?? 3500; + + // Clean up old chunks first + let oldChunkIndex = 0; + while (true) { + const oldChunkName = `${sessionConfig.cookieName}.${oldChunkIndex}`; + const oldChunk = getServerActionCookie(oldChunkName, cookieStore); + if (!oldChunk) break; + + cookieStore.set(oldChunkName, "", { + ...sessionConfig.cookieOptions, + maxAge: 0, + }); + oldChunkIndex++; + } - if (cookieLength > 4096) { - throw new Error( - `iron-session: Cookie length is too big (${cookieLength} bytes), browsers will refuse it. Try to remove some data.`, + // If seal is small enough, use single cookie + const cookieLength = + sessionConfig.cookieName.length + + seal.length + + JSON.stringify(sessionConfig.cookieOptions).length; + + if (cookieLength <= 4096) { + cookieStore.set( + sessionConfig.cookieName, + seal, + sessionConfig.cookieOptions, + ); + return; + } + + // Split into chunks + const chunks = splitCookieIntoChunks(seal, chunkSize); + chunks.forEach((chunk, index) => { + const chunkName = `${sessionConfig.cookieName}.${index}`; + cookieStore.set(chunkName, chunk, sessionConfig.cookieOptions); + }); + + // Delete the main cookie if it exists (we're using chunks now) + cookieStore.set(sessionConfig.cookieName, "", { + ...sessionConfig.cookieOptions, + maxAge: 0, + }); + } else { + // Original behavior - no chunking + const cookieLength = + sessionConfig.cookieName.length + + seal.length + + JSON.stringify(sessionConfig.cookieOptions).length; + + if (cookieLength > 4096) { + throw new Error( + `iron-session: Cookie length is too big (${cookieLength} bytes), browsers will refuse it. Try to remove some data.`, + ); + } + + cookieStore.set( + sessionConfig.cookieName, + seal, + sessionConfig.cookieOptions, ); } - - cookieStore.set( - sessionConfig.cookieName, - seal, - sessionConfig.cookieOptions, - ); }, }, @@ -494,8 +721,22 @@ async function getIronSessionFromCookieStore( delete (session as Record)[key]; }); + // Delete main cookie const cookieOptions = { ...sessionConfig.cookieOptions, maxAge: 0 }; cookieStore.set(sessionConfig.cookieName, "", cookieOptions); + + // Also delete chunks if chunking was enabled + if (sessionConfig.chunking?.enabled) { + let chunkIndex = 0; + while (true) { + const chunkName = `${sessionConfig.cookieName}.${chunkIndex}`; + const chunk = getServerActionCookie(chunkName, cookieStore); + if (!chunk) break; + + cookieStore.set(chunkName, "", cookieOptions); + chunkIndex++; + } + } }, }, }); diff --git a/src/index.test.ts b/src/index.test.ts index a91baccd..7795f45f 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -500,3 +500,224 @@ await test("should work with standard web Request/Response APIs", async () => { session = await getSession(req, res, { cookieName, password }); deepEqual(session, { user: { id: 1 } }); }); + +const collectAllCookies = (res: { setHeader: { mock: { calls: Array<{ arguments: [string, string[]] }> } } }) => { + const allCookies: string[] = []; + for (const call of res.setHeader.mock.calls) { + const [, cookies] = call.arguments; + if (Array.isArray(cookies)) { + allCookies.push(...cookies); + } + } + return allCookies; +}; + +await test("should enable chunking when configured", async () => { + const res = { getHeader: mock.fn(() => []), setHeader: mock.fn() }; + + // Create a large session (> 4096 bytes) to trigger chunking + const largeData = "x".repeat(5000); + const session = await getSession( + { headers: {} } as Request, + res as unknown as ServerResponse, + { + cookieName, + password, + chunking: { enabled: true }, + }, + ); + session.user = { id: 1, meta: largeData }; + await session.save(); + + const allCookies = collectAllCookies(res as any); + + // Should create multiple chunked cookies (at least 2 chunks + 1 delete for main cookie) + equal(allCookies.length >= 2, true); + // Check chunk naming pattern + const chunkCookies = allCookies.filter(c => /^test\.\d+=/.test(c)); + equal(chunkCookies.length >= 2, true); + match(chunkCookies[0]!, /^test\.0=/); + match(chunkCookies[1]!, /^test\.1=/); + + mock.reset(); +}); + +await test("should reconstruct session from chunked cookies", async () => { + const res = { getHeader: mock.fn(() => []), setHeader: mock.fn() }; + const largeData = "y".repeat(5000); + let session = await getSession( + { headers: {} } as Request, + res as unknown as ServerResponse, + { + cookieName, + password, + chunking: { enabled: true }, + }, + ); + session.user = { id: 2, meta: largeData }; + await session.save(); + + const allCookies = collectAllCookies(res as any); + const chunkCookies = allCookies.filter(c => /^test\.\d+=/.test(c)); + + // Build cookie header with all chunks + const cookieHeader = chunkCookies.map((c: string) => c.split(";")[0]!).join("; "); + + // Read session back - should reconstruct from chunks + const req = { headers: { cookie: cookieHeader } } as IncomingMessage; + session = await getSession(req, res as unknown as ServerResponse, { + cookieName, + password, + chunking: { enabled: true }, + }); + + equal(session.user?.id, 2); + equal(session.user?.meta, largeData); + + mock.reset(); +}); + +await test("should use single cookie when data is small with chunking enabled", async () => { + const res = { getHeader: mock.fn(() => []), setHeader: mock.fn() }; + + const session = await getSession( + { headers: {} } as Request, + res as unknown as ServerResponse, + { + cookieName, + password, + chunking: { enabled: true }, + }, + ); + session.user = { id: 3 }; // Small data + await session.save(); + + const allCookies = collectAllCookies(res as any); + // Should use single cookie for small data + equal(allCookies.length, 1); + match(allCookies[0]!, /^test=/); // Not chunked + doesNotMatch(allCookies[0]!, /^test\.0=/); // No chunk suffix + + mock.reset(); +}); + +await test("should clean up old chunks when updating session", async () => { + let res = { getHeader: mock.fn(() => []), setHeader: mock.fn() }; + + // First save with large data (creates chunks) + const largeData = "z".repeat(5000); + let session = await getSession( + { headers: {} } as Request, + res as unknown as ServerResponse, + { + cookieName, + password, + chunking: { enabled: true }, + }, + ); + session.user = { id: 4, meta: largeData }; + await session.save(); + + const firstCookies = collectAllCookies(res as any); + const firstChunkCookies = firstCookies.filter(c => /^test\.\d+=/.test(c)); + const firstChunkCount = firstChunkCookies.length; + + // Build cookie header + const cookieHeader = firstChunkCookies.map((c: string) => c.split(";")[0]!).join("; "); + + // Reset mock and update getHeader to return existing cookies + res = { getHeader: mock.fn(() => []), setHeader: mock.fn() }; + + // Update with smaller data + const req = { headers: { cookie: cookieHeader } } as IncomingMessage; + session = await getSession(req, res as unknown as ServerResponse, { + cookieName, + password, + chunking: { enabled: true }, + }); + session.user = { id: 5 }; // Small data now + await session.save(); + + const secondCookies = collectAllCookies(res as any); + + // Should have cleanup cookies (maxAge=0) for old chunks plus the new single cookie + equal(secondCookies.length, firstChunkCount + 1); + // Old chunks should be deleted (maxAge=0) + for (let i = 0; i < firstChunkCount; i++) { + match(secondCookies[i]!, /Max-Age=0/); + } + + mock.reset(); +}); + +await test("should destroy all chunks on session destroy", async () => { + let res = { getHeader: mock.fn(() => []), setHeader: mock.fn() }; + + // Create chunked session + const largeData = "a".repeat(5000); + let session = await getSession( + { headers: {} } as Request, + res as unknown as ServerResponse, + { + cookieName, + password, + chunking: { enabled: true }, + }, + ); + session.user = { id: 6, meta: largeData }; + await session.save(); + + const saveCookies = collectAllCookies(res as any); + const saveChunkCookies = saveCookies.filter(c => /^test\.\d+=/.test(c)); + const chunkCount = saveChunkCookies.length; + + // Build cookie header + const cookieHeader = saveChunkCookies.map((c: string) => c.split(";")[0]!).join("; "); + + // Reset mock + res = { getHeader: mock.fn(() => []), setHeader: mock.fn() }; + + // Destroy session + const req = { headers: { cookie: cookieHeader } } as IncomingMessage; + session = await getSession(req, res as unknown as ServerResponse, { + cookieName, + password, + chunking: { enabled: true }, + }); + session.destroy(); + + const destroyCookies = collectAllCookies(res as any); + + // Should delete main cookie + all chunks + equal(destroyCookies.length, chunkCount + 1); + // All should have maxAge=0 + for (const cookie of destroyCookies) { + match(cookie!, /Max-Age=0/); + } + + mock.reset(); +}); + +await test("should respect custom chunk size", async () => { + const res = { getHeader: mock.fn(() => []), setHeader: mock.fn() }; + + const mediumData = "b".repeat(3000); + const session = await getSession( + { headers: {} } as Request, + res as unknown as ServerResponse, + { + cookieName, + password, + chunking: { enabled: true, chunkSize: 2000 }, // Custom smaller chunk size + }, + ); + session.user = { id: 7, meta: mediumData }; + await session.save(); + + const allCookies = collectAllCookies(res as any); + const chunkCookies = allCookies.filter(c => /^test\.\d+=/.test(c)); + // With 2000 byte chunks, 3000 bytes of data should create multiple chunks + equal(chunkCookies.length > 1, true); + + mock.reset(); +});