From 1b930c56bad592d66a5766d90435d459272dbbc0 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Mon, 6 Jun 2022 17:12:00 +0300 Subject: [PATCH 1/2] Re-introduce Edge API Endpoints This reverts commit 210fa39961033842303495e86bf1658dcca01b24, and re-introduces Edge API endpoints as a possible runtime selection in API endpoints. This is done by exporting a `config` object: ```ts export config = { runtime: 'edge' } ``` Note: `'edge'` will probably change into `'experimental-edge'` to show that this is experimental and the API might change in the future. --- packages/next/build/entries.ts | 13 +- packages/next/build/index.ts | 6 +- packages/next/build/webpack-config.ts | 1 + .../webpack/loaders/get-module-build-info.ts | 1 + .../loaders/next-edge-function-loader.ts | 43 +++++++ .../webpack/plugins/middleware-plugin.ts | 40 ++++-- packages/next/server/body-streams.ts | 2 +- packages/next/server/dev/next-dev-server.ts | 4 +- packages/next/server/next-server.ts | 119 ++++++++++++++++-- .../switchable-runtime/pages/api/hello.js | 7 ++ .../switchable-runtime/pages/api/node.js | 3 + .../test/switchable-runtime.test.js | 65 +++++++++- 12 files changed, 270 insertions(+), 34 deletions(-) create mode 100644 packages/next/build/webpack/loaders/next-edge-function-loader.ts create mode 100644 test/integration/react-streaming-and-server-components/switchable-runtime/pages/api/hello.js create mode 100644 test/integration/react-streaming-and-server-components/switchable-runtime/pages/api/node.js diff --git a/packages/next/build/entries.ts b/packages/next/build/entries.ts index 0b643d95e338c..81cc4d0a6620b 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -170,6 +170,15 @@ export function getEdgeServerEntry(opts: { return `next-middleware-loader?${stringify(loaderParams)}!` } + if (opts.page.startsWith('/api/')) { + const loaderParams: MiddlewareLoaderOptions = { + absolutePagePath: opts.absolutePagePath, + page: opts.page, + } + + return `next-edge-function-loader?${stringify(loaderParams)}!` + } + const loaderParams: MiddlewareSSRLoaderQuery = { absolute500Path: opts.pages['/500'] || '', absoluteAppPath: opts.pages['/_app'], @@ -421,7 +430,9 @@ export function runDependingOnPageType(params: { if (isMiddlewareFile(params.page)) { return [params.onEdgeServer()] } else if (params.page.match(API_ROUTE)) { - return [params.onServer()] + return params.pageRuntime === 'edge' + ? [params.onEdgeServer()] + : [params.onServer()] } else if (params.page === '/_document') { return [params.onServer()] } else if ( diff --git a/packages/next/build/index.ts b/packages/next/build/index.ts index ee606e2699937..d6ec595d63fd7 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -93,7 +93,6 @@ import { getUnresolvedModuleFromError, copyTracedFiles, isReservedPage, - isCustomErrorPage, isServerComponentPage, isMiddlewareFile, } from './utils' @@ -1256,10 +1255,7 @@ export default async function build( isHybridAmp, ssgPageRoutes, initialRevalidateSeconds: false, - runtime: - !isReservedPage(page) && !isCustomErrorPage(page) - ? pageRuntime - : undefined, + runtime: pageRuntime, pageDuration: undefined, ssgPageDurations: undefined, }) diff --git a/packages/next/build/webpack-config.ts b/packages/next/build/webpack-config.ts index 573f3ae6a7ccc..b7bd8c1365ef7 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1230,6 +1230,7 @@ export default async function getBaseWebpackConfig( 'next-flight-client-entry-loader', 'noop-loader', 'next-middleware-loader', + 'next-edge-function-loader', 'next-middleware-ssr-loader', 'next-middleware-wasm-loader', 'next-app-loader', diff --git a/packages/next/build/webpack/loaders/get-module-build-info.ts b/packages/next/build/webpack/loaders/get-module-build-info.ts index 453d3bf275421..5323d31afc6a0 100644 --- a/packages/next/build/webpack/loaders/get-module-build-info.ts +++ b/packages/next/build/webpack/loaders/get-module-build-info.ts @@ -7,6 +7,7 @@ import { webpack5 } from 'next/dist/compiled/webpack/webpack' export function getModuleBuildInfo(webpackModule: webpack5.Module) { return webpackModule.buildInfo as { nextEdgeMiddleware?: EdgeMiddlewareMeta + nextEdgeApiFunction?: EdgeMiddlewareMeta nextEdgeSSR?: EdgeSSRMeta nextUsedEnvVars?: Set nextWasmMiddlewareBinding?: WasmBinding diff --git a/packages/next/build/webpack/loaders/next-edge-function-loader.ts b/packages/next/build/webpack/loaders/next-edge-function-loader.ts new file mode 100644 index 0000000000000..e66d06f270da2 --- /dev/null +++ b/packages/next/build/webpack/loaders/next-edge-function-loader.ts @@ -0,0 +1,43 @@ +import { getModuleBuildInfo } from './get-module-build-info' +import { stringifyRequest } from '../stringify-request' + +export type EdgeFunctionLoaderOptions = { + absolutePagePath: string + page: string +} + +export default function middlewareLoader(this: any) { + const { absolutePagePath, page }: EdgeFunctionLoaderOptions = + this.getOptions() + const stringifiedPagePath = stringifyRequest(this, absolutePagePath) + const buildInfo = getModuleBuildInfo(this._module) + buildInfo.nextEdgeApiFunction = { + page: page || '/', + } + + return ` + import { adapter } from 'next/dist/server/web/adapter' + + // The condition is true when the "process" module is provided + if (process !== global.process) { + // prefer local process but global.process has correct "env" + process.env = global.process.env; + global.process = process; + } + + var mod = require(${stringifiedPagePath}) + var handler = mod.middleware || mod.default; + + if (typeof handler !== 'function') { + throw new Error('The Edge Function "pages${page}" must export a \`default\` function'); + } + + export default function (opts) { + return adapter({ + ...opts, + page: ${JSON.stringify(page)}, + handler, + }) + } + ` +} diff --git a/packages/next/build/webpack/plugins/middleware-plugin.ts b/packages/next/build/webpack/plugins/middleware-plugin.ts index 48c4991105e16..882d7f12effdc 100644 --- a/packages/next/build/webpack/plugins/middleware-plugin.ts +++ b/packages/next/build/webpack/plugins/middleware-plugin.ts @@ -14,24 +14,26 @@ import { NEXT_CLIENT_SSR_ENTRY_SUFFIX, } from '../../../shared/lib/constants' +interface EdgeFunctionDefinition { + env: string[] + files: string[] + name: string + page: string + regexp: string + wasm?: WasmBinding[] +} + export interface MiddlewareManifest { version: 1 sortedMiddleware: string[] clientInfo: [location: string, isSSR: boolean][] - middleware: { - [page: string]: { - env: string[] - files: string[] - name: string - page: string - regexp: string - wasm?: WasmBinding[] - } - } + middleware: { [page: string]: EdgeFunctionDefinition } + functions: { [page: string]: EdgeFunctionDefinition } } interface EntryMetadata { edgeMiddleware?: EdgeMiddlewareMeta + edgeApiFunction?: EdgeMiddlewareMeta edgeSSR?: EdgeSSRMeta env: Set wasmBindings: Set @@ -42,6 +44,7 @@ const middlewareManifest: MiddlewareManifest = { sortedMiddleware: [], clientInfo: [], middleware: {}, + functions: {}, version: 1, } @@ -349,6 +352,8 @@ function getExtractMetadata(params: { entryMetadata.edgeSSR = buildInfo.nextEdgeSSR } else if (buildInfo?.nextEdgeMiddleware) { entryMetadata.edgeMiddleware = buildInfo.nextEdgeMiddleware + } else if (buildInfo?.nextEdgeApiFunction) { + entryMetadata.edgeApiFunction = buildInfo.nextEdgeApiFunction } /** @@ -425,17 +430,20 @@ function getCreateAssets(params: { // There should always be metadata for the entrypoint. const metadata = metadataByEntry.get(entrypoint.name) - const page = metadata?.edgeMiddleware?.page || metadata?.edgeSSR?.page + const page = + metadata?.edgeMiddleware?.page || + metadata?.edgeSSR?.page || + metadata?.edgeApiFunction?.page if (!page) { continue } const { namedRegex } = getNamedMiddlewareRegex(page, { - catchAll: !metadata.edgeSSR, + catchAll: !metadata.edgeSSR && !metadata.edgeApiFunction, }) const regexp = metadata?.edgeMiddleware?.matcherRegexp || namedRegex - middlewareManifest.middleware[page] = { + const edgeFunctionDefinition: EdgeFunctionDefinition = { env: Array.from(metadata.env), files: getEntryFiles(entrypoint.getFiles(), metadata), name: entrypoint.name, @@ -443,6 +451,12 @@ function getCreateAssets(params: { regexp, wasm: Array.from(metadata.wasmBindings), } + + if (metadata.edgeApiFunction /* || metadata.edgeSSR */) { + middlewareManifest.functions[page] = edgeFunctionDefinition + } else { + middlewareManifest.middleware[page] = edgeFunctionDefinition + } } middlewareManifest.sortedMiddleware = getSortedRoutes( diff --git a/packages/next/server/body-streams.ts b/packages/next/server/body-streams.ts index 38b14e54e7d19..19a3c20c004b9 100644 --- a/packages/next/server/body-streams.ts +++ b/packages/next/server/body-streams.ts @@ -19,7 +19,7 @@ function requestToBodyStream(request: IncomingMessage): BodyStream { return transform.readable as unknown as ReadableStream } -function bodyStreamToNodeStream(bodyStream: BodyStream): Readable { +export function bodyStreamToNodeStream(bodyStream: BodyStream): Readable { const reader = bodyStream.getReader() return Readable.from( (async function* () { diff --git a/packages/next/server/dev/next-dev-server.ts b/packages/next/server/dev/next-dev-server.ts index 88565446d4cbf..a63596d2dbf1d 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -363,7 +363,9 @@ export default class DevServer extends Server { onClient: () => {}, onServer: () => {}, onEdgeServer: () => { - routedMiddleware.push(pageName) + if (!pageName.startsWith('/api/')) { + routedMiddleware.push(pageName) + } ssrMiddleware.add(pageName) }, }) diff --git a/packages/next/server/next-server.ts b/packages/next/server/next-server.ts index c1458a2dbdc87..57559f86812dc 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -80,8 +80,8 @@ import { getCustomRoute } from './server-route-utils' import { urlQueryToSearchParams } from '../shared/lib/router/utils/querystring' import ResponseCache from '../server/response-cache' import { removeTrailingSlash } from '../shared/lib/router/utils/remove-trailing-slash' -import { clonableBodyForRequest } from './body-streams' import { getNextPathnameInfo } from '../shared/lib/router/utils/get-next-pathname-info' +import { bodyStreamToNodeStream, clonableBodyForRequest } from './body-streams' const shouldUseReactRoot = parseInt(React.version) >= 18 if (shouldUseReactRoot) { @@ -551,6 +551,19 @@ export default class NextNodeServer extends BaseServer { page: string, builtPagePath: string ): Promise { + const handledAsEdgeFunction = await this.runEdgeFunctionApiEndpoint({ + req, + res, + query, + params, + page, + builtPagePath, + }) + + if (handledAsEdgeFunction) { + return true + } + const pageModule = await require(builtPagePath) query = { ...query, ...params } @@ -1042,11 +1055,15 @@ export default class NextNodeServer extends BaseServer { } /** - * Get information for the middleware located in the provided page - * folder. If the middleware info can't be found it will throw + * Get information for the edge function located in the provided page + * folder. If the edge function info can't be found it will throw * an error. */ - protected getMiddlewareInfo(page: string) { + protected getEdgeFunctionInfo(params: { + page: string + /** Whether we should look for a middleware or not */ + middleware: boolean + }) { const manifest: MiddlewareManifest = require(join( this.serverDistDir, MIDDLEWARE_MANIFEST @@ -1055,12 +1072,14 @@ export default class NextNodeServer extends BaseServer { let foundPage: string try { - foundPage = denormalizePagePath(normalizePagePath(page)) + foundPage = denormalizePagePath(normalizePagePath(params.page)) } catch (err) { - throw new PageNotFoundError(page) + throw new PageNotFoundError(params.page) } - let pageInfo = manifest.middleware[foundPage] + let pageInfo = params.middleware + ? manifest.middleware[foundPage] + : manifest.functions[foundPage] if (!pageInfo) { throw new PageNotFoundError(foundPage) } @@ -1086,7 +1105,10 @@ export default class NextNodeServer extends BaseServer { _isSSR?: boolean ): Promise { try { - return this.getMiddlewareInfo(pathname).paths.length > 0 + return ( + this.getEdgeFunctionInfo({ page: pathname, middleware: true }).paths + .length > 0 + ) } catch (_) {} return false @@ -1158,7 +1180,10 @@ export default class NextNodeServer extends BaseServer { } await this.ensureMiddleware(middleware.page, middleware.ssr) - const middlewareInfo = this.getMiddlewareInfo(middleware.page) + const middlewareInfo = this.getEdgeFunctionInfo({ + page: middleware.page, + middleware: true, + }) result = await run({ name: middlewareInfo.name, @@ -1422,6 +1447,82 @@ export default class NextNodeServer extends BaseServer { this.warnIfQueryParametersWereDeleted = () => {} } } + + private async runEdgeFunctionApiEndpoint(params: { + req: NodeNextRequest + res: NodeNextResponse + query: ParsedUrlQuery + params: Params | false + page: string + builtPagePath: string + }): Promise { + let middlewareInfo: ReturnType | undefined + + try { + middlewareInfo = this.getEdgeFunctionInfo({ + page: params.page, + middleware: false, + }) + } catch { + return false + } + + // For middleware to "fetch" we must always provide an absolute URL + const url = getRequestMeta(params.req, '__NEXT_INIT_URL')! + if (!url.startsWith('http')) { + throw new Error( + 'To use middleware you must provide a `hostname` and `port` to the Next.js Server' + ) + } + + const result = await run({ + name: middlewareInfo.name, + paths: middlewareInfo.paths, + env: middlewareInfo.env, + wasm: middlewareInfo.wasm, + request: { + headers: params.req.headers, + method: params.req.method, + nextConfig: { + basePath: this.nextConfig.basePath, + i18n: this.nextConfig.i18n, + trailingSlash: this.nextConfig.trailingSlash, + }, + url, + page: { + name: params.page, + ...(params.params && { params: params.params }), + }, + // TODO(gal): complete body + // body: originalBody?.cloneBodyStream(), + }, + useCache: !this.nextConfig.experimental.runtime, + onWarning: (_warning: Error) => { + // if (params.onWarning) { + // warning.message += ` "./${middlewareInfo.name}"` + // params.onWarning(warning) + // } + }, + }) + + params.res.statusCode = result.response.status + params.res.statusMessage = result.response.statusText + + result.response.headers.forEach((value, key) => { + params.res.appendHeader(key, value) + }) + + if (result.response.body) { + // TODO(gal): not sure that we always need to stream + bodyStreamToNodeStream(result.response.body).pipe( + params.res.originalResponse + ) + } else { + params.res.originalResponse.end() + } + + return true + } } const MiddlewareMatcherCache = new WeakMap< diff --git a/test/integration/react-streaming-and-server-components/switchable-runtime/pages/api/hello.js b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/api/hello.js new file mode 100644 index 0000000000000..c8e368a7530a0 --- /dev/null +++ b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/api/hello.js @@ -0,0 +1,7 @@ +export default (req) => { + return new Response(`Hello from ${req.url}`) +} + +export const config = { + runtime: 'edge', +} diff --git a/test/integration/react-streaming-and-server-components/switchable-runtime/pages/api/node.js b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/api/node.js new file mode 100644 index 0000000000000..5587ef8457afc --- /dev/null +++ b/test/integration/react-streaming-and-server-components/switchable-runtime/pages/api/node.js @@ -0,0 +1,3 @@ +export default (req, res) => { + res.send('Hello, world') +} diff --git a/test/integration/react-streaming-and-server-components/test/switchable-runtime.test.js b/test/integration/react-streaming-and-server-components/test/switchable-runtime.test.js index 0a6cbade04325..bb5ad381e84d4 100644 --- a/test/integration/react-streaming-and-server-components/test/switchable-runtime.test.js +++ b/test/integration/react-streaming-and-server-components/test/switchable-runtime.test.js @@ -12,6 +12,7 @@ import { renderViaHTTP, waitFor, } from 'next-test-utils' +import { readJson } from 'fs-extra' const appDir = join(__dirname, '../switchable-runtime') @@ -174,6 +175,31 @@ describe('Switchable runtime (prod)', () => { }) }) + it('should build /api/hello as an api route with edge runtime', async () => { + const response = await fetchViaHTTP(context.appPort, '/api/hello') + const text = await response.text() + expect(text).toMatch(/Hello from .+\/api\/hello/) + + const manifest = await readJson( + join(context.appDir, '.next/server/middleware-manifest.json') + ) + expect(manifest).toMatchObject({ + functions: { + '/api/hello': { + env: [], + files: [ + 'server/edge-runtime-webpack.js', + 'server/pages/api/hello.js', + ], + name: 'pages/api/hello', + page: '/api/hello', + regexp: '^/api/hello$', + wasm: [], + }, + }, + }) + }) + it('should display correct tree view with page types in terminal', async () => { const stdoutLines = splitLines(context.stdout).filter((line) => /^[┌├└/]/.test(line) @@ -181,6 +207,8 @@ describe('Switchable runtime (prod)', () => { const expectedOutputLines = splitLines(` ┌ /_app ├ ○ /404 + ├ ℇ /api/hello + ├ λ /api/node ├ ℇ /edge ├ ℇ /edge-rsc ├ ○ /node @@ -192,12 +220,16 @@ describe('Switchable runtime (prod)', () => { ├ λ /node-ssr └ ○ /static `) - const isMatched = expectedOutputLines.every((line, index) => { - const matched = stdoutLines[index].startsWith(line) - return matched + + const mappedOutputLines = expectedOutputLines.map((_line, index) => { + /** @type {string} */ + const str = stdoutLines[index] + const beginningOfPath = str.indexOf('/') + const endOfPath = str.indexOf(' ', beginningOfPath) + return str.slice(0, endOfPath) }) - expect(isMatched).toBe(true) + expect(mappedOutputLines).toEqual(expectedOutputLines) }) it('should prefetch data for static pages', async () => { @@ -339,4 +371,29 @@ describe('Switchable runtime (dev)', () => { 'This is a static RSC page.' ) }) + + it('should build /api/hello as an api route with edge runtime', async () => { + const response = await fetchViaHTTP(context.appPort, '/api/hello') + const text = await response.text() + expect(text).toMatch(/Hello from .+\/api\/hello/) + + const manifest = await readJson( + join(context.appDir, '.next/server/middleware-manifest.json') + ) + expect(manifest).toMatchObject({ + functions: { + '/api/hello': { + env: [], + files: [ + 'server/edge-runtime-webpack.js', + 'server/pages/api/hello.js', + ], + name: 'pages/api/hello', + page: '/api/hello', + regexp: '^/api/hello$', + wasm: [], + }, + }, + }) + }) }) From 75daab1cce738e560f90c00972cc0130c97fbcc6 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Sun, 12 Jun 2022 13:40:11 +0300 Subject: [PATCH 2/2] Support `experimental-edge`, but allow `edge` too --- .../build/analysis/get-page-static-info.ts | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/next/build/analysis/get-page-static-info.ts b/packages/next/build/analysis/get-page-static-info.ts index 8d62b8f894a26..8d995ab4a0113 100644 --- a/packages/next/build/analysis/get-page-static-info.ts +++ b/packages/next/build/analysis/get-page-static-info.ts @@ -5,6 +5,7 @@ import { parseModule } from './parse-module' import { promises as fs } from 'fs' import { tryToParsePath } from '../../lib/try-to-parse-path' import { isMiddlewareFile } from '../utils' +import * as Log from '../output/log' interface MiddlewareConfig { pathMatcher: RegExp @@ -38,12 +39,16 @@ export async function getPageStaticInfo(params: { const { ssg, ssr } = checkExports(swcAST) const config = tryToExtractExportedConstValue(swcAST, 'config') || {} - const runtime = - config?.runtime === 'edge' - ? 'edge' - : ssr || ssg - ? config?.runtime || nextConfig.experimental?.runtime - : undefined + let runtime = ['experimental-edge', 'edge'].includes(config?.runtime) + ? 'edge' + : ssr || ssg + ? config?.runtime || nextConfig.experimental?.runtime + : undefined + + if (runtime === 'experimental-edge' || runtime === 'edge') { + warnAboutExperimentalEdgeApiFunctions() + runtime = 'edge' + } const middlewareConfig = isMiddlewareFile(params.page!) && getMiddlewareConfig(config) @@ -174,3 +179,13 @@ function getMiddlewareRegExpStrings(matcherOrMatchers: unknown): string[] { return regexes } } + +function warnAboutExperimentalEdgeApiFunctions() { + if (warnedAboutExperimentalEdgeApiFunctions) { + return + } + Log.warn(`You are using an experimental edge runtime, the API might change.`) + warnedAboutExperimentalEdgeApiFunctions = true +} + +let warnedAboutExperimentalEdgeApiFunctions = false