From 4d20beb7c55b1467347e05822c904a67b59ddb05 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Orb=C3=A1n?= Date: Thu, 27 Oct 2022 09:20:39 +0200 Subject: [PATCH] BREAKING CHANGE: feat(edge): split `NextCookies` to `RequestCookies` and `ResponseCookies` (#41526) Ref: [Slack thread](https://vercel.slack.com/archives/C035J346QQL/p1666056382299069?thread_ts=1666041444.633059&cid=C035J346QQL), [docs update](https://github.com/vercel/front/pull/17090) Spec: https://wicg.github.io/cookie-store/ BREAKING CHANGE: Ref: https://github.com/vercel/edge-runtime/pull/177, https://github.com/vercel/edge-runtime/pull/181 ## Bug - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Errors have a helpful link attached, see `contributing.md` ## Feature - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have a helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `pnpm lint` - [ ] The "examples guidelines" are followed from [our contributing doc](https://github.com/vercel/next.js/blob/canary/contributing/examples/adding-examples.md) --- docs/advanced-features/middleware.md | 40 +++-- docs/api-reference/next/server.md | 17 ++- errors/middleware-upgrade-guide.md | 7 +- .../components/request-async-storage.ts | 9 +- packages/next/server/app-render.tsx | 44 +++--- .../next/server/web/spec-extension/cookies.ts | 143 ------------------ .../web/spec-extension/cookies/cached.ts | 16 ++ .../web/spec-extension/cookies/index.ts | 4 + .../spec-extension/cookies/request-cookies.ts | 102 +++++++++++++ .../cookies/response-cookies.ts | 98 ++++++++++++ .../web/spec-extension/cookies/serialize.ts | 76 ++++++++++ .../web/spec-extension/cookies/types.ts | 113 ++++++++++++++ .../next/server/web/spec-extension/request.ts | 6 +- .../server/web/spec-extension/response.ts | 6 +- packages/next/server/web/types.ts | 11 -- .../app-dir/app/app/hooks/use-cookies/page.js | 4 +- test/unit/web-runtime/cookies.test.ts | 77 ---------- test/unit/web-runtime/next-cookies.test.ts | 140 ----------------- .../web-runtime/next-response-cookies.test.ts | 107 +++++++++++++ 19 files changed, 589 insertions(+), 431 deletions(-) delete mode 100644 packages/next/server/web/spec-extension/cookies.ts create mode 100644 packages/next/server/web/spec-extension/cookies/cached.ts create mode 100644 packages/next/server/web/spec-extension/cookies/index.ts create mode 100644 packages/next/server/web/spec-extension/cookies/request-cookies.ts create mode 100644 packages/next/server/web/spec-extension/cookies/response-cookies.ts create mode 100644 packages/next/server/web/spec-extension/cookies/serialize.ts create mode 100644 packages/next/server/web/spec-extension/cookies/types.ts delete mode 100644 test/unit/web-runtime/cookies.test.ts delete mode 100644 test/unit/web-runtime/next-cookies.test.ts create mode 100644 test/unit/web-runtime/next-response-cookies.test.ts diff --git a/docs/advanced-features/middleware.md b/docs/advanced-features/middleware.md index 4e719de1a13b..8b4e56a9c99d 100644 --- a/docs/advanced-features/middleware.md +++ b/docs/advanced-features/middleware.md @@ -148,7 +148,10 @@ To produce a response from Middleware, you should `rewrite` to a route ([Page](/ ## Using Cookies -The `cookies` API extends [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) and allows you to `get`, `set`, and `delete` cookies. It also includes methods like [entries](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/entries) and [values](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map/entries). +Cookies are regular headers. On a `Request`, they are stored in the `Cookie` header. On a `Response` they are in the `Set-Cookie` header. Next.js provides a convenient way to access and manipulate these cookies through the `cookies` extension on `NextRequest` and `NextResponse`. + +1. For incoming requests, `cookies` comes with the following methods: `get`, `getAll`, `set`, and `delete` cookies. You can check for the existence of a cookie with `has` or remove all cookies with `clear`. +2. For outgoing responses, `cookies` have the following methods `get`, `getAll`, `set`, and `delete`. ```typescript // middleware.ts @@ -156,23 +159,28 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' export function middleware(request: NextRequest) { - // Setting cookies on the response + // Assume a "Cookie:vercel=fast" header to be present on the incoming request + // Getting cookies from the request using the `RequestCookies` API + const cookie = request.cookies.get('nextjs')?.value + console.log(cookie) // => 'fast' + const allCookies = request.cookies.getAll() + console.log(allCookies) // => [{ name: 'vercel', value: 'fast' }] + + response.cookies.has('nextjs') // => true + response.cookies.delete('nextjs') + response.cookies.has('nextjs') // => false + + // Setting cookies on the response using the `ResponseCookies` API const response = NextResponse.next() response.cookies.set('vercel', 'fast') - response.cookies.set('vercel', 'fast', { path: '/test' }) - - // Getting cookies from the request - const cookie = request.cookies.get('vercel') - console.log(cookie) // => 'fast' - const allCookies = request.cookies.entries() - console.log(allCookies) // => [{ key: 'vercel', value: 'fast' }] - const { value, options } = response.cookies.getWithOptions('vercel') - console.log(value) // => 'fast' - console.log(options) // => { Path: '/test' } - - // Deleting cookies - response.cookies.delete('vercel') - response.cookies.clear() + response.cookies.set({ + name: 'vercel', + value: 'fast', + path: '/test', + }) + const cookie = response.cookies.get('vercel') + console.log(cookie) // => { name: 'vercel', value: 'fast', Path: '/test' } + // The outgoing response will have a `Set-Cookie:vercel=fast;path=/test` header. return response } diff --git a/docs/api-reference/next/server.md b/docs/api-reference/next/server.md index 5e83b10835e2..b48c241edd03 100644 --- a/docs/api-reference/next/server.md +++ b/docs/api-reference/next/server.md @@ -10,7 +10,15 @@ description: Learn about the server-only helpers for Middleware and Edge API Rou The `NextRequest` object is an extension of the native [`Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) interface, with the following added methods and properties: -- `cookies` - A [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) with cookies from the `Request`. See [Using cookies in Middleware](/docs/advanced-features/middleware#using-cookies) +- `cookies` - A [RequestCookies](https://edge-runtime.vercel.app/packages/cookies#for-request) instance with cookies from the `Request`. It reads/mutates the `Cookie` header of the request. See also [Using cookies in Middleware](/docs/advanced-features/middleware#using-cookies). + + - `get` - A method that takes a cookie `name` and returns an object with `name` and `value`. If a cookie with `name` isn't found, it returns `undefined`. If multiple cookies match, it will only return the first match. + - `getAll` - A method that is similar to `get`, but returns a list of all the cookies with a matching `name`. If `name` is unspecified, it returns all the available cookies. + - `set` - A method that takes an object with properties of `CookieListItem` as defined in the [W3C CookieStore API](https://wicg.github.io/cookie-store/#dictdef-cookielistitem) spec. + - `delete` - A method that takes either a cookie `name` or a list of names. and removes the cookies matching the name(s). Returns `true` for deleted and `false` for undeleted cookies. + - `has` - A method that takes a cookie `name` and returns a `boolean` based on if the cookie exists (`true`) or not (`false`). + - `clear` - A method that takes no argument and will effectively remove the `Cookie` header. + - `nextUrl`: Includes an extended, parsed, URL object that gives you access to Next.js specific properties such as `pathname`, `basePath`, `trailingSlash` and `i18n`. Includes the following properties: - `basePath` (`string`) - `buildId` (`string || undefined`) @@ -74,7 +82,12 @@ The `NextResponse` class extends the native [`Response`](https://developer.mozil Public methods are available on an instance of the `NextResponse` class. Depending on your use case, you can create an instance and assign to a variable, then access the following public methods: -- `cookies` - A [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) with the cookies in the `Response` +- `cookies` - A [ResponseCookies](https://edge-runtime.vercel.app/packages/cookies#for-response) instance with the cookies from the `Response`. It a + A [ResponseCooies](https://edge-runtime.vercel.app/packages/cookies#for-response) instance with cookies from the `Response`. It reads/mutates the `Set-Cookie` header of the response. See also [Using cookies in Middleware](/docs/advanced-features/middleware#using-cookies). + - `get` - A method that takes a cookie `name` and returns an object with `name` and `value`. If a cookie with `name` isn't found, it returns `undefined`. If multiple cookies match, it will only return the first match. + - `getAll` - A method that is similar to `get`, but returns a list of all the cookies with a matching `name`. If `name` is unspecified, it returns all the available cookies. + - `set` - A method that takes an object with properties of `CookieListItem` as defined in the [W3C CookieStore API](https://wicg.github.io/cookie-store/#dictdef-cookielistitem) spec. + - `delete` - A method that takes either a cookie `name` or a list of names. and removes the cookies matching the name(s). Returns `true` for deleted and `false` for undeleted cookies. ### Static Methods diff --git a/errors/middleware-upgrade-guide.md b/errors/middleware-upgrade-guide.md index 4f26e5730676..4bcf66ce0ecd 100644 --- a/errors/middleware-upgrade-guide.md +++ b/errors/middleware-upgrade-guide.md @@ -241,16 +241,13 @@ export function middleware() { response.cookies.set('nextjs', 'awesome', { path: '/test' }) // get all the details of a cookie - const { value, options } = response.cookies.getWithOptions('vercel') + const { value, ...options } = response.cookies.getWithOptions('vercel') console.log(value) // => 'fast' - console.log(options) // => { Path: '/test' } + console.log(options) // => { name: 'vercel', Path: '/test' } // deleting a cookie will mark it as expired response.cookies.delete('vercel') - // clear all cookies means mark all of them as expired - response.cookies.clear() - return response } ``` diff --git a/packages/next/client/components/request-async-storage.ts b/packages/next/client/components/request-async-storage.ts index 426ce78434ac..6b31ab2f371d 100644 --- a/packages/next/client/components/request-async-storage.ts +++ b/packages/next/client/components/request-async-storage.ts @@ -1,9 +1,12 @@ import type { AsyncLocalStorage } from 'async_hooks' -import type { NextCookies } from '../../server/web/spec-extension/cookies' +import type { + ReadonlyHeaders, + ReadonlyRequestCookies, +} from '../../server/app-render' export interface RequestStore { - headers: Headers - cookies: NextCookies + headers: ReadonlyHeaders + cookies: ReadonlyRequestCookies previewData: any } diff --git a/packages/next/server/app-render.tsx b/packages/next/server/app-render.tsx index 9ead0f7e566e..fe7f7f65897c 100644 --- a/packages/next/server/app-render.tsx +++ b/packages/next/server/app-render.tsx @@ -33,7 +33,7 @@ import { ServerInsertedHTMLContext } from '../shared/lib/server-inserted-html' import { stripInternalQueries } from './internal-utils' import type { ComponentsType } from '../build/webpack/loaders/next-app-loader' import { REDIRECT_ERROR_CODE } from '../client/components/redirect' -import { NextCookies } from './web/spec-extension/cookies' +import { RequestCookies } from './web/spec-extension/cookies' import { DYNAMIC_ERROR_CODE } from '../client/components/hooks-server-context' import { NOT_FOUND_ERROR_CODE } from '../client/components/not-found' import { HeadManagerContext } from '../shared/lib/head-manager-context' @@ -45,7 +45,7 @@ const INTERNAL_HEADERS_INSTANCE = Symbol('internal for headers readonly') function readonlyHeadersError() { return new Error('ReadonlyHeaders cannot be modified') } -class ReadonlyHeaders { +export class ReadonlyHeaders { [INTERNAL_HEADERS_INSTANCE]: Headers entries: Headers['entries'] @@ -83,20 +83,17 @@ class ReadonlyHeaders { } const INTERNAL_COOKIES_INSTANCE = Symbol('internal for cookies readonly') -function readonlyCookiesError() { - return new Error('ReadonlyCookies cannot be modified') +class ReadonlyRequestCookiesError extends Error { + message = + 'ReadonlyRequestCookies cannot be modified. Read more: https://nextjs.org/api-reference/cookies' } -class ReadonlyNextCookies { - [INTERNAL_COOKIES_INSTANCE]: NextCookies +export class ReadonlyRequestCookies { + [INTERNAL_COOKIES_INSTANCE]: RequestCookies - entries: NextCookies['entries'] - forEach: NextCookies['forEach'] - get: NextCookies['get'] - getWithOptions: NextCookies['getWithOptions'] - has: NextCookies['has'] - keys: NextCookies['keys'] - values: NextCookies['values'] + get: RequestCookies['get'] + getAll: RequestCookies['getAll'] + has: RequestCookies['has'] constructor(request: { headers: { @@ -105,29 +102,26 @@ class ReadonlyNextCookies { }) { // Since `new Headers` uses `this.append()` to fill the headers object ReadonlyHeaders can't extend from Headers directly as it would throw. // Request overridden to not have to provide a fully request object. - const cookiesInstance = new NextCookies(request as Request) + const cookiesInstance = new RequestCookies(request.headers as Headers) this[INTERNAL_COOKIES_INSTANCE] = cookiesInstance - this.entries = cookiesInstance.entries.bind(cookiesInstance) - this.forEach = cookiesInstance.forEach.bind(cookiesInstance) this.get = cookiesInstance.get.bind(cookiesInstance) - this.getWithOptions = cookiesInstance.getWithOptions.bind(cookiesInstance) + this.getAll = cookiesInstance.getAll.bind(cookiesInstance) this.has = cookiesInstance.has.bind(cookiesInstance) - this.keys = cookiesInstance.keys.bind(cookiesInstance) - this.values = cookiesInstance.values.bind(cookiesInstance) } + [Symbol.iterator]() { - return this[INTERNAL_COOKIES_INSTANCE][Symbol.iterator]() + return (this[INTERNAL_COOKIES_INSTANCE] as any)[Symbol.iterator]() } clear() { - throw readonlyCookiesError() + throw new ReadonlyRequestCookiesError() } delete() { - throw readonlyCookiesError() + throw new ReadonlyRequestCookiesError() } set() { - throw readonlyCookiesError() + throw new ReadonlyRequestCookiesError() } } @@ -1662,7 +1656,7 @@ export async function renderToHTMLOrFlight( ) let cachedHeadersInstance: ReadonlyHeaders | undefined - let cachedCookiesInstance: ReadonlyNextCookies | undefined + let cachedCookiesInstance: ReadonlyRequestCookies | undefined const requestStore = { get headers() { @@ -1675,7 +1669,7 @@ export async function renderToHTMLOrFlight( }, get cookies() { if (!cachedCookiesInstance) { - cachedCookiesInstance = new ReadonlyNextCookies({ + cachedCookiesInstance = new ReadonlyRequestCookies({ headers: { get: (key) => { if (key !== 'cookie') { diff --git a/packages/next/server/web/spec-extension/cookies.ts b/packages/next/server/web/spec-extension/cookies.ts deleted file mode 100644 index 13c7dc46c291..000000000000 --- a/packages/next/server/web/spec-extension/cookies.ts +++ /dev/null @@ -1,143 +0,0 @@ -import type { CookieSerializeOptions } from '../types' - -import cookie from 'next/dist/compiled/cookie' - -type GetWithOptionsOutput = { - value: string | undefined - options: { [key: string]: string } -} - -const normalizeCookieOptions = (options: CookieSerializeOptions) => { - options = Object.assign({}, options) - - if (options.maxAge) { - options.expires = new Date(Date.now() + options.maxAge * 1000) - } - - if (options.path == null) { - options.path = '/' - } - - return options -} - -const serializeValue = (value: unknown) => - typeof value === 'object' ? `j:${JSON.stringify(value)}` : String(value) - -const serializeExpiredCookie = ( - key: string, - options: CookieSerializeOptions = {} -) => - cookie.serialize(key, '', { - expires: new Date(0), - path: '/', - ...options, - }) - -const deserializeCookie = (input: Request | Response): string[] => { - const value = input.headers.get('set-cookie') - return value !== undefined && value !== null ? value.split(', ') : [] -} - -const serializeCookie = (input: string[]) => input.join(', ') - -export class Cookies extends Map { - constructor(input?: string | null) { - const parsedInput = typeof input === 'string' ? cookie.parse(input) : {} - super(Object.entries(parsedInput)) - } - set(key: string, value: unknown, options: CookieSerializeOptions = {}) { - return super.set( - key, - cookie.serialize( - key, - serializeValue(value), - normalizeCookieOptions(options) - ) - ) - } - [Symbol.for('edge-runtime.inspect.custom')]() { - return Object.fromEntries(this.entries()) - } -} - -export class NextCookies extends Cookies { - response: Request | Response - - constructor(response: Request | Response) { - super(response.headers.get('cookie')) - this.response = response - } - get = (...args: Parameters) => { - return this.getWithOptions(...args).value - } - getWithOptions = ( - ...args: Parameters - ): GetWithOptionsOutput => { - const raw = super.get(...args) - if (typeof raw !== 'string') return { value: raw, options: {} } - const { [args[0]]: value, ...options } = cookie.parse(raw) - return { value, options } - } - set = (...args: Parameters) => { - const isAlreadyAdded = super.has(args[0]) - - super.set(...args) - const currentCookie = super.get(args[0]) - - if (typeof currentCookie !== 'string') { - throw new Error( - `Invariant: failed to generate cookie for ${JSON.stringify(args)}` - ) - } - - if (isAlreadyAdded) { - const setCookie = serializeCookie( - deserializeCookie(this.response).filter( - (value) => !value.startsWith(`${args[0]}=`) - ) - ) - - if (setCookie) { - this.response.headers.set( - 'set-cookie', - [currentCookie, setCookie].join(', ') - ) - } else { - this.response.headers.set('set-cookie', currentCookie) - } - } else { - this.response.headers.append('set-cookie', currentCookie) - } - - return this - } - delete = (key: string, options: CookieSerializeOptions = {}) => { - const isDeleted = super.delete(key) - - if (isDeleted) { - const setCookie = serializeCookie( - deserializeCookie(this.response).filter( - (value) => !value.startsWith(`${key}=`) - ) - ) - const expiredCookie = serializeExpiredCookie(key, options) - this.response.headers.set( - 'set-cookie', - [expiredCookie, setCookie].join(', ') - ) - } - - return isDeleted - } - clear = (options: CookieSerializeOptions = {}) => { - const expiredCookies = Array.from(super.keys()) - .map((key) => serializeExpiredCookie(key, options)) - .join(', ') - - if (expiredCookies) this.response.headers.set('set-cookie', expiredCookies) - return super.clear() - } -} - -export { CookieSerializeOptions } diff --git a/packages/next/server/web/spec-extension/cookies/cached.ts b/packages/next/server/web/spec-extension/cookies/cached.ts new file mode 100644 index 000000000000..34cbc47e9d7b --- /dev/null +++ b/packages/next/server/web/spec-extension/cookies/cached.ts @@ -0,0 +1,16 @@ +/** + * A simple caching behavior. + * We cache the result based on the key `K` + * which uses referential equality, to avoid re-computing + * the result for the same key. + */ +export function cached(generate: (key: K) => V) { + let cache: { key: K; value: V } | undefined = undefined + return (key: K) => { + if (cache?.key !== key) { + cache = { key, value: generate(key) } + } + + return cache.value + } +} diff --git a/packages/next/server/web/spec-extension/cookies/index.ts b/packages/next/server/web/spec-extension/cookies/index.ts new file mode 100644 index 000000000000..c0bce5e3f452 --- /dev/null +++ b/packages/next/server/web/spec-extension/cookies/index.ts @@ -0,0 +1,4 @@ +// TODO: use `@edge-runtime/cookies` +export type { CookieListItem, RequestCookie, ResponseCookie } from './types' +export { RequestCookies } from './request-cookies' +export { ResponseCookies } from './response-cookies' diff --git a/packages/next/server/web/spec-extension/cookies/request-cookies.ts b/packages/next/server/web/spec-extension/cookies/request-cookies.ts new file mode 100644 index 000000000000..fafa498bdf1a --- /dev/null +++ b/packages/next/server/web/spec-extension/cookies/request-cookies.ts @@ -0,0 +1,102 @@ +import type { RequestCookie } from './types' +import { parseCookieString, serialize } from './serialize' + +/** + * A class for manipulating {@link Request} cookies (`Cookie` header). + */ +export class RequestCookies { + readonly _headers: Headers + _parsed: Map = new Map() + + constructor(requestHeaders: Headers) { + this._headers = requestHeaders + const header = requestHeaders.get('cookie') + if (header) { + const parsed = parseCookieString(header) + for (const [name, value] of parsed) { + this._parsed.set(name, { name, value }) + } + } + } + + [Symbol.iterator]() { + return this._parsed[Symbol.iterator]() + } + + /** + * The amount of cookies received from the client + */ + get size(): number { + return this._parsed.size + } + + 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] | [RequestCookie] | []) { + const all = Array.from(this._parsed) + if (!args.length) { + return all.map(([_, value]) => value) + } + + const name = typeof args[0] === 'string' ? args[0] : args[0]?.name + return all.filter(([n]) => n === name).map(([_, value]) => value) + } + + has(name: string) { + return this._parsed.has(name) + } + + 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(name, { name, value }) + + this._headers.set( + 'cookie', + Array.from(map) + .map(([_, v]) => serialize(v)) + .join('; ') + ) + return this + } + + /** + * 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( + 'cookie', + Array.from(map) + .map(([_, value]) => serialize(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/next/server/web/spec-extension/cookies/response-cookies.ts b/packages/next/server/web/spec-extension/cookies/response-cookies.ts new file mode 100644 index 000000000000..79207d7b1d32 --- /dev/null +++ b/packages/next/server/web/spec-extension/cookies/response-cookies.ts @@ -0,0 +1,98 @@ +import type { ResponseCookie } from './types' +import { parseSetCookieString, serialize } from './serialize' + +function replace(bag: Map, headers: Headers) { + headers.delete('set-cookie') + for (const [, value] of bag) { + const serialized = serialize(value) + headers.append('set-cookie', serialized) + } +} + +function normalizeCookie(cookie: ResponseCookie = { name: '', value: '' }) { + if (cookie.maxAge) { + cookie.expires = new Date(Date.now() + cookie.maxAge * 1000) + } + + if (cookie.path === null || cookie.path === undefined) { + cookie.path = '/' + } + + return cookie +} + +/** + * 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 + _parsed: Map = new Map() + + constructor(responseHeaders: Headers) { + this._headers = responseHeaders + // @ts-expect-error See https://github.com/whatwg/fetch/issues/973 + const headers = this._headers.getAll('set-cookie') + + for (const header of headers) { + const parsed = parseSetCookieString(header) + if (parsed) { + this._parsed.set(parsed.name, parsed) + } + } + } + + /** + * {@link https://wicg.github.io/cookie-store/#CookieStore-get CookieStore#get} without the Promise. + */ + 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: ResponseCookie] | [] + ): ResponseCookie[] { + 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: ResponseCookie] + ): 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 + } + + /** + * {@link https://wicg.github.io/cookie-store/#CookieStore-delete CookieStore#delete} without the Promise. + */ + 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) }) + } + + [Symbol.for('edge-runtime.inspect.custom')]() { + return `ResponseCookies ${JSON.stringify(Object.fromEntries(this._parsed))}` + } +} diff --git a/packages/next/server/web/spec-extension/cookies/serialize.ts b/packages/next/server/web/spec-extension/cookies/serialize.ts new file mode 100644 index 000000000000..aac941f19231 --- /dev/null +++ b/packages/next/server/web/spec-extension/cookies/serialize.ts @@ -0,0 +1,76 @@ +import type { RequestCookie, ResponseCookie } from './types' + +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 ResponseCookie['sameSite']) + : undefined +} + +function compact(t: T): T { + const newT = {} as Partial + for (const key in t) { + if (t[key]) { + newT[key] = t[key] + } + } + return newT as T +} + +export function serialize(c: ResponseCookie | RequestCookie): string { + const attrs = [ + '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 `${c.name}=${encodeURIComponent(c.value ?? '')}; ${attrs.join('; ')}` +} + +/** + * Parse a `Cookie` or `Set-Cookie header value + */ +export function parseCookieString(cookie: string): Map { + const map = new Map() + + for (const pair of cookie.split(/; */)) { + if (!pair) continue + const [key, value] = pair.split('=', 2) + map.set(key, decodeURIComponent(value ?? 'true')) + } + + return map +} + +/** + * Parse a `Set-Cookie` header value + */ +export function parseSetCookieString( + setCookie: string +): undefined | ResponseCookie { + if (!setCookie) { + return undefined + } + + const [[name, value], ...attributes] = parseCookieString(setCookie) + const { domain, expires, httponly, maxage, path, samesite, secure } = + Object.fromEntries(attributes.map(([key, v]) => [key.toLowerCase(), v])) + const cookie: ResponseCookie = { + name, + value: decodeURIComponent(value), + domain, + ...(expires && { expires: new Date(expires) }), + ...(httponly && { httpOnly: true }), + ...(typeof maxage === 'string' && { maxAge: Number(maxage) }), + path, + ...(samesite && { sameSite: parseSameSite(samesite) }), + ...(secure && { secure: true }), + } + + return compact(cookie) +} diff --git a/packages/next/server/web/spec-extension/cookies/types.ts b/packages/next/server/web/spec-extension/cookies/types.ts new file mode 100644 index 000000000000..a74a736dfe0b --- /dev/null +++ b/packages/next/server/web/spec-extension/cookies/types.ts @@ -0,0 +1,113 @@ +export interface CookieSerializeOptions { + /** + * Specifies the value for the Domain Set-Cookie attribute. By default, no + * domain is set, and most clients will consider the cookie to apply to only + * the current domain. + */ + domain?: string + + /** + * Specifies a function that will be used to encode a cookie's value. Since + * value of a cookie has a limited character set (and must be a simple + * string), this function can be used to encode a value into a string suited + * for a cookie's value. + * + * The default function is the global `encodeURIComponent`, which will + * encode a JavaScript string into UTF-8 byte sequences and then URL-encode + * any that fall outside of the cookie range. + */ + encode?(val: string): string + + /** + * Specifies the `Date` object to be the value for the `Expires` + * `Set-Cookie` attribute. By default, no expiration is set, and most + * clients will consider this a "non-persistent cookie" and will delete it + * on a condition like exiting a web browser application. + * + * *Note* the cookie storage model specification states that if both + * `expires` and `maxAge` are set, then `maxAge` takes precedence, but it is + * possible not all clients by obey this, so if both are set, they should + * point to the same date and time. + */ + expires?: Date + /** + * Specifies the boolean value for the `HttpOnly` `Set-Cookie` attribute. + * When truthy, the `HttpOnly` attribute is set, otherwise it is not. By + * default, the `HttpOnly` attribute is not set. + * + * *Note* be careful when setting this to true, as compliant clients will + * not allow client-side JavaScript to see the cookie in `document.cookie`. + */ + httpOnly?: boolean + /** + * Specifies the number (in seconds) to be the value for the `Max-Age` + * `Set-Cookie` attribute. The given number will be converted to an integer + * by rounding down. By default, no maximum age is set. + * + * *Note* the cookie storage model specification states that if both + * `expires` and `maxAge` are set, then `maxAge` takes precedence, but it is + * possible not all clients by obey this, so if both are set, they should + * point to the same date and time. + */ + maxAge?: number + /** + * Specifies the value for the `Path` `Set-Cookie` attribute. By default, + * the path is considered the "default path". + */ + path?: string + /** + * Specifies the boolean or string to be the value for the `SameSite` + * `Set-Cookie` attribute. + * + * - `true` will set the `SameSite` attribute to `Strict` for strict same + * site enforcement. + * - `false` will not set the `SameSite` attribute. + * - `'lax'` will set the `SameSite` attribute to Lax for lax same site + * enforcement. + * - `'strict'` will set the `SameSite` attribute to Strict for strict same + * site enforcement. + * - `'none'` will set the SameSite attribute to None for an explicit + * cross-site cookie. + */ + sameSite?: boolean | 'lax' | 'strict' | 'none' + /** + * Specifies the boolean value for the `Secure` `Set-Cookie` attribute. When + * truthy, the `Secure` attribute is set, otherwise it is not. By default, + * the `Secure` attribute is not set. + * + * *Note* be careful when setting this to `true`, as compliant clients will + * not send the cookie back to the server in the future if the browser does + * not have an HTTPS connection. + */ + secure?: boolean +} + +/** + * {@link https://wicg.github.io/cookie-store/#dictdef-cookielistitem CookieListItem} + * as specified by W3C. + */ +export interface CookieListItem + extends Partial< + 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 & + Partial> + +/** + * 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/next/server/web/spec-extension/request.ts b/packages/next/server/web/spec-extension/request.ts index 6fae8c117510..261aee3977a0 100644 --- a/packages/next/server/web/spec-extension/request.ts +++ b/packages/next/server/web/spec-extension/request.ts @@ -3,13 +3,13 @@ import type { RequestData } from '../types' import { NextURL } from '../next-url' import { toNodeHeaders, validateURL } from '../utils' import { RemovedUAError, RemovedPageError } from '../error' -import { NextCookies } from './cookies' +import { RequestCookies } from './cookies' export const INTERNALS = Symbol('internal request') export class NextRequest extends Request { [INTERNALS]: { - cookies: NextCookies + cookies: RequestCookies geo: RequestData['geo'] ip?: string url: NextURL @@ -21,7 +21,7 @@ export class NextRequest extends Request { validateURL(url) super(url, init) this[INTERNALS] = { - cookies: new NextCookies(this), + cookies: new RequestCookies(this.headers), geo: init.geo || {}, ip: init.ip, url: new NextURL(url, { diff --git a/packages/next/server/web/spec-extension/response.ts b/packages/next/server/web/spec-extension/response.ts index 386353d897ba..808d0dd00e36 100644 --- a/packages/next/server/web/spec-extension/response.ts +++ b/packages/next/server/web/spec-extension/response.ts @@ -2,7 +2,7 @@ import type { I18NConfig } from '../../config-shared' import { NextURL } from '../next-url' import { toNodeHeaders, validateURL } from '../utils' -import { NextCookies } from './cookies' +import { ResponseCookies } from './cookies' const INTERNALS = Symbol('internal response') const REDIRECTS = new Set([301, 302, 303, 307, 308]) @@ -28,7 +28,7 @@ function handleMiddlewareField( export class NextResponse extends Response { [INTERNALS]: { - cookies: NextCookies + cookies: ResponseCookies url?: NextURL } @@ -36,7 +36,7 @@ export class NextResponse extends Response { super(body, init) this[INTERNALS] = { - cookies: new NextCookies(this), + cookies: new ResponseCookies(this.headers), url: init.url ? new NextURL(init.url, { headers: toNodeHeaders(this.headers), diff --git a/packages/next/server/web/types.ts b/packages/next/server/web/types.ts index 5040d626c356..56ca53e02c5e 100644 --- a/packages/next/server/web/types.ts +++ b/packages/next/server/web/types.ts @@ -8,17 +8,6 @@ export interface NodeHeaders { [header: string]: string | string[] | undefined } -export interface CookieSerializeOptions { - domain?: string - encode?(val: string): string - expires?: Date - httpOnly?: boolean - maxAge?: number - path?: string - sameSite?: boolean | 'lax' | 'strict' | 'none' - secure?: boolean -} - export interface RequestData { geo?: { city?: string diff --git a/test/e2e/app-dir/app/app/hooks/use-cookies/page.js b/test/e2e/app-dir/app/app/hooks/use-cookies/page.js index 4b0c12db6746..f8095a3682a2 100644 --- a/test/e2e/app-dir/app/app/hooks/use-cookies/page.js +++ b/test/e2e/app-dir/app/app/hooks/use-cookies/page.js @@ -2,9 +2,7 @@ import { cookies } from 'next/headers' export default function Page() { const cookiesList = cookies() - const cookie = cookiesList.get('use-cookies') - - const hasCookie = cookie === 'value' + const hasCookie = cookiesList.has('use-cookies') return ( <> diff --git a/test/unit/web-runtime/cookies.test.ts b/test/unit/web-runtime/cookies.test.ts deleted file mode 100644 index 17fa0aa002fe..000000000000 --- a/test/unit/web-runtime/cookies.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** - * @jest-environment @edge-runtime/jest-environment - */ - -import { - Cookies, - CookieSerializeOptions, -} from 'next/dist/server/web/spec-extension/cookies' - -it('create a empty cookies bag', async () => { - const cookies = new Cookies() - expect(Object.entries(cookies)).toEqual([]) -}) - -it('create a cookies bag from string', async () => { - const cookies = new Cookies('foo=bar; equation=E%3Dmc%5E2') - expect(Array.from(cookies.entries())).toEqual([ - ['foo', 'foo=bar; Path=/'], - ['equation', 'equation=E%3Dmc%5E2; Path=/'], - ]) -}) - -it('.set', async () => { - const cookies = new Cookies() - cookies.set('foo', 'bar') - expect(Array.from(cookies.entries())).toEqual([['foo', 'foo=bar; Path=/']]) -}) - -it('.set with options', async () => { - const cookies = new Cookies() - - const options: CookieSerializeOptions = { - path: '/', - maxAge: 60 * 60 * 24 * 7, - httpOnly: true, - sameSite: 'strict', - domain: 'example.com', - } - - cookies.set('foo', 'bar', options) - - expect(options).toEqual({ - path: '/', - maxAge: 60 * 60 * 24 * 7, - httpOnly: true, - sameSite: 'strict', - domain: 'example.com', - }) - - const [[key, value]] = Array.from(cookies.entries()) - const values = value.split('; ') - - expect(key).toBe('foo') - - expect(values).toEqual([ - 'foo=bar', - 'Max-Age=604800', - 'Domain=example.com', - 'Path=/', - expect.stringContaining('Expires='), - 'HttpOnly', - 'SameSite=Strict', - ]) -}) - -it('.delete', async () => { - const cookies = new Cookies() - cookies.set('foo', 'bar') - cookies.delete('foo') - expect(Array.from(cookies.entries())).toEqual([]) -}) - -it('.has', async () => { - const cookies = new Cookies() - cookies.set('foo', 'bar') - expect(cookies.has('foo')).toBe(true) -}) diff --git a/test/unit/web-runtime/next-cookies.test.ts b/test/unit/web-runtime/next-cookies.test.ts deleted file mode 100644 index d9658d9c8e51..000000000000 --- a/test/unit/web-runtime/next-cookies.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * @jest-environment @edge-runtime/jest-environment - */ - -it('reflect .set into `set-cookie`', async () => { - const { NextResponse } = await import( - 'next/dist/server/web/spec-extension/response' - ) - - const response = new NextResponse() - - expect(response.cookies.get('foo')).toBe(undefined) - expect(response.cookies.getWithOptions('foo')).toEqual({ - value: undefined, - options: {}, - }) - - response.cookies - .set('foo', 'bar', { path: '/test' }) - .set('fooz', 'barz', { path: '/test2' }) - - expect(response.cookies.get('foo')).toBe('bar') - expect(response.cookies.get('fooz')).toBe('barz') - - expect(response.cookies.getWithOptions('foo')).toEqual({ - value: 'bar', - options: { Path: '/test' }, - }) - expect(response.cookies.getWithOptions('fooz')).toEqual({ - value: 'barz', - options: { Path: '/test2' }, - }) - - expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( - 'foo=bar; Path=/test, fooz=barz; Path=/test2' - ) -}) - -it('reflect .delete into `set-cookie`', async () => { - const { NextResponse } = await import( - 'next/dist/server/web/spec-extension/response' - ) - - const response = new NextResponse() - - response.cookies.set('foo', 'bar') - expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( - 'foo=bar; Path=/' - ) - - expect(response.cookies.get('foo')).toBe('bar') - expect(response.cookies.getWithOptions('foo')).toEqual({ - value: 'bar', - options: { Path: '/' }, - }) - - response.cookies.set('fooz', 'barz') - expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( - 'foo=bar; Path=/, fooz=barz; Path=/' - ) - - expect(response.cookies.get('fooz')).toBe('barz') - expect(response.cookies.getWithOptions('fooz')).toEqual({ - value: 'barz', - options: { Path: '/' }, - }) - - const firstDelete = response.cookies.delete('foo') - expect(firstDelete).toBe(true) - expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( - 'foo=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT, fooz=barz; Path=/' - ) - - expect(response.cookies.get('foo')).toBe(undefined) - expect(response.cookies.getWithOptions('foo')).toEqual({ - value: undefined, - options: {}, - }) - - const secondDelete = response.cookies.delete('fooz') - expect(secondDelete).toBe(true) - - expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( - 'fooz=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT, foo=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT' - ) - - expect(response.cookies.get('fooz')).toBe(undefined) - expect(response.cookies.getWithOptions('fooz')).toEqual({ - value: undefined, - options: {}, - }) - expect(response.cookies.size).toBe(0) -}) - -it('reflect .clear into `set-cookie`', async () => { - const { NextResponse } = await import( - 'next/dist/server/web/spec-extension/response' - ) - - const response = new NextResponse() - - response.cookies.clear() - expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( - undefined - ) - - response.cookies.set('foo', 'bar') - expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( - 'foo=bar; Path=/' - ) - - expect(response.cookies.get('foo')).toBe('bar') - expect(response.cookies.getWithOptions('foo')).toEqual({ - value: 'bar', - options: { Path: '/' }, - }) - - response.cookies.set('fooz', 'barz') - expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( - 'foo=bar; Path=/, fooz=barz; Path=/' - ) - - response.cookies.clear() - expect(Object.fromEntries(response.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' - ) -}) - -it('response.cookie does not modify options', async () => { - const { NextResponse } = await import( - 'next/dist/server/web/spec-extension/response' - ) - - const options = { maxAge: 10000 } - const response = new NextResponse(null, { - headers: { 'content-type': 'application/json' }, - }) - response.cookies.set('cookieName', 'cookieValue', options) - expect(options).toEqual({ maxAge: 10000 }) -}) diff --git a/test/unit/web-runtime/next-response-cookies.test.ts b/test/unit/web-runtime/next-response-cookies.test.ts new file mode 100644 index 000000000000..6460b326996c --- /dev/null +++ b/test/unit/web-runtime/next-response-cookies.test.ts @@ -0,0 +1,107 @@ +/** + * @jest-environment @edge-runtime/jest-environment + */ + +it('reflect .set into `set-cookie`', async () => { + const { NextResponse } = await import( + 'next/dist/server/web/spec-extension/response' + ) + + const response = new NextResponse() + expect(response.cookies.get('foo')?.value).toBe(undefined) + expect(response.cookies.get('foo')).toEqual(undefined) + + response.cookies + .set('foo', 'bar', { path: '/test' }) + .set('fooz', 'barz', { path: '/test2' }) + + expect(response.cookies.get('foo')?.value).toBe('bar') + expect(response.cookies.get('fooz')?.value).toBe('barz') + + expect(response.cookies.get('foo')).toEqual({ + name: 'foo', + path: '/test', + value: 'bar', + }) + expect(response.cookies.get('fooz')).toEqual({ + name: 'fooz', + path: '/test2', + value: 'barz', + }) + + expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( + 'foo=bar; Path=/test, fooz=barz; Path=/test2' + ) +}) + +it('reflect .delete into `set-cookie`', async () => { + const { NextResponse } = await import( + 'next/dist/server/web/spec-extension/response' + ) + + const response = new NextResponse() + + response.cookies.set('foo', 'bar') + expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( + 'foo=bar; Path=/' + ) + + expect(response.cookies.get('foo')?.value).toBe('bar') + expect(response.cookies.get('foo')).toEqual({ + name: 'foo', + path: '/', + value: 'bar', + }) + + response.cookies.set('fooz', 'barz') + expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( + 'foo=bar; Path=/, fooz=barz; Path=/' + ) + + expect(response.cookies.get('fooz')?.value).toBe('barz') + expect(response.cookies.get('fooz')).toEqual({ + name: 'fooz', + path: '/', + value: 'barz', + }) + + response.cookies.delete('foo') + expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( + 'foo=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT, fooz=barz; Path=/' + ) + + expect(response.cookies.get('foo')?.value).toBe('') + expect(response.cookies.get('foo')).toEqual({ + expires: new Date(0), + name: 'foo', + value: '', + path: '/', + }) + + response.cookies.delete('fooz') + + expect(Object.fromEntries(response.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(response.cookies.get('fooz')?.value).toBe('') + expect(response.cookies.get('fooz')).toEqual({ + expires: new Date(0), + name: 'fooz', + value: '', + path: '/', + }) +}) + +it('response.cookie does not modify options', async () => { + const { NextResponse } = await import( + 'next/dist/server/web/spec-extension/response' + ) + + const options = { maxAge: 10000 } + const response = new NextResponse(null, { + headers: { 'content-type': 'application/json' }, + }) + response.cookies.set('cookieName', 'cookieValue', options) + expect(options).toEqual({ maxAge: 10000 }) +})