From ee3a64eb3507694459caff6099d66accd1728dd5 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 22 Oct 2025 14:57:57 -0400 Subject: [PATCH 01/37] Bring over RR cookies code into @remix-run/cookies package --- packages/cookies/CHANGELOG.md | 5 + packages/cookies/LICENSE | 21 ++ packages/cookies/README.md | 17 ++ packages/cookies/package.json | 57 ++++++ packages/cookies/src/index.ts | 0 packages/cookies/src/lib/cookies.test.ts | 201 +++++++++++++++++++ packages/cookies/src/lib/cookies.ts | 235 +++++++++++++++++++++++ packages/cookies/src/lib/crypto.ts | 50 +++++ packages/cookies/src/lib/warnings.ts | 8 + packages/cookies/tsconfig.build.json | 11 ++ packages/cookies/tsconfig.json | 12 ++ pnpm-lock.yaml | 19 ++ 12 files changed, 636 insertions(+) create mode 100644 packages/cookies/CHANGELOG.md create mode 100644 packages/cookies/LICENSE create mode 100644 packages/cookies/README.md create mode 100644 packages/cookies/package.json create mode 100644 packages/cookies/src/index.ts create mode 100644 packages/cookies/src/lib/cookies.test.ts create mode 100644 packages/cookies/src/lib/cookies.ts create mode 100644 packages/cookies/src/lib/crypto.ts create mode 100644 packages/cookies/src/lib/warnings.ts create mode 100644 packages/cookies/tsconfig.build.json create mode 100644 packages/cookies/tsconfig.json diff --git a/packages/cookies/CHANGELOG.md b/packages/cookies/CHANGELOG.md new file mode 100644 index 00000000000..544ba75df12 --- /dev/null +++ b/packages/cookies/CHANGELOG.md @@ -0,0 +1,5 @@ +# `cookies` CHANGELOG + +This is the changelog for [`cookies`](https://github.com/remix-run/remix/tree/main/packages/cookies). It follows [semantic versioning](https://semver.org/). + +## HEAD diff --git a/packages/cookies/LICENSE b/packages/cookies/LICENSE new file mode 100644 index 00000000000..717984c0442 --- /dev/null +++ b/packages/cookies/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Michael Jackson + +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 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/packages/cookies/README.md b/packages/cookies/README.md new file mode 100644 index 00000000000..c4f4f075292 --- /dev/null +++ b/packages/cookies/README.md @@ -0,0 +1,17 @@ +# cookies + +TODO: + +## Installation + +```sh +npm install @remix-run/cookies +``` + +## Overview + +TODO: + +## License + +See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE) diff --git a/packages/cookies/package.json b/packages/cookies/package.json new file mode 100644 index 00000000000..986c01f2f51 --- /dev/null +++ b/packages/cookies/package.json @@ -0,0 +1,57 @@ +{ + "name": "@remix-run/cookies", + "version": "0.1.0", + "description": "A toolkit for working with cookies in JavaScript", + "author": "Michael Jackson ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/remix-run/remix.git", + "directory": "packages/cookies" + }, + "homepage": "https://github.com/remix-run/remix/tree/main/packages/cookies#readme", + "files": [ + "LICENSE", + "README.md", + "dist", + "src", + "!src/**/*.test.ts" + ], + "type": "module", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + } + }, + "devDependencies": { + "@types/node": "^24.6.0", + "esbuild": "^0.25.10" + }, + "scripts": { + "build": "pnpm run clean && pnpm run build:types && pnpm run build:esm", + "build:esm": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --platform=neutral --sourcemap", + "build:types": "tsc --project tsconfig.build.json", + "clean": "rm -rf dist", + "prepublishOnly": "pnpm run build", + "test": "node --disable-warning=ExperimentalWarning --test './src/**/*.test.ts'", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "http", + "cookie", + "cookies", + "http-cookies", + "set-cookie" + ], + "dependencies": { + "cookie": "^1.0.2" + } +} diff --git a/packages/cookies/src/index.ts b/packages/cookies/src/index.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/cookies/src/lib/cookies.test.ts b/packages/cookies/src/lib/cookies.test.ts new file mode 100644 index 00000000000..a6b04e58d98 --- /dev/null +++ b/packages/cookies/src/lib/cookies.test.ts @@ -0,0 +1,201 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { createCookie, isCookie } from './cookies.ts' + +function getCookieFromSetCookie(setCookie: string): string { + return setCookie.split(/;\s*/)[0] +} + +describe('isCookie', () => { + it('returns `true` for Cookie objects', () => { + assert.equal(isCookie(createCookie('my-cookie')), true) + }) + + it('returns `false` for non-Cookie objects', () => { + assert.equal(isCookie({}), false) + assert.equal(isCookie([]), false) + assert.equal(isCookie(''), false) + assert.equal(isCookie(true), false) + }) +}) + +describe('cookies', () => { + it('parses/serializes empty string values', async () => { + let cookie = createCookie('my-cookie') + let setCookie = await cookie.serialize('') + let value = await cookie.parse(getCookieFromSetCookie(setCookie)) + + assert.equal(value, '') + }) + + it('parses/serializes unsigned string values', async () => { + let cookie = createCookie('my-cookie') + let setCookie = await cookie.serialize('hello world') + let value = await cookie.parse(getCookieFromSetCookie(setCookie)) + + assert.equal(value, 'hello world') + }) + + it('parses/serializes unsigned boolean values', async () => { + let cookie = createCookie('my-cookie') + let setCookie = await cookie.serialize(true) + let value = await cookie.parse(getCookieFromSetCookie(setCookie)) + + assert.equal(value, true) + }) + + it('parses/serializes signed string values', async () => { + let cookie = createCookie('my-cookie', { + secrets: ['secret1'], + }) + let setCookie = await cookie.serialize('hello michael') + let value = await cookie.parse(getCookieFromSetCookie(setCookie)) + + assert.equal(value, 'hello michael') + }) + + it('parses/serializes string values containing utf8 characters', async () => { + let cookie = createCookie('my-cookie') + let setCookie = await cookie.serialize('日本語') + let value = await cookie.parse(getCookieFromSetCookie(setCookie)) + + assert.equal(value, '日本語') + }) + + it('fails to parses signed string values with invalid signature', async () => { + let cookie = createCookie('my-cookie', { + secrets: ['secret1'], + }) + let setCookie = await cookie.serialize('hello michael') + let cookie2 = createCookie('my-cookie', { + secrets: ['secret2'], + }) + let value = await cookie2.parse(getCookieFromSetCookie(setCookie)) + + assert.equal(value, null) + }) + + it('fails to parse signed string values with invalid signature encoding', async () => { + let cookie = createCookie('my-cookie', { + secrets: ['secret1'], + }) + let setCookie = await cookie.serialize('hello michael') + let cookie2 = createCookie('my-cookie', { + secrets: ['secret2'], + }) + // use characters that are invalid for base64 encoding + let value = await cookie2.parse(getCookieFromSetCookie(setCookie) + '%^&') + + assert.equal(value, null) + }) + + it('parses/serializes signed object values', async () => { + let cookie = createCookie('my-cookie', { + secrets: ['secret1'], + }) + let setCookie = await cookie.serialize({ hello: 'mjackson' }) + let value = await cookie.parse(getCookieFromSetCookie(setCookie)) + + assert.deepEqual(value, { hello: 'mjackson' }) + }) + + it('fails to parse signed object values with invalid signature', async () => { + let cookie = createCookie('my-cookie', { + secrets: ['secret1'], + }) + let setCookie = await cookie.serialize({ hello: 'mjackson' }) + let cookie2 = createCookie('my-cookie', { + secrets: ['secret2'], + }) + let value = await cookie2.parse(getCookieFromSetCookie(setCookie)) + + assert.equal(value, null) + }) + + it('supports secret rotation', async () => { + let cookie = createCookie('my-cookie', { + secrets: ['secret1'], + }) + let setCookie = await cookie.serialize({ hello: 'mjackson' }) + let value = await cookie.parse(getCookieFromSetCookie(setCookie)) + + assert.deepEqual(value, { hello: 'mjackson' }) + + // A new secret enters the rotation... + cookie = createCookie('my-cookie', { + secrets: ['secret2', 'secret1'], + }) + + // cookie should still be able to parse old cookies. + let oldValue = await cookie.parse(getCookieFromSetCookie(setCookie)) + assert.deepEqual(oldValue, value) + + // New Set-Cookie should be different, it uses a different secret. + let setCookie2 = await cookie.serialize(value) + assert.notEqual(setCookie, setCookie2) + }) + + it('makes the default secrets to be an empty array', async () => { + let cookie = createCookie('my-cookie') + + assert.equal(cookie.isSigned, false) + + let cookie2 = createCookie('my-cookie2', { + secrets: undefined, + }) + + assert.equal(cookie2.isSigned, false) + }) + + it('makes the default path of cookies to be /', async () => { + let cookie = createCookie('my-cookie') + + let setCookie = await cookie.serialize('hello world') + assert.ok(setCookie.includes('Path=/')) + + let cookie2 = createCookie('my-cookie2') + + let setCookie2 = await cookie2.serialize('hello world', { + path: '/about', + }) + assert.ok(setCookie2.includes('Path=/about')) + }) + + it('supports the Priority attribute', async () => { + let cookie = createCookie('my-cookie') + + let setCookie = await cookie.serialize('hello world') + assert.ok(!setCookie.includes('Priority')) + + let cookie2 = createCookie('my-cookie2') + + let setCookie2 = await cookie2.serialize('hello world', { + priority: 'high', + }) + assert.ok(setCookie2.includes('Priority=High')) + }) + + describe('warnings when providing options you may not want to', () => { + it('warns against using `expires` when creating the cookie instance', async () => { + let consoleCalls: string[] = [] + let originalWarn = console.warn + console.warn = (...args: any[]) => { + consoleCalls.push(args.join(' ')) + } + + try { + createCookie('my-cookie', { expires: new Date(Date.now() + 60_000) }) + assert.equal(consoleCalls.length, 1) + assert.ok(consoleCalls[0].includes('The "my-cookie" cookie has an "expires" property set')) + assert.ok( + consoleCalls[0].includes( + 'Instead, you should set the expires value when serializing the cookie', + ), + ) + } finally { + console.warn = originalWarn + } + }) + }) +}) diff --git a/packages/cookies/src/lib/cookies.ts b/packages/cookies/src/lib/cookies.ts new file mode 100644 index 00000000000..98f687f6e8f --- /dev/null +++ b/packages/cookies/src/lib/cookies.ts @@ -0,0 +1,235 @@ +import type { ParseOptions, SerializeOptions } from 'cookie' +import { parse, serialize } from 'cookie' + +import { sign, unsign } from './crypto.ts' +import { warnOnce } from './warnings.ts' + +export type { ParseOptions as CookieParseOptions, SerializeOptions as CookieSerializeOptions } + +export interface CookieSignatureOptions { + /** + * An array of secrets that may be used to sign/unsign the value of a cookie. + * + * The array makes it easy to rotate secrets. New secrets should be added to + * the beginning of the array. `cookie.serialize()` will always use the first + * value in the array, but `cookie.parse()` may use any of them so that + * cookies that were signed with older secrets still work. + */ + secrets?: string[] +} + +export type CookieOptions = ParseOptions & SerializeOptions & CookieSignatureOptions + +/** + * A HTTP cookie. + * + * A Cookie is a logical container for metadata about a HTTP cookie; its name + * and options. But it doesn't contain a value. Instead, it has `parse()` and + * `serialize()` methods that allow a single instance to be reused for + * parsing/encoding multiple different values. + * + * @see https://remix.run/utils/cookies#cookie-api + */ +export interface Cookie { + /** + * The name of the cookie, used in the `Cookie` and `Set-Cookie` headers. + */ + readonly name: string + + /** + * True if this cookie uses one or more secrets for verification. + */ + readonly isSigned: boolean + + /** + * The Date this cookie expires. + * + * Note: This is calculated at access time using `maxAge` when no `expires` + * option is provided to `createCookie()`. + */ + readonly expires?: Date + + /** + * Parses a raw `Cookie` header and returns the value of this cookie or + * `null` if it's not present. + */ + parse(cookieHeader: string | null, options?: ParseOptions): Promise + + /** + * Serializes the given value to a string and returns the `Set-Cookie` + * header. + */ + serialize(value: any, options?: SerializeOptions): Promise +} + +/** + * Creates a logical container for managing a browser cookie from the server. + */ +export const createCookie = (name: string, cookieOptions: CookieOptions = {}): Cookie => { + let { secrets = [], ...options } = { + path: '/', + sameSite: 'lax' as const, + ...cookieOptions, + } + + warnOnceAboutExpiresCookie(name, options.expires) + + return { + get name() { + return name + }, + get isSigned() { + return secrets.length > 0 + }, + get expires() { + // Max-Age takes precedence over Expires + return typeof options.maxAge !== 'undefined' + ? new Date(Date.now() + options.maxAge * 1000) + : options.expires + }, + async parse(cookieHeader, parseOptions) { + if (!cookieHeader) return null + let cookies = parse(cookieHeader, { ...options, ...parseOptions }) + if (name in cookies) { + let value = cookies[name] + if (typeof value === 'string' && value !== '') { + let decoded = await decodeCookieValue(value, secrets) + return decoded + } else { + return '' + } + } else { + return null + } + }, + async serialize(value, serializeOptions) { + return serialize(name, value === '' ? '' : await encodeCookieValue(value, secrets), { + ...options, + ...serializeOptions, + }) + }, + } +} + +export type IsCookieFunction = (object: any) => object is Cookie + +/** + * Returns true if an object is a Remix cookie container. + * + * @see https://remix.run/utils/cookies#iscookie + */ +export const isCookie: IsCookieFunction = (object): object is Cookie => { + return ( + object != null && + typeof object.name === 'string' && + typeof object.isSigned === 'boolean' && + typeof object.parse === 'function' && + typeof object.serialize === 'function' + ) +} + +async function encodeCookieValue(value: any, secrets: string[]): Promise { + let encoded = encodeData(value) + + if (secrets.length > 0) { + encoded = await sign(encoded, secrets[0]) + } + + return encoded +} + +async function decodeCookieValue(value: string, secrets: string[]): Promise { + if (secrets.length > 0) { + for (let secret of secrets) { + let unsignedValue = await unsign(value, secret) + if (unsignedValue !== false) { + return decodeData(unsignedValue) + } + } + + return null + } + + return decodeData(value) +} + +function encodeData(value: any): string { + return btoa(myUnescape(encodeURIComponent(JSON.stringify(value)))) +} + +function decodeData(value: string): any { + try { + return JSON.parse(decodeURIComponent(myEscape(atob(value)))) + } catch (error: unknown) { + return {} + } +} + +// See: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.escape.js +function myEscape(value: string): string { + let str = value.toString() + let result = '' + let index = 0 + let chr, code + while (index < str.length) { + chr = str.charAt(index++) + if (/[\w*+\-./@]/.exec(chr)) { + result += chr + } else { + code = chr.charCodeAt(0) + if (code < 256) { + result += '%' + hex(code, 2) + } else { + result += '%u' + hex(code, 4).toUpperCase() + } + } + } + return result +} + +function hex(code: number, length: number): string { + let result = code.toString(16) + while (result.length < length) result = '0' + result + return result +} + +// See: https://github.com/zloirock/core-js/blob/master/packages/core-js/modules/es.unescape.js +function myUnescape(value: string): string { + let str = value.toString() + let result = '' + let index = 0 + let chr, part + while (index < str.length) { + chr = str.charAt(index++) + if (chr === '%') { + if (str.charAt(index) === 'u') { + part = str.slice(index + 1, index + 5) + if (/^[\da-f]{4}$/i.exec(part)) { + result += String.fromCharCode(parseInt(part, 16)) + index += 5 + continue + } + } else { + part = str.slice(index, index + 2) + if (/^[\da-f]{2}$/i.exec(part)) { + result += String.fromCharCode(parseInt(part, 16)) + index += 2 + continue + } + } + } + result += chr + } + return result +} + +function warnOnceAboutExpiresCookie(name: string, expires?: Date) { + warnOnce( + !expires, + `The "${name}" cookie has an "expires" property set. ` + + `This will cause the expires value to not be updated when the session is committed. ` + + `Instead, you should set the expires value when serializing the cookie. ` + + `You can use \`commitSession(session, { expires })\` if using a session storage object, ` + + `or \`cookie.serialize("value", { expires })\` if you're using the cookie directly.`, + ) +} diff --git a/packages/cookies/src/lib/crypto.ts b/packages/cookies/src/lib/crypto.ts new file mode 100644 index 00000000000..7fe65e1275c --- /dev/null +++ b/packages/cookies/src/lib/crypto.ts @@ -0,0 +1,50 @@ +const encoder = /* @__PURE__ */ new TextEncoder() + +export const sign = async (value: string, secret: string): Promise => { + let data = encoder.encode(value) + let key = await createKey(secret, ['sign']) + let signature = await crypto.subtle.sign('HMAC', key, data) + let hash = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/=+$/, '') + + return value + '.' + hash +} + +export const unsign = async (cookie: string, secret: string): Promise => { + let index = cookie.lastIndexOf('.') + let value = cookie.slice(0, index) + let hash = cookie.slice(index + 1) + + let data = encoder.encode(value) + + let key = await createKey(secret, ['verify']) + try { + let signature = byteStringToUint8Array(atob(hash)) + let valid = await crypto.subtle.verify('HMAC', key, signature, data) + + return valid ? value : false + } catch (error: unknown) { + // atob will throw a DOMException with name === 'InvalidCharacterError' + // if the signature contains a non-base64 character, which should just + // be treated as an invalid signature. + return false + } +} + +const createKey = async (secret: string, usages: CryptoKey['usages']): Promise => + crypto.subtle.importKey( + 'raw', + encoder.encode(secret), + { name: 'HMAC', hash: 'SHA-256' }, + false, + usages, + ) + +function byteStringToUint8Array(byteString: string): Uint8Array { + let array = new Uint8Array(byteString.length) + + for (let i = 0; i < byteString.length; i++) { + array[i] = byteString.charCodeAt(i) + } + + return array +} diff --git a/packages/cookies/src/lib/warnings.ts b/packages/cookies/src/lib/warnings.ts new file mode 100644 index 00000000000..747086991d6 --- /dev/null +++ b/packages/cookies/src/lib/warnings.ts @@ -0,0 +1,8 @@ +const alreadyWarned: { [message: string]: boolean } = {} + +export function warnOnce(condition: boolean, message: string): void { + if (!condition && !alreadyWarned[message]) { + alreadyWarned[message] = true + console.warn(message) + } +} diff --git a/packages/cookies/tsconfig.build.json b/packages/cookies/tsconfig.build.json new file mode 100644 index 00000000000..fdeb70cad14 --- /dev/null +++ b/packages/cookies/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": true, + "outDir": "./dist" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/cookies/tsconfig.json b/packages/cookies/tsconfig.json new file mode 100644 index 00000000000..4781f83485f --- /dev/null +++ b/packages/cookies/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "strict": true, + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "module": "ES2022", + "moduleResolution": "Bundler", + "target": "ESNext", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7cc46f0013b..9fee05d9604 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,6 +61,19 @@ importers: specifier: ^4.20.6 version: 4.20.6 + packages/cookies: + dependencies: + cookie: + specifier: ^1.0.2 + version: 1.0.2 + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.6.0 + esbuild: + specifier: ^0.25.10 + version: 0.25.10 + packages/fetch-proxy: dependencies: '@remix-run/headers': @@ -1291,6 +1304,10 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} + cookie@1.0.2: + resolution: {integrity: sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==} + engines: {node: '>=18'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -3011,6 +3028,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.0.2: {} + core-util-is@1.0.3: {} cross-spawn@7.0.5: From a10d448cfa502f3bb8bbf7ddcf0aa2c9eedf99e9 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 22 Oct 2025 15:04:43 -0400 Subject: [PATCH 02/37] Update build and exports --- packages/cookies/package.json | 2 +- packages/cookies/src/index.ts | 1 + packages/cookies/src/lib/cookies.ts | 10 ++++------ 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/cookies/package.json b/packages/cookies/package.json index 986c01f2f51..5bdd294349f 100644 --- a/packages/cookies/package.json +++ b/packages/cookies/package.json @@ -37,7 +37,7 @@ }, "scripts": { "build": "pnpm run clean && pnpm run build:types && pnpm run build:esm", - "build:esm": "esbuild src/index.ts --bundle --outfile=dist/index.js --format=esm --platform=neutral --sourcemap", + "build:esm": "esbuild src/index.ts --bundle --external:cookie --outfile=dist/index.js --format=esm --platform=neutral --sourcemap", "build:types": "tsc --project tsconfig.build.json", "clean": "rm -rf dist", "prepublishOnly": "pnpm run build", diff --git a/packages/cookies/src/index.ts b/packages/cookies/src/index.ts index e69de29bb2d..ec19275b6e5 100644 --- a/packages/cookies/src/index.ts +++ b/packages/cookies/src/index.ts @@ -0,0 +1 @@ +export { createCookie, isCookie } from './lib/cookies.ts' diff --git a/packages/cookies/src/lib/cookies.ts b/packages/cookies/src/lib/cookies.ts index 98f687f6e8f..d818e920279 100644 --- a/packages/cookies/src/lib/cookies.ts +++ b/packages/cookies/src/lib/cookies.ts @@ -4,9 +4,7 @@ import { parse, serialize } from 'cookie' import { sign, unsign } from './crypto.ts' import { warnOnce } from './warnings.ts' -export type { ParseOptions as CookieParseOptions, SerializeOptions as CookieSerializeOptions } - -export interface CookieSignatureOptions { +interface CookieSignatureOptions { /** * An array of secrets that may be used to sign/unsign the value of a cookie. * @@ -18,7 +16,7 @@ export interface CookieSignatureOptions { secrets?: string[] } -export type CookieOptions = ParseOptions & SerializeOptions & CookieSignatureOptions +type CookieOptions = ParseOptions & SerializeOptions & CookieSignatureOptions /** * A HTTP cookie. @@ -30,7 +28,7 @@ export type CookieOptions = ParseOptions & SerializeOptions & CookieSignatureOpt * * @see https://remix.run/utils/cookies#cookie-api */ -export interface Cookie { +interface Cookie { /** * The name of the cookie, used in the `Cookie` and `Set-Cookie` headers. */ @@ -111,7 +109,7 @@ export const createCookie = (name: string, cookieOptions: CookieOptions = {}): C } } -export type IsCookieFunction = (object: any) => object is Cookie +type IsCookieFunction = (object: any) => object is Cookie /** * Returns true if an object is a Remix cookie container. From f4a569b62af0152b548cd2961d03e2cc240152d6 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 22 Oct 2025 15:07:49 -0400 Subject: [PATCH 03/37] Rename @remix-run/cookies -> @remix-run/cookie --- packages/cookie/CHANGELOG.md | 5 +++++ packages/{cookies => cookie}/LICENSE | 0 packages/{cookies => cookie}/README.md | 4 ++-- packages/{cookies => cookie}/package.json | 4 ++-- packages/cookie/src/index.ts | 1 + .../lib/cookies.test.ts => cookie/src/lib/cookie.test.ts} | 2 +- .../{cookies/src/lib/cookies.ts => cookie/src/lib/cookie.ts} | 0 packages/{cookies => cookie}/src/lib/crypto.ts | 0 packages/{cookies => cookie}/src/lib/warnings.ts | 0 packages/{cookies => cookie}/tsconfig.build.json | 0 packages/{cookies => cookie}/tsconfig.json | 0 packages/cookies/CHANGELOG.md | 5 ----- packages/cookies/src/index.ts | 1 - pnpm-lock.yaml | 2 +- 14 files changed, 12 insertions(+), 12 deletions(-) create mode 100644 packages/cookie/CHANGELOG.md rename packages/{cookies => cookie}/LICENSE (100%) rename packages/{cookies => cookie}/README.md (76%) rename packages/{cookies => cookie}/package.json (95%) create mode 100644 packages/cookie/src/index.ts rename packages/{cookies/src/lib/cookies.test.ts => cookie/src/lib/cookie.test.ts} (99%) rename packages/{cookies/src/lib/cookies.ts => cookie/src/lib/cookie.ts} (100%) rename packages/{cookies => cookie}/src/lib/crypto.ts (100%) rename packages/{cookies => cookie}/src/lib/warnings.ts (100%) rename packages/{cookies => cookie}/tsconfig.build.json (100%) rename packages/{cookies => cookie}/tsconfig.json (100%) delete mode 100644 packages/cookies/CHANGELOG.md delete mode 100644 packages/cookies/src/index.ts diff --git a/packages/cookie/CHANGELOG.md b/packages/cookie/CHANGELOG.md new file mode 100644 index 00000000000..4ea05884f94 --- /dev/null +++ b/packages/cookie/CHANGELOG.md @@ -0,0 +1,5 @@ +# `cookie` CHANGELOG + +This is the changelog for [`@remix-run/cookie`](https://github.com/remix-run/remix/tree/main/packages/cookie). It follows [semantic versioning](https://semver.org/). + +## HEAD diff --git a/packages/cookies/LICENSE b/packages/cookie/LICENSE similarity index 100% rename from packages/cookies/LICENSE rename to packages/cookie/LICENSE diff --git a/packages/cookies/README.md b/packages/cookie/README.md similarity index 76% rename from packages/cookies/README.md rename to packages/cookie/README.md index c4f4f075292..69102af599a 100644 --- a/packages/cookies/README.md +++ b/packages/cookie/README.md @@ -1,11 +1,11 @@ -# cookies +# cookie TODO: ## Installation ```sh -npm install @remix-run/cookies +npm install @remix-run/cookie ``` ## Overview diff --git a/packages/cookies/package.json b/packages/cookie/package.json similarity index 95% rename from packages/cookies/package.json rename to packages/cookie/package.json index 5bdd294349f..4a32c5158b4 100644 --- a/packages/cookies/package.json +++ b/packages/cookie/package.json @@ -1,5 +1,5 @@ { - "name": "@remix-run/cookies", + "name": "@remix-run/cookie", "version": "0.1.0", "description": "A toolkit for working with cookies in JavaScript", "author": "Michael Jackson ", @@ -7,7 +7,7 @@ "repository": { "type": "git", "url": "git+https://github.com/remix-run/remix.git", - "directory": "packages/cookies" + "directory": "packages/cookie" }, "homepage": "https://github.com/remix-run/remix/tree/main/packages/cookies#readme", "files": [ diff --git a/packages/cookie/src/index.ts b/packages/cookie/src/index.ts new file mode 100644 index 00000000000..a14d0a90992 --- /dev/null +++ b/packages/cookie/src/index.ts @@ -0,0 +1 @@ +export { createCookie, isCookie } from './lib/cookie.ts' diff --git a/packages/cookies/src/lib/cookies.test.ts b/packages/cookie/src/lib/cookie.test.ts similarity index 99% rename from packages/cookies/src/lib/cookies.test.ts rename to packages/cookie/src/lib/cookie.test.ts index a6b04e58d98..243c11847a2 100644 --- a/packages/cookies/src/lib/cookies.test.ts +++ b/packages/cookie/src/lib/cookie.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { createCookie, isCookie } from './cookies.ts' +import { createCookie, isCookie } from './cookie.ts' function getCookieFromSetCookie(setCookie: string): string { return setCookie.split(/;\s*/)[0] diff --git a/packages/cookies/src/lib/cookies.ts b/packages/cookie/src/lib/cookie.ts similarity index 100% rename from packages/cookies/src/lib/cookies.ts rename to packages/cookie/src/lib/cookie.ts diff --git a/packages/cookies/src/lib/crypto.ts b/packages/cookie/src/lib/crypto.ts similarity index 100% rename from packages/cookies/src/lib/crypto.ts rename to packages/cookie/src/lib/crypto.ts diff --git a/packages/cookies/src/lib/warnings.ts b/packages/cookie/src/lib/warnings.ts similarity index 100% rename from packages/cookies/src/lib/warnings.ts rename to packages/cookie/src/lib/warnings.ts diff --git a/packages/cookies/tsconfig.build.json b/packages/cookie/tsconfig.build.json similarity index 100% rename from packages/cookies/tsconfig.build.json rename to packages/cookie/tsconfig.build.json diff --git a/packages/cookies/tsconfig.json b/packages/cookie/tsconfig.json similarity index 100% rename from packages/cookies/tsconfig.json rename to packages/cookie/tsconfig.json diff --git a/packages/cookies/CHANGELOG.md b/packages/cookies/CHANGELOG.md deleted file mode 100644 index 544ba75df12..00000000000 --- a/packages/cookies/CHANGELOG.md +++ /dev/null @@ -1,5 +0,0 @@ -# `cookies` CHANGELOG - -This is the changelog for [`cookies`](https://github.com/remix-run/remix/tree/main/packages/cookies). It follows [semantic versioning](https://semver.org/). - -## HEAD diff --git a/packages/cookies/src/index.ts b/packages/cookies/src/index.ts deleted file mode 100644 index ec19275b6e5..00000000000 --- a/packages/cookies/src/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { createCookie, isCookie } from './lib/cookies.ts' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fee05d9604..6bd5e0efcc5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,7 +61,7 @@ importers: specifier: ^4.20.6 version: 4.20.6 - packages/cookies: + packages/cookie: dependencies: cookie: specifier: ^1.0.2 From 0fdf0f19013fd90a5e68bcfec352a423b94fb92d Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 22 Oct 2025 15:13:38 -0400 Subject: [PATCH 04/37] Reorder package.json --- packages/cookie/package.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/cookie/package.json b/packages/cookie/package.json index 4a32c5158b4..e61e117926a 100644 --- a/packages/cookie/package.json +++ b/packages/cookie/package.json @@ -31,6 +31,9 @@ "./package.json": "./package.json" } }, + "dependencies": { + "cookie": "^1.0.2" + }, "devDependencies": { "@types/node": "^24.6.0", "esbuild": "^0.25.10" @@ -50,8 +53,5 @@ "cookies", "http-cookies", "set-cookie" - ], - "dependencies": { - "cookie": "^1.0.2" - } + ] } From 9defa4d2d02f0b955f6e0886dea04f2b352e8e02 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 22 Oct 2025 15:25:25 -0400 Subject: [PATCH 05/37] Add README --- packages/cookie/README.md | 296 +++++++++++++++++++++++++++++++++++++- 1 file changed, 293 insertions(+), 3 deletions(-) diff --git a/packages/cookie/README.md b/packages/cookie/README.md index 69102af599a..2fa3ef9b47d 100644 --- a/packages/cookie/README.md +++ b/packages/cookie/README.md @@ -1,6 +1,19 @@ -# cookie +# @remix-run/cookie -TODO: +Simplify HTTP cookie management in JavaScript with type-safe, secure cookie handling. `@remix-run/cookie` provides a clean, intuitive API for creating, parsing, and serializing HTTP cookies with built-in support for signing, secret rotation, and comprehensive cookie attribute management. + +HTTP cookies are essential for web applications—from session management and user preferences to authentication tokens and tracking. While the standard cookie parsing libraries provide basic functionality, they often leave complex scenarios like secure signing, secret rotation, and type-safe value handling up to you. + +`@remix-run/cookie` solves this by offering: + +- **Secure Cookie Signing:** Built-in cryptographic signing using HMAC-SHA256 to prevent cookie tampering, with support for secret rotation without breaking existing cookies. +- **Type-Safe Value Handling:** Automatically serializes and deserializes JavaScript values (strings, objects, booleans, numbers) to/from cookie-safe formats. +- **Comprehensive Cookie Attributes:** Full support for all standard cookie attributes including `Path`, `Domain`, `Secure`, `HttpOnly`, `SameSite`, `Max-Age`, and `Expires`. +- **Reusable Cookie Containers:** Create logical cookie containers that can be used to parse and serialize multiple values over time. +- **Web Standards Compliant:** Built on Web Crypto API and standard cookie parsing, making it runtime-agnostic (Node.js, Bun, Deno, Cloudflare Workers). +- **Secret Rotation Support:** Seamlessly rotate signing secrets while maintaining backward compatibility with existing cookies. + +Perfect for building secure, maintainable cookie management in your JavaScript and TypeScript applications! ## Installation @@ -10,7 +23,284 @@ npm install @remix-run/cookie ## Overview -TODO: +The following should give you a sense of what kinds of things you can do with this library: + +```ts +import { createCookie } from '@remix-run/cookie' + +// Create a basic cookie +let sessionCookie = createCookie('session') + +// Serialize a value to a Set-Cookie header +let setCookieHeader = await sessionCookie.serialize({ + userId: '12345', + theme: 'dark', +}) +console.log(setCookieHeader) +// session=eyJ1c2VySWQiOiIxMjM0NSIsInRoZW1lIjoiZGFyayJ9; Path=/; SameSite=Lax + +// Parse a Cookie header to get the value back +let cookieHeader = 'session=eyJ1c2VySWQiOiIxMjM0NSIsInRoZW1lIjoiZGFyayJ9' +let sessionData = await sessionCookie.parse(cookieHeader) +console.log(sessionData) // { userId: '12345', theme: 'dark' } + +// Create a signed cookie for security +let secureCookie = createCookie('secure-session', { + secrets: ['Secr3t'], // Array to support secret rotation + httpOnly: true, + secure: true, + sameSite: 'strict', + maxAge: 60 * 60 * 24 * 7, // 7 days +}) + +// Signed cookies prevent tampering +let signedValue = await secureCookie.serialize({ admin: true }) +console.log(signedValue) +// secure-session=eyJhZG1pbiI6dHJ1ZX0.signature; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Strict + +let parsedValue = await secureCookie.parse('secure-session=eyJhZG1pbiI6dHJ1ZX0.signature') +console.log(parsedValue) // { admin: true } + +// Tampered cookies return null +let tamperedValue = await secureCookie.parse('secure-session=eyJhZG1pbiI6ZmFsc2V9.badsignature') +console.log(tamperedValue) // null + +// Cookie properties +console.log(secureCookie.name) // 'secure-session' +console.log(secureCookie.isSigned) // true +console.log(secureCookie.expires) // Date object (calculated from maxAge) + +// Handle different data types +let preferencesCookie = createCookie('preferences') + +// Strings +await preferencesCookie.serialize('light-mode') + +// Objects +await preferencesCookie.serialize({ + theme: 'dark', + language: 'en-US', + notifications: true, +}) + +// Booleans +await preferencesCookie.serialize(false) + +// Numbers +await preferencesCookie.serialize(42) +``` + +## Cookie Configuration + +Cookies can be configured with comprehensive options: + +```ts +import { createCookie } from '@remix-run/cookie' + +let cookie = createCookie('my-cookie', { + // Security options + secrets: ['secret1', 'secret2'], // For signing (first used for new cookies) + httpOnly: true, // Prevent JavaScript access + secure: true, // Require HTTPS + + // Scope options + domain: '.example.com', // Cookie domain + path: '/admin', // Cookie path + + // Expiration options + maxAge: 60 * 60 * 24, // Max age in seconds + expires: new Date('2025-12-31'), // Absolute expiration date + + // SameSite options + sameSite: 'strict', // 'strict' | 'lax' | 'none' + + // Encoding options (from 'cookie' package) + encode: (value) => encodeURIComponent(value), + decode: (value) => decodeURIComponent(value), +}) +``` + +## Secret Rotation + +One of the key features is seamless secret rotation for signed cookies: + +```ts +// Start with an initial secret +let cookie = createCookie('session', { + secrets: ['secret-v1'], +}) + +let setCookie1 = await cookie.serialize({ user: 'alice' }) + +// Later, rotate to a new secret while keeping the old one +cookie = createCookie('session', { + secrets: ['secret-v2', 'secret-v1'], // New secret first, old ones after +}) + +// New cookies use the new secret +let setCookie2 = await cookie.serialize({ user: 'bob' }) + +// But old cookies still work +let oldValue = await cookie.parse(setCookie1.split(';')[0]) +console.log(oldValue) // { user: 'alice' } - still works! + +let newValue = await cookie.parse(setCookie2.split(';')[0]) +console.log(newValue) // { user: 'bob' } +``` + +## Advanced Usage + +### Custom Serialization Options + +You can override cookie options when serializing: + +```ts +let cookie = createCookie('flexible', { + maxAge: 60 * 60, // Default 1 hour +}) + +// Override for a specific use case +let longLivedCookie = await cookie.serialize('remember-me', { + maxAge: 60 * 60 * 24 * 365, // 1 year +}) + +let sessionCookie = await cookie.serialize('temp-data', { + maxAge: undefined, // Session cookie (no expiration) + secure: false, // Maybe for development +}) +``` + +### Cookie Type Checking + +Check if an object is a cookie container: + +```ts +import { createCookie, isCookie } from '@remix-run/cookie' + +let cookie = createCookie('test') +let notCookie = { name: 'fake' } + +console.log(isCookie(cookie)) // true +console.log(isCookie(notCookie)) // false + +// Useful for type guards +function handleCookie(obj: unknown) { + if (isCookie(obj)) { + // obj is now typed as Cookie + console.log(obj.name) + console.log(obj.isSigned) + } +} +``` + +### Error Handling + +The library handles various error scenarios gracefully: + +```ts +let cookie = createCookie('test') + +// Missing or malformed cookie headers return null +await cookie.parse(null) // null +await cookie.parse('') // null +await cookie.parse('other=value') // null + +// Malformed cookie values return empty object or null +await cookie.parse('test=invalid-base64@#$') // {} + +// Signed cookies with bad signatures return null +let signedCookie = createCookie('signed', { secrets: ['secret'] }) +await signedCookie.parse('signed=value.badsignature') // null +``` + +```ts +// In your Remix loader/action +import type { LoaderFunctionArgs } from '@remix-run/node' +import { createCookie } from '@remix-run/cookie' + +let sessionCookie = createCookie('session', { + secrets: [process.env.SESSION_SECRET], + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, // 30 days +}) + +export async function loader({ request }: LoaderFunctionArgs) { + let cookieHeader = request.headers.get('Cookie') + let session = await sessionCookie.parse(cookieHeader) + + return { + user: session?.userId ? await getUser(session.userId) : null, + } +} +``` + +## API Reference + +### `createCookie(name, options?)` + +Creates a new cookie container. + +**Parameters:** + +- `name: string` - The cookie name +- `options?: CookieOptions` - Configuration options + +**Returns:** `Cookie` - A cookie container object + +### `Cookie` Interface + +**Properties:** + +- `name: string` - The cookie name (readonly) +- `isSigned: boolean` - Whether the cookie uses signing (readonly) +- `expires?: Date` - Calculated expiration date (readonly) + +**Methods:** + +- `parse(cookieHeader: string | null, options?: ParseOptions): Promise` - Parse cookie value from header +- `serialize(value: any, options?: SerializeOptions): Promise` - Serialize value to Set-Cookie header + +### `isCookie(object)` + +Type guard to check if an object is a cookie container. + +**Parameters:** + +- `object: any` - Object to test + +**Returns:** `boolean` - True if object is a Cookie + +### `CookieOptions` + +Configuration options for cookies (extends options from the [`cookie`](https://www.npmjs.com/package/cookie) package): + +```ts +interface CookieOptions { + // Signing + secrets?: string[] + + // Standard cookie attributes + domain?: string + expires?: Date + httpOnly?: boolean + maxAge?: number + path?: string + secure?: boolean + sameSite?: 'strict' | 'lax' | 'none' | boolean + + // Encoding (from cookie package) + encode?: (value: string) => string + decode?: (value: string) => string +} +``` + +## Related Packages + +- [`headers`](https://github.com/remix-run/remix/tree/main/packages/headers) - Type-safe HTTP header manipulation +- [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - Build HTTP routers using the web fetch API +- [`node-fetch-server`](https://github.com/remix-run/remix/tree/main/packages/node-fetch-server) - Build HTTP servers on Node.js using the web fetch API ## License From b2b44af4eed7bc9dc13ce7b31016605c9db8675b Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 22 Oct 2025 15:51:19 -0400 Subject: [PATCH 06/37] Updates --- packages/cookie/CHANGELOG.md | 2 +- packages/cookie/package.json | 2 +- packages/cookie/src/index.ts | 2 +- packages/cookie/src/lib/cookie.ts | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/cookie/CHANGELOG.md b/packages/cookie/CHANGELOG.md index 4ea05884f94..a514e292dc9 100644 --- a/packages/cookie/CHANGELOG.md +++ b/packages/cookie/CHANGELOG.md @@ -1,4 +1,4 @@ -# `cookie` CHANGELOG +# `@remix-run/cookie` CHANGELOG This is the changelog for [`@remix-run/cookie`](https://github.com/remix-run/remix/tree/main/packages/cookie). It follows [semantic versioning](https://semver.org/). diff --git a/packages/cookie/package.json b/packages/cookie/package.json index e61e117926a..2a210bae491 100644 --- a/packages/cookie/package.json +++ b/packages/cookie/package.json @@ -9,7 +9,7 @@ "url": "git+https://github.com/remix-run/remix.git", "directory": "packages/cookie" }, - "homepage": "https://github.com/remix-run/remix/tree/main/packages/cookies#readme", + "homepage": "https://github.com/remix-run/remix/tree/main/packages/cookie#readme", "files": [ "LICENSE", "README.md", diff --git a/packages/cookie/src/index.ts b/packages/cookie/src/index.ts index a14d0a90992..86796c7d062 100644 --- a/packages/cookie/src/index.ts +++ b/packages/cookie/src/index.ts @@ -1 +1 @@ -export { createCookie, isCookie } from './lib/cookie.ts' +export { type Cookie, type CookieOptions, createCookie, isCookie } from './lib/cookie.ts' diff --git a/packages/cookie/src/lib/cookie.ts b/packages/cookie/src/lib/cookie.ts index d818e920279..86988a78fde 100644 --- a/packages/cookie/src/lib/cookie.ts +++ b/packages/cookie/src/lib/cookie.ts @@ -16,7 +16,7 @@ interface CookieSignatureOptions { secrets?: string[] } -type CookieOptions = ParseOptions & SerializeOptions & CookieSignatureOptions +export type CookieOptions = ParseOptions & SerializeOptions & CookieSignatureOptions /** * A HTTP cookie. @@ -28,7 +28,7 @@ type CookieOptions = ParseOptions & SerializeOptions & CookieSignatureOptions * * @see https://remix.run/utils/cookies#cookie-api */ -interface Cookie { +export interface Cookie { /** * The name of the cookie, used in the `Cookie` and `Set-Cookie` headers. */ From 74f2015bb50770601651ba6be9247755d4b49520 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 22 Oct 2025 16:11:10 -0400 Subject: [PATCH 07/37] Remove stale links --- packages/cookie/src/lib/cookie.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/cookie/src/lib/cookie.ts b/packages/cookie/src/lib/cookie.ts index 86988a78fde..b3cd5d55bd1 100644 --- a/packages/cookie/src/lib/cookie.ts +++ b/packages/cookie/src/lib/cookie.ts @@ -25,8 +25,6 @@ export type CookieOptions = ParseOptions & SerializeOptions & CookieSignatureOpt * and options. But it doesn't contain a value. Instead, it has `parse()` and * `serialize()` methods that allow a single instance to be reused for * parsing/encoding multiple different values. - * - * @see https://remix.run/utils/cookies#cookie-api */ export interface Cookie { /** @@ -113,8 +111,6 @@ type IsCookieFunction = (object: any) => object is Cookie /** * Returns true if an object is a Remix cookie container. - * - * @see https://remix.run/utils/cookies#iscookie */ export const isCookie: IsCookieFunction = (object): object is Cookie => { return ( From f54a3c17c3dbbde01e5b82230a04fd7db18e057e Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 23 Oct 2025 09:55:00 -0400 Subject: [PATCH 08/37] Update copyright/author to Shopify/hello@remix.run --- LICENSE | 3 ++- packages/cookie/LICENSE | 3 ++- packages/cookie/package.json | 2 +- packages/fetch-proxy/LICENSE | 3 ++- packages/fetch-proxy/package.json | 2 +- packages/fetch-router/LICENSE | 3 ++- packages/fetch-router/package.json | 2 +- packages/file-storage/LICENSE | 3 ++- packages/file-storage/package.json | 2 +- packages/form-data-parser/LICENSE | 3 ++- packages/form-data-parser/package.json | 2 +- packages/headers/LICENSE | 3 ++- packages/headers/package.json | 2 +- packages/lazy-file/LICENSE | 3 ++- packages/lazy-file/package.json | 2 +- packages/multipart-parser/LICENSE | 3 ++- packages/multipart-parser/package.json | 2 +- packages/node-fetch-server/LICENSE | 3 ++- packages/node-fetch-server/package.json | 2 +- packages/route-pattern/LICENSE | 3 ++- packages/route-pattern/package.json | 2 +- packages/tar-parser/LICENSE | 3 ++- packages/tar-parser/package.json | 2 +- 23 files changed, 35 insertions(+), 23 deletions(-) diff --git a/LICENSE b/LICENSE index 717984c0442..386ae158bbb 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2024 Michael Jackson +Copyright (c) Shopify Inc. 2024 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/cookie/LICENSE b/packages/cookie/LICENSE index 717984c0442..386ae158bbb 100644 --- a/packages/cookie/LICENSE +++ b/packages/cookie/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2024 Michael Jackson +Copyright (c) Shopify Inc. 2024 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/cookie/package.json b/packages/cookie/package.json index 2a210bae491..a00a4d402d3 100644 --- a/packages/cookie/package.json +++ b/packages/cookie/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/cookie", "version": "0.1.0", "description": "A toolkit for working with cookies in JavaScript", - "author": "Michael Jackson ", + "author": "Remix Software ", "license": "MIT", "repository": { "type": "git", diff --git a/packages/fetch-proxy/LICENSE b/packages/fetch-proxy/LICENSE index 717984c0442..386ae158bbb 100644 --- a/packages/fetch-proxy/LICENSE +++ b/packages/fetch-proxy/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2024 Michael Jackson +Copyright (c) Shopify Inc. 2024 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/fetch-proxy/package.json b/packages/fetch-proxy/package.json index b35399ea837..274bb246987 100644 --- a/packages/fetch-proxy/package.json +++ b/packages/fetch-proxy/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/fetch-proxy", "version": "0.6.0", "description": "An HTTP proxy for the web Fetch API", - "author": "Michael Jackson ", + "author": "Remix Software ", "license": "MIT", "repository": { "type": "git", diff --git a/packages/fetch-router/LICENSE b/packages/fetch-router/LICENSE index 717984c0442..386ae158bbb 100644 --- a/packages/fetch-router/LICENSE +++ b/packages/fetch-router/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2024 Michael Jackson +Copyright (c) Shopify Inc. 2024 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/fetch-router/package.json b/packages/fetch-router/package.json index bf07a179c48..885f7ba87ec 100644 --- a/packages/fetch-router/package.json +++ b/packages/fetch-router/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/fetch-router", "version": "0.7.0", "description": "A minimal, composable router for the web Fetch API", - "author": "Michael Jackson ", + "author": "Remix Software ", "license": "MIT", "repository": { "type": "git", diff --git a/packages/file-storage/LICENSE b/packages/file-storage/LICENSE index 717984c0442..386ae158bbb 100644 --- a/packages/file-storage/LICENSE +++ b/packages/file-storage/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2024 Michael Jackson +Copyright (c) Shopify Inc. 2024 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/file-storage/package.json b/packages/file-storage/package.json index 0b197db4246..dcc562f9edb 100644 --- a/packages/file-storage/package.json +++ b/packages/file-storage/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/file-storage", "version": "0.10.0", "description": "Key/value storage for JavaScript File objects", - "author": "Michael Jackson ", + "author": "Remix Software ", "repository": { "type": "git", "url": "git+https://github.com/remix-run/remix.git", diff --git a/packages/form-data-parser/LICENSE b/packages/form-data-parser/LICENSE index 717984c0442..386ae158bbb 100644 --- a/packages/form-data-parser/LICENSE +++ b/packages/form-data-parser/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2024 Michael Jackson +Copyright (c) Shopify Inc. 2024 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/form-data-parser/package.json b/packages/form-data-parser/package.json index 98260e8e820..c93204a7c54 100644 --- a/packages/form-data-parser/package.json +++ b/packages/form-data-parser/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/form-data-parser", "version": "0.12.0", "description": "A request.formData() wrapper with streaming file upload handling", - "author": "Michael Jackson ", + "author": "Remix Software ", "repository": { "type": "git", "url": "git+https://github.com/remix-run/remix.git", diff --git a/packages/headers/LICENSE b/packages/headers/LICENSE index 717984c0442..386ae158bbb 100644 --- a/packages/headers/LICENSE +++ b/packages/headers/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2024 Michael Jackson +Copyright (c) Shopify Inc. 2024 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/headers/package.json b/packages/headers/package.json index 1bde17a2e83..e782fe1468e 100644 --- a/packages/headers/package.json +++ b/packages/headers/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/headers", "version": "0.14.0", "description": "A toolkit for working with HTTP headers in JavaScript", - "author": "Michael Jackson ", + "author": "Remix Software ", "license": "MIT", "repository": { "type": "git", diff --git a/packages/lazy-file/LICENSE b/packages/lazy-file/LICENSE index 717984c0442..386ae158bbb 100644 --- a/packages/lazy-file/LICENSE +++ b/packages/lazy-file/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2024 Michael Jackson +Copyright (c) Shopify Inc. 2024 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/lazy-file/package.json b/packages/lazy-file/package.json index 299cbb486e9..52261264b6a 100644 --- a/packages/lazy-file/package.json +++ b/packages/lazy-file/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/lazy-file", "version": "3.6.0", "description": "Lazy, streaming files for JavaScript", - "author": "Michael Jackson ", + "author": "Remix Software ", "repository": { "type": "git", "url": "git+https://github.com/remix-run/remix.git", diff --git a/packages/multipart-parser/LICENSE b/packages/multipart-parser/LICENSE index 717984c0442..386ae158bbb 100644 --- a/packages/multipart-parser/LICENSE +++ b/packages/multipart-parser/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2024 Michael Jackson +Copyright (c) Shopify Inc. 2024 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/multipart-parser/package.json b/packages/multipart-parser/package.json index 5fb74ec9d03..5b5ad3d8f16 100644 --- a/packages/multipart-parser/package.json +++ b/packages/multipart-parser/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/multipart-parser", "version": "0.12.0", "description": "A fast, efficient parser for multipart streams in any JavaScript environment", - "author": "Michael Jackson ", + "author": "Remix Software ", "repository": { "type": "git", "url": "git+https://github.com/remix-run/remix.git", diff --git a/packages/node-fetch-server/LICENSE b/packages/node-fetch-server/LICENSE index 717984c0442..386ae158bbb 100644 --- a/packages/node-fetch-server/LICENSE +++ b/packages/node-fetch-server/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2024 Michael Jackson +Copyright (c) Shopify Inc. 2024 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/node-fetch-server/package.json b/packages/node-fetch-server/package.json index 31fcc7ab236..73558fa0532 100644 --- a/packages/node-fetch-server/package.json +++ b/packages/node-fetch-server/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/node-fetch-server", "version": "0.11.0", "description": "Build servers for Node.js using the web fetch API", - "author": "Michael Jackson ", + "author": "Remix Software ", "repository": { "type": "git", "url": "git+https://github.com/remix-run/remix.git", diff --git a/packages/route-pattern/LICENSE b/packages/route-pattern/LICENSE index 717984c0442..386ae158bbb 100644 --- a/packages/route-pattern/LICENSE +++ b/packages/route-pattern/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2024 Michael Jackson +Copyright (c) Shopify Inc. 2024 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/route-pattern/package.json b/packages/route-pattern/package.json index 0e79b5bccaa..e77b151f7d6 100644 --- a/packages/route-pattern/package.json +++ b/packages/route-pattern/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/route-pattern", "version": "0.14.0", "description": "Match and generate URLs with strong typing", - "author": "Michael Jackson ", + "author": "Remix Software ", "license": "MIT", "repository": { "type": "git", diff --git a/packages/tar-parser/LICENSE b/packages/tar-parser/LICENSE index 717984c0442..386ae158bbb 100644 --- a/packages/tar-parser/LICENSE +++ b/packages/tar-parser/LICENSE @@ -1,6 +1,7 @@ MIT License -Copyright (c) 2024 Michael Jackson +Copyright (c) Shopify Inc. 2024 + Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/tar-parser/package.json b/packages/tar-parser/package.json index cd2d1d21cbb..6ce8386979b 100644 --- a/packages/tar-parser/package.json +++ b/packages/tar-parser/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/tar-parser", "version": "0.5.0", "description": "A fast, efficient parser for tar streams in any JavaScript environment", - "author": "Michael Jackson ", + "author": "Remix Software ", "license": "MIT", "repository": { "type": "git", From ff73c5a00a53d973c12feec38e6fc024adad143c Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 23 Oct 2025 10:04:35 -0400 Subject: [PATCH 09/37] bundle cookie dep and move to a devDependency --- packages/cookie/package.json | 6 ++---- pnpm-lock.yaml | 7 +++---- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/packages/cookie/package.json b/packages/cookie/package.json index a00a4d402d3..d2358f0de93 100644 --- a/packages/cookie/package.json +++ b/packages/cookie/package.json @@ -31,16 +31,14 @@ "./package.json": "./package.json" } }, - "dependencies": { - "cookie": "^1.0.2" - }, "devDependencies": { "@types/node": "^24.6.0", + "cookie": "^1.0.2", "esbuild": "^0.25.10" }, "scripts": { "build": "pnpm run clean && pnpm run build:types && pnpm run build:esm", - "build:esm": "esbuild src/index.ts --bundle --external:cookie --outfile=dist/index.js --format=esm --platform=neutral --sourcemap", + "build:esm": "esbuild src/index.ts --main-fields=main --bundle --outfile=dist/index.js --format=esm --platform=neutral --sourcemap", "build:types": "tsc --project tsconfig.build.json", "clean": "rm -rf dist", "prepublishOnly": "pnpm run build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6bd5e0efcc5..0125b48e393 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,14 +62,13 @@ importers: version: 4.20.6 packages/cookie: - dependencies: - cookie: - specifier: ^1.0.2 - version: 1.0.2 devDependencies: '@types/node': specifier: ^24.6.0 version: 24.6.0 + cookie: + specifier: ^1.0.2 + version: 1.0.2 esbuild: specifier: ^0.25.10 version: 0.25.10 From 8c3a2be5d3863ec4c750e8347277cd5347804588 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 23 Oct 2025 10:10:35 -0400 Subject: [PATCH 10/37] PR feedback --- packages/cookie/CHANGELOG.md | 2 +- packages/cookie/src/lib/cookie.ts | 6 ++---- packages/cookie/src/lib/crypto.ts | 11 ++++++----- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/cookie/CHANGELOG.md b/packages/cookie/CHANGELOG.md index a514e292dc9..0a5d127cc7a 100644 --- a/packages/cookie/CHANGELOG.md +++ b/packages/cookie/CHANGELOG.md @@ -2,4 +2,4 @@ This is the changelog for [`@remix-run/cookie`](https://github.com/remix-run/remix/tree/main/packages/cookie). It follows [semantic versioning](https://semver.org/). -## HEAD +## Unreleased diff --git a/packages/cookie/src/lib/cookie.ts b/packages/cookie/src/lib/cookie.ts index b3cd5d55bd1..4d515e6c625 100644 --- a/packages/cookie/src/lib/cookie.ts +++ b/packages/cookie/src/lib/cookie.ts @@ -61,7 +61,7 @@ export interface Cookie { /** * Creates a logical container for managing a browser cookie from the server. */ -export const createCookie = (name: string, cookieOptions: CookieOptions = {}): Cookie => { +export function createCookie(name: string, cookieOptions: CookieOptions = {}): Cookie { let { secrets = [], ...options } = { path: '/', sameSite: 'lax' as const, @@ -107,12 +107,10 @@ export const createCookie = (name: string, cookieOptions: CookieOptions = {}): C } } -type IsCookieFunction = (object: any) => object is Cookie - /** * Returns true if an object is a Remix cookie container. */ -export const isCookie: IsCookieFunction = (object): object is Cookie => { +export function isCookie(object: any): object is Cookie { return ( object != null && typeof object.name === 'string' && diff --git a/packages/cookie/src/lib/crypto.ts b/packages/cookie/src/lib/crypto.ts index 7fe65e1275c..7dffaec950f 100644 --- a/packages/cookie/src/lib/crypto.ts +++ b/packages/cookie/src/lib/crypto.ts @@ -1,6 +1,6 @@ -const encoder = /* @__PURE__ */ new TextEncoder() +const encoder = new TextEncoder() -export const sign = async (value: string, secret: string): Promise => { +export async function sign(value: string, secret: string): Promise { let data = encoder.encode(value) let key = await createKey(secret, ['sign']) let signature = await crypto.subtle.sign('HMAC', key, data) @@ -9,7 +9,7 @@ export const sign = async (value: string, secret: string): Promise => { return value + '.' + hash } -export const unsign = async (cookie: string, secret: string): Promise => { +export async function unsign(cookie: string, secret: string): Promise { let index = cookie.lastIndexOf('.') let value = cookie.slice(0, index) let hash = cookie.slice(index + 1) @@ -30,14 +30,15 @@ export const unsign = async (cookie: string, secret: string): Promise => - crypto.subtle.importKey( +async function createKey(secret: string, usages: CryptoKey['usages']): Promise { + return crypto.subtle.importKey( 'raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, usages, ) +} function byteStringToUint8Array(byteString: string): Uint8Array { let array = new Uint8Array(byteString.length) From e8ac5620bc0b8f569bd624a408aee2a5554ee979 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 23 Oct 2025 10:15:53 -0400 Subject: [PATCH 11/37] Remove space from license --- packages/cookie/LICENSE | 1 - packages/fetch-proxy/LICENSE | 1 - packages/fetch-router/LICENSE | 1 - packages/file-storage/LICENSE | 1 - packages/form-data-parser/LICENSE | 1 - packages/headers/LICENSE | 1 - packages/lazy-file/LICENSE | 1 - packages/multipart-parser/LICENSE | 1 - packages/node-fetch-server/LICENSE | 1 - packages/route-pattern/LICENSE | 1 - packages/tar-parser/LICENSE | 1 - 11 files changed, 11 deletions(-) diff --git a/packages/cookie/LICENSE b/packages/cookie/LICENSE index 386ae158bbb..2a384496821 100644 --- a/packages/cookie/LICENSE +++ b/packages/cookie/LICENSE @@ -2,7 +2,6 @@ MIT License Copyright (c) Shopify Inc. 2024 - 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 diff --git a/packages/fetch-proxy/LICENSE b/packages/fetch-proxy/LICENSE index 386ae158bbb..2a384496821 100644 --- a/packages/fetch-proxy/LICENSE +++ b/packages/fetch-proxy/LICENSE @@ -2,7 +2,6 @@ MIT License Copyright (c) Shopify Inc. 2024 - 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 diff --git a/packages/fetch-router/LICENSE b/packages/fetch-router/LICENSE index 386ae158bbb..2a384496821 100644 --- a/packages/fetch-router/LICENSE +++ b/packages/fetch-router/LICENSE @@ -2,7 +2,6 @@ MIT License Copyright (c) Shopify Inc. 2024 - 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 diff --git a/packages/file-storage/LICENSE b/packages/file-storage/LICENSE index 386ae158bbb..2a384496821 100644 --- a/packages/file-storage/LICENSE +++ b/packages/file-storage/LICENSE @@ -2,7 +2,6 @@ MIT License Copyright (c) Shopify Inc. 2024 - 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 diff --git a/packages/form-data-parser/LICENSE b/packages/form-data-parser/LICENSE index 386ae158bbb..2a384496821 100644 --- a/packages/form-data-parser/LICENSE +++ b/packages/form-data-parser/LICENSE @@ -2,7 +2,6 @@ MIT License Copyright (c) Shopify Inc. 2024 - 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 diff --git a/packages/headers/LICENSE b/packages/headers/LICENSE index 386ae158bbb..2a384496821 100644 --- a/packages/headers/LICENSE +++ b/packages/headers/LICENSE @@ -2,7 +2,6 @@ MIT License Copyright (c) Shopify Inc. 2024 - 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 diff --git a/packages/lazy-file/LICENSE b/packages/lazy-file/LICENSE index 386ae158bbb..2a384496821 100644 --- a/packages/lazy-file/LICENSE +++ b/packages/lazy-file/LICENSE @@ -2,7 +2,6 @@ MIT License Copyright (c) Shopify Inc. 2024 - 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 diff --git a/packages/multipart-parser/LICENSE b/packages/multipart-parser/LICENSE index 386ae158bbb..2a384496821 100644 --- a/packages/multipart-parser/LICENSE +++ b/packages/multipart-parser/LICENSE @@ -2,7 +2,6 @@ MIT License Copyright (c) Shopify Inc. 2024 - 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 diff --git a/packages/node-fetch-server/LICENSE b/packages/node-fetch-server/LICENSE index 386ae158bbb..2a384496821 100644 --- a/packages/node-fetch-server/LICENSE +++ b/packages/node-fetch-server/LICENSE @@ -2,7 +2,6 @@ MIT License Copyright (c) Shopify Inc. 2024 - 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 diff --git a/packages/route-pattern/LICENSE b/packages/route-pattern/LICENSE index 386ae158bbb..2a384496821 100644 --- a/packages/route-pattern/LICENSE +++ b/packages/route-pattern/LICENSE @@ -2,7 +2,6 @@ MIT License Copyright (c) Shopify Inc. 2024 - 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 diff --git a/packages/tar-parser/LICENSE b/packages/tar-parser/LICENSE index 386ae158bbb..2a384496821 100644 --- a/packages/tar-parser/LICENSE +++ b/packages/tar-parser/LICENSE @@ -2,7 +2,6 @@ MIT License Copyright (c) Shopify Inc. 2024 - 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 From bf7b4a1321cd057a15030ef11cd0921a0f7a130d Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 29 Oct 2025 12:04:56 -0400 Subject: [PATCH 12/37] Refactor createCookie() -> new Cookie() --- packages/cookie/README.md | 73 ++++-------- packages/cookie/src/index.ts | 2 +- packages/cookie/src/lib/cookie.test.ts | 57 ++++------ packages/cookie/src/lib/cookie.ts | 147 +++++++++++-------------- 4 files changed, 107 insertions(+), 172 deletions(-) diff --git a/packages/cookie/README.md b/packages/cookie/README.md index 2fa3ef9b47d..8c1e37fe13c 100644 --- a/packages/cookie/README.md +++ b/packages/cookie/README.md @@ -26,10 +26,10 @@ npm install @remix-run/cookie The following should give you a sense of what kinds of things you can do with this library: ```ts -import { createCookie } from '@remix-run/cookie' +import { Cookie } from '@remix-run/cookie' // Create a basic cookie -let sessionCookie = createCookie('session') +let sessionCookie = new Cookie('session') // Serialize a value to a Set-Cookie header let setCookieHeader = await sessionCookie.serialize({ @@ -45,7 +45,7 @@ let sessionData = await sessionCookie.parse(cookieHeader) console.log(sessionData) // { userId: '12345', theme: 'dark' } // Create a signed cookie for security -let secureCookie = createCookie('secure-session', { +let secureCookie = new Cookie('secure-session', { secrets: ['Secr3t'], // Array to support secret rotation httpOnly: true, secure: true, @@ -71,7 +71,7 @@ console.log(secureCookie.isSigned) // true console.log(secureCookie.expires) // Date object (calculated from maxAge) // Handle different data types -let preferencesCookie = createCookie('preferences') +let preferencesCookie = new Cookie('preferences') // Strings await preferencesCookie.serialize('light-mode') @@ -95,9 +95,9 @@ await preferencesCookie.serialize(42) Cookies can be configured with comprehensive options: ```ts -import { createCookie } from '@remix-run/cookie' +import { Cookie } from '@remix-run/cookie' -let cookie = createCookie('my-cookie', { +let cookie = new Cookie('my-cookie', { // Security options secrets: ['secret1', 'secret2'], // For signing (first used for new cookies) httpOnly: true, // Prevent JavaScript access @@ -126,14 +126,14 @@ One of the key features is seamless secret rotation for signed cookies: ```ts // Start with an initial secret -let cookie = createCookie('session', { +let cookie = new Cookie('session', { secrets: ['secret-v1'], }) let setCookie1 = await cookie.serialize({ user: 'alice' }) // Later, rotate to a new secret while keeping the old one -cookie = createCookie('session', { +cookie = new Cookie('session', { secrets: ['secret-v2', 'secret-v1'], // New secret first, old ones after }) @@ -155,7 +155,7 @@ console.log(newValue) // { user: 'bob' } You can override cookie options when serializing: ```ts -let cookie = createCookie('flexible', { +let cookie = new Cookie('flexible', { maxAge: 60 * 60, // Default 1 hour }) @@ -170,35 +170,12 @@ let sessionCookie = await cookie.serialize('temp-data', { }) ``` -### Cookie Type Checking - -Check if an object is a cookie container: - -```ts -import { createCookie, isCookie } from '@remix-run/cookie' - -let cookie = createCookie('test') -let notCookie = { name: 'fake' } - -console.log(isCookie(cookie)) // true -console.log(isCookie(notCookie)) // false - -// Useful for type guards -function handleCookie(obj: unknown) { - if (isCookie(obj)) { - // obj is now typed as Cookie - console.log(obj.name) - console.log(obj.isSigned) - } -} -``` - ### Error Handling The library handles various error scenarios gracefully: ```ts -let cookie = createCookie('test') +let cookie = new Cookie('test') // Missing or malformed cookie headers return null await cookie.parse(null) // null @@ -209,16 +186,16 @@ await cookie.parse('other=value') // null await cookie.parse('test=invalid-base64@#$') // {} // Signed cookies with bad signatures return null -let signedCookie = createCookie('signed', { secrets: ['secret'] }) +let signedCookie = new Cookie('signed', { secrets: ['secret'] }) await signedCookie.parse('signed=value.badsignature') // null ``` ```ts // In your Remix loader/action import type { LoaderFunctionArgs } from '@remix-run/node' -import { createCookie } from '@remix-run/cookie' +import { Cookie } from '@remix-run/cookie' -let sessionCookie = createCookie('session', { +let sessionCookie = new Cookie('session', { secrets: [process.env.SESSION_SECRET], httpOnly: true, secure: true, @@ -238,19 +215,21 @@ export async function loader({ request }: LoaderFunctionArgs) { ## API Reference -### `createCookie(name, options?)` +### `Cookie` Class + +A cookie container class for managing HTTP cookies. -Creates a new cookie container. +**Constructor:** + +```ts +new Cookie(name: string, options?: CookieOptions) +``` **Parameters:** - `name: string` - The cookie name - `options?: CookieOptions` - Configuration options -**Returns:** `Cookie` - A cookie container object - -### `Cookie` Interface - **Properties:** - `name: string` - The cookie name (readonly) @@ -262,16 +241,6 @@ Creates a new cookie container. - `parse(cookieHeader: string | null, options?: ParseOptions): Promise` - Parse cookie value from header - `serialize(value: any, options?: SerializeOptions): Promise` - Serialize value to Set-Cookie header -### `isCookie(object)` - -Type guard to check if an object is a cookie container. - -**Parameters:** - -- `object: any` - Object to test - -**Returns:** `boolean` - True if object is a Cookie - ### `CookieOptions` Configuration options for cookies (extends options from the [`cookie`](https://www.npmjs.com/package/cookie) package): diff --git a/packages/cookie/src/index.ts b/packages/cookie/src/index.ts index 86796c7d062..3eb5346a3d8 100644 --- a/packages/cookie/src/index.ts +++ b/packages/cookie/src/index.ts @@ -1 +1 @@ -export { type Cookie, type CookieOptions, createCookie, isCookie } from './lib/cookie.ts' +export { Cookie, type CookieOptions } from './lib/cookie.ts' diff --git a/packages/cookie/src/lib/cookie.test.ts b/packages/cookie/src/lib/cookie.test.ts index 243c11847a2..d85217ca370 100644 --- a/packages/cookie/src/lib/cookie.test.ts +++ b/packages/cookie/src/lib/cookie.test.ts @@ -1,28 +1,15 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { createCookie, isCookie } from './cookie.ts' +import { Cookie } from './cookie.ts' function getCookieFromSetCookie(setCookie: string): string { return setCookie.split(/;\s*/)[0] } -describe('isCookie', () => { - it('returns `true` for Cookie objects', () => { - assert.equal(isCookie(createCookie('my-cookie')), true) - }) - - it('returns `false` for non-Cookie objects', () => { - assert.equal(isCookie({}), false) - assert.equal(isCookie([]), false) - assert.equal(isCookie(''), false) - assert.equal(isCookie(true), false) - }) -}) - describe('cookies', () => { it('parses/serializes empty string values', async () => { - let cookie = createCookie('my-cookie') + let cookie = new Cookie('my-cookie') let setCookie = await cookie.serialize('') let value = await cookie.parse(getCookieFromSetCookie(setCookie)) @@ -30,7 +17,7 @@ describe('cookies', () => { }) it('parses/serializes unsigned string values', async () => { - let cookie = createCookie('my-cookie') + let cookie = new Cookie('my-cookie') let setCookie = await cookie.serialize('hello world') let value = await cookie.parse(getCookieFromSetCookie(setCookie)) @@ -38,7 +25,7 @@ describe('cookies', () => { }) it('parses/serializes unsigned boolean values', async () => { - let cookie = createCookie('my-cookie') + let cookie = new Cookie('my-cookie') let setCookie = await cookie.serialize(true) let value = await cookie.parse(getCookieFromSetCookie(setCookie)) @@ -46,7 +33,7 @@ describe('cookies', () => { }) it('parses/serializes signed string values', async () => { - let cookie = createCookie('my-cookie', { + let cookie = new Cookie('my-cookie', { secrets: ['secret1'], }) let setCookie = await cookie.serialize('hello michael') @@ -56,7 +43,7 @@ describe('cookies', () => { }) it('parses/serializes string values containing utf8 characters', async () => { - let cookie = createCookie('my-cookie') + let cookie = new Cookie('my-cookie') let setCookie = await cookie.serialize('日本語') let value = await cookie.parse(getCookieFromSetCookie(setCookie)) @@ -64,11 +51,11 @@ describe('cookies', () => { }) it('fails to parses signed string values with invalid signature', async () => { - let cookie = createCookie('my-cookie', { + let cookie = new Cookie('my-cookie', { secrets: ['secret1'], }) let setCookie = await cookie.serialize('hello michael') - let cookie2 = createCookie('my-cookie', { + let cookie2 = new Cookie('my-cookie', { secrets: ['secret2'], }) let value = await cookie2.parse(getCookieFromSetCookie(setCookie)) @@ -77,11 +64,11 @@ describe('cookies', () => { }) it('fails to parse signed string values with invalid signature encoding', async () => { - let cookie = createCookie('my-cookie', { + let cookie = new Cookie('my-cookie', { secrets: ['secret1'], }) let setCookie = await cookie.serialize('hello michael') - let cookie2 = createCookie('my-cookie', { + let cookie2 = new Cookie('my-cookie', { secrets: ['secret2'], }) // use characters that are invalid for base64 encoding @@ -91,7 +78,7 @@ describe('cookies', () => { }) it('parses/serializes signed object values', async () => { - let cookie = createCookie('my-cookie', { + let cookie = new Cookie('my-cookie', { secrets: ['secret1'], }) let setCookie = await cookie.serialize({ hello: 'mjackson' }) @@ -101,11 +88,11 @@ describe('cookies', () => { }) it('fails to parse signed object values with invalid signature', async () => { - let cookie = createCookie('my-cookie', { + let cookie = new Cookie('my-cookie', { secrets: ['secret1'], }) let setCookie = await cookie.serialize({ hello: 'mjackson' }) - let cookie2 = createCookie('my-cookie', { + let cookie2 = new Cookie('my-cookie', { secrets: ['secret2'], }) let value = await cookie2.parse(getCookieFromSetCookie(setCookie)) @@ -114,7 +101,7 @@ describe('cookies', () => { }) it('supports secret rotation', async () => { - let cookie = createCookie('my-cookie', { + let cookie = new Cookie('my-cookie', { secrets: ['secret1'], }) let setCookie = await cookie.serialize({ hello: 'mjackson' }) @@ -123,7 +110,7 @@ describe('cookies', () => { assert.deepEqual(value, { hello: 'mjackson' }) // A new secret enters the rotation... - cookie = createCookie('my-cookie', { + cookie = new Cookie('my-cookie', { secrets: ['secret2', 'secret1'], }) @@ -137,11 +124,11 @@ describe('cookies', () => { }) it('makes the default secrets to be an empty array', async () => { - let cookie = createCookie('my-cookie') + let cookie = new Cookie('my-cookie') assert.equal(cookie.isSigned, false) - let cookie2 = createCookie('my-cookie2', { + let cookie2 = new Cookie('my-cookie2', { secrets: undefined, }) @@ -149,12 +136,12 @@ describe('cookies', () => { }) it('makes the default path of cookies to be /', async () => { - let cookie = createCookie('my-cookie') + let cookie = new Cookie('my-cookie') let setCookie = await cookie.serialize('hello world') assert.ok(setCookie.includes('Path=/')) - let cookie2 = createCookie('my-cookie2') + let cookie2 = new Cookie('my-cookie2') let setCookie2 = await cookie2.serialize('hello world', { path: '/about', @@ -163,12 +150,12 @@ describe('cookies', () => { }) it('supports the Priority attribute', async () => { - let cookie = createCookie('my-cookie') + let cookie = new Cookie('my-cookie') let setCookie = await cookie.serialize('hello world') assert.ok(!setCookie.includes('Priority')) - let cookie2 = createCookie('my-cookie2') + let cookie2 = new Cookie('my-cookie2') let setCookie2 = await cookie2.serialize('hello world', { priority: 'high', @@ -185,7 +172,7 @@ describe('cookies', () => { } try { - createCookie('my-cookie', { expires: new Date(Date.now() + 60_000) }) + new Cookie('my-cookie', { expires: new Date(Date.now() + 60_000) }) assert.equal(consoleCalls.length, 1) assert.ok(consoleCalls[0].includes('The "my-cookie" cookie has an "expires" property set')) assert.ok( diff --git a/packages/cookie/src/lib/cookie.ts b/packages/cookie/src/lib/cookie.ts index 4d515e6c625..fe4dea9657d 100644 --- a/packages/cookie/src/lib/cookie.ts +++ b/packages/cookie/src/lib/cookie.ts @@ -4,19 +4,18 @@ import { parse, serialize } from 'cookie' import { sign, unsign } from './crypto.ts' import { warnOnce } from './warnings.ts' -interface CookieSignatureOptions { - /** - * An array of secrets that may be used to sign/unsign the value of a cookie. - * - * The array makes it easy to rotate secrets. New secrets should be added to - * the beginning of the array. `cookie.serialize()` will always use the first - * value in the array, but `cookie.parse()` may use any of them so that - * cookies that were signed with older secrets still work. - */ - secrets?: string[] -} - -export type CookieOptions = ParseOptions & SerializeOptions & CookieSignatureOptions +export type CookieOptions = ParseOptions & + SerializeOptions & { + /** + * An array of secrets that may be used to sign/unsign the value of a cookie. + * + * The array makes it easy to rotate secrets. New secrets should be added to + * the beginning of the array. `cookie.serialize()` will always use the first + * value in the array, but `cookie.parse()` may use any of them so that + * cookies that were signed with older secrets still work. + */ + secrets?: string[] + } /** * A HTTP cookie. @@ -26,98 +25,78 @@ export type CookieOptions = ParseOptions & SerializeOptions & CookieSignatureOpt * `serialize()` methods that allow a single instance to be reused for * parsing/encoding multiple different values. */ -export interface Cookie { +export class Cookie { + readonly name: string + readonly #secrets: string[] + readonly #options: SerializeOptions & ParseOptions + /** - * The name of the cookie, used in the `Cookie` and `Set-Cookie` headers. + * Creates a logical container for managing a browser cookie from the server. */ - readonly name: string + constructor(name: string, cookieOptions: CookieOptions = {}) { + let { secrets = [], ...options } = { + path: '/', + sameSite: 'lax' as const, + ...cookieOptions, + } + + warnOnceAboutExpiresCookie(name, options.expires) + + this.name = name + this.#secrets = secrets + this.#options = options + } /** * True if this cookie uses one or more secrets for verification. */ - readonly isSigned: boolean + get isSigned(): boolean { + return this.#secrets.length > 0 + } /** * The Date this cookie expires. * * Note: This is calculated at access time using `maxAge` when no `expires` - * option is provided to `createCookie()`. + * option is provided to the constructor. */ - readonly expires?: Date + get expires(): Date | undefined { + // Max-Age takes precedence over Expires + return typeof this.#options.maxAge !== 'undefined' + ? new Date(Date.now() + this.#options.maxAge * 1000) + : this.#options.expires + } /** * Parses a raw `Cookie` header and returns the value of this cookie or * `null` if it's not present. */ - parse(cookieHeader: string | null, options?: ParseOptions): Promise + async parse(cookieHeader: string | null, parseOptions?: ParseOptions): Promise { + if (!cookieHeader) return null + let cookies = parse(cookieHeader, { ...this.#options, ...parseOptions }) + if (this.name in cookies) { + let value = cookies[this.name] + if (typeof value === 'string' && value !== '') { + let decoded = await decodeCookieValue(value, this.#secrets) + return decoded + } else { + return '' + } + } else { + return null + } + } /** * Serializes the given value to a string and returns the `Set-Cookie` * header. */ - serialize(value: any, options?: SerializeOptions): Promise -} - -/** - * Creates a logical container for managing a browser cookie from the server. - */ -export function createCookie(name: string, cookieOptions: CookieOptions = {}): Cookie { - let { secrets = [], ...options } = { - path: '/', - sameSite: 'lax' as const, - ...cookieOptions, + async serialize(value: any, serializeOptions?: SerializeOptions): Promise { + return serialize(this.name, value === '' ? '' : await encodeCookieValue(value, this.#secrets), { + ...this.#options, + ...serializeOptions, + }) } - - warnOnceAboutExpiresCookie(name, options.expires) - - return { - get name() { - return name - }, - get isSigned() { - return secrets.length > 0 - }, - get expires() { - // Max-Age takes precedence over Expires - return typeof options.maxAge !== 'undefined' - ? new Date(Date.now() + options.maxAge * 1000) - : options.expires - }, - async parse(cookieHeader, parseOptions) { - if (!cookieHeader) return null - let cookies = parse(cookieHeader, { ...options, ...parseOptions }) - if (name in cookies) { - let value = cookies[name] - if (typeof value === 'string' && value !== '') { - let decoded = await decodeCookieValue(value, secrets) - return decoded - } else { - return '' - } - } else { - return null - } - }, - async serialize(value, serializeOptions) { - return serialize(name, value === '' ? '' : await encodeCookieValue(value, secrets), { - ...options, - ...serializeOptions, - }) - }, - } -} - -/** - * Returns true if an object is a Remix cookie container. - */ -export function isCookie(object: any): object is Cookie { - return ( - object != null && - typeof object.name === 'string' && - typeof object.isSigned === 'boolean' && - typeof object.parse === 'function' && - typeof object.serialize === 'function' - ) } async function encodeCookieValue(value: any, secrets: string[]): Promise { @@ -152,7 +131,7 @@ function encodeData(value: any): string { function decodeData(value: string): any { try { return JSON.parse(decodeURIComponent(myEscape(atob(value)))) - } catch (error: unknown) { + } catch { return {} } } @@ -222,6 +201,6 @@ function warnOnceAboutExpiresCookie(name: string, expires?: Date) { `This will cause the expires value to not be updated when the session is committed. ` + `Instead, you should set the expires value when serializing the cookie. ` + `You can use \`commitSession(session, { expires })\` if using a session storage object, ` + - `or \`cookie.serialize("value", { expires })\` if you're using the cookie directly.`, + `or \`cookie.serialize("value", { expires })\` if you're using the cookie directly.` ) } From 30295601931ad42758ca085259b9ff6951575579 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 29 Oct 2025 12:08:22 -0400 Subject: [PATCH 13/37] Convert any's to unknown's --- packages/cookie/src/lib/cookie.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/cookie/src/lib/cookie.ts b/packages/cookie/src/lib/cookie.ts index fe4dea9657d..66b559e5777 100644 --- a/packages/cookie/src/lib/cookie.ts +++ b/packages/cookie/src/lib/cookie.ts @@ -71,7 +71,7 @@ export class Cookie { * Parses a raw `Cookie` header and returns the value of this cookie or * `null` if it's not present. */ - async parse(cookieHeader: string | null, parseOptions?: ParseOptions): Promise { + async parse(cookieHeader: string | null, parseOptions?: ParseOptions): Promise { if (!cookieHeader) return null let cookies = parse(cookieHeader, { ...this.#options, ...parseOptions }) if (this.name in cookies) { @@ -91,7 +91,7 @@ export class Cookie { * Serializes the given value to a string and returns the `Set-Cookie` * header. */ - async serialize(value: any, serializeOptions?: SerializeOptions): Promise { + async serialize(value: unknown, serializeOptions?: SerializeOptions): Promise { return serialize(this.name, value === '' ? '' : await encodeCookieValue(value, this.#secrets), { ...this.#options, ...serializeOptions, @@ -99,7 +99,7 @@ export class Cookie { } } -async function encodeCookieValue(value: any, secrets: string[]): Promise { +async function encodeCookieValue(value: unknown, secrets: string[]): Promise { let encoded = encodeData(value) if (secrets.length > 0) { @@ -109,7 +109,7 @@ async function encodeCookieValue(value: any, secrets: string[]): Promise return encoded } -async function decodeCookieValue(value: string, secrets: string[]): Promise { +async function decodeCookieValue(value: string, secrets: string[]): Promise { if (secrets.length > 0) { for (let secret of secrets) { let unsignedValue = await unsign(value, secret) @@ -124,11 +124,11 @@ async function decodeCookieValue(value: string, secrets: string[]): Promise return decodeData(value) } -function encodeData(value: any): string { +function encodeData(value: unknown): string { return btoa(myUnescape(encodeURIComponent(JSON.stringify(value)))) } -function decodeData(value: string): any { +function decodeData(value: string): unknown { try { return JSON.parse(decodeURIComponent(myEscape(atob(value)))) } catch { @@ -201,6 +201,6 @@ function warnOnceAboutExpiresCookie(name: string, expires?: Date) { `This will cause the expires value to not be updated when the session is committed. ` + `Instead, you should set the expires value when serializing the cookie. ` + `You can use \`commitSession(session, { expires })\` if using a session storage object, ` + - `or \`cookie.serialize("value", { expires })\` if you're using the cookie directly.` + `or \`cookie.serialize("value", { expires })\` if you're using the cookie directly.`, ) } From e9134a03a033a91e60a49787a276ec046b2133f5 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 29 Oct 2025 14:57:13 -0400 Subject: [PATCH 14/37] Revert package.json author and license changes - to be done in main --- LICENSE | 2 +- packages/fetch-proxy/LICENSE | 2 +- packages/fetch-proxy/package.json | 2 +- packages/fetch-router/LICENSE | 2 +- packages/fetch-router/package.json | 2 +- packages/file-storage/LICENSE | 2 +- packages/file-storage/package.json | 2 +- packages/form-data-parser/LICENSE | 2 +- packages/form-data-parser/package.json | 2 +- packages/headers/LICENSE | 2 +- packages/headers/package.json | 2 +- packages/lazy-file/LICENSE | 2 +- packages/lazy-file/package.json | 2 +- packages/multipart-parser/LICENSE | 2 +- packages/multipart-parser/package.json | 2 +- packages/node-fetch-server/LICENSE | 2 +- packages/node-fetch-server/package.json | 2 +- packages/route-pattern/LICENSE | 2 +- packages/route-pattern/package.json | 2 +- packages/tar-parser/LICENSE | 2 +- packages/tar-parser/package.json | 2 +- 21 files changed, 21 insertions(+), 21 deletions(-) diff --git a/LICENSE b/LICENSE index 386ae158bbb..829ea2ae0b6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) Shopify Inc. 2024 +Copyright (c) 2024 Michael Jackson Permission is hereby granted, free of charge, to any person obtaining a copy diff --git a/packages/fetch-proxy/LICENSE b/packages/fetch-proxy/LICENSE index 2a384496821..717984c0442 100644 --- a/packages/fetch-proxy/LICENSE +++ b/packages/fetch-proxy/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) Shopify Inc. 2024 +Copyright (c) 2024 Michael Jackson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/fetch-proxy/package.json b/packages/fetch-proxy/package.json index 274bb246987..b35399ea837 100644 --- a/packages/fetch-proxy/package.json +++ b/packages/fetch-proxy/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/fetch-proxy", "version": "0.6.0", "description": "An HTTP proxy for the web Fetch API", - "author": "Remix Software ", + "author": "Michael Jackson ", "license": "MIT", "repository": { "type": "git", diff --git a/packages/fetch-router/LICENSE b/packages/fetch-router/LICENSE index 2a384496821..717984c0442 100644 --- a/packages/fetch-router/LICENSE +++ b/packages/fetch-router/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) Shopify Inc. 2024 +Copyright (c) 2024 Michael Jackson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/fetch-router/package.json b/packages/fetch-router/package.json index 885f7ba87ec..bf07a179c48 100644 --- a/packages/fetch-router/package.json +++ b/packages/fetch-router/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/fetch-router", "version": "0.7.0", "description": "A minimal, composable router for the web Fetch API", - "author": "Remix Software ", + "author": "Michael Jackson ", "license": "MIT", "repository": { "type": "git", diff --git a/packages/file-storage/LICENSE b/packages/file-storage/LICENSE index 2a384496821..717984c0442 100644 --- a/packages/file-storage/LICENSE +++ b/packages/file-storage/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) Shopify Inc. 2024 +Copyright (c) 2024 Michael Jackson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/file-storage/package.json b/packages/file-storage/package.json index dcc562f9edb..0b197db4246 100644 --- a/packages/file-storage/package.json +++ b/packages/file-storage/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/file-storage", "version": "0.10.0", "description": "Key/value storage for JavaScript File objects", - "author": "Remix Software ", + "author": "Michael Jackson ", "repository": { "type": "git", "url": "git+https://github.com/remix-run/remix.git", diff --git a/packages/form-data-parser/LICENSE b/packages/form-data-parser/LICENSE index 2a384496821..717984c0442 100644 --- a/packages/form-data-parser/LICENSE +++ b/packages/form-data-parser/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) Shopify Inc. 2024 +Copyright (c) 2024 Michael Jackson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/form-data-parser/package.json b/packages/form-data-parser/package.json index c93204a7c54..98260e8e820 100644 --- a/packages/form-data-parser/package.json +++ b/packages/form-data-parser/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/form-data-parser", "version": "0.12.0", "description": "A request.formData() wrapper with streaming file upload handling", - "author": "Remix Software ", + "author": "Michael Jackson ", "repository": { "type": "git", "url": "git+https://github.com/remix-run/remix.git", diff --git a/packages/headers/LICENSE b/packages/headers/LICENSE index 2a384496821..717984c0442 100644 --- a/packages/headers/LICENSE +++ b/packages/headers/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) Shopify Inc. 2024 +Copyright (c) 2024 Michael Jackson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/headers/package.json b/packages/headers/package.json index e782fe1468e..1bde17a2e83 100644 --- a/packages/headers/package.json +++ b/packages/headers/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/headers", "version": "0.14.0", "description": "A toolkit for working with HTTP headers in JavaScript", - "author": "Remix Software ", + "author": "Michael Jackson ", "license": "MIT", "repository": { "type": "git", diff --git a/packages/lazy-file/LICENSE b/packages/lazy-file/LICENSE index 2a384496821..717984c0442 100644 --- a/packages/lazy-file/LICENSE +++ b/packages/lazy-file/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) Shopify Inc. 2024 +Copyright (c) 2024 Michael Jackson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/lazy-file/package.json b/packages/lazy-file/package.json index 52261264b6a..299cbb486e9 100644 --- a/packages/lazy-file/package.json +++ b/packages/lazy-file/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/lazy-file", "version": "3.6.0", "description": "Lazy, streaming files for JavaScript", - "author": "Remix Software ", + "author": "Michael Jackson ", "repository": { "type": "git", "url": "git+https://github.com/remix-run/remix.git", diff --git a/packages/multipart-parser/LICENSE b/packages/multipart-parser/LICENSE index 2a384496821..717984c0442 100644 --- a/packages/multipart-parser/LICENSE +++ b/packages/multipart-parser/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) Shopify Inc. 2024 +Copyright (c) 2024 Michael Jackson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/multipart-parser/package.json b/packages/multipart-parser/package.json index 5b5ad3d8f16..5fb74ec9d03 100644 --- a/packages/multipart-parser/package.json +++ b/packages/multipart-parser/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/multipart-parser", "version": "0.12.0", "description": "A fast, efficient parser for multipart streams in any JavaScript environment", - "author": "Remix Software ", + "author": "Michael Jackson ", "repository": { "type": "git", "url": "git+https://github.com/remix-run/remix.git", diff --git a/packages/node-fetch-server/LICENSE b/packages/node-fetch-server/LICENSE index 2a384496821..717984c0442 100644 --- a/packages/node-fetch-server/LICENSE +++ b/packages/node-fetch-server/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) Shopify Inc. 2024 +Copyright (c) 2024 Michael Jackson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/node-fetch-server/package.json b/packages/node-fetch-server/package.json index 73558fa0532..31fcc7ab236 100644 --- a/packages/node-fetch-server/package.json +++ b/packages/node-fetch-server/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/node-fetch-server", "version": "0.11.0", "description": "Build servers for Node.js using the web fetch API", - "author": "Remix Software ", + "author": "Michael Jackson ", "repository": { "type": "git", "url": "git+https://github.com/remix-run/remix.git", diff --git a/packages/route-pattern/LICENSE b/packages/route-pattern/LICENSE index 2a384496821..717984c0442 100644 --- a/packages/route-pattern/LICENSE +++ b/packages/route-pattern/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) Shopify Inc. 2024 +Copyright (c) 2024 Michael Jackson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/route-pattern/package.json b/packages/route-pattern/package.json index e77b151f7d6..0e79b5bccaa 100644 --- a/packages/route-pattern/package.json +++ b/packages/route-pattern/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/route-pattern", "version": "0.14.0", "description": "Match and generate URLs with strong typing", - "author": "Remix Software ", + "author": "Michael Jackson ", "license": "MIT", "repository": { "type": "git", diff --git a/packages/tar-parser/LICENSE b/packages/tar-parser/LICENSE index 2a384496821..717984c0442 100644 --- a/packages/tar-parser/LICENSE +++ b/packages/tar-parser/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) Shopify Inc. 2024 +Copyright (c) 2024 Michael Jackson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/tar-parser/package.json b/packages/tar-parser/package.json index 6ce8386979b..cd2d1d21cbb 100644 --- a/packages/tar-parser/package.json +++ b/packages/tar-parser/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/tar-parser", "version": "0.5.0", "description": "A fast, efficient parser for tar streams in any JavaScript environment", - "author": "Remix Software ", + "author": "Michael Jackson ", "license": "MIT", "repository": { "type": "git", From 8652d7b0e992647c3c0c3f850fc791f8b83ad51c Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 29 Oct 2025 15:00:44 -0400 Subject: [PATCH 15/37] Remove stale code block from README --- packages/cookie/README.md | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/packages/cookie/README.md b/packages/cookie/README.md index 8c1e37fe13c..363e4471aed 100644 --- a/packages/cookie/README.md +++ b/packages/cookie/README.md @@ -190,29 +190,6 @@ let signedCookie = new Cookie('signed', { secrets: ['secret'] }) await signedCookie.parse('signed=value.badsignature') // null ``` -```ts -// In your Remix loader/action -import type { LoaderFunctionArgs } from '@remix-run/node' -import { Cookie } from '@remix-run/cookie' - -let sessionCookie = new Cookie('session', { - secrets: [process.env.SESSION_SECRET], - httpOnly: true, - secure: true, - sameSite: 'lax', - maxAge: 60 * 60 * 24 * 30, // 30 days -}) - -export async function loader({ request }: LoaderFunctionArgs) { - let cookieHeader = request.headers.get('Cookie') - let session = await sessionCookie.parse(cookieHeader) - - return { - user: session?.userId ? await getUser(session.userId) : null, - } -} -``` - ## API Reference ### `Cookie` Class From 80d6485d3f187b0e0c6123226a2c9e98545dc2bb Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 22 Oct 2025 15:51:49 -0400 Subject: [PATCH 16/37] Add @remix-run/session package --- packages/session/CHANGELOG.md | 5 + packages/session/LICENSE | 21 ++ packages/session/README.md | 13 + packages/session/package.json | 59 +++++ packages/session/src/index.ts | 10 + packages/session/src/lib/cookie-storage.ts | 52 ++++ packages/session/src/lib/memory-storage.ts | 57 +++++ packages/session/src/lib/session.test.ts | 231 +++++++++++++++++ packages/session/src/lib/session.ts | 282 +++++++++++++++++++++ packages/session/src/lib/warnings.ts | 8 + packages/session/tsconfig.build.json | 11 + packages/session/tsconfig.json | 12 + pnpm-lock.yaml | 16 ++ 13 files changed, 777 insertions(+) create mode 100644 packages/session/CHANGELOG.md create mode 100644 packages/session/LICENSE create mode 100644 packages/session/README.md create mode 100644 packages/session/package.json create mode 100644 packages/session/src/index.ts create mode 100644 packages/session/src/lib/cookie-storage.ts create mode 100644 packages/session/src/lib/memory-storage.ts create mode 100644 packages/session/src/lib/session.test.ts create mode 100644 packages/session/src/lib/session.ts create mode 100644 packages/session/src/lib/warnings.ts create mode 100644 packages/session/tsconfig.build.json create mode 100644 packages/session/tsconfig.json diff --git a/packages/session/CHANGELOG.md b/packages/session/CHANGELOG.md new file mode 100644 index 00000000000..7785caed7a8 --- /dev/null +++ b/packages/session/CHANGELOG.md @@ -0,0 +1,5 @@ +# `@remix-run/session` CHANGELOG + +This is the changelog for [`@remix-run/session`](https://github.com/remix-run/remix/tree/main/packages/session). It follows [semantic versioning](https://semver.org/). + +## HEAD diff --git a/packages/session/LICENSE b/packages/session/LICENSE new file mode 100644 index 00000000000..717984c0442 --- /dev/null +++ b/packages/session/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Michael Jackson + +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 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/packages/session/README.md b/packages/session/README.md new file mode 100644 index 00000000000..723965f926e --- /dev/null +++ b/packages/session/README.md @@ -0,0 +1,13 @@ +# @remix-run/session + +## Installation + +```sh +npm install @remix-run/session +``` + +## Overview + +## License + +See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE) diff --git a/packages/session/package.json b/packages/session/package.json new file mode 100644 index 00000000000..9ae948c94b1 --- /dev/null +++ b/packages/session/package.json @@ -0,0 +1,59 @@ +{ + "name": "@remix-run/session", + "version": "0.1.0", + "description": "A toolkit for working with sessions in JavaScript", + "author": "Michael Jackson ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/remix-run/remix.git", + "directory": "packages/session" + }, + "homepage": "https://github.com/remix-run/remix/tree/main/packages/session#readme", + "files": [ + "LICENSE", + "README.md", + "dist", + "src", + "!src/**/*.test.ts" + ], + "type": "module", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + } + }, + "dependencies": { + "@remix-run/cookie": "workspace:^", + "cookie": "^1.0.2" + }, + "devDependencies": { + "@types/node": "^24.6.0", + "esbuild": "^0.25.10" + }, + "scripts": { + "build": "pnpm run clean && pnpm run build:types && pnpm run build:esm", + "build:esm": "esbuild src/index.ts --bundle --external:cookie --external:@remix-run/cookie --outfile=dist/index.js --format=esm --platform=neutral --sourcemap", + "build:types": "tsc --project tsconfig.build.json", + "clean": "rm -rf dist", + "prepublishOnly": "pnpm run build", + "test": "node --disable-warning=ExperimentalWarning --test './src/**/*.test.ts'", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "http", + "session", + "sessions", + "http-sessions", + "cookie", + "cookies" + ] +} diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts new file mode 100644 index 00000000000..77b953a58a3 --- /dev/null +++ b/packages/session/src/index.ts @@ -0,0 +1,10 @@ +export { + type Session, + type SessionData, + type SessionIdStorageStrategy, + type SessionStorage, + type FlashSessionData, + createSession, + createSessionStorage, + isSession, +} from './lib/session.ts' diff --git a/packages/session/src/lib/cookie-storage.ts b/packages/session/src/lib/cookie-storage.ts new file mode 100644 index 00000000000..f4533a6279c --- /dev/null +++ b/packages/session/src/lib/cookie-storage.ts @@ -0,0 +1,52 @@ +import { createCookie, isCookie } from '@remix-run/cookie' +import type { SessionStorage, SessionIdStorageStrategy, SessionData } from './session.ts' +import { warnOnceAboutSigningSessionCookie, createSession } from './session.ts' + +interface CookieSessionStorageOptions { + /** + * The Cookie used to store the session data on the client, or options used + * to automatically create one. + */ + cookie?: SessionIdStorageStrategy['cookie'] +} + +/** + * Creates and returns a SessionStorage object that stores all session data + * directly in the session cookie itself. + * + * This has the advantage that no database or other backend services are + * needed, and can help to simplify some load-balanced scenarios. However, it + * also has the limitation that serialized session data may not exceed the + * browser's maximum cookie size. Trade-offs! + */ +export function createCookieSessionStorage({ + cookie: cookieArg, +}: CookieSessionStorageOptions = {}): SessionStorage { + let cookie = isCookie(cookieArg) + ? cookieArg + : createCookie(cookieArg?.name || '__session', cookieArg) + + warnOnceAboutSigningSessionCookie(cookie) + + return { + async getSession(cookieHeader, options) { + return createSession((cookieHeader && (await cookie.parse(cookieHeader, options))) || {}) + }, + async commitSession(session, options) { + let serializedCookie = await cookie.serialize(session.data, options) + if (serializedCookie.length > 4096) { + throw new Error( + 'Cookie length will exceed browser maximum. Length: ' + serializedCookie.length, + ) + } + return serializedCookie + }, + async destroySession(_session, options) { + return cookie.serialize('', { + ...options, + maxAge: undefined, + expires: new Date(0), + }) + }, + } +} diff --git a/packages/session/src/lib/memory-storage.ts b/packages/session/src/lib/memory-storage.ts new file mode 100644 index 00000000000..315a281c80f --- /dev/null +++ b/packages/session/src/lib/memory-storage.ts @@ -0,0 +1,57 @@ +import type { + SessionData, + SessionStorage, + SessionIdStorageStrategy, + FlashSessionData, +} from './session.ts' +import { createSessionStorage } from './session.ts' + +interface MemorySessionStorageOptions { + /** + * The Cookie used to store the session id on the client, or options used + * to automatically create one. + */ + cookie?: SessionIdStorageStrategy['cookie'] +} + +/** + * Creates and returns a simple in-memory SessionStorage object, mostly useful + * for testing and as a reference implementation. + * + * Note: This storage does not scale beyond a single process, so it is not + * suitable for most production scenarios. + */ +export function createMemorySessionStorage({ + cookie, +}: MemorySessionStorageOptions = {}): SessionStorage { + let map = new Map; expires?: Date }>() + + return createSessionStorage({ + cookie, + async createData(data, expires) { + let id = Math.random().toString(36).substring(2, 10) + map.set(id, { data, expires }) + return id + }, + async readData(id) { + if (map.has(id)) { + let { data, expires } = map.get(id)! + + if (!expires || expires > new Date()) { + return data + } + + // Remove expired session data. + if (expires) map.delete(id) + } + + return null + }, + async updateData(id, data, expires) { + map.set(id, { data, expires }) + }, + async deleteData(id) { + map.delete(id) + }, + }) +} diff --git a/packages/session/src/lib/session.test.ts b/packages/session/src/lib/session.test.ts new file mode 100644 index 00000000000..c33d733510d --- /dev/null +++ b/packages/session/src/lib/session.test.ts @@ -0,0 +1,231 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { createSession, isSession } from './session.ts' +import { createCookieSessionStorage } from './cookie-storage.ts' +import { createMemorySessionStorage } from './memory-storage.ts' + +function getCookieFromSetCookie(setCookie: string): string { + return setCookie.split(/;\s*/)[0] +} + +describe('Session', () => { + it('has an empty id by default', () => { + assert.equal(createSession().id, '') + }) + + it('correctly stores and retrieves values', () => { + let session = createSession() + + session.set('user', 'mjackson') + session.flash('error', 'boom') + + assert.equal(session.has('user'), true) + assert.equal(session.get('user'), 'mjackson') + // Normal values should remain in the session after get() + assert.equal(session.has('user'), true) + assert.equal(session.get('user'), 'mjackson') + + assert.equal(session.has('error'), true) + assert.equal(session.get('error'), 'boom') + // Flash values disappear after the first get() + assert.equal(session.has('error'), false) + assert.equal(session.get('error'), undefined) + + session.unset('user') + + assert.equal(session.has('user'), false) + assert.equal(session.get('user'), undefined) + }) +}) + +describe('isSession', () => { + it('returns `true` for Session objects', () => { + assert.equal(isSession(createSession()), true) + }) + + it('returns `false` for non-Session objects', () => { + assert.equal(isSession({}), false) + assert.equal(isSession([]), false) + assert.equal(isSession(''), false) + assert.equal(isSession(true), false) + }) +}) + +describe('In-memory session storage', () => { + it('persists session data across requests', async () => { + let { getSession, commitSession } = createMemorySessionStorage({ + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + + assert.equal(session.get('user'), 'mjackson') + }) + + it('uses random hash keys as session ids', async () => { + let { getSession, commitSession } = createMemorySessionStorage({ + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + assert.match(session.id, /^[a-z0-9]{8}$/) + }) +}) + +describe('Cookie session storage', () => { + it('persists session data across requests', async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + + assert.equal(session.get('user'), 'mjackson') + }) + + it('returns an empty session for cookies that are not signed properly', async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + + assert.equal(session.get('user'), 'mjackson') + + let setCookie = await commitSession(session) + session = await getSession( + // Tamper with the session cookie... + getCookieFromSetCookie(setCookie).slice(0, -1), + ) + + assert.equal(session.get('user'), undefined) + }) + + it('"makes the default path of cookies to be /', async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let setCookie = await commitSession(session) + assert.ok(setCookie.includes('Path=/')) + }) + + it('throws an error when the cookie size exceeds 4096 bytes', async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + let longString = Array.from({ length: 4097 }).fill('a').join('') + session.set('over4096bytes', longString) + await assert.rejects(() => commitSession(session)) + }) + + it('destroys sessions using a past date', async () => { + let originalWarn = console.warn + console.warn = () => {} + let { getSession, destroySession } = createCookieSessionStorage({ + cookie: { + secrets: ['secret1'], + }, + }) + let session = await getSession() + let setCookie = await destroySession(session) + assert.equal( + setCookie, + '__session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax', + ) + console.warn = originalWarn + }) + + it('destroys sessions that leverage maxAge', async () => { + let originalWarn = console.warn + console.warn = () => {} + let { getSession, destroySession } = createCookieSessionStorage({ + cookie: { + maxAge: 60 * 60, // 1 hour + secrets: ['secret1'], + }, + }) + let session = await getSession() + let setCookie = await destroySession(session) + assert.equal( + setCookie, + '__session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax', + ) + console.warn = originalWarn + }) + + describe('warnings when providing options you may not want to', () => { + it('warns against using `expires` when creating the session', async () => { + let warnings: string[] = [] + let originalWarn = console.warn + console.warn = (msg: string) => warnings.push(msg) + + createCookieSessionStorage({ + cookie: { + secrets: ['secret1'], + expires: new Date(Date.now() + 60_000), + }, + }) + + assert.equal(warnings.length, 1) + assert.equal( + warnings[0], + 'The "__session" cookie has an "expires" property set. This will cause the expires value to not be updated when the session is committed. Instead, you should set the expires value when serializing the cookie. You can use `commitSession(session, { expires })` if using a session storage object, or `cookie.serialize("value", { expires })` if you\'re using the cookie directly.', + ) + console.warn = originalWarn + }) + + it('warns when not passing secrets when creating the session', async () => { + let warnings: string[] = [] + let originalWarn = console.warn + console.warn = (msg: string) => warnings.push(msg) + + createCookieSessionStorage({ cookie: {} }) + + assert.equal(warnings.length, 1) + assert.equal( + warnings[0], + 'The "__session" cookie is not signed, but session cookies should be signed to prevent tampering on the client before they are sent back to the server. See https://reactrouter.com/explanation/sessions-and-cookies#signing-cookies for more information.', + ) + console.warn = originalWarn + }) + }) + + describe('when a new secret shows up in the rotation', () => { + it('unsigns old session cookies using the old secret and encodes new cookies using the new secret', async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + + assert.equal(session.get('user'), 'mjackson') + + // A new secret enters the rotation... + let storage = createCookieSessionStorage({ + cookie: { secrets: ['secret2', 'secret1'] }, + }) + getSession = storage.getSession + commitSession = storage.commitSession + + // Old cookies should still work with the old secret. + session = await storage.getSession(getCookieFromSetCookie(setCookie)) + assert.equal(session.get('user'), 'mjackson') + + // New cookies should be signed using the new secret. + let setCookie2 = await storage.commitSession(session) + assert.notEqual(setCookie2, setCookie) + }) + }) +}) diff --git a/packages/session/src/lib/session.ts b/packages/session/src/lib/session.ts new file mode 100644 index 00000000000..4853278dc87 --- /dev/null +++ b/packages/session/src/lib/session.ts @@ -0,0 +1,282 @@ +import type { ParseOptions, SerializeOptions } from 'cookie' +import type { Cookie, CookieOptions } from '@remix-run/cookie' +import { createCookie, isCookie } from '@remix-run/cookie' + +import { warnOnce } from './warnings.ts' + +/** + * An object of name/value pairs to be used in the session. + */ +export interface SessionData { + [name: string]: any +} + +/** + * Session persists data across HTTP requests. + * + * @see https://reactrouter.com/explanation/sessions-and-cookies#sessions + */ +export interface Session { + /** + * A unique identifier for this session. + * + * Note: This will be the empty string for newly created sessions and + * sessions that are not backed by a database (i.e. cookie-based sessions). + */ + readonly id: string + + /** + * The raw data contained in this session. + * + * This is useful mostly for SessionStorage internally to access the raw + * session data to persist. + */ + readonly data: FlashSessionData + + /** + * Returns `true` if the session has a value for the given `name`, `false` + * otherwise. + */ + has(name: (keyof Data | keyof FlashData) & string): boolean + + /** + * Returns the value for the given `name` in this session. + */ + get( + name: Key, + ): + | (Key extends keyof Data ? Data[Key] : undefined) + | (Key extends keyof FlashData ? FlashData[Key] : undefined) + | undefined + + /** + * Sets a value in the session for the given `name`. + */ + set(name: Key, value: Data[Key]): void + + /** + * Sets a value in the session that is only valid until the next `get()`. + * This can be useful for temporary values, like error messages. + */ + flash(name: Key, value: FlashData[Key]): void + + /** + * Removes a value from the session. + */ + unset(name: keyof Data & string): void +} + +export type FlashSessionData = Partial< + Data & { + [Key in keyof FlashData as FlashDataKey]: FlashData[Key] + } +> +type FlashDataKey = `__flash_${Key}__` +function flash(name: Key): FlashDataKey { + return `__flash_${name}__` +} + +type CreateSessionFunction = ( + initialData?: Data, + id?: string, +) => Session + +/** + * Creates a new Session object. + * + * Note: This function is typically not invoked directly by application code. + * Instead, use a `SessionStorage` object's `getSession` method. + */ +export const createSession: CreateSessionFunction = ( + initialData: Partial = {}, + id = '', +): Session => { + let map = new Map(Object.entries(initialData)) as Map< + keyof Data | FlashDataKey, + any + > + + return { + get id() { + return id + }, + get data() { + return Object.fromEntries(map) as FlashSessionData + }, + has(name) { + return map.has(name as keyof Data) || map.has(flash(name as keyof FlashData & string)) + }, + get(name) { + if (map.has(name as keyof Data)) return map.get(name as keyof Data) + + let flashName = flash(name as keyof FlashData & string) + if (map.has(flashName)) { + let value = map.get(flashName) + map.delete(flashName) + return value + } + + return undefined + }, + set(name, value) { + map.set(name, value) + }, + flash(name, value) { + map.set(flash(name), value) + }, + unset(name) { + map.delete(name) + }, + } +} + +type IsSessionFunction = (object: any) => object is Session + +/** + * Returns true if an object is a React Router session. + * + * @see https://reactrouter.com/api/utils/isSession + */ +export const isSession: IsSessionFunction = (object): object is Session => { + return ( + object != null && + typeof object.id === 'string' && + typeof object.data !== 'undefined' && + typeof object.has === 'function' && + typeof object.get === 'function' && + typeof object.set === 'function' && + typeof object.flash === 'function' && + typeof object.unset === 'function' + ) +} + +/** + * SessionStorage stores session data between HTTP requests and knows how to + * parse and create cookies. + * + * A SessionStorage creates Session objects using a `Cookie` header as input. + * Then, later it generates the `Set-Cookie` header to be used in the response. + */ +export interface SessionStorage { + /** + * Parses a Cookie header from a HTTP request and returns the associated + * Session. If there is no session associated with the cookie, this will + * return a new Session with no data. + */ + getSession: ( + cookieHeader?: string | null, + options?: ParseOptions, + ) => Promise> + + /** + * Stores all data in the Session and returns the Set-Cookie header to be + * used in the HTTP response. + */ + commitSession: (session: Session, options?: SerializeOptions) => Promise + + /** + * Deletes all data associated with the Session and returns the Set-Cookie + * header to be used in the HTTP response. + */ + destroySession: (session: Session, options?: SerializeOptions) => Promise +} + +/** + * SessionIdStorageStrategy is designed to allow anyone to easily build their + * own SessionStorage using `createSessionStorage(strategy)`. + * + * This strategy describes a common scenario where the session id is stored in + * a cookie but the actual session data is stored elsewhere, usually in a + * database or on disk. A set of create, read, update, and delete operations + * are provided for managing the session data. + */ +export interface SessionIdStorageStrategy { + /** + * The Cookie used to store the session id, or options used to automatically + * create one. + */ + cookie?: Cookie | (CookieOptions & { name?: string }) + + /** + * Creates a new record with the given data and returns the session id. + */ + createData: (data: FlashSessionData, expires?: Date) => Promise + + /** + * Returns data for a given session id, or `null` if there isn't any. + */ + readData: (id: string) => Promise | null> + + /** + * Updates data for the given session id. + */ + updateData: (id: string, data: FlashSessionData, expires?: Date) => Promise + + /** + * Deletes data for a given session id from the data store. + */ + deleteData: (id: string) => Promise +} + +/** + * Creates a SessionStorage object using a SessionIdStorageStrategy. + * + * Note: This is a low-level API that should only be used if none of the + * existing session storage options meet your requirements. + */ +export function createSessionStorage({ + cookie: cookieArg, + createData, + readData, + updateData, + deleteData, +}: SessionIdStorageStrategy): SessionStorage { + let cookie = isCookie(cookieArg) + ? cookieArg + : createCookie(cookieArg?.name || '__session', cookieArg) + + warnOnceAboutSigningSessionCookie(cookie) + + return { + async getSession(cookieHeader, options) { + let id = cookieHeader && (await cookie.parse(cookieHeader, options)) + let data = id && (await readData(id)) + return createSession(data || {}, id || '') + }, + async commitSession(session, options) { + let { id, data } = session + let expires = + options?.maxAge != null + ? new Date(Date.now() + options.maxAge * 1000) + : options?.expires != null + ? options.expires + : cookie.expires + + if (id) { + await updateData(id, data, expires) + } else { + id = await createData(data, expires) + } + + return cookie.serialize(id, options) + }, + async destroySession(session, options) { + await deleteData(session.id) + return cookie.serialize('', { + ...options, + maxAge: undefined, + expires: new Date(0), + }) + }, + } +} + +export function warnOnceAboutSigningSessionCookie(cookie: Cookie) { + warnOnce( + cookie.isSigned, + `The "${cookie.name}" cookie is not signed, but session cookies should be ` + + `signed to prevent tampering on the client before they are sent back to the ` + + `server. See https://reactrouter.com/explanation/sessions-and-cookies#signing-cookies ` + + `for more information.`, + ) +} diff --git a/packages/session/src/lib/warnings.ts b/packages/session/src/lib/warnings.ts new file mode 100644 index 00000000000..747086991d6 --- /dev/null +++ b/packages/session/src/lib/warnings.ts @@ -0,0 +1,8 @@ +const alreadyWarned: { [message: string]: boolean } = {} + +export function warnOnce(condition: boolean, message: string): void { + if (!condition && !alreadyWarned[message]) { + alreadyWarned[message] = true + console.warn(message) + } +} diff --git a/packages/session/tsconfig.build.json b/packages/session/tsconfig.build.json new file mode 100644 index 00000000000..fdeb70cad14 --- /dev/null +++ b/packages/session/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "declaration": true, + "emitDeclarationOnly": true, + "declarationMap": true, + "outDir": "./dist" + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/session/tsconfig.json b/packages/session/tsconfig.json new file mode 100644 index 00000000000..4781f83485f --- /dev/null +++ b/packages/session/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "strict": true, + "lib": ["ES2024", "DOM", "DOM.Iterable"], + "module": "ES2022", + "moduleResolution": "Bundler", + "target": "ESNext", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "verbatimModuleSyntax": true + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0125b48e393..f675372abc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -350,6 +350,22 @@ importers: specifier: ^8.2.0 version: 8.3.0 + packages/session: + dependencies: + '@remix-run/cookie': + specifier: workspace:^ + version: link:../cookie + cookie: + specifier: ^1.0.2 + version: 1.0.2 + devDependencies: + '@types/node': + specifier: ^24.6.0 + version: 24.6.0 + esbuild: + specifier: ^0.25.10 + version: 0.25.10 + packages/tar-parser: devDependencies: '@remix-run/lazy-file': From 8d4d70c251b47cbef2cd6c4c812b09115bd40eb7 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 22 Oct 2025 15:58:28 -0400 Subject: [PATCH 17/37] Add README --- packages/session/README.md | 517 ++++++++++++++++++++++++++++++++++ packages/session/src/index.ts | 3 + 2 files changed, 520 insertions(+) diff --git a/packages/session/README.md b/packages/session/README.md index 723965f926e..dc887d17512 100644 --- a/packages/session/README.md +++ b/packages/session/README.md @@ -1,5 +1,21 @@ # @remix-run/session +Powerful, flexible session management for JavaScript applications with built-in support for flash messages, multiple storage backends, and type-safe session handling. `@remix-run/session` provides a clean, intuitive API for managing user sessions with comprehensive security features and runtime-agnostic design. + +Sessions are fundamental to web applications—from user authentication and shopping carts to temporary messages and multi-step forms. While basic cookie handling can work for simple cases, real applications need robust session management with features like flash messages, secure storage, and flexible backends. + +`@remix-run/session` solves this by offering: + +- **Multiple Storage Backends:** Choose from cookie-based sessions (no server storage needed), in-memory storage (for development), or build custom storage adapters for databases and external services. +- **Flash Messages:** Built-in support for temporary values that automatically expire after one read—perfect for success messages, error notifications, and form validation feedback. +- **Type-Safe Session Data:** Full TypeScript support with generic interfaces for strongly-typed session and flash data. +- **Secure by Default:** Automatic warnings for unsigned cookies, secure cookie defaults, and protection against session tampering. +- **Custom Storage Strategies:** Extensible architecture allows you to implement any storage backend using the `SessionIdStorageStrategy` interface. +- **Web Standards Compliant:** Built on standard APIs, making it runtime-agnostic (Node.js, Bun, Deno, Cloudflare Workers). +- **Cookie Size Management:** Automatic validation and error handling for cookie size limits when using cookie storage. + +Perfect for building secure, scalable session management in your JavaScript and TypeScript applications! + ## Installation ```sh @@ -8,6 +24,507 @@ npm install @remix-run/session ## Overview +The following should give you a sense of what kinds of things you can do with this library: + +```ts +import { + createCookieSessionStorage, + createMemorySessionStorage, + createSessionStorage, +} from '@remix-run/session' + +// Cookie-based sessions (no server storage required) +let cookieStorage = createCookieSessionStorage({ + cookie: { + name: '__session', + secrets: ['s3cr3t'], // Required for security + httpOnly: true, + secure: true, + sameSite: 'lax', + maxAge: 60 * 60 * 24 * 30, // 30 days + }, +}) + +// Get session from request +let session = await cookieStorage.getSession(request.headers.get('Cookie')) + +// Store regular session data +session.set('userId', '12345') +session.set('theme', 'dark') +session.set('cartItems', ['item1', 'item2']) + +// Store flash messages (disappear after one read) +session.flash('successMessage', 'Profile updated successfully!') +session.flash('errorMessage', 'Invalid email address') + +// Check if values exist +console.log(session.has('userId')) // true +console.log(session.has('successMessage')) // true + +// Get values +console.log(session.get('userId')) // '12345' +console.log(session.get('successMessage')) // 'Profile updated successfully!' +console.log(session.get('successMessage')) // undefined (flash messages disappear) + +// Commit session to Set-Cookie header +let setCookieHeader = await cookieStorage.commitSession(session) +response.headers.set('Set-Cookie', setCookieHeader) + +// Memory-based sessions (for development/testing) +let memoryStorage = createMemorySessionStorage({ + cookie: { + name: '__session', + secrets: ['s3cr3t'], + maxAge: 60 * 60 * 24, // 24 hours + }, +}) + +// Same API as cookie storage +let memorySession = await memoryStorage.getSession() +memorySession.set('user', { id: '123', name: 'Alice' }) +let memorySetCookie = await memoryStorage.commitSession(memorySession) + +// Destroy sessions +await cookieStorage.destroySession(session) +await memoryStorage.destroySession(memorySession) + +// Session type checking +import { isSession } from '@remix-run/session' +console.log(isSession(session)) // true +console.log(isSession({})) // false +``` + +## Storage Types + +### Cookie Sessions + +Store all session data directly in encrypted cookies. No server-side storage required: + +```ts +import { createCookieSessionStorage } from '@remix-run/session' + +let storage = createCookieSessionStorage({ + cookie: { + name: '__session', + secrets: ['secret1', 'secret2'], // Support secret rotation + httpOnly: true, // Prevent XSS attacks + secure: true, // HTTPS only + sameSite: 'strict', // CSRF protection + maxAge: 60 * 60 * 24 * 7, // 7 days + }, +}) + +// Automatic cookie size validation +try { + let largeSession = await storage.getSession() + largeSession.set('data', 'x'.repeat(5000)) // Very large data + await storage.commitSession(largeSession) // Throws error if > 4KB +} catch (error) { + console.log(error.message) // "Cookie length will exceed browser maximum" +} +``` + +### Memory Sessions + +Simple in-memory storage, useful for development and testing: + +```ts +import { createMemorySessionStorage } from '@remix-run/session' + +let storage = createMemorySessionStorage({ + cookie: { + name: '__session', + secrets: ['dev-secret'], + // Session ID stored in cookie, data stored in memory + }, +}) + +// Data persists across requests until server restart +let session = await storage.getSession() +session.set('user', { id: '123', role: 'admin' }) +await storage.commitSession(session) + +// Later request with same session ID +let sameSession = await storage.getSession(cookieHeader) +console.log(sameSession.get('user')) // { id: '123', role: 'admin' } +``` + +### Custom Storage + +Implement your own storage backend using `createSessionStorage`: + +```ts +import { createSessionStorage } from '@remix-run/session' + +// Example: Database-backed session storage +let dbStorage = createSessionStorage({ + cookie: { + name: '__session', + secrets: ['db-secret'], + secure: true, + httpOnly: true, + }, + async createData(data, expires) { + // Create new session in database + let id = await db.sessions.create({ + data: JSON.stringify(data), + expires, + }) + return id + }, + async readData(id) { + // Read session from database + let session = await db.sessions.findById(id) + if (!session || (session.expires && session.expires < new Date())) { + return null + } + return JSON.parse(session.data) + }, + async updateData(id, data, expires) { + // Update session in database + await db.sessions.update(id, { + data: JSON.stringify(data), + expires, + }) + }, + async deleteData(id) { + // Delete session from database + await db.sessions.delete(id) + }, +}) + +// Example: Redis-backed session storage +let redisStorage = createSessionStorage({ + cookie: { + name: '__session', + secrets: [process.env.SESSION_SECRET], + }, + async createData(data, expires) { + let id = generateSessionId() + let ttl = expires ? Math.floor((expires.getTime() - Date.now()) / 1000) : undefined + await redis.setex(id, ttl || 86400, JSON.stringify(data)) + return id + }, + async readData(id) { + let data = await redis.get(id) + return data ? JSON.parse(data) : null + }, + async updateData(id, data, expires) { + let ttl = expires ? Math.floor((expires.getTime() - Date.now()) / 1000) : 86400 + await redis.setex(id, ttl, JSON.stringify(data)) + }, + async deleteData(id) { + await redis.del(id) + }, +}) +``` + +## Flash Messages + +Flash messages are perfect for one-time notifications: + +```ts +// Set flash messages (they disappear after first read) +session.flash('success', 'Account created successfully!') +session.flash('error', 'Invalid password') +session.flash('info', 'Please verify your email') + +// In your template/component +let successMessage = session.get('success') // 'Account created successfully!' +let errorMessage = session.get('error') // 'Invalid password' +let infoMessage = session.get('info') // 'Please verify your email' + +// Second call returns undefined (messages are consumed) +let noMessage = session.get('success') // undefined + +// Check if flash message exists before consuming +if (session.has('success')) { + let message = session.get('success') + console.log(message) +} + +// Flash messages work alongside regular session data +session.set('userId', '123') // Persistent +session.flash('welcome', 'Welcome back!') // One-time + +console.log(session.get('userId')) // '123' +console.log(session.get('welcome')) // 'Welcome back!' +console.log(session.get('userId')) // '123' (still there) +console.log(session.get('welcome')) // undefined (consumed) +``` + +## Type Safety + +Full TypeScript support with generic interfaces: + +```ts +interface UserData { + userId: string + role: 'admin' | 'user' + preferences: { + theme: string + notifications: boolean + } +} + +interface FlashData { + successMessage: string + errorMessage: string + warningMessage: string +} + +// Type-safe session storage +let typedStorage = createCookieSessionStorage({ + cookie: { secrets: ['secret'] }, +}) + +let session = await typedStorage.getSession() + +// TypeScript ensures type safety +session.set('userId', '123') // ✅ Valid +session.set('role', 'admin') // ✅ Valid +session.set('role', 'invalid') // ❌ TypeScript error + +session.flash('successMessage', 'Done!') // ✅ Valid +session.flash('errorMessage', 'Oops!') // ✅ Valid +session.flash('invalidKey', 'Bad') // ❌ TypeScript error + +// Return types are properly inferred +let userId: string | undefined = session.get('userId') +let role: 'admin' | 'user' | undefined = session.get('role') +let success: string | undefined = session.get('successMessage') +``` + +## Advanced Usage + +### Session Expiration + +Control session lifetime with flexible expiration options: + +```ts +let storage = createCookieSessionStorage({ + cookie: { + name: '__session', + secrets: ['secret'], + maxAge: 60 * 60 * 24, // Default 24 hours + }, +}) + +// Override expiration per commit +await storage.commitSession(session, { + maxAge: 60 * 60, // This session expires in 1 hour +}) + +await storage.commitSession(session, { + expires: new Date(Date.now() + 60 * 60 * 1000), // Absolute expiration +}) + +// Create session cookies (no expiration) +await storage.commitSession(session, { + maxAge: undefined, +}) +``` + +### Session Management Patterns + +```ts +// Remix loader example +export async function loader({ request }) { + let session = await storage.getSession(request.headers.get('Cookie')) + + // Check authentication + if (!session.get('userId')) { + // Flash message for login redirect + session.flash('error', 'Please log in to continue') + return redirect('/login', { + headers: { + 'Set-Cookie': await storage.commitSession(session), + }, + }) + } + + return { + user: await getUserById(session.get('userId')), + successMessage: session.get('success'), // Flash message + } +} + +// Remix action example +export async function action({ request }) { + let session = await storage.getSession(request.headers.get('Cookie')) + + try { + // Process form submission + await updateUserProfile(formData) + + // Set success flash message + session.flash('success', 'Profile updated successfully!') + + return redirect('/profile', { + headers: { + 'Set-Cookie': await storage.commitSession(session), + }, + }) + } catch (error) { + // Set error flash message + session.flash('error', error.message) + + return ( + { + error: error.message, + }, + { + headers: { + 'Set-Cookie': await storage.commitSession(session), + }, + } + ) + } +} + +// Shopping cart example +export async function addToCart({ request, params }) { + let session = await storage.getSession(request.headers.get('Cookie')) + + let cart = session.get('cart') || [] + cart.push(params.productId) + session.set('cart', cart) + + session.flash('success', 'Item added to cart!') + + return redirect('/products', { + headers: { + 'Set-Cookie': await storage.commitSession(session), + }, + }) +} +``` + +### Error Handling + +Sessions handle various error scenarios gracefully: + +```ts +// Invalid or missing cookies return empty sessions +let session = await storage.getSession(null) // Empty session +let session2 = await storage.getSession('') // Empty session +let session3 = await storage.getSession('invalid') // Empty session + +// Corrupted signed cookies return empty sessions +let storage = createCookieSessionStorage({ + cookie: { secrets: ['secret'] }, +}) + +let session = await storage.getSession('__session=corrupted.signature') +console.log(session.id) // '' (empty session) +console.log(session.get('anything')) // undefined + +// Cookie size limit handling +try { + let session = await storage.getSession() + session.set('data', 'x'.repeat(5000)) + await storage.commitSession(session) +} catch (error) { + console.log(error.message) // "Cookie length will exceed browser maximum. Length: 5234" +} +``` + +## API Reference + +### `createCookieSessionStorage(options?)` + +Creates a session storage that stores all data in encrypted cookies. + +**Parameters:** + +- `options.cookie?: Cookie | CookieOptions` - Cookie configuration + +**Returns:** `SessionStorage` + +### `createMemorySessionStorage(options?)` + +Creates a session storage that stores data in server memory. + +**Parameters:** + +- `options.cookie?: Cookie | CookieOptions` - Cookie configuration for session ID + +**Returns:** `SessionStorage` + +### `createSessionStorage(strategy)` + +Creates a custom session storage using the provided strategy. + +**Parameters:** + +- `strategy: SessionIdStorageStrategy` - Custom storage implementation + +**Returns:** `SessionStorage` + +### `createSession(initialData?, id?)` + +Creates a new session object (typically used internally). + +**Parameters:** + +- `initialData?: Data` - Initial session data +- `id?: string` - Session ID + +**Returns:** `Session` + +### `isSession(object)` + +Type guard to check if an object is a session. + +**Parameters:** + +- `object: any` - Object to test + +**Returns:** `boolean` + +### `Session` Interface + +**Properties:** + +- `id: string` - Unique session identifier (readonly) +- `data: FlashSessionData` - Raw session data (readonly) + +**Methods:** + +- `has(name: string): boolean` - Check if session has a value +- `get(name: Key): Value | undefined` - Get session value (consumes flash messages) +- `set(name: Key, value: Value): void` - Set persistent session value +- `flash(name: Key, value: Value): void` - Set flash message (one-time value) +- `unset(name: string): void` - Remove session value + +### `SessionStorage` Interface + +**Methods:** + +- `getSession(cookieHeader?: string, options?: ParseOptions): Promise` - Parse session from cookie +- `commitSession(session: Session, options?: SerializeOptions): Promise` - Serialize session to Set-Cookie header +- `destroySession(session: Session, options?: SerializeOptions): Promise` - Delete session and return clearing Set-Cookie header + +### `SessionIdStorageStrategy` Interface + +For implementing custom storage backends: + +```ts +interface SessionIdStorageStrategy { + cookie?: Cookie | CookieOptions + createData: (data: FlashSessionData, expires?: Date) => Promise + readData: (id: string) => Promise | null> + updateData: (id: string, data: FlashSessionData, expires?: Date) => Promise + deleteData: (id: string) => Promise +} +``` + +## Related Packages + +- [`cookie`](https://github.com/remix-run/remix/tree/main/packages/cookie) - Secure HTTP cookie management with signing and type safety +- [`headers`](https://github.com/remix-run/remix/tree/main/packages/headers) - Type-safe HTTP header manipulation +- [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - Build HTTP routers using the web fetch API + ## License See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE) diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index 77b953a58a3..a2d86202ba5 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -8,3 +8,6 @@ export { createSessionStorage, isSession, } from './lib/session.ts' + +export { createCookieSessionStorage } from './lib/cookie-storage.ts' +export { createMemorySessionStorage } from './lib/memory-storage.ts' From ad93a5d6eb8f1f6040bc8d38cb749c5f0993dfca Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 22 Oct 2025 16:08:57 -0400 Subject: [PATCH 18/37] Remove stale links --- packages/session/src/lib/session.test.ts | 2 +- packages/session/src/lib/session.ts | 9 ++------- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/session/src/lib/session.test.ts b/packages/session/src/lib/session.test.ts index c33d733510d..979bb5c2cd2 100644 --- a/packages/session/src/lib/session.test.ts +++ b/packages/session/src/lib/session.test.ts @@ -194,7 +194,7 @@ describe('Cookie session storage', () => { assert.equal(warnings.length, 1) assert.equal( warnings[0], - 'The "__session" cookie is not signed, but session cookies should be signed to prevent tampering on the client before they are sent back to the server. See https://reactrouter.com/explanation/sessions-and-cookies#signing-cookies for more information.', + 'The "__session" cookie is not signed, but session cookies should be signed to prevent tampering on the client before they are sent back to the server.', ) console.warn = originalWarn }) diff --git a/packages/session/src/lib/session.ts b/packages/session/src/lib/session.ts index 4853278dc87..2eb41f20f4e 100644 --- a/packages/session/src/lib/session.ts +++ b/packages/session/src/lib/session.ts @@ -13,8 +13,6 @@ export interface SessionData { /** * Session persists data across HTTP requests. - * - * @see https://reactrouter.com/explanation/sessions-and-cookies#sessions */ export interface Session { /** @@ -133,9 +131,7 @@ export const createSession: CreateSessionFunction = object is Session /** - * Returns true if an object is a React Router session. - * - * @see https://reactrouter.com/api/utils/isSession + * Returns true if an object is a Remix session. */ export const isSession: IsSessionFunction = (object): object is Session => { return ( @@ -276,7 +272,6 @@ export function warnOnceAboutSigningSessionCookie(cookie: Cookie) { cookie.isSigned, `The "${cookie.name}" cookie is not signed, but session cookies should be ` + `signed to prevent tampering on the client before they are sent back to the ` + - `server. See https://reactrouter.com/explanation/sessions-and-cookies#signing-cookies ` + - `for more information.`, + `server.`, ) } From 32da854195195311dd90e8f8d2e50431ce3e276c Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Thu, 23 Oct 2025 10:14:14 -0400 Subject: [PATCH 19/37] Updates --- packages/session/CHANGELOG.md | 2 +- packages/session/LICENSE | 2 +- packages/session/package.json | 2 +- packages/session/src/lib/session.ts | 13 +++---------- 4 files changed, 6 insertions(+), 13 deletions(-) diff --git a/packages/session/CHANGELOG.md b/packages/session/CHANGELOG.md index 7785caed7a8..be7115ff332 100644 --- a/packages/session/CHANGELOG.md +++ b/packages/session/CHANGELOG.md @@ -2,4 +2,4 @@ This is the changelog for [`@remix-run/session`](https://github.com/remix-run/remix/tree/main/packages/session). It follows [semantic versioning](https://semver.org/). -## HEAD +## Unreleased diff --git a/packages/session/LICENSE b/packages/session/LICENSE index 717984c0442..2a384496821 100644 --- a/packages/session/LICENSE +++ b/packages/session/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 Michael Jackson +Copyright (c) Shopify Inc. 2024 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/session/package.json b/packages/session/package.json index 9ae948c94b1..d7581e699c8 100644 --- a/packages/session/package.json +++ b/packages/session/package.json @@ -2,7 +2,7 @@ "name": "@remix-run/session", "version": "0.1.0", "description": "A toolkit for working with sessions in JavaScript", - "author": "Michael Jackson ", + "author": "Remix Software ", "license": "MIT", "repository": { "type": "git", diff --git a/packages/session/src/lib/session.ts b/packages/session/src/lib/session.ts index 2eb41f20f4e..c71b3f192d6 100644 --- a/packages/session/src/lib/session.ts +++ b/packages/session/src/lib/session.ts @@ -74,21 +74,16 @@ function flash(name: Key): FlashDataKey { return `__flash_${name}__` } -type CreateSessionFunction = ( - initialData?: Data, - id?: string, -) => Session - /** * Creates a new Session object. * * Note: This function is typically not invoked directly by application code. * Instead, use a `SessionStorage` object's `getSession` method. */ -export const createSession: CreateSessionFunction = ( +export function createSession( initialData: Partial = {}, id = '', -): Session => { +): Session { let map = new Map(Object.entries(initialData)) as Map< keyof Data | FlashDataKey, any @@ -128,12 +123,10 @@ export const createSession: CreateSessionFunction = object is Session - /** * Returns true if an object is a Remix session. */ -export const isSession: IsSessionFunction = (object): object is Session => { +export function isSession(object: any): object is Session { return ( object != null && typeof object.id === 'string' && From a51e480b661d2ec3f8a323b8320a196b8b497953 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 24 Oct 2025 14:02:57 -0400 Subject: [PATCH 20/37] Wire up automatic session management --- packages/fetch-router/package.json | 5 +- .../src/lib/middleware/session.ts | 30 +++ .../fetch-router/src/lib/request-context.ts | 8 +- packages/fetch-router/src/lib/router.test.ts | 236 +++++++++++++++++- packages/fetch-router/src/lib/router.ts | 26 +- packages/session/package.json | 2 +- pnpm-lock.yaml | 3 + 7 files changed, 299 insertions(+), 11 deletions(-) create mode 100644 packages/fetch-router/src/lib/middleware/session.ts diff --git a/packages/fetch-router/package.json b/packages/fetch-router/package.json index bf07a179c48..d1e86fa412f 100644 --- a/packages/fetch-router/package.json +++ b/packages/fetch-router/package.json @@ -51,11 +51,12 @@ "@remix-run/form-data-parser": "workspace:*", "@remix-run/headers": "workspace:*", "@remix-run/html-template": "workspace:*", - "@remix-run/route-pattern": "workspace:*" + "@remix-run/route-pattern": "workspace:*", + "@remix-run/session": "workspace:*" }, "scripts": { "build": "pnpm run clean && pnpm run build:types && pnpm run build:index && pnpm run build:logger-middleware && pnpm run build:response-helpers", - "build:index": "esbuild src/index.ts --bundle --external:@remix-run/form-data-parser --external:@remix-run/headers --external:@remix-run/route-pattern --outfile=dist/index.js --format=esm --platform=neutral --sourcemap", + "build:index": "esbuild src/index.ts --bundle --external:@remix-run/form-data-parser --external:@remix-run/headers --external:@remix-run/route-pattern --external:@remix-run/session --outfile=dist/index.js --format=esm --platform=neutral --sourcemap", "build:logger-middleware": "esbuild src/logger-middleware.ts --bundle --outfile=dist/logger-middleware.js --format=esm --platform=neutral --sourcemap", "build:response-helpers": "esbuild src/response-helpers.ts --bundle --outfile=dist/response-helpers.js --format=esm --platform=neutral --sourcemap", "build:types": "tsc --project tsconfig.build.json", diff --git a/packages/fetch-router/src/lib/middleware/session.ts b/packages/fetch-router/src/lib/middleware/session.ts new file mode 100644 index 00000000000..d278aab2851 --- /dev/null +++ b/packages/fetch-router/src/lib/middleware/session.ts @@ -0,0 +1,30 @@ +import type { SessionStorage } from '@remix-run/session' +import type { Middleware } from '../middleware.ts' + +export interface SessionOptions { + /** + * Session storage instance to create user sessions. + */ + sessionStorage: SessionStorage +} + +/** + * Creates a middleware handler that manages user sessions. + */ +export function session(options: SessionOptions): Middleware { + return async (context, next) => { + // No session creation - that's handled by the router when we create the + // RouterContext. This middleware just handles auto-committing sessions + // TODO: If we wanted to do the session creation in here and also keep + // `context.session` typed as `Session` (without an `| undefined`), we could + // go with a Symbol-driven empty session that we could detect in here and + // overwrite + let response = await next() + + // TODO: Implement session dirty check + let cookie = await options.sessionStorage.commitSession(context.session) + response.headers.append('Set-Cookie', cookie) + + return response + } +} diff --git a/packages/fetch-router/src/lib/request-context.ts b/packages/fetch-router/src/lib/request-context.ts index dbf6fca3d2a..c3d6eac0227 100644 --- a/packages/fetch-router/src/lib/request-context.ts +++ b/packages/fetch-router/src/lib/request-context.ts @@ -1,4 +1,5 @@ import SuperHeaders from '@remix-run/headers' +import { type Session } from '@remix-run/session' import { AppStorage } from './app-storage.ts' import type { RequestBodyMethod, RequestMethod } from './request-methods.ts' @@ -34,6 +35,10 @@ export class RequestContext< * The original request that was dispatched to the router. */ request: Request + /** + * Active session for the request + */ + session: Session /** * Shared application-specific storage. */ @@ -50,12 +55,13 @@ export class RequestContext< */ headers: SuperHeaders - constructor(request: Request) { + constructor(request: Request, session: Session) { this.formData = undefined as any this.method = request.method.toUpperCase() as RequestMethod this.params = {} as Params this.request = request this.storage = new AppStorage() + this.session = session this.headers = new SuperHeaders(request.headers) this.url = new URL(request.url) } diff --git a/packages/fetch-router/src/lib/router.test.ts b/packages/fetch-router/src/lib/router.test.ts index 9a272c96992..180df29f67f 100644 --- a/packages/fetch-router/src/lib/router.test.ts +++ b/packages/fetch-router/src/lib/router.test.ts @@ -1,6 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it, mock } from 'node:test' import { RegExpMatcher, RoutePattern } from '@remix-run/route-pattern' +import { createMemorySessionStorage } from '@remix-run/session' import { createStorageKey } from './app-storage.ts' import { RequestContext } from './request-context.ts' @@ -966,7 +967,8 @@ describe('router.dispatch()', () => { }) let request = new Request('https://remix.run/123') - let context = new RequestContext(request) + let session = await createMemorySessionStorage().getSession() + let context = new RequestContext(request, session) context.storage.set(storageKey, 'value') let response = await router.dispatch(context) @@ -1461,8 +1463,10 @@ describe('abort signal support', () => { let controller = new AbortController() router.get('/', async () => { - // Abort while handler is running - controller.abort() + // Abort in 1ms, while handler is running. We do this async to ensure + // `router.fetch()` rejects asynchronously so that `assert.rejects` doesn't + // re-throw the error and cause a false negative + setTimeout(() => controller.abort(), 1) // Simulate some async work await new Promise((resolve) => setTimeout(resolve, 10)) return new Response('Home') @@ -1574,7 +1578,10 @@ describe('abort signal support', () => { let controller = new AbortController() router.use(async () => { - controller.abort() + // Abort in 1ms, while handler is running. We do this async to ensure + // `router.fetch()` rejects asynchronously so that `assert.rejects` doesn't + // re-throw the error and cause a false negative + setTimeout(() => controller.abort(), 1) await new Promise((resolve) => setTimeout(resolve, 10)) }) @@ -1596,7 +1603,10 @@ describe('abort signal support', () => { let controller = new AbortController() adminRouter.get('/', async () => { - controller.abort() + // Abort in 1ms, while handler is running. We do this async to ensure + // `router.fetch()` rejects asynchronously so that `assert.rejects` doesn't + // re-throw the error and cause a false negative + setTimeout(() => controller.abort(), 1) await new Promise((resolve) => setTimeout(resolve, 10)) return new Response('Admin') }) @@ -1641,7 +1651,10 @@ describe('abort signal support', () => { // Upstream middleware that aborts router.use(async () => { - controller.abort() + // Abort in 1ms, while handler is running. We do this async to ensure + // `router.fetch()` rejects asynchronously so that `assert.rejects` doesn't + // re-throw the error and cause a false negative + setTimeout(() => controller.abort(), 1) await new Promise((resolve) => setTimeout(resolve, 10)) }) @@ -1670,3 +1683,214 @@ describe('abort signal support', () => { assert.equal(handlerCalled, false) }) }) + +describe('sessions', () => { + it('automatically provides a cookie-based session', async () => { + let routes = createRoutes({ + home: '/', + }) + + let router = createRouter() + + let requestLog: string[] = [] + + router.use(({ session }) => { + requestLog.push(`middleware: ${session.get('name')}`) + }) + + router.get(routes.home, ({ session }) => { + if (session.has('name')) { + requestLog.push(`handler: ${session?.get('name')}`) + } else { + requestLog.push(`setting name Remix`) + session.set('name', 'Remix') + } + + return new Response('Home') + }) + + // Session creation + let response = await router.fetch('https://remix.run') + + assert.equal(await response.text(), 'Home') + assert.deepEqual(requestLog, ['middleware: undefined', 'setting name Remix']) + + // Grab the set-cookie header and extract/decode the session to ensure that + // it is a cookie session that contains the data in the cookie + let cookie = response.headers.get('Set-Cookie')?.split(';')[0] || '' + let session = JSON.parse(atob(decodeURIComponent(cookie.split('=')[1]))) + assert.deepEqual(session, { name: 'Remix' }) + + // Session parsing + response = await router.fetch('https://remix.run', { + headers: { + Cookie: response.headers.get('Set-Cookie')?.split(';')[0] || '', + }, + }) + + assert.equal(await response.text(), 'Home') + assert.deepEqual(requestLog, [ + 'middleware: undefined', + 'setting name Remix', + 'middleware: Remix', + 'handler: Remix', + ]) + }) + + it('accepts a sessionStorage for user-controlled session implementations', async () => { + let routes = createRoutes({ + home: '/', + }) + + let router = createRouter({ sessionStorage: createMemorySessionStorage() }) + + let requestLog: string[] = [] + + router.use(({ session }) => { + requestLog.push(`middleware: ${session?.get('name')}`) + }) + + router.get(routes.home, ({ session }) => { + if (session?.has('name')) { + requestLog.push(`handler: ${session?.get('name')}`) + } else { + requestLog.push(`setting name Remix`) + session?.set('name', 'Remix') + } + + return new Response('Home') + }) + + // Session creation + let response = await router.fetch('https://remix.run') + + assert.equal(await response.text(), 'Home') + assert.deepEqual(requestLog, ['middleware: undefined', 'setting name Remix']) + + // Grab the set-cookie header and extract/decode the session to ensure that + // it only contains the sessionId proving that this is actually a memory + // session + let cookie = response.headers.get('Set-Cookie')?.split(';')[0] || '' + let sessionId = JSON.parse(atob(decodeURIComponent(cookie.split('=')[1]))) + assert.equal(sessionId.length, 8) + + // Session parsing + response = await router.fetch('https://remix.run', { + headers: { Cookie: cookie }, + }) + + assert.equal(await response.text(), 'Home') + assert.deepEqual(requestLog, [ + 'middleware: undefined', + 'setting name Remix', + 'middleware: Remix', + 'handler: Remix', + ]) + }) + + it('provides the session to the default handler', async () => { + let routes = createRoutes({ + home: '/', + }) + + let requestLog: string[] = [] + + let router = createRouter({ + defaultHandler: ({ url, session }) => { + requestLog.push(`default handler: ${session?.get('name')}`) + return new Response(`Not Found: ${url.pathname}`) + }, + }) + + router.use(({ session }) => { + requestLog.push(`middleware: ${session.get('name')}`) + }) + + router.get(routes.home, ({ session }) => { + requestLog.push(`setting name Remix`) + session.set('name', 'Remix') + return new Response('Home') + }) + + // Session creation + let response = await router.fetch('https://remix.run') + + assert.equal(await response.text(), 'Home') + assert.deepEqual(requestLog, ['middleware: undefined', 'setting name Remix']) + + // Grab the set-cookie header and extract/decode the session to ensure that + // it is a cookie session that contains the data in the cookie + let cookie = response.headers.get('Set-Cookie')?.split(';')[0] || '' + let session = JSON.parse(atob(decodeURIComponent(cookie.split('=')[1]))) + assert.deepEqual(session, { name: 'Remix' }) + + // Session parsing + response = await router.fetch('https://remix.run/junk', { + headers: { + Cookie: response.headers.get('Set-Cookie')?.split(';')[0] || '', + }, + }) + + assert.equal(await response.text(), 'Not Found: /junk') + assert.deepEqual(requestLog, [ + 'middleware: undefined', + 'setting name Remix', + 'default handler: Remix', + ]) + }) + + it('exposes session to sub-routers', async () => { + let routes = createRoutes({ + home: '/', + }) + + let router = createRouter() + router.get(routes.home, () => { + return new Response('Home') + }) + + let blogRoutes = createRoutes({ + index: '/', + }) + + let blogRouter = createRouter() + + let requestLog: string[] = [] + + blogRouter.use(({ session }) => { + requestLog.push(`middleware: ${session.get('name')}`) + }) + + blogRouter.get(blogRoutes.index, ({ session }) => { + if (session.has('name')) { + requestLog.push(`handler: ${session?.get('name')}`) + } else { + requestLog.push(`setting name Remix`) + session.set('name', 'Remix') + } + + return new Response('Blog') + }) + + router.mount('/blog', blogRouter) + + // Session creation + let response = await router.fetch('https://remix.run/blog') + assert.equal(await response.text(), 'Blog') + assert.deepEqual(requestLog, ['middleware: undefined', 'setting name Remix']) + + // Session parsing + response = await router.fetch('https://remix.run/blog', { + headers: { + Cookie: response.headers.get('Set-Cookie')?.split(';')[0] || '', + }, + }) + assert.equal(await response.text(), 'Blog') + assert.deepEqual(requestLog, [ + 'middleware: undefined', + 'setting name Remix', + 'middleware: Remix', + 'handler: Remix', + ]) + }) +}) diff --git a/packages/fetch-router/src/lib/router.ts b/packages/fetch-router/src/lib/router.ts index 5b7feed00f0..fd53dfdf835 100644 --- a/packages/fetch-router/src/lib/router.ts +++ b/packages/fetch-router/src/lib/router.ts @@ -14,6 +14,9 @@ import { isRequestHandlerWithMiddleware, isRouteHandlersWithMiddleware } from '. import type { RouteHandlers, RouteHandler } from './route-handlers.ts' import { Route } from './route-map.ts' import type { RouteMap } from './route-map.ts' +import type { SessionStorage } from '@remix-run/session' +import { createCookieSessionStorage } from '@remix-run/session' +import { session } from './middleware/session.ts' export interface RouterOptions { /** @@ -36,6 +39,10 @@ export interface RouterOptions { * Set `false` to disable form data parsing. */ parseFormData?: (ParseFormDataOptions & { suppressErrors?: boolean }) | boolean + /** + * Session storage instance to create user sessions. + */ + sessionStorage?: SessionStorage /** * A function that handles file uploads. It receives a `FileUpload` object and may return any * value that is a valid `FormData` value. @@ -71,6 +78,7 @@ export class Router { #matcher: Matcher #middleware: Middleware[] | undefined #parseFormData: (ParseFormDataOptions & { suppressErrors?: boolean }) | boolean + #sessionStorage: SessionStorage #uploadHandler: FileUploadHandler | undefined #methodOverride: string | boolean @@ -78,6 +86,7 @@ export class Router { this.#defaultHandler = options?.defaultHandler ?? noMatchHandler this.#matcher = options?.matcher ?? new RegExpMatcher() this.#parseFormData = options?.parseFormData ?? true + this.#sessionStorage = options?.sessionStorage ?? createCookieSessionStorage() this.#uploadHandler = options?.uploadHandler this.#methodOverride = options?.methodOverride ?? true } @@ -114,6 +123,12 @@ export class Router { ): Promise { let context = request instanceof Request ? await this.#createContext(request) : request + // Prepend session middleware only for the root router + upstreamMiddleware = + upstreamMiddleware == null + ? [session({ sessionStorage: this.#sessionStorage })] + : upstreamMiddleware + for (let match of this.#matcher.matchAll(context.url)) { if ('router' in match.data) { // Matched a sub-router, try to dispatch to it @@ -162,7 +177,16 @@ export class Router { } async #createContext(request: Request): Promise { - let context = new RequestContext(request) + // We have to create the session here because it's an async operation to + // parse the cookie internally using `cookie.parse()`. + // - We can't use a `get session()` getter to lazily create the session because + // `getSession` is async + // - We can't create the session in the `RequestContext` constructor because + // constructors can't be async + // - If we assign the session in the middleware, then `context.session` has + // to have a type of `Session | undefined` which is inconvenient for users + let session = await this.#sessionStorage.getSession(request.headers.get('Cookie')) + let context = new RequestContext(request, session) if (!RequestBodyMethods.includes(request.method as RequestBodyMethod)) { return context diff --git a/packages/session/package.json b/packages/session/package.json index d7581e699c8..d744c530cea 100644 --- a/packages/session/package.json +++ b/packages/session/package.json @@ -41,7 +41,7 @@ }, "scripts": { "build": "pnpm run clean && pnpm run build:types && pnpm run build:esm", - "build:esm": "esbuild src/index.ts --bundle --external:cookie --external:@remix-run/cookie --outfile=dist/index.js --format=esm --platform=neutral --sourcemap", + "build:esm": "esbuild src/index.ts --bundle --external:@remix-run/cookie --outfile=dist/index.js --format=esm --platform=neutral --sourcemap", "build:types": "tsc --project tsconfig.build.json", "clean": "rm -rf dist", "prepublishOnly": "pnpm run build", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f675372abc0..8c492ba70e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -100,6 +100,9 @@ importers: '@remix-run/route-pattern': specifier: workspace:* version: link:../route-pattern + '@remix-run/session': + specifier: workspace:* + version: link:../session devDependencies: '@types/node': specifier: ^24.6.0 From 153abefb4ec3554635d4beee523b420ca00804a4 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 24 Oct 2025 15:58:40 -0400 Subject: [PATCH 21/37] Automatic handling of session commit/destroy --- .../src/lib/middleware/session.ts | 27 +- packages/fetch-router/src/lib/router.test.ts | 257 +++++++++++++++++- packages/session/src/lib/cookie-storage.ts | 2 +- packages/session/src/lib/session.test.ts | 53 ++++ packages/session/src/lib/session.ts | 45 ++- 5 files changed, 375 insertions(+), 9 deletions(-) diff --git a/packages/fetch-router/src/lib/middleware/session.ts b/packages/fetch-router/src/lib/middleware/session.ts index d278aab2851..4482d1899c7 100644 --- a/packages/fetch-router/src/lib/middleware/session.ts +++ b/packages/fetch-router/src/lib/middleware/session.ts @@ -12,18 +12,37 @@ export interface SessionOptions { * Creates a middleware handler that manages user sessions. */ export function session(options: SessionOptions): Middleware { - return async (context, next) => { + return async ({ session }, next) => { // No session creation - that's handled by the router when we create the // RouterContext. This middleware just handles auto-committing sessions + // TODO: If we wanted to do the session creation in here and also keep // `context.session` typed as `Session` (without an `| undefined`), we could // go with a Symbol-driven empty session that we could detect in here and // overwrite + let response = await next() - // TODO: Implement session dirty check - let cookie = await options.sessionStorage.commitSession(context.session) - response.headers.append('Set-Cookie', cookie) + if (session.status === 'destroyed') { + let cookie = await options.sessionStorage.destroySession(session) + response.headers.append('Set-Cookie', cookie) + } else if (session.status === 'new' || session.status === 'dirty') { + // Commit the session to persist the data to the backing store + let cookie = await options.sessionStorage.commitSession(session) + + // But only add the Set-Cookie header if info serialized in the cookie has changed: + // - For cookie-backed session, `session.id` is always empty - they store all + // data in the cookie and thus _always_ need to be committed when the session + // is new or dirty + // - For non-cookie-backed sessions (file, memory, etc), `session.id` is only + // empty on initial creation, which means we need to commit. `session.id will + // be populated for existing sessions read in from a cookie, and when that + // happens we don't need to send up a new cookie because we already have the + // ID in there + if (session.id === '') { + response.headers.append('Set-Cookie', cookie) + } + } return response } diff --git a/packages/fetch-router/src/lib/router.test.ts b/packages/fetch-router/src/lib/router.test.ts index 180df29f67f..1e3b4e9693c 100644 --- a/packages/fetch-router/src/lib/router.test.ts +++ b/packages/fetch-router/src/lib/router.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it, mock } from 'node:test' import { RegExpMatcher, RoutePattern } from '@remix-run/route-pattern' -import { createMemorySessionStorage } from '@remix-run/session' +import { createCookieSessionStorage, createMemorySessionStorage } from '@remix-run/session' import { createStorageKey } from './app-storage.ts' import { RequestContext } from './request-context.ts' @@ -1685,6 +1685,8 @@ describe('abort signal support', () => { }) describe('sessions', () => { + let getSessionCookie = (r: Response) => r.headers.get('Set-Cookie')?.split(';')[0] || '' + it('automatically provides a cookie-based session', async () => { let routes = createRoutes({ home: '/', @@ -1893,4 +1895,257 @@ describe('sessions', () => { 'handler: Remix', ]) }) + + describe('cookie-backed sessions', () => { + it('sends a set-cookie header on initial session creation', async () => { + let routes = createRoutes({ + home: '/', + }) + + let router = createRouter({ sessionStorage: createCookieSessionStorage() }) + + router.get(routes.home, () => { + return new Response('Home') + }) + + let response1 = await router.fetch('https://remix.run') + assert.equal(await response1.text(), 'Home') + assert.equal(response1.headers.has('Set-Cookie'), true) + }) + + it('does not send a set-cookie header on request that only read from a session', async () => { + let routes = createRoutes({ + home: '/', + }) + + let router = createRouter({ sessionStorage: createCookieSessionStorage() }) + + router.get(routes.home, () => { + return new Response('Home') + }) + + let response = await router.fetch('https://remix.run') + + response = await router.fetch('https://remix.run', { + headers: { + Cookie: getSessionCookie(response), + }, + }) + + assert.equal(await response.text(), 'Home') + assert.equal(response.headers.has('Set-Cookie'), false) + }) + + it('sends a set-cookie header if the session data is modified', async () => { + let routes = createRoutes({ + home: '/', + }) + + let router = createRouter({ sessionStorage: createCookieSessionStorage() }) + + router.get(routes.home, ({ session }) => { + return new Response('Home:' + (session.get('name') ?? '')) + }) + + router.post(routes.home, ({ session }) => { + session.set('name', 'Remix') + return new Response('Home (post):' + (session.get('name') ?? '')) + }) + + let response = await router.fetch('https://remix.run') + let cookie = getSessionCookie(response) + + response = await router.fetch('https://remix.run', { + method: 'post', + body: '', + headers: { + Cookie: cookie, + }, + }) + + assert.equal(await response.text(), 'Home (post):Remix') + assert.equal(response.headers.has('Set-Cookie'), true) + cookie = getSessionCookie(response) + + // Another GET request - should read from the session but not send back a + // Set-Cookie header + response = await router.fetch('https://remix.run', { + headers: { + Cookie: cookie, + }, + }) + + assert.equal(await response.text(), 'Home:Remix') + assert.equal(response.headers.has('Set-Cookie'), false) + }) + + it('sends a set-cookie header if the session is destroyed', async () => { + let routes = createRoutes({ + home: '/', + logout: '/logout', + }) + + let router = createRouter({ sessionStorage: createCookieSessionStorage() }) + + router.get(routes.home, () => { + return new Response('Home') + }) + + router.post(routes.logout, ({ session }) => { + session.destroy() + return new Response('Logout') + }) + + let response = await router.fetch('https://remix.run') + assert.equal(response.headers.has('Set-Cookie'), true) + let cookie = getSessionCookie(response) + + // Another GET request - ensure we're re-using the session + response = await router.fetch('https://remix.run', { + headers: { + Cookie: cookie, + }, + }) + assert.equal(response.headers.has('Set-Cookie'), false) + + // Logout to destroy the session + let response5 = await router.fetch('https://remix.run/logout', { + method: 'post', + body: '', + headers: { + Cookie: cookie, + }, + }) + assert.equal(await response5.text(), 'Logout') + assert.equal(response5.headers.has('Set-Cookie'), true) + let logoutCookie = response5.headers.get('Set-Cookie') || '' + assert.ok(logoutCookie.includes('Expires=Thu, 01 Jan 1970 00:00:00 GMT')) + }) + }) + + describe('non-cookie-backed-sessions', () => { + it('sends a set-cookie header on initial session creation', async () => { + let routes = createRoutes({ + home: '/', + }) + + let router = createRouter({ sessionStorage: createMemorySessionStorage() }) + + router.get(routes.home, () => { + return new Response('Home') + }) + + let response1 = await router.fetch('https://remix.run') + assert.equal(await response1.text(), 'Home') + assert.equal(response1.headers.has('Set-Cookie'), true) + }) + + it('does not send a set-cookie header on request that only read from a session', async () => { + let routes = createRoutes({ + home: '/', + }) + + let router = createRouter({ sessionStorage: createMemorySessionStorage() }) + + router.get(routes.home, () => { + return new Response('Home') + }) + + let response = await router.fetch('https://remix.run') + + response = await router.fetch('https://remix.run', { + headers: { + Cookie: getSessionCookie(response), + }, + }) + + assert.equal(await response.text(), 'Home') + assert.equal(response.headers.has('Set-Cookie'), false) + }) + + it('does not send a set-cookie header if the session data is modified', async () => { + let routes = createRoutes({ + home: '/', + }) + + let router = createRouter({ sessionStorage: createMemorySessionStorage() }) + + router.get(routes.home, ({ session }) => { + return new Response('Home:' + (session.get('name') ?? '')) + }) + + router.post(routes.home, ({ session }) => { + session.set('name', 'Remix') + return new Response('Home (post):' + (session.get('name') ?? '')) + }) + + let response = await router.fetch('https://remix.run') + let cookie = getSessionCookie(response) + + response = await router.fetch('https://remix.run', { + method: 'post', + body: '', + headers: { + Cookie: cookie, + }, + }) + + assert.equal(await response.text(), 'Home (post):Remix') + assert.equal(response.headers.has('Set-Cookie'), false) + + // Another GET request - should read from the session but not send back a + // Set-Cookie header + response = await router.fetch('https://remix.run', { + headers: { + Cookie: cookie, + }, + }) + + assert.equal(await response.text(), 'Home:Remix') + assert.equal(response.headers.has('Set-Cookie'), false) + }) + + it('sends a set-cookie header if the session is destroyed', async () => { + let routes = createRoutes({ + home: '/', + logout: '/logout', + }) + + let router = createRouter({ sessionStorage: createMemorySessionStorage() }) + + router.get(routes.home, () => { + return new Response('Home') + }) + + router.post(routes.logout, ({ session }) => { + session.destroy() + return new Response('Logout') + }) + + let response = await router.fetch('https://remix.run') + assert.equal(response.headers.has('Set-Cookie'), true) + let cookie = getSessionCookie(response) + + // Another GET request - ensure we're re-using the session + response = await router.fetch('https://remix.run', { + headers: { + Cookie: cookie, + }, + }) + assert.equal(response.headers.has('Set-Cookie'), false) + + // Logout to destroy the session + let response5 = await router.fetch('https://remix.run/logout', { + method: 'post', + body: '', + headers: { + Cookie: cookie, + }, + }) + assert.equal(await response5.text(), 'Logout') + assert.equal(response5.headers.has('Set-Cookie'), true) + let logoutCookie = response5.headers.get('Set-Cookie') || '' + assert.ok(logoutCookie.includes('Expires=Thu, 01 Jan 1970 00:00:00 GMT')) + }) + }) }) diff --git a/packages/session/src/lib/cookie-storage.ts b/packages/session/src/lib/cookie-storage.ts index f4533a6279c..4c444fc2e66 100644 --- a/packages/session/src/lib/cookie-storage.ts +++ b/packages/session/src/lib/cookie-storage.ts @@ -30,7 +30,7 @@ export function createCookieSessionStorage return { async getSession(cookieHeader, options) { - return createSession((cookieHeader && (await cookie.parse(cookieHeader, options))) || {}) + return createSession(cookieHeader ? await cookie.parse(cookieHeader, options) : undefined) }, async commitSession(session, options) { let serializedCookie = await cookie.serialize(session.data, options) diff --git a/packages/session/src/lib/session.test.ts b/packages/session/src/lib/session.test.ts index 979bb5c2cd2..27c9219d0cb 100644 --- a/packages/session/src/lib/session.test.ts +++ b/packages/session/src/lib/session.test.ts @@ -37,6 +37,59 @@ describe('Session', () => { assert.equal(session.has('user'), false) assert.equal(session.get('user'), undefined) }) + + it('correctly destroys a session', () => { + let session = createSession() + + session.set('user', 'mjackson') + assert.equal(session.get('user'), 'mjackson') + + session.destroy() + + assert.equal(session.has('user'), false) + assert.equal(session.get('user'), undefined) + }) + + it('tracks session status for newly created sessions', () => { + let session = createSession() + assert.equal(session.status, 'new') + + session.get('user') + assert.equal(session.status, 'new') + + session.set('user', 'mjackson') + assert.equal(session.status, 'dirty') + + session.destroy() + assert.equal(session.status, 'destroyed') + }) + + it('tracks session status for existing sessions', () => { + let session = createSession({ user: 'brophdawg11' }) + assert.equal(session.status, 'clean') + + session.get('user') + assert.equal(session.status, 'clean') + + session.set('user', 'mjackson') + assert.equal(session.status, 'dirty') + + session.destroy() + assert.equal(session.status, 'destroyed') + }) + + it('throws an error if you try to operate on a destroyed session', () => { + let session = createSession({ user: 'brophdawg11' }) + assert.equal(session.status, 'clean') + + session.destroy() + assert.equal(session.status, 'destroyed') + + assert.equal(session.get('user'), undefined) + assert.throws(() => session.set('user', 'mjackson'), { + message: 'Cannot operate on a destroyed session', + }) + }) }) describe('isSession', () => { diff --git a/packages/session/src/lib/session.ts b/packages/session/src/lib/session.ts index c71b3f192d6..dc8c797f230 100644 --- a/packages/session/src/lib/session.ts +++ b/packages/session/src/lib/session.ts @@ -31,6 +31,13 @@ export interface Session { */ readonly data: FlashSessionData + /** + * A value indicating the status of the session. + * + * This is useful for middlewares to know if they need to commit the session. + */ + readonly status: 'new' | 'clean' | 'dirty' | 'destroyed' + /** * Returns `true` if the session has a value for the given `name`, `false` * otherwise. @@ -62,6 +69,11 @@ export interface Session { * Removes a value from the session. */ unset(name: keyof Data & string): void + + /** + * Clears a session for destruction + * */ + destroy(): void } export type FlashSessionData = Partial< @@ -81,14 +93,27 @@ function flash(name: Key): FlashDataKey { * Instead, use a `SessionStorage` object's `getSession` method. */ export function createSession( - initialData: Partial = {}, - id = '', + initialData?: Partial, + id?: string, ): Session { + // Brand new sessions start in a dirty state to force an initial commit + let status: 'new' | 'clean' | 'dirty' | 'destroyed' = + initialData == null && id == null ? 'new' : 'clean' + + initialData ||= {} + id ??= '' + let map = new Map(Object.entries(initialData)) as Map< keyof Data | FlashDataKey, any > + let throwIfDestroyed = () => { + if (status === 'destroyed') { + throw new Error('Cannot operate on a destroyed session') + } + } + return { get id() { return id @@ -96,6 +121,9 @@ export function createSession( get data() { return Object.fromEntries(map) as FlashSessionData }, + get status() { + return status + }, has(name) { return map.has(name as keyof Data) || map.has(flash(name as keyof FlashData & string)) }, @@ -106,19 +134,30 @@ export function createSession( if (map.has(flashName)) { let value = map.get(flashName) map.delete(flashName) + status = 'dirty' return value } return undefined }, set(name, value) { + throwIfDestroyed() map.set(name, value) + status = 'dirty' }, flash(name, value) { + throwIfDestroyed() map.set(flash(name), value) + status = 'dirty' }, unset(name) { + throwIfDestroyed() map.delete(name) + status = 'dirty' + }, + destroy() { + map.clear() + status = 'destroyed' }, } } @@ -230,7 +269,7 @@ export function createSessionStorage({ async getSession(cookieHeader, options) { let id = cookieHeader && (await cookie.parse(cookieHeader, options)) let data = id && (await readData(id)) - return createSession(data || {}, id || '') + return createSession(data, id) }, async commitSession(session, options) { let { id, data } = session From e8d31bf79b198b403a485ea52b91665875442449 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 24 Oct 2025 16:44:20 -0400 Subject: [PATCH 22/37] Update bookstore to use built-in session --- demos/bookstore/app/auth.tsx | 27 ++++------ demos/bookstore/app/cart.tsx | 40 +++++--------- demos/bookstore/app/checkout.tsx | 14 +++-- demos/bookstore/app/fragments.tsx | 9 ++-- demos/bookstore/app/middleware/auth.ts | 17 ++---- demos/bookstore/app/models/cart.ts | 28 +++++----- demos/bookstore/app/utils/context.ts | 8 +++ demos/bookstore/app/utils/frame.tsx | 5 +- demos/bookstore/app/utils/session.ts | 72 +++----------------------- pnpm-lock.yaml | 3 ++ 10 files changed, 69 insertions(+), 154 deletions(-) diff --git a/demos/bookstore/app/auth.tsx b/demos/bookstore/app/auth.tsx index 2c4833da504..e79d4843396 100644 --- a/demos/bookstore/app/auth.tsx +++ b/demos/bookstore/app/auth.tsx @@ -2,7 +2,7 @@ import type { RouteHandlers } from '@remix-run/fetch-router' import { redirect } from '@remix-run/fetch-router/response-helpers' import { routes } from '../routes.ts' -import { getSession, setSessionCookie, login, logout } from './utils/session.ts' +import { login, logout } from './utils/session.ts' import { authenticateUser, createUser, @@ -64,7 +64,7 @@ export default { ) }, - async action({ request, formData }) { + async action({ session, formData }) { let email = formData.get('email')?.toString() ?? '' let password = formData.get('password')?.toString() ?? '' let user = authenticateUser(email, password) @@ -85,13 +85,9 @@ export default { ) } - let session = getSession(request) - login(session.sessionId, user) + login(session, user) - let headers = new Headers() - setSessionCookie(headers, session.sessionId) - - return redirect(routes.account.index.href(), { headers }) + return redirect(routes.account.index.href()) }, }, @@ -136,7 +132,7 @@ export default { ) }, - async action({ request, formData }) { + async action({ session, formData }) { let name = formData.get('name')?.toString() ?? '' let email = formData.get('email')?.toString() ?? '' let password = formData.get('password')?.toString() ?? '' @@ -167,19 +163,14 @@ export default { let user = createUser(email, password, name) - let session = getSession(request) - login(session.sessionId, user) - - let headers = new Headers() - setSessionCookie(headers, session.sessionId) + login(session, user) - return redirect(routes.account.index.href(), { headers }) + return redirect(routes.account.index.href()) }, }, - logout({ request }) { - let session = getSession(request) - logout(session.sessionId) + logout({ session }) { + logout(session) return redirect(routes.home.href()) }, diff --git a/demos/bookstore/app/cart.tsx b/demos/bookstore/app/cart.tsx index f14fa014933..9e825eeefd3 100644 --- a/demos/bookstore/app/cart.tsx +++ b/demos/bookstore/app/cart.tsx @@ -4,21 +4,19 @@ import { redirect } from '@remix-run/fetch-router/response-helpers' import { routes } from '../routes.ts' import { Layout } from './layout.tsx' -import { loadAuth, SESSION_ID_KEY } from './middleware/auth.ts' +import { loadAuth } from './middleware/auth.ts' import { getBookById } from './models/books.ts' import { getCart, addToCart, updateCartItem, removeFromCart, getCartTotal } from './models/cart.ts' import type { User } from './models/users.ts' -import { getCurrentUser, getStorage } from './utils/context.ts' +import { getCurrentUser } from './utils/context.ts' import { render } from './utils/render.ts' -import { setSessionCookie } from './utils/session.ts' import { RestfulForm } from './components/restful-form.tsx' export default { use: [loadAuth], handlers: { - index() { - let sessionId = getStorage().get(SESSION_ID_KEY) - let cart = getCart(sessionId) + index({ session }) { + let cart = getCart(session.get('userId')) let total = getCartTotal(cart) let user: User | null = null @@ -137,11 +135,10 @@ export default { }, api: { - async add({ storage, formData }) { + async add({ session, formData }) { // Simulate network latency await new Promise((resolve) => setTimeout(resolve, 1000)) - let sessionId = storage.get(SESSION_ID_KEY) let bookId = formData.get('bookId')?.toString() ?? '' let book = getBookById(bookId) @@ -149,52 +146,41 @@ export default { return new Response('Book not found', { status: 404 }) } - addToCart(sessionId, book.id, book.slug, book.title, book.price, 1) - - let headers = new Headers() - setSessionCookie(headers, sessionId) + addToCart(session.get('userId'), book.id, book.slug, book.title, book.price, 1) if (formData.get('redirect') === 'none') { return new Response(null, { status: 204 }) } - return redirect(routes.cart.index.href(), { headers }) + return redirect(routes.cart.index.href()) }, - async update({ storage, formData }) { - let sessionId = storage.get(SESSION_ID_KEY) + async update({ session, formData }) { let bookId = formData.get('bookId')?.toString() ?? '' let quantity = parseInt(formData.get('quantity')?.toString() ?? '1', 10) - updateCartItem(sessionId, bookId, quantity) - - let headers = new Headers() - setSessionCookie(headers, sessionId) + updateCartItem(session.get('userId'), bookId, quantity) if (formData.get('redirect') === 'none') { return new Response(null, { status: 204 }) } - return redirect(routes.cart.index.href(), { headers }) + return redirect(routes.cart.index.href()) }, - async remove({ storage, formData }) { + async remove({ session, formData }) { // Simulate network latency await new Promise((resolve) => setTimeout(resolve, 1000)) - let sessionId = storage.get(SESSION_ID_KEY) let bookId = formData.get('bookId')?.toString() ?? '' - removeFromCart(sessionId, bookId) - - let headers = new Headers() - setSessionCookie(headers, sessionId) + removeFromCart(session.get('userId'), bookId) if (formData.get('redirect') === 'none') { return new Response(null, { status: 204 }) } - return redirect(routes.cart.index.href(), { headers }) + return redirect(routes.cart.index.href()) }, }, }, diff --git a/demos/bookstore/app/checkout.tsx b/demos/bookstore/app/checkout.tsx index 4940360263a..7f9f269f66b 100644 --- a/demos/bookstore/app/checkout.tsx +++ b/demos/bookstore/app/checkout.tsx @@ -2,7 +2,7 @@ import type { RouteHandlers } from '@remix-run/fetch-router' import { redirect } from '@remix-run/fetch-router/response-helpers' import { routes } from '../routes.ts' -import { requireAuth, SESSION_ID_KEY } from './middleware/auth.ts' +import { requireAuth } from './middleware/auth.ts' import { getCart, clearCart, getCartTotal } from './models/cart.ts' import { createOrder, getOrderById } from './models/orders.ts' import { Layout } from './layout.tsx' @@ -12,9 +12,8 @@ import { getCurrentUser, getStorage } from './utils/context.ts' export default { use: [requireAuth], handlers: { - index() { - let sessionId = getStorage().get(SESSION_ID_KEY) - let cart = getCart(sessionId) + index({ session }) { + let cart = getCart(session.get('userId')) let total = getCartTotal(cart) if (cart.items.length === 0) { @@ -108,10 +107,9 @@ export default { ) }, - async action({ formData }) { + async action({ session, formData }) { let user = getCurrentUser() - let sessionId = getStorage().get(SESSION_ID_KEY) - let cart = getCart(sessionId) + let cart = getCart(session.get('userId')) if (cart.items.length === 0) { return redirect(routes.cart.index.href()) @@ -135,7 +133,7 @@ export default { shippingAddress, ) - clearCart(sessionId) + clearCart(session.get('userId')) return redirect(routes.checkout.confirmation.href({ orderId: order.id })) }, diff --git a/demos/bookstore/app/fragments.tsx b/demos/bookstore/app/fragments.tsx index 0c911e071d0..6065747eddf 100644 --- a/demos/bookstore/app/fragments.tsx +++ b/demos/bookstore/app/fragments.tsx @@ -1,18 +1,17 @@ import type { RouteHandlers } from '@remix-run/fetch-router' -import { routes } from '../routes.ts' +import type { routes } from '../routes.ts' import { BookCard } from './components/book-card.tsx' -import { loadAuth, SESSION_ID_KEY } from './middleware/auth.ts' +import { loadAuth } from './middleware/auth.ts' import { getCart } from './models/cart.ts' import { getBookBySlug } from './models/books.ts' -import { getStorage } from './utils/context.ts' import { render } from './utils/render.ts' export default { use: [loadAuth], handlers: { - async bookCard({ params }) { + async bookCard({ session, params }) { // Simulate network latency // await new Promise((resolve) => setTimeout(resolve, 1000 * Math.random())) @@ -22,7 +21,7 @@ export default { return render(
Book not found
, { status: 404 }) } - let cart = getCart(getStorage().get(SESSION_ID_KEY)) + let cart = getCart(session.get('userId')) let inCart = cart.items.some((item) => item.slug === params.slug) return render() diff --git a/demos/bookstore/app/middleware/auth.ts b/demos/bookstore/app/middleware/auth.ts index b2c9edcf154..c88e739985d 100644 --- a/demos/bookstore/app/middleware/auth.ts +++ b/demos/bookstore/app/middleware/auth.ts @@ -5,23 +5,18 @@ import { redirect } from '@remix-run/fetch-router/response-helpers' import { routes } from '../../routes.ts' import { getUserById } from '../models/users.ts' import type { User } from '../models/users.ts' -import { getSession, getUserIdFromSession } from '../utils/session.ts' +import { getUserIdFromSession } from '../utils/session.ts' // Storage keys for attaching data to request context export const USER_KEY = createStorageKey() -export const SESSION_ID_KEY = createStorageKey() /** * Middleware that optionally loads the current user if authenticated. * Does not redirect if not authenticated. * Attaches user (if any) and sessionId to context.storage. */ -export let loadAuth: Middleware = async ({ request, storage }) => { - let session = getSession(request) - let userId = getUserIdFromSession(session.sessionId) - - // Always set session ID for cart/guest functionality - storage.set(SESSION_ID_KEY, session.sessionId) +export let loadAuth: Middleware = async ({ session, storage }) => { + let userId = getUserIdFromSession(session) // Only set USER_KEY if user is authenticated if (userId) { @@ -37,9 +32,8 @@ export let loadAuth: Middleware = async ({ request, storage }) => { * Redirects to login if not authenticated. * Attaches user and sessionId to context.storage. */ -export let requireAuth: Middleware = async ({ request, storage }) => { - let session = getSession(request) - let userId = getUserIdFromSession(session.sessionId) +export let requireAuth: Middleware = async ({ session, storage }) => { + let userId = getUserIdFromSession(session) if (!userId) { return redirect(routes.auth.login.index.href(), 302) @@ -51,5 +45,4 @@ export let requireAuth: Middleware = async ({ request, storage }) => { } storage.set(USER_KEY, user) - storage.set(SESSION_ID_KEY, session.sessionId) } diff --git a/demos/bookstore/app/models/cart.ts b/demos/bookstore/app/models/cart.ts index 8ee76e6f6db..dfe17be8ab1 100644 --- a/demos/bookstore/app/models/cart.ts +++ b/demos/bookstore/app/models/cart.ts @@ -10,27 +10,27 @@ export interface Cart { items: CartItem[] } -// Store carts by session ID +// Store carts by user ID const carts = new Map() -export function getCart(sessionId: string): Cart { - let cart = carts.get(sessionId) +export function getCart(userId: string): Cart { + let cart = carts.get(userId) if (!cart) { cart = { items: [] } - carts.set(sessionId, cart) + carts.set(userId, cart) } return cart } export function addToCart( - sessionId: string, + userId: string, bookId: string, slug: string, title: string, price: number, quantity: number = 1, ): Cart { - let cart = getCart(sessionId) + let cart = getCart(userId) let existingItem = cart.items.find((item) => item.bookId === bookId) if (existingItem) { @@ -42,12 +42,8 @@ export function addToCart( return cart } -export function updateCartItem( - sessionId: string, - bookId: string, - quantity: number, -): Cart | undefined { - let cart = getCart(sessionId) +export function updateCartItem(userId: string, bookId: string, quantity: number): Cart | undefined { + let cart = getCart(userId) let item = cart.items.find((item) => item.bookId === bookId) if (!item) return undefined @@ -61,14 +57,14 @@ export function updateCartItem( return cart } -export function removeFromCart(sessionId: string, bookId: string): Cart { - let cart = getCart(sessionId) +export function removeFromCart(userId: string, bookId: string): Cart { + let cart = getCart(userId) cart.items = cart.items.filter((item) => item.bookId !== bookId) return cart } -export function clearCart(sessionId: string): void { - carts.set(sessionId, { items: [] }) +export function clearCart(userId: string): void { + carts.set(userId, { items: [] }) } export function getCartTotal(cart: Cart): number { diff --git a/demos/bookstore/app/utils/context.ts b/demos/bookstore/app/utils/context.ts index 8ce6a9f8c2e..1cec34434cc 100644 --- a/demos/bookstore/app/utils/context.ts +++ b/demos/bookstore/app/utils/context.ts @@ -28,6 +28,14 @@ export function getStorage() { return getContext().storage } +/** + * Get the session from the current RequestContext. + * This is a convenience helper for the most common use case. + */ +export function getSession() { + return getContext().session +} + /** * Get the current authenticated user from storage. * Throws if no user is authenticated. diff --git a/demos/bookstore/app/utils/frame.tsx b/demos/bookstore/app/utils/frame.tsx index 537b5604811..1ccf29dbf75 100644 --- a/demos/bookstore/app/utils/frame.tsx +++ b/demos/bookstore/app/utils/frame.tsx @@ -2,9 +2,8 @@ import { routes } from '../../routes.ts' import { getBookBySlug } from '../models/books.ts' import { BookCard } from '../components/book-card.tsx' -import { getStorage } from './context.ts' +import { getSession } from './context.ts' import { getCart } from '../models/cart.ts' -import { SESSION_ID_KEY } from '../middleware/auth.ts' export async function resolveFrame(frameSrc: string) { let url = new URL(frameSrc, 'http://localhost:44100') @@ -21,7 +20,7 @@ export async function resolveFrame(frameSrc: string) { throw new Error(`Book not found: ${slug}`) } - let cart = getCart(getStorage().get(SESSION_ID_KEY)) + let cart = getCart(getSession().get('userId')) let inCart = cart.items.some((item) => item.slug === slug) return diff --git a/demos/bookstore/app/utils/session.ts b/demos/bookstore/app/utils/session.ts index ff9f5b7070e..2574d318299 100644 --- a/demos/bookstore/app/utils/session.ts +++ b/demos/bookstore/app/utils/session.ts @@ -1,77 +1,19 @@ -import { Cookie, SetCookie } from '@remix-run/headers' - import type { User } from '../models/users.ts' +import type { Session } from '@remix-run/session' export interface SessionData { userId?: string sessionId: string } -// Simple, in-memory session store for demo purposes -const sessions = new Map() - -export function getSessionId(request: Request): string { - let cookieHeader = request.headers.get('Cookie') - if (!cookieHeader) return createSessionId() - - let cookie = new Cookie(cookieHeader) - let sessionId = cookie.get('sessionId') - - if (!sessionId) return createSessionId() - - if (!sessions.has(sessionId)) { - sessions.set(sessionId, { sessionId }) - } - - return sessionId -} - -export function createSessionId(): string { - return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15) -} - -export function getSession(request: Request): SessionData { - let sessionId = getSessionId(request) - let session = sessions.get(sessionId) - - if (!session) { - session = { sessionId } - sessions.set(sessionId, session) - } - - return session -} - -export function setSessionCookie(headers: Headers, sessionId: string): void { - let cookie = new SetCookie({ - name: 'sessionId', - value: sessionId, - path: '/', - httpOnly: true, - sameSite: 'Lax', - maxAge: 2592000, // 30 days - }) - - headers.set('Set-Cookie', cookie.toString()) -} - -export function login(sessionId: string, user: User): void { - let session = sessions.get(sessionId) - if (!session) { - session = { sessionId } - sessions.set(sessionId, session) - } - session.userId = user.id +export function login(session: Session, user: User): void { + session.set('userId', user.id) } -export function logout(sessionId: string): void { - let session = sessions.get(sessionId) - if (session) { - delete session.userId - } +export function logout(session: Session): void { + session.destroy() } -export function getUserIdFromSession(sessionId: string): string | undefined { - let session = sessions.get(sessionId) - return session?.userId +export function getUserIdFromSession(session: Session): string | undefined { + return session.get('userId') } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c492ba70e7..36a1ad350e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,9 @@ importers: '@remix-run/node-fetch-server': specifier: workspace:* version: link:../../packages/node-fetch-server + '@remix-run/session': + specifier: workspace:* + version: link:../../packages/session devDependencies: '@types/node': specifier: ^24.6.0 From b70e70ff7dfb7a4340441c160dbad67afcbbf510 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 24 Oct 2025 16:48:00 -0400 Subject: [PATCH 23/37] Fix some type issues in tests --- packages/fetch-router/src/lib/middleware.test.ts | 6 +++++- .../fetch-router/src/lib/request-context.test.ts | 14 +++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/fetch-router/src/lib/middleware.test.ts b/packages/fetch-router/src/lib/middleware.test.ts index ee3cfb76dbe..377735bb6cb 100644 --- a/packages/fetch-router/src/lib/middleware.test.ts +++ b/packages/fetch-router/src/lib/middleware.test.ts @@ -4,10 +4,14 @@ import { describe, it } from 'node:test' import { runMiddleware } from './middleware.ts' import type { NextFunction } from './middleware.ts' import { RequestContext } from './request-context.ts' +import { createSession } from '@remix-run/session' function mockContext(input: string | Request, params: Record = {}): RequestContext { + let session = createSession() let context = - input instanceof Request ? new RequestContext(input) : new RequestContext(new Request(input)) + input instanceof Request + ? new RequestContext(input, session) + : new RequestContext(new Request(input), session) context.params = params return context } diff --git a/packages/fetch-router/src/lib/request-context.test.ts b/packages/fetch-router/src/lib/request-context.test.ts index b5d26f7f735..b42e77c8216 100644 --- a/packages/fetch-router/src/lib/request-context.test.ts +++ b/packages/fetch-router/src/lib/request-context.test.ts @@ -1,6 +1,7 @@ import { describe, it } from 'node:test' -import assert from 'node:assert/strict' -import {RequestContext} from "./request-context.ts"; +import assert from 'node:assert/strict' +import { RequestContext } from './request-context.ts' +import { createSession } from '@remix-run/session' describe('new RequestContext()', () => { it('has a header object that is SuperHeaders', () => { @@ -9,11 +10,14 @@ describe('new RequestContext()', () => { 'Content-Type': 'application/json', }, }) - let context = new RequestContext(req) + let context = new RequestContext(req, createSession()) assert.equal('contentType' in context.headers, true) assert.equal('contentType' in context.request.headers, false) assert.equal(context.headers.contentType.toString(), 'application/json') - assert.equal(context.headers.contentType.toString(), context.request.headers.get('content-type')) + assert.equal( + context.headers.contentType.toString(), + context.request.headers.get('content-type'), + ) }) -}); \ No newline at end of file +}) From 83edda148f4e7dfdc1433e3c42df8fba86e5ca56 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 24 Oct 2025 16:48:55 -0400 Subject: [PATCH 24/37] Fix lockfile --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36a1ad350e5..8c492ba70e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,9 +50,6 @@ importers: '@remix-run/node-fetch-server': specifier: workspace:* version: link:../../packages/node-fetch-server - '@remix-run/session': - specifier: workspace:* - version: link:../../packages/session devDependencies: '@types/node': specifier: ^24.6.0 From 76a38f50daa3c166ef5d02b24ca5c99418a071f6 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Sun, 26 Oct 2025 11:25:03 -0400 Subject: [PATCH 25/37] Adopt NoOpSession approach --- .../src/lib/middleware/session.ts | 33 ++++++++++++++----- .../fetch-router/src/lib/request-context.ts | 5 +-- packages/fetch-router/src/lib/router.test.ts | 2 +- packages/fetch-router/src/lib/router.ts | 31 +++++++++-------- 4 files changed, 43 insertions(+), 28 deletions(-) diff --git a/packages/fetch-router/src/lib/middleware/session.ts b/packages/fetch-router/src/lib/middleware/session.ts index 4482d1899c7..64c21a9abc7 100644 --- a/packages/fetch-router/src/lib/middleware/session.ts +++ b/packages/fetch-router/src/lib/middleware/session.ts @@ -1,4 +1,4 @@ -import type { SessionStorage } from '@remix-run/session' +import type { Session, SessionStorage } from '@remix-run/session' import type { Middleware } from '../middleware.ts' export interface SessionOptions { @@ -8,21 +8,36 @@ export interface SessionOptions { sessionStorage: SessionStorage } +// We use this no-op session approach so we can keep session logic contained +// in this middleware. Otherwise, in order to ensure `context.session` is always +// populated, we would have to create it prior to creating the `RequestContext` +// because session parsing is async so it can't be done in the constructor or a +// `context.session` getter method. +export const NoOpSession: Session = { + id: '', + data: {}, + status: 'clean', + has: () => false, + get: () => undefined, + set() {}, + unset() {}, + flash() {}, + destroy() {}, +} + /** * Creates a middleware handler that manages user sessions. */ export function session(options: SessionOptions): Middleware { - return async ({ session }, next) => { - // No session creation - that's handled by the router when we create the - // RouterContext. This middleware just handles auto-committing sessions - - // TODO: If we wanted to do the session creation in here and also keep - // `context.session` typed as `Session` (without an `| undefined`), we could - // go with a Symbol-driven empty session that we could detect in here and - // overwrite + return async (context, next) => { + if (context.session === NoOpSession) { + let cookie = context.request.headers.get('Cookie') + context.session = await options.sessionStorage.getSession(cookie) + } let response = await next() + let { session } = context if (session.status === 'destroyed') { let cookie = await options.sessionStorage.destroySession(session) response.headers.append('Set-Cookie', cookie) diff --git a/packages/fetch-router/src/lib/request-context.ts b/packages/fetch-router/src/lib/request-context.ts index c3d6eac0227..94e81680e3c 100644 --- a/packages/fetch-router/src/lib/request-context.ts +++ b/packages/fetch-router/src/lib/request-context.ts @@ -3,6 +3,7 @@ import { type Session } from '@remix-run/session' import { AppStorage } from './app-storage.ts' import type { RequestBodyMethod, RequestMethod } from './request-methods.ts' +import { NoOpSession } from './middleware/session.ts' /** * A context object that contains information about the current request. Every request @@ -55,13 +56,13 @@ export class RequestContext< */ headers: SuperHeaders - constructor(request: Request, session: Session) { + constructor(request: Request) { this.formData = undefined as any this.method = request.method.toUpperCase() as RequestMethod this.params = {} as Params this.request = request this.storage = new AppStorage() - this.session = session + this.session = NoOpSession this.headers = new SuperHeaders(request.headers) this.url = new URL(request.url) } diff --git a/packages/fetch-router/src/lib/router.test.ts b/packages/fetch-router/src/lib/router.test.ts index 1e3b4e9693c..0df54ffa245 100644 --- a/packages/fetch-router/src/lib/router.test.ts +++ b/packages/fetch-router/src/lib/router.test.ts @@ -968,7 +968,7 @@ describe('router.dispatch()', () => { let request = new Request('https://remix.run/123') let session = await createMemorySessionStorage().getSession() - let context = new RequestContext(request, session) + let context = new RequestContext(request) context.storage.set(storageKey, 'value') let response = await router.dispatch(context) diff --git a/packages/fetch-router/src/lib/router.ts b/packages/fetch-router/src/lib/router.ts index fd53dfdf835..6ae76044d65 100644 --- a/packages/fetch-router/src/lib/router.ts +++ b/packages/fetch-router/src/lib/router.ts @@ -78,7 +78,7 @@ export class Router { #matcher: Matcher #middleware: Middleware[] | undefined #parseFormData: (ParseFormDataOptions & { suppressErrors?: boolean }) | boolean - #sessionStorage: SessionStorage + #sessionMiddleware: Middleware #uploadHandler: FileUploadHandler | undefined #methodOverride: string | boolean @@ -86,7 +86,16 @@ export class Router { this.#defaultHandler = options?.defaultHandler ?? noMatchHandler this.#matcher = options?.matcher ?? new RegExpMatcher() this.#parseFormData = options?.parseFormData ?? true - this.#sessionStorage = options?.sessionStorage ?? createCookieSessionStorage() + this.#sessionMiddleware = session({ + sessionStorage: + options?.sessionStorage ?? + createCookieSessionStorage({ + cookie: { + httpOnly: true, + }, + }), + }) + this.#middleware = [this.#sessionMiddleware] this.#uploadHandler = options?.uploadHandler this.#methodOverride = options?.methodOverride ?? true } @@ -124,10 +133,9 @@ export class Router { let context = request instanceof Request ? await this.#createContext(request) : request // Prepend session middleware only for the root router - upstreamMiddleware = - upstreamMiddleware == null - ? [session({ sessionStorage: this.#sessionStorage })] - : upstreamMiddleware + if (upstreamMiddleware == null || upstreamMiddleware[0] !== this.#sessionMiddleware) { + upstreamMiddleware = concatMiddleware([this.#sessionMiddleware], upstreamMiddleware) + } for (let match of this.#matcher.matchAll(context.url)) { if ('router' in match.data) { @@ -177,16 +185,7 @@ export class Router { } async #createContext(request: Request): Promise { - // We have to create the session here because it's an async operation to - // parse the cookie internally using `cookie.parse()`. - // - We can't use a `get session()` getter to lazily create the session because - // `getSession` is async - // - We can't create the session in the `RequestContext` constructor because - // constructors can't be async - // - If we assign the session in the middleware, then `context.session` has - // to have a type of `Session | undefined` which is inconvenient for users - let session = await this.#sessionStorage.getSession(request.headers.get('Cookie')) - let context = new RequestContext(request, session) + let context = new RequestContext(request) if (!RequestBodyMethods.includes(request.method as RequestBodyMethod)) { return context From 1191d867141ff5b2eefb3927bbb6c118c3aff21a Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 28 Oct 2025 13:45:53 -0400 Subject: [PATCH 26/37] PR feedback --- packages/fetch-router/package.json | 2 +- .../src/lib/middleware/session.ts | 2 +- packages/fetch-router/src/lib/router.test.ts | 165 ++++++++++-------- packages/session/src/lib/session.ts | 14 +- 4 files changed, 103 insertions(+), 80 deletions(-) diff --git a/packages/fetch-router/package.json b/packages/fetch-router/package.json index d1e86fa412f..b4e7331ccf9 100644 --- a/packages/fetch-router/package.json +++ b/packages/fetch-router/package.json @@ -47,7 +47,7 @@ "esbuild": "^0.25.10", "tsx": "^4.20.6" }, - "peerDependencies": { + "dependencies": { "@remix-run/form-data-parser": "workspace:*", "@remix-run/headers": "workspace:*", "@remix-run/html-template": "workspace:*", diff --git a/packages/fetch-router/src/lib/middleware/session.ts b/packages/fetch-router/src/lib/middleware/session.ts index 64c21a9abc7..2e2ab59719a 100644 --- a/packages/fetch-router/src/lib/middleware/session.ts +++ b/packages/fetch-router/src/lib/middleware/session.ts @@ -41,7 +41,7 @@ export function session(options: SessionOptions): Middleware { if (session.status === 'destroyed') { let cookie = await options.sessionStorage.destroySession(session) response.headers.append('Set-Cookie', cookie) - } else if (session.status === 'new' || session.status === 'dirty') { + } else if (session.status === 'dirty') { // Commit the session to persist the data to the backing store let cookie = await options.sessionStorage.commitSession(session) diff --git a/packages/fetch-router/src/lib/router.test.ts b/packages/fetch-router/src/lib/router.test.ts index 0df54ffa245..01fbf35fa8c 100644 --- a/packages/fetch-router/src/lib/router.test.ts +++ b/packages/fetch-router/src/lib/router.test.ts @@ -1687,59 +1687,62 @@ describe('abort signal support', () => { describe('sessions', () => { let getSessionCookie = (r: Response) => r.headers.get('Set-Cookie')?.split(';')[0] || '' - it('automatically provides a cookie-based session', async () => { + it('automatically provides an HttpOnly cookie-based session, only if the session is used', async () => { let routes = createRoutes({ home: '/', }) let router = createRouter() - let requestLog: string[] = [] - - router.use(({ session }) => { - requestLog.push(`middleware: ${session.get('name')}`) - }) - router.get(routes.home, ({ session }) => { - if (session.has('name')) { - requestLog.push(`handler: ${session?.get('name')}`) - } else { - requestLog.push(`setting name Remix`) - session.set('name', 'Remix') - } + return new Response(`Home: ${session.get('name')}`) + }) - return new Response('Home') + router.post(routes.home, ({ session, url }) => { + session.set('name', url.searchParams.get('name') ?? 'Remix') + return new Response(`Home (post): ${session.get('name')}`) }) - // Session creation + // No session cookie created if session is unused let response = await router.fetch('https://remix.run') - - assert.equal(await response.text(), 'Home') - assert.deepEqual(requestLog, ['middleware: undefined', 'setting name Remix']) - + assert.equal(await response.text(), 'Home: undefined') + assert.equal(response.headers.has('Set-Cookie'), false) + + // Session cookie created when a new session is used + response = await router.fetch('https://remix.run/', { method: 'POST' }) + assert.equal(await response.text(), 'Home (post): Remix') + assert.match(response.headers.get('Set-Cookie')!, /HttpOnly;/) + assert.match(response.headers.get('Set-Cookie')!, /Path=\/;/) // Grab the set-cookie header and extract/decode the session to ensure that // it is a cookie session that contains the data in the cookie - let cookie = response.headers.get('Set-Cookie')?.split(';')[0] || '' + let cookie = getSessionCookie(response) let session = JSON.parse(atob(decodeURIComponent(cookie.split('=')[1]))) assert.deepEqual(session, { name: 'Remix' }) - // Session parsing - response = await router.fetch('https://remix.run', { + // Parses the session from the incoming cookie + // No updated session cookie on read-only requests + response = await router.fetch('https://remix.run/', { headers: { - Cookie: response.headers.get('Set-Cookie')?.split(';')[0] || '', + Cookie: cookie, }, }) - assert.equal(await response.text(), 'Home') - assert.deepEqual(requestLog, [ - 'middleware: undefined', - 'setting name Remix', - 'middleware: Remix', - 'handler: Remix', - ]) + assert.equal(await response.text(), 'Home: Remix') + assert.equal(response.headers.has('Set-Cookie'), false) + + // Parses the session from the incoming cookie + // Updates session cookie when session is mutated + response = await router.fetch('https://remix.run/?name=Remix2', { + method: 'POST', + headers: { + Cookie: cookie, + }, + }) + assert.equal(response.headers.has('Set-Cookie'), true) + assert.equal(await response.text(), 'Home (post): Remix2') }) - it('accepts a sessionStorage for user-controlled session implementations', async () => { + it('provides session to middleware and handlers', async () => { let routes = createRoutes({ home: '/', }) @@ -1795,50 +1798,28 @@ describe('sessions', () => { home: '/', }) - let requestLog: string[] = [] - let router = createRouter({ defaultHandler: ({ url, session }) => { - requestLog.push(`default handler: ${session?.get('name')}`) - return new Response(`Not Found: ${url.pathname}`) + return new Response(`Not Found: ${url.pathname} ${session?.get('name')}`) }, }) - router.use(({ session }) => { - requestLog.push(`middleware: ${session.get('name')}`) - }) - - router.get(routes.home, ({ session }) => { - requestLog.push(`setting name Remix`) + router.post(routes.home, ({ session }) => { session.set('name', 'Remix') return new Response('Home') }) // Session creation - let response = await router.fetch('https://remix.run') - + let response = await router.fetch('https://remix.run', { method: 'POST' }) assert.equal(await response.text(), 'Home') - assert.deepEqual(requestLog, ['middleware: undefined', 'setting name Remix']) - - // Grab the set-cookie header and extract/decode the session to ensure that - // it is a cookie session that contains the data in the cookie - let cookie = response.headers.get('Set-Cookie')?.split(';')[0] || '' - let session = JSON.parse(atob(decodeURIComponent(cookie.split('=')[1]))) - assert.deepEqual(session, { name: 'Remix' }) - // Session parsing response = await router.fetch('https://remix.run/junk', { headers: { - Cookie: response.headers.get('Set-Cookie')?.split(';')[0] || '', + Cookie: getSessionCookie(response), }, }) - assert.equal(await response.text(), 'Not Found: /junk') - assert.deepEqual(requestLog, [ - 'middleware: undefined', - 'setting name Remix', - 'default handler: Remix', - ]) + assert.equal(await response.text(), 'Not Found: /junk Remix') }) it('exposes session to sub-routers', async () => { @@ -1897,7 +1878,7 @@ describe('sessions', () => { }) describe('cookie-backed sessions', () => { - it('sends a set-cookie header on initial session creation', async () => { + it('does not send a set-cookie header on initial session creation if the session is not used', async () => { let routes = createRoutes({ home: '/', }) @@ -1908,9 +1889,26 @@ describe('sessions', () => { return new Response('Home') }) - let response1 = await router.fetch('https://remix.run') - assert.equal(await response1.text(), 'Home') - assert.equal(response1.headers.has('Set-Cookie'), true) + let response = await router.fetch('https://remix.run') + assert.equal(await response.text(), 'Home') + assert.equal(response.headers.has('Set-Cookie'), false) + }) + + it('sends a set-cookie header on initial session creation if the session is used', async () => { + let routes = createRoutes({ + home: '/', + }) + + let router = createRouter({ sessionStorage: createCookieSessionStorage() }) + + router.get(routes.home, ({ session }) => { + session.set('name', 'Remix') + return new Response('Home') + }) + + let response = await router.fetch('https://remix.run') + assert.equal(await response.text(), 'Home') + assert.equal(response.headers.has('Set-Cookie'), true) }) it('does not send a set-cookie header on request that only read from a session', async () => { @@ -1987,7 +1985,8 @@ describe('sessions', () => { let router = createRouter({ sessionStorage: createCookieSessionStorage() }) - router.get(routes.home, () => { + router.get(routes.home, ({ session }) => { + session.set('name', 'Remix') return new Response('Home') }) @@ -2006,7 +2005,7 @@ describe('sessions', () => { Cookie: cookie, }, }) - assert.equal(response.headers.has('Set-Cookie'), false) + assert.equal(response.headers.has('Set-Cookie'), true) // Logout to destroy the session let response5 = await router.fetch('https://remix.run/logout', { @@ -2024,7 +2023,7 @@ describe('sessions', () => { }) describe('non-cookie-backed-sessions', () => { - it('sends a set-cookie header on initial session creation', async () => { + it('does not send a set-cookie header on initial session creation if the session is not used', async () => { let routes = createRoutes({ home: '/', }) @@ -2035,9 +2034,26 @@ describe('sessions', () => { return new Response('Home') }) - let response1 = await router.fetch('https://remix.run') - assert.equal(await response1.text(), 'Home') - assert.equal(response1.headers.has('Set-Cookie'), true) + let response = await router.fetch('https://remix.run') + assert.equal(await response.text(), 'Home') + assert.equal(response.headers.has('Set-Cookie'), false) + }) + + it('sends a set-cookie header on initial session creation if the session is used', async () => { + let routes = createRoutes({ + home: '/', + }) + + let router = createRouter({ sessionStorage: createMemorySessionStorage() }) + + router.get(routes.home, ({ session }) => { + session.set('name', 'Remix') + return new Response('Home') + }) + + let response = await router.fetch('https://remix.run') + assert.equal(await response.text(), 'Home') + assert.equal(response.headers.has('Set-Cookie'), true) }) it('does not send a set-cookie header on request that only read from a session', async () => { @@ -2080,24 +2096,22 @@ describe('sessions', () => { }) let response = await router.fetch('https://remix.run') - let cookie = getSessionCookie(response) + assert.equal(await response.text(), 'Home:') + assert.equal(response.headers.has('Set-Cookie'), false) response = await router.fetch('https://remix.run', { method: 'post', body: '', - headers: { - Cookie: cookie, - }, }) assert.equal(await response.text(), 'Home (post):Remix') - assert.equal(response.headers.has('Set-Cookie'), false) + assert.equal(response.headers.has('Set-Cookie'), true) // Another GET request - should read from the session but not send back a // Set-Cookie header response = await router.fetch('https://remix.run', { headers: { - Cookie: cookie, + Cookie: getSessionCookie(response), }, }) @@ -2113,7 +2127,8 @@ describe('sessions', () => { let router = createRouter({ sessionStorage: createMemorySessionStorage() }) - router.get(routes.home, () => { + router.get(routes.home, ({ session }) => { + session.set('name', 'Remix') return new Response('Home') }) diff --git a/packages/session/src/lib/session.ts b/packages/session/src/lib/session.ts index dc8c797f230..7094334b556 100644 --- a/packages/session/src/lib/session.ts +++ b/packages/session/src/lib/session.ts @@ -8,7 +8,7 @@ import { warnOnce } from './warnings.ts' * An object of name/value pairs to be used in the session. */ export interface SessionData { - [name: string]: any + [name: string]: unknown } /** @@ -72,7 +72,7 @@ export interface Session { /** * Clears a session for destruction - * */ + **/ destroy(): void } @@ -165,15 +165,23 @@ export function createSession( /** * Returns true if an object is a Remix session. */ -export function isSession(object: any): object is Session { +export function isSession(object: unknown): object is Session { return ( + typeof object === 'object' && object != null && + 'id' in object && typeof object.id === 'string' && + 'data' in object && typeof object.data !== 'undefined' && + 'has' in object && typeof object.has === 'function' && + 'get' in object && typeof object.get === 'function' && + 'set' in object && typeof object.set === 'function' && + 'flash' in object && typeof object.flash === 'function' && + 'unset' in object && typeof object.unset === 'function' ) } From 9c9ed31b5af77157b7b68a8bd0a815c809359104 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 28 Oct 2025 14:45:43 -0400 Subject: [PATCH 27/37] Convert session to a class, inline middleware logic --- .../fetch-router/src/lib/middleware.test.ts | 6 +- .../src/lib/middleware/session.ts | 64 ------ .../src/lib/request-context.test.ts | 16 +- .../fetch-router/src/lib/request-context.ts | 13 +- packages/fetch-router/src/lib/router.ts | 69 ++++-- packages/session/src/index.ts | 4 +- packages/session/src/lib/cookie-storage.ts | 5 +- packages/session/src/lib/session.test.ts | 27 +-- packages/session/src/lib/session.ts | 196 +++++++----------- 9 files changed, 164 insertions(+), 236 deletions(-) delete mode 100644 packages/fetch-router/src/lib/middleware/session.ts diff --git a/packages/fetch-router/src/lib/middleware.test.ts b/packages/fetch-router/src/lib/middleware.test.ts index 377735bb6cb..ee3cfb76dbe 100644 --- a/packages/fetch-router/src/lib/middleware.test.ts +++ b/packages/fetch-router/src/lib/middleware.test.ts @@ -4,14 +4,10 @@ import { describe, it } from 'node:test' import { runMiddleware } from './middleware.ts' import type { NextFunction } from './middleware.ts' import { RequestContext } from './request-context.ts' -import { createSession } from '@remix-run/session' function mockContext(input: string | Request, params: Record = {}): RequestContext { - let session = createSession() let context = - input instanceof Request - ? new RequestContext(input, session) - : new RequestContext(new Request(input), session) + input instanceof Request ? new RequestContext(input) : new RequestContext(new Request(input)) context.params = params return context } diff --git a/packages/fetch-router/src/lib/middleware/session.ts b/packages/fetch-router/src/lib/middleware/session.ts deleted file mode 100644 index 2e2ab59719a..00000000000 --- a/packages/fetch-router/src/lib/middleware/session.ts +++ /dev/null @@ -1,64 +0,0 @@ -import type { Session, SessionStorage } from '@remix-run/session' -import type { Middleware } from '../middleware.ts' - -export interface SessionOptions { - /** - * Session storage instance to create user sessions. - */ - sessionStorage: SessionStorage -} - -// We use this no-op session approach so we can keep session logic contained -// in this middleware. Otherwise, in order to ensure `context.session` is always -// populated, we would have to create it prior to creating the `RequestContext` -// because session parsing is async so it can't be done in the constructor or a -// `context.session` getter method. -export const NoOpSession: Session = { - id: '', - data: {}, - status: 'clean', - has: () => false, - get: () => undefined, - set() {}, - unset() {}, - flash() {}, - destroy() {}, -} - -/** - * Creates a middleware handler that manages user sessions. - */ -export function session(options: SessionOptions): Middleware { - return async (context, next) => { - if (context.session === NoOpSession) { - let cookie = context.request.headers.get('Cookie') - context.session = await options.sessionStorage.getSession(cookie) - } - - let response = await next() - - let { session } = context - if (session.status === 'destroyed') { - let cookie = await options.sessionStorage.destroySession(session) - response.headers.append('Set-Cookie', cookie) - } else if (session.status === 'dirty') { - // Commit the session to persist the data to the backing store - let cookie = await options.sessionStorage.commitSession(session) - - // But only add the Set-Cookie header if info serialized in the cookie has changed: - // - For cookie-backed session, `session.id` is always empty - they store all - // data in the cookie and thus _always_ need to be committed when the session - // is new or dirty - // - For non-cookie-backed sessions (file, memory, etc), `session.id` is only - // empty on initial creation, which means we need to commit. `session.id will - // be populated for existing sessions read in from a cookie, and when that - // happens we don't need to send up a new cookie because we already have the - // ID in there - if (session.id === '') { - response.headers.append('Set-Cookie', cookie) - } - } - - return response - } -} diff --git a/packages/fetch-router/src/lib/request-context.test.ts b/packages/fetch-router/src/lib/request-context.test.ts index b42e77c8216..7654d95ad46 100644 --- a/packages/fetch-router/src/lib/request-context.test.ts +++ b/packages/fetch-router/src/lib/request-context.test.ts @@ -1,7 +1,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { RequestContext } from './request-context.ts' -import { createSession } from '@remix-run/session' +import { Session } from '@remix-run/session' describe('new RequestContext()', () => { it('has a header object that is SuperHeaders', () => { @@ -10,7 +10,7 @@ describe('new RequestContext()', () => { 'Content-Type': 'application/json', }, }) - let context = new RequestContext(req, createSession()) + let context = new RequestContext(req) assert.equal('contentType' in context.headers, true) assert.equal('contentType' in context.request.headers, false) @@ -20,4 +20,16 @@ describe('new RequestContext()', () => { context.request.headers.get('content-type'), ) }) + + it('handles sessions with a default empty session if none exist', () => { + let req = new Request('http://localhost:3000/', {}) + let context = new RequestContext(req) + + // Default/empty session + assert.equal(context.session.id, '') + + let session = new Session({}, 'test') + context._session = session + assert.equal(context.session?.id, 'test') + }) }) diff --git a/packages/fetch-router/src/lib/request-context.ts b/packages/fetch-router/src/lib/request-context.ts index 94e81680e3c..3989e33feaf 100644 --- a/packages/fetch-router/src/lib/request-context.ts +++ b/packages/fetch-router/src/lib/request-context.ts @@ -1,9 +1,8 @@ import SuperHeaders from '@remix-run/headers' -import { type Session } from '@remix-run/session' +import { Session } from '@remix-run/session' import { AppStorage } from './app-storage.ts' import type { RequestBodyMethod, RequestMethod } from './request-methods.ts' -import { NoOpSession } from './middleware/session.ts' /** * A context object that contains information about the current request. Every request @@ -37,9 +36,10 @@ export class RequestContext< */ request: Request /** - * Active session for the request + * @private + * Privately tracked session, if exists */ - session: Session + _session: Session | undefined /** * Shared application-specific storage. */ @@ -62,7 +62,6 @@ export class RequestContext< this.params = {} as Params this.request = request this.storage = new AppStorage() - this.session = NoOpSession this.headers = new SuperHeaders(request.headers) this.url = new URL(request.url) } @@ -87,4 +86,8 @@ export class RequestContext< return files } + + get session() { + return this._session ?? new Session() + } } diff --git a/packages/fetch-router/src/lib/router.ts b/packages/fetch-router/src/lib/router.ts index 6ae76044d65..a8aa9130b4b 100644 --- a/packages/fetch-router/src/lib/router.ts +++ b/packages/fetch-router/src/lib/router.ts @@ -16,7 +16,6 @@ import { Route } from './route-map.ts' import type { RouteMap } from './route-map.ts' import type { SessionStorage } from '@remix-run/session' import { createCookieSessionStorage } from '@remix-run/session' -import { session } from './middleware/session.ts' export interface RouterOptions { /** @@ -42,7 +41,7 @@ export interface RouterOptions { /** * Session storage instance to create user sessions. */ - sessionStorage?: SessionStorage + sessionStorage?: SessionStorage | boolean /** * A function that handles file uploads. It receives a `FileUpload` object and may return any * value that is a valid `FormData` value. @@ -78,7 +77,7 @@ export class Router { #matcher: Matcher #middleware: Middleware[] | undefined #parseFormData: (ParseFormDataOptions & { suppressErrors?: boolean }) | boolean - #sessionMiddleware: Middleware + #sessionStorage: SessionStorage | undefined #uploadHandler: FileUploadHandler | undefined #methodOverride: string | boolean @@ -86,16 +85,17 @@ export class Router { this.#defaultHandler = options?.defaultHandler ?? noMatchHandler this.#matcher = options?.matcher ?? new RegExpMatcher() this.#parseFormData = options?.parseFormData ?? true - this.#sessionMiddleware = session({ - sessionStorage: - options?.sessionStorage ?? - createCookieSessionStorage({ - cookie: { - httpOnly: true, - }, - }), - }) - this.#middleware = [this.#sessionMiddleware] + if (options?.sessionStorage === false) { + this.#sessionStorage = undefined + } else if (options?.sessionStorage != null && options.sessionStorage !== true) { + this.#sessionStorage = options.sessionStorage + } else { + this.#sessionStorage = createCookieSessionStorage({ + cookie: { + httpOnly: true, + }, + }) + } this.#uploadHandler = options?.uploadHandler this.#methodOverride = options?.methodOverride ?? true } @@ -114,6 +114,7 @@ export class Router { let response = await this.dispatch(context) if (response == null) { response = await this.#runHandler(this.#defaultHandler, context, this.#middleware) + await this.#setSessionCookieHeader(response, context) } return response @@ -132,11 +133,6 @@ export class Router { ): Promise { let context = request instanceof Request ? await this.#createContext(request) : request - // Prepend session middleware only for the root router - if (upstreamMiddleware == null || upstreamMiddleware[0] !== this.#sessionMiddleware) { - upstreamMiddleware = concatMiddleware([this.#sessionMiddleware], upstreamMiddleware) - } - for (let match of this.#matcher.matchAll(context.url)) { if ('router' in match.data) { // Matched a sub-router, try to dispatch to it @@ -174,11 +170,14 @@ export class Router { context.params = match.params context.url = match.url - return this.#runHandler( + let response = await this.#runHandler( handler, context, concatMiddleware(upstreamMiddleware, routeMiddleware), ) + + await this.#setSessionCookieHeader(response, context) + return response } return null @@ -187,6 +186,11 @@ export class Router { async #createContext(request: Request): Promise { let context = new RequestContext(request) + if (this.#sessionStorage) { + let cookie = context.request.headers.get('Cookie') + context._session = await this.#sessionStorage.getSession(cookie) + } + if (!RequestBodyMethods.includes(request.method as RequestBodyMethod)) { return context } @@ -241,6 +245,33 @@ export class Router { : await runMiddleware(middleware, context, handler) } + async #setSessionCookieHeader(response: Response, context: RequestContext): Promise { + if (!this.#sessionStorage) { + return + } + let { session } = context + if (session.status === 'destroyed') { + let cookie = await this.#sessionStorage.destroySession(session) + response.headers.append('Set-Cookie', cookie) + } else if (session.status === 'dirty') { + // Commit the session to persist the data to the backing store + let cookie = await this.#sessionStorage.commitSession(session) + + // But only add the Set-Cookie header if info serialized in the cookie has changed: + // - For cookie-backed session, `session.id` is always empty - they store all + // data in the cookie and thus _always_ need to be committed when the session + // is new or dirty + // - For non-cookie-backed sessions (file, memory, etc), `session.id` is only + // empty on initial creation, which means we need to commit. `session.id will + // be populated for existing sessions read in from a cookie, and when that + // happens we don't need to send up a new cookie because we already have the + // ID in there + if (session.id === '') { + response.headers.append('Set-Cookie', cookie) + } + } + } + /** * Mount a router at a given pathname prefix in the current router. */ diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index a2d86202ba5..399672582ed 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -1,12 +1,10 @@ export { - type Session, type SessionData, type SessionIdStorageStrategy, type SessionStorage, type FlashSessionData, - createSession, createSessionStorage, - isSession, + Session, } from './lib/session.ts' export { createCookieSessionStorage } from './lib/cookie-storage.ts' diff --git a/packages/session/src/lib/cookie-storage.ts b/packages/session/src/lib/cookie-storage.ts index 4c444fc2e66..c98ec42376b 100644 --- a/packages/session/src/lib/cookie-storage.ts +++ b/packages/session/src/lib/cookie-storage.ts @@ -1,6 +1,6 @@ import { createCookie, isCookie } from '@remix-run/cookie' import type { SessionStorage, SessionIdStorageStrategy, SessionData } from './session.ts' -import { warnOnceAboutSigningSessionCookie, createSession } from './session.ts' +import { warnOnceAboutSigningSessionCookie, Session } from './session.ts' interface CookieSessionStorageOptions { /** @@ -30,7 +30,8 @@ export function createCookieSessionStorage return { async getSession(cookieHeader, options) { - return createSession(cookieHeader ? await cookie.parse(cookieHeader, options) : undefined) + let data = cookieHeader ? await cookie.parse(cookieHeader, options) : undefined + return new Session(data) }, async commitSession(session, options) { let serializedCookie = await cookie.serialize(session.data, options) diff --git a/packages/session/src/lib/session.test.ts b/packages/session/src/lib/session.test.ts index 27c9219d0cb..3f7fb1f6952 100644 --- a/packages/session/src/lib/session.test.ts +++ b/packages/session/src/lib/session.test.ts @@ -1,7 +1,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' -import { createSession, isSession } from './session.ts' +import { Session } from './session.ts' import { createCookieSessionStorage } from './cookie-storage.ts' import { createMemorySessionStorage } from './memory-storage.ts' @@ -11,11 +11,11 @@ function getCookieFromSetCookie(setCookie: string): string { describe('Session', () => { it('has an empty id by default', () => { - assert.equal(createSession().id, '') + assert.equal(new Session().id, '') }) it('correctly stores and retrieves values', () => { - let session = createSession() + let session = new Session() session.set('user', 'mjackson') session.flash('error', 'boom') @@ -39,7 +39,7 @@ describe('Session', () => { }) it('correctly destroys a session', () => { - let session = createSession() + let session = new Session() session.set('user', 'mjackson') assert.equal(session.get('user'), 'mjackson') @@ -51,7 +51,7 @@ describe('Session', () => { }) it('tracks session status for newly created sessions', () => { - let session = createSession() + let session = new Session() assert.equal(session.status, 'new') session.get('user') @@ -65,7 +65,7 @@ describe('Session', () => { }) it('tracks session status for existing sessions', () => { - let session = createSession({ user: 'brophdawg11' }) + let session = new Session({ user: 'brophdawg11' }) assert.equal(session.status, 'clean') session.get('user') @@ -79,7 +79,7 @@ describe('Session', () => { }) it('throws an error if you try to operate on a destroyed session', () => { - let session = createSession({ user: 'brophdawg11' }) + let session = new Session({ user: 'brophdawg11' }) assert.equal(session.status, 'clean') session.destroy() @@ -92,19 +92,6 @@ describe('Session', () => { }) }) -describe('isSession', () => { - it('returns `true` for Session objects', () => { - assert.equal(isSession(createSession()), true) - }) - - it('returns `false` for non-Session objects', () => { - assert.equal(isSession({}), false) - assert.equal(isSession([]), false) - assert.equal(isSession(''), false) - assert.equal(isSession(true), false) - }) -}) - describe('In-memory session storage', () => { it('persists session data across requests', async () => { let { getSession, commitSession } = createMemorySessionStorage({ diff --git a/packages/session/src/lib/session.ts b/packages/session/src/lib/session.ts index 7094334b556..c1c17c0b60a 100644 --- a/packages/session/src/lib/session.ts +++ b/packages/session/src/lib/session.ts @@ -13,36 +13,63 @@ export interface SessionData { /** * Session persists data across HTTP requests. + * + * Note: This class is typically not invoked directly by application code. + * Instead, use a `SessionStorage` object's `getSession` method. */ -export interface Session { +export class Session { + #id: string + #map: Map, unknown> + #status: 'new' | 'clean' | 'dirty' | 'destroyed' + + constructor(initialData?: Partial, id?: string) { + // Brand new sessions start in a dirty state to force an initial commit + this.#status = initialData == null && id == null ? 'new' : 'clean' + this.#id = id ?? '' + this.#map = new Map(initialData ? Object.entries(initialData) : undefined) as Map< + keyof Data | FlashDataKey, + unknown + > + } + /** * A unique identifier for this session. * * Note: This will be the empty string for newly created sessions and * sessions that are not backed by a database (i.e. cookie-based sessions). */ - readonly id: string + get id() { + return this.#id + } /** - * The raw data contained in this session. + * A value indicating the status of the session. * - * This is useful mostly for SessionStorage internally to access the raw - * session data to persist. + * This is useful for middlewares to know if they need to commit the session. */ - readonly data: FlashSessionData + get status() { + return this.#status + } /** - * A value indicating the status of the session. + * The raw data contained in this session. * - * This is useful for middlewares to know if they need to commit the session. + * This is useful mostly for SessionStorage internally to access the raw + * session data to persist. */ - readonly status: 'new' | 'clean' | 'dirty' | 'destroyed' + get data() { + return Object.fromEntries(this.#map) as FlashSessionData + } /** * Returns `true` if the session has a value for the given `name`, `false` * otherwise. */ - has(name: (keyof Data | keyof FlashData) & string): boolean + has(name: (keyof Data | keyof FlashData) & string) { + return ( + this.#map.has(name as keyof Data) || this.#map.has(flash(name as keyof FlashData & string)) + ) + } /** * Returns the value for the given `name` in this session. @@ -52,28 +79,65 @@ export interface Session { ): | (Key extends keyof Data ? Data[Key] : undefined) | (Key extends keyof FlashData ? FlashData[Key] : undefined) - | undefined + | undefined { + if (this.#map.has(name as keyof Data)) { + return this.#map.get(name as keyof Data) as Key extends keyof Data ? Data[Key] : undefined + } + + let flashName = flash(name as keyof FlashData & string) + if (this.#map.has(flashName)) { + let value = this.#map.get(flashName) as Key extends keyof FlashData + ? FlashData[Key] + : undefined + this.#map.delete(flashName) + this.#status = 'dirty' + return value + } + + return undefined + } /** * Sets a value in the session for the given `name`. */ - set(name: Key, value: Data[Key]): void + set(name: Key, value: Data[Key]) { + this.#throwIfDestroyed() + this.#map.set(name, value) + this.#status = 'dirty' + } /** * Sets a value in the session that is only valid until the next `get()`. * This can be useful for temporary values, like error messages. */ - flash(name: Key, value: FlashData[Key]): void + flash(name: Key, value: FlashData[Key]) { + this.#throwIfDestroyed() + this.#map.set(flash(name), value) + this.#status = 'dirty' + } /** * Removes a value from the session. */ - unset(name: keyof Data & string): void + unset(name: keyof Data & string) { + this.#throwIfDestroyed() + this.#map.delete(name) + this.#status = 'dirty' + } /** * Clears a session for destruction **/ - destroy(): void + destroy() { + this.#map.clear() + this.#status = 'destroyed' + } + + #throwIfDestroyed() { + if (this.#status === 'destroyed') { + throw new Error('Cannot operate on a destroyed session') + } + } } export type FlashSessionData = Partial< @@ -86,106 +150,6 @@ function flash(name: Key): FlashDataKey { return `__flash_${name}__` } -/** - * Creates a new Session object. - * - * Note: This function is typically not invoked directly by application code. - * Instead, use a `SessionStorage` object's `getSession` method. - */ -export function createSession( - initialData?: Partial, - id?: string, -): Session { - // Brand new sessions start in a dirty state to force an initial commit - let status: 'new' | 'clean' | 'dirty' | 'destroyed' = - initialData == null && id == null ? 'new' : 'clean' - - initialData ||= {} - id ??= '' - - let map = new Map(Object.entries(initialData)) as Map< - keyof Data | FlashDataKey, - any - > - - let throwIfDestroyed = () => { - if (status === 'destroyed') { - throw new Error('Cannot operate on a destroyed session') - } - } - - return { - get id() { - return id - }, - get data() { - return Object.fromEntries(map) as FlashSessionData - }, - get status() { - return status - }, - has(name) { - return map.has(name as keyof Data) || map.has(flash(name as keyof FlashData & string)) - }, - get(name) { - if (map.has(name as keyof Data)) return map.get(name as keyof Data) - - let flashName = flash(name as keyof FlashData & string) - if (map.has(flashName)) { - let value = map.get(flashName) - map.delete(flashName) - status = 'dirty' - return value - } - - return undefined - }, - set(name, value) { - throwIfDestroyed() - map.set(name, value) - status = 'dirty' - }, - flash(name, value) { - throwIfDestroyed() - map.set(flash(name), value) - status = 'dirty' - }, - unset(name) { - throwIfDestroyed() - map.delete(name) - status = 'dirty' - }, - destroy() { - map.clear() - status = 'destroyed' - }, - } -} - -/** - * Returns true if an object is a Remix session. - */ -export function isSession(object: unknown): object is Session { - return ( - typeof object === 'object' && - object != null && - 'id' in object && - typeof object.id === 'string' && - 'data' in object && - typeof object.data !== 'undefined' && - 'has' in object && - typeof object.has === 'function' && - 'get' in object && - typeof object.get === 'function' && - 'set' in object && - typeof object.set === 'function' && - 'flash' in object && - typeof object.flash === 'function' && - 'unset' in object && - typeof object.unset === 'function' - ) -} - /** * SessionStorage stores session data between HTTP requests and knows how to * parse and create cookies. @@ -277,7 +241,7 @@ export function createSessionStorage({ async getSession(cookieHeader, options) { let id = cookieHeader && (await cookie.parse(cookieHeader, options)) let data = id && (await readData(id)) - return createSession(data, id) + return id ? new Session(data, id) : new Session() }, async commitSession(session, options) { let { id, data } = session From 62c9c25e5c74bd2dd0be34b0d50ea69963efe0c5 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 28 Oct 2025 15:02:32 -0400 Subject: [PATCH 28/37] Cleanup --- .../fetch-router/src/lib/request-context.ts | 3 +- packages/fetch-router/src/lib/router.test.ts | 58 +++++++++++++------ packages/fetch-router/src/lib/router.ts | 13 ++--- 3 files changed, 49 insertions(+), 25 deletions(-) diff --git a/packages/fetch-router/src/lib/request-context.ts b/packages/fetch-router/src/lib/request-context.ts index 3989e33feaf..96aefe192ca 100644 --- a/packages/fetch-router/src/lib/request-context.ts +++ b/packages/fetch-router/src/lib/request-context.ts @@ -88,6 +88,7 @@ export class RequestContext< } get session() { - return this._session ?? new Session() + this._session ??= new Session() + return this._session } } diff --git a/packages/fetch-router/src/lib/router.test.ts b/packages/fetch-router/src/lib/router.test.ts index 01fbf35fa8c..7209df4fdb0 100644 --- a/packages/fetch-router/src/lib/router.test.ts +++ b/packages/fetch-router/src/lib/router.test.ts @@ -967,7 +967,6 @@ describe('router.dispatch()', () => { }) let request = new Request('https://remix.run/123') - let session = await createMemorySessionStorage().getSession() let context = new RequestContext(request) context.storage.set(storageKey, 'value') @@ -1463,10 +1462,8 @@ describe('abort signal support', () => { let controller = new AbortController() router.get('/', async () => { - // Abort in 1ms, while handler is running. We do this async to ensure - // `router.fetch()` rejects asynchronously so that `assert.rejects` doesn't - // re-throw the error and cause a false negative - setTimeout(() => controller.abort(), 1) + // Abort while handler is running + controller.abort() // Simulate some async work await new Promise((resolve) => setTimeout(resolve, 10)) return new Response('Home') @@ -1578,10 +1575,7 @@ describe('abort signal support', () => { let controller = new AbortController() router.use(async () => { - // Abort in 1ms, while handler is running. We do this async to ensure - // `router.fetch()` rejects asynchronously so that `assert.rejects` doesn't - // re-throw the error and cause a false negative - setTimeout(() => controller.abort(), 1) + controller.abort() await new Promise((resolve) => setTimeout(resolve, 10)) }) @@ -1603,10 +1597,7 @@ describe('abort signal support', () => { let controller = new AbortController() adminRouter.get('/', async () => { - // Abort in 1ms, while handler is running. We do this async to ensure - // `router.fetch()` rejects asynchronously so that `assert.rejects` doesn't - // re-throw the error and cause a false negative - setTimeout(() => controller.abort(), 1) + controller.abort() await new Promise((resolve) => setTimeout(resolve, 10)) return new Response('Admin') }) @@ -1651,10 +1642,7 @@ describe('abort signal support', () => { // Upstream middleware that aborts router.use(async () => { - // Abort in 1ms, while handler is running. We do this async to ensure - // `router.fetch()` rejects asynchronously so that `assert.rejects` doesn't - // re-throw the error and cause a false negative - setTimeout(() => controller.abort(), 1) + controller.abort() await new Promise((resolve) => setTimeout(resolve, 10)) }) @@ -1742,6 +1730,42 @@ describe('sessions', () => { assert.equal(await response.text(), 'Home (post): Remix2') }) + it('allows user to opt out of session handling entirely', async () => { + let routes = createRoutes({ + home: '/', + }) + + let router = createRouter({ sessionStorage: false }) + + router.get(routes.home, ({ session }) => { + return new Response(`Home: ${session.get('name')}`) + }) + + router.post(routes.home, ({ session, url }) => { + session.set('name', url.searchParams.get('name') ?? 'Remix') + return new Response(`Home (post): ${session.get('name')}`) + }) + + // No cookie created + let response = await router.fetch('https://remix.run') + assert.equal(await response.text(), 'Home: undefined') + assert.equal(response.headers.has('Set-Cookie'), false) + + // Even if the session is mutated + response = await router.fetch('https://remix.run/', { method: 'POST' }) + assert.equal(await response.text(), 'Home (post): Remix') + assert.equal(response.headers.has('Set-Cookie'), false) + + // And no session is parsed from the cookie + response = await router.fetch('https://remix.run/', { + headers: { + Cookie: '__session=eyJuYW1lIjoiUmVtaXgifQ%3D%3D', // { name: "Remix" } + }, + }) + assert.equal(await response.text(), 'Home: undefined') + assert.equal(response.headers.has('Set-Cookie'), false) + }) + it('provides session to middleware and handlers', async () => { let routes = createRoutes({ home: '/', diff --git a/packages/fetch-router/src/lib/router.ts b/packages/fetch-router/src/lib/router.ts index a8aa9130b4b..8fe87fccb9c 100644 --- a/packages/fetch-router/src/lib/router.ts +++ b/packages/fetch-router/src/lib/router.ts @@ -85,19 +85,18 @@ export class Router { this.#defaultHandler = options?.defaultHandler ?? noMatchHandler this.#matcher = options?.matcher ?? new RegExpMatcher() this.#parseFormData = options?.parseFormData ?? true - if (options?.sessionStorage === false) { - this.#sessionStorage = undefined - } else if (options?.sessionStorage != null && options.sessionStorage !== true) { - this.#sessionStorage = options.sessionStorage - } else { + this.#uploadHandler = options?.uploadHandler + this.#methodOverride = options?.methodOverride ?? true + + if (!options || options.sessionStorage == null || options.sessionStorage === true) { this.#sessionStorage = createCookieSessionStorage({ cookie: { httpOnly: true, }, }) + } else if (options?.sessionStorage) { + this.#sessionStorage = options.sessionStorage } - this.#uploadHandler = options?.uploadHandler - this.#methodOverride = options?.methodOverride ?? true } /** From fe8c8ff970cb8e064f69f974a7f992795a1ca125 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 28 Oct 2025 16:03:42 -0400 Subject: [PATCH 29/37] Fix up bookstore tests --- demos/bookstore/app/account.test.ts | 8 ++-- demos/bookstore/app/admin.books.test.ts | 4 +- demos/bookstore/app/admin.test.ts | 4 +- demos/bookstore/app/auth.test.ts | 14 ++++--- demos/bookstore/app/cart.test.ts | 49 ++++++++++++++++++------- demos/bookstore/app/checkout.test.ts | 6 +-- demos/bookstore/app/middleware/auth.ts | 4 +- demos/bookstore/app/uploads.test.ts | 4 +- demos/bookstore/app/utils/session.ts | 7 ++-- demos/bookstore/package.json | 1 + demos/bookstore/test/helpers.ts | 24 +++++++----- pnpm-lock.yaml | 3 ++ 12 files changed, 82 insertions(+), 46 deletions(-) diff --git a/demos/bookstore/app/account.test.ts b/demos/bookstore/app/account.test.ts index 106a063ea54..2730a95c3b9 100644 --- a/demos/bookstore/app/account.test.ts +++ b/demos/bookstore/app/account.test.ts @@ -13,10 +13,10 @@ describe('account handlers', () => { }) it('GET /account returns account page when authenticated', async () => { - let sessionId = await loginAsCustomer(router) + let sessionCookie = await loginAsCustomer(router) // Now access account page with session - let request = requestWithSession('http://localhost:3000/account', sessionId) + let request = requestWithSession('http://localhost:3000/account', sessionCookie) let response = await router.fetch(request) assert.equal(response.status, 200) @@ -27,10 +27,10 @@ describe('account handlers', () => { }) it('GET /account/orders/:orderId shows order for authenticated user', async () => { - let sessionId = await loginAsCustomer(router) + let sessionCookie = await loginAsCustomer(router) // Access existing order - let request = requestWithSession('http://localhost:3000/account/orders/1001', sessionId) + let request = requestWithSession('http://localhost:3000/account/orders/1001', sessionCookie) let response = await router.fetch(request) assert.equal(response.status, 200) diff --git a/demos/bookstore/app/admin.books.test.ts b/demos/bookstore/app/admin.books.test.ts index 562b8b3fd3d..0ced1962841 100644 --- a/demos/bookstore/app/admin.books.test.ts +++ b/demos/bookstore/app/admin.books.test.ts @@ -6,10 +6,10 @@ import { loginAsAdmin, requestWithSession } from '../test/helpers.ts' describe('admin books handlers', () => { it('POST /admin/books creates new book when admin', async () => { - let sessionId = await loginAsAdmin(router) + let sessionCookie = await loginAsAdmin(router) // Create new book - let createRequest = requestWithSession('http://localhost:3000/admin/books', sessionId, { + let createRequest = requestWithSession('http://localhost:3000/admin/books', sessionCookie, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', diff --git a/demos/bookstore/app/admin.test.ts b/demos/bookstore/app/admin.test.ts index 454307a2159..7255c4b7676 100644 --- a/demos/bookstore/app/admin.test.ts +++ b/demos/bookstore/app/admin.test.ts @@ -13,10 +13,10 @@ describe('admin handlers', () => { }) it('GET /admin returns 403 for non-admin users', async () => { - let sessionId = await loginAsCustomer(router) + let sessionCookie = await loginAsCustomer(router) // Try to access admin - let request = requestWithSession('http://localhost:3000/admin', sessionId) + let request = requestWithSession('http://localhost:3000/admin', sessionCookie) let response = await router.fetch(request) assert.equal(response.status, 403) diff --git a/demos/bookstore/app/auth.test.ts b/demos/bookstore/app/auth.test.ts index ee76ff0de0d..e05151ab74e 100644 --- a/demos/bookstore/app/auth.test.ts +++ b/demos/bookstore/app/auth.test.ts @@ -2,7 +2,11 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { router } from './router.ts' -import { getSessionCookie, assertContains } from '../test/helpers.ts' +import { + getSessionCookie as sessionCookie, + assertContains, + getSessionCookie, +} from '../test/helpers.ts' describe('auth handlers', () => { it('POST /login with valid credentials sets session cookie and redirects', async () => { @@ -18,8 +22,8 @@ describe('auth handlers', () => { assert.equal(response.status, 302) assert.equal(response.headers.get('Location'), '/account') - let sessionId = getSessionCookie(response) - assert.ok(sessionId, 'Expected session cookie to be set') + let sessionCookie = getSessionCookie(response) + assert.ok(sessionCookie, 'Expected session cookie to be set') }) it('POST /login with invalid credentials returns 401', async () => { @@ -52,7 +56,7 @@ describe('auth handlers', () => { assert.equal(response.status, 302) assert.equal(response.headers.get('Location'), '/account') - let sessionId = getSessionCookie(response) - assert.ok(sessionId, 'Expected session cookie to be set') + let sessionCookie = getSessionCookie(response) + assert.ok(sessionCookie, 'Expected session cookie to be set') }) }) diff --git a/demos/bookstore/app/cart.test.ts b/demos/bookstore/app/cart.test.ts index 2f10cf90f5f..5dfc1bec051 100644 --- a/demos/bookstore/app/cart.test.ts +++ b/demos/bookstore/app/cart.test.ts @@ -2,7 +2,12 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { router } from './router.ts' -import { getSessionCookie, requestWithSession, assertContains } from '../test/helpers.ts' +import { + requestWithSession, + assertContains, + loginAsCustomer, + assertNotContains, +} from '../test/helpers.ts' describe('cart handlers', () => { it('POST /cart/api/add adds book to cart', async () => { @@ -20,55 +25,71 @@ describe('cart handlers', () => { }) it('GET /cart shows cart items', async () => { + let sessionCookie = await loginAsCustomer(router) + + let request = requestWithSession('http://localhost:3000/cart', sessionCookie) + let response = await router.fetch(request) + + assert.equal(response.status, 200) + let html = await response.text() + assertContains(html, 'Shopping Cart') + assertNotContains(html, 'Heavy Metal Guitar Riffs') + // First, add item to cart to get a session - let addResponse = await router.fetch('http://localhost:3000/cart/api/add', { + await router.fetch('http://localhost:3000/cart/api/add', { method: 'POST', body: new URLSearchParams({ bookId: '002', slug: 'heavy-metal', }), + headers: { + Cookie: sessionCookie, + }, redirect: 'manual', }) - let sessionId = getSessionCookie(addResponse) - assert.ok(sessionId) - // Now view cart with session - let request = requestWithSession('http://localhost:3000/cart', sessionId) - let response = await router.fetch(request) + request = requestWithSession('http://localhost:3000/cart', sessionCookie) + response = await router.fetch(request) assert.equal(response.status, 200) - let html = await response.text() + html = await response.text() assertContains(html, 'Shopping Cart') assertContains(html, 'Heavy Metal Guitar Riffs') }) it('cart persists state across requests with same session', async () => { + let sessionCookie = await loginAsCustomer(router) + // Add first item - let addResponse1 = await router.fetch('http://localhost:3000/cart/api/add', { + await router.fetch('http://localhost:3000/cart/api/add', { method: 'POST', body: new URLSearchParams({ bookId: '001', slug: 'bbq', }), + headers: { + Cookie: sessionCookie, + }, redirect: 'manual', }) - let sessionId = getSessionCookie(addResponse1) - assert.ok(sessionId) - // Add second item with same session - let addRequest2 = requestWithSession('http://localhost:3000/cart/api/add', sessionId, { + let addRequest2 = requestWithSession('http://localhost:3000/cart/api/add', sessionCookie, { method: 'POST', body: new URLSearchParams({ bookId: '003', slug: 'three-ways', }), + headers: { + Cookie: sessionCookie, + }, + redirect: 'manual', }) await router.fetch(addRequest2) // View cart - should have both items - let cartRequest = requestWithSession('http://localhost:3000/cart', sessionId) + let cartRequest = requestWithSession('http://localhost:3000/cart', sessionCookie) let cartResponse = await router.fetch(cartRequest) let html = await cartResponse.text() diff --git a/demos/bookstore/app/checkout.test.ts b/demos/bookstore/app/checkout.test.ts index af7fd86a409..ff295c6b777 100644 --- a/demos/bookstore/app/checkout.test.ts +++ b/demos/bookstore/app/checkout.test.ts @@ -13,10 +13,10 @@ describe('checkout handlers', () => { }) it('POST /checkout creates order when authenticated with items in cart', async () => { - let sessionId = await loginAsCustomer(router) + let sessionCookie = await loginAsCustomer(router) // Add item to cart - let addRequest = requestWithSession('http://localhost:3000/cart/api/add', sessionId, { + let addRequest = requestWithSession('http://localhost:3000/cart/api/add', sessionCookie, { method: 'POST', body: new URLSearchParams({ bookId: '001', @@ -26,7 +26,7 @@ describe('checkout handlers', () => { await router.fetch(addRequest) // Submit checkout - let checkoutRequest = requestWithSession('http://localhost:3000/checkout', sessionId, { + let checkoutRequest = requestWithSession('http://localhost:3000/checkout', sessionCookie, { method: 'POST', body: new URLSearchParams({ street: '123 Test St', diff --git a/demos/bookstore/app/middleware/auth.ts b/demos/bookstore/app/middleware/auth.ts index c88e739985d..854f38740bd 100644 --- a/demos/bookstore/app/middleware/auth.ts +++ b/demos/bookstore/app/middleware/auth.ts @@ -13,7 +13,7 @@ export const USER_KEY = createStorageKey() /** * Middleware that optionally loads the current user if authenticated. * Does not redirect if not authenticated. - * Attaches user (if any) and sessionId to context.storage. + * Attaches user (if any) to context.storage. */ export let loadAuth: Middleware = async ({ session, storage }) => { let userId = getUserIdFromSession(session) @@ -30,7 +30,7 @@ export let loadAuth: Middleware = async ({ session, storage }) => { /** * Middleware that requires a user to be authenticated. * Redirects to login if not authenticated. - * Attaches user and sessionId to context.storage. + * Attaches user to context.storage. */ export let requireAuth: Middleware = async ({ session, storage }) => { let userId = getUserIdFromSession(session) diff --git a/demos/bookstore/app/uploads.test.ts b/demos/bookstore/app/uploads.test.ts index 515bbaa009b..72493e409e0 100644 --- a/demos/bookstore/app/uploads.test.ts +++ b/demos/bookstore/app/uploads.test.ts @@ -8,7 +8,7 @@ import { uploadsStorage as uploads } from './utils/uploads.ts' describe('uploads handler', () => { it('serves uploaded files from storage', async () => { - let sessionId = await loginAsAdmin(router) + let sessionCookie = await loginAsAdmin(router) // Get initial book count let initialBookCount = getAllBooks().length @@ -62,7 +62,7 @@ describe('uploads handler', () => { ].join('\r\n') // Create book with file upload - let createRequest = requestWithSession('http://localhost:3000/admin/books', sessionId, { + let createRequest = requestWithSession('http://localhost:3000/admin/books', sessionCookie, { method: 'POST', headers: { 'Content-Type': `multipart/form-data; boundary=----${boundary}`, diff --git a/demos/bookstore/app/utils/session.ts b/demos/bookstore/app/utils/session.ts index 2574d318299..4cda143a20f 100644 --- a/demos/bookstore/app/utils/session.ts +++ b/demos/bookstore/app/utils/session.ts @@ -1,9 +1,10 @@ import type { User } from '../models/users.ts' import type { Session } from '@remix-run/session' -export interface SessionData { - userId?: string - sessionId: string +declare module '@remix-run/session' { + interface SessionData { + userId?: string + } } export function login(session: Session, user: User): void { diff --git a/demos/bookstore/package.json b/demos/bookstore/package.json index 17b65f540b1..341b3f5a3cb 100644 --- a/demos/bookstore/package.json +++ b/demos/bookstore/package.json @@ -12,6 +12,7 @@ "@remix-run/node-fetch-server": "workspace:*" }, "devDependencies": { + "@remix-run/session": "workspace:*", "@types/node": "^24.6.0", "esbuild": "^0.25.10", "tsx": "^4.20.6" diff --git a/demos/bookstore/test/helpers.ts b/demos/bookstore/test/helpers.ts index 195eccf6b31..0464c68217c 100644 --- a/demos/bookstore/test/helpers.ts +++ b/demos/bookstore/test/helpers.ts @@ -1,4 +1,4 @@ -import { SetCookie, Cookie } from '@remix-run/headers' +import { SetCookie } from '@remix-run/headers' /** * Extract session cookie from Set-Cookie header @@ -8,20 +8,26 @@ export function getSessionCookie(response: Response): string | null { if (!setCookieHeader) return null let setCookie = new SetCookie(setCookieHeader) - return setCookie.name === 'sessionId' ? (setCookie.value ?? null) : null + if (setCookie.name === '__session') { + return `${setCookie.name}=${setCookie.value}` + } + + return null } /** * Create a request with a session cookie */ -export function requestWithSession(url: string, sessionId: string, init?: RequestInit): Request { - let cookie = new Cookie({ sessionId }) - +export function requestWithSession( + url: string, + sessionCookie: string, + init?: RequestInit, +): Request { return new Request(url, { ...init, headers: { ...init?.headers, - Cookie: cookie.toString(), + Cookie: sessionCookie, }, }) } @@ -54,12 +60,12 @@ export async function login(router: any, email: string, password: string): Promi redirect: 'manual', }) - let sessionId = getSessionCookie(loginResponse) - if (!sessionId) { + let cookie = getSessionCookie(loginResponse) + if (!cookie) { throw new Error('Failed to get session cookie from login response') } - return sessionId + return cookie } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c492ba70e7..3c36650d034 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -51,6 +51,9 @@ importers: specifier: workspace:* version: link:../../packages/node-fetch-server devDependencies: + '@remix-run/session': + specifier: workspace:* + version: link:../../packages/session '@types/node': specifier: ^24.6.0 version: 24.6.0 From 3dd1582ded479fe8caaa3eb02af538d539b1cf65 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 28 Oct 2025 16:15:16 -0400 Subject: [PATCH 30/37] Add a few comments --- packages/fetch-router/src/lib/router.ts | 29 ++++++++++++++++--------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/packages/fetch-router/src/lib/router.ts b/packages/fetch-router/src/lib/router.ts index 8fe87fccb9c..c96398fbff7 100644 --- a/packages/fetch-router/src/lib/router.ts +++ b/packages/fetch-router/src/lib/router.ts @@ -89,6 +89,9 @@ export class Router { this.#methodOverride = options?.methodOverride ?? true if (!options || options.sessionStorage == null || options.sessionStorage === true) { + // Unless they opt-out, we default to an `HttpOnly` cookie-based session that + // will only be "activated" in a `Set-Cookie` response if they mutate the + // session this.#sessionStorage = createCookieSessionStorage({ cookie: { httpOnly: true, @@ -185,6 +188,7 @@ export class Router { async #createContext(request: Request): Promise { let context = new RequestContext(request) + // Only process sessions if they didn't opt-out if (this.#sessionStorage) { let cookie = context.request.headers.get('Cookie') context._session = await this.#sessionStorage.getSession(cookie) @@ -248,27 +252,32 @@ export class Router { if (!this.#sessionStorage) { return } + let { session } = context - if (session.status === 'destroyed') { - let cookie = await this.#sessionStorage.destroySession(session) - response.headers.append('Set-Cookie', cookie) - } else if (session.status === 'dirty') { - // Commit the session to persist the data to the backing store + if (session.status === 'dirty') { + // If the session has been mutated, commit to persist to the backing store let cookie = await this.#sessionStorage.commitSession(session) - // But only add the Set-Cookie header if info serialized in the cookie has changed: + // But only add the Set-Cookie header if info *serialized in the cookie* has changed: // - For cookie-backed session, `session.id` is always empty - they store all // data in the cookie and thus _always_ need to be committed when the session // is new or dirty // - For non-cookie-backed sessions (file, memory, etc), `session.id` is only - // empty on initial creation, which means we need to commit. `session.id will - // be populated for existing sessions read in from a cookie, and when that - // happens we don't need to send up a new cookie because we already have the - // ID in there + // empty on initial creation, so if they've set any session data to put it + // in a dirty state, we need to commit to store the session id in the cookie + // for subsequent requests. `session.id` be populated for existing sessions + // read in from a cookie, and when that happens we don't need to send up + // a new cookie because we already have the ID in there if (session.id === '') { response.headers.append('Set-Cookie', cookie) } + } else if (session.status === 'destroyed') { + let cookie = await this.#sessionStorage.destroySession(session) + response.headers.append('Set-Cookie', cookie) } + + // Otherwise, the session is new|clean but hasn't been mutated and we don't + // need to send any Set-Cookie header } /** From f9227a792be1906a22a5cf75255cc06ac6fe4598 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Tue, 28 Oct 2025 17:10:11 -0400 Subject: [PATCH 31/37] Support sub router sessions --- packages/fetch-router/src/lib/router.test.ts | 90 ++++++++++++++++++++ packages/fetch-router/src/lib/router.ts | 20 ++++- 2 files changed, 107 insertions(+), 3 deletions(-) diff --git a/packages/fetch-router/src/lib/router.test.ts b/packages/fetch-router/src/lib/router.test.ts index 7209df4fdb0..d1f726994c2 100644 --- a/packages/fetch-router/src/lib/router.test.ts +++ b/packages/fetch-router/src/lib/router.test.ts @@ -8,6 +8,7 @@ import { RequestContext } from './request-context.ts' import { createRoutes } from './route-map.ts' import { createRouter } from './router.ts' import type { Assert, IsEqual } from './type-utils.ts' +import type { RouteHandlers } from './route-handlers.ts' describe('router.fetch()', () => { it('fetches a route', async () => { @@ -1901,6 +1902,95 @@ describe('sessions', () => { ]) }) + it('supports sibling path-specific sessions on sub routers', async () => { + let rootRoutes = createRoutes({ + home: '/', + }) + + let router = createRouter({ sessionStorage: false }) + router.get(rootRoutes.home, () => { + return new Response('Home') + }) + + let appRoutes = createRoutes({ + index: '/', + }) + let appHandlers = { + index({ request, method, url, session }) { + let subApp = new URL(request.url).pathname.split('/')[1] + if (method === 'POST') { + session.set('subRouter', subApp) + } + return new Response(`App Index: ${session.get('subRouter')}`) + }, + } satisfies RouteHandlers + + let aRouter = createRouter({ + sessionStorage: createCookieSessionStorage({ + cookie: { + path: '/a', + }, + }), + }) + aRouter.map(appRoutes, appHandlers) + router.mount('/a', aRouter) + + let bRouter = createRouter({ + sessionStorage: createCookieSessionStorage({ + cookie: { + path: '/b', + }, + }), + }) + bRouter.map(appRoutes, appHandlers) + router.mount('/b', bRouter) + + // No session on root + let response = await router.fetch('https://remix.run/') + assert.equal(await response.text(), 'Home') + assert.equal(response.headers.has('Set-Cookie'), false) + + // No initial session on /a + response = await router.fetch('https://remix.run/a') + assert.equal(await response.text(), 'App Index: undefined') + assert.equal(response.headers.has('Set-Cookie'), false) + + // Create session on /a + response = await router.fetch('https://remix.run/a', { method: 'POST' }) + assert.equal(await response.text(), 'App Index: a') + assert.equal(response.headers.has('Set-Cookie'), true) + assert.match(response.headers.get('Set-Cookie')!, /Path=\/a/) + + // Reuse session on /a + response = await router.fetch('https://remix.run/a', { + headers: { + Cookie: getSessionCookie(response), + }, + }) + assert.equal(await response.text(), 'App Index: a') + assert.equal(response.headers.has('Set-Cookie'), false) + + // No initial session on /b + response = await router.fetch('https://remix.run/b') + assert.equal(await response.text(), 'App Index: undefined') + assert.equal(response.headers.has('Set-Cookie'), false) + + // Create session on /b + response = await router.fetch('https://remix.run/b', { method: 'POST' }) + assert.equal(await response.text(), 'App Index: b') + assert.equal(response.headers.has('Set-Cookie'), true) + assert.match(response.headers.get('Set-Cookie')!, /Path=\/b/) + + // Reuse session on /b + response = await router.fetch('https://remix.run/b', { + headers: { + Cookie: getSessionCookie(response), + }, + }) + assert.equal(await response.text(), 'App Index: b') + assert.equal(response.headers.has('Set-Cookie'), false) + }) + describe('cookie-backed sessions', () => { it('does not send a set-cookie header on initial session creation if the session is not used', async () => { let routes = createRoutes({ diff --git a/packages/fetch-router/src/lib/router.ts b/packages/fetch-router/src/lib/router.ts index c96398fbff7..7e07b389c30 100644 --- a/packages/fetch-router/src/lib/router.ts +++ b/packages/fetch-router/src/lib/router.ts @@ -133,7 +133,20 @@ export class Router { request: Request | RequestContext, upstreamMiddleware?: Middleware[], ): Promise { - let context = request instanceof Request ? await this.#createContext(request) : request + let context: RequestContext + + if (request instanceof Request) { + context = await this.#createContext(request) + } else { + context = request + + // Setup sessions for sub-routers if no parent session exists and they didn't opt-out + if (!context._session && this.#sessionStorage) { + context._session = await this.#sessionStorage.getSession( + context.request.headers.get('Cookie'), + ) + } + } for (let match of this.#matcher.matchAll(context.url)) { if ('router' in match.data) { @@ -190,8 +203,9 @@ export class Router { // Only process sessions if they didn't opt-out if (this.#sessionStorage) { - let cookie = context.request.headers.get('Cookie') - context._session = await this.#sessionStorage.getSession(cookie) + context._session = await this.#sessionStorage.getSession( + context.request.headers.get('Cookie'), + ) } if (!RequestBodyMethods.includes(request.method as RequestBodyMethod)) { From fac07313cd2fca0861f056341f4c41b07cf03397 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 29 Oct 2025 12:29:04 -0400 Subject: [PATCH 32/37] Update to use new Cookie class API --- packages/session/src/lib/cookie-storage.ts | 11 ++++++----- packages/session/src/lib/session.ts | 18 ++++++++++-------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/packages/session/src/lib/cookie-storage.ts b/packages/session/src/lib/cookie-storage.ts index c98ec42376b..bd16b20de48 100644 --- a/packages/session/src/lib/cookie-storage.ts +++ b/packages/session/src/lib/cookie-storage.ts @@ -1,4 +1,4 @@ -import { createCookie, isCookie } from '@remix-run/cookie' +import { Cookie } from '@remix-run/cookie' import type { SessionStorage, SessionIdStorageStrategy, SessionData } from './session.ts' import { warnOnceAboutSigningSessionCookie, Session } from './session.ts' @@ -22,15 +22,16 @@ interface CookieSessionStorageOptions { export function createCookieSessionStorage({ cookie: cookieArg, }: CookieSessionStorageOptions = {}): SessionStorage { - let cookie = isCookie(cookieArg) - ? cookieArg - : createCookie(cookieArg?.name || '__session', cookieArg) + let cookie = + cookieArg instanceof Cookie ? cookieArg : new Cookie(cookieArg?.name || '__session', cookieArg) warnOnceAboutSigningSessionCookie(cookie) return { async getSession(cookieHeader, options) { - let data = cookieHeader ? await cookie.parse(cookieHeader, options) : undefined + let data = cookieHeader + ? ((await cookie.parse(cookieHeader, options)) as Partial | null) + : undefined return new Session(data) }, async commitSession(session, options) { diff --git a/packages/session/src/lib/session.ts b/packages/session/src/lib/session.ts index c1c17c0b60a..3a2f916a49f 100644 --- a/packages/session/src/lib/session.ts +++ b/packages/session/src/lib/session.ts @@ -1,6 +1,6 @@ import type { ParseOptions, SerializeOptions } from 'cookie' -import type { Cookie, CookieOptions } from '@remix-run/cookie' -import { createCookie, isCookie } from '@remix-run/cookie' +import type { CookieOptions } from '@remix-run/cookie' +import { Cookie } from '@remix-run/cookie' import { warnOnce } from './warnings.ts' @@ -22,7 +22,7 @@ export class Session { #map: Map, unknown> #status: 'new' | 'clean' | 'dirty' | 'destroyed' - constructor(initialData?: Partial, id?: string) { + constructor(initialData?: Partial | null, id?: string) { // Brand new sessions start in a dirty state to force an initial commit this.#status = initialData == null && id == null ? 'new' : 'clean' this.#id = id ?? '' @@ -231,17 +231,19 @@ export function createSessionStorage({ updateData, deleteData, }: SessionIdStorageStrategy): SessionStorage { - let cookie = isCookie(cookieArg) - ? cookieArg - : createCookie(cookieArg?.name || '__session', cookieArg) + let cookie = + cookieArg instanceof Cookie ? cookieArg : new Cookie(cookieArg?.name || '__session', cookieArg) warnOnceAboutSigningSessionCookie(cookie) return { async getSession(cookieHeader, options) { let id = cookieHeader && (await cookie.parse(cookieHeader, options)) - let data = id && (await readData(id)) - return id ? new Session(data, id) : new Session() + if (typeof id === 'string' && id !== '') { + let data = await readData(id) + return new Session(data, id) + } + return new Session() }, async commitSession(session, options) { let { id, data } = session From e4bb5a97731a1fa8fd8585478e66e3afa1c85602 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 29 Oct 2025 12:37:51 -0400 Subject: [PATCH 33/37] Bring over createFileSessionStorage --- packages/session/src/lib/file-storage.ts | 116 +++++++++++++++++ packages/session/src/lib/session.test.ts | 158 +++++++++++++++++++++++ 2 files changed, 274 insertions(+) create mode 100644 packages/session/src/lib/file-storage.ts diff --git a/packages/session/src/lib/file-storage.ts b/packages/session/src/lib/file-storage.ts new file mode 100644 index 00000000000..ead5a4727f3 --- /dev/null +++ b/packages/session/src/lib/file-storage.ts @@ -0,0 +1,116 @@ +import { promises as fsp } from 'node:fs' +import * as path from 'node:path' +import type { SessionStorage, SessionIdStorageStrategy, SessionData } from './session.ts' +import { createSessionStorage } from './session.ts' + +interface FileSessionStorageOptions { + /** + * The Cookie used to store the session id on the client, or options used + * to automatically create one. + */ + cookie?: SessionIdStorageStrategy['cookie'] + + /** + * The directory to use to store session files. + */ + dir: string +} + +/** + * Creates a SessionStorage that stores session data on a filesystem. + * + * The advantage of using this instead of cookie session storage is that + * files may contain much more data than cookies. + */ +export function createFileSessionStorage({ + cookie, + dir, +}: FileSessionStorageOptions): SessionStorage { + return createSessionStorage({ + cookie, + async createData(data, expires) { + let content = JSON.stringify({ data, expires }) + + while (true) { + let randomBytes = crypto.getRandomValues(new Uint8Array(8)) + // This storage manages an id space of 2^64 ids, which is far greater + // than the maximum number of files allowed on an NTFS or ext4 volume + // (2^32). However, the larger id space should help to avoid collisions + // with existing ids when creating new sessions, which speeds things up. + let id = Buffer.from(randomBytes).toString('hex') + + try { + let file = getFile(dir, id) + if (!file) { + throw new Error('Error generating session') + } + await fsp.mkdir(path.dirname(file), { recursive: true }) + await fsp.writeFile(file, content, { encoding: 'utf-8', flag: 'wx' }) + return id + } catch (error: any) { + if (error.code !== 'EEXIST') throw error + } + } + }, + async readData(id) { + try { + let file = getFile(dir, id) + if (!file) { + return null + } + let content = JSON.parse(await fsp.readFile(file, 'utf-8')) + let data = content.data + let expires = typeof content.expires === 'string' ? new Date(content.expires) : null + + if (!expires || expires > new Date()) { + return data + } + + // Remove expired session data. + if (expires) await fsp.unlink(file) + + return null + } catch (error: any) { + if (error.code !== 'ENOENT') throw error + return null + } + }, + async updateData(id, data, expires) { + let content = JSON.stringify({ data, expires }) + let file = getFile(dir, id) + if (!file) { + return + } + await fsp.mkdir(path.dirname(file), { recursive: true }) + await fsp.writeFile(file, content, 'utf-8') + }, + async deleteData(id) { + // Return early if the id is empty, otherwise we'll end up trying to + // unlink the dir, which will cause the EPERM error. + if (!id) { + return + } + let file = getFile(dir, id) + if (!file) { + return + } + try { + await fsp.unlink(file) + } catch (error: any) { + if (error.code !== 'ENOENT') throw error + } + }, + }) +} + +export function getFile(dir: string, id: string): string | null { + if (!/^[0-9a-f]{16}$/i.test(id)) { + return null + } + + // Divide the session id up into a directory (first 2 bytes) and filename + // (remaining 6 bytes) to reduce the chance of having very large directories, + // which should speed up file access. This is a maximum of 2^16 directories, + // each with 2^48 files. + return path.join(dir, id.slice(0, 4), id.slice(4)) +} diff --git a/packages/session/src/lib/session.test.ts b/packages/session/src/lib/session.test.ts index 3f7fb1f6952..01085d97fc6 100644 --- a/packages/session/src/lib/session.test.ts +++ b/packages/session/src/lib/session.test.ts @@ -1,9 +1,13 @@ import * as assert from 'node:assert/strict' +import { promises as fsp } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' import { describe, it } from 'node:test' import { Session } from './session.ts' import { createCookieSessionStorage } from './cookie-storage.ts' import { createMemorySessionStorage } from './memory-storage.ts' +import { createFileSessionStorage, getFile } from './file-storage.ts' function getCookieFromSetCookie(setCookie: string): string { return setCookie.split(/;\s*/)[0] @@ -269,3 +273,157 @@ describe('Cookie session storage', () => { }) }) }) + +describe('File session storage', async () => { + let dir = path.join(os.tmpdir(), 'file-session-storage') + + // Setup test directory + await fsp.mkdir(dir, { recursive: true }) + + // Cleanup after all tests + process.on('exit', () => { + try { + require('node:fs').rmSync(dir, { recursive: true, force: true }) + } catch { + // Ignore cleanup errors + } + }) + + it('persists session data across requests', async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + + assert.equal(session.get('user'), 'mjackson') + }) + + it('returns an empty session for cookies that are not signed properly', async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + + assert.equal(session.get('user'), 'mjackson') + + let setCookie = await commitSession(session) + session = await getSession( + // Tamper with the cookie... + getCookieFromSetCookie(setCookie).slice(0, -1), + ) + + assert.equal(session.get('user'), undefined) + }) + + it('returns an empty session for invalid session ids', async () => { + let originalWarn = console.warn + console.warn = () => {} + let { getSession, commitSession } = createFileSessionStorage({ + dir, + }) + + let cookie = `__session=${btoa(JSON.stringify('0123456789abcdef'))}` + let session = await getSession(cookie) + session.set('user', 'mjackson') + assert.equal(session.get('user'), 'mjackson') + let setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + assert.equal(session.get('user'), 'mjackson') + + cookie = `__session=${btoa(JSON.stringify('0123456789abcdeg'))}` + session = await getSession(cookie) + session.set('user', 'mjackson') + assert.equal(session.get('user'), 'mjackson') + + setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + assert.equal(session.get('user'), undefined) + + console.warn = originalWarn + }) + + it("doesn't destroy the entire session directory when destroying an empty file session", async () => { + let { getSession, destroySession } = createFileSessionStorage({ + dir, + cookie: { secrets: ['secret1'] }, + }) + + let session = await getSession() + + await assert.doesNotReject(() => destroySession(session)) + }) + + it('saves expires to file if expires provided to commitSession when creating new cookie', async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let date = new Date(Date.now() + 1000 * 60) + let cookieHeader = await commitSession(session, { expires: date }) + let createdSession = await getSession(cookieHeader) + + let { id } = createdSession + let file = getFile(dir, id) + assert.notEqual(file, null) + let fileContents = await fsp.readFile(file!, 'utf8') + let fileData = JSON.parse(fileContents) + assert.equal(fileData.expires, date.toISOString()) + }) + + it('saves expires to file if maxAge provided to commitSession when creating new cookie', async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let cookieHeader = await commitSession(session, { maxAge: 60 }) + let createdSession = await getSession(cookieHeader) + + let { id } = createdSession + let file = getFile(dir, id) + assert.notEqual(file, null) + let fileContents = await fsp.readFile(file!, 'utf8') + let fileData = JSON.parse(fileContents) + assert.equal(typeof fileData.expires, 'string') + }) + + describe('when a new secret shows up in the rotation', () => { + it('unsigns old session cookies using the old secret and encodes new cookies using the new secret', async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + + assert.equal(session.get('user'), 'mjackson') + + // A new secret enters the rotation... + let storage = createFileSessionStorage({ + dir, + cookie: { secrets: ['secret2', 'secret1'] }, + }) + getSession = storage.getSession + commitSession = storage.commitSession + + // Old cookies should still work with the old secret. + session = await getSession(getCookieFromSetCookie(setCookie)) + assert.equal(session.get('user'), 'mjackson') + + // New cookies should be signed using the new secret. + let setCookie2 = await commitSession(session) + assert.notEqual(setCookie2, setCookie) + }) + }) +}) From 976a06b8e4e984f0b124e7a909bd9c4086e40327 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 29 Oct 2025 12:37:58 -0400 Subject: [PATCH 34/37] Fix lint issues --- demos/bookstore/app/public.ts | 2 +- demos/bookstore/app/uploads.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/demos/bookstore/app/public.ts b/demos/bookstore/app/public.ts index 37b38d3bf7f..868b68a3df6 100644 --- a/demos/bookstore/app/public.ts +++ b/demos/bookstore/app/public.ts @@ -2,7 +2,7 @@ import * as path from 'node:path' import type { BuildRouteHandler } from '@remix-run/fetch-router' import { openFile } from '@remix-run/lazy-file/fs' -import { routes } from '../routes.ts' +import type { routes } from '../routes.ts' const publicDir = path.join(import.meta.dirname, '..', 'public') const publicAssetsDir = path.join(publicDir, 'assets') diff --git a/demos/bookstore/app/uploads.tsx b/demos/bookstore/app/uploads.tsx index 8b5850039ed..39ab735a369 100644 --- a/demos/bookstore/app/uploads.tsx +++ b/demos/bookstore/app/uploads.tsx @@ -1,6 +1,6 @@ import type { BuildRouteHandler } from '@remix-run/fetch-router' -import { routes } from '../routes.ts' +import type { routes } from '../routes.ts' import { uploadsStorage } from './utils/uploads.ts' export let uploadsHandler: BuildRouteHandler<'GET', typeof routes.uploads> = async ({ params }) => { From b7cd2dec90dd5bb856f37292fa7b0c9591259dbc Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 29 Oct 2025 14:45:34 -0400 Subject: [PATCH 35/37] Move file storage to a sub export so it's not pulled in from an import of fetch-router in the browser bundle --- packages/session/package.json | 10 +- packages/session/src/file-storage.ts | 1 + packages/session/src/index.ts | 4 +- packages/session/src/lib/session.test.ts | 342 ------------------ .../src/lib/storage/cookie-storage.test.ts | 161 +++++++++ .../src/lib/{ => storage}/cookie-storage.ts | 4 +- .../src/lib/storage/file-storage.test.ts | 165 +++++++++ .../src/lib/{ => storage}/file-storage.ts | 4 +- .../src/lib/storage/memory-storage.test.ts | 33 ++ .../src/lib/{ => storage}/memory-storage.ts | 4 +- packages/session/tsconfig.json | 1 + 11 files changed, 377 insertions(+), 352 deletions(-) create mode 100644 packages/session/src/file-storage.ts create mode 100644 packages/session/src/lib/storage/cookie-storage.test.ts rename packages/session/src/lib/{ => storage}/cookie-storage.ts (94%) create mode 100644 packages/session/src/lib/storage/file-storage.test.ts rename packages/session/src/lib/{ => storage}/file-storage.ts (97%) create mode 100644 packages/session/src/lib/storage/memory-storage.test.ts rename packages/session/src/lib/{ => storage}/memory-storage.ts (95%) diff --git a/packages/session/package.json b/packages/session/package.json index d744c530cea..ca97281fa26 100644 --- a/packages/session/package.json +++ b/packages/session/package.json @@ -20,6 +20,7 @@ "type": "module", "exports": { ".": "./src/index.ts", + "./file-storage": "./src/file-storage.ts", "./package.json": "./package.json" }, "publishConfig": { @@ -28,6 +29,10 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./file-storage": { + "types": "./dist/file-storage.d.ts", + "default": "./dist/file-storage.js" + }, "./package.json": "./package.json" } }, @@ -40,8 +45,9 @@ "esbuild": "^0.25.10" }, "scripts": { - "build": "pnpm run clean && pnpm run build:types && pnpm run build:esm", - "build:esm": "esbuild src/index.ts --bundle --external:@remix-run/cookie --outfile=dist/index.js --format=esm --platform=neutral --sourcemap", + "build": "pnpm run clean && pnpm run build:types && pnpm run build:index && pnpm run build:file-storage", + "build:index": "esbuild src/index.ts --bundle --external:@remix-run/cookie --outfile=dist/index.js --format=esm --platform=neutral --sourcemap", + "build:file-storage": "esbuild src/file-storage.ts --bundle --external:@remix-run/session --outfile=dist/file-storage.js --format=esm --platform=node --sourcemap", "build:types": "tsc --project tsconfig.build.json", "clean": "rm -rf dist", "prepublishOnly": "pnpm run build", diff --git a/packages/session/src/file-storage.ts b/packages/session/src/file-storage.ts new file mode 100644 index 00000000000..cdf92194bb6 --- /dev/null +++ b/packages/session/src/file-storage.ts @@ -0,0 +1 @@ +export { createFileSessionStorage } from './lib/storage/file-storage.ts' diff --git a/packages/session/src/index.ts b/packages/session/src/index.ts index 399672582ed..eb02baed608 100644 --- a/packages/session/src/index.ts +++ b/packages/session/src/index.ts @@ -7,5 +7,5 @@ export { Session, } from './lib/session.ts' -export { createCookieSessionStorage } from './lib/cookie-storage.ts' -export { createMemorySessionStorage } from './lib/memory-storage.ts' +export { createCookieSessionStorage } from './lib/storage/cookie-storage.ts' +export { createMemorySessionStorage } from './lib/storage/memory-storage.ts' diff --git a/packages/session/src/lib/session.test.ts b/packages/session/src/lib/session.test.ts index 01085d97fc6..d7bbb552e86 100644 --- a/packages/session/src/lib/session.test.ts +++ b/packages/session/src/lib/session.test.ts @@ -1,17 +1,7 @@ import * as assert from 'node:assert/strict' -import { promises as fsp } from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' import { describe, it } from 'node:test' import { Session } from './session.ts' -import { createCookieSessionStorage } from './cookie-storage.ts' -import { createMemorySessionStorage } from './memory-storage.ts' -import { createFileSessionStorage, getFile } from './file-storage.ts' - -function getCookieFromSetCookie(setCookie: string): string { - return setCookie.split(/;\s*/)[0] -} describe('Session', () => { it('has an empty id by default', () => { @@ -95,335 +85,3 @@ describe('Session', () => { }) }) }) - -describe('In-memory session storage', () => { - it('persists session data across requests', async () => { - let { getSession, commitSession } = createMemorySessionStorage({ - cookie: { secrets: ['secret1'] }, - }) - let session = await getSession() - session.set('user', 'mjackson') - let setCookie = await commitSession(session) - session = await getSession(getCookieFromSetCookie(setCookie)) - - assert.equal(session.get('user'), 'mjackson') - }) - - it('uses random hash keys as session ids', async () => { - let { getSession, commitSession } = createMemorySessionStorage({ - cookie: { secrets: ['secret1'] }, - }) - let session = await getSession() - session.set('user', 'mjackson') - let setCookie = await commitSession(session) - session = await getSession(getCookieFromSetCookie(setCookie)) - assert.match(session.id, /^[a-z0-9]{8}$/) - }) -}) - -describe('Cookie session storage', () => { - it('persists session data across requests', async () => { - let { getSession, commitSession } = createCookieSessionStorage({ - cookie: { secrets: ['secret1'] }, - }) - let session = await getSession() - session.set('user', 'mjackson') - let setCookie = await commitSession(session) - session = await getSession(getCookieFromSetCookie(setCookie)) - - assert.equal(session.get('user'), 'mjackson') - }) - - it('returns an empty session for cookies that are not signed properly', async () => { - let { getSession, commitSession } = createCookieSessionStorage({ - cookie: { secrets: ['secret1'] }, - }) - let session = await getSession() - session.set('user', 'mjackson') - - assert.equal(session.get('user'), 'mjackson') - - let setCookie = await commitSession(session) - session = await getSession( - // Tamper with the session cookie... - getCookieFromSetCookie(setCookie).slice(0, -1), - ) - - assert.equal(session.get('user'), undefined) - }) - - it('"makes the default path of cookies to be /', async () => { - let { getSession, commitSession } = createCookieSessionStorage({ - cookie: { secrets: ['secret1'] }, - }) - let session = await getSession() - session.set('user', 'mjackson') - let setCookie = await commitSession(session) - assert.ok(setCookie.includes('Path=/')) - }) - - it('throws an error when the cookie size exceeds 4096 bytes', async () => { - let { getSession, commitSession } = createCookieSessionStorage({ - cookie: { secrets: ['secret1'] }, - }) - let session = await getSession() - let longString = Array.from({ length: 4097 }).fill('a').join('') - session.set('over4096bytes', longString) - await assert.rejects(() => commitSession(session)) - }) - - it('destroys sessions using a past date', async () => { - let originalWarn = console.warn - console.warn = () => {} - let { getSession, destroySession } = createCookieSessionStorage({ - cookie: { - secrets: ['secret1'], - }, - }) - let session = await getSession() - let setCookie = await destroySession(session) - assert.equal( - setCookie, - '__session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax', - ) - console.warn = originalWarn - }) - - it('destroys sessions that leverage maxAge', async () => { - let originalWarn = console.warn - console.warn = () => {} - let { getSession, destroySession } = createCookieSessionStorage({ - cookie: { - maxAge: 60 * 60, // 1 hour - secrets: ['secret1'], - }, - }) - let session = await getSession() - let setCookie = await destroySession(session) - assert.equal( - setCookie, - '__session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax', - ) - console.warn = originalWarn - }) - - describe('warnings when providing options you may not want to', () => { - it('warns against using `expires` when creating the session', async () => { - let warnings: string[] = [] - let originalWarn = console.warn - console.warn = (msg: string) => warnings.push(msg) - - createCookieSessionStorage({ - cookie: { - secrets: ['secret1'], - expires: new Date(Date.now() + 60_000), - }, - }) - - assert.equal(warnings.length, 1) - assert.equal( - warnings[0], - 'The "__session" cookie has an "expires" property set. This will cause the expires value to not be updated when the session is committed. Instead, you should set the expires value when serializing the cookie. You can use `commitSession(session, { expires })` if using a session storage object, or `cookie.serialize("value", { expires })` if you\'re using the cookie directly.', - ) - console.warn = originalWarn - }) - - it('warns when not passing secrets when creating the session', async () => { - let warnings: string[] = [] - let originalWarn = console.warn - console.warn = (msg: string) => warnings.push(msg) - - createCookieSessionStorage({ cookie: {} }) - - assert.equal(warnings.length, 1) - assert.equal( - warnings[0], - 'The "__session" cookie is not signed, but session cookies should be signed to prevent tampering on the client before they are sent back to the server.', - ) - console.warn = originalWarn - }) - }) - - describe('when a new secret shows up in the rotation', () => { - it('unsigns old session cookies using the old secret and encodes new cookies using the new secret', async () => { - let { getSession, commitSession } = createCookieSessionStorage({ - cookie: { secrets: ['secret1'] }, - }) - let session = await getSession() - session.set('user', 'mjackson') - let setCookie = await commitSession(session) - session = await getSession(getCookieFromSetCookie(setCookie)) - - assert.equal(session.get('user'), 'mjackson') - - // A new secret enters the rotation... - let storage = createCookieSessionStorage({ - cookie: { secrets: ['secret2', 'secret1'] }, - }) - getSession = storage.getSession - commitSession = storage.commitSession - - // Old cookies should still work with the old secret. - session = await storage.getSession(getCookieFromSetCookie(setCookie)) - assert.equal(session.get('user'), 'mjackson') - - // New cookies should be signed using the new secret. - let setCookie2 = await storage.commitSession(session) - assert.notEqual(setCookie2, setCookie) - }) - }) -}) - -describe('File session storage', async () => { - let dir = path.join(os.tmpdir(), 'file-session-storage') - - // Setup test directory - await fsp.mkdir(dir, { recursive: true }) - - // Cleanup after all tests - process.on('exit', () => { - try { - require('node:fs').rmSync(dir, { recursive: true, force: true }) - } catch { - // Ignore cleanup errors - } - }) - - it('persists session data across requests', async () => { - let { getSession, commitSession } = createFileSessionStorage({ - dir, - cookie: { secrets: ['secret1'] }, - }) - let session = await getSession() - session.set('user', 'mjackson') - let setCookie = await commitSession(session) - session = await getSession(getCookieFromSetCookie(setCookie)) - - assert.equal(session.get('user'), 'mjackson') - }) - - it('returns an empty session for cookies that are not signed properly', async () => { - let { getSession, commitSession } = createFileSessionStorage({ - dir, - cookie: { secrets: ['secret1'] }, - }) - let session = await getSession() - session.set('user', 'mjackson') - - assert.equal(session.get('user'), 'mjackson') - - let setCookie = await commitSession(session) - session = await getSession( - // Tamper with the cookie... - getCookieFromSetCookie(setCookie).slice(0, -1), - ) - - assert.equal(session.get('user'), undefined) - }) - - it('returns an empty session for invalid session ids', async () => { - let originalWarn = console.warn - console.warn = () => {} - let { getSession, commitSession } = createFileSessionStorage({ - dir, - }) - - let cookie = `__session=${btoa(JSON.stringify('0123456789abcdef'))}` - let session = await getSession(cookie) - session.set('user', 'mjackson') - assert.equal(session.get('user'), 'mjackson') - let setCookie = await commitSession(session) - session = await getSession(getCookieFromSetCookie(setCookie)) - assert.equal(session.get('user'), 'mjackson') - - cookie = `__session=${btoa(JSON.stringify('0123456789abcdeg'))}` - session = await getSession(cookie) - session.set('user', 'mjackson') - assert.equal(session.get('user'), 'mjackson') - - setCookie = await commitSession(session) - session = await getSession(getCookieFromSetCookie(setCookie)) - assert.equal(session.get('user'), undefined) - - console.warn = originalWarn - }) - - it("doesn't destroy the entire session directory when destroying an empty file session", async () => { - let { getSession, destroySession } = createFileSessionStorage({ - dir, - cookie: { secrets: ['secret1'] }, - }) - - let session = await getSession() - - await assert.doesNotReject(() => destroySession(session)) - }) - - it('saves expires to file if expires provided to commitSession when creating new cookie', async () => { - let { getSession, commitSession } = createFileSessionStorage({ - dir, - cookie: { secrets: ['secret1'] }, - }) - let session = await getSession() - session.set('user', 'mjackson') - let date = new Date(Date.now() + 1000 * 60) - let cookieHeader = await commitSession(session, { expires: date }) - let createdSession = await getSession(cookieHeader) - - let { id } = createdSession - let file = getFile(dir, id) - assert.notEqual(file, null) - let fileContents = await fsp.readFile(file!, 'utf8') - let fileData = JSON.parse(fileContents) - assert.equal(fileData.expires, date.toISOString()) - }) - - it('saves expires to file if maxAge provided to commitSession when creating new cookie', async () => { - let { getSession, commitSession } = createFileSessionStorage({ - dir, - cookie: { secrets: ['secret1'] }, - }) - let session = await getSession() - session.set('user', 'mjackson') - let cookieHeader = await commitSession(session, { maxAge: 60 }) - let createdSession = await getSession(cookieHeader) - - let { id } = createdSession - let file = getFile(dir, id) - assert.notEqual(file, null) - let fileContents = await fsp.readFile(file!, 'utf8') - let fileData = JSON.parse(fileContents) - assert.equal(typeof fileData.expires, 'string') - }) - - describe('when a new secret shows up in the rotation', () => { - it('unsigns old session cookies using the old secret and encodes new cookies using the new secret', async () => { - let { getSession, commitSession } = createFileSessionStorage({ - dir, - cookie: { secrets: ['secret1'] }, - }) - let session = await getSession() - session.set('user', 'mjackson') - let setCookie = await commitSession(session) - session = await getSession(getCookieFromSetCookie(setCookie)) - - assert.equal(session.get('user'), 'mjackson') - - // A new secret enters the rotation... - let storage = createFileSessionStorage({ - dir, - cookie: { secrets: ['secret2', 'secret1'] }, - }) - getSession = storage.getSession - commitSession = storage.commitSession - - // Old cookies should still work with the old secret. - session = await getSession(getCookieFromSetCookie(setCookie)) - assert.equal(session.get('user'), 'mjackson') - - // New cookies should be signed using the new secret. - let setCookie2 = await commitSession(session) - assert.notEqual(setCookie2, setCookie) - }) - }) -}) diff --git a/packages/session/src/lib/storage/cookie-storage.test.ts b/packages/session/src/lib/storage/cookie-storage.test.ts new file mode 100644 index 00000000000..4fc8d95ec78 --- /dev/null +++ b/packages/session/src/lib/storage/cookie-storage.test.ts @@ -0,0 +1,161 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { createCookieSessionStorage } from './cookie-storage.ts' + +function getCookieFromSetCookie(setCookie: string): string { + return setCookie.split(/;\s*/)[0] +} + +describe('Cookie session storage', () => { + it('persists session data across requests', async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + + assert.equal(session.get('user'), 'mjackson') + }) + + it('returns an empty session for cookies that are not signed properly', async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + + assert.equal(session.get('user'), 'mjackson') + + let setCookie = await commitSession(session) + session = await getSession( + // Tamper with the session cookie... + getCookieFromSetCookie(setCookie).slice(0, -1), + ) + + assert.equal(session.get('user'), undefined) + }) + + it('"makes the default path of cookies to be /', async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let setCookie = await commitSession(session) + assert.ok(setCookie.includes('Path=/')) + }) + + it('throws an error when the cookie size exceeds 4096 bytes', async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + let longString = Array.from({ length: 4097 }).fill('a').join('') + session.set('over4096bytes', longString) + await assert.rejects(() => commitSession(session)) + }) + + it('destroys sessions using a past date', async () => { + let originalWarn = console.warn + console.warn = () => {} + let { getSession, destroySession } = createCookieSessionStorage({ + cookie: { + secrets: ['secret1'], + }, + }) + let session = await getSession() + let setCookie = await destroySession(session) + assert.equal( + setCookie, + '__session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax', + ) + console.warn = originalWarn + }) + + it('destroys sessions that leverage maxAge', async () => { + let originalWarn = console.warn + console.warn = () => {} + let { getSession, destroySession } = createCookieSessionStorage({ + cookie: { + maxAge: 60 * 60, // 1 hour + secrets: ['secret1'], + }, + }) + let session = await getSession() + let setCookie = await destroySession(session) + assert.equal( + setCookie, + '__session=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT; SameSite=Lax', + ) + console.warn = originalWarn + }) + + describe('warnings when providing options you may not want to', () => { + it('warns against using `expires` when creating the session', async () => { + let warnings: string[] = [] + let originalWarn = console.warn + console.warn = (msg: string) => warnings.push(msg) + + createCookieSessionStorage({ + cookie: { + secrets: ['secret1'], + expires: new Date(Date.now() + 60_000), + }, + }) + + assert.equal(warnings.length, 1) + assert.equal( + warnings[0], + 'The "__session" cookie has an "expires" property set. This will cause the expires value to not be updated when the session is committed. Instead, you should set the expires value when serializing the cookie. You can use `commitSession(session, { expires })` if using a session storage object, or `cookie.serialize("value", { expires })` if you\'re using the cookie directly.', + ) + console.warn = originalWarn + }) + + it('warns when not passing secrets when creating the session', async () => { + let warnings: string[] = [] + let originalWarn = console.warn + console.warn = (msg: string) => warnings.push(msg) + + createCookieSessionStorage({ cookie: {} }) + + assert.equal(warnings.length, 1) + assert.equal( + warnings[0], + 'The "__session" cookie is not signed, but session cookies should be signed to prevent tampering on the client before they are sent back to the server.', + ) + console.warn = originalWarn + }) + }) + + describe('when a new secret shows up in the rotation', () => { + it('unsigns old session cookies using the old secret and encodes new cookies using the new secret', async () => { + let { getSession, commitSession } = createCookieSessionStorage({ + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + + assert.equal(session.get('user'), 'mjackson') + + // A new secret enters the rotation... + let storage = createCookieSessionStorage({ + cookie: { secrets: ['secret2', 'secret1'] }, + }) + getSession = storage.getSession + commitSession = storage.commitSession + + // Old cookies should still work with the old secret. + session = await storage.getSession(getCookieFromSetCookie(setCookie)) + assert.equal(session.get('user'), 'mjackson') + + // New cookies should be signed using the new secret. + let setCookie2 = await storage.commitSession(session) + assert.notEqual(setCookie2, setCookie) + }) + }) +}) diff --git a/packages/session/src/lib/cookie-storage.ts b/packages/session/src/lib/storage/cookie-storage.ts similarity index 94% rename from packages/session/src/lib/cookie-storage.ts rename to packages/session/src/lib/storage/cookie-storage.ts index bd16b20de48..fe61db6e8a9 100644 --- a/packages/session/src/lib/cookie-storage.ts +++ b/packages/session/src/lib/storage/cookie-storage.ts @@ -1,6 +1,6 @@ import { Cookie } from '@remix-run/cookie' -import type { SessionStorage, SessionIdStorageStrategy, SessionData } from './session.ts' -import { warnOnceAboutSigningSessionCookie, Session } from './session.ts' +import type { SessionStorage, SessionIdStorageStrategy, SessionData } from '../session.ts' +import { warnOnceAboutSigningSessionCookie, Session } from '../session.ts' interface CookieSessionStorageOptions { /** diff --git a/packages/session/src/lib/storage/file-storage.test.ts b/packages/session/src/lib/storage/file-storage.test.ts new file mode 100644 index 00000000000..9f00f3f448c --- /dev/null +++ b/packages/session/src/lib/storage/file-storage.test.ts @@ -0,0 +1,165 @@ +import * as assert from 'node:assert/strict' +import { promises as fsp } from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { describe, it } from 'node:test' + +import { createFileSessionStorage, getFile } from './file-storage.ts' + +function getCookieFromSetCookie(setCookie: string): string { + return setCookie.split(/;\s*/)[0] +} + +describe('File session storage', async () => { + let dir = path.join(os.tmpdir(), 'file-storage') + + // Setup test directory + await fsp.mkdir(dir, { recursive: true }) + + // Cleanup after all tests + process.on('exit', () => { + try { + require('node:fs').rmSync(dir, { recursive: true, force: true }) + } catch { + // Ignore cleanup errors + } + }) + + it('persists session data across requests', async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + + assert.equal(session.get('user'), 'mjackson') + }) + + it('returns an empty session for cookies that are not signed properly', async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + + assert.equal(session.get('user'), 'mjackson') + + let setCookie = await commitSession(session) + session = await getSession( + // Tamper with the cookie... + getCookieFromSetCookie(setCookie).slice(0, -1), + ) + + assert.equal(session.get('user'), undefined) + }) + + it('returns an empty session for invalid session ids', async () => { + let originalWarn = console.warn + console.warn = () => {} + let { getSession, commitSession } = createFileSessionStorage({ + dir, + }) + + let cookie = `__session=${btoa(JSON.stringify('0123456789abcdef'))}` + let session = await getSession(cookie) + session.set('user', 'mjackson') + assert.equal(session.get('user'), 'mjackson') + let setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + assert.equal(session.get('user'), 'mjackson') + + cookie = `__session=${btoa(JSON.stringify('0123456789abcdeg'))}` + session = await getSession(cookie) + session.set('user', 'mjackson') + assert.equal(session.get('user'), 'mjackson') + + setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + assert.equal(session.get('user'), undefined) + + console.warn = originalWarn + }) + + it("doesn't destroy the entire session directory when destroying an empty file session", async () => { + let { getSession, destroySession } = createFileSessionStorage({ + dir, + cookie: { secrets: ['secret1'] }, + }) + + let session = await getSession() + + await assert.doesNotReject(() => destroySession(session)) + }) + + it('saves expires to file if expires provided to commitSession when creating new cookie', async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let date = new Date(Date.now() + 1000 * 60) + let cookieHeader = await commitSession(session, { expires: date }) + let createdSession = await getSession(cookieHeader) + + let { id } = createdSession + let file = getFile(dir, id) + assert.notEqual(file, null) + let fileContents = await fsp.readFile(file!, 'utf8') + let fileData = JSON.parse(fileContents) + assert.equal(fileData.expires, date.toISOString()) + }) + + it('saves expires to file if maxAge provided to commitSession when creating new cookie', async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let cookieHeader = await commitSession(session, { maxAge: 60 }) + let createdSession = await getSession(cookieHeader) + + let { id } = createdSession + let file = getFile(dir, id) + assert.notEqual(file, null) + let fileContents = await fsp.readFile(file!, 'utf8') + let fileData = JSON.parse(fileContents) + assert.equal(typeof fileData.expires, 'string') + }) + + describe('when a new secret shows up in the rotation', () => { + it('unsigns old session cookies using the old secret and encodes new cookies using the new secret', async () => { + let { getSession, commitSession } = createFileSessionStorage({ + dir, + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + + assert.equal(session.get('user'), 'mjackson') + + // A new secret enters the rotation... + let storage = createFileSessionStorage({ + dir, + cookie: { secrets: ['secret2', 'secret1'] }, + }) + getSession = storage.getSession + commitSession = storage.commitSession + + // Old cookies should still work with the old secret. + session = await getSession(getCookieFromSetCookie(setCookie)) + assert.equal(session.get('user'), 'mjackson') + + // New cookies should be signed using the new secret. + let setCookie2 = await commitSession(session) + assert.notEqual(setCookie2, setCookie) + }) + }) +}) diff --git a/packages/session/src/lib/file-storage.ts b/packages/session/src/lib/storage/file-storage.ts similarity index 97% rename from packages/session/src/lib/file-storage.ts rename to packages/session/src/lib/storage/file-storage.ts index ead5a4727f3..d5515569560 100644 --- a/packages/session/src/lib/file-storage.ts +++ b/packages/session/src/lib/storage/file-storage.ts @@ -1,7 +1,7 @@ import { promises as fsp } from 'node:fs' import * as path from 'node:path' -import type { SessionStorage, SessionIdStorageStrategy, SessionData } from './session.ts' -import { createSessionStorage } from './session.ts' +import type { SessionStorage, SessionIdStorageStrategy, SessionData } from '@remix-run/session' +import { createSessionStorage } from '@remix-run/session' interface FileSessionStorageOptions { /** diff --git a/packages/session/src/lib/storage/memory-storage.test.ts b/packages/session/src/lib/storage/memory-storage.test.ts new file mode 100644 index 00000000000..2f21c51670a --- /dev/null +++ b/packages/session/src/lib/storage/memory-storage.test.ts @@ -0,0 +1,33 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { createMemorySessionStorage } from './memory-storage.ts' + +function getCookieFromSetCookie(setCookie: string): string { + return setCookie.split(/;\s*/)[0] +} + +describe('In-memory session storage', () => { + it('persists session data across requests', async () => { + let { getSession, commitSession } = createMemorySessionStorage({ + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + + assert.equal(session.get('user'), 'mjackson') + }) + + it('uses random hash keys as session ids', async () => { + let { getSession, commitSession } = createMemorySessionStorage({ + cookie: { secrets: ['secret1'] }, + }) + let session = await getSession() + session.set('user', 'mjackson') + let setCookie = await commitSession(session) + session = await getSession(getCookieFromSetCookie(setCookie)) + assert.match(session.id, /^[a-z0-9]{8}$/) + }) +}) diff --git a/packages/session/src/lib/memory-storage.ts b/packages/session/src/lib/storage/memory-storage.ts similarity index 95% rename from packages/session/src/lib/memory-storage.ts rename to packages/session/src/lib/storage/memory-storage.ts index 315a281c80f..6449b860941 100644 --- a/packages/session/src/lib/memory-storage.ts +++ b/packages/session/src/lib/storage/memory-storage.ts @@ -3,8 +3,8 @@ import type { SessionStorage, SessionIdStorageStrategy, FlashSessionData, -} from './session.ts' -import { createSessionStorage } from './session.ts' +} from '../session.ts' +import { createSessionStorage } from '../session.ts' interface MemorySessionStorageOptions { /** diff --git a/packages/session/tsconfig.json b/packages/session/tsconfig.json index 4781f83485f..78cd1233a1a 100644 --- a/packages/session/tsconfig.json +++ b/packages/session/tsconfig.json @@ -1,5 +1,6 @@ { "compilerOptions": { + "rootDir": ".", "strict": true, "lib": ["ES2024", "DOM", "DOM.Iterable"], "module": "ES2022", From acda0dca8de38c2b114d0d585897f0ab2a64b879 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Wed, 29 Oct 2025 14:54:49 -0400 Subject: [PATCH 36/37] Update bookstore demo session/cart handling --- demos/bookstore/app/cart.tsx | 75 +++++++++++++------------ demos/bookstore/app/checkout.tsx | 16 +++--- demos/bookstore/app/fragments.tsx | 5 +- demos/bookstore/app/middleware/cart.ts | 10 ++++ demos/bookstore/app/models/cart.ts | 45 ++++++++++----- demos/bookstore/app/utils/frame.tsx | 5 +- demos/bookstore/app/utils/session.ts | 1 + packages/fetch-router/src/lib/router.ts | 2 +- 8 files changed, 99 insertions(+), 60 deletions(-) create mode 100644 demos/bookstore/app/middleware/cart.ts diff --git a/demos/bookstore/app/cart.tsx b/demos/bookstore/app/cart.tsx index 9e825eeefd3..2d6dd898f18 100644 --- a/demos/bookstore/app/cart.tsx +++ b/demos/bookstore/app/cart.tsx @@ -11,13 +11,15 @@ import type { User } from './models/users.ts' import { getCurrentUser } from './utils/context.ts' import { render } from './utils/render.ts' import { RestfulForm } from './components/restful-form.tsx' +import { ensureCart } from './middleware/cart.ts' export default { use: [loadAuth], handlers: { index({ session }) { - let cart = getCart(session.get('userId')) - let total = getCartTotal(cart) + let cartId = session.get('cartId') + let cart = cartId ? getCart(cartId) : null + let total = cart ? getCartTotal(cart) : 0 let user: User | null = null try { @@ -31,7 +33,7 @@ export default {

Shopping Cart

- {cart.items.length > 0 ? ( + {cart && cart.items.length > 0 ? ( <> @@ -135,52 +137,55 @@ export default { }, api: { - async add({ session, formData }) { - // Simulate network latency - await new Promise((resolve) => setTimeout(resolve, 1000)) + use: [ensureCart], + handlers: { + async add({ session, formData }) { + // Simulate network latency + await new Promise((resolve) => setTimeout(resolve, 1000)) - let bookId = formData.get('bookId')?.toString() ?? '' + let bookId = formData.get('bookId')?.toString() ?? '' - let book = getBookById(bookId) - if (!book) { - return new Response('Book not found', { status: 404 }) - } + let book = getBookById(bookId) + if (!book) { + return new Response('Book not found', { status: 404 }) + } - addToCart(session.get('userId'), book.id, book.slug, book.title, book.price, 1) + addToCart(session.get('cartId')!, book.id, book.slug, book.title, book.price, 1) - if (formData.get('redirect') === 'none') { - return new Response(null, { status: 204 }) - } + if (formData.get('redirect') === 'none') { + return new Response(null, { status: 204 }) + } - return redirect(routes.cart.index.href()) - }, + return redirect(routes.cart.index.href()) + }, - async update({ session, formData }) { - let bookId = formData.get('bookId')?.toString() ?? '' - let quantity = parseInt(formData.get('quantity')?.toString() ?? '1', 10) + async update({ session, formData }) { + let bookId = formData.get('bookId')?.toString() ?? '' + let quantity = parseInt(formData.get('quantity')?.toString() ?? '1', 10) - updateCartItem(session.get('userId'), bookId, quantity) + updateCartItem(session.get('cartId')!, bookId, quantity) - if (formData.get('redirect') === 'none') { - return new Response(null, { status: 204 }) - } + if (formData.get('redirect') === 'none') { + return new Response(null, { status: 204 }) + } - return redirect(routes.cart.index.href()) - }, + return redirect(routes.cart.index.href()) + }, - async remove({ session, formData }) { - // Simulate network latency - await new Promise((resolve) => setTimeout(resolve, 1000)) + async remove({ session, formData }) { + // Simulate network latency + await new Promise((resolve) => setTimeout(resolve, 1000)) - let bookId = formData.get('bookId')?.toString() ?? '' + let bookId = formData.get('bookId')?.toString() ?? '' - removeFromCart(session.get('userId'), bookId) + removeFromCart(session.get('cartId')!, bookId) - if (formData.get('redirect') === 'none') { - return new Response(null, { status: 204 }) - } + if (formData.get('redirect') === 'none') { + return new Response(null, { status: 204 }) + } - return redirect(routes.cart.index.href()) + return redirect(routes.cart.index.href()) + }, }, }, }, diff --git a/demos/bookstore/app/checkout.tsx b/demos/bookstore/app/checkout.tsx index 7f9f269f66b..2dac8e1b870 100644 --- a/demos/bookstore/app/checkout.tsx +++ b/demos/bookstore/app/checkout.tsx @@ -7,16 +7,17 @@ import { getCart, clearCart, getCartTotal } from './models/cart.ts' import { createOrder, getOrderById } from './models/orders.ts' import { Layout } from './layout.tsx' import { render } from './utils/render.ts' -import { getCurrentUser, getStorage } from './utils/context.ts' +import { getCurrentUser } from './utils/context.ts' export default { use: [requireAuth], handlers: { index({ session }) { - let cart = getCart(session.get('userId')) - let total = getCartTotal(cart) + let cartId = session.get('cartId') + let cart = cartId ? getCart(cartId) : null + let total = cart ? getCartTotal(cart) : 0 - if (cart.items.length === 0) { + if (!cart || cart.items.length === 0) { return render(
@@ -109,9 +110,10 @@ export default { async action({ session, formData }) { let user = getCurrentUser() - let cart = getCart(session.get('userId')) + let cartId = session.get('cartId') + let cart = cartId ? getCart(cartId) : null - if (cart.items.length === 0) { + if (!cartId || !cart || cart.items.length === 0) { return redirect(routes.cart.index.href()) } @@ -133,7 +135,7 @@ export default { shippingAddress, ) - clearCart(session.get('userId')) + clearCart(cartId) return redirect(routes.checkout.confirmation.href({ orderId: order.id })) }, diff --git a/demos/bookstore/app/fragments.tsx b/demos/bookstore/app/fragments.tsx index 6065747eddf..77025a25632 100644 --- a/demos/bookstore/app/fragments.tsx +++ b/demos/bookstore/app/fragments.tsx @@ -21,8 +21,9 @@ export default { return render(
Book not found
, { status: 404 }) } - let cart = getCart(session.get('userId')) - let inCart = cart.items.some((item) => item.slug === params.slug) + let cartId = session.get('cartId') + let cart = cartId ? getCart(cartId) : null + let inCart = cart?.items.some((item) => item.slug === params.slug) === true return render() }, diff --git a/demos/bookstore/app/middleware/cart.ts b/demos/bookstore/app/middleware/cart.ts new file mode 100644 index 00000000000..be721649517 --- /dev/null +++ b/demos/bookstore/app/middleware/cart.ts @@ -0,0 +1,10 @@ +import type { Middleware } from '@remix-run/fetch-router' +import { createCartIfNotExists } from '../models/cart.ts' + +/** + * Middleware that ensures the user session has an associated cart + * To be used on any route that mutates the cart + */ +export const ensureCart: Middleware = async ({ session }) => { + createCartIfNotExists(session) +} diff --git a/demos/bookstore/app/models/cart.ts b/demos/bookstore/app/models/cart.ts index dfe17be8ab1..cbbb9abb3a0 100644 --- a/demos/bookstore/app/models/cart.ts +++ b/demos/bookstore/app/models/cart.ts @@ -1,3 +1,5 @@ +import type { Session } from '@remix-run/session' + export interface CartItem { bookId: string slug: string @@ -10,27 +12,44 @@ export interface Cart { items: CartItem[] } -// Store carts by user ID +// Store carts by cartId, cartId will be stored in the session +let nextCartId = 1 const carts = new Map() -export function getCart(userId: string): Cart { - let cart = carts.get(userId) +export function createCartIfNotExists(session: Session): Cart { + let cartId = session.get('cartId') + if (cartId) { + let cart = carts.get(cartId) + if (cart) { + return cart + } + } else { + cartId = String(nextCartId++) + session.set('cartId', cartId) + } + + let cart = { items: [] } + carts.set(cartId, cart) + return cart +} + +export function getCart(cartId: string): Cart { + let cart = carts.get(cartId) if (!cart) { - cart = { items: [] } - carts.set(userId, cart) + throw new Error('Cart not found') } return cart } export function addToCart( - userId: string, + cartId: string, bookId: string, slug: string, title: string, price: number, quantity: number = 1, ): Cart { - let cart = getCart(userId) + let cart = getCart(cartId) let existingItem = cart.items.find((item) => item.bookId === bookId) if (existingItem) { @@ -42,8 +61,8 @@ export function addToCart( return cart } -export function updateCartItem(userId: string, bookId: string, quantity: number): Cart | undefined { - let cart = getCart(userId) +export function updateCartItem(cartId: string, bookId: string, quantity: number): Cart | undefined { + let cart = getCart(cartId) let item = cart.items.find((item) => item.bookId === bookId) if (!item) return undefined @@ -57,14 +76,14 @@ export function updateCartItem(userId: string, bookId: string, quantity: number) return cart } -export function removeFromCart(userId: string, bookId: string): Cart { - let cart = getCart(userId) +export function removeFromCart(cartId: string, bookId: string): Cart { + let cart = getCart(cartId) cart.items = cart.items.filter((item) => item.bookId !== bookId) return cart } -export function clearCart(userId: string): void { - carts.set(userId, { items: [] }) +export function clearCart(cartId: string): void { + carts.set(cartId, { items: [] }) } export function getCartTotal(cart: Cart): number { diff --git a/demos/bookstore/app/utils/frame.tsx b/demos/bookstore/app/utils/frame.tsx index 1ccf29dbf75..e27984c7fdb 100644 --- a/demos/bookstore/app/utils/frame.tsx +++ b/demos/bookstore/app/utils/frame.tsx @@ -20,8 +20,9 @@ export async function resolveFrame(frameSrc: string) { throw new Error(`Book not found: ${slug}`) } - let cart = getCart(getSession().get('userId')) - let inCart = cart.items.some((item) => item.slug === slug) + let cartId = getSession().get('cartId') + let cart = cartId ? getCart(cartId) : null + let inCart = cart?.items.some((item) => item.slug === slug) === true return } diff --git a/demos/bookstore/app/utils/session.ts b/demos/bookstore/app/utils/session.ts index 4cda143a20f..ee622b11651 100644 --- a/demos/bookstore/app/utils/session.ts +++ b/demos/bookstore/app/utils/session.ts @@ -3,6 +3,7 @@ import type { Session } from '@remix-run/session' declare module '@remix-run/session' { interface SessionData { + cartId?: string userId?: string } } diff --git a/packages/fetch-router/src/lib/router.ts b/packages/fetch-router/src/lib/router.ts index 7e07b389c30..439009aa9e5 100644 --- a/packages/fetch-router/src/lib/router.ts +++ b/packages/fetch-router/src/lib/router.ts @@ -88,7 +88,7 @@ export class Router { this.#uploadHandler = options?.uploadHandler this.#methodOverride = options?.methodOverride ?? true - if (!options || options.sessionStorage == null || options.sessionStorage === true) { + if (options?.sessionStorage == null || options?.sessionStorage === true) { // Unless they opt-out, we default to an `HttpOnly` cookie-based session that // will only be "activated" in a `Set-Cookie` response if they mutate the // session From 2d87615507b8e5014f2634d1aab74c002cfa8890 Mon Sep 17 00:00:00 2001 From: Matt Brophy Date: Fri, 31 Oct 2025 12:27:17 -0400 Subject: [PATCH 37/37] Fix tests based on new cart flow --- demos/bookstore/app/auth.test.ts | 6 +----- demos/bookstore/app/cart.test.ts | 7 +++++-- demos/bookstore/app/checkout.test.ts | 5 +++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/demos/bookstore/app/auth.test.ts b/demos/bookstore/app/auth.test.ts index e05151ab74e..12881b560ae 100644 --- a/demos/bookstore/app/auth.test.ts +++ b/demos/bookstore/app/auth.test.ts @@ -2,11 +2,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { router } from './router.ts' -import { - getSessionCookie as sessionCookie, - assertContains, - getSessionCookie, -} from '../test/helpers.ts' +import { assertContains, getSessionCookie } from '../test/helpers.ts' describe('auth handlers', () => { it('POST /login with valid credentials sets session cookie and redirects', async () => { diff --git a/demos/bookstore/app/cart.test.ts b/demos/bookstore/app/cart.test.ts index 5dfc1bec051..cf2483df996 100644 --- a/demos/bookstore/app/cart.test.ts +++ b/demos/bookstore/app/cart.test.ts @@ -7,6 +7,7 @@ import { assertContains, loginAsCustomer, assertNotContains, + getSessionCookie, } from '../test/helpers.ts' describe('cart handlers', () => { @@ -36,7 +37,7 @@ describe('cart handlers', () => { assertNotContains(html, 'Heavy Metal Guitar Riffs') // First, add item to cart to get a session - await router.fetch('http://localhost:3000/cart/api/add', { + response = await router.fetch('http://localhost:3000/cart/api/add', { method: 'POST', body: new URLSearchParams({ bookId: '002', @@ -47,6 +48,7 @@ describe('cart handlers', () => { }, redirect: 'manual', }) + sessionCookie = getSessionCookie(response)! // Now view cart with session request = requestWithSession('http://localhost:3000/cart', sessionCookie) @@ -62,7 +64,7 @@ describe('cart handlers', () => { let sessionCookie = await loginAsCustomer(router) // Add first item - await router.fetch('http://localhost:3000/cart/api/add', { + let response = await router.fetch('http://localhost:3000/cart/api/add', { method: 'POST', body: new URLSearchParams({ bookId: '001', @@ -73,6 +75,7 @@ describe('cart handlers', () => { }, redirect: 'manual', }) + sessionCookie = getSessionCookie(response)! // Add second item with same session let addRequest2 = requestWithSession('http://localhost:3000/cart/api/add', sessionCookie, { diff --git a/demos/bookstore/app/checkout.test.ts b/demos/bookstore/app/checkout.test.ts index ff295c6b777..6d55b8512ca 100644 --- a/demos/bookstore/app/checkout.test.ts +++ b/demos/bookstore/app/checkout.test.ts @@ -2,7 +2,7 @@ import * as assert from 'node:assert/strict' import { describe, it } from 'node:test' import { router } from './router.ts' -import { loginAsCustomer, requestWithSession } from '../test/helpers.ts' +import { getSessionCookie, loginAsCustomer, requestWithSession } from '../test/helpers.ts' describe('checkout handlers', () => { it('GET /checkout redirects when not authenticated', async () => { @@ -23,7 +23,8 @@ describe('checkout handlers', () => { slug: 'bbq', }), }) - await router.fetch(addRequest) + let response = await router.fetch(addRequest) + sessionCookie = getSessionCookie(response)! // Submit checkout let checkoutRequest = requestWithSession('http://localhost:3000/checkout', sessionCookie, {