diff --git a/packages/next/server/web/spec-extension/cookies.ts b/packages/next/server/web/spec-extension/cookies.ts new file mode 100644 index 000000000000..a4c885274ae9 --- /dev/null +++ b/packages/next/server/web/spec-extension/cookies.ts @@ -0,0 +1,103 @@ +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) + ) + ) + } +} + +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 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 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}` + ) + } 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) { + const isDeleted = super.delete(key) + + if (isDeleted) { + const setCookie = serializeCookie( + deserializeCookie(this.response).filter( + (value) => !value.startsWith(`${key}=`) + ) + ) + + if (setCookie) { + this.response.headers.set('set-cookie', setCookie) + } else { + this.response.headers.delete('set-cookie') + } + } + + return isDeleted + } + clear() { + this.response.headers.delete('set-cookie') + return super.clear() + } +} + +export { CookieSerializeOptions } diff --git a/packages/next/server/web/spec-extension/request.ts b/packages/next/server/web/spec-extension/request.ts index b4b20424f116..39f67d41bab9 100644 --- a/packages/next/server/web/spec-extension/request.ts +++ b/packages/next/server/web/spec-extension/request.ts @@ -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[] } } @@ -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, @@ -41,7 +37,7 @@ export class NextRequest extends Request { } public get cookies() { - return this[INTERNALS].cookieParser() + return this[INTERNALS].cookies } public get geo() { diff --git a/packages/next/server/web/spec-extension/response.ts b/packages/next/server/web/spec-extension/response.ts index a82224e6643d..8ab8b0877317 100644 --- a/packages/next/server/web/spec-extension/response.ts +++ b/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, @@ -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) { diff --git a/test/unit/web-runtime/cookies.test.ts b/test/unit/web-runtime/cookies.test.ts new file mode 100644 index 000000000000..e69fa8026830 --- /dev/null +++ b/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) +}) diff --git a/test/unit/web-runtime/next-cookies.test.ts b/test/unit/web-runtime/next-cookies.test.ts new file mode 100644 index 000000000000..a650ee2f2069 --- /dev/null +++ b/test/unit/web-runtime/next-cookies.test.ts @@ -0,0 +1,129 @@ +/* eslint-env jest */ + +import { Blob, File, FormData } from 'next/dist/compiled/formdata-node' +import { Crypto } from 'next/dist/server/web/sandbox/polyfills' +import { Response } from 'next/dist/server/web/spec-compliant/response' +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['Response'] = Response +}) + +afterAll(() => { + delete global['Blob'] + delete global['crypto'] + delete global['File'] + delete global['Headers'] + delete global['FormData'] + delete global['ReadableStream'] + delete global['TransformStream'] +}) + +it('reflect .set 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('foo=bar; Path=/') + + response.cookies.set('foo', 'barz') + expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( + 'foo=barz; Path=/' + ) + expect(response.cookies.get('foo')).toBe('foo=barz; Path=/') + + response.cookies.set('fooz', 'barz') + expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( + 'foo=barz; Path=/, fooz=barz; Path=/' + ) + + response.cookies.set('foo', 'bar') + expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( + 'foo=bar; Path=/, fooz=barz; Path=/' + ) +}) + +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('foo=bar; 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('fooz=barz; Path=/') + + const firstDelete = response.cookies.delete('foo') + expect(firstDelete).toBe(true) + expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( + 'fooz=barz; Path=/' + ) + expect(response.cookies.get('foo')).toBe(undefined) + + const secondDelete = response.cookies.delete('fooz') + expect(secondDelete).toBe(true) + expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( + undefined + ) + expect(response.cookies.get('fooz')).toBe(undefined) + + 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.set('foo', 'bar') + expect(Object.fromEntries(response.headers.entries())['set-cookie']).toBe( + 'foo=bar; Path=/' + ) + expect(response.cookies.get('foo')).toBe('foo=bar; 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( + undefined + ) +}) + +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 = NextResponse.json(null) + response.cookies.set('cookieName', 'cookieValue', options) + expect(options).toEqual({ maxAge: 10000 }) +}) diff --git a/test/unit/web-runtime/next-response.test.ts b/test/unit/web-runtime/next-response.test.ts index 98fe67b08f24..85351b6e2212 100644 --- a/test/unit/web-runtime/next-response.test.ts +++ b/test/unit/web-runtime/next-response.test.ts @@ -71,14 +71,3 @@ it('automatically parses and formats JSON', async () => { body: '', }) }) - -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 = NextResponse.json(null) - response.cookie('cookieName', 'cookieValue', options) - expect(options).toEqual({ maxAge: 10000 }) -})