From 3d1a28720705dace4f8ede88b0d929f65459ef57 Mon Sep 17 00:00:00 2001 From: Gal Schlezinger Date: Tue, 31 May 2022 16:23:11 +0300 Subject: [PATCH] support `runtime: edge` in api endpoints (#36947) ## Feature This PR introduces the ability to provide `runtime: "edge"` in API endpoints, the same as the experimental RSC runtime configurations. - [ ] Implements an existing feature request or RFC. Make sure the feature request has been accepted for implementation before opening a PR. - [ ] Related issues linked using `fixes #number` - [ ] Integration tests added - [ ] Documentation added - [ ] Telemetry added. In case of a feature if it's used or not. - [ ] Errors have helpful link attached, see `contributing.md` ## Documentation / Examples - [ ] Make sure the linting passes by running `yarn lint` --- 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 | 121 ++++++++++++++++-- .../switchable-runtime/pages/api/hello.js | 7 + .../switchable-runtime/pages/api/node.js | 3 + .../test/switchable-runtime.test.js | 65 +++++++++- 12 files changed, 271 insertions(+), 35 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 01a3f9e5c2268..bf743e8d03db2 100644 --- a/packages/next/build/entries.ts +++ b/packages/next/build/entries.ts @@ -164,6 +164,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'], @@ -409,7 +418,9 @@ export function runDependingOnPageType(params: { if (params.page === MIDDLEWARE_FILE) { 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 2a2179e519b09..2ef17d8528015 100644 --- a/packages/next/build/index.ts +++ b/packages/next/build/index.ts @@ -94,7 +94,6 @@ import { getUnresolvedModuleFromError, copyTracedFiles, isReservedPage, - isCustomErrorPage, isServerComponentPage, } from './utils' import getBaseWebpackConfig from './webpack-config' @@ -1255,10 +1254,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 be2aa7ee50e8f..bbae4c5040099 100644 --- a/packages/next/build/webpack-config.ts +++ b/packages/next/build/webpack-config.ts @@ -1232,6 +1232,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 36e4380778c87..4f8a1b45ca5a9 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 1b3d58ab74e5f..8f837d2ea6053 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, } @@ -310,6 +313,8 @@ function getExtractMetadata(params: { entryMetadata.edgeSSR = buildInfo.nextEdgeSSR } else if (buildInfo?.nextEdgeMiddleware) { entryMetadata.edgeMiddleware = buildInfo.nextEdgeMiddleware + } else if (buildInfo?.nextEdgeApiFunction) { + entryMetadata.edgeApiFunction = buildInfo.nextEdgeApiFunction } /** @@ -386,16 +391,19 @@ 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, }) - middlewareManifest.middleware[page] = { + const edgeFunctionDefinition: EdgeFunctionDefinition = { env: Array.from(metadata.env), files: getEntryFiles(entrypoint.getFiles(), metadata), name: entrypoint.name, @@ -403,6 +411,12 @@ function getCreateAssets(params: { regexp: namedRegex, 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 0b733bd28fb3c..290f481edf50c 100644 --- a/packages/next/server/dev/next-dev-server.ts +++ b/packages/next/server/dev/next-dev-server.ts @@ -354,7 +354,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 969bf533211c1..6b72fb474cfe8 100644 --- a/packages/next/server/next-server.ts +++ b/packages/next/server/next-server.ts @@ -72,9 +72,9 @@ 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 { getMiddlewareRegex } from '../shared/lib/router/utils/route-regex' import { getNextPathnameInfo } from '../shared/lib/router/utils/get-next-pathname-info' +import { getMiddlewareRegex } from '../shared/lib/router/utils/route-regex' +import { bodyStreamToNodeStream, clonableBodyForRequest } from './body-streams' export * from './base-server' @@ -540,6 +540,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 } @@ -1031,11 +1044,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 @@ -1044,12 +1061,14 @@ export default class NextNodeServer extends BaseServer { let foundPage: string try { - foundPage = denormalizePagePath(normalizePagePath(page)) + foundPage = denormalizePagePath(normalizePagePath(params.page)) } catch (err) { - throw pageNotFoundError(page) + throw pageNotFoundError(params.page) } - let pageInfo = manifest.middleware[foundPage] + let pageInfo = params.middleware + ? manifest.middleware[foundPage] + : manifest.functions[foundPage] if (!pageInfo) { throw pageNotFoundError(foundPage) } @@ -1075,7 +1094,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 @@ -1142,7 +1164,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, @@ -1411,4 +1436,80 @@ 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 + } } 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: [], + }, + }, + }) + }) })