Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(cookies): align RequestCookies and ResponseCookies #181

Merged
5 changes: 5 additions & 0 deletions .changeset/breezy-geese-sneeze.md
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 2 additions & 2 deletions packages/cookies/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
21 changes: 11 additions & 10 deletions packages/cookies/src/request-cookies.ts
Original file line number Diff line number Diff line change
@@ -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
Comment on lines -10 to -11
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I used that type to hint that we need a request here and not a response

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

new RequestCookies(request) looks nicer than new RequestCookies(request.headers), the latter feels kinda leaky to me. wdyt?

constructor(requestHeaders: Headers) {
this.#headers = requestHeaders
}

#cache = cached((header: string | null) => {
Expand All @@ -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
Expand 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',
Expand Down
49 changes: 17 additions & 32 deletions packages/cookies/src/response-cookies.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,23 @@
import type { ResponseCookie } from './types'
import { cached } from './cached'
import { type Cookie, parseSetCookieString, serialize } from './serialize'

export type CookieBag = Map<string, Cookie>
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
Comment on lines +13 to +14
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as RequestCookies

}

#cache = cached(() => {
// @ts-expect-error See https://github.com/whatwg/fetch/issues/973
const headers = this.#headers.getAll('set-cookie')
const map = new Map<string, Cookie>()
const map = new Map<string, ResponseCookie>()

for (const header of headers) {
const parsed = parseSetCookieString(header)
Expand All @@ -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
Expand All @@ -59,8 +63,8 @@ export class ResponseCookies {
*/
set(
...args:
| [key: string, value: string, cookie?: Partial<Cookie>]
| [options: Cookie]
| [key: string, value: string, cookie?: Partial<ResponseCookie>]
| [options: ResponseCookie]
): this {
const [name, value, cookie] =
args.length === 1 ? [args[0].name, args[0].value, args[0]] : args
Expand All @@ -74,46 +78,27 @@ 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())
)}`
}
}

function replace(bag: CookieBag, headers: Headers) {
function replace(bag: Map<string, ResponseCookie>, headers: Headers) {
headers.delete('set-cookie')
for (const [, value] of bag) {
const serialized = serialize(value)
headers.append('set-cookie', serialized)
}
}

function normalizeCookie(cookie: Cookie = { name: '', value: '' }) {
function normalizeCookie(cookie: ResponseCookie = { name: '', value: '' }) {
if (cookie.maxAge) {
cookie.expires = new Date(Date.now() + cookie.maxAge * 1000)
}
Expand Down
57 changes: 19 additions & 38 deletions packages/cookies/src/serialize.ts
Original file line number Diff line number Diff line change
@@ -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<CookieSerializeOptions, 'httpOnly' | 'maxAge' | 'priority'>

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<string, string> {
const map = new Map<string, string>()
Expand All @@ -54,7 +32,9 @@ export function parseCookieString(cookie: string): Map<string, string> {
/**
* Parse a `Set-Cookie` header value
*/
export function parseSetCookieString(setCookie: string): undefined | Cookie {
export function parseSetCookieString(
setCookie: string
): undefined | ResponseCookie {
if (!setCookie) {
return undefined
}
Expand All @@ -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,
Expand All @@ -89,9 +69,10 @@ function compact<T>(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
}
29 changes: 29 additions & 0 deletions packages/cookies/src/types.ts
Original file line number Diff line number Diff line change
@@ -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<CookieSerializeOptions, 'httpOnly' | 'maxAge' | 'priority'>

/**
* Subset of {@link CookieListItem}, only containing `name` and `value`
* since other cookie attributes aren't be available on a `Request`.
*/
export type RequestCookie = Pick<CookieListItem, 'name' | 'value'>
37 changes: 17 additions & 20 deletions packages/cookies/test/request-cookies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand All @@ -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'],
Expand All @@ -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'],
Expand All @@ -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)
Expand All @@ -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 })
}
Loading