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: better cookies API for Edge Functions #36478

Merged
merged 11 commits into from May 9, 2022
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 })
Kikobeats marked this conversation as resolved.
Show resolved Hide resolved
return this[INTERNALS].cookies
}

static json(body: any, init?: ResponseInit) {
Expand Down
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 @@ -73,7 +73,7 @@ export async function middleware(request) {
) {
url.searchParams.set('middleware', 'foo')
url.pathname =
request.cookies['about-bypass'] === '1'
request.cookies.get('about-bypass') === '1'
? '/rewrites/about-bypass'
: '/rewrites/about'

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