diff --git a/packages/next/src/server/lib/render-server.ts b/packages/next/src/server/lib/render-server.ts index 206b238018c83..24220006c88ff 100644 --- a/packages/next/src/server/lib/render-server.ts +++ b/packages/next/src/server/lib/render-server.ts @@ -1,23 +1,17 @@ -import type { NextServer, RequestHandler } from '../next' +import type { NextServer, RequestHandler, UpgradeHandler } from '../next' import type { DevBundlerService } from './dev-bundler-service' import type { PropagateToWorkersField } from './router-utils/types' import next from '../next' import type { Span } from '../../trace' -let initializations: Record< - string, - | Promise<{ - requestHandler: ReturnType< - InstanceType['getRequestHandler'] - > - upgradeHandler: ReturnType< - InstanceType['getUpgradeHandler'] - > - app: ReturnType - }> - | undefined -> = {} +export type ServerInitResult = { + requestHandler: RequestHandler + upgradeHandler: UpgradeHandler + server: NextServer +} + +let initializations: Record | undefined> = {} let sandboxContext: undefined | typeof import('../web/sandbox/context') @@ -41,9 +35,9 @@ export async function getServerField( if (!initialization) { throw new Error('Invariant cant propagate server field, no app initialized') } - const { app } = initialization - let appField = (app as any).server - return appField[field] + const { server } = initialization + let wrappedServer = server['server']! // NextServer.server is private + return wrappedServer[field as keyof typeof wrappedServer] } export async function propagateServerField( @@ -55,17 +49,20 @@ export async function propagateServerField( if (!initialization) { throw new Error('Invariant cant propagate server field, no app initialized') } - const { app } = initialization - let appField = (app as any).server + const { server } = initialization + let wrappedServer = server['server'] + const _field = field as keyof NonNullable - if (appField) { - if (typeof appField[field] === 'function') { - await appField[field].apply( - (app as any).server, + if (wrappedServer) { + if (typeof wrappedServer[_field] === 'function') { + // @ts-expect-error + await wrappedServer[_field].apply( + wrappedServer, Array.isArray(value) ? value : [] ) } else { - appField[field] = value + // @ts-expect-error + wrappedServer[_field] = value } } } @@ -86,45 +83,37 @@ async function initializeImpl(opts: { bundlerService: DevBundlerService | undefined startServerSpan: Span | undefined quiet?: boolean -}) { +}): Promise { const type = process.env.__NEXT_PRIVATE_RENDER_WORKER if (type) { process.title = 'next-render-worker-' + type } let requestHandler: RequestHandler - let upgradeHandler: any + let upgradeHandler: UpgradeHandler - const app = next({ + const server = next({ ...opts, hostname: opts.hostname || 'localhost', customServer: false, httpServer: opts.server, port: opts.port, - }) - requestHandler = app.getRequestHandler() - upgradeHandler = app.getUpgradeHandler() + }) as NextServer // should return a NextServer when `customServer: false` + requestHandler = server.getRequestHandler() + upgradeHandler = server.getUpgradeHandler() - await app.prepare(opts.serverFields) + await server.prepare(opts.serverFields) return { requestHandler, upgradeHandler, - app, + server, } } export async function initialize( opts: Parameters[0] -): Promise<{ - requestHandler: ReturnType< - InstanceType['getRequestHandler'] - > - upgradeHandler: ReturnType< - InstanceType['getUpgradeHandler'] - > - app: NextServer -}> { +): Promise { // if we already setup the server return as we only need to do // this on first worker boot if (initializations[opts.dir]) { diff --git a/packages/next/src/server/lib/router-server.ts b/packages/next/src/server/lib/router-server.ts index 999a4d0805dd2..709bbff4d62ce 100644 --- a/packages/next/src/server/lib/router-server.ts +++ b/packages/next/src/server/lib/router-server.ts @@ -2,7 +2,6 @@ import type { WorkerRequestHandler, WorkerUpgradeHandler } from './types' import type { DevBundler, ServerFields } from './router-utils/setup-dev-bundler' import type { NextUrlWithParsedQuery, RequestMeta } from '../request-meta' -import type { NextServer } from '../next' // This is required before other imports to ensure the require hook is setup. import '../node-environment' @@ -47,6 +46,7 @@ import { } from '../dev/hot-reloader-types' import { normalizedAssetPrefix } from '../../shared/lib/normalized-asset-prefix' import { NEXT_PATCH_SYMBOL } from './patch-fetch' +import type { ServerInitResult } from './render-server' const debug = setupDebug('next:router-server:main') const isNextFont = (pathname: string | null) => @@ -79,7 +79,7 @@ export async function initialize(opts: { experimentalHttpsServer?: boolean startServerSpan?: Span quiet?: boolean -}): Promise<[WorkerRequestHandler, WorkerUpgradeHandler, NextServer]> { +}): Promise { if (!process.env.NODE_ENV) { // @ts-ignore not readonly process.env.NODE_ENV = opts.dev ? 'development' : 'production' @@ -739,5 +739,5 @@ export async function initialize(opts: { } } - return [requestHandler, upgradeHandler, handlers.app] + return { requestHandler, upgradeHandler, server: handlers.server } } diff --git a/packages/next/src/server/lib/start-server.ts b/packages/next/src/server/lib/start-server.ts index c427a0fc82e68..8345c26b69adf 100644 --- a/packages/next/src/server/lib/start-server.ts +++ b/packages/next/src/server/lib/start-server.ts @@ -334,8 +334,8 @@ export async function startServer( keepAliveTimeout, experimentalHttpsServer: !!selfSignedCertificate, }) - requestHandler = initResult[0] - upgradeHandler = initResult[1] + requestHandler = initResult.requestHandler + upgradeHandler = initResult.upgradeHandler const startServerProcessDuration = performance.mark('next-start-end') && diff --git a/packages/next/src/server/next.ts b/packages/next/src/server/next.ts index ed57c8f34fb04..86986d761bf7c 100644 --- a/packages/next/src/server/next.ts +++ b/packages/next/src/server/next.ts @@ -5,13 +5,13 @@ import type { } from './next-server' import type { UrlWithParsedQuery } from 'url' import type { IncomingMessage, ServerResponse } from 'http' +import type { Duplex } from 'stream' import type { NextUrlWithParsedQuery } from './request-meta' -import type { WorkerRequestHandler, WorkerUpgradeHandler } from './lib/types' import './require-hook' import './node-polyfill-crypto' -import type { default as Server } from './next-server' +import type { default as NextNodeServer } from './next-server' import * as log from '../build/output/log' import loadConfig from './config' import path, { resolve } from 'path' @@ -25,8 +25,9 @@ import { getTracer } from './lib/trace/tracer' import { NextServerSpan } from './lib/trace/constants' import { formatUrl } from '../shared/lib/router/utils/format-url' import type { ServerFields } from './lib/router-utils/setup-dev-bundler' +import type { ServerInitResult } from './lib/render-server' -let ServerImpl: typeof Server +let ServerImpl: typeof NextNodeServer const getServerImpl = async () => { if (ServerImpl === undefined) { @@ -42,26 +43,69 @@ export type NextServerOptions = Omit< > & Partial> -export interface RequestHandler { - ( - req: IncomingMessage, - res: ServerResponse, - parsedUrl?: NextUrlWithParsedQuery | undefined - ): Promise -} +export type RequestHandler = ( + req: IncomingMessage, + res: ServerResponse, + parsedUrl?: NextUrlWithParsedQuery | undefined +) => Promise + +export type UpgradeHandler = ( + req: IncomingMessage, + socket: Duplex, + head: Buffer +) => Promise const SYMBOL_LOAD_CONFIG = Symbol('next.load_config') -export class NextServer { - private serverPromise?: Promise - private server?: Server +interface NextWrapperServer { + // NOTE: the methods/properties here are the public API for custom servers. + // Consider backwards compatibilty when changing something here! + + options: NextServerOptions + hostname: string | undefined + port: number | undefined + + getRequestHandler(): RequestHandler + prepare(serverFields?: ServerFields): Promise + setAssetPrefix(assetPrefix: string): void + close(): Promise + + // used internally + getUpgradeHandler(): UpgradeHandler + + // legacy methods that we left exposed in the past + + logError(...args: Parameters): void + + render( + ...args: Parameters + ): ReturnType + + renderToHTML( + ...args: Parameters + ): ReturnType + + renderError( + ...args: Parameters + ): ReturnType + + renderErrorToHTML( + ...args: Parameters + ): ReturnType + + render404( + ...args: Parameters + ): ReturnType +} + +/** The wrapper server used by `next start` */ +export class NextServer implements NextWrapperServer { + private serverPromise?: Promise + private server?: NextNodeServer private reqHandler?: NodeRequestHandler private reqHandlerPromise?: Promise private preparedAssetPrefix?: string - protected cleanupListeners: (() => Promise)[] = [] - protected standaloneMode?: boolean - public options: NextServerOptions constructor(options: NextServerOptions) { @@ -89,7 +133,7 @@ export class NextServer { } } - getUpgradeHandler() { + getUpgradeHandler(): UpgradeHandler { return async (req: IncomingMessage, socket: any, head: any) => { const server = await this.getServer() // @ts-expect-error we mark this as protected so it @@ -106,40 +150,40 @@ export class NextServer { } } - logError(...args: Parameters) { + logError(...args: Parameters) { if (this.server) { this.server.logError(...args) } } - async render(...args: Parameters) { + async render(...args: Parameters) { const server = await this.getServer() return server.render(...args) } - async renderToHTML(...args: Parameters) { + async renderToHTML(...args: Parameters) { const server = await this.getServer() return server.renderToHTML(...args) } - async renderError(...args: Parameters) { + async renderError(...args: Parameters) { const server = await this.getServer() return server.renderError(...args) } - async renderErrorToHTML(...args: Parameters) { + async renderErrorToHTML( + ...args: Parameters + ) { const server = await this.getServer() return server.renderErrorToHTML(...args) } - async render404(...args: Parameters) { + async render404(...args: Parameters) { const server = await this.getServer() return server.render404(...args) } async prepare(serverFields?: ServerFields) { - if (this.standaloneMode) return - const server = await this.getServer() if (serverFields) { @@ -153,21 +197,16 @@ export class NextServer { } async close() { - await Promise.all( - [ - async () => { - const server = await this.getServer() - await (server as any).close() - }, - ...this.cleanupListeners, - ].map((f) => f()) - ) + if (this.server) { + // BaseServer.close() is protected + await this.server['close']() + } } private async createServer( options: ServerOptions | DevServerOptions - ): Promise { - let ServerImplementation: typeof Server + ): Promise { + let ServerImplementation: typeof NextNodeServer if (options.dev) { ServerImplementation = require('./dev/next-dev-server') .default as typeof import('./dev/next-dev-server').default @@ -214,10 +253,6 @@ export class NextServer { private async getServer() { if (!this.serverPromise) { this.serverPromise = this[SYMBOL_LOAD_CONFIG]().then(async (conf) => { - if (this.standaloneMode) { - process.env.__NEXT_PRIVATE_STANDALONE_CONFIG = JSON.stringify(conf) - } - if (!this.options.dev) { if (conf.output === 'standalone') { if (!process.env.__NEXT_PRIVATE_STANDALONE_CONFIG) { @@ -263,16 +298,45 @@ export class NextServer { } } -class NextCustomServer extends NextServer { - protected standaloneMode = true +/** The wrapper server used for `import next from "next" (in a custom server)` */ +class NextCustomServer implements NextWrapperServer { private didWebSocketSetup: boolean = false + protected cleanupListeners: (() => Promise)[] = [] + + protected init?: ServerInitResult + + public options: NextServerOptions + + constructor(options: NextServerOptions) { + this.options = options + } - // @ts-expect-error These are initialized in prepare() - protected requestHandler: WorkerRequestHandler - // @ts-expect-error These are initialized in prepare() - protected upgradeHandler: WorkerUpgradeHandler - // @ts-expect-error These are initialized in prepare() - protected renderServer: NextServer + protected getInit() { + if (!this.init) { + throw new Error( + 'prepare() must be called before performing this operation' + ) + } + return this.init + } + + protected get requestHandler() { + return this.getInit().requestHandler + } + protected get upgradeHandler() { + return this.getInit().upgradeHandler + } + protected get server() { + return this.getInit().server + } + + get hostname() { + return this.options.hostname + } + + get port() { + return this.options.port + } async prepare() { const { getRequestHandlers } = @@ -287,9 +351,7 @@ class NextCustomServer extends NextServer { minimalMode: this.options.minimalMode, quiet: this.options.quiet, }) - this.requestHandler = initResult[0] - this.upgradeHandler = initResult[1] - this.renderServer = initResult[2] + this.init = initResult } private setupWebSocketHandler( @@ -308,7 +370,7 @@ class NextCustomServer extends NextServer { } } - getRequestHandler() { + getRequestHandler(): RequestHandler { return async ( req: IncomingMessage, res: ServerResponse, @@ -324,9 +386,9 @@ class NextCustomServer extends NextServer { } } - async render(...args: Parameters) { + async render(...args: Parameters) { let [req, res, pathname, query, parsedUrl] = args - this.setupWebSocketHandler(this.options.httpServer, req as any) + this.setupWebSocketHandler(this.options.httpServer, req as IncomingMessage) if (!pathname.startsWith('/')) { console.error(`Cannot render page with path "${pathname}"`) @@ -340,13 +402,45 @@ class NextCustomServer extends NextServer { query, }) - await this.requestHandler(req as any, res as any) + await this.requestHandler(req as IncomingMessage, res as ServerResponse) return } setAssetPrefix(assetPrefix: string): void { - super.setAssetPrefix(assetPrefix) - this.renderServer.setAssetPrefix(assetPrefix) + this.server.setAssetPrefix(assetPrefix) + } + + getUpgradeHandler(): UpgradeHandler { + return this.server.getUpgradeHandler() + } + + logError(...args: Parameters) { + this.server.logError(...args) + } + + async renderToHTML(...args: Parameters) { + return this.server.renderToHTML(...args) + } + + async renderError(...args: Parameters) { + return this.server.renderError(...args) + } + + async renderErrorToHTML( + ...args: Parameters + ) { + return this.server.renderErrorToHTML(...args) + } + + async render404(...args: Parameters) { + return this.server.render404(...args) + } + + async close() { + await Promise.all([ + this.init?.server.close(), + ...this.cleanupListeners.map((f) => f()), + ]) } } @@ -356,7 +450,7 @@ function createServer( turbo?: boolean turbopack?: boolean } -): NextServer { +): NextWrapperServer { if (options && (options.turbo || options.turbopack)) { process.env.TURBOPACK = '1' } diff --git a/test/integration/custom-server/pages/404.js b/test/integration/custom-server/pages/404.js new file mode 100644 index 0000000000000..e4dfaf154509f --- /dev/null +++ b/test/integration/custom-server/pages/404.js @@ -0,0 +1 @@ +export default () =>

made it to 404

diff --git a/test/integration/custom-server/pages/500.js b/test/integration/custom-server/pages/500.js new file mode 100644 index 0000000000000..c068f460f5081 --- /dev/null +++ b/test/integration/custom-server/pages/500.js @@ -0,0 +1 @@ +export default () =>

made it to 500

diff --git a/test/integration/custom-server/pages/dynamic-dashboard/index.js b/test/integration/custom-server/pages/dynamic-dashboard/index.js new file mode 100644 index 0000000000000..36fd1f7239378 --- /dev/null +++ b/test/integration/custom-server/pages/dynamic-dashboard/index.js @@ -0,0 +1,22 @@ +import { useRouter } from 'next/router' + +// NOTE: we want this page to be dynamic, otherwise the HTML won't contain search params +export async function getServerSideProps() { + return { props: {} } +} + +export default function Page() { + const router = useRouter() + const searchParam = router.query.q + return ( +

+ made it to dynamic dashboard + {!!searchParam && ( + <> +
+ query param: {searchParam} + + )} +

+ ) +} diff --git a/test/integration/custom-server/server.js b/test/integration/custom-server/server.js index 24173e1831911..0725e4ed12373 100644 --- a/test/integration/custom-server/server.js +++ b/test/integration/custom-server/server.js @@ -1,17 +1,27 @@ +// @ts-check + if (process.env.POLYFILL_FETCH) { + // @ts-expect-error global.fetch = require('node-fetch').default + // @ts-expect-error global.Request = require('node-fetch').Request + // @ts-expect-error global.Headers = require('node-fetch').Headers } const { readFileSync } = require('fs') + +/** @type {import('next').default} */ +// @ts-ignore: missing interopDefault const next = require('next') + const { join } = require('path') const { parse } = require('url') const dev = process.env.NODE_ENV !== 'production' const dir = __dirname -const port = process.env.PORT || 3000 +const port = + (process.env.PORT ? Number.parseInt(process.env.PORT) : undefined) || 3000 const { createServer } = require( process.env.USE_HTTPS === 'true' ? 'https' : 'http' ) @@ -30,6 +40,11 @@ process.on('unhandledRejection', (err) => { app.prepare().then(() => { const server = createServer(httpOptions, async (req, res) => { + // let next.js handle assets from /_next/ + if (/\/_next\//.test(req.url)) { + return handleNextRequests(req, res) + } + if (req.url === '/no-query') { return app.render(req, res, '/no-query') } @@ -42,7 +57,7 @@ app.prepare().then(() => { if (/setAssetPrefix/.test(req.url)) { app.setAssetPrefix(`http://127.0.0.1:${port}`) } else if (/setEmptyAssetPrefix/.test(req.url)) { - app.setAssetPrefix(null) + app.setAssetPrefix('') } else { // This is to support multi-zones support in localhost // and may be in staging deployments @@ -73,6 +88,56 @@ app.prepare().then(() => { return handleNextRequests(req, res, parse('/dashboard', true)) } + if (/legacy-methods\/render-to-html/.test(req.url)) { + try { + const html = await app.renderToHTML(req, res, '/dynamic-dashboard', { + q: '1', + }) + res.end(html) + } catch (err) { + res.end(err.message) + } + return + } + + if (/legacy-methods\/render404/.test(req.url)) { + try { + await app.render404(req, res, parse('/__non_existent__?q=1', true)) + } catch (err) { + res.end(err.message) + } + return + } + + if (/legacy-methods\/render-error/.test(req.url)) { + try { + res.statusCode = 500 + await app.renderError(new Error('kaboom'), req, res, '/dashboard', { + q: '1', + }) + } catch (err) { + res.end(err.message) + } + return + } + + if (/legacy-methods\/render-error-to-html/.test(req.url)) { + try { + res.statusCode = 500 + const html = await app.renderErrorToHTML( + new Error('kaboom'), + req, + res, + '/dashboard', + { q: '1' } + ) + res.end(html) + } catch (err) { + res.end(err.message) + } + return + } + handleNextRequests(req, res) }) diff --git a/test/integration/custom-server/test/index.test.js b/test/integration/custom-server/test/index.test.js index 293d060f88141..6d7b20e8dd9ac 100644 --- a/test/integration/custom-server/test/index.test.js +++ b/test/integration/custom-server/test/index.test.js @@ -301,7 +301,7 @@ describe.each([ await fetchViaHTTP(nextUrl, '/unhandled-rejection', undefined, { agent }) await check(() => stderr, /unhandledRejection/) expect(stderr).toContain('unhandledRejection: Error: unhandled rejection') - expect(stderr).toContain('server.js:38:22') + expect(stderr).toMatch(/\/server\.js:\d+\d+/) }) }) @@ -317,4 +317,75 @@ describe.each([ ) }) }) + + const modes = process.env.TURBOPACK_DEV + ? ['development'] + : process.env.TURBOPACK_BUILD + ? ['production'] + : ['development', 'production'] + + describe.each(modes)('legacy NextCustomServer methods - %s mode', (mode) => { + const isNextDev = mode === 'development' + + beforeAll(async () => { + if (!isNextDev) { + await nextBuild(appDir) + } + await startServer({ NODE_ENV: mode }) + }) + afterAll(() => killApp(server)) + + it('NextCustomServer.renderToHTML', async () => { + const rawHTML = await renderViaHTTP( + nextUrl, + '/legacy-methods/render-to-html?q=2', + undefined, + { agent } + ) + const $ = cheerio.load(rawHTML) + const text = $('p').text() + expect(text).toContain('made it to dynamic dashboard') + expect(text).toContain('query param: 1') + }) + + it('NextCustomServer.render404', async () => { + const html = await renderViaHTTP( + nextUrl, + '/legacy-methods/render404', + undefined, + { agent } + ) + expect(html).toContain('made it to 404') + }) + + it('NextCustomServer.renderError', async () => { + const html = await renderViaHTTP( + nextUrl, + '/legacy-methods/render-error', + undefined, + { agent } + ) + if (isNextDev) { + // in dev, we always render error overlay + default error page, not /500 + expect(html).toContain('Error: kaboom') + } else { + expect(html).toContain('made it to 500') + } + }) + + it('NextCustomServer.renderErrorToHTML', async () => { + const html = await renderViaHTTP( + nextUrl, + '/legacy-methods/render-error-to-html', + undefined, + { agent } + ) + if (isNextDev) { + // in dev, we always render error overlay + default error page, not /500 + expect(html).toContain('Error: kaboom') + } else { + expect(html).toContain('made it to 500') + } + }) + }) }) diff --git a/test/integration/filesystempublicroutes/server.js b/test/integration/filesystempublicroutes/server.js index 71b62d95d34af..00131ab832d87 100644 --- a/test/integration/filesystempublicroutes/server.js +++ b/test/integration/filesystempublicroutes/server.js @@ -13,7 +13,7 @@ app.prepare().then(() => { if (/setAssetPrefix/.test(req.url)) { app.setAssetPrefix(`http://127.0.0.1:${port}`) } else if (/setEmptyAssetPrefix/.test(req.url)) { - app.setAssetPrefix(null) + app.setAssetPrefix('') } else { // This is to support multi-zones support in localhost // and may be in staging deployments