From f6a2ac76cd6fde16ba61b173e366b5e7744f9d02 Mon Sep 17 00:00:00 2001 From: Steven Date: Mon, 2 Aug 2021 20:38:42 -0400 Subject: [PATCH] Add `next.config.js` option to override default `keepAlive` (#27709) Follow up to #27376 so users can disable keep-alive. See comment https://github.com/vercel/next.js/pull/27376#issuecomment-885415623 --- .../disabling-http-keep-alive.md | 36 ++++++ docs/manifest.json | 4 + packages/next/build/index.ts | 2 + packages/next/build/utils.ts | 4 + packages/next/export/index.ts | 1 + packages/next/export/worker.ts | 5 + packages/next/server/config-shared.ts | 5 +- packages/next/server/config.ts | 29 ++++- packages/next/server/dev/next-dev-server.ts | 7 +- .../next/server/dev/static-paths-worker.ts | 4 + packages/next/server/node-polyfill-fetch.js | 6 +- .../pages/blog/[slug].js | 23 ++++ .../node-fetch-keep-alive/test/index.test.js | 107 ++++++++++++------ 13 files changed, 189 insertions(+), 44 deletions(-) create mode 100644 docs/api-reference/next.config.js/disabling-http-keep-alive.md create mode 100644 test/integration/node-fetch-keep-alive/pages/blog/[slug].js diff --git a/docs/api-reference/next.config.js/disabling-http-keep-alive.md b/docs/api-reference/next.config.js/disabling-http-keep-alive.md new file mode 100644 index 0000000000000..bfa79ad3d8b6d --- /dev/null +++ b/docs/api-reference/next.config.js/disabling-http-keep-alive.md @@ -0,0 +1,36 @@ +--- +description: Next.js will automatically use HTTP Keep-Alive by default. Learn more about how to disable HTTP Keep-Alive here. +--- + +# Disabling HTTP Keep-Alive + +Next.js automatically polyfills [node-fetch](/docs/basic-features/supported-browsers-features#polyfills) and enables [HTTP Keep-Alive](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Keep-Alive) by default. You may want to disable HTTP Keep-Alive for certain `fetch()` calls or globally. + +For a single `fetch()` call, you can add the agent option: + +```js +import { Agent } from 'https' + +const url = 'https://example.com' +const agent = new Agent({ keepAlive: false }) +fetch(url, { agent }) +``` + +To override all `fetch()` calls globally, you can use `next.config.js`: + +```js +module.exports = { + httpAgentOptions: { + keepAlive: false, + }, +} +``` + +## Related + +
+ + Introduction to next.config.js: + Learn more about the configuration file used by Next.js. + +
diff --git a/docs/manifest.json b/docs/manifest.json index 725f7a41628ce..f49ec789bb33f 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -348,6 +348,10 @@ "title": "Disabling ETag Generation", "path": "/docs/api-reference/next.config.js/disabling-etag-generation.md" }, + { + "title": "Disabling HTTP Keep-Alive", + "path": "/docs/api-reference/next.config.js/disabling-http-keep-alive.md" + }, { "title": "Setting a custom build directory", "path": "/docs/api-reference/next.config.js/setting-a-custom-build-directory.md" diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 5dd7e2f143f4e..6621ed8ef2cf5 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -743,6 +743,7 @@ export default async function build( distDir, isLikeServerless, runtimeEnvConfig, + config.httpAgentOptions, config.i18n?.locales, config.i18n?.defaultLocale ) @@ -812,6 +813,7 @@ export default async function build( distDir, isLikeServerless, runtimeEnvConfig, + config.httpAgentOptions, config.i18n?.locales, config.i18n?.defaultLocale, isPageStaticSpan.id diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index 81559b22fa1bf..20c0913f5fc0a 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -32,6 +32,8 @@ import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import * as Log from './output/log' import { loadComponents } from '../server/load-components' import { trace } from '../telemetry/trace' +import { setHttpAgentOptions } from '../server/config' +import { NextConfigComplete } from '../server/config-shared' const fileGzipStats: { [k: string]: Promise | undefined } = {} const fsStatGzip = (file: string) => { @@ -815,6 +817,7 @@ export async function isPageStatic( distDir: string, serverless: boolean, runtimeEnvConfig: any, + httpAgentOptions: NextConfigComplete['httpAgentOptions'], locales?: string[], defaultLocale?: string, parentId?: any @@ -833,6 +836,7 @@ export async function isPageStatic( return isPageStaticSpan.traceAsyncFn(async () => { try { require('../shared/lib/runtime-config').setConfig(runtimeEnvConfig) + setHttpAgentOptions(httpAgentOptions) const components = await loadComponents(distDir, page, serverless) const mod = components.ComponentMod const Comp = mod.default || mod diff --git a/packages/next/export/index.ts b/packages/next/export/index.ts index 38c2acf09c4ab..3ed7dd6fed3b9 100644 --- a/packages/next/export/index.ts +++ b/packages/next/export/index.ts @@ -570,6 +570,7 @@ export default async function exportApp( disableOptimizedLoading: nextConfig.experimental.disableOptimizedLoading, parentSpanId: pageExportSpan.id, + httpAgentOptions: nextConfig.httpAgentOptions, }) for (const validation of result.ampValidations || []) { diff --git a/packages/next/export/worker.ts b/packages/next/export/worker.ts index 1628619590610..fcb499268df3b 100644 --- a/packages/next/export/worker.ts +++ b/packages/next/export/worker.ts @@ -19,6 +19,8 @@ import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import { trace } from '../telemetry/trace' import { isInAmpMode } from '../shared/lib/amp' import { resultFromChunks, resultToChunks } from '../server/utils' +import { NextConfigComplete } from '../server/config-shared' +import { setHttpAgentOptions } from '../server/config' const envConfig = require('../shared/lib/runtime-config') @@ -55,6 +57,7 @@ interface ExportPageInput { optimizeCss: any disableOptimizedLoading: any parentSpanId: any + httpAgentOptions: NextConfigComplete['httpAgentOptions'] } interface ExportPageResults { @@ -103,7 +106,9 @@ export default async function exportPage({ optimizeImages, optimizeCss, disableOptimizedLoading, + httpAgentOptions, }: ExportPageInput): Promise { + setHttpAgentOptions(httpAgentOptions) const exportPageSpan = trace('export-page-worker', parentSpanId) return exportPageSpan.traceAsyncFn(async () => { diff --git a/packages/next/server/config-shared.ts b/packages/next/server/config-shared.ts index a026bf9bb4fc4..481176f6f91bf 100644 --- a/packages/next/server/config-shared.ts +++ b/packages/next/server/config-shared.ts @@ -76,7 +76,7 @@ export type NextConfig = { [key: string]: any } & { reactStrictMode?: boolean publicRuntimeConfig?: { [key: string]: any } serverRuntimeConfig?: { [key: string]: any } - + httpAgentOptions?: { keepAlive?: boolean } future?: { /** * @deprecated this options was moved to the top level @@ -153,6 +153,9 @@ export const defaultConfig: NextConfig = { serverRuntimeConfig: {}, publicRuntimeConfig: {}, reactStrictMode: false, + httpAgentOptions: { + keepAlive: true, + }, experimental: { cpus: Math.max( 1, diff --git a/packages/next/server/config.ts b/packages/next/server/config.ts index 8edf55a0ed23f..b2a40a20db414 100644 --- a/packages/next/server/config.ts +++ b/packages/next/server/config.ts @@ -1,6 +1,8 @@ import chalk from 'chalk' import findUp from 'next/dist/compiled/find-up' import { basename, extname } from 'path' +import { Agent as HttpAgent } from 'http' +import { Agent as HttpsAgent } from 'https' import * as Log from '../build/output/log' import { CONFIG_FILE, PHASE_DEVELOPMENT_SERVER } from '../shared/lib/constants' import { execOnce } from '../shared/lib/utils' @@ -304,6 +306,12 @@ function assignDefaults(userConfig: { [key: string]: any }) { } } + // TODO: Change defaultConfig type to NextConfigComplete + // so we don't need "!" here. + setHttpAgentOptions( + result.httpAgentOptions || defaultConfig.httpAgentOptions! + ) + if (result.i18n) { const { i18n } = result const i18nType = typeof i18n @@ -517,7 +525,9 @@ export default async function loadConfig( } } - return defaultConfig as NextConfigComplete + const completeConfig = defaultConfig as NextConfigComplete + setHttpAgentOptions(completeConfig.httpAgentOptions) + return completeConfig } export function isTargetLikeServerless(target: string) { @@ -525,3 +535,20 @@ export function isTargetLikeServerless(target: string) { const isServerlessTrace = target === 'experimental-serverless-trace' return isServerless || isServerlessTrace } + +export function setHttpAgentOptions( + options: NextConfigComplete['httpAgentOptions'] +) { + if ((global as any).__NEXT_HTTP_AGENT) { + // We only need to assign once because we want + // to resuse the same agent for all requests. + return + } + + if (!options) { + throw new Error('Expected config.httpAgentOptions to be an object') + } + + ;(global as any).__NEXT_HTTP_AGENT = new HttpAgent(options) + ;(global as any).__NEXT_HTTPS_AGENT = new HttpsAgent(options) +} diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index 95d6245727f9a..33140bff3451b 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -566,7 +566,11 @@ export default class DevServer extends Server { // from waiting on them for the page to load in dev mode const __getStaticPaths = async () => { - const { publicRuntimeConfig, serverRuntimeConfig } = this.nextConfig + const { + publicRuntimeConfig, + serverRuntimeConfig, + httpAgentOptions, + } = this.nextConfig const { locales, defaultLocale } = this.nextConfig.i18n || {} const paths = await this.staticPathsWorker.loadStaticPaths( @@ -577,6 +581,7 @@ export default class DevServer extends Server { publicRuntimeConfig, serverRuntimeConfig, }, + httpAgentOptions, locales, defaultLocale ) diff --git a/packages/next/server/dev/static-paths-worker.ts b/packages/next/server/dev/static-paths-worker.ts index eb29a6b99d81f..45af71803219c 100644 --- a/packages/next/server/dev/static-paths-worker.ts +++ b/packages/next/server/dev/static-paths-worker.ts @@ -1,4 +1,6 @@ import { buildStaticPaths } from '../../build/utils' +import { setHttpAgentOptions } from '../config' +import { NextConfigComplete } from '../config-shared' import { loadComponents } from '../load-components' import '../node-polyfill-fetch' @@ -14,6 +16,7 @@ export async function loadStaticPaths( pathname: string, serverless: boolean, config: RuntimeConfig, + httpAgentOptions: NextConfigComplete['httpAgentOptions'], locales?: string[], defaultLocale?: string ) { @@ -25,6 +28,7 @@ export async function loadStaticPaths( // update work memory runtime-config require('../../shared/lib/runtime-config').setConfig(config) + setHttpAgentOptions(httpAgentOptions) const components = await loadComponents(distDir, pathname, serverless) diff --git a/packages/next/server/node-polyfill-fetch.js b/packages/next/server/node-polyfill-fetch.js index 4896e53cc08e9..dfc99ecaf2c61 100644 --- a/packages/next/server/node-polyfill-fetch.js +++ b/packages/next/server/node-polyfill-fetch.js @@ -1,13 +1,9 @@ import fetch, { Headers, Request, Response } from 'node-fetch' -import { Agent as HttpAgent } from 'http' -import { Agent as HttpsAgent } from 'https' // Polyfill fetch() in the Node.js environment if (!global.fetch) { - const httpAgent = new HttpAgent({ keepAlive: true }) - const httpsAgent = new HttpsAgent({ keepAlive: true }) const agent = ({ protocol }) => - protocol === 'http:' ? httpAgent : httpsAgent + protocol === 'http:' ? global.__NEXT_HTTP_AGENT : global.__NEXT_HTTPS_AGENT const fetchWithAgent = (url, opts, ...rest) => { if (!opts) { opts = { agent } diff --git a/test/integration/node-fetch-keep-alive/pages/blog/[slug].js b/test/integration/node-fetch-keep-alive/pages/blog/[slug].js new file mode 100644 index 0000000000000..307b8469e02a2 --- /dev/null +++ b/test/integration/node-fetch-keep-alive/pages/blog/[slug].js @@ -0,0 +1,23 @@ +export default function Blog(props) { + return
{JSON.stringify(props)}
+} + +export async function getStaticProps({ params: { slug } }) { + return { props: { slug } } +} + +export async function getStaticPaths() { + const res = await fetch('http://localhost:44001') + const obj = await res.json() + if (obj.connection === 'keep-alive') { + return { + paths: [{ params: { slug: 'first' } }], + fallback: false, + } + } + + return { + paths: [], + fallback: false, + } +} diff --git a/test/integration/node-fetch-keep-alive/test/index.test.js b/test/integration/node-fetch-keep-alive/test/index.test.js index b78b036bce252..3dd98af9fae09 100644 --- a/test/integration/node-fetch-keep-alive/test/index.test.js +++ b/test/integration/node-fetch-keep-alive/test/index.test.js @@ -7,6 +7,7 @@ import { nextBuild, findPort, nextStart, + launchApp, killApp, } from 'next-test-utils' import webdriver from 'next-webdriver' @@ -20,47 +21,81 @@ let app let mockServer describe('node-fetch-keep-alive', () => { - beforeAll(async () => { - mockServer = createServer((req, res) => { - // we can test request headers by sending them - // back with the response - const { connection } = req.headers - res.end(JSON.stringify({ connection })) + describe('dev', () => { + beforeAll(async () => { + mockServer = createServer((req, res) => { + // we can test request headers by sending them + // back with the response + const { connection } = req.headers + res.end(JSON.stringify({ connection })) + }) + mockServer.listen(44001) + appPort = await findPort() + app = await launchApp(appDir, appPort) }) - mockServer.listen(44001) - const { stdout, stderr } = await nextBuild(appDir, [], { - stdout: true, - stderr: true, + afterAll(async () => { + await killApp(app) + mockServer.close() }) - if (stdout) console.log(stdout) - if (stderr) console.error(stderr) - appPort = await findPort() - app = await nextStart(appDir, appPort) - }) - afterAll(async () => { - await killApp(app) - mockServer.close() - }) - it('should send keep-alive for json API', async () => { - const res = await fetchViaHTTP(appPort, '/api/json') - const obj = await res.json() - expect(obj).toEqual({ connection: 'keep-alive' }) + runTests() }) - it('should send keep-alive for getStaticProps', async () => { - const browser = await webdriver(appPort, '/ssg') - const props = await browser.elementById('props').text() - const obj = JSON.parse(props) - expect(obj).toEqual({ connection: 'keep-alive' }) - await browser.close() - }) + describe('production', () => { + beforeAll(async () => { + mockServer = createServer((req, res) => { + // we can test request headers by sending them + // back with the response + const { connection } = req.headers + res.end(JSON.stringify({ connection })) + }) + mockServer.listen(44001) + const { stdout, stderr } = await nextBuild(appDir, [], { + stdout: true, + stderr: true, + }) + if (stdout) console.log(stdout) + if (stderr) console.error(stderr) + appPort = await findPort() + app = await nextStart(appDir, appPort) + }) + afterAll(async () => { + await killApp(app) + mockServer.close() + }) - it('should send keep-alive for getServerSideProps', async () => { - const browser = await webdriver(appPort, '/ssr') - const props = await browser.elementById('props').text() - const obj = JSON.parse(props) - expect(obj).toEqual({ connection: 'keep-alive' }) - await browser.close() + runTests() }) + + function runTests() { + it('should send keep-alive for json API', async () => { + const res = await fetchViaHTTP(appPort, '/api/json') + const obj = await res.json() + expect(obj).toEqual({ connection: 'keep-alive' }) + }) + + it('should send keep-alive for getStaticProps', async () => { + const browser = await webdriver(appPort, '/ssg') + const props = await browser.elementById('props').text() + const obj = JSON.parse(props) + expect(obj).toEqual({ connection: 'keep-alive' }) + await browser.close() + }) + + it('should send keep-alive for getStaticPaths', async () => { + const browser = await webdriver(appPort, '/blog/first') + const props = await browser.elementById('props').text() + const obj = JSON.parse(props) + expect(obj).toEqual({ slug: 'first' }) + await browser.close() + }) + + it('should send keep-alive for getServerSideProps', async () => { + const browser = await webdriver(appPort, '/ssr') + const props = await browser.elementById('props').text() + const obj = JSON.parse(props) + expect(obj).toEqual({ connection: 'keep-alive' }) + await browser.close() + }) + } })