diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index 3a24961a3fcaeaf..1cec41de0b50272 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -1162,16 +1162,17 @@ export default async function build( const errorPageStaticResult = nonStaticErrorPageSpan.traceAsyncFn( async () => hasCustomErrorPage && - staticWorkers.isPageStatic( - '/_error', + staticWorkers.isPageStatic({ + page: '/_error', distDir, - isLikeServerless, + serverless: isLikeServerless, configFileName, runtimeEnvConfig, - config.httpAgentOptions, - config.i18n?.locales, - config.i18n?.defaultLocale - ) + httpAgentOptions: config.httpAgentOptions, + locales: config.i18n?.locales, + defaultLocale: config.i18n?.defaultLocale, + pageRuntime: config.experimental.runtime, + }) ) // we don't output _app in serverless mode so use _app export @@ -1274,29 +1275,53 @@ export default async function build( // Only calculate page static information if the page is not an // app page. pageType !== 'app' && - !isReservedPage(page) && - // We currently don't support static optimization in the Edge runtime. - pageRuntime !== SERVER_RUNTIME.edge + !isReservedPage(page) ) { try { + let edgeInfo: any + + if (pageRuntime === SERVER_RUNTIME.edge) { + const manifest = require(join( + distDir, + serverDir, + MIDDLEWARE_MANIFEST + )) + + edgeInfo = manifest.functions[page] + } + let isPageStaticSpan = checkPageSpan.traceChild('is-page-static') let workerResult = await isPageStaticSpan.traceAsyncFn( () => { - return staticWorkers.isPageStatic( + return staticWorkers.isPageStatic({ page, distDir, - isLikeServerless, + serverless: isLikeServerless, configFileName, runtimeEnvConfig, - config.httpAgentOptions, - config.i18n?.locales, - config.i18n?.defaultLocale, - isPageStaticSpan.id - ) + httpAgentOptions: config.httpAgentOptions, + locales: config.i18n?.locales, + defaultLocale: config.i18n?.defaultLocale, + parentId: isPageStaticSpan.id, + pageRuntime, + edgeInfo, + }) } ) + if (pageRuntime === SERVER_RUNTIME.edge) { + if (workerResult.hasStaticProps) { + console.warn( + `"getStaticProps" is not yet supported fully with "experimental-edge", detected on ${page}` + ) + } + // TODO: add handling for statically rendering edge + // pages and allow edge with Prerender outputs + workerResult.isStatic = false + workerResult.hasStaticProps = false + } + if (config.outputFileTracing) { pageTraceIncludes.set( page, diff --git a/packages/next/build/utils.ts b/packages/next/build/utils.ts index e37d1fa55420c2b..c050e89d49cb377 100644 --- a/packages/next/build/utils.ts +++ b/packages/next/build/utils.ts @@ -34,7 +34,10 @@ import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing- import { UnwrapPromise } from '../lib/coalesced-function' import { normalizeLocalePath } from '../shared/lib/i18n/normalize-locale-path' import * as Log from './output/log' -import { loadComponents } from '../server/load-components' +import { + loadComponents, + LoadComponentsReturnType, +} from '../server/load-components' import { trace } from '../trace' import { setHttpAgentOptions } from '../server/config' import { recursiveDelete } from '../lib/recursive-delete' @@ -43,6 +46,7 @@ import { MiddlewareManifest } from './webpack/plugins/middleware-plugin' import { denormalizePagePath } from '../shared/lib/page-path/denormalize-page-path' import { normalizePagePath } from '../shared/lib/page-path/normalize-page-path' import { AppBuildManifest } from './webpack/plugins/app-build-manifest-plugin' +import { getRuntimeContext } from '../server/web/sandbox' export type ROUTER_TYPE = 'pages' | 'app' @@ -1008,17 +1012,31 @@ export async function buildStaticPaths( } } -export async function isPageStatic( - page: string, - distDir: string, - serverless: boolean, - configFileName: string, - runtimeEnvConfig: any, - httpAgentOptions: NextConfigComplete['httpAgentOptions'], - locales?: string[], - defaultLocale?: string, +export async function isPageStatic({ + page, + distDir, + serverless, + configFileName, + runtimeEnvConfig, + httpAgentOptions, + locales, + defaultLocale, + parentId, + pageRuntime, + edgeInfo, +}: { + page: string + distDir: string + serverless: boolean + configFileName: string + runtimeEnvConfig: any + httpAgentOptions: NextConfigComplete['httpAgentOptions'] + locales?: string[] + defaultLocale?: string parentId?: any -): Promise<{ + edgeInfo?: any + pageRuntime: ServerRuntime +}): Promise<{ isStatic?: boolean isAmpOnly?: boolean isHybridAmp?: boolean @@ -1037,24 +1055,51 @@ export async function isPageStatic( require('../shared/lib/runtime-config').setConfig(runtimeEnvConfig) setHttpAgentOptions(httpAgentOptions) - const mod = await loadComponents(distDir, page, serverless) - const Comp = mod.Component + let componentsResult: LoadComponentsReturnType + + if (pageRuntime === SERVER_RUNTIME.edge) { + const runtime = await getRuntimeContext({ + paths: edgeInfo.files.map((file: string) => path.join(distDir, file)), + env: edgeInfo.env, + edgeFunctionEntry: edgeInfo, + name: edgeInfo.name, + useCache: true, + distDir, + }) + const mod = + runtime.context._ENTRIES[`middleware_${edgeInfo.name}`].ComponentMod + + componentsResult = { + Component: mod.default, + ComponentMod: mod, + pageConfig: mod.config || {}, + // @ts-expect-error this is not needed during require + buildManifest: {}, + reactLoadableManifest: {}, + getServerSideProps: mod.getServerSideProps, + getStaticPaths: mod.getStaticPaths, + getStaticProps: mod.getStaticProps, + } + } else { + componentsResult = await loadComponents(distDir, page, serverless) + } + const Comp = componentsResult.Component if (!Comp || !isValidElementType(Comp) || typeof Comp === 'string') { throw new Error('INVALID_DEFAULT_EXPORT') } const hasGetInitialProps = !!(Comp as any).getInitialProps - const hasStaticProps = !!mod.getStaticProps - const hasStaticPaths = !!mod.getStaticPaths - const hasServerProps = !!mod.getServerSideProps - const hasLegacyServerProps = !!(await mod.ComponentMod + const hasStaticProps = !!componentsResult.getStaticProps + const hasStaticPaths = !!componentsResult.getStaticPaths + const hasServerProps = !!componentsResult.getServerSideProps + const hasLegacyServerProps = !!(await componentsResult.ComponentMod .unstable_getServerProps) - const hasLegacyStaticProps = !!(await mod.ComponentMod + const hasLegacyStaticProps = !!(await componentsResult.ComponentMod .unstable_getStaticProps) - const hasLegacyStaticPaths = !!(await mod.ComponentMod + const hasLegacyStaticPaths = !!(await componentsResult.ComponentMod .unstable_getStaticPaths) - const hasLegacyStaticParams = !!(await mod.ComponentMod + const hasLegacyStaticParams = !!(await componentsResult.ComponentMod .unstable_getStaticParams) if (hasLegacyStaticParams) { @@ -1121,7 +1166,7 @@ export async function isPageStatic( encodedPaths: encodedPrerenderRoutes, } = await buildStaticPaths( page, - mod.getStaticPaths!, + componentsResult.getStaticPaths!, configFileName, locales, defaultLocale @@ -1129,7 +1174,7 @@ export async function isPageStatic( } const isNextImageImported = (global as any).__NEXT_IMAGE_IMPORTED - const config: PageConfig = mod.pageConfig + const config: PageConfig = componentsResult.pageConfig return { isStatic: !hasStaticProps && !hasGetInitialProps && !hasServerProps, isHybridAmp: config.amp === 'hybrid', diff --git a/packages/next/build/webpack/loaders/next-edge-ssr-loader/index.ts b/packages/next/build/webpack/loaders/next-edge-ssr-loader/index.ts index c09e23cb7e68c32..527aae70d77dc26 100644 --- a/packages/next/build/webpack/loaders/next-edge-ssr-loader/index.ts +++ b/packages/next/build/webpack/loaders/next-edge-ssr-loader/index.ts @@ -112,6 +112,8 @@ export default async function edgeSSRLoader(this: any) { config: ${stringifiedConfig}, buildId: ${JSON.stringify(buildId)}, }) + + export const ComponentMod = pageMod export default function(opts) { return adapter({ diff --git a/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts b/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts index 0763c0f423cea81..47246669056e63a 100644 --- a/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts +++ b/packages/next/build/webpack/loaders/next-serverless-loader/utils.ts @@ -68,6 +68,45 @@ export type ServerlessHandlerCtx = { i18n?: NextConfig['i18n'] } +export function interpolateDynamicPath( + pathname: string, + params: ParsedUrlQuery, + defaultRouteRegex?: ReturnType | undefined +) { + if (!defaultRouteRegex) return pathname + + for (const param of Object.keys(defaultRouteRegex.groups)) { + const { optional, repeat } = defaultRouteRegex.groups[param] + let builtParam = `[${repeat ? '...' : ''}${param}]` + + if (optional) { + builtParam = `[${builtParam}]` + } + + const paramIdx = pathname!.indexOf(builtParam) + + if (paramIdx > -1) { + let paramValue: string + + if (Array.isArray(params[param])) { + paramValue = (params[param] as string[]) + .map((v) => v && encodeURIComponent(v)) + .join('/') + } else { + paramValue = + params[param] && encodeURIComponent(params[param] as string) + } + + pathname = + pathname.slice(0, paramIdx) + + (paramValue || '') + + pathname.slice(paramIdx + builtParam.length) + } + } + + return pathname +} + export function getUtils({ page, i18n, @@ -297,41 +336,6 @@ export function getUtils({ )(req.headers['x-now-route-matches'] as string) as ParsedUrlQuery } - function interpolateDynamicPath(pathname: string, params: ParsedUrlQuery) { - if (!defaultRouteRegex) return pathname - - for (const param of Object.keys(defaultRouteRegex.groups)) { - const { optional, repeat } = defaultRouteRegex.groups[param] - let builtParam = `[${repeat ? '...' : ''}${param}]` - - if (optional) { - builtParam = `[${builtParam}]` - } - - const paramIdx = pathname!.indexOf(builtParam) - - if (paramIdx > -1) { - let paramValue: string - - if (Array.isArray(params[param])) { - paramValue = (params[param] as string[]) - .map((v) => v && encodeURIComponent(v)) - .join('/') - } else { - paramValue = - params[param] && encodeURIComponent(params[param] as string) - } - - pathname = - pathname.slice(0, paramIdx) + - (paramValue || '') + - pathname.slice(paramIdx + builtParam.length) - } - } - - return pathname - } - function normalizeVercelUrl( req: BaseNextRequest | IncomingMessage, trustQuery: boolean, @@ -570,8 +574,11 @@ export function getUtils({ normalizeVercelUrl, dynamicRouteMatcher, defaultRouteMatches, - interpolateDynamicPath, getParamsFromRouteMatches, normalizeDynamicRouteParams, + interpolateDynamicPath: ( + pathname: string, + params: Record + ) => interpolateDynamicPath(pathname, params, defaultRouteRegex), } } diff --git a/packages/next/server/base-server.ts b/packages/next/server/base-server.ts index df3413e07fafb99..a6da2bd9578095b 100644 --- a/packages/next/server/base-server.ts +++ b/packages/next/server/base-server.ts @@ -946,7 +946,8 @@ export default abstract class Server { // Toggle whether or not this is a Data request const isDataReq = - !!query.__nextDataReq && (isSSG || hasServerProps || isServerComponent) + !!(query.__nextDataReq || req.headers['x-nextjs-data']) && + (isSSG || hasServerProps || isServerComponent) delete query.__nextDataReq diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index 18d75626b9a81fc..28021d731f7a055 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -96,6 +96,8 @@ import { checkIsManualRevalidate } from './api-utils' import { shouldUseReactRoot, isTargetLikeServerless } from './utils' import ResponseCache from './response-cache' import { IncrementalCache } from './lib/incremental-cache' +import { interpolateDynamicPath } from '../build/webpack/loaders/next-serverless-loader/utils' +import { getNamedRouteRegex } from '../shared/lib/router/utils/route-regex' if (shouldUseReactRoot) { ;(process.env as any).__NEXT_REACT_ROOT = 'true' @@ -1951,7 +1953,32 @@ export default class NextNodeServer extends BaseServer { } // For middleware to "fetch" we must always provide an absolute URL - const url = getRequestMeta(params.req, '__NEXT_INIT_URL')! + const isDataReq = !!params.query.__nextDataReq + const query = urlQueryToSearchParams( + Object.assign({}, getRequestMeta(params.req, '__NEXT_INIT_QUERY') || {}) + ).toString() + const locale = params.query.__nextLocale + let normalizedPathname = params.page + + if (isDataReq) { + params.req.headers['x-nextjs-data'] = '1' + } + + if (isDynamicRoute(normalizedPathname)) { + const routeRegex = getNamedRouteRegex(params.page) + normalizedPathname = interpolateDynamicPath( + params.page, + Object.assign({}, params.params, params.query), + routeRegex + ) + } + + const url = `${getRequestMeta(params.req, '_protocol')}://${ + this.hostname + }:${this.port}${locale ? `/${locale}` : ''}${normalizedPathname}${ + query ? `?${query}` : '' + }` + if (!url.startsWith('http')) { throw new Error( 'To use middleware you must provide a `hostname` and `port` to the Next.js Server' diff --git a/packages/next/server/web-server.ts b/packages/next/server/web-server.ts index 657b05bbc375f4d..7afff576aea23a7 100644 --- a/packages/next/server/web-server.ts +++ b/packages/next/server/web-server.ts @@ -66,7 +66,6 @@ export default class NextWebServer extends BaseServer { res: BaseNextResponse, parsedUrl: UrlWithParsedQuery ): Promise { - parsedUrl.pathname = this.serverOptions.webServerConfig.page super.run(req, res, parsedUrl) } protected async hasPage(page: string) { @@ -343,11 +342,10 @@ export default class NextWebServer extends BaseServer { {} as any, pathname, query, - { - ...renderOpts, + Object.assign(renderOpts, { disableOptimizedLoading: true, runtime: 'experimental-edge', - }, + }), !!pagesRenderToHTML ) } else { diff --git a/packages/next/server/web/sandbox/sandbox.ts b/packages/next/server/web/sandbox/sandbox.ts index 773936c75017435..d232294d5fceab6 100644 --- a/packages/next/server/web/sandbox/sandbox.ts +++ b/packages/next/server/web/sandbox/sandbox.ts @@ -3,6 +3,7 @@ import { getServerError } from 'next/dist/compiled/@next/react-dev-overlay/dist/ import { getModuleContext } from './context' import { EdgeFunctionDefinition } from '../../../build/webpack/plugins/middleware-plugin' import { requestToBodyStream } from '../../body-streams' +import type { EdgeRuntime } from 'next/dist/compiled/edge-runtime' export const ErrorSource = Symbol('SandboxError') @@ -43,7 +44,15 @@ function withTaggedErrors(fn: RunnerFn): RunnerFn { }) } -export const run = withTaggedErrors(async (params) => { +export const getRuntimeContext = async (params: { + name: string + onWarning?: any + useCache: boolean + env: string[] + edgeFunctionEntry: any + distDir: string + paths: string[] +}): Promise> => { const { runtime, evaluateInContext } = await getModuleContext({ moduleName: params.name, onWarning: params.onWarning ?? (() => {}), @@ -56,7 +65,11 @@ export const run = withTaggedErrors(async (params) => { for (const paramPath of params.paths) { evaluateInContext(paramPath) } + return runtime +} +export const run = withTaggedErrors(async (params) => { + const runtime = await getRuntimeContext(params) const subreq = params.request.headers[`x-middleware-subrequest`] const subrequests = typeof subreq === 'string' ? subreq.split(':') : [] if (subrequests.includes(params.name)) { diff --git a/test/e2e/edge-render-getserversideprops/app/pages/[id].js b/test/e2e/edge-render-getserversideprops/app/pages/[id].js new file mode 100644 index 000000000000000..c4d5932704aa255 --- /dev/null +++ b/test/e2e/edge-render-getserversideprops/app/pages/[id].js @@ -0,0 +1,22 @@ +export const config = { + runtime: 'experimental-edge', +} + +export default function Page(props) { + return ( + <> +

/[id]

+

{JSON.stringify(props)}

+ + ) +} + +export function getServerSideProps({ params, query }) { + return { + props: { + query, + params, + now: Date.now(), + }, + } +} diff --git a/test/e2e/edge-render-getserversideprops/app/pages/index.js b/test/e2e/edge-render-getserversideprops/app/pages/index.js new file mode 100644 index 000000000000000..8264c1fa7c48c30 --- /dev/null +++ b/test/e2e/edge-render-getserversideprops/app/pages/index.js @@ -0,0 +1,22 @@ +export const config = { + runtime: 'experimental-edge', +} + +export default function Page(props) { + return ( + <> +

/index

+

{JSON.stringify(props)}

+ + ) +} + +export function getServerSideProps({ params, query }) { + return { + props: { + query, + now: Date.now(), + params: params || null, + }, + } +} diff --git a/test/e2e/edge-render-getserversideprops/index.test.ts b/test/e2e/edge-render-getserversideprops/index.test.ts new file mode 100644 index 000000000000000..7bc8cef2d5abda6 --- /dev/null +++ b/test/e2e/edge-render-getserversideprops/index.test.ts @@ -0,0 +1,108 @@ +import { createNext, FileRef } from 'e2e-utils' +import { NextInstance } from 'test/lib/next-modes/base' +import { fetchViaHTTP, normalizeRegEx, renderViaHTTP } from 'next-test-utils' +import cheerio from 'cheerio' +import { join } from 'path' +import escapeStringRegexp from 'escape-string-regexp' + +describe('edge-render-getserversideprops', () => { + let next: NextInstance + + if (process.env.NEXT_TEST_REACT_VERSION === '^17') { + it('should skip for react v17', () => {}) + return + } + + beforeAll(async () => { + next = await createNext({ + files: new FileRef(join(__dirname, 'app')), + dependencies: {}, + }) + }) + afterAll(() => next.destroy()) + + it('should have correct query/params on index', async () => { + const html = await renderViaHTTP(next.url, '/') + const $ = cheerio.load(html) + expect($('#page').text()).toBe('/index') + const props = JSON.parse($('#props').text()) + expect(props.query).toEqual({}) + expect(props.params).toBe(null) + }) + + it('should have correct query/params on /[id]', async () => { + const html = await renderViaHTTP(next.url, '/123', { hello: 'world' }) + const $ = cheerio.load(html) + expect($('#page').text()).toBe('/[id]') + const props = JSON.parse($('#props').text()) + expect(props.query).toEqual({ id: '123', hello: 'world' }) + expect(props.params).toEqual({ id: '123' }) + }) + + it('should respond to _next/data for index correctly', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/index.json`, + undefined, + { + headers: { + 'x-nextjs-data': '1', + }, + } + ) + expect(res.status).toBe(200) + const { pageProps: props } = await res.json() + expect(props.query).toEqual({}) + expect(props.params).toBe(null) + }) + + it('should respond to _next/data for [id] correctly', async () => { + const res = await fetchViaHTTP( + next.url, + `/_next/data/${next.buildId}/321.json`, + { hello: 'world' }, + { + headers: { + 'x-nextjs-data': '1', + }, + } + ) + expect(res.status).toBe(200) + const { pageProps: props } = await res.json() + expect(props.query).toEqual({ id: '321', hello: 'world' }) + expect(props.params).toEqual({ id: '321' }) + }) + + if ((global as any).isNextStart) { + it('should have data routes in routes-manifest', async () => { + const manifest = JSON.parse( + await next.readFile('.next/routes-manifest.json') + ) + + for (const route of manifest.dataRoutes) { + route.dataRouteRegex = normalizeRegEx(route.dataRouteRegex) + } + + expect(manifest.dataRoutes).toEqual([ + { + dataRouteRegex: normalizeRegEx( + `^/_next/data/${escapeStringRegexp(next.buildId)}/index.json$` + ), + page: '/', + }, + { + dataRouteRegex: normalizeRegEx( + `^/_next/data/${escapeStringRegexp(next.buildId)}/([^/]+?)\\.json$` + ), + namedDataRouteRegex: `^/_next/data/${escapeStringRegexp( + next.buildId + )}/(?[^/]+?)\\.json$`, + page: '/[id]', + routeKeys: { + id: 'id', + }, + }, + ]) + }) + } +})