From 45d6bb63ef4a3614f9a5cd98f34d161921c0a3e1 Mon Sep 17 00:00:00 2001 From: Tobias Koppers Date: Tue, 2 May 2023 16:12:46 +0200 Subject: [PATCH 1/6] fix quoted env vars from next config (#49090) ### What? Avoid quotes around env vars from next.config.js ### Why? values in `env` in next.config.js got stringified incorrectly --- .../crates/next-core/src/next_config.rs | 12 +++++++- .../integration/next/env/basic/input/.env | 7 +++++ .../next/env/basic/input/next.config.js | 7 +++++ .../next/env/basic/input/pages/index.js | 30 +++++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 packages/next-swc/crates/next-dev-tests/tests/integration/next/env/basic/input/.env create mode 100644 packages/next-swc/crates/next-dev-tests/tests/integration/next/env/basic/input/next.config.js create mode 100644 packages/next-swc/crates/next-dev-tests/tests/integration/next/env/basic/input/pages/index.js diff --git a/packages/next-swc/crates/next-core/src/next_config.rs b/packages/next-swc/crates/next-core/src/next_config.rs index f89778a24c693..e687baf25eceb 100644 --- a/packages/next-swc/crates/next-core/src/next_config.rs +++ b/packages/next-swc/crates/next-core/src/next_config.rs @@ -516,7 +516,17 @@ impl NextConfigVc { .await? .env .iter() - .map(|(k, v)| (k.clone(), v.to_string())) + .map(|(k, v)| { + ( + k.clone(), + if let JsonValue::String(s) = v { + // A string value is kept, calling `to_string` would wrap in to quotes. + s.clone() + } else { + v.to_string() + }, + ) + }) .collect(); Ok(EnvMapVc::cell(env)) diff --git a/packages/next-swc/crates/next-dev-tests/tests/integration/next/env/basic/input/.env b/packages/next-swc/crates/next-dev-tests/tests/integration/next/env/basic/input/.env new file mode 100644 index 0000000000000..08478922f4ceb --- /dev/null +++ b/packages/next-swc/crates/next-dev-tests/tests/integration/next/env/basic/input/.env @@ -0,0 +1,7 @@ +STRING_ENV_VAR_FROM_DOT_ENV="Hello World" +NUMBER_ENV_VAR_FROM_DOT_ENV=123 +BOOLEAN_ENV_VAR_FROM_DOT_ENV=true + +NEXT_PUBLIC_STRING_ENV_VAR_FROM_DOT_ENV="Hello World" +NEXT_PUBLIC_NUMBER_ENV_VAR_FROM_DOT_ENV=123 +NEXT_PUBLIC_BOOLEAN_ENV_VAR_FROM_DOT_ENV=true diff --git a/packages/next-swc/crates/next-dev-tests/tests/integration/next/env/basic/input/next.config.js b/packages/next-swc/crates/next-dev-tests/tests/integration/next/env/basic/input/next.config.js new file mode 100644 index 0000000000000..8950691740e0e --- /dev/null +++ b/packages/next-swc/crates/next-dev-tests/tests/integration/next/env/basic/input/next.config.js @@ -0,0 +1,7 @@ +module.exports = { + env: { + STRING_ENV_VAR_FROM_CONFIG: 'Hello World', + NUMBER_ENV_VAR_FROM_CONFIG: 123, + BOOLEAN_ENV_VAR_FROM_CONFIG: true, + }, +} diff --git a/packages/next-swc/crates/next-dev-tests/tests/integration/next/env/basic/input/pages/index.js b/packages/next-swc/crates/next-dev-tests/tests/integration/next/env/basic/input/pages/index.js new file mode 100644 index 0000000000000..29604f73deb23 --- /dev/null +++ b/packages/next-swc/crates/next-dev-tests/tests/integration/next/env/basic/input/pages/index.js @@ -0,0 +1,30 @@ +import { useEffect } from 'react' + +export default function Page() { + useEffect(() => { + // Only run on client + import('@turbo/pack-test-harness').then(runTests) + }) +} + +function runTests() { + it('should support env vars from config', () => { + expect(process.env.STRING_ENV_VAR_FROM_CONFIG).toBe('Hello World') + expect(process.env.BOOLEAN_ENV_VAR_FROM_CONFIG).toBe('true') + expect(process.env.NUMBER_ENV_VAR_FROM_CONFIG).toBe('123') + }) + + it('should support env vars from .env', () => { + expect(process.env.NEXT_PUBLIC_STRING_ENV_VAR_FROM_DOT_ENV).toBe( + 'Hello World' + ) + expect(process.env.NEXT_PUBLIC_BOOLEAN_ENV_VAR_FROM_DOT_ENV).toBe('true') + expect(process.env.NEXT_PUBLIC_NUMBER_ENV_VAR_FROM_DOT_ENV).toBe('123') + }) + + it('should not support env vars from .env without NEXT_PUBLIC prefix', () => { + expect(process.env.STRING_ENV_VAR_FROM_DOT_ENV).toBeUndefined() + expect(process.env.BOOLEAN_ENV_VAR_FROM_DOT_ENV).toBeUndefined() + expect(process.env.NUMBER_ENV_VAR_FROM_DOT_ENV).toBeUndefined() + }) +} From 02c5b5f6d6b57051ed03c017519fba71caaa5681 Mon Sep 17 00:00:00 2001 From: Tobias Koppers Date: Tue, 2 May 2023 17:12:21 +0200 Subject: [PATCH 2/6] find and handle not-found page in app dir (#49095) ### What? add not-found to the app dir scanning ### Why? not found pages should be supported ### How? --- packages/next-swc/crates/napi/src/app_structure.rs | 4 ++++ packages/next-swc/crates/next-core/src/app_source.rs | 4 +++- packages/next-swc/crates/next-core/src/app_structure.rs | 5 +++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/next-swc/crates/napi/src/app_structure.rs b/packages/next-swc/crates/napi/src/app_structure.rs index 659f02288dd8b..a06700cf3951a 100644 --- a/packages/next-swc/crates/napi/src/app_structure.rs +++ b/packages/next-swc/crates/napi/src/app_structure.rs @@ -83,6 +83,8 @@ struct ComponentsForJs { loading: Option, #[serde(skip_serializing_if = "Option::is_none")] template: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "not-found")] + not_found: Option, #[serde(skip_serializing_if = "Option::is_none")] default: Option, #[serde(skip_serializing_if = "Option::is_none")] @@ -127,6 +129,7 @@ async fn prepare_components_for_js( error, loading, template, + not_found, default, route, metadata, @@ -147,6 +150,7 @@ async fn prepare_components_for_js( add(&mut result.error, project_path, error).await?; add(&mut result.loading, project_path, loading).await?; add(&mut result.template, project_path, template).await?; + add(&mut result.not_found, project_path, not_found).await?; add(&mut result.default, project_path, default).await?; add(&mut result.route, project_path, route).await?; async fn add_meta<'a>( diff --git a/packages/next-swc/crates/next-core/src/app_source.rs b/packages/next-swc/crates/next-core/src/app_source.rs index 94763fdeb12bb..898e4d1cadf63 100644 --- a/packages/next-swc/crates/next-core/src/app_source.rs +++ b/packages/next-swc/crates/next-core/src/app_source.rs @@ -856,15 +856,17 @@ import {}, {{ chunks as {} }} from "COMPONENT_{}"; layout, loading, template, + not_found, metadata, route: _, } = &*components.await?; write_component(state, "page", *page)?; - write_component(state, "default", *default)?; + write_component(state, "defaultPage", *default)?; write_component(state, "error", *error)?; write_component(state, "layout", *layout)?; write_component(state, "loading", *loading)?; write_component(state, "template", *template)?; + write_component(state, "not-found", *not_found)?; write_metadata(state, metadata)?; write!(state.loader_tree_code, "}}]")?; Ok(()) diff --git a/packages/next-swc/crates/next-core/src/app_structure.rs b/packages/next-swc/crates/next-core/src/app_structure.rs index 869630c7117ec..4cf8fdf941563 100644 --- a/packages/next-swc/crates/next-core/src/app_structure.rs +++ b/packages/next-swc/crates/next-core/src/app_structure.rs @@ -36,6 +36,8 @@ pub struct Components { #[serde(skip_serializing_if = "Option::is_none")] pub template: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub not_found: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub default: Option, #[serde(skip_serializing_if = "Option::is_none")] pub route: Option, @@ -51,6 +53,7 @@ impl Components { error: self.error, loading: self.loading, template: self.template, + not_found: self.not_found, default: None, route: None, metadata: self.metadata.clone(), @@ -64,6 +67,7 @@ impl Components { error: a.error.or(b.error), loading: a.loading.or(b.loading), template: a.template.or(b.template), + not_found: a.not_found.or(b.not_found), default: a.default.or(b.default), route: a.default.or(b.route), metadata: Metadata::merge(&a.metadata, &b.metadata), @@ -321,6 +325,7 @@ async fn get_directory_tree( "error" => components.error = Some(file), "loading" => components.loading = Some(file), "template" => components.template = Some(file), + "not-found" => components.not_found = Some(file), "default" => components.default = Some(file), "route" => components.route = Some(file), "manifest" => { From abc74fb92e99c0b0a9b06dfeec201d00f1209f38 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Tue, 2 May 2023 08:19:02 -0700 Subject: [PATCH 3/6] Update revalidate handling for app (#49062) This updates revalidate handling for app and moves some exports from `next/server` to `next/cache` x-ref: [slack thread](https://vercel.slack.com/archives/C042LHPJ1NX/p1682535961644979?thread_ts=1682368724.384619&cid=C042LHPJ1NX) --------- --- packages/next/cache.d.ts | 3 + packages/next/cache.js | 19 +++ packages/next/index.d.ts | 1 + packages/next/package.json | 2 + packages/next/server.d.ts | 3 - packages/next/server.js | 11 -- packages/next/src/build/index.ts | 18 +-- .../static-generation-async-storage.ts | 4 + packages/next/src/export/worker.ts | 38 +++++ .../next/src/server/app-render/app-render.tsx | 4 +- packages/next/src/server/base-http/index.ts | 5 + packages/next/src/server/base-http/node.ts | 5 + packages/next/src/server/base-http/web.ts | 5 + packages/next/src/server/base-server.ts | 51 +++++-- .../future/route-modules/app-route/module.ts | 5 +- .../incremental-cache/file-system-cache.ts | 86 +++++++++++- .../src/server/lib/incremental-cache/index.ts | 31 +++-- packages/next/src/server/lib/patch-fetch.ts | 27 +++- packages/next/src/server/render.tsx | 1 + packages/next/src/server/request-meta.ts | 1 + .../next/src/server/response-cache/index.ts | 6 +- .../next/src/server/response-cache/types.ts | 13 +- .../next/src/server/response-cache/web.ts | 2 +- .../unstable-revalidate-path.ts | 6 + .../e2e/app-dir/app-static/app-static.test.ts | 33 ++++- .../app/api/revalidate-path-edge/route.ts | 3 +- .../app/api/revalidate-path-node/route.ts | 3 +- .../app/api/revalidate-tag-edge/route.ts | 4 +- .../app/api/revalidate-tag-node/route.ts | 4 +- .../route-handler/revalidate-360-isr/route.js | 25 ++++ .../revalidate-360-isr/page.js | 2 +- .../revalidate-360/page.js | 2 +- .../app/api/isr/[slug]/route.js | 24 ++++ .../app/api/ssr/[slug]/route.js | 24 ++++ .../app/isr/[slug]/page.js | 25 ++++ .../required-server-files/app/layout.js | 8 ++ .../app/ssr/[slug]/page.js | 21 +++ .../required-server-files-app.test.ts | 130 ++++++++++++++++++ 38 files changed, 592 insertions(+), 63 deletions(-) create mode 100644 packages/next/cache.d.ts create mode 100644 packages/next/cache.js create mode 100644 test/e2e/app-dir/app-static/app/route-handler/revalidate-360-isr/route.js create mode 100644 test/production/standalone-mode/required-server-files/app/api/isr/[slug]/route.js create mode 100644 test/production/standalone-mode/required-server-files/app/api/ssr/[slug]/route.js create mode 100644 test/production/standalone-mode/required-server-files/app/isr/[slug]/page.js create mode 100644 test/production/standalone-mode/required-server-files/app/layout.js create mode 100644 test/production/standalone-mode/required-server-files/app/ssr/[slug]/page.js create mode 100644 test/production/standalone-mode/required-server-files/required-server-files-app.test.ts diff --git a/packages/next/cache.d.ts b/packages/next/cache.d.ts new file mode 100644 index 0000000000000..0746ee7b1d6e5 --- /dev/null +++ b/packages/next/cache.d.ts @@ -0,0 +1,3 @@ +export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache' +export { unstable_revalidatePath } from 'next/dist/server/web/spec-extension/unstable-revalidate-path' +export { unstable_revalidateTag } from 'next/dist/server/web/spec-extension/unstable-revalidate-tag' diff --git a/packages/next/cache.js b/packages/next/cache.js new file mode 100644 index 0000000000000..d5d737a62e01b --- /dev/null +++ b/packages/next/cache.js @@ -0,0 +1,19 @@ +const cacheExports = { + unstable_cache: require('next/dist/server/web/spec-extension/unstable-cache') + .unstable_cache, + unstable_revalidateTag: + require('next/dist/server/web/spec-extension/unstable-revalidate-tag') + .unstable_revalidateTag, + unstable_revalidatePath: + require('next/dist/server/web/spec-extension/unstable-revalidate-path') + .unstable_revalidatePath, +} + +// https://nodejs.org/api/esm.html#commonjs-namespaces +// When importing CommonJS modules, the module.exports object is provided as the default export +module.exports = cacheExports + +// make import { xxx } from 'next/server' work +exports.unstable_cache = cacheExports.unstable_cache +exports.unstable_revalidatePath = cacheExports.unstable_revalidatePath +exports.unstable_revalidateTag = cacheExports.unstable_revalidateTag diff --git a/packages/next/index.d.ts b/packages/next/index.d.ts index cba2b1dd4e056..70a08c6e414fe 100644 --- a/packages/next/index.d.ts +++ b/packages/next/index.d.ts @@ -2,6 +2,7 @@ /// /// /// +/// /// /// /// diff --git a/packages/next/package.json b/packages/next/package.json index 592114a01184e..76663043b8be9 100644 --- a/packages/next/package.json +++ b/packages/next/package.json @@ -17,6 +17,8 @@ "client.js", "client.d.ts", "compat", + "cache.js", + "cache.d.ts", "config.js", "config.d.ts", "constants.js", diff --git a/packages/next/server.d.ts b/packages/next/server.d.ts index 6f4d50fac2d64..a47014498321d 100644 --- a/packages/next/server.d.ts +++ b/packages/next/server.d.ts @@ -13,6 +13,3 @@ export { userAgent } from 'next/dist/server/web/spec-extension/user-agent' export { URLPattern } from 'next/dist/compiled/@edge-runtime/primitives/url' export { ImageResponse } from 'next/dist/server/web/spec-extension/image-response' export type { ImageResponseOptions } from 'next/dist/compiled/@vercel/og/types' -export { unstable_cache } from 'next/dist/server/web/spec-extension/unstable-cache' -export { unstable_revalidatePath } from 'next/dist/server/web/spec-extension/unstable-revalidate-path' -export { unstable_revalidateTag } from 'next/dist/server/web/spec-extension/unstable-revalidate-tag' diff --git a/packages/next/server.js b/packages/next/server.js index 7644250087239..1fe002aa1333b 100644 --- a/packages/next/server.js +++ b/packages/next/server.js @@ -5,14 +5,6 @@ const serverExports = { .NextResponse, ImageResponse: require('next/dist/server/web/spec-extension/image-response') .ImageResponse, - unstable_cache: require('next/dist/server/web/spec-extension/unstable-cache') - .unstable_cache, - unstable_revalidateTag: - require('next/dist/server/web/spec-extension/unstable-revalidate-tag') - .unstable_revalidateTag, - unstable_revalidatePath: - require('next/dist/server/web/spec-extension/unstable-revalidate-path') - .unstable_revalidatePath, userAgentFromString: require('next/dist/server/web/spec-extension/user-agent') .userAgentFromString, userAgent: require('next/dist/server/web/spec-extension/user-agent') @@ -32,9 +24,6 @@ module.exports = serverExports exports.NextRequest = serverExports.NextRequest exports.NextResponse = serverExports.NextResponse exports.ImageResponse = serverExports.ImageResponse -exports.unstable_cache = serverExports.unstable_cache -exports.unstable_revalidatePath = serverExports.unstable_revalidatePath -exports.unstable_revalidateTag = serverExports.unstable_revalidateTag exports.userAgentFromString = serverExports.userAgentFromString exports.userAgent = serverExports.userAgent exports.URLPattern = serverExports.URLPattern diff --git a/packages/next/src/build/index.ts b/packages/next/src/build/index.ts index 58dcb802f4226..ce86c729dfb68 100644 --- a/packages/next/src/build/index.ts +++ b/packages/next/src/build/index.ts @@ -2377,16 +2377,16 @@ export default async function build( initialHeaders?: SsgRoute['initialHeaders'] } = {} - if (isRouteHandler) { - const exportRouteMeta = - exportConfig.initialPageMetaMap[route] || {} + const exportRouteMeta: { + status?: number + headers?: Record + } = exportConfig.initialPageMetaMap[route] || {} - if (exportRouteMeta.status !== 200) { - routeMeta.initialStatus = exportRouteMeta.status - } - if (Object.keys(exportRouteMeta.headers).length) { - routeMeta.initialHeaders = exportRouteMeta.headers - } + if (exportRouteMeta.status !== 200) { + routeMeta.initialStatus = exportRouteMeta.status + } + if (Object.keys(exportRouteMeta.headers || {}).length) { + routeMeta.initialHeaders = exportRouteMeta.headers } finalPrerenderRoutes[route] = { diff --git a/packages/next/src/client/components/static-generation-async-storage.ts b/packages/next/src/client/components/static-generation-async-storage.ts index 844ec85fd2ca0..cc47afadfcc3b 100644 --- a/packages/next/src/client/components/static-generation-async-storage.ts +++ b/packages/next/src/client/components/static-generation-async-storage.ts @@ -7,6 +7,7 @@ export interface StaticGenerationStore { readonly pathname: string readonly incrementalCache?: IncrementalCache readonly isRevalidate?: boolean + readonly isMinimalMode?: boolean readonly isOnDemandRevalidate?: boolean readonly isPrerendering?: boolean @@ -17,6 +18,7 @@ export interface StaticGenerationStore { | 'force-no-store' | 'default-no-store' | 'only-no-store' + revalidate?: false | number forceStatic?: boolean dynamicShouldError?: boolean @@ -26,6 +28,8 @@ export interface StaticGenerationStore { dynamicUsageStack?: string nextFetchId?: number + + tags?: string[] } export type StaticGenerationAsyncStorage = diff --git a/packages/next/src/export/worker.ts b/packages/next/src/export/worker.ts index cc5d5566764f3..571b311ef1fe5 100644 --- a/packages/next/src/export/worker.ts +++ b/packages/next/src/export/worker.ts @@ -46,6 +46,7 @@ import { isAppRouteRoute } from '../lib/is-app-route-route' import { toNodeHeaders } from '../server/web/utils' import { RouteModuleLoader } from '../server/future/helpers/module-loader/route-module-loader' import { NextRequestAdapter } from '../server/web/spec-extension/adapters/next-request' +import * as ciEnvironment from '../telemetry/ci-info' const envConfig = require('../shared/lib/runtime-config') @@ -322,6 +323,12 @@ export default async function exportPage({ fontManifest: optimizeFonts ? requireFontManifest(distDir) : null, locale: locale as string, supportsDynamicHTML: false, + ...(ciEnvironment.hasNextSupport + ? { + isMinimalMode: true, + isRevalidate: true, + } + : {}), } } @@ -400,6 +407,12 @@ export default async function exportPage({ nextExport: true, supportsDynamicHTML: false, incrementalCache: curRenderOpts.incrementalCache, + ...(ciEnvironment.hasNextSupport + ? { + isMinimalMode: true, + isRevalidate: true, + } + : {}), }, } @@ -429,6 +442,12 @@ export default async function exportPage({ results.fromBuildExportRevalidate = revalidate const headers = toNodeHeaders(response.headers) + const cacheTags = (context.staticGenerationContext as any) + .fetchTags + + if (cacheTags) { + headers['x-next-cache-tags'] = cacheTags + } if (!headers['content-type'] && body.type) { headers['content-type'] = body.type @@ -481,7 +500,26 @@ export default async function exportPage({ results.fromBuildExportRevalidate = revalidate if (revalidate !== 0) { + const cacheTags = (curRenderOpts as any).fetchTags + const headers = cacheTags + ? { + 'x-next-cache-tags': cacheTags, + } + : undefined + + if (ciEnvironment.hasNextSupport) { + if (cacheTags) { + results.fromBuildExportMeta = { + headers, + } + } + } + await promises.writeFile(htmlFilepath, html ?? '', 'utf8') + await promises.writeFile( + htmlFilepath.replace(/\.html$/, '.meta'), + JSON.stringify({ headers }) + ) await promises.writeFile( htmlFilepath.replace(/\.html$/, '.rsc'), flightData diff --git a/packages/next/src/server/app-render/app-render.tsx b/packages/next/src/server/app-render/app-render.tsx index ea2a609a7d18f..f34bd147076df 100644 --- a/packages/next/src/server/app-render/app-render.tsx +++ b/packages/next/src/server/app-render/app-render.tsx @@ -49,7 +49,7 @@ import { getURLFromRedirectError, isRedirectError, } from '../../client/components/redirect' -import { patchFetch } from '../lib/patch-fetch' +import { addImplicitTags, patchFetch } from '../lib/patch-fetch' import { AppRenderSpan } from '../lib/trace/constants' import { getTracer } from '../lib/trace/tracer' import { interopDefault } from './interop-default' @@ -1571,6 +1571,8 @@ export async function renderToHTMLOrFlight( if (staticGenerationStore.pendingRevalidates) { await Promise.all(staticGenerationStore.pendingRevalidates) } + addImplicitTags(staticGenerationStore) + ;(renderOpts as any).fetchTags = staticGenerationStore.tags?.join(',') if (staticGenerationStore.isStaticGeneration) { const htmlResult = await streamToBufferedResult(renderResult) diff --git a/packages/next/src/server/base-http/index.ts b/packages/next/src/server/base-http/index.ts index 786f55293056d..bb4b4bfa5cdff 100644 --- a/packages/next/src/server/base-http/index.ts +++ b/packages/next/src/server/base-http/index.ts @@ -38,6 +38,11 @@ export abstract class BaseNextResponse { */ abstract setHeader(name: string, value: string | string[]): this + /** + * Removes a header + */ + abstract removeHeader(name: string): this + /** * Appends value for the given header name */ diff --git a/packages/next/src/server/base-http/node.ts b/packages/next/src/server/base-http/node.ts index 8bda81681a6fc..7d99b8c198b1e 100644 --- a/packages/next/src/server/base-http/node.ts +++ b/packages/next/src/server/base-http/node.ts @@ -85,6 +85,11 @@ export class NodeNextResponse extends BaseNextResponse { return this } + removeHeader(name: string): this { + this._res.removeHeader(name) + return this + } + getHeaderValues(name: string): string[] | undefined { const values = this._res.getHeader(name) diff --git a/packages/next/src/server/base-http/web.ts b/packages/next/src/server/base-http/web.ts index 563b79aa845bb..4d3ac733320c8 100644 --- a/packages/next/src/server/base-http/web.ts +++ b/packages/next/src/server/base-http/web.ts @@ -64,6 +64,11 @@ export class WebNextResponse extends BaseNextResponse { return this } + removeHeader(name: string): this { + this.headers.delete(name) + return this + } + getHeaderValues(name: string): string[] | undefined { // https://developer.mozilla.org/en-US/docs/Web/API/Headers/get#example return this.getHeader(name) diff --git a/packages/next/src/server/base-server.ts b/packages/next/src/server/base-server.ts index e77d2d623e763..eba675603779d 100644 --- a/packages/next/src/server/base-server.ts +++ b/packages/next/src/server/base-server.ts @@ -25,6 +25,7 @@ import { } from '../shared/lib/utils' import type { PreviewData, ServerRuntime } from 'next/types' import type { PagesManifest } from '../build/webpack/plugins/pages-manifest-plugin' +import type { OutgoingHttpHeaders } from 'http2' import type { BaseNextRequest, BaseNextResponse } from './base-http' import type { PayloadOptions } from './send-payload' import type { PrerenderManifest } from '../build' @@ -1586,8 +1587,6 @@ export default abstract class Server { | 'https', }) - let isRevalidate = false - const doRender: () => Promise = async () => { // In development, we always want to generate dynamic HTML. const supportsDynamicHTML = @@ -1605,6 +1604,7 @@ export default abstract class Server { staticGenerationContext: { supportsDynamicHTML, incrementalCache, + isRevalidate: isSSG, }, } @@ -1612,6 +1612,8 @@ export default abstract class Server { // Handle the match and collect the response if it's a static response. const response = await this.handlers.handle(match, req, context) if (response) { + const cacheTags = (context.staticGenerationContext as any).fetchTags + // If the request is for a static response, we can cache it so long // as it's not edge. if (isSSG && process.env.NEXT_RUNTIME !== 'edge') { @@ -1619,6 +1621,11 @@ export default abstract class Server { // Copy the headers from the response. const headers = toNodeHeaders(response.headers) + + if (cacheTags) { + headers['x-next-cache-tags'] = cacheTags + } + if (!headers['content-type'] && blob.type) { headers['content-type'] = blob.type } @@ -1669,6 +1676,7 @@ export default abstract class Server { let isrRevalidate: number | false let isNotFound: boolean | undefined let isRedirect: boolean | undefined + let headers: OutgoingHttpHeaders | undefined const origQuery = parseUrl(req.url || '', true).query @@ -1694,7 +1702,7 @@ export default abstract class Server { ...(isAppPath && this.nextConfig.experimental.appDir ? { incrementalCache, - isRevalidate: this.minimalMode || isRevalidate, + isRevalidate: isSSG, } : {}), isDataReq, @@ -1717,6 +1725,7 @@ export default abstract class Server { supportsDynamicHTML, isOnDemandRevalidate, + isMinimalMode: this.minimalMode, } const renderResult = await this.renderHTML( @@ -1736,6 +1745,14 @@ export default abstract class Server { isNotFound = renderResultMeta.isNotFound isRedirect = renderResultMeta.isRedirect + const cacheTags = (renderOpts as any).fetchTags + + if (cacheTags) { + headers = { + 'x-next-cache-tags': cacheTags, + } + } + // we don't throw static to dynamic errors in dev as isSSG // is a best guess in dev since we don't have the prerender pass // to know whether the path is actually static or not @@ -1771,7 +1788,7 @@ export default abstract class Server { if (body.isNull()) { return null } - value = { kind: 'PAGE', html: body, pageData } + value = { kind: 'PAGE', html: body, pageData, headers } } return { revalidate: isrRevalidate, value } } @@ -1783,10 +1800,6 @@ export default abstract class Server { const isDynamicPathname = isDynamicRoute(pathname) const didRespond = hasResolved || res.sent - if (hadCache) { - isRevalidate = true - } - if (!staticPaths) { ;({ staticPaths, fallbackMode } = hasStaticPaths ? await this.getStaticPaths({ @@ -1815,6 +1828,10 @@ export default abstract class Server { return null } + if (hadCache?.isStale === -1) { + isOnDemandRevalidate = true + } + // only allow on-demand revalidate for fallback: true/blocking // or for prerendered fallback: false paths if (isOnDemandRevalidate && (fallbackMode !== false || hadCache)) { @@ -1990,17 +2007,33 @@ export default abstract class Server { } else if (cachedData.kind === 'IMAGE') { throw new Error('invariant SSG should not return an image cache value') } else if (cachedData.kind === 'ROUTE') { + const headers = { ...cachedData.headers } + + if (!(this.minimalMode && isSSG)) { + delete headers['x-next-cache-tags'] + } + await sendResponse( req, res, new Response(cachedData.body, { - headers: fromNodeHeaders(cachedData.headers), + headers: fromNodeHeaders(headers), status: cachedData.status || 200, }) ) return null } else { if (isAppPath) { + if ( + this.minimalMode && + isSSG && + cachedData.headers?.['x-next-cache-tags'] + ) { + res.setHeader( + 'x-next-cache-tags', + cachedData.headers['x-next-cache-tags'] as string + ) + } if (isDataReq && typeof cachedData.pageData !== 'string') { throw new Error( 'invariant: Expected pageData to be a string for app data request but received ' + diff --git a/packages/next/src/server/future/route-modules/app-route/module.ts b/packages/next/src/server/future/route-modules/app-route/module.ts index 8e8ca7e8d6db5..745df4d775a3b 100644 --- a/packages/next/src/server/future/route-modules/app-route/module.ts +++ b/packages/next/src/server/future/route-modules/app-route/module.ts @@ -21,7 +21,7 @@ import { handleInternalServerErrorResponse, } from '../helpers/response-handlers' import { type HTTP_METHOD, HTTP_METHODS, isHTTPMethod } from '../../../web/http' -import { patchFetch } from '../../../lib/patch-fetch' +import { addImplicitTags, patchFetch } from '../../../lib/patch-fetch' import { getTracer } from '../../../lib/trace/tracer' import { AppRouteRouteHandlersSpan } from '../../../lib/trace/constants' import { getPathnameFromAbsolutePath } from './helpers/get-pathname-from-absolute-path' @@ -349,6 +349,9 @@ export class AppRouteRouteModule extends RouteModule< await Promise.all( staticGenerationStore.pendingRevalidates || [] ) + addImplicitTags(staticGenerationStore) + ;(context.staticGenerationContext as any).fetchTags = + staticGenerationStore.tags?.join(',') // It's possible cookies were set in the handler, so we need // to merge the modified cookies and the returned response diff --git a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts index 188d05e07983c..e52cd7a5638d8 100644 --- a/packages/next/src/server/lib/incremental-cache/file-system-cache.ts +++ b/packages/next/src/server/lib/incremental-cache/file-system-cache.ts @@ -1,8 +1,9 @@ +import type { OutgoingHttpHeaders } from 'http' +import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from './' import LRUCache from 'next/dist/compiled/lru-cache' +import { CacheFs } from '../../../shared/lib/utils' import path from '../../../shared/lib/isomorphic/path' import { CachedFetchValue } from '../../response-cache' -import { CacheFs } from '../../../shared/lib/utils' -import type { CacheHandler, CacheHandlerContext, CacheHandlerValue } from './' import { CACHE_ONE_YEAR } from '../../../lib/constants' type FileSystemCacheContext = Omit< @@ -199,12 +200,27 @@ export default class FileSystemCache implements CacheHandler { ) ).toString('utf8') ) + + let meta: { status?: number; headers?: OutgoingHttpHeaders } = {} + + if (isAppPath) { + try { + meta = JSON.parse( + ( + await this.fs.readFile(filePath.replace(/\.html$/, '.meta')) + ).toString('utf-8') + ) + } catch (_) {} + } + data = { lastModified: mtime.getTime(), value: { kind: 'PAGE', html: fileData, pageData, + headers: meta.headers, + status: meta.status, }, } } @@ -216,11 +232,65 @@ export default class FileSystemCache implements CacheHandler { // unable to get data from disk } } + let cacheTags: undefined | string[] + + if (data?.value?.kind === 'PAGE') { + const tagsHeader = data.value.headers?.['x-next-cache-tags'] + + if (typeof tagsHeader === 'string') { + cacheTags = tagsHeader.split(',') + } + } + + const getDerivedTags = (tags: string[]): string[] => { + const derivedTags: string[] = [] + + for (const tag of tags || []) { + if (tag.startsWith('/')) { + const pathnameParts = tag.split('/') + + // we automatically add the current path segments as tags + // for revalidatePath handling + for (let i = 1; i < pathnameParts.length + 1; i++) { + const curPathname = pathnameParts.slice(0, i).join('/') + + if (curPathname) { + derivedTags.push(curPathname) + + if (!derivedTags.includes(curPathname)) { + derivedTags.push(curPathname) + } + } + } + } else if (!derivedTags.includes(tag)) { + derivedTags.push(tag) + } + } + return derivedTags + } + + if (data?.value?.kind === 'PAGE' && cacheTags?.length) { + this.loadTagsManifest() + const derivedTags = getDerivedTags(cacheTags || []) + + const isStale = derivedTags.some((tag) => { + return ( + tagsManifest?.items[tag]?.revalidatedAt && + tagsManifest?.items[tag].revalidatedAt >= + (data?.lastModified || Date.now()) + ) + }) + if (isStale) { + data.lastModified = -1 + } + } if (data && data?.value?.kind === 'FETCH') { this.loadTagsManifest() const innerData = data.value.data - const isStale = innerData.tags?.some((tag) => { + const derivedTags = getDerivedTags(innerData.tags || []) + + const isStale = derivedTags.some((tag) => { return ( tagsManifest?.items[tag]?.revalidatedAt && tagsManifest?.items[tag].revalidatedAt >= @@ -274,6 +344,16 @@ export default class FileSystemCache implements CacheHandler { ).filePath, isAppPath ? data.pageData : JSON.stringify(data.pageData) ) + + if (data.headers || data.status) { + await this.fs.writeFile( + htmlPath.replace(/\.html$/, '.meta'), + JSON.stringify({ + headers: data.headers, + status: data.status, + }) + ) + } } else if (data?.kind === 'FETCH') { const { filePath } = await this.getFsPath({ pathname: key, diff --git a/packages/next/src/server/lib/incremental-cache/index.ts b/packages/next/src/server/lib/incremental-cache/index.ts index 187465844231f..690b2d801535c 100644 --- a/packages/next/src/server/lib/incremental-cache/index.ts +++ b/packages/next/src/server/lib/incremental-cache/index.ts @@ -10,6 +10,7 @@ import { } from '../../response-cache' import { encode } from '../../../shared/lib/bloom-filter/base64-arraybuffer' import { encodeText } from '../../stream-utils/encode-decode' +import { CACHE_ONE_YEAR } from '../../../lib/constants' function toRoute(pathname: string): string { return pathname.replace(/\/$/, '').replace(/\/index$/, '') || '/' @@ -21,9 +22,10 @@ export interface CacheHandlerContext { flushToDisk?: boolean serverDistDir?: string maxMemoryCacheSize?: number + fetchCacheKeyPrefix?: string + prerenderManifest?: PrerenderManifest _appDir: boolean _requestHeaders: IncrementalCache['requestHeaders'] - fetchCacheKeyPrefix?: string } export interface CacheHandlerValue { @@ -327,15 +329,24 @@ export class IncrementalCache { const curRevalidate = this.prerenderManifest.routes[toRoute(pathname)]?.initialRevalidateSeconds - const revalidateAfter = this.calculateRevalidate( - pathname, - cacheData?.lastModified || Date.now(), - this.dev && !fetchCache - ) - const isStale = - revalidateAfter !== false && revalidateAfter < Date.now() - ? true - : undefined + + let isStale: boolean | -1 | undefined + let revalidateAfter: false | number + + if (cacheData?.lastModified === -1) { + isStale = -1 + revalidateAfter = -1 * CACHE_ONE_YEAR + } else { + revalidateAfter = this.calculateRevalidate( + pathname, + cacheData?.lastModified || Date.now(), + this.dev && !fetchCache + ) + isStale = + revalidateAfter !== false && revalidateAfter < Date.now() + ? true + : undefined + } if (cacheData) { entry = { diff --git a/packages/next/src/server/lib/patch-fetch.ts b/packages/next/src/server/lib/patch-fetch.ts index f1da0e699bd95..1a6f2a2d30ff4 100644 --- a/packages/next/src/server/lib/patch-fetch.ts +++ b/packages/next/src/server/lib/patch-fetch.ts @@ -7,6 +7,19 @@ import { CACHE_ONE_YEAR } from '../../lib/constants' const isEdgeRuntime = process.env.NEXT_RUNTIME === 'edge' +export function addImplicitTags( + staticGenerationStore: ReturnType +) { + if (!staticGenerationStore?.pathname) return + + if (!Array.isArray(staticGenerationStore.tags)) { + staticGenerationStore.tags = [] + } + if (!staticGenerationStore.tags.includes(staticGenerationStore.pathname)) { + staticGenerationStore.tags.push(staticGenerationStore.pathname) + } +} + // we patch fetch to collect cache information used for // determining if a page is static or not export function patchFetch({ @@ -81,7 +94,19 @@ export function patchFetch({ // RequestInit doesn't keep extra fields e.g. next so it's // only available if init is used separate let curRevalidate = getNextField('revalidate') - const tags: undefined | string[] = getNextField('tags') + const tags: string[] = getNextField('tags') || [] + + if (Array.isArray(tags)) { + if (!staticGenerationStore.tags) { + staticGenerationStore.tags = [] + } + for (const tag of tags) { + if (!staticGenerationStore.tags.includes(tag)) { + staticGenerationStore.tags.push(tag) + } + } + } + addImplicitTags(staticGenerationStore) const isOnlyCache = staticGenerationStore.fetchCache === 'only-cache' const isForceCache = staticGenerationStore.fetchCache === 'force-cache' diff --git a/packages/next/src/server/render.tsx b/packages/next/src/server/render.tsx index 8fa524d7d12d0..ea9d3cc731b13 100644 --- a/packages/next/src/server/render.tsx +++ b/packages/next/src/server/render.tsx @@ -268,6 +268,7 @@ export type RenderOptsPartial = { largePageDataBytes?: number isOnDemandRevalidate?: boolean strictNextHead: boolean + isMinimalMode?: boolean } export type RenderOpts = LoadComponentsReturnType & RenderOptsPartial diff --git a/packages/next/src/server/request-meta.ts b/packages/next/src/server/request-meta.ts index c62fc3e400118..c1fb5bcd64453 100644 --- a/packages/next/src/server/request-meta.ts +++ b/packages/next/src/server/request-meta.ts @@ -38,6 +38,7 @@ export interface RequestMeta { _nextDataNormalizing?: boolean _nextMatch?: RouteMatch _nextIncrementalCache?: any + _nextMinimalMode?: boolean } export function getRequestMeta( diff --git a/packages/next/src/server/response-cache/index.ts b/packages/next/src/server/response-cache/index.ts index e988c6159ab2e..39bb478d8bdcc 100644 --- a/packages/next/src/server/response-cache/index.ts +++ b/packages/next/src/server/response-cache/index.ts @@ -111,6 +111,8 @@ export default class ResponseCache { kind: 'PAGE', html: RenderResult.fromStatic(cachedResponse.value.html), pageData: cachedResponse.value.pageData, + headers: cachedResponse.value.headers, + status: cachedResponse.value.status, } : cachedResponse.value, }) @@ -121,7 +123,7 @@ export default class ResponseCache { } } - const cacheEntry = await responseGenerator(resolved, !!cachedResponse) + const cacheEntry = await responseGenerator(resolved, cachedResponse) const resolveValue = cacheEntry === null ? null @@ -150,6 +152,8 @@ export default class ResponseCache { kind: 'PAGE', html: cacheEntry.value.html.toUnchunkedString(), pageData: cacheEntry.value.pageData, + headers: cacheEntry.value.headers, + status: cacheEntry.value.status, } : cacheEntry.value, cacheEntry.revalidate diff --git a/packages/next/src/server/response-cache/types.ts b/packages/next/src/server/response-cache/types.ts index 0f0e79cd87f60..938052b2d44d8 100644 --- a/packages/next/src/server/response-cache/types.ts +++ b/packages/next/src/server/response-cache/types.ts @@ -35,6 +35,8 @@ interface CachedPageValue { // expects that type instead of a string html: RenderResult pageData: Object + status?: number + headers?: OutgoingHttpHeaders } export interface CachedRouteValue { @@ -61,13 +63,16 @@ interface IncrementalCachedPageValue { // the string value html: string pageData: Object + headers?: OutgoingHttpHeaders + status?: number } export type IncrementalCacheEntry = { curRevalidate?: number | false // milliseconds to revalidate after revalidateAfter: number | false - isStale?: boolean + // -1 here dictates a blocking revalidate should be used + isStale?: boolean | -1 value: IncrementalCacheValue | null } @@ -87,13 +92,13 @@ export type ResponseCacheValue = export type ResponseCacheEntry = { revalidate?: number | false value: ResponseCacheValue | null - isStale?: boolean + isStale?: boolean | -1 isMiss?: boolean } export type ResponseGenerator = ( hasResolved: boolean, - hadCache: boolean + cacheEntry?: IncrementalCacheItem ) => Promise export type IncrementalCacheItem = { @@ -101,7 +106,7 @@ export type IncrementalCacheItem = { curRevalidate?: number | false revalidate?: number | false value: IncrementalCacheValue | null - isStale?: boolean + isStale?: boolean | -1 isMiss?: boolean } | null diff --git a/packages/next/src/server/response-cache/web.ts b/packages/next/src/server/response-cache/web.ts index 644dd1685af4e..e37ccca314812 100644 --- a/packages/next/src/server/response-cache/web.ts +++ b/packages/next/src/server/response-cache/web.ts @@ -84,7 +84,7 @@ export default class WebResponseCache { // same promise until we've fully finished our work. ;(async () => { try { - const cacheEntry = await responseGenerator(resolved, false) + const cacheEntry = await responseGenerator(resolved) const resolveValue = cacheEntry === null ? null diff --git a/packages/next/src/server/web/spec-extension/unstable-revalidate-path.ts b/packages/next/src/server/web/spec-extension/unstable-revalidate-path.ts index 3382a2121026e..3295388079192 100644 --- a/packages/next/src/server/web/spec-extension/unstable-revalidate-path.ts +++ b/packages/next/src/server/web/spec-extension/unstable-revalidate-path.ts @@ -3,6 +3,7 @@ import type { StaticGenerationStore, } from '../../../client/components/static-generation-async-storage' +import { unstable_revalidateTag } from './unstable-revalidate-tag' import { headers } from '../../../client/components/headers' import { PRERENDER_REVALIDATE_HEADER, @@ -12,9 +13,14 @@ import { export function unstable_revalidatePath( path: string, ctx: { + manualRevalidate?: boolean unstable_onlyGenerated?: boolean } = {} ) { + if (!ctx?.manualRevalidate) { + return unstable_revalidateTag(path) + } + const staticGenerationAsyncStorage = ( fetch as any ).__nextGetStaticStore?.() as undefined | StaticGenerationAsyncStorage diff --git a/test/e2e/app-dir/app-static/app-static.test.ts b/test/e2e/app-dir/app-static/app-static.test.ts index 4496c3e3c55d4..58c6f510c91d6 100644 --- a/test/e2e/app-dir/app-static/app-static.test.ts +++ b/test/e2e/app-dir/app-static/app-static.test.ts @@ -32,6 +32,22 @@ createNextDescribe( } }) + it('should not have cache tags header for non-minimal mode', async () => { + for (const path of [ + '/ssr-forced', + '/ssr-forced', + '/variable-revalidate/revalidate-3', + '/variable-revalidate/revalidate-360', + '/variable-revalidate/revalidate-360-isr', + ]) { + const res = await fetchViaHTTP(next.url, path, undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-next-cache-tags')).toBeFalsy() + } + }) + if (isDev) { it('should error correctly for invalid params from generateStaticParams', async () => { await next.patchFile( @@ -154,7 +170,7 @@ createNextDescribe( // On-Demand Revalidate has not effect in dev since app routes // aren't considered static until prerendering - if (!(global as any).isNextDev) { + if (!(global as any).isNextDev && !process.env.CUSTOM_CACHE_HANDLER) { it.each([ { type: 'edge route handler', @@ -205,7 +221,9 @@ createNextDescribe( // On-Demand Revalidate has not effect in dev if (!(global as any).isNextDev && !process.env.CUSTOM_CACHE_HANDLER) { it('should revalidate all fetches during on-demand revalidate', async () => { - const initRes = await next.fetch('/variable-revalidate/revalidate-360') + const initRes = await next.fetch( + '/variable-revalidate/revalidate-360-isr' + ) const html = await initRes.text() const $ = cheerio.load(html) const initLayoutData = $('#layout-data').text() @@ -410,6 +428,7 @@ createNextDescribe( 'partial-gen-params/[lang]/[slug]/page.js', 'route-handler-edge/revalidate-360/route.js', 'route-handler/post/route.js', + 'route-handler/revalidate-360-isr/route.js', 'route-handler/revalidate-360/route.js', 'ssg-draft-mode.html', 'ssg-draft-mode.rsc', @@ -633,6 +652,16 @@ createNextDescribe( initialRevalidateSeconds: 3, srcRoute: '/gen-params-dynamic-revalidate/[slug]', }, + '/route-handler/revalidate-360-isr': { + dataRoute: null, + initialHeaders: { + 'content-type': 'application/json', + 'x-next-cache-tags': + 'thankyounext,/route-handler/revalidate-360-isr', + }, + initialRevalidateSeconds: false, + srcRoute: '/route-handler/revalidate-360-isr', + }, '/variable-config-revalidate/revalidate-3': { dataRoute: '/variable-config-revalidate/revalidate-3.rsc', initialRevalidateSeconds: 3, diff --git a/test/e2e/app-dir/app-static/app/api/revalidate-path-edge/route.ts b/test/e2e/app-dir/app-static/app/api/revalidate-path-edge/route.ts index 0f40251ecef30..2e82c12457911 100644 --- a/test/e2e/app-dir/app-static/app/api/revalidate-path-edge/route.ts +++ b/test/e2e/app-dir/app-static/app/api/revalidate-path-edge/route.ts @@ -1,4 +1,5 @@ -import { NextRequest, NextResponse, unstable_revalidatePath } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' +import { unstable_revalidatePath } from 'next/cache' export const runtime = 'edge' diff --git a/test/e2e/app-dir/app-static/app/api/revalidate-path-node/route.ts b/test/e2e/app-dir/app-static/app/api/revalidate-path-node/route.ts index 41698be1fda3a..71f3327779099 100644 --- a/test/e2e/app-dir/app-static/app/api/revalidate-path-node/route.ts +++ b/test/e2e/app-dir/app-static/app/api/revalidate-path-node/route.ts @@ -1,4 +1,5 @@ -import { NextRequest, NextResponse, unstable_revalidatePath } from 'next/server' +import { NextRequest, NextResponse } from 'next/server' +import { unstable_revalidatePath } from 'next/cache' export async function GET(req: NextRequest) { const path = req.nextUrl.searchParams.get('path') || '/' diff --git a/test/e2e/app-dir/app-static/app/api/revalidate-tag-edge/route.ts b/test/e2e/app-dir/app-static/app/api/revalidate-tag-edge/route.ts index 93a57f3e1a014..5f854baf84679 100644 --- a/test/e2e/app-dir/app-static/app/api/revalidate-tag-edge/route.ts +++ b/test/e2e/app-dir/app-static/app/api/revalidate-tag-edge/route.ts @@ -1,6 +1,8 @@ -import { NextResponse, unstable_revalidateTag } from 'next/server' +import { NextResponse } from 'next/server' +import { unstable_revalidateTag } from 'next/cache' export const revalidate = 0 +export const runtime = 'edge' export async function GET(req) { const tag = req.nextUrl.searchParams.get('tag') diff --git a/test/e2e/app-dir/app-static/app/api/revalidate-tag-node/route.ts b/test/e2e/app-dir/app-static/app/api/revalidate-tag-node/route.ts index dbfd3cfb2f269..cb2991b985a66 100644 --- a/test/e2e/app-dir/app-static/app/api/revalidate-tag-node/route.ts +++ b/test/e2e/app-dir/app-static/app/api/revalidate-tag-node/route.ts @@ -1,7 +1,7 @@ -import { NextResponse, unstable_revalidateTag } from 'next/server' +import { NextResponse } from 'next/server' +import { unstable_revalidateTag } from 'next/cache' export const revalidate = 0 -export const runtime = 'edge' export async function GET(req) { const tag = req.nextUrl.searchParams.get('tag') diff --git a/test/e2e/app-dir/app-static/app/route-handler/revalidate-360-isr/route.js b/test/e2e/app-dir/app-static/app/route-handler/revalidate-360-isr/route.js new file mode 100644 index 0000000000000..cea77b5db529c --- /dev/null +++ b/test/e2e/app-dir/app-static/app/route-handler/revalidate-360-isr/route.js @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server' + +export async function GET() { + const data360 = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random', + { + next: { + revalidate: 360, + tags: ['thankyounext'], + }, + } + ).then((res) => res.text()) + + const data10 = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random?a=10', + { + next: { + revalidate: 10, + tags: ['thankyounext'], + }, + } + ).then((res) => res.text()) + + return NextResponse.json({ data360, data10 }) +} diff --git a/test/e2e/app-dir/app-static/app/variable-revalidate/revalidate-360-isr/page.js b/test/e2e/app-dir/app-static/app/variable-revalidate/revalidate-360-isr/page.js index 7ffc7ab15e611..0598d218a9873 100644 --- a/test/e2e/app-dir/app-static/app/variable-revalidate/revalidate-360-isr/page.js +++ b/test/e2e/app-dir/app-static/app/variable-revalidate/revalidate-360-isr/page.js @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/server' +import { unstable_cache } from 'next/cache' export default async function Page() { const data = await fetch( diff --git a/test/e2e/app-dir/app-static/app/variable-revalidate/revalidate-360/page.js b/test/e2e/app-dir/app-static/app/variable-revalidate/revalidate-360/page.js index 54f6666f2a687..87b2d496bb78e 100644 --- a/test/e2e/app-dir/app-static/app/variable-revalidate/revalidate-360/page.js +++ b/test/e2e/app-dir/app-static/app/variable-revalidate/revalidate-360/page.js @@ -1,4 +1,4 @@ -import { unstable_cache } from 'next/server' +import { unstable_cache } from 'next/cache' export const revalidate = 0 diff --git a/test/production/standalone-mode/required-server-files/app/api/isr/[slug]/route.js b/test/production/standalone-mode/required-server-files/app/api/isr/[slug]/route.js new file mode 100644 index 0000000000000..10c70845534a5 --- /dev/null +++ b/test/production/standalone-mode/required-server-files/app/api/isr/[slug]/route.js @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server' + +export const revalidate = 3 + +export function generateStaticParams() { + return [{ slug: 'first' }] +} + +export async function GET(req, { params }) { + const data = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random', + { + next: { + tags: ['isr-page'], + }, + } + ).then((res) => res.text()) + + return NextResponse.json({ + now: Date.now(), + params, + data, + }) +} diff --git a/test/production/standalone-mode/required-server-files/app/api/ssr/[slug]/route.js b/test/production/standalone-mode/required-server-files/app/api/ssr/[slug]/route.js new file mode 100644 index 0000000000000..992480ab1a69e --- /dev/null +++ b/test/production/standalone-mode/required-server-files/app/api/ssr/[slug]/route.js @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server' + +export const revalidate = 0 + +export function generateStaticParams() { + return [{ slug: 'first' }] +} + +export async function GET(req, { params }) { + const data = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random', + { + next: { + tags: ['ssr-page'], + }, + } + ).then((res) => res.text()) + + return NextResponse.json({ + now: Date.now(), + params, + data, + }) +} diff --git a/test/production/standalone-mode/required-server-files/app/isr/[slug]/page.js b/test/production/standalone-mode/required-server-files/app/isr/[slug]/page.js new file mode 100644 index 0000000000000..a1d71dfb5892b --- /dev/null +++ b/test/production/standalone-mode/required-server-files/app/isr/[slug]/page.js @@ -0,0 +1,25 @@ +export const revalidate = 3 + +export function generateStaticParams() { + return [{ slug: 'first' }] +} + +export default async function Page({ params }) { + const data = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random', + { + next: { + tags: ['isr-page'], + }, + } + ).then((res) => res.text()) + + return ( + <> +

/isr/[slug]

+

{JSON.stringify(params)}

+

{Date.now()}

+

{data}

+ + ) +} diff --git a/test/production/standalone-mode/required-server-files/app/layout.js b/test/production/standalone-mode/required-server-files/app/layout.js new file mode 100644 index 0000000000000..9e313094feba9 --- /dev/null +++ b/test/production/standalone-mode/required-server-files/app/layout.js @@ -0,0 +1,8 @@ +export default function Layout({ children }) { + return ( + + + {children} + + ) +} diff --git a/test/production/standalone-mode/required-server-files/app/ssr/[slug]/page.js b/test/production/standalone-mode/required-server-files/app/ssr/[slug]/page.js new file mode 100644 index 0000000000000..e48d976c32167 --- /dev/null +++ b/test/production/standalone-mode/required-server-files/app/ssr/[slug]/page.js @@ -0,0 +1,21 @@ +export const revalidate = 0 + +export default async function Page({ params }) { + const data = await fetch( + 'https://next-data-api-endpoint.vercel.app/api/random', + { + next: { + tags: ['ssr-page'], + }, + } + ).then((res) => res.text()) + + return ( + <> +

/ssr/[slug]

+

{JSON.stringify(params)}

+

{Date.now()}

+

{data}

+ + ) +} diff --git a/test/production/standalone-mode/required-server-files/required-server-files-app.test.ts b/test/production/standalone-mode/required-server-files/required-server-files-app.test.ts new file mode 100644 index 0000000000000..a09fba952f438 --- /dev/null +++ b/test/production/standalone-mode/required-server-files/required-server-files-app.test.ts @@ -0,0 +1,130 @@ +import glob from 'glob' +import fs from 'fs-extra' +import { join } from 'path' +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { + fetchViaHTTP, + findPort, + initNextServerScript, + killApp, +} from 'next-test-utils' + +describe('should set-up next', () => { + let next: NextInstance + let server + let appPort + + const setupNext = async ({ + nextEnv, + minimalMode, + }: { + nextEnv?: boolean + minimalMode?: boolean + }) => { + // test build against environment with next support + process.env.NOW_BUILDER = nextEnv ? '1' : '' + + next = await createNext({ + files: { + app: new FileRef(join(__dirname, 'app')), + lib: new FileRef(join(__dirname, 'lib')), + 'middleware.js': new FileRef(join(__dirname, 'middleware.js')), + 'data.txt': new FileRef(join(__dirname, 'data.txt')), + '.env': new FileRef(join(__dirname, '.env')), + '.env.local': new FileRef(join(__dirname, '.env.local')), + '.env.production': new FileRef(join(__dirname, '.env.production')), + }, + nextConfig: { + eslint: { + ignoreDuringBuilds: true, + }, + experimental: { + appDir: true, + }, + output: 'standalone', + }, + }) + await next.stop() + + await fs.move( + join(next.testDir, '.next/standalone'), + join(next.testDir, 'standalone') + ) + for (const file of await fs.readdir(next.testDir)) { + if (file !== 'standalone') { + await fs.remove(join(next.testDir, file)) + console.log('removed', file) + } + } + const files = glob.sync('**/*', { + cwd: join(next.testDir, 'standalone/.next/server/pages'), + dot: true, + }) + + for (const file of files) { + if (file.endsWith('.json') || file.endsWith('.html')) { + await fs.remove(join(next.testDir, '.next/server', file)) + } + } + + const testServer = join(next.testDir, 'standalone/server.js') + await fs.writeFile( + testServer, + (await fs.readFile(testServer, 'utf8')) + .replace('console.error(err)', `console.error('top-level', err)`) + .replace('conf:', `minimalMode: ${minimalMode},conf:`) + ) + appPort = await findPort() + server = await initNextServerScript( + testServer, + /Listening on/, + { + ...process.env, + PORT: appPort, + }, + undefined, + { + cwd: next.testDir, + } + ) + } + + beforeAll(async () => { + await setupNext({ nextEnv: true, minimalMode: true }) + }) + afterAll(async () => { + await next.destroy() + if (server) await killApp(server) + }) + + it('should send cache tags in minimal mode for ISR', async () => { + for (const [path, tags] of [ + ['/isr/first', 'isr-page,/isr/[slug]'], + ['/isr/second', 'isr-page,/isr/[slug]'], + ['/api/isr/first', 'isr-page,/api/isr/[slug]'], + ['/api/isr/second', 'isr-page,/api/isr/[slug]'], + ]) { + const res = await fetchViaHTTP(appPort, path, undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-next-cache-tags')).toBe(tags) + } + }) + + it('should not send cache tags in minimal mode for SSR', async () => { + for (const path of [ + '/ssr/first', + '/ssr/second', + '/api/ssr/first', + '/api/ssr/second', + ]) { + const res = await fetchViaHTTP(appPort, path, undefined, { + redirect: 'manual', + }) + expect(res.status).toBe(200) + expect(res.headers.get('x-next-cache-tags')).toBeFalsy() + } + }) +}) From 26f69d5ef3ccf5c19ff454050f01ddce4dc29f20 Mon Sep 17 00:00:00 2001 From: JJ Kasper Date: Tue, 2 May 2023 10:01:36 -0700 Subject: [PATCH 4/6] Simplify CNA prompts a bit (#49063) This updates the default CNA prompts a bit to be more straightforward. x-ref: [slack thread](https://vercel.slack.com/archives/C03KAR5DCKC/p1681919151818769) x-ref: [slack thread](https://vercel.slack.com/archives/C04K237UHCP/p1682959312307409) --- docs/api-reference/create-next-app.md | 4 +- packages/create-next-app/create-app.ts | 6 +- packages/create-next-app/index.ts | 58 ++++++++++++------- .../integration/create-next-app/index.test.ts | 22 +++---- .../create-next-app/templates.test.ts | 44 +++++++++----- 5 files changed, 81 insertions(+), 53 deletions(-) diff --git a/docs/api-reference/create-next-app.md b/docs/api-reference/create-next-app.md index 1eb306a6a98af..5e7ab10d74f45 100644 --- a/docs/api-reference/create-next-app.md +++ b/docs/api-reference/create-next-app.md @@ -57,9 +57,9 @@ Options: Initialize with ESLint config. - --experimental-app + --app-dir - Initialize as a `app/` directory project. + Initialize as an `app/` directory project. --src-dir diff --git a/packages/create-next-app/create-app.ts b/packages/create-next-app/create-app.ts index e525f0aba121e..a809b9f2a9b53 100644 --- a/packages/create-next-app/create-app.ts +++ b/packages/create-next-app/create-app.ts @@ -36,7 +36,7 @@ export async function createApp({ typescript, tailwind, eslint, - experimentalApp, + appDir, srcDir, importAlias, }: { @@ -47,13 +47,13 @@ export async function createApp({ typescript: boolean tailwind: boolean eslint: boolean - experimentalApp: boolean + appDir: boolean srcDir: boolean importAlias: string }): Promise { let repoInfo: RepoInfo | undefined const mode: TemplateMode = typescript ? 'ts' : 'js' - const template: TemplateType = experimentalApp + const template: TemplateType = appDir ? tailwind ? 'app-tw' : 'app' diff --git a/packages/create-next-app/index.ts b/packages/create-next-app/index.ts index a7a139e731c91..b66b4bb37c9f8 100644 --- a/packages/create-next-app/index.ts +++ b/packages/create-next-app/index.ts @@ -67,10 +67,10 @@ const program = new Commander.Command(packageJson.name) ` ) .option( - '--experimental-app', + '--app-dir', ` - Initialize as a \`app/\` directory project. + Initialize as an \`app/\` directory project. ` ) .option( @@ -232,6 +232,7 @@ async function run(): Promise { tailwind: true, srcDir: false, importAlias: '@/*', + customizeImportAlias: false, } const getPrefOrDefault = (field: string) => preferences[field] ?? defaults[field] @@ -340,15 +341,13 @@ async function run(): Promise { } if ( - !process.argv.includes('--experimental-app') && - !process.argv.includes('--no-experimental-app') + !process.argv.includes('--app-dir') && + !process.argv.includes('--no-app-dir') ) { if (ciInfo.isCI) { - program.experimentalApp = false + program.appDir = false } else { - const styledAppDir = chalk.hex('#007acc')( - 'experimental `app/` directory' - ) + const styledAppDir = chalk.hex('#007acc')('`app/` directory') const { appDir } = await prompts({ onState: onPromptState, type: 'toggle', @@ -358,7 +357,7 @@ async function run(): Promise { active: 'Yes', inactive: 'No', }) - program.experimentalApp = Boolean(appDir) + program.appDir = Boolean(appDir) } } @@ -370,19 +369,34 @@ async function run(): Promise { program.importAlias = '@/*' } else { const styledImportAlias = chalk.hex('#007acc')('import alias') - const { importAlias } = await prompts({ + + const { customizeImportAlias } = await prompts({ onState: onPromptState, - type: 'text', - name: 'importAlias', - message: `What ${styledImportAlias} would you like configured?`, - initial: getPrefOrDefault('importAlias'), - validate: (value) => - /.+\/\*/.test(value) - ? true - : 'Import alias must follow the pattern /*', + type: 'toggle', + name: 'customizeImportAlias', + message: `Would you like to customize the default ${styledImportAlias}?`, + initial: getPrefOrDefault('customizeImportAlias'), + active: 'Yes', + inactive: 'No', }) - program.importAlias = importAlias - preferences.importAlias = importAlias + + if (!customizeImportAlias) { + program.importAlias = '@/*' + } else { + const { importAlias } = await prompts({ + onState: onPromptState, + type: 'text', + name: 'importAlias', + message: `What ${styledImportAlias} would you like configured?`, + initial: getPrefOrDefault('importAlias'), + validate: (value) => + /.+\/\*/.test(value) + ? true + : 'Import alias must follow the pattern /*', + }) + program.importAlias = importAlias + preferences.importAlias = importAlias + } } } } @@ -396,7 +410,7 @@ async function run(): Promise { typescript: program.typescript, tailwind: program.tailwind, eslint: program.eslint, - experimentalApp: program.experimentalApp, + appDir: program.appDir, srcDir: program.srcDir, importAlias: program.importAlias, }) @@ -424,7 +438,7 @@ async function run(): Promise { typescript: program.typescript, eslint: program.eslint, tailwind: program.tailwind, - experimentalApp: program.experimentalApp, + appDir: program.appDir, srcDir: program.srcDir, importAlias: program.importAlias, }) diff --git a/test/integration/create-next-app/index.test.ts b/test/integration/create-next-app/index.test.ts index 12df3eb27e689..b1c3352204c00 100644 --- a/test/integration/create-next-app/index.test.ts +++ b/test/integration/create-next-app/index.test.ts @@ -51,7 +51,7 @@ describe('create next app', () => { '--no-tailwind', '--eslint', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -77,7 +77,7 @@ describe('create next app', () => { '--no-tailwind', '--eslint', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { cwd } @@ -362,7 +362,7 @@ describe('create next app', () => { '--eslint', '--example', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -399,7 +399,7 @@ describe('create next app', () => { '--no-tailwind', '--eslint', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -440,7 +440,7 @@ describe('create next app', () => { '--no-tailwind', '--eslint', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -466,7 +466,7 @@ describe('create next app', () => { '--no-tailwind', '--eslint', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -491,7 +491,7 @@ describe('create next app', () => { '--eslint', '--use-npm', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -546,7 +546,7 @@ describe('create next app', () => { '--eslint', '--use-pnpm', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -618,7 +618,7 @@ describe('create next app', () => { '--no-tailwind', '--eslint', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -686,7 +686,7 @@ describe('create next app', () => { '--no-tailwind', '--eslint', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -761,7 +761,7 @@ describe('create next app', () => { '--no-tailwind', '--eslint', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { diff --git a/test/integration/create-next-app/templates.test.ts b/test/integration/create-next-app/templates.test.ts index cf5e20931778f..ec32ef42e53dc 100644 --- a/test/integration/create-next-app/templates.test.ts +++ b/test/integration/create-next-app/templates.test.ts @@ -19,9 +19,16 @@ import { import { Span } from 'next/dist/trace' import { useTempDir } from '../../../test/lib/use-temp-dir' -import { fetchViaHTTP, findPort, killApp, launchApp } from 'next-test-utils' +import { + check, + fetchViaHTTP, + findPort, + killApp, + launchApp, +} from 'next-test-utils' import resolveFrom from 'resolve-from' import { createNextInstall } from '../../../test/lib/create-next-install' +import ansiEscapes from 'ansi-escapes' const startsWithoutError = async ( appDir: string, @@ -80,7 +87,7 @@ describe('create-next-app templates', () => { '--no-tailwind', '--eslint', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -126,7 +133,7 @@ describe('create-next-app templates', () => { '--no-tailwind', '--eslint', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -153,7 +160,7 @@ describe('create-next-app templates', () => { '--no-tailwind', '--eslint', '--src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -207,7 +214,7 @@ describe('create-next-app templates', () => { '--no-tailwind', '--eslint', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -236,7 +243,7 @@ describe('create-next-app templates', () => { '--no-tailwind', '--eslint', '--src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -274,7 +281,7 @@ describe('create-next-app templates', () => { '--no-tailwind', '--eslint', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', ], { cwd, @@ -284,11 +291,18 @@ describe('create-next-app templates', () => { /** * Bind the exit listener. */ - await new Promise((resolve) => { + await new Promise(async (resolve) => { childProcess.on('exit', async (exitCode) => { expect(exitCode).toBe(0) resolve() }) + let output = '' + childProcess.stdout.on('data', (data) => { + output += data + process.stdout.write(data) + }) + childProcess.stdin.write(ansiEscapes.cursorForward() + '\n') + await check(() => output, /What import alias would you like configured/) childProcess.stdin.write('@/something/*\n') }) @@ -320,7 +334,7 @@ describe('create-next-app templates', () => { '--no-eslint', '--tailwind', '--src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -369,7 +383,7 @@ describe('create-next-app templates', () => { '--js', '--eslint', '--no-src-dir', - '--no-experimental-app', + '--no-app-dir', `--import-alias=@/*`, ], { @@ -406,7 +420,7 @@ describe('create-next-app templates', () => { }) }) -describe('create-next-app --experimental-app', () => { +describe('create-next-app --app-dir', () => { if (!process.env.NEXT_TEST_CNA && process.env.NEXT_TEST_JOB) { it('should skip when env is not set', () => {}) return @@ -428,7 +442,7 @@ describe('create-next-app --experimental-app', () => { projectName, '--ts', '--no-tailwind', - '--experimental-app', + '--app-dir', '--eslint', '--no-src-dir', `--import-alias=@/*`, @@ -458,7 +472,7 @@ describe('create-next-app --experimental-app', () => { projectName, '--js', '--no-tailwind', - '--experimental-app', + '--app-dir', '--eslint', '--no-src-dir', `--import-alias=@/*`, @@ -489,7 +503,7 @@ describe('create-next-app --experimental-app', () => { projectName, '--js', '--no-tailwind', - '--experimental-app', + '--app-dir', '--eslint', '--src-dir', '--import-alias=@/*', @@ -526,7 +540,7 @@ describe('create-next-app --experimental-app', () => { projectName, '--ts', '--tailwind', - '--experimental-app', + '--app-dir', '--eslint', '--src-dir', `--import-alias=@/*`, From 692d28b193a731c5f678157a1702574685672fdf Mon Sep 17 00:00:00 2001 From: Maia Teegarden Date: Tue, 2 May 2023 10:54:07 -0700 Subject: [PATCH 5/6] Update turbopack warning (#49051) This PR: * Adds more config keys that should be supported or can be ignored * Cleans up supported key checking and allows nested keys that aren't experimental * Removes logging for "only supported options" since the list is much longer now --- packages/next/src/lib/turbopack-warning.ts | 128 +++++++++++---------- 1 file changed, 67 insertions(+), 61 deletions(-) diff --git a/packages/next/src/lib/turbopack-warning.ts b/packages/next/src/lib/turbopack-warning.ts index 2d4261cc218f2..b02ba373d9e63 100644 --- a/packages/next/src/lib/turbopack-warning.ts +++ b/packages/next/src/lib/turbopack-warning.ts @@ -6,13 +6,9 @@ import { PHASE_DEVELOPMENT_SERVER } from '../shared/lib/constants' const supportedTurbopackNextConfigOptions = [ 'configFileName', 'env', - 'experimental.appDir', 'modularizeImports', 'compiler.emotion', 'compiler.styledComponents', - 'experimental.serverComponentsExternalPackages', - 'experimental.turbo', - 'experimental.mdxRs', 'images', 'pageExtensions', 'onDemandEntries', @@ -22,14 +18,27 @@ const supportedTurbopackNextConfigOptions = [ 'reactStrictMode', 'swcMinify', 'transpilePackages', + 'experimental.appDir', + 'experimental.serverComponentsExternalPackages', + 'experimental.turbo', + 'experimental.mdxRs', 'experimental.swcFileReading', 'experimental.forceSwcTransforms', + // options below are not really supported, but ignored + 'devIndicators', + 'onDemandEntries', + 'experimental.cpus', + 'experimental.sharedPool', + 'experimental.proxyTimeout', + 'experimental.isrFlushToDisk', + 'experimental.workerThreads', + 'experimenatl.pageEnv', ] // The following will need to be supported by `next build --turbo` const prodSpecificTurboNextConfigOptions = [ - 'eslint.ignoreDuringBuilds', - 'typescript.ignoreDuringBuilds', + 'eslint', + 'typescript', 'staticPageGenerationTimeout', 'outputFileTracing', 'output', @@ -39,6 +48,15 @@ const prodSpecificTurboNextConfigOptions = [ 'productionBrowserSourceMaps', 'optimizeFonts', 'poweredByHeader', + 'staticPageGenerationTimeout', + 'compiler.reactRemoveProperties', + 'compiler.removeConsole', + 'experimental.turbotrace', + 'experimental.outputFileTracingRoot', + 'experimental.outputFileTracingExcludes', + 'experimental.outputFileTracingIgnores', + 'experiemental.outputFileTracingIncludes', + 'experimental.gzipSize', ] // check for babelrc, swc plugins @@ -94,65 +112,57 @@ export async function validateTurboNextConfig({ }) } - let supported = isDev - ? supportedTurbopackNextConfigOptions - : [ - ...supportedTurbopackNextConfigOptions, - ...prodSpecificTurboNextConfigOptions, - ] - const checkUnsupportedCustomConfig = ( - configKey = '', - parentUserConfig: any, - parentDefaultConfig: any - ): boolean => { - try { - // these should not error - if ( - // we only want the key after the dot for experimental options - supported - .map((key) => key.split('.').splice(-1)[0]) - .includes(configKey) - ) { - return false - } + const flattenKeys = (obj: any, prefix: string = ''): string[] => { + let keys: string[] = [] - // experimental options are checked separately - if (configKey === 'experimental') { - return false + for (const key in obj) { + if (typeof obj[key] === 'undefined') { + continue } - let userValue = parentUserConfig?.[configKey] - let defaultValue = parentDefaultConfig?.[configKey] + const pre = prefix.length ? `${prefix}.` : '' - if (typeof defaultValue !== 'object') { - return defaultValue !== userValue + if ( + typeof obj[key] === 'object' && + !Array.isArray(obj[key]) && + obj[key] !== null + ) { + keys = keys.concat(flattenKeys(obj[key], pre + key)) + } else { + keys.push(pre + key) } - return Object.keys(userValue || {}).some((key: string) => { - return checkUnsupportedCustomConfig(key, userValue, defaultValue) - }) - } catch (e) { - console.error( - `Unexpected error occurred while checking ${configKey}`, - e - ) - return false } + + return keys + } + + const getDeepValue = (obj: any, keys: string | string[]): any => { + if (typeof keys === 'string') { + keys = keys.split('.') + } + if (keys.length === 1) { + return obj[keys[0]] + } + return getDeepValue(obj[keys[0]], keys.slice(1)) } - unsupportedConfig = [ - ...Object.keys(rawNextConfig).filter((key) => - checkUnsupportedCustomConfig(key, rawNextConfig, defaultConfig) - ), - ...Object.keys(rawNextConfig.experimental ?? {}) - .filter((key) => - checkUnsupportedCustomConfig( - key, - rawNextConfig?.experimental, - defaultConfig?.experimental - ) - ) - .map((key) => `experimental.${key}`), - ] + const customKeys = flattenKeys(rawNextConfig) + + let supportedKeys = isDev + ? [ + ...supportedTurbopackNextConfigOptions, + ...prodSpecificTurboNextConfigOptions, + ] + : supportedTurbopackNextConfigOptions + + for (const key of customKeys) { + let isSupported = + supportedKeys.some((supportedKey) => key.startsWith(supportedKey)) || + getDeepValue(rawNextConfig, key) === getDeepValue(defaultConfig, key) + if (!isSupported) { + unsupportedConfig.push(key) + } + } } catch (e) { console.error('Unexpected error occurred while checking config', e) } @@ -188,10 +198,6 @@ export async function validateTurboNextConfig({ )})\n ${chalk.dim( `To use Turbopack, remove the following configuration options:\n${unsupportedConfig .map((name) => ` - ${chalk.red(name)}\n`) - .join( - '' - )} The only supported configurations options are:\n${supportedTurbopackNextConfigOptions - .map((name) => ` - ${chalk.cyan(name)}\n`) .join('')} ` )} ` } From f3068a5bbb3f16fed4335a1e1e269898ff4bae27 Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Tue, 2 May 2023 20:46:13 +0200 Subject: [PATCH 6/6] Add validation to server methods (#49104) This PR adds a `key` param to the IPC server to validate if a request is from a child process or not. --- packages/next/src/server/dev/next-dev-server.ts | 3 ++- packages/next/src/server/lib/server-ipc.ts | 16 ++++++++++++++++ packages/next/src/server/next-server.ts | 4 +++- 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/next/src/server/dev/next-dev-server.ts b/packages/next/src/server/dev/next-dev-server.ts index 900f453862c93..b83bb0d072b1a 100644 --- a/packages/next/src/server/dev/next-dev-server.ts +++ b/packages/next/src/server/dev/next-dev-server.ts @@ -1271,9 +1271,10 @@ 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}?method=${ + `http://${this.hostname}:${ipcPort}?key=${ipcKey}&method=${ method as string }&args=${encodeURIComponent(JSON.stringify(args))}`, { diff --git a/packages/next/src/server/lib/server-ipc.ts b/packages/next/src/server/lib/server-ipc.ts index 026429df8d0da..e711ecaea81c1 100644 --- a/packages/next/src/server/lib/server-ipc.ts +++ b/packages/next/src/server/lib/server-ipc.ts @@ -2,6 +2,7 @@ import type NextServer from '../next-server' import { genExecArgv, getNodeOptionsWithoutInspect } from './utils' import { deserializeErr, errorToJSON } from '../render' import { IncomingMessage } from 'http' +import crypto from 'crypto' import isError from '../../lib/is-error' // we can't use process.send as jest-worker relies on @@ -12,11 +13,23 @@ export async function createIpcServer( ): Promise<{ ipcPort: number ipcServer: import('http').Server + ipcValidationKey: string }> { + // Generate a random key in memory to validate messages from other processes. + // This is just a simple guard against other processes attempting to send + // traffic to the IPC server. + const ipcValidationKey = crypto.randomBytes(32).toString('hex') + const ipcServer = (require('http') as typeof import('http')).createServer( async (req, res) => { try { const url = new URL(req.url || '/', 'http://n') + const key = url.searchParams.get('key') + + if (key !== ipcValidationKey) { + return res.end() + } + const method = url.searchParams.get('method') const args: any[] = JSON.parse(url.searchParams.get('args') || '[]') @@ -61,12 +74,14 @@ export async function createIpcServer( return { ipcPort, ipcServer, + ipcValidationKey, } } export const createWorker = ( serverPort: number, ipcPort: number, + ipcValidationKey: string, isNodeDebugging: boolean | 'brk' | undefined, type: 'pages' | 'app', useServerActions?: boolean @@ -88,6 +103,7 @@ export const createWorker = ( .trim(), __NEXT_PRIVATE_RENDER_WORKER: type, __NEXT_PRIVATE_ROUTER_IPC_PORT: ipcPort + '', + __NEXT_PRIVATE_ROUTER_IPC_KEY: ipcValidationKey, NODE_ENV: process.env.NODE_ENV, ...(type === 'app' ? { diff --git a/packages/next/src/server/next-server.ts b/packages/next/src/server/next-server.ts index 55d4ec4f1e4cc..0ebce2758ae79 100644 --- a/packages/next/src/server/next-server.ts +++ b/packages/next/src/server/next-server.ts @@ -268,11 +268,12 @@ export default class NextNodeServer extends BaseServer { this.renderWorkersPromises = new Promise(async (resolveWorkers) => { try { this.renderWorkers = {} - const { ipcPort } = await createIpcServer(this) + const { ipcPort, ipcValidationKey } = await createIpcServer(this) if (this.hasAppDir) { this.renderWorkers.app = createWorker( this.port || 0, ipcPort, + ipcValidationKey, options.isNodeDebugging, 'app', this.nextConfig.experimental.serverActions @@ -281,6 +282,7 @@ export default class NextNodeServer extends BaseServer { this.renderWorkers.pages = createWorker( this.port || 0, ipcPort, + ipcValidationKey, options.isNodeDebugging, 'pages' )