summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--packages/integrations/node/package.json2
-rw-r--r--packages/integrations/node/src/createOutgoingHttpHeaders.ts34
-rw-r--r--packages/integrations/node/src/get-network-address.ts48
-rw-r--r--packages/integrations/node/src/http-server.ts131
-rw-r--r--packages/integrations/node/src/index.ts3
-rw-r--r--packages/integrations/node/src/log-listening-on.ts84
-rw-r--r--packages/integrations/node/src/middleware.ts43
-rw-r--r--packages/integrations/node/src/nodeMiddleware.ts110
-rw-r--r--packages/integrations/node/src/preview.ts73
-rw-r--r--packages/integrations/node/src/serve-app.ts27
-rw-r--r--packages/integrations/node/src/serve-static.ts86
-rw-r--r--packages/integrations/node/src/server.ts10
-rw-r--r--packages/integrations/node/src/standalone.ts129
-rw-r--r--packages/integrations/node/src/types.ts13
-rw-r--r--packages/integrations/node/test/bad-urls.test.js4
-rw-r--r--packages/integrations/node/test/createOutgoingHttpHeaders.test.js76
-rw-r--r--packages/integrations/node/test/node-middleware.test.js2
-rw-r--r--packages/integrations/node/test/prerender-404-500.test.js4
-rw-r--r--packages/integrations/node/test/prerender.test.js4
-rw-r--r--packages/integrations/node/test/test-utils.js2
20 files changed, 357 insertions, 528 deletions
diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json
index c3d952840..347eba0d6 100644
--- a/packages/integrations/node/package.json
+++ b/packages/integrations/node/package.json
@@ -37,7 +37,7 @@
"server-destroy": "^1.0.1"
},
"peerDependencies": {
- "astro": "^4.0.0"
+ "astro": "^4.2.0"
},
"devDependencies": {
"@types/node": "^18.17.8",
diff --git a/packages/integrations/node/src/createOutgoingHttpHeaders.ts b/packages/integrations/node/src/createOutgoingHttpHeaders.ts
deleted file mode 100644
index 44bbf81ca..000000000
--- 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 3834c7617..000000000
--- 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 904937601..000000000
--- 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<http.IncomingMessage>) => {
- 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<typeof http.IncomingMessage, typeof http.ServerResponse>
- | https.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
-
- 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<void>((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 bac5c25ef..e7d655403 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 000000000..4f56b3ee8
--- /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<Options, "host">) {
+ 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(
+ 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 000000000..a936dc5bc
--- /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 a13cc5da3..000000000
--- 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<string> = 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 89baa1897..26b91756c 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<typeof createExports>;
- type MaybeServerModule = Partial<ServerModule>;
+type ServerModule = ReturnType<typeof createExports>;
+type MaybeServerModule = Partial<ServerModule>;
+
+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<void>((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 000000000..51ef31575
--- /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,
+ 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..ee3bdaf79
--- /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 88bcd7d62..5c2577ff8 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 e167e8ab6..fc1875e97 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 '127.0.0.1';
- } 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<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,
- done: server.closed(),
+ server: httpServer,
+ ...previewable,
};
}
diff --git a/packages/integrations/node/src/types.ts b/packages/integrations/node/src/types.ts
index 273b80529..9e4f4ce91 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<void>;
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 894729e36..bfef81278 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/createOutgoingHttpHeaders.test.js b/packages/integrations/node/test/createOutgoingHttpHeaders.test.js
deleted file mode 100644
index 2f7063b1c..000000000
--- a/packages/integrations/node/test/createOutgoingHttpHeaders.test.js
+++ /dev/null
@@ -1,76 +0,0 @@
-import { expect } from 'chai';
-
-import { createOutgoingHttpHeaders } from '../dist/createOutgoingHttpHeaders.js';
-
-describe('createOutgoingHttpHeaders', () => {
- it('undefined input headers', async () => {
- const result = createOutgoingHttpHeaders(undefined);
- expect(result).to.equal(undefined);
- });
-
- it('null input headers', async () => {
- const result = createOutgoingHttpHeaders(undefined);
- expect(result).to.equal(undefined);
- });
-
- it('Empty Headers', async () => {
- const headers = new Headers();
- const result = createOutgoingHttpHeaders(headers);
- expect(result).to.equal(undefined);
- });
-
- it('Headers with single key', async () => {
- const headers = new Headers();
- headers.append('x-test', 'hello world');
- const result = createOutgoingHttpHeaders(headers);
- expect(result).to.deep.equal({ 'x-test': 'hello world' });
- });
-
- it('Headers with multiple keys', async () => {
- const headers = new Headers();
- headers.append('x-test1', 'hello');
- headers.append('x-test2', 'world');
- const result = createOutgoingHttpHeaders(headers);
- expect(result).to.deep.equal({ 'x-test1': 'hello', 'x-test2': 'world' });
- });
-
- it('Headers with multiple values (not set-cookie)', async () => {
- const headers = new Headers();
- headers.append('x-test', 'hello');
- headers.append('x-test', 'world');
- const result = createOutgoingHttpHeaders(headers);
- expect(result).to.deep.equal({ 'x-test': 'hello, world' });
- });
-
- it('Headers with multiple values (set-cookie special case)', async () => {
- const headers = new Headers();
- headers.append('set-cookie', 'hello');
- headers.append('set-cookie', 'world');
- const result = createOutgoingHttpHeaders(headers);
- expect(result).to.deep.equal({ 'set-cookie': ['hello', 'world'] });
- });
-
- it('Headers with multiple values (set-cookie case handling)', async () => {
- const headers = new Headers();
- headers.append('Set-cookie', 'hello');
- headers.append('Set-Cookie', 'world');
- const result = createOutgoingHttpHeaders(headers);
- expect(result).to.deep.equal({ 'set-cookie': ['hello', 'world'] });
- });
-
- it('Headers with all use cases', async () => {
- const headers = new Headers();
- headers.append('x-single', 'single');
- headers.append('x-triple', 'one');
- headers.append('x-triple', 'two');
- headers.append('x-triple', 'three');
- headers.append('Set-cookie', 'hello');
- headers.append('Set-Cookie', 'world');
- const result = createOutgoingHttpHeaders(headers);
- expect(result).to.deep.equal({
- 'x-single': 'single',
- 'x-triple': 'one, two, three',
- 'set-cookie': ['hello', 'world'],
- });
- });
-});
diff --git a/packages/integrations/node/test/node-middleware.test.js b/packages/integrations/node/test/node-middleware.test.js
index 009f403c2..6b6785953 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 f8bf0778c..745a1958c 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 65e3b4cb2..0d87e7711 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 70ceaed25..6c8c5d270 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
*/