diff options
Diffstat (limited to 'packages/integrations/node/src')
| -rw-r--r-- | packages/integrations/node/src/http-server.ts | 77 | ||||
| -rw-r--r-- | packages/integrations/node/src/index.ts | 30 | ||||
| -rw-r--r-- | packages/integrations/node/src/middleware.ts | 53 | ||||
| -rw-r--r-- | packages/integrations/node/src/preview.ts | 54 | ||||
| -rw-r--r-- | packages/integrations/node/src/server.ts | 53 | ||||
| -rw-r--r-- | packages/integrations/node/src/standalone.ts | 53 | ||||
| -rw-r--r-- | packages/integrations/node/src/types.ts | 17 | 
7 files changed, 291 insertions, 46 deletions
| diff --git a/packages/integrations/node/src/http-server.ts b/packages/integrations/node/src/http-server.ts new file mode 100644 index 000000000..34192c5f9 --- /dev/null +++ b/packages/integrations/node/src/http-server.ts @@ -0,0 +1,77 @@ +import fs from 'fs'; +import http from 'http'; +import https from 'https'; +import { fileURLToPath } from 'url'; +import send from 'send'; + +interface CreateServerOptions { +	client: URL; +	port: number; +	host: string | undefined; +} + +export function createServer({ client, port, host }: CreateServerOptions, handler: http.RequestListener) { +	const listener: http.RequestListener = (req, res) => { +		if(req.url) { +			const fileURL = new URL('.' + req.url, client); + +			const stream = send(req, fileURLToPath(fileURL), { +				dotfiles: 'deny', +			}); + +			let forwardError = false; + +			stream.on('error', err => { +				if(forwardError) { +					// eslint-disable-next-line no-console +					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('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); + +	// 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.close((err) => (err ? reject(err) : resolve(undefined))); +			}); +		}, +	}; +} diff --git a/packages/integrations/node/src/index.ts b/packages/integrations/node/src/index.ts index 53b94b916..80dfacdab 100644 --- a/packages/integrations/node/src/index.ts +++ b/packages/integrations/node/src/index.ts @@ -1,24 +1,48 @@  import type { AstroAdapter, AstroIntegration } from 'astro'; +import type { Options, UserOptions } from './types'; -export function getAdapter(): AstroAdapter { +export function getAdapter(options: Options): AstroAdapter {  	return {  		name: '@astrojs/node',  		serverEntrypoint: '@astrojs/node/server.js', +		previewEntrypoint: '@astrojs/node/preview.js',  		exports: ['handler'], +		args: options  	};  } -export default function createIntegration(): AstroIntegration { +export default function createIntegration(userOptions: UserOptions): AstroIntegration { +	if(!userOptions?.mode) { +		throw new Error(`[@astrojs/node] Setting the 'mode' option is required.`) +	} + +	let needsBuildConfig = false; +	let _options: Options;  	return {  		name: '@astrojs/node',  		hooks: {  			'astro:config:done': ({ setAdapter, config }) => { -				setAdapter(getAdapter()); +				needsBuildConfig = !config.build?.server; +				_options = { +					...userOptions, +					client: config.build.client?.toString(), +					server: config.build.server?.toString(), +					host: config.server.host, +					port: config.server.port, +				}; +				setAdapter(getAdapter(_options));  				if (config.output === 'static') {  					console.warn(`[@astrojs/node] \`output: "server"\` is required to use this adapter.`);  				}  			}, +			'astro:build:start': ({ buildConfig }) => { +				// Backwards compat +				if(needsBuildConfig) { +					_options.client = buildConfig.client.toString(); +					_options.server = buildConfig.server.toString(); +				} +			}  		},  	};  } diff --git a/packages/integrations/node/src/middleware.ts b/packages/integrations/node/src/middleware.ts new file mode 100644 index 000000000..772461f2a --- /dev/null +++ b/packages/integrations/node/src/middleware.ts @@ -0,0 +1,53 @@ +import type { NodeApp } from 'astro/app/node'; +import type { IncomingMessage, ServerResponse } from 'http'; +import type { Readable } from 'stream'; + +export default function(app: NodeApp) { +	return async function(req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) { +		try { +			const route = app.match(req); + +			if (route) { +				try { +					const response = await app.render(req); +					await writeWebResponse(app, res, response); +				} catch (err: unknown) { +					if (next) { +						next(err); +					} else { +						throw err; +					} +				} +			} else if (next) { +				return next(); +			} else { +				res.writeHead(404); +				res.end('Not found'); +			} +		} catch (err: unknown) { +			if (!res.headersSent) { +				res.writeHead(500, `Server error`); +				res.end(); +			} +		} +	}; +} + +async function writeWebResponse(app: NodeApp, res: ServerResponse, webResponse: Response) { +	const { status, headers, body } = webResponse; + +	if (app.setCookieHeaders) { +		const setCookieHeaders: Array<string> = Array.from(app.setCookieHeaders(webResponse)); +		if (setCookieHeaders.length) { +			res.setHeader('Set-Cookie', setCookieHeaders); +		} +	} + +	res.writeHead(status, Object.fromEntries(headers.entries())); +	if (body) { +		for await (const chunk of body as unknown as Readable) { +			res.write(chunk); +		} +	} +	res.end(); +} diff --git a/packages/integrations/node/src/preview.ts b/packages/integrations/node/src/preview.ts new file mode 100644 index 000000000..33c2f18e2 --- /dev/null +++ b/packages/integrations/node/src/preview.ts @@ -0,0 +1,54 @@ +import type { CreatePreviewServer } from 'astro'; +import type { createExports } from './server'; +import http from 'http'; +import { fileURLToPath } from 'url'; +import { createServer } from './http-server.js'; + +const preview: CreatePreviewServer = async function({ +	client, +	serverEntrypoint, +	host, +	port, +}) { +	type ServerModule = ReturnType<typeof createExports>; +	type MaybeServerModule = Partial<ServerModule>; +	let ssrHandler: ServerModule['handler']; +	try { +		process.env.ASTRO_NODE_AUTOSTART = 'disabled'; +		const ssrModule: MaybeServerModule = await import(serverEntrypoint.toString()); +		if(typeof ssrModule.handler === 'function') { +			ssrHandler = ssrModule.handler; +		} else { +			throw new Error(`The server entrypoint doesn't have a handler. Are you sure this is the right file?`); +		} +	} catch(_err) { +		throw new Error(`The server entrypoint ${fileURLToPath} does not exist. Have you ran a build yet?`); +	} + +	const handler: http.RequestListener = (req, res) => { +		ssrHandler(req, res, (ssrErr: any) => { +			if (ssrErr) { +				res.writeHead(500); +				res.end(ssrErr.toString()); +			} else { +				res.writeHead(404); +				res.end(); +			} +		}); +	}; + +	const server = createServer({ +		client, +		port, +		host, +	}, handler); + +	// eslint-disable-next-line no-console +	console.log(`Preview server listening on http://${host}:${port}`); + +	return server; +} + +export { +	preview as default +}; diff --git a/packages/integrations/node/src/server.ts b/packages/integrations/node/src/server.ts index 6ecd14931..202e66b7e 100644 --- a/packages/integrations/node/src/server.ts +++ b/packages/integrations/node/src/server.ts @@ -1,8 +1,9 @@ -import { polyfill } from '@astrojs/webapi';  import type { SSRManifest } from 'astro'; +import type { Options } from './types'; +import { polyfill } from '@astrojs/webapi';  import { NodeApp } from 'astro/app/node'; -import type { IncomingMessage, ServerResponse } from 'http'; -import type { Readable } from 'stream'; +import middleware from './middleware.js'; +import startServer from './standalone.js';  polyfill(globalThis, {  	exclude: 'window document', @@ -11,49 +12,15 @@ polyfill(globalThis, {  export function createExports(manifest: SSRManifest) {  	const app = new NodeApp(manifest);  	return { -		async handler(req: IncomingMessage, res: ServerResponse, next?: (err?: unknown) => void) { -			try { -				const route = app.match(req); - -				if (route) { -					try { -						const response = await app.render(req); -						await writeWebResponse(app, res, response); -					} catch (err: unknown) { -						if (next) { -							next(err); -						} else { -							throw err; -						} -					} -				} else if (next) { -					return next(); -				} -			} catch (err: unknown) { -				if (!res.headersSent) { -					res.writeHead(500, `Server error`); -					res.end(); -				} -			} -		}, +		handler: middleware(app)  	};  } -async function writeWebResponse(app: NodeApp, res: ServerResponse, webResponse: Response) { -	const { status, headers, body } = webResponse; - -	if (app.setCookieHeaders) { -		const setCookieHeaders: Array<string> = Array.from(app.setCookieHeaders(webResponse)); -		if (setCookieHeaders.length) { -			res.setHeader('Set-Cookie', setCookieHeaders); -		} +export function start(manifest: SSRManifest, options: Options) { +	if(options.mode !== 'standalone' || process.env.ASTRO_NODE_AUTOSTART === 'disabled') { +		return;  	} -	res.writeHead(status, Object.fromEntries(headers.entries())); -	if (body) { -		for await (const chunk of body as unknown as Readable) { -			res.write(chunk); -		} -	} -	res.end(); +	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..8fef96ed5 --- /dev/null +++ b/packages/integrations/node/src/standalone.ts @@ -0,0 +1,53 @@ +import type { NodeApp } from 'astro/app/node'; +import type { Options } from './types'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import middleware from './middleware.js'; +import { createServer } from './http-server.js'; + +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); + +	return { +		client: clientURL, +	}; +} + +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; +	} +} + +export default function startServer(app: NodeApp, options: Options) { +	const port = process.env.PORT ? Number(process.env.port) : (options.port ?? 8080); +	const { client } = resolvePaths(options); +	const handler = middleware(app); + +	const host = getResolvedHostForHttpServer(options.host); +	const server = createServer({ +		client, +		port, +		host, +	}, handler); + +	// eslint-disable-next-line no-console +	console.log(`Server listening on http://${host}:${port}`); + +	return server.closed(); +} diff --git a/packages/integrations/node/src/types.ts b/packages/integrations/node/src/types.ts new file mode 100644 index 000000000..aaf3be942 --- /dev/null +++ b/packages/integrations/node/src/types.ts @@ -0,0 +1,17 @@ + +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; +} | 
