Skip to content

Commit

Permalink
feat: better cookies API for Edge Functions (#36478)
Browse files Browse the repository at this point in the history
This PR introduces a more predictable API to manipulate cookies in an Edge Function context.

```js
const response = new NextResponse()

// set a cookie
response.cookies.set('foo, 'bar') // => set-cookie: 'foo=bar; Path=/'`

// set another cookie
response.cookies.set('fooz, 'barz') // => set-cookie: 'foo=bar; Path=/, fooz=barz; Path=/'`

// delete a cookie means mark it as expired
response.cookies.delete('foo') // => set-cookie: 'foo=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT, fooz=barz; Path=/'`

// clear all cookies means mark all of them as expired
response.cookies.clear() // => set-cookie: 'fooz=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT, foo=; Path=/; Expires=Thu, 01 Jan 1970 00:00:00 GMT'`
``` 

This new cookies API uses [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) interface, and it's available for `NextRequest` and `NextResponse`.

Additionally, you can pass a specific cookies option as a third argument in `set` method:

```js
response.cookies.set('foo', 'bar', {
  path: '/',
  maxAge: 60 * 60 * 24 * 7,
  httpOnly: true,
  sameSite: 'strict',
  domain: 'example.com'
}
```

**Note**: `maxAge` it's in seconds rather than milliseconds.

Any cookie manipulation will be reflected over the `set-cookie` header, transparently.

closes #31719
  • Loading branch information
Kikobeats committed May 9, 2022
1 parent 40e9891 commit b78c28f
Show file tree
Hide file tree
Showing 9 changed files with 374 additions and 76 deletions.
16 changes: 7 additions & 9 deletions docs/api-reference/next/server.md
Expand Up @@ -35,7 +35,7 @@ The function can be a default export and as such, does **not** have to be named

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` - Has the cookies from the `Request`
- `cookies` - A [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) with cookies from the `Request`
- `nextUrl` - Includes an extended, parsed, URL object that gives you access to Next.js specific properties such as `pathname`, `basePath`, `trailingSlash` and `i18n`
- `ip` - Has the IP address of the `Request`
- `ua` - Has the user agent
Expand Down Expand Up @@ -69,9 +69,7 @@ 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` - An object with the cookies in the `Response`
- `cookie()` - Set a cookie in the `Response`
- `clearCookie()` - Accepts a `cookie` and clears it
- `cookies` - A [Map](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Map) with the cookies in the `Response`

```ts
import { NextResponse } from 'next/server'
Expand All @@ -82,13 +80,13 @@ export function middleware(request: NextRequest) {
// you could use `redirect()` or `rewrite()` as well
let response = NextResponse.next()
// get the cookies from the request
let cookieFromRequest = request.cookies['my-cookie']
let cookieFromRequest = request.cookies.get('my-cookie')
// set the `cookie`
response.cookie('hello', 'world')
response.cookies.set('hello', 'world')
// set the `cookie` with options
const cookieWithOptions = response.cookie('hello', 'world', {
const cookieWithOptions = response.cookies.set('hello', 'world', {
path: '/',
maxAge: 1000 * 60 * 60 * 24 * 7,
maxAge: 60 * 60 * 24 * 7,
httpOnly: true,
sameSite: 'strict',
domain: 'example.com',
Expand Down Expand Up @@ -146,7 +144,7 @@ import type { NextRequest } from 'next/server'

export function middleware(req: NextRequest) {
const res = NextResponse.redirect('/') // creates an actual instance
res.cookie('hello', 'world') // can be called on an instance
res.cookies.set('hello', 'world') // can be called on an instance
return res
}
```
Expand Down
115 changes: 115 additions & 0 deletions packages/next/server/web/spec-extension/cookies.ts
@@ -0,0 +1,115 @@
import cookie from 'next/dist/compiled/cookie'
import { CookieSerializeOptions } from '../types'

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<string, any> {
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)
)
)
}
}

export class NextCookies extends Cookies {
response: Request | Response

constructor(response: Request | Response) {
super(response.headers.get('cookie'))
this.response = response
}
set = (...args: Parameters<Cookies['set']>) => {
const isAlreadyAdded = super.has(args[0])
const store = super.set(...args)

if (isAlreadyAdded) {
const setCookie = serializeCookie(
deserializeCookie(this.response).filter(
(value) => !value.startsWith(`${args[0]}=`)
)
)

if (setCookie) {
this.response.headers.set(
'set-cookie',
[store.get(args[0]), setCookie].join(', ')
)
} else {
this.response.headers.set('set-cookie', store.get(args[0]))
}
} else {
this.response.headers.append('set-cookie', store.get(args[0]))
}

return store
}
delete = (key: any, 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 }
14 changes: 5 additions & 9 deletions packages/next/server/web/spec-extension/request.ts
Expand Up @@ -3,14 +3,15 @@ import type { RequestData } from '../types'
import { NextURL } from '../next-url'
import { isBot } from '../../utils'
import { toNodeHeaders } from '../utils'
import cookie from 'next/dist/compiled/cookie'
import parseua from 'next/dist/compiled/ua-parser-js'

import { NextCookies } from './cookies'

export const INTERNALS = Symbol('internal request')

export class NextRequest extends Request {
[INTERNALS]: {
cookieParser(): { [key: string]: string }
cookies: NextCookies
geo: RequestData['geo']
ip?: string
page?: { name?: string; params?: { [key: string]: string | string[] } }
Expand All @@ -21,13 +22,8 @@ export class NextRequest extends Request {
constructor(input: Request | string, init: RequestInit = {}) {
super(input, init)

const cookieParser = () => {
const value = this.headers.get('cookie')
return value ? cookie.parse(value) : {}
}

this[INTERNALS] = {
cookieParser,
cookies: new NextCookies(this),
geo: init.geo || {},
ip: init.ip,
page: init.page,
Expand All @@ -41,7 +37,7 @@ export class NextRequest extends Request {
}

public get cookies() {
return this[INTERNALS].cookieParser()
return this[INTERNALS].cookies
}

public get geo() {
Expand Down
44 changes: 5 additions & 39 deletions packages/next/server/web/spec-extension/response.ts
@@ -1,28 +1,23 @@
import type { I18NConfig } from '../../config-shared'
import { NextURL } from '../next-url'
import { toNodeHeaders, validateURL } from '../utils'
import cookie from 'next/dist/compiled/cookie'
import { CookieSerializeOptions } from '../types'

import { NextCookies } from './cookies'

const INTERNALS = Symbol('internal response')
const REDIRECTS = new Set([301, 302, 303, 307, 308])

export class NextResponse extends Response {
[INTERNALS]: {
cookieParser(): { [key: string]: string }
cookies: NextCookies
url?: NextURL
}

constructor(body?: BodyInit | null, init: ResponseInit = {}) {
super(body, init)

const cookieParser = () => {
const value = this.headers.get('cookie')
return value ? cookie.parse(value) : {}
}

this[INTERNALS] = {
cookieParser,
cookies: new NextCookies(this),
url: init.url
? new NextURL(init.url, {
basePath: init.nextConfig?.basePath,
Expand All @@ -35,36 +30,7 @@ export class NextResponse extends Response {
}

public get cookies() {
return this[INTERNALS].cookieParser()
}

public cookie(
name: string,
value: { [key: string]: any } | string,
opts: CookieSerializeOptions = {}
) {
const val =
typeof value === 'object' ? 'j:' + JSON.stringify(value) : String(value)

const options = { ...opts }
if (options.maxAge) {
options.expires = new Date(Date.now() + options.maxAge)
options.maxAge /= 1000
}

if (options.path == null) {
options.path = '/'
}

this.headers.append(
'Set-Cookie',
cookie.serialize(name, String(val), options)
)
return this
}

public clearCookie(name: string, opts: CookieSerializeOptions = {}) {
return this.cookie(name, '', { expires: new Date(1), path: '/', ...opts })
return this[INTERNALS].cookies
}

static json(body: any, init?: ResponseInit) {
Expand Down
11 changes: 5 additions & 6 deletions test/integration/middleware/core/pages/rewrites/_middleware.js
Expand Up @@ -25,12 +25,12 @@ export async function middleware(request) {
}

if (url.pathname === '/rewrites/rewrite-to-ab-test') {
let bucket = request.cookies.bucket
let bucket = request.cookies.get('bucket')
if (!bucket) {
bucket = Math.random() >= 0.5 ? 'a' : 'b'
url.pathname = `/rewrites/${bucket}`
const response = NextResponse.rewrite(url)
response.cookie('bucket', bucket, { maxAge: 10000 })
response.cookies.set('bucket', bucket, { maxAge: 10 })
return response
}

Expand Down Expand Up @@ -72,10 +72,9 @@ export async function middleware(request) {
url.searchParams.get('path') === 'rewrite-me-without-hard-navigation'
) {
url.searchParams.set('middleware', 'foo')
url.pathname =
request.cookies['about-bypass'] === '1'
? '/rewrites/about-bypass'
: '/rewrites/about'
url.pathname = request.cookies.has('about-bypass')
? '/rewrites/about-bypass'
: '/rewrites/about'

const response = NextResponse.rewrite(url)
response.headers.set('x-middleware-cache', 'no-cache')
Expand Down
Expand Up @@ -4,11 +4,11 @@ export const middleware: NextMiddleware = async function (request) {
const url = request.nextUrl

if (url.pathname === '/') {
let bucket = request.cookies.bucket
let bucket = request.cookies.get('bucket')
if (!bucket) {
bucket = Math.random() >= 0.5 ? 'a' : 'b'
const response = NextResponse.rewrite(`/rewrites/${bucket}`)
response.cookie('bucket', bucket, { maxAge: 10000 })
response.cookies.set('bucket', bucket, { maxAge: 10 })
return response
}

Expand Down

0 comments on commit b78c28f

Please sign in to comment.