diff --git a/.changeset/unlucky-icons-heal.md b/.changeset/unlucky-icons-heal.md new file mode 100644 index 00000000..f42e9397 --- /dev/null +++ b/.changeset/unlucky-icons-heal.md @@ -0,0 +1,10 @@ +--- +'@edge-runtime/cookies': major +--- + +Make `RequestCookies` and `ResponseCookies` more spec compliant by resembling the [Cookie Store API](https://wicg.github.io/cookie-store). The main difference is that the methods do not return `Promise`. + +Breaking changes: + +- `ResponseCookies#get` has been renamed to `ResponseCookies#getValue` +- `ResponseCookies#getWithOptions` has been renamed to `ResponseCookies#get` diff --git a/packages/cookies/src/index.ts b/packages/cookies/src/index.ts index 9cc0b65a..bcf44ce8 100644 --- a/packages/cookies/src/index.ts +++ b/packages/cookies/src/index.ts @@ -1,3 +1,3 @@ -export type { Options } from './serialize' +export type { Cookie, CookieListItem } from './serialize' export { ResponseCookies } from './response-cookies' export { RequestCookies } from './request-cookies' diff --git a/packages/cookies/src/request-cookies.ts b/packages/cookies/src/request-cookies.ts index ef139c84..351aec93 100644 --- a/packages/cookies/src/request-cookies.ts +++ b/packages/cookies/src/request-cookies.ts @@ -1,80 +1,106 @@ -import { parseCookieString, serialize } from './serialize' +import { type Cookie, parseCookieString, serialize } from './serialize' import { cached } from './cached' /** * A class for manipulating {@link Request} cookies. */ export class RequestCookies { - private readonly headers: Headers + readonly #headers: Headers constructor(request: Request) { - this.headers = request.headers + this.#headers = request.headers } - /** - * Delete all the cookies in the cookies in the request - */ - clear(): void { - this.delete([...this.parsed().keys()]) + #cache = cached((header: string | null) => { + const parsed = header ? parseCookieString(header) : new Map() + return parsed + }) + + #parsed(): Map { + const header = this.#headers.get('cookie') + return this.#cache(header) } - /** - * Format the cookies in the request as a string for logging - */ - [Symbol.for('edge-runtime.inspect.custom')]() { - return `RequestCookies ${JSON.stringify(Object.fromEntries(this.parsed()))}` + [Symbol.iterator]() { + return this.#parsed()[Symbol.iterator]() } /** * The amount of cookies received from the client */ get size(): number { - return this.parsed().size + return this.#parsed().size } - [Symbol.iterator]() { - return this.parsed()[Symbol.iterator]() + get(...args: [name: string] | [Cookie]) { + const name = typeof args[0] === 'string' ? args[0] : args[0].name + return this.#parsed().get(name) } - private cache = cached((header: string | null) => { - const parsed = header ? parseCookieString(header) : new Map() - return parsed - }) - - private parsed(): Map { - const header = this.headers.get('cookie') - return this.cache(header) - } + getAll(...args: [name: string] | [Cookie] | [undefined]) { + const all = Array.from(this.#parsed()) + if (!args.length) { + return all + } - get(name: string) { - return this.parsed().get(name) + const name = typeof args[0] === 'string' ? args[0] : args[0]?.name + return all.filter(([n]) => n === name) } has(name: string) { - return this.parsed().has(name) + return this.#parsed().has(name) } - set(name: string, value: string): this { - const map = this.parsed() - map.set(name, value) - this.headers.set( + set(...args: [key: string, value: string] | [options: Cookie]): this { + const [key, value] = + args.length === 1 ? [args[0].name, args[0].value, args[0]] : args + + const map = this.#parsed() + map.set(key, value) + + this.#headers.set( 'cookie', - [...map].map(([key, value]) => serialize(key, value, {})).join('; ') + Array.from(map) + .map(([name, value]) => serialize({ name, value })) + .join('; ') ) return this } - delete(names: string[]): boolean[] - delete(name: string): boolean - delete(names: string | string[]): boolean | boolean[] { - const map = this.parsed() + /** + * Delete the cookies matching the passed name or names in the request. + */ + delete( + /** Name or names of the cookies to be deleted */ + names: string | string[] + ): boolean | boolean[] { + const map = this.#parsed() const result = !Array.isArray(names) ? map.delete(names) : names.map((name) => map.delete(name)) - this.headers.set( + this.#headers.set( 'cookie', - [...map].map(([key, value]) => serialize(key, value, {})).join('; ') + Array.from(map) + .map(([name, value]) => serialize({ name, value })) + .join('; ') ) return result } + + /** + * Delete all the cookies in the cookies in the request. + */ + clear(): this { + this.delete(Array.from(this.#parsed().keys())) + return this + } + + /** + * Format the cookies in the request as a string for logging + */ + [Symbol.for('edge-runtime.inspect.custom')]() { + return `RequestCookies ${JSON.stringify( + Object.fromEntries(this.#parsed()) + )}` + } } diff --git a/packages/cookies/src/response-cookies.ts b/packages/cookies/src/response-cookies.ts index f2c35316..91e07e7d 100644 --- a/packages/cookies/src/response-cookies.ts +++ b/packages/cookies/src/response-cookies.ts @@ -1,88 +1,126 @@ import { cached } from './cached' -import { type Options, parseSetCookieString, serialize } from './serialize' +import { type Cookie, parseSetCookieString, serialize } from './serialize' -type ParsedCookie = { value: string; options: Options } -export type CookieBag = Map +export type CookieBag = Map +/** + * 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 { - private readonly headers: Headers + readonly #headers: Headers constructor(response: Response) { - this.headers = response.headers + this.#headers = response.headers } - private cache = cached((_key: string | null) => { - // @ts-ignore - const headers = this.headers.getAll('set-cookie') - const map = new Map() + #cache = cached(() => { + // @ts-expect-error See https://github.com/whatwg/fetch/issues/973 + const headers = this.#headers.getAll('set-cookie') + const map = new Map() for (const header of headers) { const parsed = parseSetCookieString(header) if (parsed) { - map.set(parsed.name, { - value: parsed.value, - options: parsed?.attributes, - }) + map.set(parsed.name, parsed) } } return map }) - private parsed() { - const allCookies = this.headers.get('set-cookie') - return this.cache(allCookies) + #parsed() { + const allCookies = this.#headers.get('set-cookie') + return this.#cache(allCookies) } - set(key: string, value: string, options?: Options): this { - const map = this.parsed() - map.set(key, { value, options: normalizeCookieOptions(options || {}) }) - replace(map, this.headers) + /** + * {@link https://wicg.github.io/cookie-store/#CookieStore-get CookieStore#get} without the Promise. + */ + get(...args: [key: string] | [options: Cookie]): Cookie | 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[] { + const all = Array.from(this.#parsed().values()) + if (!args.length) { + return all + } + + const key = typeof args[0] === 'string' ? args[0] : args[0]?.name + return all.filter((c) => c.name === key) + } + + /** + * {@link https://wicg.github.io/cookie-store/#CookieStore-set CookieStore#set} without the Promise. + */ + set( + ...args: + | [key: string, value: string, cookie?: Partial] + | [options: Cookie] + ): this { + const [name, value, cookie] = + args.length === 1 ? [args[0].name, args[0].value, args[0]] : args + const map = this.#parsed() + map.set(name, normalizeCookie({ name, value, ...cookie })) + replace(map, this.#headers) return this } - delete(key: string): this { - return this.set(key, '', { expires: new Date(0) }) + /** + * {@link https://wicg.github.io/cookie-store/#CookieStore-delete CookieStore#delete} without the Promise. + */ + delete(...args: [key: string] | [options: Cookie]): this { + const name = typeof args[0] === 'string' ? args[0] : args[0].name + return this.set({ name, value: '', expires: new Date(0) }) } - get(key: string): string | undefined { - return this.getWithOptions(key)?.value + // 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 } - getWithOptions(key: string): { - value: string | undefined - options: Options - } { - const element = this.parsed().get(key) - return { value: element?.value, options: element?.options ?? {} } + /** + * 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()) + Object.fromEntries(this.#parsed()) )}` } } function replace(bag: CookieBag, headers: Headers) { headers.delete('set-cookie') - for (const [key, { value, options }] of bag) { - const serialized = serialize(key, value, options) + for (const [, value] of bag) { + const serialized = serialize(value) headers.append('set-cookie', serialized) } } -const normalizeCookieOptions = (options: Options) => { - options = Object.assign({}, options) - - if (options.maxAge) { - options.expires = new Date(Date.now() + options.maxAge * 1000) +function normalizeCookie(cookie: Cookie = { name: '', value: '' }) { + if (cookie.maxAge) { + cookie.expires = new Date(Date.now() + cookie.maxAge * 1000) } - if (options.path === null || options.path === undefined) { - options.path = '/' + if (cookie.path === null || cookie.path === undefined) { + cookie.path = '/' } - return options + return cookie } diff --git a/packages/cookies/src/serialize.ts b/packages/cookies/src/serialize.ts index a8cc5989..b3fb2140 100644 --- a/packages/cookies/src/serialize.ts +++ b/packages/cookies/src/serialize.ts @@ -1,24 +1,39 @@ import type { CookieSerializeOptions } from 'cookie' -export interface Options extends CookieSerializeOptions {} +/** + * {@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( - name: string, - value: string, - options: Options -): string { - const { expires, maxAge, domain, path, secure, httpOnly, sameSite } = options +export function serialize(cookie: Cookie): string { const attrs = [ - path ? `Path=${path}` : '', - expires ? `Expires=${expires.toUTCString()}` : '', - maxAge ? `Max-Age=${maxAge}` : '', - domain ? `Domain=${domain}` : '', - secure ? 'Secure' : '', - httpOnly ? 'HttpOnly' : '', - sameSite ? `SameSite=${sameSite}` : '', + 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}` : '', ].filter(Boolean) - return `${name}=${encodeURIComponent(value)}; ${attrs.join('; ')}` + return `${cookie.name}=${encodeURIComponent( + cookie.value ?? '' + )}; ${attrs.join('; ')}` } /** @@ -39,9 +54,7 @@ export function parseCookieString(cookie: string): Map { /** * Parse a `Set-Cookie` header value */ -export function parseSetCookieString( - setCookie: string -): undefined | { name: string; value: string; attributes: Options } { +export function parseSetCookieString(setCookie: string): undefined | Cookie { if (!setCookie) { return undefined } @@ -51,7 +64,9 @@ export function parseSetCookieString( Object.fromEntries( attributes.map(([key, value]) => [key.toLowerCase(), value]) ) - const options: Options = { + const cookie: Cookie = { + name, + value: decodeURIComponent(value), domain, ...(expires && { expires: new Date(expires) }), ...(httponly && { httpOnly: true }), @@ -61,11 +76,7 @@ export function parseSetCookieString( ...(secure && { secure: true }), } - return { - name, - value: decodeURIComponent(value), - attributes: compact(options), - } + return compact(cookie) } function compact(t: T): T { @@ -78,9 +89,9 @@ function compact(t: T): T { return newT as T } -const SAME_SITE: Options['sameSite'][] = ['strict', 'lax', 'none'] -function parseSameSite(string: string): Options['sameSite'] { +const SAME_SITE: Cookie['sameSite'][] = ['strict', 'lax', 'none'] +function parseSameSite(string: string): Cookie['sameSite'] { return SAME_SITE.includes(string as any) - ? (string as Options['sameSite']) + ? (string as Cookie['sameSite']) : undefined } diff --git a/packages/cookies/test/response-cookies.test.ts b/packages/cookies/test/response-cookies.test.ts index a699f5ba..04e1159b 100644 --- a/packages/cookies/test/response-cookies.test.ts +++ b/packages/cookies/test/response-cookies.test.ts @@ -5,36 +5,34 @@ it('reflect .set into `set-cookie`', async () => { const response = new Response() const cookies = new ResponseCookies(response) - expect(cookies.get('foo')).toBe(undefined) - expect(cookies.getWithOptions('foo')).toEqual({ - value: undefined, - options: {}, - }) + expect(cookies.getValue('foo')).toBe(undefined) + expect(cookies.get('foo')).toEqual(undefined) cookies .set('foo', 'bar', { path: '/test' }) .set('fooz', 'barz', { path: '/test2' }) .set('fooHttpOnly', 'barHttpOnly', { httpOnly: true }) - expect(cookies.get('foo')).toBe('bar') - expect(cookies.get('fooz')).toBe('barz') - expect(cookies.get('fooHttpOnly')).toBe('barHttpOnly') + expect(cookies.getValue('foo')).toBe('bar') + expect(cookies.get('fooz')?.value).toBe('barz') + expect(cookies.getValue('fooHttpOnly')).toBe('barHttpOnly') - const opt1 = cookies.getWithOptions('foo') + const opt1 = cookies.get('foo') expect(opt1).toEqual({ + name: 'foo', value: 'bar', - options: { path: '/test' }, + path: '/test', }) - expect(cookies.getWithOptions('fooz')).toEqual({ + expect(cookies.get('fooz')).toEqual({ + name: 'fooz', value: 'barz', - options: { path: '/test2' }, + path: '/test2', }) - expect(cookies.getWithOptions('fooHttpOnly')).toEqual({ + expect(cookies.get('fooHttpOnly')).toEqual({ + name: 'fooHttpOnly', value: 'barHttpOnly', - options: { - path: '/', - httpOnly: true, - }, + path: '/', + httpOnly: true, }) expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( @@ -51,10 +49,11 @@ it('reflect .delete into `set-cookie`', async () => { 'foo=bar; Path=/' ) - expect(cookies.get('foo')).toBe('bar') - expect(cookies.getWithOptions('foo')).toEqual({ + expect(cookies.getValue('foo')).toBe('bar') + expect(cookies.get('foo')).toEqual({ + name: 'foo', value: 'bar', - options: { path: '/' }, + path: '/', }) cookies.set('fooz', 'barz') @@ -62,10 +61,11 @@ it('reflect .delete into `set-cookie`', async () => { 'foo=bar; Path=/, fooz=barz; Path=/' ) - expect(cookies.get('fooz')).toBe('barz') - expect(cookies.getWithOptions('fooz')).toEqual({ + expect(cookies.getValue('fooz')).toBe('barz') + expect(cookies.get('fooz')).toEqual({ + name: 'fooz', value: 'barz', - options: { path: '/' }, + path: '/', }) cookies.delete('foo') @@ -73,10 +73,11 @@ it('reflect .delete into `set-cookie`', async () => { 'foo=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT, fooz=barz; Path=/' ) - expect(cookies.get('foo')).toBe('') - expect(cookies.getWithOptions('foo')).toEqual({ - value: '', - options: { expires: new Date(0), path: '/' }, + expect(cookies.getValue('foo')).toBe(undefined) + expect(cookies.get('foo')).toEqual({ + name: 'foo', + path: '/', + expires: new Date(0), }) cookies.delete('fooz') @@ -85,10 +86,11 @@ it('reflect .delete into `set-cookie`', async () => { 'foo=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT, fooz=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT' ) - expect(cookies.get('fooz')).toBe('') - expect(cookies.getWithOptions('fooz')).toEqual({ - value: '', - options: { expires: new Date(0), path: '/' }, + expect(cookies.getValue('fooz')).toBe(undefined) + expect(cookies.get('fooz')).toEqual({ + name: 'fooz', + expires: new Date(0), + path: '/', }) }) @@ -111,6 +113,6 @@ test('formatting with @edge-runtime/format', () => { const format = createFormat() const result = format(cookies) expect(result).toMatchInlineSnapshot( - `"ResponseCookies {\\"a\\":{\\"value\\":\\"1\\",\\"options\\":{\\"httpOnly\\":true,\\"path\\":\\"/\\"}},\\"b\\":{\\"value\\":\\"2\\",\\"options\\":{\\"path\\":\\"/\\",\\"sameSite\\":\\"lax\\"}}}"` + `"ResponseCookies {\\"a\\":{\\"name\\":\\"a\\",\\"value\\":\\"1\\",\\"httpOnly\\":true,\\"path\\":\\"/\\"},\\"b\\":{\\"name\\":\\"b\\",\\"value\\":\\"2\\",\\"path\\":\\"/\\",\\"sameSite\\":\\"lax\\"}}"` ) })