diff --git a/packages/next/build/webpack/loaders/next-serverless-loader.ts b/packages/next/build/webpack/loaders/next-serverless-loader.ts index 55ee67059859ff4..7fcaf28c218005b 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader.ts @@ -205,6 +205,7 @@ const nextServerlessLoader: loader.Loader = function() { const {renderToHTML} = require('next/dist/next-server/server/render'); const { tryGetPreviewData } = require('next/dist/next-server/server/api-utils'); const {sendHTML} = require('next/dist/next-server/server/send-html'); + const {sendPayload} = require('next/dist/next-server/server/send-payload'); const buildManifest = require('${buildManifest}'); const reactLoadableManifest = require('${reactLoadableManifest}'); const Document = require('${absoluteDocumentPath}').default; @@ -328,21 +329,15 @@ const nextServerlessLoader: loader.Loader = function() { let result = await renderToHTML(req, res, "${page}", Object.assign({}, getStaticProps ? {} : parsedUrl.query, nowParams ? nowParams : params, _params, isFallback ? { __nextFallback: 'true' } : {}), renderOpts) - if (_nextData && !renderMode) { - const payload = JSON.stringify(renderOpts.pageData) - res.setHeader('Content-Type', 'application/json') - res.setHeader('Content-Length', Buffer.byteLength(payload)) - - res.setHeader( - 'Cache-Control', - isPreviewMode - ? \`private, no-cache, no-store, max-age=0, must-revalidate\` - : getServerSideProps - ? \`no-cache, no-store, must-revalidate\` - : \`s-maxage=\${renderOpts.revalidate}, stale-while-revalidate\` - ) - res.end(payload) - return null + if (!renderMode) { + if (_nextData || getStaticProps || getServerSideProps) { + sendPayload(res, _nextData ? JSON.stringify(renderOpts.pageData) : result, _nextData ? 'json' : 'html', { + private: isPreviewMode, + stateful: !!getServerSideProps, + revalidate: renderOpts.revalidate, + }) + return null + } } else if (isPreviewMode) { res.setHeader( 'Cache-Control', diff --git a/packages/next/next-server/server/next-server.ts b/packages/next/next-server/server/next-server.ts index f9ecd2ed57deadf..2fb9c66b5a39d4a 100644 --- a/packages/next/next-server/server/next-server.ts +++ b/packages/next/next-server/server/next-server.ts @@ -52,6 +52,7 @@ import Router, { Route, } from './router' import { sendHTML } from './send-html' +import { sendPayload } from './send-payload' import { serveStatic } from './serve-static' import { getFallback, @@ -932,11 +933,11 @@ export default class Server { sendPayload( res, JSON.stringify(renderResult?.renderOpts?.pageData), - 'application/json', + 'json', !this.renderOpts.dev ? { - revalidate: -1, - private: isPreviewMode, // Leave to user-land caching + private: isPreviewMode, + stateful: true, // non-SSG data request } : undefined ) @@ -955,11 +956,11 @@ export default class Server { sendPayload( res, JSON.stringify(props), - 'application/json', + 'json', !this.renderOpts.dev ? { - revalidate: -1, - private: isPreviewMode, // Leave to user-land caching + private: isPreviewMode, + stateful: true, // GSSP data request } : undefined ) @@ -971,11 +972,12 @@ export default class Server { ...opts, }) - if (html && isServerProps && isPreviewMode) { - sendPayload(res, html, 'text/html; charset=utf-8', { - revalidate: -1, + if (html && isServerProps) { + sendPayload(res, html, 'html', { private: isPreviewMode, + stateful: true, // GSSP request }) + return null } return html @@ -1000,9 +1002,16 @@ export default class Server { sendPayload( res, data, - isDataReq ? 'application/json' : 'text/html; charset=utf-8', - cachedData.curRevalidate !== undefined && !this.renderOpts.dev - ? { revalidate: cachedData.curRevalidate, private: isPreviewMode } + isDataReq ? 'json' : 'html', + !this.renderOpts.dev + ? { + private: isPreviewMode, + stateful: false, // GSP response + revalidate: + cachedData.curRevalidate !== undefined + ? cachedData.curRevalidate + : /* default to minimum revalidate (this should be an invariant) */ 1, + } : undefined ) @@ -1105,7 +1114,12 @@ export default class Server { query.__nextFallback = 'true' if (isLikeServerless) { prepareServerlessUrl(req, query) - html = await (components.Component as any).renderReqToHTML(req, res) + const renderResult = await (components.Component as any).renderReqToHTML( + req, + res, + 'passthrough' + ) + html = renderResult.html } else { html = (await renderToHTML(req, res, pathname, query, { ...components, @@ -1114,7 +1128,7 @@ export default class Server { } } - sendPayload(res, html, 'text/html; charset=utf-8') + sendPayload(res, html, 'html') } const { @@ -1125,9 +1139,13 @@ export default class Server { sendPayload( res, isDataReq ? JSON.stringify(pageData) : html, - isDataReq ? 'application/json' : 'text/html; charset=utf-8', + isDataReq ? 'json' : 'html', !this.renderOpts.dev - ? { revalidate: sprRevalidate, private: isPreviewMode } + ? { + private: isPreviewMode, + stateful: false, // GSP response + revalidate: sprRevalidate, + } : undefined ) } @@ -1348,38 +1366,6 @@ export default class Server { } } -function sendPayload( - res: ServerResponse, - payload: any, - type: string, - options?: { revalidate: number | false; private: boolean } -) { - // TODO: ETag? Cache-Control headers? Next-specific headers? - res.setHeader('Content-Type', type) - res.setHeader('Content-Length', Buffer.byteLength(payload)) - if (options != null) { - if (options?.private) { - res.setHeader( - 'Cache-Control', - `private, no-cache, no-store, max-age=0, must-revalidate` - ) - } else if (options?.revalidate) { - res.setHeader( - 'Cache-Control', - options.revalidate < 0 - ? `no-cache, no-store, must-revalidate` - : `s-maxage=${options.revalidate}, stale-while-revalidate` - ) - } else if (options?.revalidate === false) { - res.setHeader( - 'Cache-Control', - `s-maxage=31536000, stale-while-revalidate` - ) - } - } - res.end(payload) -} - function prepareServerlessUrl(req: IncomingMessage, query: ParsedUrlQuery) { const curUrl = parseUrl(req.url!, true) req.url = formatUrl({ diff --git a/packages/next/next-server/server/send-payload.ts b/packages/next/next-server/server/send-payload.ts new file mode 100644 index 000000000000000..0a222ebdbb52c84 --- /dev/null +++ b/packages/next/next-server/server/send-payload.ts @@ -0,0 +1,50 @@ +import { ServerResponse } from 'http' +import { isResSent } from '../lib/utils' + +export function sendPayload( + res: ServerResponse, + payload: any, + type: 'html' | 'json', + options?: + | { private: true } + | { private: boolean; stateful: true } + | { private: boolean; stateful: false; revalidate: number | false } +): void { + if (isResSent(res)) { + return + } + + // TODO: ETag headers? + res.setHeader( + 'Content-Type', + type === 'json' ? 'application/json' : 'text/html; charset=utf-8' + ) + res.setHeader('Content-Length', Buffer.byteLength(payload)) + if (options != null) { + if (options.private || options.stateful) { + if (options.private || !res.hasHeader('Cache-Control')) { + res.setHeader( + 'Cache-Control', + `private, no-cache, no-store, max-age=0, must-revalidate` + ) + } + } else if (typeof options.revalidate === 'number') { + if (options.revalidate < 1) { + throw new Error( + `invariant: invalid Cache-Control duration provided: ${options.revalidate} < 1` + ) + } + + res.setHeader( + 'Cache-Control', + `s-maxage=${options.revalidate}, stale-while-revalidate` + ) + } else if (options.revalidate === false) { + res.setHeader( + 'Cache-Control', + `s-maxage=31536000, stale-while-revalidate` + ) + } + } + res.end(payload) +} diff --git a/test/integration/getserversideprops-preview/pages/index.js b/test/integration/getserversideprops-preview/pages/index.js index bc556a1e7dc1632..d48204d469bf744 100644 --- a/test/integration/getserversideprops-preview/pages/index.js +++ b/test/integration/getserversideprops-preview/pages/index.js @@ -1,4 +1,6 @@ -export function getServerSideProps({ preview, previewData }) { +export function getServerSideProps({ res, preview, previewData }) { + // test override in preview mode + res.setHeader('Cache-Control', 'public, max-age=3600') return { props: { hasProps: true, diff --git a/test/integration/getserversideprops/pages/custom-cache.js b/test/integration/getserversideprops/pages/custom-cache.js new file mode 100644 index 000000000000000..ddeb3bd7a11ef19 --- /dev/null +++ b/test/integration/getserversideprops/pages/custom-cache.js @@ -0,0 +1,12 @@ +import React from 'react' + +export async function getServerSideProps({ res }) { + res.setHeader('Cache-Control', 'public, max-age=3600') + return { + props: { world: 'world' }, + } +} + +export default ({ world }) => { + return

hello: {world}

+} diff --git a/test/integration/getserversideprops/test/index.test.js b/test/integration/getserversideprops/test/index.test.js index 1f3e58cd4c10144..283560deff2416a 100644 --- a/test/integration/getserversideprops/test/index.test.js +++ b/test/integration/getserversideprops/test/index.test.js @@ -66,6 +66,12 @@ const expectedManifestRoutes = () => [ ), page: '/catchall/[...path]', }, + { + dataRouteRegex: normalizeRegEx( + `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/custom-cache.json$` + ), + page: '/custom-cache', + }, { dataRouteRegex: normalizeRegEx( `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/default-revalidate.json$` @@ -414,12 +420,30 @@ const runTests = (dev = false) => { expect(dataRoutes).toEqual(expectedManifestRoutes()) }) - it('should set no-cache, no-store, must-revalidate header', async () => { - const res = await fetchViaHTTP( + it('should set default caching header', async () => { + const resPage = await fetchViaHTTP(appPort, `/something`) + expect(resPage.headers.get('cache-control')).toBe( + 'private, no-cache, no-store, max-age=0, must-revalidate' + ) + + const resData = await fetchViaHTTP( appPort, `/_next/data/${buildId}/something.json` ) - expect(res.headers.get('cache-control')).toContain('no-cache') + expect(resData.headers.get('cache-control')).toBe( + 'private, no-cache, no-store, max-age=0, must-revalidate' + ) + }) + + it('should respect custom caching header', async () => { + const resPage = await fetchViaHTTP(appPort, `/custom-cache`) + expect(resPage.headers.get('cache-control')).toBe('public, max-age=3600') + + const resData = await fetchViaHTTP( + appPort, + `/_next/data/${buildId}/custom-cache.json` + ) + expect(resData.headers.get('cache-control')).toBe('public, max-age=3600') }) it('should not show error for invalid JSON returned from getServerSideProps', async () => {