diff --git a/.changeset/breezy-geese-sneeze.md b/.changeset/breezy-geese-sneeze.md new file mode 100644 index 00000000..a894c445 --- /dev/null +++ b/.changeset/breezy-geese-sneeze.md @@ -0,0 +1,5 @@ +--- +'@edge-runtime/cookies': major +--- + +Align `RequestCookies` and `ResponseCookies` APIs as much as possible with [CookieStore](https://developer.mozilla.org/en-US/docs/Web/API/CookieStore) diff --git a/packages/cookies/src/index.ts b/packages/cookies/src/index.ts index bcf44ce8..7e4d5980 100644 --- a/packages/cookies/src/index.ts +++ b/packages/cookies/src/index.ts @@ -1,3 +1,3 @@ -export type { Cookie, CookieListItem } from './serialize' -export { ResponseCookies } from './response-cookies' +export type { CookieListItem, RequestCookie, ResponseCookie } from './types' export { RequestCookies } from './request-cookies' +export { ResponseCookies } from './response-cookies' diff --git a/packages/cookies/src/request-cookies.ts b/packages/cookies/src/request-cookies.ts index 351aec93..d7953e6f 100644 --- a/packages/cookies/src/request-cookies.ts +++ b/packages/cookies/src/request-cookies.ts @@ -1,14 +1,15 @@ -import { type Cookie, parseCookieString, serialize } from './serialize' +import type { RequestCookie } from './types' +import { parseCookieString, serialize } from './serialize' import { cached } from './cached' /** - * A class for manipulating {@link Request} cookies. + * A class for manipulating {@link Request} cookies (`Cookie` header). */ export class RequestCookies { readonly #headers: Headers - constructor(request: Request) { - this.#headers = request.headers + constructor(requestHeaders: Headers) { + this.#headers = requestHeaders } #cache = cached((header: string | null) => { @@ -32,12 +33,12 @@ export class RequestCookies { return this.#parsed().size } - get(...args: [name: string] | [Cookie]) { + get(...args: [name: string] | [RequestCookie]) { const name = typeof args[0] === 'string' ? args[0] : args[0].name return this.#parsed().get(name) } - getAll(...args: [name: string] | [Cookie] | [undefined]) { + getAll(...args: [name: string] | [RequestCookie] | []) { const all = Array.from(this.#parsed()) if (!args.length) { return all @@ -51,12 +52,12 @@ export class RequestCookies { return this.#parsed().has(name) } - set(...args: [key: string, value: string] | [options: Cookie]): this { - const [key, value] = - args.length === 1 ? [args[0].name, args[0].value, args[0]] : args + set(...args: [key: string, value: string] | [options: RequestCookie]): this { + const [name, value] = + args.length === 1 ? [args[0].name, args[0].value] : args const map = this.#parsed() - map.set(key, value) + map.set(name, value) this.#headers.set( 'cookie', diff --git a/packages/cookies/src/response-cookies.ts b/packages/cookies/src/response-cookies.ts index 91e07e7d..3087a048 100644 --- a/packages/cookies/src/response-cookies.ts +++ b/packages/cookies/src/response-cookies.ts @@ -1,23 +1,23 @@ +import type { ResponseCookie } from './types' import { cached } from './cached' -import { type Cookie, parseSetCookieString, serialize } from './serialize' - -export type CookieBag = Map +import { parseSetCookieString, serialize } from './serialize' /** + * A class for manipulating {@link Response} cookies (`Set-Cookie` header). * Loose implementation of the experimental [Cookie Store API](https://wicg.github.io/cookie-store/#dictdef-cookie) * The main difference is `ResponseCookies` methods do not return a Promise. */ export class ResponseCookies { readonly #headers: Headers - constructor(response: Response) { - this.#headers = response.headers + constructor(responseHeaders: Headers) { + this.#headers = responseHeaders } #cache = cached(() => { // @ts-expect-error See https://github.com/whatwg/fetch/issues/973 const headers = this.#headers.getAll('set-cookie') - const map = new Map() + const map = new Map() for (const header of headers) { const parsed = parseSetCookieString(header) @@ -37,14 +37,18 @@ export class ResponseCookies { /** * {@link https://wicg.github.io/cookie-store/#CookieStore-get CookieStore#get} without the Promise. */ - get(...args: [key: string] | [options: Cookie]): Cookie | undefined { + get( + ...args: [key: string] | [options: ResponseCookie] + ): ResponseCookie | undefined { const key = typeof args[0] === 'string' ? args[0] : args[0].name return this.#parsed().get(key) } /** * {@link https://wicg.github.io/cookie-store/#CookieStore-getAll CookieStore#getAll} without the Promise. */ - getAll(...args: [key: string] | [options: Cookie] | [undefined]): Cookie[] { + getAll( + ...args: [key: string] | [options: ResponseCookie] | [] + ): ResponseCookie[] { const all = Array.from(this.#parsed().values()) if (!args.length) { return all @@ -59,8 +63,8 @@ export class ResponseCookies { */ set( ...args: - | [key: string, value: string, cookie?: Partial] - | [options: Cookie] + | [key: string, value: string, cookie?: Partial] + | [options: ResponseCookie] ): this { const [name, value, cookie] = args.length === 1 ? [args[0].name, args[0].value, args[0]] : args @@ -74,30 +78,11 @@ export class ResponseCookies { /** * {@link https://wicg.github.io/cookie-store/#CookieStore-delete CookieStore#delete} without the Promise. */ - delete(...args: [key: string] | [options: Cookie]): this { + delete(...args: [key: string] | [options: ResponseCookie]): this { const name = typeof args[0] === 'string' ? args[0] : args[0].name return this.set({ name, value: '', expires: new Date(0) }) } - // Non-spec - - /** - * Uses {@link ResponseCookies.get} to return only the cookie value. - */ - getValue(...args: [key: string] | [options: Cookie]): string | undefined { - return this.get(...args)?.value - } - - /** - * Uses {@link ResponseCookies.delete} to invalidate all cookies matching the given name. - * If no name is provided, all cookies are invalidated. - */ - clear(...args: [key: string] | [options: Cookie] | [undefined]): this { - const key = typeof args[0] === 'string' ? args[0] : args[0]?.name - this.getAll(key).forEach((c) => this.delete(c)) - return this - } - [Symbol.for('edge-runtime.inspect.custom')]() { return `ResponseCookies ${JSON.stringify( Object.fromEntries(this.#parsed()) @@ -105,7 +90,7 @@ export class ResponseCookies { } } -function replace(bag: CookieBag, headers: Headers) { +function replace(bag: Map, headers: Headers) { headers.delete('set-cookie') for (const [, value] of bag) { const serialized = serialize(value) @@ -113,7 +98,7 @@ function replace(bag: CookieBag, headers: Headers) { } } -function normalizeCookie(cookie: Cookie = { name: '', value: '' }) { +function normalizeCookie(cookie: ResponseCookie = { name: '', value: '' }) { if (cookie.maxAge) { cookie.expires = new Date(Date.now() + cookie.maxAge * 1000) } diff --git a/packages/cookies/src/serialize.ts b/packages/cookies/src/serialize.ts index b3fb2140..b82530b3 100644 --- a/packages/cookies/src/serialize.ts +++ b/packages/cookies/src/serialize.ts @@ -1,43 +1,21 @@ -import type { CookieSerializeOptions } from 'cookie' +import type { RequestCookie, ResponseCookie } from './types' -/** - * {@link https://wicg.github.io/cookie-store/#dictdef-cookielistitem CookieListItem} as specified by W3C. - */ -export interface CookieListItem - extends Pick< - CookieSerializeOptions, - 'domain' | 'path' | 'expires' | 'secure' | 'sameSite' - > { - /** A string with the name of a cookie. */ - name: string - /** A string containing the value of the cookie. */ - value: string -} - -/** - * Extends {@link CookieListItem} with the `httpOnly`, `maxAge` and `priority` properties. - */ -export type Cookie = CookieListItem & - Pick - -export function serialize(cookie: Cookie): string { +export function serialize(c: ResponseCookie | RequestCookie): string { const attrs = [ - cookie.path ? `Path=${cookie.path}` : '', - cookie.expires ? `Expires=${cookie.expires.toUTCString()}` : '', - cookie.maxAge ? `Max-Age=${cookie.maxAge}` : '', - cookie.domain ? `Domain=${cookie.domain}` : '', - cookie.secure ? 'Secure' : '', - cookie.httpOnly ? 'HttpOnly' : '', - cookie.sameSite ? `SameSite=${cookie.sameSite}` : '', + 'path' in c && c.path && `Path=${c.path}`, + 'expires' in c && c.expires && `Expires=${c.expires.toUTCString()}`, + 'maxAge' in c && c.maxAge && `Max-Age=${c.maxAge}`, + 'domain' in c && c.domain && `Domain=${c.domain}`, + 'secure' in c && c.secure && 'Secure', + 'httpOnly' in c && c.httpOnly && 'HttpOnly', + 'sameSite' in c && c.sameSite && `SameSite=${c.sameSite}`, ].filter(Boolean) - return `${cookie.name}=${encodeURIComponent( - cookie.value ?? '' - )}; ${attrs.join('; ')}` + return `${c.name}=${encodeURIComponent(c.value ?? '')}; ${attrs.join('; ')}` } /** - * Parse a `Cookie` header value + * Parse a `Cookie` or `Set-Cookie header value */ export function parseCookieString(cookie: string): Map { const map = new Map() @@ -54,7 +32,9 @@ export function parseCookieString(cookie: string): Map { /** * Parse a `Set-Cookie` header value */ -export function parseSetCookieString(setCookie: string): undefined | Cookie { +export function parseSetCookieString( + setCookie: string +): undefined | ResponseCookie { if (!setCookie) { return undefined } @@ -64,7 +44,7 @@ export function parseSetCookieString(setCookie: string): undefined | Cookie { Object.fromEntries( attributes.map(([key, value]) => [key.toLowerCase(), value]) ) - const cookie: Cookie = { + const cookie: ResponseCookie = { name, value: decodeURIComponent(value), domain, @@ -89,9 +69,10 @@ function compact(t: T): T { return newT as T } -const SAME_SITE: Cookie['sameSite'][] = ['strict', 'lax', 'none'] -function parseSameSite(string: string): Cookie['sameSite'] { +const SAME_SITE: ResponseCookie['sameSite'][] = ['strict', 'lax', 'none'] +function parseSameSite(string: string): ResponseCookie['sameSite'] { + string = string.toLowerCase() return SAME_SITE.includes(string as any) - ? (string as Cookie['sameSite']) + ? (string as ResponseCookie['sameSite']) : undefined } diff --git a/packages/cookies/src/types.ts b/packages/cookies/src/types.ts new file mode 100644 index 00000000..8e5bf947 --- /dev/null +++ b/packages/cookies/src/types.ts @@ -0,0 +1,29 @@ +import type { CookieSerializeOptions } from 'cookie' + +/** + * {@link https://wicg.github.io/cookie-store/#dictdef-cookielistitem CookieListItem} + * as specified by W3C. + */ +export interface CookieListItem + extends Pick< + CookieSerializeOptions, + 'domain' | 'path' | 'expires' | 'secure' | 'sameSite' + > { + /** A string with the name of a cookie. */ + name: string + /** A string containing the value of the cookie. */ + value: string +} + +/** + * Superset of {@link CookieListItem} extending it with + * the `httpOnly`, `maxAge` and `priority` properties. + */ +export type ResponseCookie = CookieListItem & + Pick + +/** + * Subset of {@link CookieListItem}, only containing `name` and `value` + * since other cookie attributes aren't be available on a `Request`. + */ +export type RequestCookie = Pick diff --git a/packages/cookies/test/request-cookies.test.ts b/packages/cookies/test/request-cookies.test.ts index f1ffc78f..2685584b 100644 --- a/packages/cookies/test/request-cookies.test.ts +++ b/packages/cookies/test/request-cookies.test.ts @@ -3,21 +3,21 @@ import { createFormat } from '@edge-runtime/format' describe('input parsing', () => { test('single element', () => { - const request = requestWithCookies('a=1') - const cookies = new RequestCookies(request) + const headers = requestHeadersWithCookies('a=1') + const cookies = new RequestCookies(headers) expect([...cookies]).toEqual([['a', '1']]) }) test('multiple elements', () => { - const request = requestWithCookies('a=1; b=2') - const cookies = new RequestCookies(request) + const headers = requestHeadersWithCookies('a=1; b=2') + const cookies = new RequestCookies(headers) expect([...cookies]).toEqual([ ['a', '1'], ['b', '2'], ]) }) test('multiple elements followed by a semicolon', () => { - const request = requestWithCookies('a=1; b=2;') - const cookies = new RequestCookies(request) + const headers = requestHeadersWithCookies('a=1; b=2;') + const cookies = new RequestCookies(headers) expect([...cookies]).toEqual([ ['a', '1'], ['b', '2'], @@ -26,8 +26,9 @@ describe('input parsing', () => { }) test('updating a cookie', () => { - const request = requestWithCookies('a=1; b=2') - const cookies = new RequestCookies(request) + const headers = requestHeadersWithCookies('a=1; b=2') + + const cookies = new RequestCookies(headers) cookies.set('b', 'hello!') expect([...cookies]).toEqual([ ['a', '1'], @@ -36,15 +37,15 @@ test('updating a cookie', () => { }) test('deleting a cookie', () => { - const request = requestWithCookies('a=1; b=2') - const cookies = new RequestCookies(request) + const headers = requestHeadersWithCookies('a=1; b=2') + const cookies = new RequestCookies(headers) cookies.delete('b') expect([...cookies]).toEqual([['a', '1']]) }) test('adding a cookie', () => { - const request = requestWithCookies('a=1; b=2') - const cookies = new RequestCookies(request) + const headers = requestHeadersWithCookies('a=1; b=2') + const cookies = new RequestCookies(headers) cookies.set('c', '3') expect([...cookies]).toEqual([ ['a', '1'], @@ -54,8 +55,8 @@ test('adding a cookie', () => { }) test('formatting with @edge-runtime/format', () => { - const request = requestWithCookies('a=1; b=2') - const cookies = new RequestCookies(request) + const headers = requestHeadersWithCookies('a=1; b=2') + const cookies = new RequestCookies(headers) const format = createFormat() const result = format(cookies) @@ -64,10 +65,6 @@ test('formatting with @edge-runtime/format', () => { ) }) -function requestWithCookies(cookies: string) { - return new Request('https://example.vercel.sh', { - headers: { - cookie: cookies, - }, - }) +function requestHeadersWithCookies(cookies: string) { + return new Headers({ cookie: cookies }) } diff --git a/packages/cookies/test/response-cookies.test.ts b/packages/cookies/test/response-cookies.test.ts index 04e1159b..69a40144 100644 --- a/packages/cookies/test/response-cookies.test.ts +++ b/packages/cookies/test/response-cookies.test.ts @@ -2,10 +2,10 @@ import { createFormat } from '@edge-runtime/format' import { ResponseCookies } from '../src/response-cookies' it('reflect .set into `set-cookie`', async () => { - const response = new Response() - const cookies = new ResponseCookies(response) + const headers = new Headers() + const cookies = new ResponseCookies(headers) - expect(cookies.getValue('foo')).toBe(undefined) + expect(cookies.get('foo')?.value).toBe(undefined) expect(cookies.get('foo')).toEqual(undefined) cookies @@ -13,9 +13,9 @@ it('reflect .set into `set-cookie`', async () => { .set('fooz', 'barz', { path: '/test2' }) .set('fooHttpOnly', 'barHttpOnly', { httpOnly: true }) - expect(cookies.getValue('foo')).toBe('bar') + expect(cookies.get('foo')?.value).toBe('bar') expect(cookies.get('fooz')?.value).toBe('barz') - expect(cookies.getValue('fooHttpOnly')).toBe('barHttpOnly') + expect(cookies.get('fooHttpOnly')?.value).toBe('barHttpOnly') const opt1 = cookies.get('foo') expect(opt1).toEqual({ @@ -35,21 +35,21 @@ it('reflect .set into `set-cookie`', async () => { httpOnly: true, }) - expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( + expect(Object.fromEntries(headers.entries())['set-cookie']).toBe( 'foo=bar; Path=/test, fooz=barz; Path=/test2, fooHttpOnly=barHttpOnly; Path=/; HttpOnly' ) }) it('reflect .delete into `set-cookie`', async () => { - const response = new Response() - const cookies = new ResponseCookies(response) + const headers = new Headers() + const cookies = new ResponseCookies(headers) cookies.set('foo', 'bar') - expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( + expect(Object.fromEntries(headers.entries())['set-cookie']).toBe( 'foo=bar; Path=/' ) - expect(cookies.getValue('foo')).toBe('bar') + expect(cookies.get('foo')?.value).toBe('bar') expect(cookies.get('foo')).toEqual({ name: 'foo', value: 'bar', @@ -57,11 +57,11 @@ it('reflect .delete into `set-cookie`', async () => { }) cookies.set('fooz', 'barz') - expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( + expect(Object.fromEntries(headers.entries())['set-cookie']).toBe( 'foo=bar; Path=/, fooz=barz; Path=/' ) - expect(cookies.getValue('fooz')).toBe('barz') + expect(cookies.get('fooz')?.value).toBe('barz') expect(cookies.get('fooz')).toEqual({ name: 'fooz', value: 'barz', @@ -69,11 +69,11 @@ it('reflect .delete into `set-cookie`', async () => { }) cookies.delete('foo') - expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( + expect(Object.fromEntries(headers.entries())['set-cookie']).toBe( 'foo=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT, fooz=barz; Path=/' ) - expect(cookies.getValue('foo')).toBe(undefined) + expect(cookies.get('foo')?.value).toBe(undefined) expect(cookies.get('foo')).toEqual({ name: 'foo', path: '/', @@ -82,11 +82,11 @@ it('reflect .delete into `set-cookie`', async () => { cookies.delete('fooz') - expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( + expect(Object.fromEntries(headers.entries())['set-cookie']).toBe( 'foo=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT, fooz=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT' ) - expect(cookies.getValue('fooz')).toBe(undefined) + expect(cookies.get('fooz')?.value).toBe(undefined) expect(cookies.get('fooz')).toEqual({ name: 'fooz', expires: new Date(0), @@ -96,17 +96,15 @@ it('reflect .delete into `set-cookie`', async () => { it('options are not modified', async () => { const options = { maxAge: 10000 } - const response = new Response(null, { - headers: { 'content-type': 'application/json' }, - }) - const cookies = new ResponseCookies(response) + const headers = new Headers({ 'content-type': 'application/json' }) + const cookies = new ResponseCookies(headers) cookies.set('cookieName', 'cookieValue', options) expect(options).toEqual({ maxAge: 10000 }) }) test('formatting with @edge-runtime/format', () => { - const response = new Response(null) - const cookies = new ResponseCookies(response) + const headers = new Headers() + const cookies = new ResponseCookies(headers) cookies.set('a', '1', { httpOnly: true }) cookies.set('b', '2', { sameSite: 'lax' })