diff --git a/LICENSE b/LICENSE index 717984c0442..829ea2ae0b6 100644 --- a/LICENSE +++ b/LICENSE @@ -2,6 +2,7 @@ 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 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..12881b560ae 100644 --- a/demos/bookstore/app/auth.test.ts +++ b/demos/bookstore/app/auth.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 { getSessionCookie, assertContains } 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 () => { @@ -18,8 +18,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 +52,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/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.test.ts b/demos/bookstore/app/cart.test.ts index 2f10cf90f5f..cf2483df996 100644 --- a/demos/bookstore/app/cart.test.ts +++ b/demos/bookstore/app/cart.test.ts @@ -2,7 +2,13 @@ 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, + getSessionCookie, +} from '../test/helpers.ts' describe('cart handlers', () => { it('POST /cart/api/add adds book to cart', async () => { @@ -20,55 +26,73 @@ 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', { + response = 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) + sessionCookie = getSessionCookie(response)! // 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', { + let response = 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) + sessionCookie = getSessionCookie(response)! // 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/cart.tsx b/demos/bookstore/app/cart.tsx index f14fa014933..2d6dd898f18 100644 --- a/demos/bookstore/app/cart.tsx +++ b/demos/bookstore/app/cart.tsx @@ -4,22 +4,22 @@ 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' +import { ensureCart } from './middleware/cart.ts' export default { use: [loadAuth], handlers: { - index() { - let sessionId = getStorage().get(SESSION_ID_KEY) - let cart = getCart(sessionId) - let total = getCartTotal(cart) + index({ session }) { + let cartId = session.get('cartId') + let cart = cartId ? getCart(cartId) : null + let total = cart ? getCartTotal(cart) : 0 let user: User | null = null try { @@ -33,7 +33,7 @@ export default {

Shopping Cart

- {cart.items.length > 0 ? ( + {cart && cart.items.length > 0 ? ( <> @@ -137,64 +137,55 @@ export default { }, api: { - async add({ storage, 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 sessionId = storage.get(SESSION_ID_KEY) - 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(sessionId, book.id, book.slug, book.title, book.price, 1) + addToCart(session.get('cartId')!, book.id, book.slug, book.title, book.price, 1) - let headers = new Headers() - setSessionCookie(headers, sessionId) + 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(), { headers }) - }, - - async update({ storage, formData }) { - let sessionId = storage.get(SESSION_ID_KEY) - let bookId = formData.get('bookId')?.toString() ?? '' - let quantity = parseInt(formData.get('quantity')?.toString() ?? '1', 10) - - updateCartItem(sessionId, bookId, quantity) + async update({ session, formData }) { + let bookId = formData.get('bookId')?.toString() ?? '' + let quantity = parseInt(formData.get('quantity')?.toString() ?? '1', 10) - let headers = new Headers() - setSessionCookie(headers, sessionId) + updateCartItem(session.get('cartId')!, bookId, quantity) - if (formData.get('redirect') === 'none') { - return new Response(null, { status: 204 }) - } - - return redirect(routes.cart.index.href(), { headers }) - }, + if (formData.get('redirect') === 'none') { + return new Response(null, { status: 204 }) + } - async remove({ storage, formData }) { - // Simulate network latency - await new Promise((resolve) => setTimeout(resolve, 1000)) + return redirect(routes.cart.index.href()) + }, - let sessionId = storage.get(SESSION_ID_KEY) - let bookId = formData.get('bookId')?.toString() ?? '' + async remove({ session, formData }) { + // Simulate network latency + await new Promise((resolve) => setTimeout(resolve, 1000)) - removeFromCart(sessionId, bookId) + let bookId = formData.get('bookId')?.toString() ?? '' - let headers = new Headers() - setSessionCookie(headers, sessionId) + 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(), { headers }) + return redirect(routes.cart.index.href()) + }, }, }, }, diff --git a/demos/bookstore/app/checkout.test.ts b/demos/bookstore/app/checkout.test.ts index af7fd86a409..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 () => { @@ -13,20 +13,21 @@ 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', slug: 'bbq', }), }) - await router.fetch(addRequest) + let response = await router.fetch(addRequest) + sessionCookie = getSessionCookie(response)! // 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/checkout.tsx b/demos/bookstore/app/checkout.tsx index 4940360263a..2dac8e1b870 100644 --- a/demos/bookstore/app/checkout.tsx +++ b/demos/bookstore/app/checkout.tsx @@ -2,22 +2,22 @@ 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' 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() { - let sessionId = getStorage().get(SESSION_ID_KEY) - let cart = getCart(sessionId) - let total = getCartTotal(cart) + index({ session }) { + 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(
@@ -108,12 +108,12 @@ export default { ) }, - async action({ formData }) { + async action({ session, formData }) { let user = getCurrentUser() - let sessionId = getStorage().get(SESSION_ID_KEY) - let cart = getCart(sessionId) + 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()) } @@ -135,7 +135,7 @@ export default { shippingAddress, ) - clearCart(sessionId) + 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 0c911e071d0..77025a25632 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,8 +21,9 @@ export default { return render(
Book not found
, { status: 404 }) } - let cart = getCart(getStorage().get(SESSION_ID_KEY)) - 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/auth.ts b/demos/bookstore/app/middleware/auth.ts index b2c9edcf154..854f38740bd 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. + * Attaches user (if any) 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) { @@ -35,11 +30,10 @@ export let loadAuth: Middleware = async ({ request, 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 ({ 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/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 8ee76e6f6db..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 session ID +// Store carts by cartId, cartId will be stored in the session +let nextCartId = 1 const carts = new Map() -export function getCart(sessionId: string): Cart { - let cart = carts.get(sessionId) +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(sessionId, cart) + throw new Error('Cart not found') } return cart } export function addToCart( - sessionId: string, + cartId: string, bookId: string, slug: string, title: string, price: number, quantity: number = 1, ): Cart { - let cart = getCart(sessionId) + let cart = getCart(cartId) let existingItem = cart.items.find((item) => item.bookId === bookId) if (existingItem) { @@ -42,12 +61,8 @@ export function addToCart( return cart } -export function updateCartItem( - sessionId: string, - bookId: string, - quantity: number, -): Cart | undefined { - let cart = getCart(sessionId) +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 @@ -61,14 +76,14 @@ export function updateCartItem( return cart } -export function removeFromCart(sessionId: string, bookId: string): Cart { - let cart = getCart(sessionId) +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(sessionId: string): void { - carts.set(sessionId, { items: [] }) +export function clearCart(cartId: string): void { + carts.set(cartId, { items: [] }) } export function getCartTotal(cart: Cart): number { 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.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/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 }) => { 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..e27984c7fdb 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,8 +20,9 @@ export async function resolveFrame(frameSrc: string) { throw new Error(`Book not found: ${slug}`) } - let cart = getCart(getStorage().get(SESSION_ID_KEY)) - 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 ff9f5b7070e..ee622b11651 100644 --- a/demos/bookstore/app/utils/session.ts +++ b/demos/bookstore/app/utils/session.ts @@ -1,77 +1,21 @@ -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) +declare module '@remix-run/session' { + interface SessionData { + cartId?: string + userId?: string } - - 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(session: Session, user: User): void { + session.set('userId', user.id) } -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 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/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/packages/cookie/CHANGELOG.md b/packages/cookie/CHANGELOG.md new file mode 100644 index 00000000000..0a5d127cc7a --- /dev/null +++ b/packages/cookie/CHANGELOG.md @@ -0,0 +1,5 @@ +# `@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/). + +## Unreleased diff --git a/packages/cookie/LICENSE b/packages/cookie/LICENSE new file mode 100644 index 00000000000..2a384496821 --- /dev/null +++ b/packages/cookie/LICENSE @@ -0,0 +1,21 @@ +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 +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/cookie/README.md b/packages/cookie/README.md new file mode 100644 index 00000000000..363e4471aed --- /dev/null +++ b/packages/cookie/README.md @@ -0,0 +1,253 @@ +# @remix-run/cookie + +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 + +```sh +npm install @remix-run/cookie +``` + +## Overview + +The following should give you a sense of what kinds of things you can do with this library: + +```ts +import { Cookie } from '@remix-run/cookie' + +// Create a basic cookie +let sessionCookie = new Cookie('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 = new Cookie('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 = new Cookie('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 { Cookie } from '@remix-run/cookie' + +let cookie = new Cookie('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 = 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 = new Cookie('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 = new Cookie('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 +}) +``` + +### Error Handling + +The library handles various error scenarios gracefully: + +```ts +let cookie = new Cookie('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 = new Cookie('signed', { secrets: ['secret'] }) +await signedCookie.parse('signed=value.badsignature') // null +``` + +## API Reference + +### `Cookie` Class + +A cookie container class for managing HTTP cookies. + +**Constructor:** + +```ts +new Cookie(name: string, options?: CookieOptions) +``` + +**Parameters:** + +- `name: string` - The cookie name +- `options?: CookieOptions` - Configuration options + +**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 + +### `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 + +See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE) diff --git a/packages/cookie/package.json b/packages/cookie/package.json new file mode 100644 index 00000000000..d2358f0de93 --- /dev/null +++ b/packages/cookie/package.json @@ -0,0 +1,55 @@ +{ + "name": "@remix-run/cookie", + "version": "0.1.0", + "description": "A toolkit for working with cookies in JavaScript", + "author": "Remix Software ", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/remix-run/remix.git", + "directory": "packages/cookie" + }, + "homepage": "https://github.com/remix-run/remix/tree/main/packages/cookie#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", + "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 --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", + "test": "node --disable-warning=ExperimentalWarning --test './src/**/*.test.ts'", + "typecheck": "tsc --noEmit" + }, + "keywords": [ + "http", + "cookie", + "cookies", + "http-cookies", + "set-cookie" + ] +} diff --git a/packages/cookie/src/index.ts b/packages/cookie/src/index.ts new file mode 100644 index 00000000000..3eb5346a3d8 --- /dev/null +++ b/packages/cookie/src/index.ts @@ -0,0 +1 @@ +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 new file mode 100644 index 00000000000..d85217ca370 --- /dev/null +++ b/packages/cookie/src/lib/cookie.test.ts @@ -0,0 +1,188 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Cookie } from './cookie.ts' + +function getCookieFromSetCookie(setCookie: string): string { + return setCookie.split(/;\s*/)[0] +} + +describe('cookies', () => { + it('parses/serializes empty string values', async () => { + let cookie = new Cookie('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 = new Cookie('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 = new Cookie('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 = new Cookie('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 = new Cookie('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 = new Cookie('my-cookie', { + secrets: ['secret1'], + }) + let setCookie = await cookie.serialize('hello michael') + let cookie2 = new Cookie('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 = new Cookie('my-cookie', { + secrets: ['secret1'], + }) + let setCookie = await cookie.serialize('hello michael') + let cookie2 = new Cookie('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 = new Cookie('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 = new Cookie('my-cookie', { + secrets: ['secret1'], + }) + let setCookie = await cookie.serialize({ hello: 'mjackson' }) + let cookie2 = new Cookie('my-cookie', { + secrets: ['secret2'], + }) + let value = await cookie2.parse(getCookieFromSetCookie(setCookie)) + + assert.equal(value, null) + }) + + it('supports secret rotation', async () => { + let cookie = new Cookie('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 = new Cookie('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 = new Cookie('my-cookie') + + assert.equal(cookie.isSigned, false) + + let cookie2 = new Cookie('my-cookie2', { + secrets: undefined, + }) + + assert.equal(cookie2.isSigned, false) + }) + + it('makes the default path of cookies to be /', async () => { + let cookie = new Cookie('my-cookie') + + let setCookie = await cookie.serialize('hello world') + assert.ok(setCookie.includes('Path=/')) + + let cookie2 = new Cookie('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 = new Cookie('my-cookie') + + let setCookie = await cookie.serialize('hello world') + assert.ok(!setCookie.includes('Priority')) + + let cookie2 = new Cookie('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 { + 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( + consoleCalls[0].includes( + 'Instead, you should set the expires value when serializing the cookie', + ), + ) + } finally { + console.warn = originalWarn + } + }) + }) +}) diff --git a/packages/cookie/src/lib/cookie.ts b/packages/cookie/src/lib/cookie.ts new file mode 100644 index 00000000000..66b559e5777 --- /dev/null +++ b/packages/cookie/src/lib/cookie.ts @@ -0,0 +1,206 @@ +import type { ParseOptions, SerializeOptions } from 'cookie' +import { parse, serialize } from 'cookie' + +import { sign, unsign } from './crypto.ts' +import { warnOnce } from './warnings.ts' + +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. + * + * 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. + */ +export class Cookie { + readonly name: string + readonly #secrets: string[] + readonly #options: SerializeOptions & ParseOptions + + /** + * Creates a logical container for managing a browser cookie from the server. + */ + 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. + */ + 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 the constructor. + */ + 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. + */ + 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. + */ + async serialize(value: unknown, serializeOptions?: SerializeOptions): Promise { + return serialize(this.name, value === '' ? '' : await encodeCookieValue(value, this.#secrets), { + ...this.#options, + ...serializeOptions, + }) + } +} + +async function encodeCookieValue(value: unknown, 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: unknown): string { + return btoa(myUnescape(encodeURIComponent(JSON.stringify(value)))) +} + +function decodeData(value: string): unknown { + try { + return JSON.parse(decodeURIComponent(myEscape(atob(value)))) + } catch { + 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/cookie/src/lib/crypto.ts b/packages/cookie/src/lib/crypto.ts new file mode 100644 index 00000000000..7dffaec950f --- /dev/null +++ b/packages/cookie/src/lib/crypto.ts @@ -0,0 +1,51 @@ +const encoder = new TextEncoder() + +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) + let hash = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/=+$/, '') + + return value + '.' + hash +} + +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) + + 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 + } +} + +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) + + for (let i = 0; i < byteString.length; i++) { + array[i] = byteString.charCodeAt(i) + } + + return array +} diff --git a/packages/cookie/src/lib/warnings.ts b/packages/cookie/src/lib/warnings.ts new file mode 100644 index 00000000000..747086991d6 --- /dev/null +++ b/packages/cookie/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/cookie/tsconfig.build.json b/packages/cookie/tsconfig.build.json new file mode 100644 index 00000000000..fdeb70cad14 --- /dev/null +++ b/packages/cookie/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/cookie/tsconfig.json b/packages/cookie/tsconfig.json new file mode 100644 index 00000000000..4781f83485f --- /dev/null +++ b/packages/cookie/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/packages/fetch-router/package.json b/packages/fetch-router/package.json index bf07a179c48..b4e7331ccf9 100644 --- a/packages/fetch-router/package.json +++ b/packages/fetch-router/package.json @@ -47,15 +47,16 @@ "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:*", - "@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/request-context.test.ts b/packages/fetch-router/src/lib/request-context.test.ts index b5d26f7f735..7654d95ad46 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 { Session } from '@remix-run/session' describe('new RequestContext()', () => { it('has a header object that is SuperHeaders', () => { @@ -14,6 +15,21 @@ describe('new RequestContext()', () => { 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 + + 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 dbf6fca3d2a..96aefe192ca 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 { Session } from '@remix-run/session' import { AppStorage } from './app-storage.ts' import type { RequestBodyMethod, RequestMethod } from './request-methods.ts' @@ -34,6 +35,11 @@ export class RequestContext< * The original request that was dispatched to the router. */ request: Request + /** + * @private + * Privately tracked session, if exists + */ + _session: Session | undefined /** * Shared application-specific storage. */ @@ -80,4 +86,9 @@ export class RequestContext< return files } + + get 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 9a272c96992..d1f726994c2 100644 --- a/packages/fetch-router/src/lib/router.test.ts +++ b/packages/fetch-router/src/lib/router.test.ts @@ -1,12 +1,14 @@ import * as assert from 'node:assert/strict' import { describe, it, mock } from 'node:test' import { RegExpMatcher, RoutePattern } from '@remix-run/route-pattern' +import { createCookieSessionStorage, createMemorySessionStorage } from '@remix-run/session' import { createStorageKey } from './app-storage.ts' 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 () => { @@ -1670,3 +1672,609 @@ describe('abort signal support', () => { assert.equal(handlerCalled, false) }) }) + +describe('sessions', () => { + let getSessionCookie = (r: Response) => r.headers.get('Set-Cookie')?.split(';')[0] || '' + + it('automatically provides an HttpOnly cookie-based session, only if the session is used', async () => { + let routes = createRoutes({ + home: '/', + }) + + let router = createRouter() + + 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 session cookie created if session is unused + let response = await router.fetch('https://remix.run') + 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 = getSessionCookie(response) + let session = JSON.parse(atob(decodeURIComponent(cookie.split('=')[1]))) + assert.deepEqual(session, { name: 'Remix' }) + + // Parses the session from the incoming cookie + // No updated session cookie on read-only requests + 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) + + // 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('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: '/', + }) + + 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 router = createRouter({ + defaultHandler: ({ url, session }) => { + return new Response(`Not Found: ${url.pathname} ${session?.get('name')}`) + }, + }) + + router.post(routes.home, ({ session }) => { + session.set('name', 'Remix') + return new Response('Home') + }) + + // Session creation + let response = await router.fetch('https://remix.run', { method: 'POST' }) + assert.equal(await response.text(), 'Home') + + response = await router.fetch('https://remix.run/junk', { + headers: { + Cookie: getSessionCookie(response), + }, + }) + + assert.equal(await response.text(), 'Not Found: /junk 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', + ]) + }) + + 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({ + home: '/', + }) + + let router = createRouter({ sessionStorage: createCookieSessionStorage() }) + + router.get(routes.home, () => { + 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'), 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 () => { + 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, ({ session }) => { + session.set('name', 'Remix') + 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'), true) + + // 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('does not send a set-cookie header on initial session creation if the session is not used', 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') + 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 () => { + 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') + assert.equal(await response.text(), 'Home:') + assert.equal(response.headers.has('Set-Cookie'), false) + + response = await router.fetch('https://remix.run', { + method: 'post', + body: '', + }) + + assert.equal(await response.text(), 'Home (post):Remix') + 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: getSessionCookie(response), + }, + }) + + 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, ({ session }) => { + session.set('name', 'Remix') + 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/fetch-router/src/lib/router.ts b/packages/fetch-router/src/lib/router.ts index 5b7feed00f0..439009aa9e5 100644 --- a/packages/fetch-router/src/lib/router.ts +++ b/packages/fetch-router/src/lib/router.ts @@ -14,6 +14,8 @@ 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' export interface RouterOptions { /** @@ -36,6 +38,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 | boolean /** * A function that handles file uploads. It receives a `FileUpload` object and may return any * value that is a valid `FormData` value. @@ -71,6 +77,7 @@ export class Router { #matcher: Matcher #middleware: Middleware[] | undefined #parseFormData: (ParseFormDataOptions & { suppressErrors?: boolean }) | boolean + #sessionStorage: SessionStorage | undefined #uploadHandler: FileUploadHandler | undefined #methodOverride: string | boolean @@ -80,6 +87,19 @@ export class Router { this.#parseFormData = options?.parseFormData ?? true this.#uploadHandler = options?.uploadHandler this.#methodOverride = options?.methodOverride ?? 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 + this.#sessionStorage = createCookieSessionStorage({ + cookie: { + httpOnly: true, + }, + }) + } else if (options?.sessionStorage) { + this.#sessionStorage = options.sessionStorage + } } /** @@ -96,6 +116,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 @@ -112,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) { @@ -151,11 +185,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 @@ -164,6 +201,13 @@ 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) { + context._session = await this.#sessionStorage.getSession( + context.request.headers.get('Cookie'), + ) + } + if (!RequestBodyMethods.includes(request.method as RequestBodyMethod)) { return context } @@ -218,6 +262,38 @@ 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 === '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: + // - 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, 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 + } + /** * Mount a router at a given pathname prefix in the current router. */ diff --git a/packages/session/CHANGELOG.md b/packages/session/CHANGELOG.md new file mode 100644 index 00000000000..be7115ff332 --- /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/). + +## Unreleased diff --git a/packages/session/LICENSE b/packages/session/LICENSE new file mode 100644 index 00000000000..2a384496821 --- /dev/null +++ b/packages/session/LICENSE @@ -0,0 +1,21 @@ +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 +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..dc887d17512 --- /dev/null +++ b/packages/session/README.md @@ -0,0 +1,530 @@ +# @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 +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/package.json b/packages/session/package.json new file mode 100644 index 00000000000..ca97281fa26 --- /dev/null +++ b/packages/session/package.json @@ -0,0 +1,65 @@ +{ + "name": "@remix-run/session", + "version": "0.1.0", + "description": "A toolkit for working with sessions in JavaScript", + "author": "Remix Software ", + "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", + "./file-storage": "./src/file-storage.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "exports": { + ".": { + "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" + } + }, + "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: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", + "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/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 new file mode 100644 index 00000000000..eb02baed608 --- /dev/null +++ b/packages/session/src/index.ts @@ -0,0 +1,11 @@ +export { + type SessionData, + type SessionIdStorageStrategy, + type SessionStorage, + type FlashSessionData, + createSessionStorage, + Session, +} from './lib/session.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 new file mode 100644 index 00000000000..d7bbb552e86 --- /dev/null +++ b/packages/session/src/lib/session.test.ts @@ -0,0 +1,87 @@ +import * as assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { Session } from './session.ts' + +describe('Session', () => { + it('has an empty id by default', () => { + assert.equal(new Session().id, '') + }) + + it('correctly stores and retrieves values', () => { + let session = new Session() + + 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) + }) + + it('correctly destroys a session', () => { + let session = new Session() + + 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 = new Session() + 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 = new Session({ 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 = new Session({ 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', + }) + }) +}) diff --git a/packages/session/src/lib/session.ts b/packages/session/src/lib/session.ts new file mode 100644 index 00000000000..3a2f916a49f --- /dev/null +++ b/packages/session/src/lib/session.ts @@ -0,0 +1,283 @@ +import type { ParseOptions, SerializeOptions } from 'cookie' +import type { CookieOptions } from '@remix-run/cookie' +import { Cookie } 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]: unknown +} + +/** + * 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 class Session { + #id: string + #map: Map, unknown> + #status: 'new' | 'clean' | 'dirty' | 'destroyed' + + 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 ?? '' + 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). + */ + get id() { + return this.#id + } + + /** + * A value indicating the status of the session. + * + * This is useful for middlewares to know if they need to commit the session. + */ + get status() { + return this.#status + } + + /** + * The raw data contained in this session. + * + * This is useful mostly for SessionStorage internally to access the raw + * session data to persist. + */ + 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) { + 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. + */ + get( + name: Key, + ): + | (Key extends keyof Data ? Data[Key] : undefined) + | (Key extends keyof FlashData ? FlashData[Key] : 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]) { + 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]) { + this.#throwIfDestroyed() + this.#map.set(flash(name), value) + this.#status = 'dirty' + } + + /** + * Removes a value from the session. + */ + unset(name: keyof Data & string) { + this.#throwIfDestroyed() + this.#map.delete(name) + this.#status = 'dirty' + } + + /** + * Clears a session for destruction + **/ + 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< + Data & { + [Key in keyof FlashData as FlashDataKey]: FlashData[Key] + } +> +type FlashDataKey = `__flash_${Key}__` +function flash(name: Key): FlashDataKey { + return `__flash_${name}__` +} + +/** + * 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 = + 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)) + 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 + 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.`, + ) +} 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/storage/cookie-storage.ts b/packages/session/src/lib/storage/cookie-storage.ts new file mode 100644 index 00000000000..fe61db6e8a9 --- /dev/null +++ b/packages/session/src/lib/storage/cookie-storage.ts @@ -0,0 +1,54 @@ +import { Cookie } from '@remix-run/cookie' +import type { SessionStorage, SessionIdStorageStrategy, SessionData } from '../session.ts' +import { warnOnceAboutSigningSessionCookie, Session } 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 = + 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)) as Partial | null) + : undefined + return new Session(data) + }, + 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/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/storage/file-storage.ts b/packages/session/src/lib/storage/file-storage.ts new file mode 100644 index 00000000000..d5515569560 --- /dev/null +++ b/packages/session/src/lib/storage/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 '@remix-run/session' +import { createSessionStorage } from '@remix-run/session' + +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/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/storage/memory-storage.ts b/packages/session/src/lib/storage/memory-storage.ts new file mode 100644 index 00000000000..6449b860941 --- /dev/null +++ b/packages/session/src/lib/storage/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/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..78cd1233a1a --- /dev/null +++ b/packages/session/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "rootDir": ".", + "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..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 @@ -61,6 +64,18 @@ importers: specifier: ^4.20.6 version: 4.20.6 + packages/cookie: + 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 + packages/fetch-proxy: dependencies: '@remix-run/headers': @@ -88,6 +103,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 @@ -338,6 +356,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': @@ -1291,6 +1325,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 +3049,8 @@ snapshots: cookie@0.7.2: {} + cookie@1.0.2: {} + core-util-is@1.0.3: {} cross-spawn@7.0.5: