Skip to content

Commit

Permalink
feat: better cookies API for NextResponse
Browse files Browse the repository at this point in the history
  • Loading branch information
Kikobeats committed Apr 26, 2022
1 parent af23248 commit 9372e4b
Show file tree
Hide file tree
Showing 6 changed files with 292 additions and 49 deletions.
72 changes: 72 additions & 0 deletions packages/next/server/web/spec-extension/cookies.ts
@@ -0,0 +1,72 @@
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)
options.maxAge /= 1000
}

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

return options
}

const serializeValue = (value: unknown) =>
typeof value === 'object' ? `j:${JSON.stringify(value)}` : String(value)

export class Cookies extends Map {
constructor(input: string | null | undefined) {
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: [key: string, value: unknown, options: CookieSerializeOptions]) {
const store = super.set(...args)
this.response.headers.append('set-cookie', store.get(args[0]))
return store
}
delete(key: any) {
const isDeleted = super.delete(key)

if (isDeleted) {
const setCookie = this.response.headers
.get('set-cookie')
?.split(', ')
.filter((value) => !value.startsWith(`${key}=`))
.join(',')

if (setCookie) {
this.response.headers.set('set-cookie', setCookie)
} else {
this.response.headers.delete('set-cookie')
}
}

return isDeleted
}
}

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
98 changes: 98 additions & 0 deletions test/unit/web-runtime/cookies.test.ts
@@ -0,0 +1,98 @@
/* eslint-env jest */

import { Cookies } from 'next/dist/server/web/spec-extension/cookies'
import { Blob, File, FormData } from 'next/dist/compiled/formdata-node'
import { Headers } from 'next/dist/server/web/spec-compliant/headers'
import { Crypto } from 'next/dist/server/web/sandbox/polyfills'
import * as streams from 'web-streams-polyfill/ponyfill'

beforeAll(() => {
global['Blob'] = Blob
global['crypto'] = new Crypto()
global['File'] = File
global['FormData'] = FormData
global['Headers'] = Headers
global['ReadableStream'] = streams.ReadableStream
global['TransformStream'] = streams.TransformStream
})

afterAll(() => {
delete global['Blob']
delete global['crypto']
delete global['File']
delete global['Headers']
delete global['FormData']
delete global['ReadableStream']
delete global['TransformStream']
})

it('create a empty cookies bag', async () => {
const cookies = new Cookies()
expect(Object.entries(cookies)).toStrictEqual([])
})

it('create a cookies bag from string', async () => {
const cookies = new Cookies('foo=bar; equation=E%3Dmc%5E2')
expect(Array.from(cookies.entries())).toStrictEqual([
['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())).toStrictEqual([
['foo', 'foo=bar; Path=/'],
])
})

it('.set with options', async () => {
const cookies = new Cookies()

const options = {
path: '/',
maxAge: 1000 * 60 * 60 * 24 * 7,
httpOnly: true,
sameSite: 'strict',
domain: 'example.com',
}

cookies.set('foo', 'bar', options)

expect(options).toStrictEqual({
path: '/',
maxAge: 1000 * 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).toStrictEqual([
'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())).toStrictEqual([])
})

it('.has', async () => {
const cookies = new Cookies()
cookies.set('foo', 'bar')
expect(cookies.has('foo')).toBe(true)
})
75 changes: 75 additions & 0 deletions test/unit/web-runtime/next-request.test.ts
@@ -0,0 +1,75 @@
/* eslint-env jest */

import { Blob, File, FormData } from 'next/dist/compiled/formdata-node'
import { Crypto } from 'next/dist/server/web/sandbox/polyfills'
import { Request } from 'next/dist/server/web/spec-compliant/request'
import { Headers } from 'next/dist/server/web/spec-compliant/headers'
import * as streams from 'web-streams-polyfill/ponyfill'

beforeAll(() => {
global['Blob'] = Blob
global['crypto'] = new Crypto()
global['File'] = File
global['FormData'] = FormData
global['Headers'] = Headers
global['ReadableStream'] = streams.ReadableStream
global['TransformStream'] = streams.TransformStream
global['Request'] = Request
})

afterAll(() => {
delete global['Blob']
delete global['crypto']
delete global['File']
delete global['Headers']
delete global['FormData']
delete global['ReadableStream']
delete global['TransformStream']
})

it('automatically modify `set-cookie` when interact with request.cookies', async () => {
const { NextRequest } = await import(
'next/dist/server/web/spec-extension/request'
)

const request = new NextRequest('https://vercel.com')

request.cookies.set('foo', 'bar')
expect(Object.fromEntries(request.headers.entries())['set-cookie']).toBe(
'foo=bar; Path=/'
)
expect(request.cookies.get('foo')).toBe('foo=bar; Path=/')

request.cookies.set('fooz', 'barz')
expect(Object.fromEntries(request.headers.entries())['set-cookie']).toBe(
'foo=bar; Path=/, fooz=barz; Path=/'
)
expect(request.cookies.get('fooz')).toBe('fooz=barz; Path=/')

const firstDelete = request.cookies.delete('foo')
expect(firstDelete).toBe(true)
expect(Object.fromEntries(request.headers.entries())['set-cookie']).toBe(
'fooz=barz; Path=/'
)
expect(request.cookies.get('foo')).toBe(undefined)

const secondDelete = request.cookies.delete('fooz')
expect(secondDelete).toBe(true)
expect(Object.fromEntries(request.headers.entries())['set-cookie']).toBe(
undefined
)
expect(request.cookies.get('fooz')).toBe(undefined)

expect(request.cookies.size).toBe(0)
})

it('request.cookie does not modify options', async () => {
const { NextRequest } = await import(
'next/dist/server/web/spec-extension/request'
)

const options = { maxAge: 10000 }
const request = new NextRequest('https://vercel.com')
request.cookies.set('cookieName', 'cookieValue', options)
expect(options).toEqual({ maxAge: 10000 })
})

0 comments on commit 9372e4b

Please sign in to comment.