From 22ca85946ee87a7af2d967565aeb4efa4b1ec978 Mon Sep 17 00:00:00 2001 From: Zack Tanner Date: Wed, 26 Jul 2023 16:19:46 -0700 Subject: [PATCH] Wrap incremental cache in an IPC server (#53030) This uses an IPC server (if available) for incremental cache methods to help prevent race conditions when reading/writing from cache and also to dedupe requests in cases where multiple cache reads are in flight. This cuts down on data fetching across the different build-time workers. Co-authored-by: JJ Kasper <22380829+ijjk@users.noreply.github.com> --- packages/next/src/build/index.ts | 58 +++++++++- .../next/src/server/dev/next-dev-server.ts | 73 ++++++------ .../server/lib/incremental-cache-server.ts | 44 ++++++++ .../src/server/lib/incremental-cache/index.ts | 104 ++++++++++++++++++ packages/next/src/server/lib/patch-fetch.ts | 14 ++- .../next/src/server/lib/server-ipc/index.ts | 3 +- .../server/lib/server-ipc/invoke-request.ts | 2 - .../server/lib/server-ipc/request-utils.ts | 70 ++++++++++++ packages/next/src/server/lib/start-server.ts | 2 + packages/next/src/server/next-server.ts | 2 +- packages/next/src/server/render.tsx | 28 ----- packages/next/src/server/web-server.ts | 2 +- .../actions/app/revalidate-multiple/page.js | 41 +++++++ .../app-static-request-deduping.test.ts | 50 +++++++++ .../app/blog/[slug]/page.js | 32 ++++++ .../app-static-request-deduping/app/layout.js | 10 ++ 16 files changed, 455 insertions(+), 80 deletions(-) create mode 100644 packages/next/src/server/lib/incremental-cache-server.ts create mode 100644 packages/next/src/server/lib/server-ipc/request-utils.ts create mode 100644 test/e2e/app-dir/actions/app/revalidate-multiple/page.js create mode 100644 test/e2e/app-dir/app-static-request-deduping/app-static-request-deduping.test.ts create mode 100644 test/e2e/app-dir/app-static-request-deduping/app/blog/[slug]/page.js create mode 100644 test/e2e/app-dir/app-static-request-deduping/app/layout.js diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index cfe311c2c9938..c71e2725dc8bd 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -146,6 +146,8 @@ import { defaultOverrides, experimentalOverrides, } from '../server/require-hook' +import { initialize } from '../server/lib/incremental-cache-server' +import { nodeFs } from '../server/lib/node-fs-methods' export type SsgRoute = { initialRevalidateSeconds: number | false @@ -1179,7 +1181,11 @@ export default async function build( ) : config.experimental.cpus || 4 - function createStaticWorker(type: 'app' | 'pages') { + function createStaticWorker( + type: 'app' | 'pages', + ipcPort: number, + ipcValidationKey: string + ) { let infoPrinted = false return new Worker(staticWorkerPath, { @@ -1217,6 +1223,8 @@ export default async function build( forkOptions: { env: { ...process.env, + __NEXT_INCREMENTAL_CACHE_IPC_PORT: ipcPort + '', + __NEXT_INCREMENTAL_CACHE_IPC_KEY: ipcValidationKey, __NEXT_PRIVATE_PREBUNDLED_REACT: type === 'app' ? config.experimental.serverActions @@ -1248,9 +1256,45 @@ export default async function build( > } - const pagesStaticWorkers = createStaticWorker('pages') + let CacheHandler: any + + if (incrementalCacheHandlerPath) { + CacheHandler = require(path.isAbsolute(incrementalCacheHandlerPath) + ? incrementalCacheHandlerPath + : path.join(dir, incrementalCacheHandlerPath)) + } + + const { ipcPort, ipcValidationKey } = await initialize({ + fs: nodeFs, + dev: false, + appDir: isAppDirEnabled, + fetchCache: isAppDirEnabled, + flushToDisk: config.experimental.isrFlushToDisk, + serverDistDir: path.join(distDir, 'server'), + fetchCacheKeyPrefix: config.experimental.fetchCacheKeyPrefix, + maxMemoryCacheSize: config.experimental.isrMemoryCacheSize, + getPrerenderManifest: () => ({ + version: -1 as any, // letting us know this doesn't conform to spec + routes: {}, + dynamicRoutes: {}, + notFoundRoutes: [], + preview: null as any, // `preview` is special case read in next-dev-server + }), + requestHeaders: {}, + CurCacheHandler: CacheHandler, + minimalMode: ciEnvironment.hasNextSupport, + + allowedRevalidateHeaderKeys: + config.experimental.allowedRevalidateHeaderKeys, + }) + + const pagesStaticWorkers = createStaticWorker( + 'pages', + ipcPort, + ipcValidationKey + ) const appStaticWorkers = isAppDirEnabled - ? createStaticWorker('app') + ? createStaticWorker('app', ipcPort, ipcValidationKey) : undefined const analysisBegin = process.hrtime() @@ -3181,8 +3225,12 @@ export default async function build( const exportApp: typeof import('../export').default = require('../export').default - const pagesWorker = createStaticWorker('pages') - const appWorker = createStaticWorker('app') + const pagesWorker = createStaticWorker( + 'pages', + ipcPort, + ipcValidationKey + ) + const appWorker = createStaticWorker('app', ipcPort, ipcValidationKey) const options: ExportOptions = { isInvokedFromCli: false, diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 2a1d043fbc1c4..80ae75f5a9b47 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -60,9 +60,12 @@ import { NextBuildContext } from '../../build/build-context' import { IncrementalCache } from '../lib/incremental-cache' import LRUCache from 'next/dist/compiled/lru-cache' import { NextUrlWithParsedQuery } from '../request-meta' -import { deserializeErr, errorToJSON } from '../render' -import { invokeRequest } from '../lib/server-ipc/invoke-request' +import { errorToJSON } from '../render' import { getMiddlewareRouteMatcher } from '../../shared/lib/router/utils/middleware-route-matcher' +import { + deserializeErr, + invokeIpcMethod, +} from '../lib/server-ipc/request-utils' // Load ReactDevOverlay only when needed let ReactDevOverlayImpl: FunctionComponent @@ -478,46 +481,18 @@ export default class DevServer extends Server { } } - private async invokeIpcMethod(method: string, args: any[]): Promise { - const ipcPort = process.env.__NEXT_PRIVATE_ROUTER_IPC_PORT - const ipcKey = process.env.__NEXT_PRIVATE_ROUTER_IPC_KEY - if (ipcPort) { - const res = await invokeRequest( - `http://${this.hostname}:${ipcPort}?key=${ipcKey}&method=${ - method as string - }&args=${encodeURIComponent(JSON.stringify(args))}`, - { - method: 'GET', - headers: {}, - } - ) - const body = await res.text() - - if (body.startsWith('{') && body.endsWith('}')) { - const parsedBody = JSON.parse(body) - - if ( - parsedBody && - typeof parsedBody === 'object' && - 'err' in parsedBody && - 'stack' in parsedBody.err - ) { - throw deserializeErr(parsedBody.err) - } - return parsedBody - } - } - } - protected async logErrorWithOriginalStack( err?: unknown, type?: 'unhandledRejection' | 'uncaughtException' | 'warning' | 'app-dir' ) { if (this.isRenderWorker) { - await this.invokeIpcMethod('logErrorWithOriginalStack', [ - errorToJSON(err as Error), - type, - ]) + await invokeIpcMethod({ + hostname: this.hostname, + method: 'logErrorWithOriginalStack', + args: [errorToJSON(err as Error), type], + ipcPort: process.env.__NEXT_PRIVATE_ROUTER_IPC_PORT, + ipcKey: process.env.__NEXT_PRIVATE_ROUTER_IPC_KEY, + }) return } throw new Error( @@ -767,7 +742,13 @@ export default class DevServer extends Server { match?: RouteMatch }) { if (this.isRenderWorker) { - await this.invokeIpcMethod('ensurePage', [opts]) + await invokeIpcMethod({ + hostname: this.hostname, + method: 'ensurePage', + args: [opts], + ipcPort: process.env.__NEXT_PRIVATE_ROUTER_IPC_PORT, + ipcKey: process.env.__NEXT_PRIVATE_ROUTER_IPC_KEY, + }) return } throw new Error('Invariant ensurePage called outside render worker') @@ -826,7 +807,13 @@ export default class DevServer extends Server { protected async getFallbackErrorComponents(): Promise { if (this.isRenderWorker) { - await this.invokeIpcMethod('getFallbackErrorComponents', []) + await invokeIpcMethod({ + hostname: this.hostname, + method: 'getFallbackErrorComponents', + args: [], + ipcPort: process.env.__NEXT_PRIVATE_ROUTER_IPC_PORT, + ipcKey: process.env.__NEXT_PRIVATE_ROUTER_IPC_KEY, + }) return await loadDefaultErrorComponents(this.distDir) } throw new Error( @@ -836,7 +823,13 @@ export default class DevServer extends Server { async getCompilationError(page: string): Promise { if (this.isRenderWorker) { - const err = await this.invokeIpcMethod('getCompilationError', [page]) + const err = await invokeIpcMethod({ + hostname: this.hostname, + method: 'getCompilationError', + args: [page], + ipcPort: process.env.__NEXT_PRIVATE_ROUTER_IPC_PORT, + ipcKey: process.env.__NEXT_PRIVATE_ROUTER_IPC_KEY, + }) return deserializeErr(err) } throw new Error( diff --git a/packages/next/src/server/lib/incremental-cache-server.ts b/packages/next/src/server/lib/incremental-cache-server.ts new file mode 100644 index 0000000000000..0f4a19f1801a7 --- /dev/null +++ b/packages/next/src/server/lib/incremental-cache-server.ts @@ -0,0 +1,44 @@ +import { createIpcServer } from './server-ipc' +import { IncrementalCache } from './incremental-cache' + +let initializeResult: + | undefined + | { + ipcPort: number + ipcValidationKey: string + } + +export async function initialize( + ...constructorArgs: ConstructorParameters +): Promise> { + const incrementalCache = new IncrementalCache(...constructorArgs) + + const { ipcPort, ipcValidationKey } = await createIpcServer({ + async revalidateTag( + ...args: Parameters + ) { + return incrementalCache.revalidateTag(...args) + }, + + async get(...args: Parameters) { + return incrementalCache.get(...args) + }, + + async set(...args: Parameters) { + return incrementalCache.set(...args) + }, + + async lock(...args: Parameters) { + return incrementalCache.lock(...args) + }, + + async unlock(...args: Parameters) { + return incrementalCache.unlock(...args) + }, + } as any) + + return { + ipcPort, + ipcValidationKey, + } +} diff --git a/packages/next/src/server/lib/incremental-cache/index.ts b/packages/next/src/server/lib/incremental-cache/index.ts index 569d8b0a89242..588bc0162a89d 100644 --- a/packages/next/src/server/lib/incremental-cache/index.ts +++ b/packages/next/src/server/lib/incremental-cache/index.ts @@ -74,6 +74,8 @@ export class IncrementalCache { fetchCacheKeyPrefix?: string revalidatedTags?: string[] isOnDemandRevalidate?: boolean + private locks = new Map>() + private unlocks = new Map Promise>() constructor({ fs, @@ -200,7 +202,76 @@ export class IncrementalCache { return fetchCache ? pathname : normalizePagePath(pathname) } + async unlock(cacheKey: string) { + const unlock = this.unlocks.get(cacheKey) + if (unlock) { + unlock() + this.locks.delete(cacheKey) + this.unlocks.delete(cacheKey) + } + } + + async lock(cacheKey: string) { + if ( + process.env.__NEXT_INCREMENTAL_CACHE_IPC_PORT && + process.env.__NEXT_INCREMENTAL_CACHE_IPC_KEY && + process.env.NEXT_RUNTIME !== 'edge' + ) { + const invokeIpcMethod = require('../server-ipc/request-utils') + .invokeIpcMethod as typeof import('../server-ipc/request-utils').invokeIpcMethod + + await invokeIpcMethod({ + method: 'lock', + ipcPort: process.env.__NEXT_INCREMENTAL_CACHE_IPC_PORT, + ipcKey: process.env.__NEXT_INCREMENTAL_CACHE_IPC_KEY, + args: [cacheKey], + }) + + return async () => { + await invokeIpcMethod({ + method: 'unlock', + ipcPort: process.env.__NEXT_INCREMENTAL_CACHE_IPC_PORT, + ipcKey: process.env.__NEXT_INCREMENTAL_CACHE_IPC_KEY, + args: [cacheKey], + }) + } + } + + let unlockNext: () => Promise = () => Promise.resolve() + const existingLock = this.locks.get(cacheKey) + + if (existingLock) { + await existingLock + } else { + const newLock = new Promise((resolve) => { + unlockNext = async () => { + resolve() + } + }) + + this.locks.set(cacheKey, newLock) + this.unlocks.set(cacheKey, unlockNext) + } + + return unlockNext + } + async revalidateTag(tag: string) { + if ( + process.env.__NEXT_INCREMENTAL_CACHE_IPC_PORT && + process.env.__NEXT_INCREMENTAL_CACHE_IPC_KEY && + process.env.NEXT_RUNTIME !== 'edge' + ) { + const invokeIpcMethod = require('../server-ipc/request-utils') + .invokeIpcMethod as typeof import('../server-ipc/request-utils').invokeIpcMethod + return invokeIpcMethod({ + method: 'revalidateTag', + ipcPort: process.env.__NEXT_INCREMENTAL_CACHE_IPC_PORT, + ipcKey: process.env.__NEXT_INCREMENTAL_CACHE_IPC_KEY, + args: [...arguments], + }) + } + return this.cacheHandler?.revalidateTag?.(tag) } @@ -328,6 +399,22 @@ export class IncrementalCache { fetchUrl?: string, fetchIdx?: number ): Promise { + if ( + process.env.__NEXT_INCREMENTAL_CACHE_IPC_PORT && + process.env.__NEXT_INCREMENTAL_CACHE_IPC_KEY && + process.env.NEXT_RUNTIME !== 'edge' + ) { + const invokeIpcMethod = require('../server-ipc/request-utils') + .invokeIpcMethod as typeof import('../server-ipc/request-utils').invokeIpcMethod + + return invokeIpcMethod({ + method: 'get', + ipcPort: process.env.__NEXT_INCREMENTAL_CACHE_IPC_PORT, + ipcKey: process.env.__NEXT_INCREMENTAL_CACHE_IPC_KEY, + args: [...arguments], + }) + } + // we don't leverage the prerender cache in dev mode // so that getStaticProps is always called for easier debugging if ( @@ -339,6 +426,7 @@ export class IncrementalCache { pathname = this._getPathname(pathname, fetchCache) let entry: IncrementalCacheEntry | null = null + const cacheData = await this.cacheHandler?.get( pathname, fetchCache, @@ -432,6 +520,22 @@ export class IncrementalCache { fetchUrl?: string, fetchIdx?: number ) { + if ( + process.env.__NEXT_INCREMENTAL_CACHE_IPC_PORT && + process.env.__NEXT_INCREMENTAL_CACHE_IPC_KEY && + process.env.NEXT_RUNTIME !== 'edge' + ) { + const invokeIpcMethod = require('../server-ipc/request-utils') + .invokeIpcMethod as typeof import('../server-ipc/request-utils').invokeIpcMethod + + return invokeIpcMethod({ + method: 'set', + ipcPort: process.env.__NEXT_INCREMENTAL_CACHE_IPC_PORT, + ipcKey: process.env.__NEXT_INCREMENTAL_CACHE_IPC_KEY, + args: [...arguments], + }) + } + if (this.dev && !fetchCache) return // fetchCache has upper limit of 2MB per-entry currently if (fetchCache && JSON.stringify(data).length > 2 * 1024 * 1024) { diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index 1efa546781645..f8fd844808606 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -402,7 +402,13 @@ export function patchFetch({ }) } - if (cacheKey && staticGenerationStore?.incrementalCache) { + let handleUnlock = () => Promise.resolve() + + if (cacheKey && staticGenerationStore.incrementalCache) { + handleUnlock = await staticGenerationStore.incrementalCache.lock( + cacheKey + ) + const entry = staticGenerationStore.isOnDemandRevalidate ? null : await staticGenerationStore.incrementalCache.get( @@ -413,6 +419,10 @@ export function patchFetch({ fetchIdx ) + if (entry) { + await handleUnlock() + } + if (entry?.value && entry.value.kind === 'FETCH') { const currentTags = entry.value.data.tags // when stale and is revalidating we wait for fresh data @@ -535,7 +545,7 @@ export function patchFetch({ } } - return doOriginalFetch() + return doOriginalFetch().finally(handleUnlock) } ) } diff --git a/packages/next/src/server/lib/server-ipc/index.ts b/packages/next/src/server/lib/server-ipc/index.ts index ff61000bb6ab0..1194834f623dd 100644 --- a/packages/next/src/server/lib/server-ipc/index.ts +++ b/packages/next/src/server/lib/server-ipc/index.ts @@ -2,10 +2,11 @@ import type NextServer from '../../next-server' import type { NextConfigComplete } from '../../config-shared' import { getNodeOptionsWithoutInspect } from '../utils' -import { deserializeErr, errorToJSON } from '../../render' +import { errorToJSON } from '../../render' import crypto from 'crypto' import isError from '../../../lib/is-error' import { genRenderExecArgv } from '../worker-utils' +import { deserializeErr } from './request-utils' // we can't use process.send as jest-worker relies on // it already and can cause unexpected message errors diff --git a/packages/next/src/server/lib/server-ipc/invoke-request.ts b/packages/next/src/server/lib/server-ipc/invoke-request.ts index c9370983329ad..d50fdddf25df0 100644 --- a/packages/next/src/server/lib/server-ipc/invoke-request.ts +++ b/packages/next/src/server/lib/server-ipc/invoke-request.ts @@ -1,5 +1,3 @@ -import '../../node-polyfill-fetch' - import type { IncomingMessage } from 'http' import type { Readable } from 'stream' import { filterReqHeaders } from './utils' diff --git a/packages/next/src/server/lib/server-ipc/request-utils.ts b/packages/next/src/server/lib/server-ipc/request-utils.ts new file mode 100644 index 0000000000000..d9c453edaf7c2 --- /dev/null +++ b/packages/next/src/server/lib/server-ipc/request-utils.ts @@ -0,0 +1,70 @@ +import { PageNotFoundError } from '../../../shared/lib/utils' +import { invokeRequest } from './invoke-request' + +export const deserializeErr = (serializedErr: any) => { + if ( + !serializedErr || + typeof serializedErr !== 'object' || + !serializedErr.stack + ) { + return serializedErr + } + let ErrorType: any = Error + + if (serializedErr.name === 'PageNotFoundError') { + ErrorType = PageNotFoundError + } + + const err = new ErrorType(serializedErr.message) + err.stack = serializedErr.stack + err.name = serializedErr.name + ;(err as any).digest = serializedErr.digest + + if (process.env.NEXT_RUNTIME !== 'edge') { + const { decorateServerError } = + require('next/dist/compiled/@next/react-dev-overlay/dist/middleware') as typeof import('next/dist/compiled/@next/react-dev-overlay/dist/middleware') + decorateServerError(err, serializedErr.source || 'server') + } + return err +} + +export async function invokeIpcMethod({ + hostname = '127.0.0.1', + method, + args, + ipcPort, + ipcKey, +}: { + hostname?: string + method: string + args: any[] + ipcPort?: string + ipcKey?: string +}): Promise { + if (ipcPort) { + const res = await invokeRequest( + `http://${hostname}:${ipcPort}?key=${ipcKey}&method=${ + method as string + }&args=${encodeURIComponent(JSON.stringify(args))}`, + { + method: 'GET', + headers: {}, + } + ) + const body = await res.text() + + if (body.startsWith('{') && body.endsWith('}')) { + const parsedBody = JSON.parse(body) + + if ( + parsedBody && + typeof parsedBody === 'object' && + 'err' in parsedBody && + 'stack' in parsedBody.err + ) { + throw deserializeErr(parsedBody.err) + } + return parsedBody + } + } +} diff --git a/packages/next/src/server/lib/start-server.ts b/packages/next/src/server/lib/start-server.ts index cd7538455db66..ca386b7fa0e8b 100644 --- a/packages/next/src/server/lib/start-server.ts +++ b/packages/next/src/server/lib/start-server.ts @@ -1,3 +1,5 @@ +import '../node-polyfill-fetch' + import type { Duplex } from 'stream' import type { IncomingMessage, ServerResponse } from 'http' import type { ChildProcess } from 'child_process' diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index ac91f9318fc99..2f2376f1da47a 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -281,7 +281,7 @@ export default class NextNodeServer extends BaseServer { CacheHandler = CacheHandler.default || CacheHandler } - // incremental-cache is request specific with a shared + // incremental-cache is request specific // although can have shared caches in module scope // per-cache handler return new IncrementalCache({ diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index aab167b244f3c..b67ec1d8ab9f7 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -91,7 +91,6 @@ import { AppRouterContext } from '../shared/lib/app-router-context' import { SearchParamsContext } from '../shared/lib/hooks-client-context' import { getTracer } from './lib/trace/tracer' import { RenderSpan } from './lib/trace/constants' -import { PageNotFoundError } from '../shared/lib/utils' import { ReflectAdapter } from './web/spec-extension/adapters/reflect' let tryGetPreviewData: typeof import('./api-utils/node').tryGetPreviewData @@ -341,33 +340,6 @@ function checkRedirectValues( } } -export const deserializeErr = (serializedErr: any) => { - if ( - !serializedErr || - typeof serializedErr !== 'object' || - !serializedErr.stack - ) { - return serializedErr - } - let ErrorType: any = Error - - if (serializedErr.name === 'PageNotFoundError') { - ErrorType = PageNotFoundError - } - - const err = new ErrorType(serializedErr.message) - err.stack = serializedErr.stack - err.name = serializedErr.name - ;(err as any).digest = serializedErr.digest - - if (process.env.NEXT_RUNTIME !== 'edge') { - const { decorateServerError } = - require('next/dist/compiled/@next/react-dev-overlay/dist/middleware') as typeof import('next/dist/compiled/@next/react-dev-overlay/dist/middleware') - decorateServerError(err, serializedErr.source || 'server') - } - return err -} - export function errorToJSON(err: Error) { let source: typeof COMPILER_NAMES.server | typeof COMPILER_NAMES.edgeServer = 'server' diff --git a/packages/next/src/server/web-server.ts b/packages/next/src/server/web-server.ts index 3b9583a86afab..7d44bf0991e12 100644 --- a/packages/next/src/server/web-server.ts +++ b/packages/next/src/server/web-server.ts @@ -60,7 +60,7 @@ export default class NextWebServer extends BaseServer { requestHeaders: IncrementalCache['requestHeaders'] }) { const dev = !!this.renderOpts.dev - // incremental-cache is request specific with a shared + // incremental-cache is request specific // although can have shared caches in module scope // per-cache handler return new IncrementalCache({ diff --git a/test/e2e/app-dir/actions/app/revalidate-multiple/page.js b/test/e2e/app-dir/actions/app/revalidate-multiple/page.js new file mode 100644 index 0000000000000..fe2053c6d9389 --- /dev/null +++ b/test/e2e/app-dir/actions/app/revalidate-multiple/page.js @@ -0,0 +1,41 @@ +import { revalidateTag } from 'next/cache' + +export default async function Page() { + const data1 = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random?a', + { + next: { tags: ['thankyounext'] }, + } + ).then((res) => res.text()) + + const data2 = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random?b', + { + next: { tags: ['justputit'] }, + } + ).then((res) => res.text()) + + return ( + <> +

another route

+

+ revalidate (tags: thankyounext): {data1} +

+

+ revalidate (tags: justputit): {data2} +

+
+ +
+ + ) +} diff --git a/test/e2e/app-dir/app-static-request-deduping/app-static-request-deduping.test.ts b/test/e2e/app-dir/app-static-request-deduping/app-static-request-deduping.test.ts new file mode 100644 index 0000000000000..445d6f4f7f518 --- /dev/null +++ b/test/e2e/app-dir/app-static-request-deduping/app-static-request-deduping.test.ts @@ -0,0 +1,50 @@ +import { findPort } from 'next-test-utils' +import http from 'http' +import { FileRef, createNext } from 'e2e-utils' + +describe('incremental cache request deduping', () => { + if ((global as any).isNextStart) { + let externalServerPort: number + let externalServer: http.Server + let requests = [] + beforeAll(async () => { + externalServerPort = await findPort() + externalServer = http.createServer((req, res) => { + requests.push(req.url) + res.end(`Request ${req.url} received at ${Date.now()}`) + }) + + await new Promise((resolve, reject) => { + externalServer.listen(externalServerPort, () => { + resolve() + }) + + externalServer.once('error', (err) => { + reject(err) + }) + }) + }) + + beforeEach(() => { + requests = [] + }) + + afterAll(() => { + externalServer.close() + }) + + it('uses a shared IPC cache amongst workers to dedupe requests', async () => { + const next = await createNext({ + files: new FileRef(__dirname), + env: { TEST_SERVER_PORT: `${externalServerPort}` }, + }) + + await next.destroy() + + expect(requests.length).toBe(1) + }) + } else { + it('should skip other scenarios', () => {}) + return + } +}) diff --git a/test/e2e/app-dir/app-static-request-deduping/app/blog/[slug]/page.js b/test/e2e/app-dir/app-static-request-deduping/app/blog/[slug]/page.js new file mode 100644 index 0000000000000..3e8ac4de29dfd --- /dev/null +++ b/test/e2e/app-dir/app-static-request-deduping/app/blog/[slug]/page.js @@ -0,0 +1,32 @@ +export async function generateStaticParams() { + return [ + { + slug: 'slug-0', + }, + { + slug: 'slug-1', + }, + { + slug: 'slug-2', + }, + { + slug: 'slug-3', + }, + { + slug: 'slug-4', + }, + ] +} + +export default async function Page({ params }) { + const data = await fetch( + `http://localhost:${process.env.TEST_SERVER_PORT}` + ).then((res) => res.text()) + + return ( + <> +

hello world

+

{data}

+ + ) +} diff --git a/test/e2e/app-dir/app-static-request-deduping/app/layout.js b/test/e2e/app-dir/app-static-request-deduping/app/layout.js new file mode 100644 index 0000000000000..69e348c39ef16 --- /dev/null +++ b/test/e2e/app-dir/app-static-request-deduping/app/layout.js @@ -0,0 +1,10 @@ +export default function Layout({ children }) { + return ( + + + my static site + + {children} + + ) +}