diff options
Diffstat (limited to 'packages/integrations/node/src')
-rw-r--r-- | packages/integrations/node/src/index.ts | 61 | ||||
-rw-r--r-- | packages/integrations/node/src/log-listening-on.ts | 91 | ||||
-rw-r--r-- | packages/integrations/node/src/middleware.ts | 43 | ||||
-rw-r--r-- | packages/integrations/node/src/polyfill.ts | 3 | ||||
-rw-r--r-- | packages/integrations/node/src/preview.ts | 69 | ||||
-rw-r--r-- | packages/integrations/node/src/serve-app.ts | 52 | ||||
-rw-r--r-- | packages/integrations/node/src/serve-static.ts | 135 | ||||
-rw-r--r-- | packages/integrations/node/src/server.ts | 32 | ||||
-rw-r--r-- | packages/integrations/node/src/standalone.ts | 93 | ||||
-rw-r--r-- | packages/integrations/node/src/types.ts | 39 |
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, +]; |