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/getserverprops/test/index.test.js b/test/integration/getserverprops/test/index.test.js index 1c932da181e2a68..283560deff2416a 100644 --- a/test/integration/getserverprops/test/index.test.js +++ b/test/integration/getserverprops/test/index.test.js @@ -1,21 +1,23 @@ /* eslint-env jest */ /* global jasmine */ -import fs from 'fs-extra' -import { join } from 'path' -import webdriver from 'next-webdriver' import cheerio from 'cheerio' import escapeRegex from 'escape-string-regexp' +import fs from 'fs-extra' import { - renderViaHTTP, + check, fetchViaHTTP, findPort, - launchApp, + getBrowserBodyText, killApp, - waitFor, + launchApp, nextBuild, nextStart, normalizeRegEx, + renderViaHTTP, + waitFor, } from 'next-test-utils' +import webdriver from 'next-webdriver' +import { join } from 'path' jasmine.DEFAULT_TIMEOUT_INTERVAL = 1000 * 60 * 2 const appDir = join(__dirname, '..') @@ -23,73 +25,86 @@ const nextConfig = join(appDir, 'next.config.js') let app let appPort let buildId +let stderr -const expectedManifestRoutes = () => ({ - '/something': { - page: '/something', +const expectedManifestRoutes = () => [ + { dataRouteRegex: normalizeRegEx( - `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/something.json$` + `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/index.json$` ), + page: '/', }, - '/blog/[post]': { - page: '/blog/[post]', + { dataRouteRegex: normalizeRegEx( - `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/blog\\/([^/]+?)\\.json$` + `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/another.json$` ), + page: '/another', }, - '/': { - page: '/', + { dataRouteRegex: normalizeRegEx( - `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/index.json$` + `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/blog.json$` ), + page: '/blog', }, - '/default-revalidate': { - page: '/default-revalidate', + { dataRouteRegex: normalizeRegEx( - `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/default-revalidate.json$` + `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/blog\\/([^\\/]+?)\\.json$` ), + page: '/blog/[post]', }, - '/catchall/[...path]': { - page: '/catchall/[...path]', + { + dataRouteRegex: normalizeRegEx( + `^\\/_next\\/data\\/${escapeRegex( + buildId + )}\\/blog\\/([^\\/]+?)\\/([^\\/]+?)\\.json$` + ), + page: '/blog/[post]/[comment]', + }, + { dataRouteRegex: normalizeRegEx( `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/catchall\\/(.+?)\\.json$` ), + page: '/catchall/[...path]', }, - '/blog': { - page: '/blog', + { dataRouteRegex: normalizeRegEx( - `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/blog.json$` + `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/custom-cache.json$` ), + page: '/custom-cache', }, - '/blog/[post]/[comment]': { - page: '/blog/[post]/[comment]', + { dataRouteRegex: normalizeRegEx( - `^\\/_next\\/data\\/${escapeRegex( - buildId - )}\\/blog\\/([^/]+?)\\/([^/]+?)\\.json$` + `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/default-revalidate.json$` ), + page: '/default-revalidate', }, - '/user/[user]/profile': { - page: '/user/[user]/profile', + { dataRouteRegex: normalizeRegEx( - `^\\/_next\\/data\\/${escapeRegex( - buildId - )}\\/user\\/([^/]+?)\\/profile\\.json$` + `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/invalid-keys.json$` ), + page: '/invalid-keys', }, - '/another': { - page: '/another', + { dataRouteRegex: normalizeRegEx( - `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/another.json$` + `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/non-json.json$` ), + page: '/non-json', }, - '/invalid-keys': { + { dataRouteRegex: normalizeRegEx( - `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/invalid-keys.json$` + `^\\/_next\\/data\\/${escapeRegex(buildId)}\\/something.json$` ), - page: '/invalid-keys', + page: '/something', }, -}) + { + dataRouteRegex: normalizeRegEx( + `^\\/_next\\/data\\/${escapeRegex( + buildId + )}\\/user\\/([^\\/]+?)\\/profile\\.json$` + ), + page: '/user/[user]/profile', + }, +] const navigateTest = (dev = false) => { it('should navigate between pages successfully', async () => { @@ -185,11 +200,23 @@ const runTests = (dev = false) => { expect(html).toMatch(/hello.*?world/) }) - it('should SSR getServerProps page correctly', async () => { + it('should SSR getServerSideProps page correctly', async () => { const html = await renderViaHTTP(appPort, '/blog/post-1') expect(html).toMatch(/Post:.*?post-1/) }) + it('should have gssp in __NEXT_DATA__', async () => { + const html = await renderViaHTTP(appPort, '/') + const $ = cheerio.load(html) + expect(JSON.parse($('#__NEXT_DATA__').text()).gssp).toBe(true) + }) + + it('should not have gssp in __NEXT_DATA__ for non-GSSP page', async () => { + const html = await renderViaHTTP(appPort, '/normal') + const $ = cheerio.load(html) + expect('gssp' in JSON.parse($('#__NEXT_DATA__').text())).toBe(false) + }) + it('should supply query values SSR', async () => { const html = await renderViaHTTP(appPort, '/blog/post-1?hello=world') const $ = cheerio.load(html) @@ -282,13 +309,13 @@ const runTests = (dev = false) => { it('should reload page on failed data request', async () => { const browser = await webdriver(appPort, '/') await waitFor(500) - await browser.eval('window.beforeClick = true') + await browser.eval('window.beforeClick = "abc"') await browser.elementByCss('#broken-post').click() await waitFor(1000) - expect(await browser.eval('window.beforeClick')).not.toBe('true') + expect(await browser.eval('window.beforeClick')).not.toBe('abc') }) - it('should always call getServerProps without caching', async () => { + it('should always call getServerSideProps without caching', async () => { const initialRes = await fetchViaHTTP(appPort, '/something') const initialHtml = await initialRes.text() expect(initialHtml).toMatch(/hello.*?world/) @@ -304,7 +331,7 @@ const runTests = (dev = false) => { expect(newHtml !== newerHtml).toBe(true) }) - it('should not re-call getServerProps when updating query', async () => { + it('should not re-call getServerSideProps when updating query', async () => { const browser = await webdriver(appPort, '/something?hello=world') await waitFor(2000) @@ -322,15 +349,57 @@ const runTests = (dev = false) => { }) if (dev) { - it('should show error for extra keys returned from getServerProps', async () => { + it('should not show warning from url prop being returned', async () => { + const urlPropPage = join(appDir, 'pages/url-prop.js') + await fs.writeFile( + urlPropPage, + ` + export async function getServerSideProps() { + return { + props: { + url: 'something' + } + } + } + + export default ({ url }) =>

url: {url}

+ ` + ) + + const html = await renderViaHTTP(appPort, '/url-prop') + await fs.remove(urlPropPage) + expect(stderr).not.toMatch( + /The prop `url` is a reserved prop in Next.js for legacy reasons and will be overridden on page \/url-prop/ + ) + expect(html).toMatch(/url:.*?something/) + }) + + it('should show error for extra keys returned from getServerSideProps', async () => { const html = await renderViaHTTP(appPort, '/invalid-keys') expect(html).toContain( - `Additional keys were returned from \`getServerProps\`. Properties intended for your component must be nested under the \`props\` key, e.g.:` + `Additional keys were returned from \`getServerSideProps\`. Properties intended for your component must be nested under the \`props\` key, e.g.:` ) expect(html).toContain( `Keys that need to be moved: world, query, params, time, random` ) }) + + it('should show error for invalid JSON returned from getServerSideProps', async () => { + const html = await renderViaHTTP(appPort, '/non-json') + expect(html).toContain( + 'Error serializing `.time` returned from `getServerSideProps`' + ) + }) + + it('should show error for invalid JSON returned from getStaticProps on CST', async () => { + const browser = await webdriver(appPort, '/') + await browser.elementByCss('#non-json').click() + + await check( + () => getBrowserBodyText(browser), + /Error serializing `.time` returned from `getServerSideProps`/ + ) + }) } else { it('should not fetch data on mount', async () => { const browser = await webdriver(appPort, '/blog/post-100') @@ -341,32 +410,66 @@ const runTests = (dev = false) => { }) it('should output routes-manifest correctly', async () => { - const { serverPropsRoutes } = await fs.readJSON( + const { dataRoutes } = await fs.readJSON( join(appDir, '.next/routes-manifest.json') ) - for (const key of Object.keys(serverPropsRoutes)) { - const val = serverPropsRoutes[key].dataRouteRegex - serverPropsRoutes[key].dataRouteRegex = normalizeRegEx(val) + for (const route of dataRoutes) { + route.dataRouteRegex = normalizeRegEx(route.dataRouteRegex) } - expect(serverPropsRoutes).toEqual(expectedManifestRoutes()) + 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/${escapeRegex(buildId)}/something.json` + `/_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 () => { + const html = await renderViaHTTP(appPort, '/non-json') + expect(html).not.toContain('Error serializing') + expect(html).toContain('hello ') + }) + + it('should not show error for invalid JSON returned from getStaticProps on CST', async () => { + const browser = await webdriver(appPort, '/') + await browser.elementByCss('#non-json').click() + await check(() => getBrowserBodyText(browser), /hello /) }) } } -describe('unstable_getServerProps', () => { +describe('getServerSideProps', () => { describe('dev mode', () => { beforeAll(async () => { + stderr = '' appPort = await findPort() - app = await launchApp(appDir, appPort) + app = await launchApp(appDir, appPort, { + onStderr(msg) { + stderr += msg + }, + }) buildId = 'development' }) afterAll(() => killApp(app)) @@ -382,8 +485,13 @@ describe('unstable_getServerProps', () => { 'utf8' ) await nextBuild(appDir) + stderr = '' appPort = await findPort() - app = await nextStart(appDir, appPort) + app = await nextStart(appDir, appPort, { + onStderr(msg) { + stderr += msg + }, + }) buildId = await fs.readFile(join(appDir, '.next/BUILD_ID'), 'utf8') }) afterAll(() => killApp(app)) 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/prerender-preview/pages/index.js b/test/integration/prerender-preview/pages/index.js index a9da9aa293579a9..d48204d469bf744 100644 --- a/test/integration/prerender-preview/pages/index.js +++ b/test/integration/prerender-preview/pages/index.js @@ -1,5 +1,13 @@ -export function unstable_getStaticProps({ preview, previewData }) { - return { props: { hasProps: true, 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, + preview: !!preview, + previewData: previewData || null, + }, + } } export default function({ hasProps, preview, previewData }) {