diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 5b76ae1b1176f..eec802c107c14 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -2818,6 +2818,10 @@ export default async function build( // normalize header values as initialHeaders // must be Record for (const key of headerKeys) { + // set-cookie is already handled - the middleware cookie setting case + // isn't needed for the prerender manifest since it can't read cookies + if (key === 'x-middleware-set-cookie') continue + let value = exportHeaders[key] if (Array.isArray(value)) { diff --git a/packages/next/src/server/async-storage/request-async-storage-wrapper.ts b/packages/next/src/server/async-storage/request-async-storage-wrapper.ts index c0d12f6873fcd..833b3ad1e0392 100644 --- a/packages/next/src/server/async-storage/request-async-storage-wrapper.ts +++ b/packages/next/src/server/async-storage/request-async-storage-wrapper.ts @@ -101,9 +101,26 @@ export const RequestAsyncStorageWrapper: AsyncStorageWrapper< }, get cookies() { if (!cache.cookies) { + // if middleware is setting cookie(s), then include those in + // the initial cached cookies so they can be read in render + let combinedCookies + if ( + 'x-middleware-set-cookie' in req.headers && + typeof req.headers['x-middleware-set-cookie'] === 'string' + ) { + combinedCookies = `${req.headers.cookie}; ${req.headers['x-middleware-set-cookie']}` + } + // Seal the cookies object that'll freeze out any methods that could // mutate the underlying data. - cache.cookies = getCookies(req.headers) + cache.cookies = getCookies( + combinedCookies + ? { + ...req.headers, + cookie: combinedCookies, + } + : req.headers + ) } return cache.cookies diff --git a/packages/next/src/server/web/spec-extension/response.ts b/packages/next/src/server/web/spec-extension/response.ts index db14979fb8d91..c680c2117a191 100644 --- a/packages/next/src/server/web/spec-extension/response.ts +++ b/packages/next/src/server/web/spec-extension/response.ts @@ -1,6 +1,7 @@ import type { I18NConfig } from '../../config-shared' import { NextURL } from '../next-url' import { toNodeOutgoingHttpHeaders, validateURL } from '../utils' +import { ReflectAdapter } from './adapters/reflect' import { ResponseCookies } from './cookies' @@ -41,11 +42,37 @@ export class NextResponse extends Response { constructor(body?: BodyInit | null, init: ResponseInit = {}) { super(body, init) + const headers = this.headers + const cookies = new ResponseCookies(headers) + + const cookiesProxy = new Proxy(cookies, { + get(target, prop, receiver) { + switch (prop) { + case 'delete': + case 'set': { + return (...args: [string, string]) => { + const result = Reflect.apply(target[prop], target, args) + const newHeaders = new Headers(headers) + + if (result instanceof ResponseCookies) { + headers.set('x-middleware-set-cookie', result.toString()) + } + + handleMiddlewareField(init, newHeaders) + return result + } + } + default: + return ReflectAdapter.get(target, prop, receiver) + } + }, + }) + this[INTERNALS] = { - cookies: new ResponseCookies(this.headers), + cookies: cookiesProxy, url: init.url ? new NextURL(init.url, { - headers: toNodeOutgoingHttpHeaders(this.headers), + headers: toNodeOutgoingHttpHeaders(headers), nextConfig: init.nextConfig, }) : undefined, diff --git a/test/e2e/app-dir/app-middleware/app-middleware.test.ts b/test/e2e/app-dir/app-middleware/app-middleware.test.ts index 661839a2aa37d..40a0d36d9baad 100644 --- a/test/e2e/app-dir/app-middleware/app-middleware.test.ts +++ b/test/e2e/app-dir/app-middleware/app-middleware.test.ts @@ -1,7 +1,7 @@ /* eslint-env jest */ import path from 'path' import cheerio from 'cheerio' -import { check, withQuery } from 'next-test-utils' +import { check, retry, withQuery } from 'next-test-utils' import { createNextDescribe, FileRef } from 'e2e-utils' import type { Response } from 'node-fetch' @@ -134,6 +134,41 @@ createNextDescribe( expect(bypassCookie).toBeDefined() }) }) + + it('should be possible to modify cookies & read them in an RSC in a single request', async () => { + const browser = await next.browser('/rsc-cookies') + + const initialRandom1 = await browser.elementById('rsc-cookie-1').text() + const initialRandom2 = await browser.elementById('rsc-cookie-2').text() + + // cookies were set in middleware, assert they are present and match the Math.random() pattern + expect(initialRandom1).toMatch(/Cookie 1: \d+\.\d+/) + expect(initialRandom2).toMatch(/Cookie 2: \d+\.\d+/) + + await browser.refresh() + + const refreshedRandom1 = await browser.elementById('rsc-cookie-1').text() + const refreshedRandom2 = await browser.elementById('rsc-cookie-2').text() + + // the cookies should be refreshed and have new values + expect(refreshedRandom1).toMatch(/Cookie 1: \d+\.\d+/) + expect(refreshedRandom2).toMatch(/Cookie 2: \d+\.\d+/) + expect(refreshedRandom1).not.toBe(initialRandom1) + expect(refreshedRandom2).not.toBe(initialRandom2) + + // navigate to delete cookies route + await browser.elementByCss('[href="/rsc-cookies-delete"]').click() + await retry(async () => { + // only the first cookie should be deleted + expect(await browser.elementById('rsc-cookie-1').text()).toBe( + 'Cookie 1:' + ) + + expect(await browser.elementById('rsc-cookie-2').text()).toMatch( + /Cookie 2: \d+\.\d+/ + ) + }) + }) } ) diff --git a/test/e2e/app-dir/app-middleware/app/rsc-cookies-delete/page.js b/test/e2e/app-dir/app-middleware/app/rsc-cookies-delete/page.js new file mode 100644 index 0000000000000..9bdfed8530e18 --- /dev/null +++ b/test/e2e/app-dir/app-middleware/app/rsc-cookies-delete/page.js @@ -0,0 +1,13 @@ +import { cookies } from 'next/headers' + +export default function Page() { + const rscCookie1 = cookies().get('rsc-cookie-value-1')?.value + const rscCookie2 = cookies().get('rsc-cookie-value-2')?.value + + return ( +
+ + +
+ ) +} diff --git a/test/e2e/app-dir/app-middleware/app/rsc-cookies/page.js b/test/e2e/app-dir/app-middleware/app/rsc-cookies/page.js new file mode 100644 index 0000000000000..ed72af4c6607c --- /dev/null +++ b/test/e2e/app-dir/app-middleware/app/rsc-cookies/page.js @@ -0,0 +1,15 @@ +import { cookies } from 'next/headers' +import Link from 'next/link' + +export default function Page() { + const rscCookie1 = cookies().get('rsc-cookie-value-1')?.value + const rscCookie2 = cookies().get('rsc-cookie-value-2')?.value + + return ( +
+ + + To Delete Cookies Route +
+ ) +} diff --git a/test/e2e/app-dir/app-middleware/middleware.js b/test/e2e/app-dir/app-middleware/middleware.js index 0048747a3812c..3b243480c3671 100644 --- a/test/e2e/app-dir/app-middleware/middleware.js +++ b/test/e2e/app-dir/app-middleware/middleware.js @@ -44,6 +44,21 @@ export async function middleware(request) { return NextResponse.rewrite(request.nextUrl) } + if (request.nextUrl.pathname === '/rsc-cookies') { + const res = NextResponse.next() + res.cookies.set('rsc-cookie-value-1', `${Math.random()}`) + res.cookies.set('rsc-cookie-value-2', `${Math.random()}`) + + return res + } + + if (request.nextUrl.pathname === '/rsc-cookies-delete') { + const res = NextResponse.next() + res.cookies.delete('rsc-cookie-value-1') + + return res + } + return NextResponse.next({ request: { headers: headersFromRequest,