From 3285dadbcc0a7c7dec2160595f92dd3e9db5de26 Mon Sep 17 00:00:00 2001 From: Arsh <69170106+lilnasy@users.noreply.github.com> Date: Fri, 12 Jan 2024 16:32:39 +0000 Subject: [PATCH] quality of life updates for the node adapter (#9582) * descriptive names for files and functions * update tests * add changeset * appease linter * Apply suggestions from code review Co-authored-by: Nate Moore * `server-entrypoint.js` -> `server.js` * prevent crash on stream error (from PR 9533) * Apply suggestions from code review Co-authored-by: Luiz Ferraz * `127.0.0.1` -> `localhost` * add changeset for fryuni's fix * Apply suggestions from code review * Apply suggestions from code review Co-authored-by: Emanuele Stoppa --------- Co-authored-by: Nate Moore Co-authored-by: Luiz Ferraz Co-authored-by: Emanuele Stoppa --- .changeset/unlucky-stingrays-clean.md | 5 + .changeset/weak-apes-add.md | 6 + packages/astro/src/core/app/node.ts | 11 +- .../node/src/createOutgoingHttpHeaders.ts | 34 ----- .../node/src/get-network-address.ts | 48 ------- packages/integrations/node/src/http-server.ts | 131 ------------------ packages/integrations/node/src/index.ts | 3 +- .../integrations/node/src/log-listening-on.ts | 84 +++++++++++ packages/integrations/node/src/middleware.ts | 43 ++++++ .../integrations/node/src/nodeMiddleware.ts | 110 --------------- packages/integrations/node/src/preview.ts | 73 +++------- packages/integrations/node/src/serve-app.ts | 27 ++++ .../integrations/node/src/serve-static.ts | 86 ++++++++++++ packages/integrations/node/src/server.ts | 10 +- packages/integrations/node/src/standalone.ts | 129 +++++++++-------- packages/integrations/node/src/types.ts | 13 +- .../integrations/node/test/bad-urls.test.js | 4 +- .../node/test/node-middleware.test.js | 2 - .../node/test/prerender-404-500.test.js | 4 - .../integrations/node/test/prerender.test.js | 4 - packages/integrations/node/test/test-utils.js | 2 + 21 files changed, 375 insertions(+), 454 deletions(-) create mode 100644 .changeset/unlucky-stingrays-clean.md create mode 100644 .changeset/weak-apes-add.md delete mode 100644 packages/integrations/node/src/createOutgoingHttpHeaders.ts delete mode 100644 packages/integrations/node/src/get-network-address.ts delete mode 100644 packages/integrations/node/src/http-server.ts create mode 100644 packages/integrations/node/src/log-listening-on.ts create mode 100644 packages/integrations/node/src/middleware.ts delete mode 100644 packages/integrations/node/src/nodeMiddleware.ts create mode 100644 packages/integrations/node/src/serve-app.ts create mode 100644 packages/integrations/node/src/serve-static.ts diff --git a/.changeset/unlucky-stingrays-clean.md b/.changeset/unlucky-stingrays-clean.md new file mode 100644 index 000000000000..c13fd500f891 --- /dev/null +++ b/.changeset/unlucky-stingrays-clean.md @@ -0,0 +1,5 @@ +--- +"@astrojs/node": patch +--- + +Fixes an issue where the preview server appeared to be ready to serve requests before binding to a port. diff --git a/.changeset/weak-apes-add.md b/.changeset/weak-apes-add.md new file mode 100644 index 000000000000..b8723453e917 --- /dev/null +++ b/.changeset/weak-apes-add.md @@ -0,0 +1,6 @@ +--- +"@astrojs/node": major +--- + +**Breaking**: Minimum required Astro version is now 4.2.0. +Reorganizes internals to be more maintainable. diff --git a/packages/astro/src/core/app/node.ts b/packages/astro/src/core/app/node.ts index 667ec78bfd54..9e1e5e8cda94 100644 --- a/packages/astro/src/core/app/node.ts +++ b/packages/astro/src/core/app/node.ts @@ -106,15 +106,20 @@ export class NodeApp extends App { try { const reader = body.getReader(); destination.on('close', () => { - reader.cancel(); + // Cancelling the reader may reject not just because of + // an error in the ReadableStream's cancel callback, but + // also because of an error anywhere in the stream. + reader.cancel().catch(err => { + console.error(`There was an uncaught error in the middle of the stream while rendering ${destination.req.url}.`, err); + }); }); let result = await reader.read(); while (!result.done) { destination.write(result.value); result = await reader.read(); } - } catch (err: any) { - console.error(err?.stack || err?.message || String(err)); + // the error will be logged by the "on end" callback above + } catch { destination.write('Internal server error'); } } diff --git a/packages/integrations/node/src/createOutgoingHttpHeaders.ts b/packages/integrations/node/src/createOutgoingHttpHeaders.ts deleted file mode 100644 index 44bbf81ca993..000000000000 --- a/packages/integrations/node/src/createOutgoingHttpHeaders.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { OutgoingHttpHeaders } from 'node:http'; - -/** - * Takes in a nullable WebAPI Headers object and produces a NodeJS OutgoingHttpHeaders object suitable for usage - * with ServerResponse.writeHead(..) or ServerResponse.setHeader(..) - * - * @param webHeaders WebAPI Headers object - * @returns NodeJS OutgoingHttpHeaders object with multiple set-cookie handled as an array of values - */ -export const createOutgoingHttpHeaders = ( - headers: Headers | undefined | null -): OutgoingHttpHeaders | undefined => { - if (!headers) { - return undefined; - } - - // at this point, a multi-value'd set-cookie header is invalid (it was concatenated as a single CSV, which is not valid for set-cookie) - const nodeHeaders: OutgoingHttpHeaders = Object.fromEntries(headers.entries()); - - if (Object.keys(nodeHeaders).length === 0) { - return undefined; - } - - // if there is > 1 set-cookie header, we have to fix it to be an array of values - if (headers.has('set-cookie')) { - const cookieHeaders = headers.getSetCookie(); - if (cookieHeaders.length > 1) { - // the Headers.entries() API already normalized all header names to lower case so we can safely index this as 'set-cookie' - nodeHeaders['set-cookie'] = cookieHeaders; - } - } - - return nodeHeaders; -}; diff --git a/packages/integrations/node/src/get-network-address.ts b/packages/integrations/node/src/get-network-address.ts deleted file mode 100644 index 3834c761722f..000000000000 --- a/packages/integrations/node/src/get-network-address.ts +++ /dev/null @@ -1,48 +0,0 @@ -import os from 'os'; -interface NetworkAddressOpt { - local: string[]; - network: string[]; -} - -const wildcardHosts = new Set(['0.0.0.0', '::', '0000:0000:0000:0000:0000:0000:0000:0000']); -type Protocol = 'http' | 'https'; - -// this code from vite https://github.com/vitejs/vite/blob/d09bbd093a4b893e78f0bbff5b17c7cf7821f403/packages/vite/src/node/utils.ts#L892-L914 -export function getNetworkAddress( - protocol: Protocol = 'http', - hostname: string | undefined, - port: number, - base?: string -) { - const NetworkAddress: NetworkAddressOpt = { - local: [], - network: [], - }; - Object.values(os.networkInterfaces()) - .flatMap((nInterface) => nInterface ?? []) - .filter( - (detail) => - 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/http-server.ts b/packages/integrations/node/src/http-server.ts deleted file mode 100644 index 90493760176c..000000000000 --- a/packages/integrations/node/src/http-server.ts +++ /dev/null @@ -1,131 +0,0 @@ -import https from 'https'; -import fs from 'node:fs'; -import http from 'node:http'; -import { fileURLToPath } from 'node:url'; -import send from 'send'; -import enableDestroy from 'server-destroy'; - -interface CreateServerOptions { - client: URL; - port: number; - host: string | undefined; - removeBase: (pathname: string) => string; - assets: string; -} - -function parsePathname(pathname: string, host: string | undefined, port: number) { - try { - const urlPathname = new URL(pathname, `http://${host}:${port}`).pathname; - return decodeURI(encodeURI(urlPathname)); - } catch (err) { - return undefined; - } -} - -export function createServer( - { client, port, host, removeBase, assets }: CreateServerOptions, - handler: http.RequestListener -) { - // The `base` is removed before passed to this function, so we don't - // need to check for it here. - const assetsPrefix = `/${assets}/`; - function isImmutableAsset(pathname: string) { - return pathname.startsWith(assetsPrefix); - } - - const listener: http.RequestListener = (req, res) => { - if (req.url) { - let pathname: string | undefined = removeBase(req.url); - pathname = pathname[0] === '/' ? pathname : '/' + pathname; - const encodedURI = parsePathname(pathname, host, port); - - if (!encodedURI) { - res.writeHead(400); - res.end('Bad request.'); - return res; - } - - const stream = send(req, encodedURI, { - root: fileURLToPath(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 - handler(req, res); - }); - stream.on('headers', (_res: http.ServerResponse) => { - if (isImmutableAsset(encodedURI)) { - // 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('directory', () => { - // On directory find, redirect to the trailing slash - let location: string; - if (req.url!.includes('?')) { - const [url = '', search] = req.url!.split('?'); - location = `${url}/?${search}`; - } else { - location = req.url + '/'; - } - - res.statusCode = 301; - res.setHeader('Location', location); - res.end(location); - }); - stream.on('file', () => { - forwardError = true; - }); - stream.pipe(res); - } else { - handler(req, res); - } - }; - - 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); - } - httpServer.listen(port, host); - enableDestroy(httpServer); - - // Resolves once the server is closed - const closed = new Promise((resolve, reject) => { - httpServer.addListener('close', resolve); - httpServer.addListener('error', reject); - }); - - return { - host, - port, - closed() { - return closed; - }, - server: httpServer, - stop: async () => { - await new Promise((resolve, reject) => { - httpServer.destroy((err) => (err ? reject(err) : resolve(undefined))); - }); - }, - }; -} diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts index bac5c25ef5bf..e7d655403be1 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -1,6 +1,7 @@ -import type { AstroAdapter, AstroIntegration } from 'astro'; import { AstroError } from 'astro/errors'; +import type { AstroAdapter, AstroIntegration } from 'astro'; import type { Options, UserOptions } from './types.js'; + export function getAdapter(options: Options): AstroAdapter { return { name: '@astrojs/node', 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 000000000000..4f56b3ee8bd2 --- /dev/null +++ b/packages/integrations/node/src/log-listening-on.ts @@ -0,0 +1,84 @@ +import os from "node:os"; +import type http from "node:http"; +import https from "node:https"; +import type { AstroIntegrationLogger } from "astro"; +import type { Options } from './types.js'; +import type { AddressInfo } from "node:net"; + +export async function logListeningOn(logger: AstroIntegrationLogger, server: http.Server | https.Server, options: Pick) { + await new Promise(resolve => server.once('listening', resolve)) + const protocol = server instanceof https.Server ? 'https' : 'http'; + // Allow to provide host value at runtime + const host = getResolvedHostForHttpServer( + process.env.HOST !== undefined && process.env.HOST !== '' ? process.env.HOST : options.host + ); + const { port } = server.address() as AddressInfo; + const address = getNetworkAddress(protocol, host, port); + + if (host === undefined) { + 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) { + if (host === false) { + // Use a secure default + return 'localhost'; + } 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) + } else { + return host; + } +} + +interface NetworkAddressOpt { + local: string[]; + network: string[]; +} + +const wildcardHosts = new Set(['0.0.0.0', '::', '0000:0000:0000:0000:0000:0000:0000:0000']); + +// this code from vite https://github.com/vitejs/vite/blob/d09bbd093a4b893e78f0bbff5b17c7cf7821f403/packages/vite/src/node/utils.ts#L892-L914 +export function getNetworkAddress( + protocol: 'http' | 'https' = 'http', + hostname: string | undefined, + port: number, + base?: string +) { + const NetworkAddress: NetworkAddressOpt = { + local: [], + network: [], + }; + Object.values(os.networkInterfaces()) + .flatMap((nInterface) => nInterface ?? []) + .filter( + (detail) => + 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 000000000000..a936dc5bc92c --- /dev/null +++ b/packages/integrations/node/src/middleware.ts @@ -0,0 +1,43 @@ +import { createAppHandler } from './serve-app.js'; +import type { RequestHandler } from "./types.js"; +import type { NodeApp } from "astro/app/node"; + +/** + * 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 function (...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); + } 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) { + res.writeHead(500, `Server error`); + res.end(); + } + } + } +} diff --git a/packages/integrations/node/src/nodeMiddleware.ts b/packages/integrations/node/src/nodeMiddleware.ts deleted file mode 100644 index a13cc5da3b07..000000000000 --- a/packages/integrations/node/src/nodeMiddleware.ts +++ /dev/null @@ -1,110 +0,0 @@ -import type { NodeApp } from 'astro/app/node'; -import type { ServerResponse } from 'node:http'; -import { createOutgoingHttpHeaders } from './createOutgoingHttpHeaders.js'; -import type { ErrorHandlerParams, Options, RequestHandlerParams } from './types.js'; -import type { AstroIntegrationLogger } from 'astro'; - -// Disable no-unused-vars to avoid breaking signature change -export default function (app: NodeApp, mode: Options['mode']) { - return async function (...args: RequestHandlerParams | ErrorHandlerParams) { - let error = null; - let locals; - let [req, res, next] = args as RequestHandlerParams; - if (mode === 'middleware') { - let { [3]: _locals } = args; - locals = _locals; - } - - if (args[0] instanceof Error) { - [error, req, res, next] = args as ErrorHandlerParams; - if (mode === 'middleware') { - let { [4]: _locals } = args as ErrorHandlerParams; - locals = _locals; - } - if (error) { - if (next) { - return next(error); - } else { - throw error; - } - } - } - - const logger = app.getAdapterLogger(); - - try { - const routeData = app.match(req); - if (routeData) { - try { - const response = await app.render(req, { routeData, locals }); - await writeWebResponse(app, res, response, logger); - } catch (err: unknown) { - if (next) { - next(err); - } else { - throw err; - } - } - } else if (next) { - return next(); - } else { - const response = await app.render(req); - await writeWebResponse(app, res, response, logger); - } - } catch (err: unknown) { - logger.error(`Could not render ${req.url}`); - console.error(err); - if (!res.headersSent) { - res.writeHead(500, `Server error`); - res.end(); - } - } - }; -} - -async function writeWebResponse( - app: NodeApp, - res: ServerResponse, - webResponse: Response, - logger: AstroIntegrationLogger -) { - const { status, headers, body } = webResponse; - - if (app.setCookieHeaders) { - const setCookieHeaders: Array = Array.from(app.setCookieHeaders(webResponse)); - - if (setCookieHeaders.length) { - for (const setCookieHeader of setCookieHeaders) { - headers.append('set-cookie', setCookieHeader); - } - } - } - - const nodeHeaders = createOutgoingHttpHeaders(headers); - res.writeHead(status, nodeHeaders); - if (body) { - try { - const reader = body.getReader(); - res.on('close', () => { - // Cancelling the reader may reject not just because of - // an error in the ReadableStream's cancel callback, but - // also because of an error anywhere in the stream. - reader.cancel().catch((err) => { - logger.error( - `There was an uncaught error in the middle of the stream while rendering ${res.req.url}.` - ); - console.error(err); - }); - }); - let result = await reader.read(); - while (!result.done) { - res.write(result.value); - result = await reader.read(); - } - // the error will be logged by the "on end" callback above - } catch { - res.write('Internal server error'); - } - } - res.end(); -} diff --git a/packages/integrations/node/src/preview.ts b/packages/integrations/node/src/preview.ts index 89baa1897b69..26b91756c8f0 100644 --- a/packages/integrations/node/src/preview.ts +++ b/packages/integrations/node/src/preview.ts @@ -1,26 +1,19 @@ -import type { CreatePreviewServer } from 'astro'; -import { AstroError } from 'astro/errors'; -import type http from 'node:http'; import { fileURLToPath } from 'node:url'; -import { getNetworkAddress } from './get-network-address.js'; -import { createServer } from './http-server.js'; +import { AstroError } from 'astro/errors'; +import { logListeningOn } from './log-listening-on.js'; +import { createServer } from './standalone.js'; +import type { CreatePreviewServer } from 'astro'; import type { createExports } from './server.js'; -const preview: CreatePreviewServer = async function ({ - client, - serverEntrypoint, - host, - port, - base, - logger, -}) { - type ServerModule = ReturnType; - type MaybeServerModule = Partial; +type ServerModule = ReturnType; +type MaybeServerModule = Partial; + +const createPreviewServer: CreatePreviewServer = async function (preview) { let ssrHandler: ServerModule['handler']; let options: ServerModule['options']; try { process.env.ASTRO_NODE_AUTOSTART = 'disabled'; - const ssrModule: MaybeServerModule = await import(serverEntrypoint.toString()); + const ssrModule: MaybeServerModule = await import(preview.serverEntrypoint.toString()); if (typeof ssrModule.handler === 'function') { ssrHandler = ssrModule.handler; options = ssrModule.options!; @@ -33,49 +26,23 @@ const preview: CreatePreviewServer = async function ({ if ((err as any).code === 'ERR_MODULE_NOT_FOUND') { throw new AstroError( `The server entrypoint ${fileURLToPath( - serverEntrypoint + preview.serverEntrypoint )} does not exist. Have you ran a build yet?` ); } else { throw err; } } - - const handler: http.RequestListener = (req, res) => { - ssrHandler(req, res); - }; - - const baseWithoutTrailingSlash: string = base.endsWith('/') - ? base.slice(0, base.length - 1) - : base; - function removeBase(pathname: string): string { - if (pathname.startsWith(base)) { - return pathname.slice(baseWithoutTrailingSlash.length); - } - return pathname; - } - - const server = createServer( - { - client, - port, - host, - removeBase, - assets: options.assets, - }, - handler - ); - const address = getNetworkAddress('http', host, port); - - if (host === undefined) { - logger.info( - `Preview server listening on \n local: ${address.local[0]} \t\n network: ${address.network[0]}\n` - ); - } else { - logger.info(`Preview server listening on ${address.local[0]}`); - } - + const host = preview.host ?? "localhost" + const port = preview.port ?? 4321 + const server = createServer(ssrHandler, host, port); + logListeningOn(preview.logger, server.server, options) + await new Promise((resolve, reject) => { + server.server.once('listening', resolve); + server.server.once('error', reject); + server.server.listen(port, host); + }); return server; }; -export { preview as default }; +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 000000000000..41725fa73b4c --- /dev/null +++ b/packages/integrations/node/src/serve-app.ts @@ -0,0 +1,27 @@ +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 { + return async (req, res, next, locals) => { + const request = NodeApp.createRequest(req); + const routeData = app.match(request); + if (routeData) { + const response = await app.render(request, { + addCookieHeader: true, + locals: locals ?? Reflect.get(req, NodeApp.Symbol.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 000000000000..ee3bdaf791b6 --- /dev/null +++ b/packages/integrations/node/src/serve-static.ts @@ -0,0 +1,86 @@ +import path from "node:path"; +import url from "node:url"; +import send from "send"; +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Options } from "./types.js"; +import type { NodeApp } from "astro/app/node"; + +/** + * 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) { + let pathname = app.removeBase(req.url); + pathname = decodeURI(new URL(pathname, 'http://host').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('directory', () => { + // On directory find, redirect to the trailing slash + let location: string; + if (req.url!.includes('?')) { + const [url1 = '', search] = req.url!.split('?'); + location = `${url1}/?${search}`; + } else { + location = appendForwardSlash(req.url!); + } + + res.statusCode = 301; + res.setHeader('Location', location); + res.end(location); + }); + 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)); + const serverEntryURL = new URL(import.meta.url); + const clientURL = new URL(appendForwardSlash(rel), serverEntryURL); + const client = url.fileURLToPath(clientURL); + return client; +} + +function appendForwardSlash(pth: string) { + return pth.endsWith('/') ? pth : pth + '/'; +} diff --git a/packages/integrations/node/src/server.ts b/packages/integrations/node/src/server.ts index 88bcd7d62e5f..5c2577ff8d5d 100644 --- a/packages/integrations/node/src/server.ts +++ b/packages/integrations/node/src/server.ts @@ -1,7 +1,8 @@ -import type { SSRManifest } from 'astro'; import { NodeApp, applyPolyfills } from 'astro/app/node'; -import middleware from './nodeMiddleware.js'; +import { createStandaloneHandler } from './standalone.js'; import startServer from './standalone.js'; +import createMiddleware from './middleware.js'; +import type { SSRManifest } from 'astro'; import type { Options } from './types.js'; applyPolyfills(); @@ -9,7 +10,10 @@ export function createExports(manifest: SSRManifest, options: Options) { const app = new NodeApp(manifest); return { options: options, - handler: middleware(app, options.mode), + handler: + options.mode === "middleware" + ? createMiddleware(app) + : createStandaloneHandler(app, options), startServer: () => startServer(app, options), }; } diff --git a/packages/integrations/node/src/standalone.ts b/packages/integrations/node/src/standalone.ts index 3fb39561a098..fc1875e972b4 100644 --- a/packages/integrations/node/src/standalone.ts +++ b/packages/integrations/node/src/standalone.ts @@ -1,75 +1,90 @@ -import type { NodeApp } from 'astro/app/node'; +import http from 'node:http'; import https from 'https'; -import path from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { getNetworkAddress } from './get-network-address.js'; -import { createServer } from './http-server.js'; -import middleware from './nodeMiddleware.js'; +import fs from 'node:fs'; +import enableDestroy from 'server-destroy'; +import { createAppHandler } from './serve-app.js'; +import { createStaticHandler } from './serve-static.js'; +import { logListeningOn } from './log-listening-on.js'; +import type { NodeApp } from 'astro/app/node'; import type { Options } from './types.js'; +import type { PreviewServer } from 'astro'; -function resolvePaths(options: Options) { - const clientURLRaw = new URL(options.client); - const serverURLRaw = new URL(options.server); - const rel = path.relative(fileURLToPath(serverURLRaw), fileURLToPath(clientURLRaw)); - - const serverEntryURL = new URL(import.meta.url); - const clientURL = new URL(appendForwardSlash(rel), serverEntryURL); - +export default function standalone(app: NodeApp, options: Options) { + const port = process.env.PORT ? Number(process.env.PORT) : options.port ?? 8080; + // Allow to provide host value at runtime + const hostOptions = typeof options.host === "boolean" ? "localhost" : options.host + const host = process.env.HOST ?? hostOptions; + 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, options) + } return { - client: clientURL, + server, + done: server.closed(), }; } -function appendForwardSlash(pth: string) { - return pth.endsWith('/') ? pth : pth + '/'; -} - -export function getResolvedHostForHttpServer(host: string | boolean) { - if (host === false) { - // Use a secure default - return 'localhost'; - } 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) - } else { - return host; +// 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 + decodeURI(req.url!); + } catch { + res.writeHead(400); + res.end('Bad request.'); + return; + } + staticHandler(req, res, () => appHandler(req, res)); } } -export default function startServer(app: NodeApp, options: Options) { - const logger = app.getAdapterLogger(); - const port = process.env.PORT ? Number(process.env.PORT) : options.port ?? 8080; - const { client } = resolvePaths(options); - const handler = middleware(app, options.mode); - - // Allow to provide host value at runtime - const host = getResolvedHostForHttpServer( - process.env.HOST !== undefined && process.env.HOST !== '' ? process.env.HOST : options.host - ); - const server = createServer( - { - client, - port, - host, - removeBase: app.removeBase.bind(app), - assets: options.assets, - }, - handler - ); - - const protocol = server.server instanceof https.Server ? 'https' : 'http'; - const address = getNetworkAddress(protocol, host, port); +// also used by preview entrypoint +export function createServer( + listener: http.RequestListener, + host: string, + port: number +) { + let httpServer: http.Server | https.Server; - if (host === undefined) { - logger.info( - `Server listening on \n local: ${address.local[0]} \t\n network: ${address.network[0]}\n` + 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 { - logger.info(`Server listening on ${address.local[0]}`); + httpServer = http.createServer(listener); } + enableDestroy(httpServer); + + // Resolves once the server is closed + const closed = new Promise((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, - done: server.closed(), + server: httpServer, + ...previewable, }; } diff --git a/packages/integrations/node/src/types.ts b/packages/integrations/node/src/types.ts index 273b805292cc..9e4f4ce91997 100644 --- a/packages/integrations/node/src/types.ts +++ b/packages/integrations/node/src/types.ts @@ -1,3 +1,4 @@ +import type { NodeApp } from 'astro/app/node'; import type { IncomingMessage, ServerResponse } from 'node:http'; export interface UserOptions { @@ -18,11 +19,19 @@ export interface Options extends UserOptions { assets: string; } +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; export type RequestHandlerParams = [ req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void, locals?: object, ]; - -export type ErrorHandlerParams = [unknown, ...RequestHandlerParams]; diff --git a/packages/integrations/node/test/bad-urls.test.js b/packages/integrations/node/test/bad-urls.test.js index 894729e3679b..bfef81278dc0 100644 --- a/packages/integrations/node/test/bad-urls.test.js +++ b/packages/integrations/node/test/bad-urls.test.js @@ -34,9 +34,9 @@ describe('Bad URLs', () => { for (const weirdUrl of weirdURLs) { const fetchResult = await fixture.fetch(weirdUrl); - expect([400, 500]).to.include( + expect([400, 404, 500]).to.include( fetchResult.status, - `${weirdUrl} returned something else than 400 or 500` + `${weirdUrl} returned something else than 400, 404, or 500` ); } const stillWork = await fixture.fetch('/'); diff --git a/packages/integrations/node/test/node-middleware.test.js b/packages/integrations/node/test/node-middleware.test.js index 009f403c21c7..6b678595359d 100644 --- a/packages/integrations/node/test/node-middleware.test.js +++ b/packages/integrations/node/test/node-middleware.test.js @@ -21,7 +21,6 @@ describe('behavior from middleware, standalone', () => { let server; before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = false; fixture = await loadFixture({ root: './fixtures/node-middleware/', @@ -61,7 +60,6 @@ describe('behavior from middleware, middleware', () => { let server; before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = false; fixture = await loadFixture({ root: './fixtures/node-middleware/', diff --git a/packages/integrations/node/test/prerender-404-500.test.js b/packages/integrations/node/test/prerender-404-500.test.js index f8bf0778c756..745a1958c659 100644 --- a/packages/integrations/node/test/prerender-404-500.test.js +++ b/packages/integrations/node/test/prerender-404-500.test.js @@ -21,7 +21,6 @@ describe('Prerender 404', () => { describe('With base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = true; fixture = await loadFixture({ @@ -107,7 +106,6 @@ describe('Prerender 404', () => { describe('Without base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = true; fixture = await loadFixture({ @@ -171,7 +169,6 @@ describe('Hybrid 404', () => { describe('With base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = false; fixture = await loadFixture({ // inconsequential config that differs between tests @@ -229,7 +226,6 @@ describe('Hybrid 404', () => { describe('Without base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = false; fixture = await loadFixture({ // inconsequential config that differs between tests diff --git a/packages/integrations/node/test/prerender.test.js b/packages/integrations/node/test/prerender.test.js index 65e3b4cb2e78..0d87e77110f3 100644 --- a/packages/integrations/node/test/prerender.test.js +++ b/packages/integrations/node/test/prerender.test.js @@ -18,7 +18,6 @@ describe('Prerendering', () => { describe('With base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = true; fixture = await loadFixture({ @@ -86,7 +85,6 @@ describe('Prerendering', () => { describe('Without base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = true; fixture = await loadFixture({ @@ -151,7 +149,6 @@ describe('Hybrid rendering', () => { describe('With base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = false; fixture = await loadFixture({ base: '/some-base', @@ -217,7 +214,6 @@ describe('Hybrid rendering', () => { describe('Without base', async () => { before(async () => { - process.env.ASTRO_NODE_AUTOSTART = 'disabled'; process.env.PRERENDER = false; fixture = await loadFixture({ root: './fixtures/prerender/', diff --git a/packages/integrations/node/test/test-utils.js b/packages/integrations/node/test/test-utils.js index 70ceaed25803..6c8c5d270628 100644 --- a/packages/integrations/node/test/test-utils.js +++ b/packages/integrations/node/test/test-utils.js @@ -2,6 +2,8 @@ import httpMocks from 'node-mocks-http'; import { EventEmitter } from 'node:events'; import { loadFixture as baseLoadFixture } from '../../../astro/test/test-utils.js'; +process.env.ASTRO_NODE_AUTOSTART = "disabled"; +process.env.ASTRO_NODE_LOGGING = "disabled"; /** * @typedef {import('../../../astro/test/test-utils').Fixture} Fixture */