diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index dab27b168dffc51..053944e5d0bc22f 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 cc0b4435deec2b4..4f23d19936fcaaa 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 a0f158e8587bec2..a56c33a3a7319de 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 d6150d2dcf3e95a..f25f56ec3522419 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 eb4be7f9bd5eafc..4cc398429aab604 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 b2683472fbb1897..e229f0da60a68bf 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 4a20caadeae2656..47b4b35822ffe2e 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 9faa1a20f3311f5..705af386ec4e241 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 9480ee488c9241d..5330982778b2ba6 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 000000000000000..5917470c2c1a4af --- /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 000000000000000..8d63d380b67d260 --- /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 eee586678967bc2..288d6165f9990c9 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 e5edbe282437ce5..dfb68408db28062 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 () => {