aboutsummaryrefslogtreecommitdiff
path: root/packages/integrations/node/src
diff options
context:
space:
mode:
authorGravatar Emanuele Stoppa <my.burning@gmail.com> 2025-02-05 16:05:26 +0000
committerGravatar Emanuele Stoppa <my.burning@gmail.com> 2025-02-05 16:05:26 +0000
commit43a37c1956d415c54bb5847e3b29927e16bef1e3 (patch)
tree67c7b4b086c1d41e5a09c1c64dab3aa01e541310 /packages/integrations/node/src
parent817fe553899d0a8a0e4ff27c8d062bf1e24ca566 (diff)
parent0f3e23b50afe3f6f82caaf3e964c451280aa0688 (diff)
downloadastro-43a37c1956d415c54bb5847e3b29927e16bef1e3.tar.gz
astro-43a37c1956d415c54bb5847e3b29927e16bef1e3.tar.zst
astro-43a37c1956d415c54bb5847e3b29927e16bef1e3.zip
Merge branch 'main' of ../../temp/adapters into move-node
Diffstat (limited to 'packages/integrations/node/src')
-rw-r--r--packages/integrations/node/src/index.ts61
-rw-r--r--packages/integrations/node/src/log-listening-on.ts91
-rw-r--r--packages/integrations/node/src/middleware.ts43
-rw-r--r--packages/integrations/node/src/polyfill.ts3
-rw-r--r--packages/integrations/node/src/preview.ts69
-rw-r--r--packages/integrations/node/src/serve-app.ts52
-rw-r--r--packages/integrations/node/src/serve-static.ts135
-rw-r--r--packages/integrations/node/src/server.ts32
-rw-r--r--packages/integrations/node/src/standalone.ts93
-rw-r--r--packages/integrations/node/src/types.ts39
10 files changed, 618 insertions, 0 deletions
diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts
new file mode 100644
index 000000000..e91ed171b
--- /dev/null
+++ b/packages/integrations/node/src/index.ts
@@ -0,0 +1,61 @@
+import type { AstroAdapter, AstroIntegration } from 'astro';
+import { AstroError } from 'astro/errors';
+import type { Options, UserOptions } from './types.js';
+
+export function getAdapter(options: Options): AstroAdapter {
+ return {
+ name: '@astrojs/node',
+ serverEntrypoint: '@astrojs/node/server.js',
+ previewEntrypoint: '@astrojs/node/preview.js',
+ exports: ['handler', 'startServer', 'options'],
+ args: options,
+ adapterFeatures: {
+ buildOutput: 'server',
+ edgeMiddleware: false,
+ },
+ supportedAstroFeatures: {
+ hybridOutput: 'stable',
+ staticOutput: 'stable',
+ serverOutput: 'stable',
+ sharpImageService: 'stable',
+ i18nDomains: 'experimental',
+ envGetSecret: 'stable',
+ },
+ };
+}
+
+export default function createIntegration(userOptions: UserOptions): AstroIntegration {
+ if (!userOptions?.mode) {
+ throw new AstroError(`Setting the 'mode' option is required.`);
+ }
+
+ let _options: Options;
+ return {
+ name: '@astrojs/node',
+ hooks: {
+ 'astro:config:setup': async ({ updateConfig, config }) => {
+ updateConfig({
+ image: {
+ endpoint: config.image.endpoint ?? 'astro/assets/endpoint/node',
+ },
+ vite: {
+ ssr: {
+ noExternal: ['@astrojs/node'],
+ },
+ },
+ });
+ },
+ 'astro:config:done': ({ setAdapter, config }) => {
+ _options = {
+ ...userOptions,
+ client: config.build.client?.toString(),
+ server: config.build.server?.toString(),
+ host: config.server.host,
+ port: config.server.port,
+ assets: config.build.assets,
+ };
+ setAdapter(getAdapter(_options));
+ },
+ },
+ };
+}
diff --git a/packages/integrations/node/src/log-listening-on.ts b/packages/integrations/node/src/log-listening-on.ts
new file mode 100644
index 000000000..88c4e9d80
--- /dev/null
+++ b/packages/integrations/node/src/log-listening-on.ts
@@ -0,0 +1,91 @@
+import type http from 'node:http';
+import https from 'node:https';
+import type { AddressInfo } from 'node:net';
+import os from 'node:os';
+import type { AstroIntegrationLogger } from 'astro';
+import type { Options } from './types.js';
+
+const wildcardHosts = new Set(['0.0.0.0', '::', '0000:0000:0000:0000:0000:0000:0000:0000']);
+
+export async function logListeningOn(
+ logger: AstroIntegrationLogger,
+ server: http.Server | https.Server,
+ configuredHost: string | boolean | undefined
+) {
+ await new Promise<void>((resolve) => server.once('listening', resolve));
+ const protocol = server instanceof https.Server ? 'https' : 'http';
+ // Allow to provide host value at runtime
+ const host = getResolvedHostForHttpServer(configuredHost);
+ const { port } = server.address() as AddressInfo;
+ const address = getNetworkAddress(protocol, host, port);
+
+ if (host === undefined || wildcardHosts.has(host)) {
+ logger.info(
+ `Server listening on \n local: ${address.local[0]} \t\n network: ${address.network[0]}\n`
+ );
+ } else {
+ logger.info(`Server listening on ${address.local[0]}`);
+ }
+}
+
+function getResolvedHostForHttpServer(host: string | boolean | undefined) {
+ if (host === false) {
+ // Use a secure default
+ return 'localhost';
+ // biome-ignore lint/style/noUselessElse: <explanation>
+ } else if (host === true) {
+ // If passed --host in the CLI without arguments
+ return undefined; // undefined typically means 0.0.0.0 or :: (listen on all IPs)
+ // biome-ignore lint/style/noUselessElse: <explanation>
+ } else {
+ return host;
+ }
+}
+
+interface NetworkAddressOpt {
+ local: string[];
+ network: string[];
+}
+
+// this code from vite https://github.com/vitejs/vite/blob/d09bbd093a4b893e78f0bbff5b17c7cf7821f403/packages/vite/src/node/utils.ts#L892-L914
+export function getNetworkAddress(
+ // biome-ignore lint/style/useDefaultParameterLast: <explanation>
+ protocol: 'http' | 'https' = 'http',
+ hostname: string | undefined,
+ port: number,
+ base?: string
+) {
+ const NetworkAddress: NetworkAddressOpt = {
+ local: [],
+ network: [],
+ };
+ // biome-ignore lint/complexity/noForEach: <explanation>
+ Object.values(os.networkInterfaces())
+ .flatMap((nInterface) => nInterface ?? [])
+ .filter(
+ (detail) =>
+ // biome-ignore lint/complexity/useOptionalChain: <explanation>
+ detail &&
+ detail.address &&
+ (detail.family === 'IPv4' ||
+ // @ts-expect-error Node 18.0 - 18.3 returns number
+ detail.family === 4)
+ )
+ .forEach((detail) => {
+ let host = detail.address.replace(
+ '127.0.0.1',
+ hostname === undefined || wildcardHosts.has(hostname) ? 'localhost' : hostname
+ );
+ // ipv6 host
+ if (host.includes(':')) {
+ host = `[${host}]`;
+ }
+ const url = `${protocol}://${host}:${port}${base ? base : ''}`;
+ if (detail.address.includes('127.0.0.1')) {
+ NetworkAddress.local.push(url);
+ } else {
+ NetworkAddress.network.push(url);
+ }
+ });
+ return NetworkAddress;
+}
diff --git a/packages/integrations/node/src/middleware.ts b/packages/integrations/node/src/middleware.ts
new file mode 100644
index 000000000..5bb104914
--- /dev/null
+++ b/packages/integrations/node/src/middleware.ts
@@ -0,0 +1,43 @@
+import type { NodeApp } from 'astro/app/node';
+import { createAppHandler } from './serve-app.js';
+import type { RequestHandler } from './types.js';
+
+/**
+ * Creates a middleware that can be used with Express, Connect, etc.
+ *
+ * Similar to `createAppHandler` but can additionally be placed in the express
+ * chain as an error middleware.
+ *
+ * https://expressjs.com/en/guide/using-middleware.html#middleware.error-handling
+ */
+export default function createMiddleware(app: NodeApp): RequestHandler {
+ const handler = createAppHandler(app);
+ const logger = app.getAdapterLogger();
+ // using spread args because express trips up if the function's
+ // stringified body includes req, res, next, locals directly
+ return async (...args) => {
+ // assume normal invocation at first
+ const [req, res, next, locals] = args;
+ // short circuit if it is an error invocation
+ if (req instanceof Error) {
+ const error = req;
+ if (next) {
+ return next(error);
+ // biome-ignore lint/style/noUselessElse: <explanation>
+ } else {
+ throw error;
+ }
+ }
+ try {
+ await handler(req, res, next, locals);
+ } catch (err) {
+ logger.error(`Could not render ${req.url}`);
+ console.error(err);
+ if (!res.headersSent) {
+ // biome-ignore lint/style/noUnusedTemplateLiteral: <explanation>
+ res.writeHead(500, `Server error`);
+ res.end();
+ }
+ }
+ };
+}
diff --git a/packages/integrations/node/src/polyfill.ts b/packages/integrations/node/src/polyfill.ts
new file mode 100644
index 000000000..dc00f45d7
--- /dev/null
+++ b/packages/integrations/node/src/polyfill.ts
@@ -0,0 +1,3 @@
+import { applyPolyfills } from 'astro/app/node';
+
+applyPolyfills();
diff --git a/packages/integrations/node/src/preview.ts b/packages/integrations/node/src/preview.ts
new file mode 100644
index 000000000..94a81bfdb
--- /dev/null
+++ b/packages/integrations/node/src/preview.ts
@@ -0,0 +1,69 @@
+import { fileURLToPath } from 'node:url';
+import type { CreatePreviewServer } from 'astro';
+import { AstroError } from 'astro/errors';
+import { logListeningOn } from './log-listening-on.js';
+import type { createExports } from './server.js';
+import { createServer } from './standalone.js';
+
+type ServerModule = ReturnType<typeof createExports>;
+type MaybeServerModule = Partial<ServerModule>;
+
+const createPreviewServer: CreatePreviewServer = async (preview) => {
+ let ssrHandler: ServerModule['handler'];
+ let options: ServerModule['options'];
+ try {
+ process.env.ASTRO_NODE_AUTOSTART = 'disabled';
+ const ssrModule: MaybeServerModule = await import(preview.serverEntrypoint.toString());
+ if (typeof ssrModule.handler === 'function') {
+ ssrHandler = ssrModule.handler;
+ // biome-ignore lint/style/noNonNullAssertion: <explanation>
+ options = ssrModule.options!;
+ } else {
+ throw new AstroError(
+ `The server entrypoint doesn't have a handler. Are you sure this is the right file?`
+ );
+ }
+ } catch (err) {
+ if ((err as any).code === 'ERR_MODULE_NOT_FOUND') {
+ throw new AstroError(
+ `The server entrypoint ${fileURLToPath(
+ preview.serverEntrypoint
+ )} does not exist. Have you ran a build yet?`
+ );
+ // biome-ignore lint/style/noUselessElse: <explanation>
+ } else {
+ throw err;
+ }
+ }
+ // If the user didn't specify a host, it will already have been defaulted to
+ // "localhost" by getResolvedHostForHttpServer in astro core/preview/util.ts.
+ // The value `undefined` actually means that either the user set `options.server.host`
+ // to `true`, or they passed `--host` without an argument. In that case, we
+ // should listen on all IPs.
+ const host = process.env.HOST ?? preview.host ?? '0.0.0.0';
+
+ const port = preview.port ?? 4321;
+ const server = createServer(ssrHandler, host, port);
+
+ // If user specified custom headers append a listener
+ // to the server to add those headers to response
+ if (preview.headers) {
+ server.server.addListener('request', (_, res) => {
+ if (res.statusCode === 200) {
+ for (const [name, value] of Object.entries(preview.headers ?? {})) {
+ if (value) res.setHeader(name, value);
+ }
+ }
+ });
+ }
+
+ logListeningOn(preview.logger, server.server, host);
+ await new Promise<void>((resolve, reject) => {
+ server.server.once('listening', resolve);
+ server.server.once('error', reject);
+ server.server.listen(port, host);
+ });
+ return server;
+};
+
+export { createPreviewServer as default };
diff --git a/packages/integrations/node/src/serve-app.ts b/packages/integrations/node/src/serve-app.ts
new file mode 100644
index 000000000..2934a01ab
--- /dev/null
+++ b/packages/integrations/node/src/serve-app.ts
@@ -0,0 +1,52 @@
+import { AsyncLocalStorage } from 'node:async_hooks';
+import { NodeApp } from 'astro/app/node';
+import type { RequestHandler } from './types.js';
+
+/**
+ * Creates a Node.js http listener for on-demand rendered pages, compatible with http.createServer and Connect middleware.
+ * If the next callback is provided, it will be called if the request does not have a matching route.
+ * Intended to be used in both standalone and middleware mode.
+ */
+export function createAppHandler(app: NodeApp): RequestHandler {
+ /**
+ * Keep track of the current request path using AsyncLocalStorage.
+ * Used to log unhandled rejections with a helpful message.
+ */
+ const als = new AsyncLocalStorage<string>();
+ const logger = app.getAdapterLogger();
+ process.on('unhandledRejection', (reason) => {
+ const requestUrl = als.getStore();
+ logger.error(`Unhandled rejection while rendering ${requestUrl}`);
+ console.error(reason);
+ });
+
+ return async (req, res, next, locals) => {
+ let request: Request;
+ try {
+ request = NodeApp.createRequest(req);
+ } catch (err) {
+ logger.error(`Could not render ${req.url}`);
+ console.error(err);
+ res.statusCode = 500;
+ res.end('Internal Server Error');
+ return;
+ }
+
+ const routeData = app.match(request);
+ if (routeData) {
+ const response = await als.run(request.url, () =>
+ app.render(request, {
+ addCookieHeader: true,
+ locals,
+ routeData,
+ })
+ );
+ await NodeApp.writeResponse(response, res);
+ } else if (next) {
+ return next();
+ } else {
+ const response = await app.render(req);
+ await NodeApp.writeResponse(response, res);
+ }
+ };
+}
diff --git a/packages/integrations/node/src/serve-static.ts b/packages/integrations/node/src/serve-static.ts
new file mode 100644
index 000000000..c9839ea8b
--- /dev/null
+++ b/packages/integrations/node/src/serve-static.ts
@@ -0,0 +1,135 @@
+import fs from 'node:fs';
+import type { IncomingMessage, ServerResponse } from 'node:http';
+import path from 'node:path';
+import url from 'node:url';
+import type { NodeApp } from 'astro/app/node';
+import send from 'send';
+import type { Options } from './types.js';
+
+// check for a dot followed by a extension made up of lowercase characters
+const isSubresourceRegex = /.+\.[a-z]+$/i;
+
+/**
+ * Creates a Node.js http listener for static files and prerendered pages.
+ * In standalone mode, the static handler is queried first for the static files.
+ * If one matching the request path is not found, it relegates to the SSR handler.
+ * Intended to be used only in the standalone mode.
+ */
+export function createStaticHandler(app: NodeApp, options: Options) {
+ const client = resolveClientDir(options);
+ /**
+ * @param ssr The SSR handler to be called if the static handler does not find a matching file.
+ */
+ return (req: IncomingMessage, res: ServerResponse, ssr: () => unknown) => {
+ if (req.url) {
+ const [urlPath, urlQuery] = req.url.split('?');
+ const filePath = path.join(client, app.removeBase(urlPath));
+
+ let isDirectory = false;
+ try {
+ isDirectory = fs.lstatSync(filePath).isDirectory();
+ } catch {}
+
+ const { trailingSlash = 'ignore' } = options;
+
+ const hasSlash = urlPath.endsWith('/');
+ let pathname = urlPath;
+
+ switch (trailingSlash) {
+ case 'never': {
+ if (isDirectory && urlPath !== '/' && hasSlash) {
+ // biome-ignore lint/style/useTemplate: more readable like this
+ pathname = urlPath.slice(0, -1) + (urlQuery ? '?' + urlQuery : '');
+ res.statusCode = 301;
+ res.setHeader('Location', pathname);
+ return res.end();
+ }
+ if (isDirectory && !hasSlash) {
+ pathname = `${urlPath}/index.html`;
+ }
+ break;
+ }
+ case 'ignore': {
+ if (isDirectory && !hasSlash) {
+ pathname = `${urlPath}/index.html`;
+ }
+ break;
+ }
+ case 'always': {
+ // trailing slash is not added to "subresources"
+ if (!hasSlash && !isSubresourceRegex.test(urlPath)) {
+ // biome-ignore lint/style/useTemplate: more readable like this
+ pathname = urlPath + '/' + (urlQuery ? '?' + urlQuery : '');
+ res.statusCode = 301;
+ res.setHeader('Location', pathname);
+ return res.end();
+ }
+ break;
+ }
+ }
+ // app.removeBase sometimes returns a path without a leading slash
+ pathname = prependForwardSlash(app.removeBase(pathname));
+
+ const stream = send(req, pathname, {
+ root: client,
+ dotfiles: pathname.startsWith('/.well-known/') ? 'allow' : 'deny',
+ });
+
+ let forwardError = false;
+
+ stream.on('error', (err) => {
+ if (forwardError) {
+ console.error(err.toString());
+ res.writeHead(500);
+ res.end('Internal server error');
+ return;
+ }
+ // File not found, forward to the SSR handler
+ ssr();
+ });
+ stream.on('headers', (_res: ServerResponse) => {
+ // assets in dist/_astro are hashed and should get the immutable header
+ if (pathname.startsWith(`/${options.assets}/`)) {
+ // This is the "far future" cache header, used for static files whose name includes their digest hash.
+ // 1 year (31,536,000 seconds) is convention.
+ // Taken from https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#immutable
+ _res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
+ }
+ });
+ stream.on('file', () => {
+ forwardError = true;
+ });
+ stream.pipe(res);
+ } else {
+ ssr();
+ }
+ };
+}
+
+function resolveClientDir(options: Options) {
+ const clientURLRaw = new URL(options.client);
+ const serverURLRaw = new URL(options.server);
+ const rel = path.relative(url.fileURLToPath(serverURLRaw), url.fileURLToPath(clientURLRaw));
+
+ // walk up the parent folders until you find the one that is the root of the server entry folder. This is how we find the client folder relatively.
+ const serverFolder = path.basename(options.server);
+ let serverEntryFolderURL = path.dirname(import.meta.url);
+ while (!serverEntryFolderURL.endsWith(serverFolder)) {
+ serverEntryFolderURL = path.dirname(serverEntryFolderURL);
+ }
+ // biome-ignore lint/style/useTemplate: <explanation>
+ const serverEntryURL = serverEntryFolderURL + '/entry.mjs';
+ const clientURL = new URL(appendForwardSlash(rel), serverEntryURL);
+ const client = url.fileURLToPath(clientURL);
+ return client;
+}
+
+function prependForwardSlash(pth: string) {
+ // biome-ignore lint/style/useTemplate: <explanation>
+ return pth.startsWith('/') ? pth : '/' + pth;
+}
+
+function appendForwardSlash(pth: string) {
+ // biome-ignore lint/style/useTemplate: <explanation>
+ return pth.endsWith('/') ? pth : pth + '/';
+}
diff --git a/packages/integrations/node/src/server.ts b/packages/integrations/node/src/server.ts
new file mode 100644
index 000000000..cef262b47
--- /dev/null
+++ b/packages/integrations/node/src/server.ts
@@ -0,0 +1,32 @@
+// Keep at the top
+import './polyfill.js';
+
+import type { SSRManifest } from 'astro';
+import { NodeApp } from 'astro/app/node';
+import { setGetEnv } from 'astro/env/setup';
+import createMiddleware from './middleware.js';
+import { createStandaloneHandler } from './standalone.js';
+import startServer from './standalone.js';
+import type { Options } from './types.js';
+
+setGetEnv((key) => process.env[key]);
+
+export function createExports(manifest: SSRManifest, options: Options) {
+ const app = new NodeApp(manifest);
+ options.trailingSlash = manifest.trailingSlash;
+ return {
+ options: options,
+ handler:
+ options.mode === 'middleware' ? createMiddleware(app) : createStandaloneHandler(app, options),
+ startServer: () => startServer(app, options),
+ };
+}
+
+export function start(manifest: SSRManifest, options: Options) {
+ if (options.mode !== 'standalone' || process.env.ASTRO_NODE_AUTOSTART === 'disabled') {
+ return;
+ }
+
+ const app = new NodeApp(manifest);
+ startServer(app, options);
+}
diff --git a/packages/integrations/node/src/standalone.ts b/packages/integrations/node/src/standalone.ts
new file mode 100644
index 000000000..fadcc37b4
--- /dev/null
+++ b/packages/integrations/node/src/standalone.ts
@@ -0,0 +1,93 @@
+import fs from 'node:fs';
+import http from 'node:http';
+import https from 'node:https';
+import type { PreviewServer } from 'astro';
+import type { NodeApp } from 'astro/app/node';
+import enableDestroy from 'server-destroy';
+import { logListeningOn } from './log-listening-on.js';
+import { createAppHandler } from './serve-app.js';
+import { createStaticHandler } from './serve-static.js';
+import type { Options } from './types.js';
+
+// Used to get Host Value at Runtime
+export const hostOptions = (host: Options['host']): string => {
+ if (typeof host === 'boolean') {
+ return host ? '0.0.0.0' : 'localhost';
+ }
+ return host;
+};
+
+export default function standalone(app: NodeApp, options: Options) {
+ const port = process.env.PORT ? Number(process.env.PORT) : (options.port ?? 8080);
+ const host = process.env.HOST ?? hostOptions(options.host);
+ const handler = createStandaloneHandler(app, options);
+ const server = createServer(handler, host, port);
+ server.server.listen(port, host);
+ if (process.env.ASTRO_NODE_LOGGING !== 'disabled') {
+ logListeningOn(app.getAdapterLogger(), server.server, host);
+ }
+ return {
+ server,
+ done: server.closed(),
+ };
+}
+
+// also used by server entrypoint
+export function createStandaloneHandler(app: NodeApp, options: Options) {
+ const appHandler = createAppHandler(app);
+ const staticHandler = createStaticHandler(app, options);
+ return (req: http.IncomingMessage, res: http.ServerResponse) => {
+ try {
+ // validate request path
+ // biome-ignore lint/style/noNonNullAssertion: <explanation>
+ decodeURI(req.url!);
+ } catch {
+ res.writeHead(400);
+ res.end('Bad request.');
+ return;
+ }
+ staticHandler(req, res, () => appHandler(req, res));
+ };
+}
+
+// also used by preview entrypoint
+export function createServer(listener: http.RequestListener, host: string, port: number) {
+ let httpServer: http.Server | https.Server;
+
+ if (process.env.SERVER_CERT_PATH && process.env.SERVER_KEY_PATH) {
+ httpServer = https.createServer(
+ {
+ key: fs.readFileSync(process.env.SERVER_KEY_PATH),
+ cert: fs.readFileSync(process.env.SERVER_CERT_PATH),
+ },
+ listener
+ );
+ } else {
+ httpServer = http.createServer(listener);
+ }
+ enableDestroy(httpServer);
+
+ // Resolves once the server is closed
+ const closed = new Promise<void>((resolve, reject) => {
+ httpServer.addListener('close', resolve);
+ httpServer.addListener('error', reject);
+ });
+
+ const previewable = {
+ host,
+ port,
+ closed() {
+ return closed;
+ },
+ async stop() {
+ await new Promise((resolve, reject) => {
+ httpServer.destroy((err) => (err ? reject(err) : resolve(undefined)));
+ });
+ },
+ } satisfies PreviewServer;
+
+ return {
+ server: httpServer,
+ ...previewable,
+ };
+}
diff --git a/packages/integrations/node/src/types.ts b/packages/integrations/node/src/types.ts
new file mode 100644
index 000000000..010053de5
--- /dev/null
+++ b/packages/integrations/node/src/types.ts
@@ -0,0 +1,39 @@
+import type { IncomingMessage, ServerResponse } from 'node:http';
+import type { SSRManifest } from 'astro';
+import type { NodeApp } from 'astro/app/node';
+
+export interface UserOptions {
+ /**
+ * Specifies the mode that the adapter builds to.
+ *
+ * - 'middleware' - Build to middleware, to be used within another Node.js server, such as Express.
+ * - 'standalone' - Build to a standalone server. The server starts up just by running the built script.
+ */
+ mode: 'middleware' | 'standalone';
+}
+
+export interface Options extends UserOptions {
+ host: string | boolean;
+ port: number;
+ server: string;
+ client: string;
+ assets: string;
+ trailingSlash?: SSRManifest['trailingSlash'];
+}
+
+export interface CreateServerOptions {
+ app: NodeApp;
+ assets: string;
+ client: URL;
+ port: number;
+ host: string | undefined;
+ removeBase: (pathname: string) => string;
+}
+
+export type RequestHandler = (...args: RequestHandlerParams) => void | Promise<void>;
+export type RequestHandlerParams = [
+ req: IncomingMessage,
+ res: ServerResponse,
+ next?: (err?: unknown) => void,
+ locals?: object,
+];