From 99d4d6c5a41003338f62fd20bdcafe78e02f7a1a Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Wed, 26 Jan 2022 07:22:11 +0100 Subject: [PATCH] Implement web server as the request handler for edge SSR (#33635) (#31506 for context) This PR implements the minimum viable web server on top of the Next.js base server, and integrates it into our middleware (edge) SSR runtime to handle all the requests. This also addresses problems like missing dynamic routes support in our current handler. Note that this is the initial implementation with the assumption that the web server is running under minimal mode. Also later we can refactor the `__server_context` environment to properly passing the context via the constructor or methods. --- packages/next/build/entries.ts | 4 +- .../next-middleware-ssr-loader/index.ts | 40 ++- .../next-middleware-ssr-loader/render.ts | 123 ++------ packages/next/lib/chalk.ts | 2 +- packages/next/lib/web/chalk.ts | 3 +- packages/next/server/base-server.ts | 273 +++--------------- packages/next/server/dev/hot-reloader.ts | 10 +- packages/next/server/incremental-cache.ts | 19 +- packages/next/server/next-server.ts | 99 +++++++ packages/next/server/server-route-utils.ts | 155 ++++++++++ packages/next/server/web-server.ts | 191 ++++++++++++ .../app/pages/routes/[dynamic].server.js | 4 +- .../test/index.test.js | 23 +- 13 files changed, 569 insertions(+), 377 deletions(-) create mode 100644 packages/next/server/server-route-utils.ts create mode 100644 packages/next/server/web-server.ts diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index dab27b168dffc..053944e5d0bc2 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -157,7 +157,6 @@ export function createEntrypoints( const isFlight = isFlightPage(config, absolutePagePath) const webServerRuntime = !!config.experimental.concurrentFeatures - const hasServerComponents = !!config.experimental.serverComponents if (page.match(MIDDLEWARE_ROUTE)) { const loaderOpts: MiddlewareLoaderOptions = { @@ -176,11 +175,12 @@ export function createEntrypoints( serverWeb[serverBundlePath] = finalizeEntrypoint({ name: '[name].js', value: `next-middleware-ssr-loader?${stringify({ + dev: false, page, + stringifiedConfig: JSON.stringify(config), absolute500Path: pages['/500'] || '', absolutePagePath, isServerComponent: isFlight, - serverComponents: hasServerComponents, ...defaultServerlessOptions, } as any)}!`, isServer: false, diff --git a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts index cc0b4435deec2..4f23d19936fca 100644 --- a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts +++ b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/index.ts @@ -2,13 +2,16 @@ import { stringifyRequest } from '../../stringify-request' export default async function middlewareSSRLoader(this: any) { const { + dev, + page, + buildId, absolutePagePath, absoluteAppPath, absoluteDocumentPath, absolute500Path, absoluteErrorPath, isServerComponent, - ...restRenderOpts + stringifiedConfig, } = this.getOptions() const stringifiedAbsolutePagePath = stringifyRequest(this, absolutePagePath) @@ -42,16 +45,37 @@ export default async function middlewareSSRLoader(this: any) { throw new Error('Your page must export a \`default\` component') } - const render = getRender({ - App, - Document, - pageMod, - errorMod, + // Set server context + self.__current_route = ${JSON.stringify(page)} + self.__server_context = { + Component: pageMod.default, + pageConfig: pageMod.config || {}, buildManifest, reactLoadableManifest, - rscManifest, + Document, + App, + getStaticProps: pageMod.getStaticProps, + getServerSideProps: pageMod.getServerSideProps, + getStaticPaths: pageMod.getStaticPaths, + ComponentMod: undefined, + serverComponentManifest: ${isServerComponent} ? rscManifest : null, + + // components + errorMod, + + // renderOpts + buildId: ${JSON.stringify(buildId)}, + dev: ${dev}, + env: process.env, + supportsDynamicHTML: true, + concurrentFeatures: true, + disableOptimizedLoading: true, + } + + const render = getRender({ + Document, isServerComponent: ${isServerComponent}, - restRenderOpts: ${JSON.stringify(restRenderOpts)} + config: ${stringifiedConfig}, }) export default function rscMiddleware(opts) { diff --git a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts index a0f158e8587be..a56c33a3a7319 100644 --- a/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts +++ b/packages/next/build/webpack/loaders/next-middleware-ssr-loader/render.ts @@ -1,8 +1,11 @@ +import type { NextConfig } from '../../../../server/config-shared' + import { NextRequest } from '../../../../server/web/spec-extension/request' -import { renderToHTML } from '../../../../server/web/render' -import RenderResult from '../../../../server/render-result' import { toNodeHeaders } from '../../../../server/web/utils' +import WebServer from '../../../../server/web-server' +import { WebNextRequest, WebNextResponse } from '../../../../server/base-http' + const createHeaders = (args?: any) => ({ ...args, 'x-middleware-ssr': '1', @@ -18,26 +21,22 @@ function sendError(req: any, error: Error) { } export function getRender({ - App, Document, - pageMod, - errorMod, - rscManifest, - buildManifest, - reactLoadableManifest, isServerComponent, - restRenderOpts, + config, }: { - App: any Document: any - pageMod: any - errorMod: any - rscManifest: object - buildManifest: any - reactLoadableManifest: any isServerComponent: boolean - restRenderOpts: any + config: NextConfig }) { + // Polyfilled for `path-browserify`. + process.cwd = () => '' + const server = new WebServer({ + conf: config, + minimalMode: true, + }) + const requestHandler = server.getRequestHandler() + return async function render(request: NextRequest) { const { nextUrl: url, cookies, headers } = request const { pathname, searchParams } = url @@ -56,6 +55,7 @@ export function getRender({ }) } + // @TODO: We should move this into server/render. if (Document.getInitialProps) { const err = new Error( '`getInitialProps` in Document component is not supported with `concurrentFeatures` enabled.' @@ -72,92 +72,15 @@ export function getRender({ ? JSON.parse(query.__props__) : undefined - delete query.__flight__ - delete query.__props__ - - const renderOpts = { - ...restRenderOpts, - // Locales are not supported yet. - // locales: i18n?.locales, - // locale: detectedLocale, - // defaultLocale, - // domainLocales: i18n?.domains, - dev: process.env.NODE_ENV !== 'production', - App, - Document, - buildManifest, - Component: pageMod.default, - pageConfig: pageMod.config || {}, - getStaticProps: pageMod.getStaticProps, - getServerSideProps: pageMod.getServerSideProps, - getStaticPaths: pageMod.getStaticPaths, - reactLoadableManifest, - env: process.env, - supportsDynamicHTML: true, - concurrentFeatures: true, - // When streaming, opt-out the `defer` behavior for script tags. - disableOptimizedLoading: true, + // Extend the context. + Object.assign((self as any).__server_context, { renderServerComponentData, serverComponentProps, - serverComponentManifest: isServerComponent ? rscManifest : null, - ComponentMod: null, - } - - const transformStream = new TransformStream() - const writer = transformStream.writable.getWriter() - const encoder = new TextEncoder() - - let result: RenderResult | null - let renderError: any - try { - result = await renderToHTML( - req as any, - {} as any, - pathname, - query, - renderOpts - ) - } catch (err: any) { - console.error( - 'An error occurred while rendering the initial result:', - err - ) - const errorRes = { statusCode: 500, err } - renderError = err - try { - req.url = '/_error' - result = await renderToHTML( - req as any, - errorRes as any, - '/_error', - query, - { - ...renderOpts, - err, - Component: errorMod.default, - getStaticProps: errorMod.getStaticProps, - getServerSideProps: errorMod.getServerSideProps, - getStaticPaths: errorMod.getStaticPaths, - } - ) - } catch (err2: any) { - return sendError(req, err2) - } - } - - if (!result) { - return sendError(req, new Error('No result returned from render.')) - } - - result.pipe({ - write: (str: string) => writer.write(encoder.encode(str)), - end: () => writer.close(), - // Not implemented: cork/uncork/on/removeListener - } as any) - - return new Response(transformStream.readable, { - headers: createHeaders(), - status: renderError ? 500 : 200, }) + + const extendedReq = new WebNextRequest(request) + const extendedRes = new WebNextResponse() + requestHandler(extendedReq, extendedRes) + return await extendedRes.toResponse() } } diff --git a/packages/next/lib/chalk.ts b/packages/next/lib/chalk.ts index d6150d2dcf3e9..f25f56ec35224 100644 --- a/packages/next/lib/chalk.ts +++ b/packages/next/lib/chalk.ts @@ -1,6 +1,6 @@ let chalk: typeof import('next/dist/compiled/chalk') -if (typeof window === 'undefined') { +if (!process.browser) { chalk = require('next/dist/compiled/chalk') } else { chalk = require('./web/chalk').default diff --git a/packages/next/lib/web/chalk.ts b/packages/next/lib/web/chalk.ts index eb4be7f9bd5ea..4cc398429aab6 100644 --- a/packages/next/lib/web/chalk.ts +++ b/packages/next/lib/web/chalk.ts @@ -4,8 +4,7 @@ // - chalk.red('error') // - chalk.bold.cyan('message') // - chalk.hex('#fff').underline('hello') -const log = console.log -const chalk: any = new Proxy(log, { +const chalk: any = new Proxy((s: string) => s, { get(_, prop: string) { if ( ['hex', 'rgb', 'ansi256', 'bgHex', 'bgRgb', 'bgAnsi256'].includes(prop) diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index b2683472fbb18..e229f0da60a68 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -1,18 +1,14 @@ import type { __ApiPreviewProps } from './api-utils' -import type { CustomRoutes, Header } from '../lib/load-custom-routes' +import type { CustomRoutes } from '../lib/load-custom-routes' import type { DomainLocale } from './config' import type { DynamicRoutes, PageChecker, Params, Route } from './router' -import type { FetchEventResult } from './web/types' import type { FontManifest } from './font-utils' import type { LoadComponentsReturnType } from './load-components' import type { MiddlewareManifest } from '../build/webpack/plugins/middleware-plugin' import type { NextConfig, NextConfigComplete } from './config-shared' import type { NextParsedUrlQuery, NextUrlWithParsedQuery } from './request-meta' -import type { ParsedNextUrl } from '../shared/lib/router/utils/parse-next-url' -import type { ParsedUrl } from '../shared/lib/router/utils/parse-url' import type { ParsedUrlQuery } from 'querystring' -import type { PrerenderManifest } from '../build' -import type { Redirect, Rewrite, RouteType } from '../lib/load-custom-routes' +import type { Rewrite } from '../lib/load-custom-routes' import type { RenderOpts, RenderOptsPartial } from './render' import type { ResponseCacheEntry, ResponseCacheValue } from './response-cache' import type { UrlWithParsedQuery } from 'url' @@ -22,9 +18,9 @@ import type { PagesManifest } from '../build/webpack/plugins/pages-manifest-plug import type { BaseNextRequest, BaseNextResponse } from './base-http' import { join, resolve } from 'path' -import { parse as parseQs, stringify as stringifyQs } from 'querystring' +import { parse as parseQs } from 'querystring' import { format as formatUrl, parse as parseUrl } from 'url' -import { getRedirectStatus, modifyRouteRegex } from '../lib/load-custom-routes' +import { getRedirectStatus } from '../lib/load-custom-routes' import { SERVERLESS_DIRECTORY, SERVER_DIRECTORY, @@ -41,12 +37,7 @@ import * as envConfig from '../shared/lib/runtime-config' import { DecodeError, normalizeRepeatedSlashes } from '../shared/lib/utils' import { setLazyProp, getCookieParser, tryGetPreviewData } from './api-utils' import { isTargetLikeServerless } from './utils' -import pathMatch from '../shared/lib/router/utils/path-match' import Router, { replaceBasePath, route } from './router' -import { - compileNonPath, - prepareDestination, -} from '../shared/lib/router/utils/prepare-destination' import { PayloadOptions, setRevalidateHeaders } from './send-payload' import { IncrementalCache } from './incremental-cache' import { execOnce } from '../shared/lib/utils' @@ -65,8 +56,8 @@ import { parseNextUrl } from '../shared/lib/router/utils/parse-next-url' import isError, { getProperError } from '../lib/is-error' import { MIDDLEWARE_ROUTE } from '../lib/constants' import { addRequestMeta, getRequestMeta } from './request-meta' - -const getCustomRouteMatcher = pathMatch(true) +import { createHeaderRoute, createRedirectRoute } from './server-route-utils' +import { PrerenderManifest } from '../build' export type FindComponentsResult = { components: LoadComponentsReturnType @@ -187,6 +178,15 @@ export default abstract class Server { protected abstract generateStaticRotes(): Route[] protected abstract generateFsStaticRoutes(): Route[] protected abstract generateCatchAllMiddlewareRoute(): Route | undefined + protected abstract generateRewrites({ + restrictedRedirectPaths, + }: { + restrictedRedirectPaths: string[] + }): { + beforeFiles: Route[] + afterFiles: Route[] + fallback: Route[] + } protected abstract getFilesystemPaths(): Set protected abstract getMiddleware(): { match: (pathname: string | null | undefined) => @@ -201,16 +201,15 @@ export default abstract class Server { query?: NextParsedUrlQuery, params?: Params | null ): Promise - protected abstract getMiddlewareInfo(page: string): { - name: string - paths: string[] - env: string[] - } + protected abstract hasMiddleware( + pathname: string, + _isSSR?: boolean + ): Promise protected abstract getPagePath(pathname: string, locales?: string[]): string protected abstract getFontManifest(): FontManifest | undefined protected abstract getMiddlewareManifest(): MiddlewareManifest | undefined - protected abstract getPrerenderManifest(): PrerenderManifest protected abstract getRoutesManifest(): CustomRoutes + protected abstract getPrerenderManifest(): PrerenderManifest protected abstract sendRenderResult( req: BaseNextRequest, @@ -241,36 +240,11 @@ export default abstract class Server { renderOpts: RenderOpts ): Promise - protected abstract streamResponseChunk( - res: BaseNextResponse, - chunk: any - ): void - protected abstract handleCompression( req: BaseNextRequest, res: BaseNextResponse ): void - protected abstract proxyRequest( - req: BaseNextRequest, - res: BaseNextResponse, - parsedUrl: ParsedUrl - ): Promise<{ finished: boolean }> - - protected abstract imageOptimizer( - req: BaseNextRequest, - res: BaseNextResponse, - parsedUrl: UrlWithParsedQuery - ): Promise<{ finished: boolean }> - - protected abstract runMiddleware(params: { - request: BaseNextRequest - response: BaseNextResponse - parsedUrl: ParsedNextUrl - parsed: UrlWithParsedQuery - onWarning?: (warning: Error) => void - }): Promise - protected abstract loadEnvConfig(params: { dev: boolean }): void public constructor({ @@ -367,6 +341,19 @@ export default abstract class Server { locales: this.nextConfig.i18n?.locales, max: this.nextConfig.experimental.isrMemoryCacheSize, flushToDisk: !minimalMode && this.nextConfig.experimental.isrFlushToDisk, + getPrerenderManifest: () => { + if (dev) { + return { + 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 + } + } else { + return this.getPrerenderManifest() + } + }, }) this.responseCache = new ResponseCache(this.incrementalCache) } @@ -612,10 +599,6 @@ export default abstract class Server { // Backwards compatibility protected async close(): Promise {} - protected setImmutableAssetCacheControl(res: BaseNextResponse): void { - res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') - } - protected getCustomRoutes(): CustomRoutes { const customRoutes = this.getRoutesManifest() let rewrites: CustomRoutes['rewrites'] @@ -639,17 +622,6 @@ export default abstract class Server { return this.getPrerenderManifest().preview } - protected async hasMiddleware( - pathname: string, - _isSSR?: boolean - ): Promise { - try { - return this.getMiddlewareInfo(pathname).paths.length > 0 - } catch (_) {} - - return false - } - protected async ensureMiddleware(_pathname: string, _isSSR?: boolean) {} protected generateRoutes(): { @@ -767,159 +739,24 @@ export default abstract class Server { ...staticFilesRoutes, ] - const restrictedRedirectPaths = ['/_next'].map((p) => - this.nextConfig.basePath ? `${this.nextConfig.basePath}${p}` : p - ) - - const getCustomRoute = ( - r: Rewrite | Redirect | Header, - type: RouteType - ) => { - const match = getCustomRouteMatcher( - r.source, - !(r as any).internal - ? (regex: string) => - modifyRouteRegex( - regex, - type === 'redirect' ? restrictedRedirectPaths : undefined - ) - : undefined - ) - - return { - ...r, - type, - match, - name: type, - fn: async (_req, _res, _params, _parsedUrl) => ({ finished: false }), - } as Route & Rewrite & Header - } + const restrictedRedirectPaths = this.nextConfig.basePath + ? [`${this.nextConfig.basePath}/_next`] + : ['/_next'] // Headers come very first const headers = this.minimalMode ? [] - : this.customRoutes.headers.map((r) => { - const headerRoute = getCustomRoute(r, 'header') - return { - match: headerRoute.match, - has: headerRoute.has, - type: headerRoute.type, - name: `${headerRoute.type} ${headerRoute.source} header route`, - fn: async (_req, res, params, _parsedUrl) => { - const hasParams = Object.keys(params).length > 0 - - for (const header of (headerRoute as Header).headers) { - let { key, value } = header - if (hasParams) { - key = compileNonPath(key, params) - value = compileNonPath(value, params) - } - res.setHeader(key, value) - } - return { finished: false } - }, - } as Route - }) + : this.customRoutes.headers.map((rule) => + createHeaderRoute({ rule, restrictedRedirectPaths }) + ) const redirects = this.minimalMode ? [] - : this.customRoutes.redirects.map((redirect) => { - const redirectRoute = getCustomRoute(redirect, 'redirect') - return { - internal: redirectRoute.internal, - type: redirectRoute.type, - match: redirectRoute.match, - has: redirectRoute.has, - statusCode: redirectRoute.statusCode, - name: `Redirect route ${redirectRoute.source}`, - fn: async (req, res, params, parsedUrl) => { - const { parsedDestination } = prepareDestination({ - appendParamsToQuery: false, - destination: redirectRoute.destination, - params: params, - query: parsedUrl.query, - }) - - const { query } = parsedDestination - delete (parsedDestination as any).query - - parsedDestination.search = stringifyQuery(req, query) - - let updatedDestination = formatUrl(parsedDestination) - - if (updatedDestination.startsWith('/')) { - updatedDestination = - normalizeRepeatedSlashes(updatedDestination) - } - - res - .redirect( - updatedDestination, - getRedirectStatus(redirectRoute as Redirect) - ) - .body(updatedDestination) - .send() - - return { - finished: true, - } - }, - } as Route - }) - - const buildRewrite = (rewrite: Rewrite, check = true) => { - const rewriteRoute = getCustomRoute(rewrite, 'rewrite') - return { - ...rewriteRoute, - check, - type: rewriteRoute.type, - name: `Rewrite route ${rewriteRoute.source}`, - match: rewriteRoute.match, - fn: async (req, res, params, parsedUrl) => { - const { newUrl, parsedDestination } = prepareDestination({ - appendParamsToQuery: true, - destination: rewriteRoute.destination, - params: params, - query: parsedUrl.query, - }) - - // external rewrite, proxy it - if (parsedDestination.protocol) { - return this.proxyRequest(req, res, parsedDestination) - } - - addRequestMeta(req, '_nextRewroteUrl', newUrl) - addRequestMeta(req, '_nextDidRewrite', newUrl !== req.url) - - return { - finished: false, - pathname: newUrl, - query: parsedDestination.query, - } - }, - } as Route - } - - let beforeFiles: Route[] = [] - let afterFiles: Route[] = [] - let fallback: Route[] = [] - - if (!this.minimalMode) { - if (Array.isArray(this.customRoutes.rewrites)) { - afterFiles = this.customRoutes.rewrites.map((r) => buildRewrite(r)) - } else { - beforeFiles = this.customRoutes.rewrites.beforeFiles.map((r) => - buildRewrite(r, false) - ) - afterFiles = this.customRoutes.rewrites.afterFiles.map((r) => - buildRewrite(r) - ) - fallback = this.customRoutes.rewrites.fallback.map((r) => - buildRewrite(r) + : this.customRoutes.redirects.map((rule) => + createRedirectRoute({ rule, restrictedRedirectPaths }) ) - } - } + const rewrites = this.generateRewrites({ restrictedRedirectPaths }) const catchAllMiddleware = this.generateCatchAllMiddlewareRoute() const catchAllRoute: Route = { @@ -993,11 +830,7 @@ export default abstract class Server { return { headers, fsRoutes, - rewrites: { - beforeFiles, - afterFiles, - fallback, - }, + rewrites, redirects, catchAllRoute, catchAllMiddleware, @@ -1769,7 +1602,7 @@ export default abstract class Server { ) if (!isWrappedError) { - if (this.minimalMode || this.renderOpts.dev) { + if ((this.minimalMode && !process.browser) || this.renderOpts.dev) { if (isError(err)) err.page = page throw err } @@ -1989,23 +1822,7 @@ export function prepareServerlessUrl( }) } -// since initial query values are decoded by querystring.parse -// we need to re-encode them here but still allow passing through -// values from rewrites/redirects -export const stringifyQuery = (req: BaseNextRequest, query: ParsedUrlQuery) => { - const initialQueryValues = Object.values( - getRequestMeta(req, '__NEXT_INIT_QUERY') || {} - ) - - return stringifyQs(query, undefined, undefined, { - encodeURIComponent(value) { - if (initialQueryValues.some((val) => val === value)) { - return encodeURIComponent(value) - } - return value - }, - }) -} +export { stringifyQuery } from './server-route-utils' class NoFallbackError extends Error {} diff --git a/packages/next/server/dev/hot-reloader.ts b/packages/next/server/dev/hot-reloader.ts index 4a20caadeae26..47b4b35822ffe 100644 --- a/packages/next/server/dev/hot-reloader.ts +++ b/packages/next/server/dev/hot-reloader.ts @@ -525,22 +525,16 @@ export default class HotReloader { entrypoints[bundlePath] = finalizeEntrypoint({ name: '[name].js', value: `next-middleware-ssr-loader?${stringify({ + dev: true, page, + stringifiedConfig: JSON.stringify(this.config), absoluteAppPath: this.pagesMapping['/_app'], absoluteDocumentPath: this.pagesMapping['/_document'], absoluteErrorPath: this.pagesMapping['/_error'], absolute404Path: this.pagesMapping['/404'] || '', absolutePagePath, isServerComponent, - serverComponents: this.hasServerComponents, buildId: this.buildId, - basePath: this.config.basePath, - assetPrefix: this.config.assetPrefix, - generateEtags: this.config.generateEtags, - poweredByHeader: this.config.poweredByHeader, - canonicalBase: this.config.amp.canonicalBase, - i18n: this.config.i18n, - previewProps: this.previewProps, } as any)}!`, isServer: false, isServerWeb: true, diff --git a/packages/next/server/incremental-cache.ts b/packages/next/server/incremental-cache.ts index 9faa1a20f3311..705af386ec4e2 100644 --- a/packages/next/server/incremental-cache.ts +++ b/packages/next/server/incremental-cache.ts @@ -3,7 +3,6 @@ import type { CacheFs } from '../shared/lib/utils' import LRUCache from 'next/dist/compiled/lru-cache' import path from 'path' import { PrerenderManifest } from '../build' -import { PRERENDER_MANIFEST } from '../shared/lib/constants' import { normalizePagePath } from './normalize-page-path' function toRoute(pathname: string): string { @@ -52,6 +51,7 @@ export class IncrementalCache { pagesDir, flushToDisk, locales, + getPrerenderManifest, }: { fs: CacheFs dev: boolean @@ -60,6 +60,7 @@ export class IncrementalCache { pagesDir: string flushToDisk?: boolean locales?: string[] + getPrerenderManifest: () => PrerenderManifest }) { this.fs = fs this.incrementalOptions = { @@ -70,21 +71,7 @@ export class IncrementalCache { !dev && (typeof flushToDisk !== 'undefined' ? flushToDisk : true), } this.locales = locales - - if (dev) { - this.prerenderManifest = { - 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 - } - } else { - const manifestJson = this.fs.readFileSync( - path.join(distDir, PRERENDER_MANIFEST) - ) - this.prerenderManifest = JSON.parse(manifestJson) - } + this.prerenderManifest = getPrerenderManifest() if (process.env.__NEXT_TEST_MAX_ISR_CACHE) { // Allow cache size to be overridden for testing purposes diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 9480ee488c924..5330982778b2b 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -6,6 +6,7 @@ import type RenderResult from './render-result' import type { FetchEventResult } from './web/types' import type { ParsedNextUrl } from '../shared/lib/router/utils/parse-next-url' import type { PrerenderManifest } from '../build' +import type { Rewrite } from '../lib/load-custom-routes' import { execOnce } from '../shared/lib/utils' import { @@ -72,6 +73,7 @@ import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import { getMiddlewareRegex, getRouteMatcher } from '../shared/lib/router/utils' import { MIDDLEWARE_ROUTE } from '../lib/constants' import { loadEnvConfig } from '@next/env' +import { getCustomRoute } from './server-route-utils' export * from './base-server' @@ -91,7 +93,9 @@ export interface NodeRequestHandler { export default class NextNodeServer extends BaseServer { constructor(options: Options) { + // Initialize super class super(options) + /** * This sets environment variable to be used at the time of SSR by head.tsx. * Using this from process.env allows targeting both serverless and SSR by calling @@ -197,6 +201,10 @@ export default class NextNodeServer extends BaseServer { : [] } + protected setImmutableAssetCacheControl(res: BaseNextResponse): void { + res.setHeader('Cache-Control', 'public, max-age=31536000, immutable') + } + protected generateFsStaticRoutes(): Route[] { return [ { @@ -677,6 +685,24 @@ export default class NextNodeServer extends BaseServer { ) } + protected async hasMiddleware( + pathname: string, + _isSSR?: boolean + ): Promise { + try { + return ( + getMiddlewareInfo({ + dev: this.renderOpts.dev, + distDir: this.distDir, + page: pathname, + serverless: this._isLikeServerless, + }).paths.length > 0 + ) + } catch (_) {} + + return false + } + public async serveStatic( req: BaseNextRequest | IncomingMessage, res: BaseNextResponse | ServerResponse, @@ -795,6 +821,79 @@ export default class NextNodeServer extends BaseServer { return undefined } + protected generateRewrites({ + restrictedRedirectPaths, + }: { + restrictedRedirectPaths: string[] + }) { + let beforeFiles: Route[] = [] + let afterFiles: Route[] = [] + let fallback: Route[] = [] + + if (!this.minimalMode) { + const buildRewrite = (rewrite: Rewrite, check = true) => { + const rewriteRoute = getCustomRoute({ + type: 'rewrite', + rule: rewrite, + restrictedRedirectPaths, + }) + return { + ...rewriteRoute, + check, + type: rewriteRoute.type, + name: `Rewrite route ${rewriteRoute.source}`, + match: rewriteRoute.match, + fn: async (req, res, params, parsedUrl) => { + const { newUrl, parsedDestination } = prepareDestination({ + appendParamsToQuery: true, + destination: rewriteRoute.destination, + params: params, + query: parsedUrl.query, + }) + + // external rewrite, proxy it + if (parsedDestination.protocol) { + return this.proxyRequest( + req as NodeNextRequest, + res as NodeNextResponse, + parsedDestination + ) + } + + addRequestMeta(req, '_nextRewroteUrl', newUrl) + addRequestMeta(req, '_nextDidRewrite', newUrl !== req.url) + + return { + finished: false, + pathname: newUrl, + query: parsedDestination.query, + } + }, + } as Route + } + + if (Array.isArray(this.customRoutes.rewrites)) { + afterFiles = this.customRoutes.rewrites.map((r) => buildRewrite(r)) + } else { + beforeFiles = this.customRoutes.rewrites.beforeFiles.map((r) => + buildRewrite(r, false) + ) + afterFiles = this.customRoutes.rewrites.afterFiles.map((r) => + buildRewrite(r) + ) + fallback = this.customRoutes.rewrites.fallback.map((r) => + buildRewrite(r) + ) + } + } + + return { + beforeFiles, + afterFiles, + fallback, + } + } + protected generateCatchAllMiddlewareRoute(): Route | undefined { if (this.minimalMode) return undefined diff --git a/packages/next/server/server-route-utils.ts b/packages/next/server/server-route-utils.ts new file mode 100644 index 0000000000000..5917470c2c1a4 --- /dev/null +++ b/packages/next/server/server-route-utils.ts @@ -0,0 +1,155 @@ +import type { + Header, + Redirect, + Rewrite, + RouteType, +} from '../lib/load-custom-routes' +import type { Route } from './router' +import type { BaseNextRequest } from './base-http' +import type { ParsedUrlQuery } from 'querystring' + +import { getRedirectStatus, modifyRouteRegex } from '../lib/load-custom-routes' +import pathMatch from '../shared/lib/router/utils/path-match' +import { + compileNonPath, + prepareDestination, +} from '../shared/lib/router/utils/prepare-destination' +import { getRequestMeta } from './request-meta' +import { stringify as stringifyQs } from 'querystring' +import { format as formatUrl } from 'url' +import { normalizeRepeatedSlashes } from '../shared/lib/utils' + +const getCustomRouteMatcher = pathMatch(true) + +export const getCustomRoute = ({ + type, + rule, + restrictedRedirectPaths, +}: { + rule: Rewrite | Redirect | Header + type: RouteType + restrictedRedirectPaths: string[] +}) => { + const match = getCustomRouteMatcher( + rule.source, + !(rule as any).internal + ? (regex: string) => + modifyRouteRegex( + regex, + type === 'redirect' ? restrictedRedirectPaths : undefined + ) + : undefined + ) + + return { + ...rule, + type, + match, + name: type, + fn: async (_req, _res, _params, _parsedUrl) => ({ finished: false }), + } as Route & Rewrite & Header +} + +export const createHeaderRoute = ({ + rule, + restrictedRedirectPaths, +}: { + rule: Header + restrictedRedirectPaths: string[] +}) => { + const headerRoute = getCustomRoute({ + type: 'header', + rule, + restrictedRedirectPaths, + }) + return { + match: headerRoute.match, + has: headerRoute.has, + type: headerRoute.type, + name: `${headerRoute.type} ${headerRoute.source} header route`, + fn: async (_req, res, params, _parsedUrl) => { + const hasParams = Object.keys(params).length > 0 + + for (const header of (headerRoute as Header).headers) { + let { key, value } = header + if (hasParams) { + key = compileNonPath(key, params) + value = compileNonPath(value, params) + } + res.setHeader(key, value) + } + return { finished: false } + }, + } as Route +} + +export const createRedirectRoute = ({ + rule, + restrictedRedirectPaths, +}: { + rule: Redirect + restrictedRedirectPaths: string[] +}) => { + const redirectRoute = getCustomRoute({ + type: 'redirect', + rule, + restrictedRedirectPaths, + }) + return { + internal: redirectRoute.internal, + type: redirectRoute.type, + match: redirectRoute.match, + has: redirectRoute.has, + statusCode: redirectRoute.statusCode, + name: `Redirect route ${redirectRoute.source}`, + fn: async (req, res, params, parsedUrl) => { + const { parsedDestination } = prepareDestination({ + appendParamsToQuery: false, + destination: redirectRoute.destination, + params: params, + query: parsedUrl.query, + }) + + const { query } = parsedDestination + delete (parsedDestination as any).query + + parsedDestination.search = stringifyQuery(req, query) + + let updatedDestination = formatUrl(parsedDestination) + + if (updatedDestination.startsWith('/')) { + updatedDestination = normalizeRepeatedSlashes(updatedDestination) + } + + res + .redirect( + updatedDestination, + getRedirectStatus(redirectRoute as Redirect) + ) + .body(updatedDestination) + .send() + + return { + finished: true, + } + }, + } as Route +} + +// since initial query values are decoded by querystring.parse +// we need to re-encode them here but still allow passing through +// values from rewrites/redirects +export const stringifyQuery = (req: BaseNextRequest, query: ParsedUrlQuery) => { + const initialQueryValues = Object.values( + getRequestMeta(req, '__NEXT_INIT_QUERY') || {} + ) + + return stringifyQs(query, undefined, undefined, { + encodeURIComponent(value) { + if (initialQueryValues.some((val) => val === value)) { + return encodeURIComponent(value) + } + return value + }, + }) +} diff --git a/packages/next/server/web-server.ts b/packages/next/server/web-server.ts new file mode 100644 index 0000000000000..8d63d380b67d2 --- /dev/null +++ b/packages/next/server/web-server.ts @@ -0,0 +1,191 @@ +import type { WebNextRequest, WebNextResponse } from './base-http' +import type { RenderOpts } from './render' +import type RenderResult from './render-result' +import type { NextParsedUrlQuery } from './request-meta' +import type { Params } from './router' +import type { PayloadOptions } from './send-payload' + +import BaseServer from './base-server' +import { renderToHTML } from './render' +import { LoadComponentsReturnType } from './load-components' + +export default class NextWebServer extends BaseServer { + protected generateRewrites() { + // @TODO: assuming minimal mode right now + return { + beforeFiles: [], + afterFiles: [], + fallback: [], + } + } + protected handleCompression() { + // @TODO + } + protected getRoutesManifest() { + return { + headers: [], + rewrites: { + fallback: [], + afterFiles: [], + beforeFiles: [], + }, + redirects: [], + } + } + protected getPagePath() { + // @TODO + return '' + } + protected getPublicDir() { + // @TODO + return '' + } + protected getBuildId() { + return (globalThis as any).__server_context.buildId + } + protected loadEnvConfig() { + // @TODO + } + protected getHasStaticDir() { + return false + } + protected async hasMiddleware() { + return false + } + protected generateImageRoutes() { + return [] + } + protected generateStaticRotes() { + return [] + } + protected generateFsStaticRoutes() { + return [] + } + protected generatePublicRoutes() { + return [] + } + protected getMiddleware() { + return [] + } + protected generateCatchAllMiddlewareRoute() { + return undefined + } + protected getFontManifest() { + return undefined + } + protected getMiddlewareManifest() { + return undefined + } + protected getPagesManifest() { + return { + [(globalThis as any).__current_route]: '', + } + } + protected getFilesystemPaths() { + return new Set() + } + protected getPrerenderManifest() { + return { + version: 3 as const, + routes: {}, + dynamicRoutes: {}, + notFoundRoutes: [], + preview: { + previewModeId: '', + previewModeSigningKey: '', + previewModeEncryptionKey: '', + }, + } + } + protected async renderHTML( + req: WebNextRequest, + _res: WebNextResponse, + pathname: string, + query: NextParsedUrlQuery, + renderOpts: RenderOpts + ): Promise { + return renderToHTML( + { + url: pathname, + cookies: req.cookies, + headers: req.headers, + } as any, + {} as any, + pathname, + query, + { + ...renderOpts, + supportsDynamicHTML: true, + concurrentFeatures: true, + disableOptimizedLoading: true, + } + ) + } + protected async sendRenderResult( + _req: WebNextRequest, + res: WebNextResponse, + options: { + result: RenderResult + type: 'html' | 'json' + generateEtags: boolean + poweredByHeader: boolean + options?: PayloadOptions | undefined + } + ): Promise { + // @TODO + const writer = res.transformStream.writable.getWriter() + const encoder = new TextEncoder() + options.result.pipe({ + write: (str: string) => writer.write(encoder.encode(str)), + end: () => writer.close(), + // Not implemented: cork/uncork/on/removeListener + } as any) + + // To prevent Safari's bfcache caching the "shell", we have to add the + // `no-cache` header to document responses. + res.setHeader( + 'Cache-Control', + 'no-cache, no-store, max-age=0, must-revalidate' + ) + res.send() + } + protected async runApi() { + // @TODO + return true + } + protected async findPageComponents( + pathname: string, + query?: NextParsedUrlQuery, + params?: Params | null + ) { + if (pathname === (globalThis as any).__current_route) { + return { + query: { + ...(query || {}), + ...(params || {}), + }, + components: (globalThis as any) + .__server_context as LoadComponentsReturnType, + } + } + + if (pathname === '/_error') { + const errorMod = (globalThis as any).__server_context.errorMod + return { + query: { + ...(query || {}), + ...(params || {}), + }, + components: { + ...(globalThis as any).__server_context, + Component: errorMod.default, + getStaticProps: errorMod.getStaticProps, + getServerSideProps: errorMod.getServerSideProps, + getStaticPaths: errorMod.getStaticPaths, + } as LoadComponentsReturnType, + } + } + + return null + } +} diff --git a/test/integration/react-streaming-and-server-components/app/pages/routes/[dynamic].server.js b/test/integration/react-streaming-and-server-components/app/pages/routes/[dynamic].server.js index eee586678967b..288d6165f9990 100644 --- a/test/integration/react-streaming-and-server-components/app/pages/routes/[dynamic].server.js +++ b/test/integration/react-streaming-and-server-components/app/pages/routes/[dynamic].server.js @@ -1,3 +1,3 @@ -export default function Pid() { - return '[pid]' // TODO: display based on query +export default function Pid({ router }) { + return
{`query: ${router.query.dynamic}`}
} diff --git a/test/integration/react-streaming-and-server-components/test/index.test.js b/test/integration/react-streaming-and-server-components/test/index.test.js index e5edbe282437c..dfb68408db280 100644 --- a/test/integration/react-streaming-and-server-components/test/index.test.js +++ b/test/integration/react-streaming-and-server-components/test/index.test.js @@ -344,20 +344,18 @@ async function runBasicTests(context, env) { expect(homeHTML).toContain('path:/') expect(homeHTML).toContain('foo.client') - expect(dynamicRouteHTML1).toContain('[pid]') - expect(dynamicRouteHTML2).toContain('[pid]') + expect(dynamicRouteHTML1).toContain('query: dynamic1') + expect(dynamicRouteHTML2).toContain('query: dynamic2') const $404 = cheerio.load(path404HTML) expect($404('#__next').text()).toBe(page404Content) - // in dev mode: custom error page is still using default _error - expect(path500HTML).toContain( - isDev ? 'Internal Server Error' : 'custom-500-page' - ) + // In dev mode: it should show the error popup. + expect(path500HTML).toContain(isDev ? 'Error: oops' : 'custom-500-page') expect(pathNotFoundHTML).toContain(page404Content) }) - it('should disable cache for RSC pages', async () => { + it('should disable cache for fizz pages', async () => { const urls = ['/', '/next-api/image', '/next-api/link'] await Promise.all( urls.map(async (url) => { @@ -377,16 +375,21 @@ async function runBasicTests(context, env) { expect(linkText).toContain('go home') const browser = await webdriver(context.appPort, '/next-api/link') + + // We need to make sure the app is fully hydrated before clicking, otherwise + // it will be a full redirection instead of being taken over by the next + // router. This timeout prevents it being flaky caused by fast refresh's + // rebuilding event. + await new Promise((res) => setTimeout(res, 1000)) await browser.eval('window.beforeNav = 1') + await browser.waitForElementByCss('#next_id').click() await check(() => browser.elementByCss('#query').text(), 'query:1') await browser.waitForElementByCss('#next_id').click() await check(() => browser.elementByCss('#query').text(), 'query:2') - if (!isDev) { - expect(await browser.eval('window.beforeNav')).toBe(1) - } + expect(await browser.eval('window.beforeNav')).toBe(1) }) it('should suspense next/image on server side', async () => {