diff options
author | 2022-10-12 17:25:51 -0400 | |
---|---|---|
committer | 2022-10-12 17:25:51 -0400 | |
commit | e55af8a23233b6335f45b7a04b9d026990fb616c (patch) | |
tree | 62f47ae6e1fa56c04c045318c3a0d34674cb4a63 /packages/integrations/node | |
parent | 2b7fb848bbe18942960c17a135c5a3769780512b (diff) | |
download | astro-e55af8a23233b6335f45b7a04b9d026990fb616c.tar.gz astro-e55af8a23233b6335f45b7a04b9d026990fb616c.tar.zst astro-e55af8a23233b6335f45b7a04b9d026990fb616c.zip |
Node.js standalone mode + support for astro preview (#5056)
* wip
* Deprecate buildConfig and move to config.build
* Implement the standalone server
* Stay backwards compat
* Add changesets
* correctly merge URLs
* Get config earlier
* update node tests
* Return the preview server
* update remaining tests
* swap usage and config ordering
* Update packages/astro/src/@types/astro.ts
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
* Update .changeset/metal-pumas-walk.md
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
* Update .changeset/metal-pumas-walk.md
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
* Update .changeset/stupid-points-refuse.md
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
* Update .changeset/stupid-points-refuse.md
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
* Link to build.server config
Co-authored-by: Fred K. Schott <fkschott@gmail.com>
Co-authored-by: Sarah Rainsberger <sarah@rainsberger.ca>
Diffstat (limited to 'packages/integrations/node')
-rw-r--r-- | packages/integrations/node/README.md | 81 | ||||
-rw-r--r-- | packages/integrations/node/package.json | 5 | ||||
-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 | ||||
-rw-r--r-- | packages/integrations/node/test/api-route.test.js | 2 |
10 files changed, 342 insertions, 83 deletions
diff --git a/packages/integrations/node/README.md b/packages/integrations/node/README.md index 7c95dd0ea..9e0f8150e 100644 --- a/packages/integrations/node/README.md +++ b/packages/integrations/node/README.md @@ -1,23 +1,23 @@ -# @astrojs/node 🔲 +# @astrojs/node This adapter allows Astro to deploy your SSR site to Node targets. - <strong>[Why Astro Node](#why-astro-node)</strong> - <strong>[Installation](#installation)</strong> -- <strong>[Usage](#usage)</strong> - <strong>[Configuration](#configuration)</strong> +- <strong>[Usage](#usage)</strong> - <strong>[Troubleshooting](#troubleshooting)</strong> - <strong>[Contributing](#contributing)</strong> - <strong>[Changelog](#changelog)</strong> -## Why Astro Node +## Why @astrojs/node If you're using Astro as a static site builder—its behavior out of the box—you don't need an adapter. If you wish to [use server-side rendering (SSR)](https://docs.astro.build/en/guides/server-side-rendering/), Astro requires an adapter that matches your deployment runtime. -[Node](https://nodejs.org/en/) is a JavaScript runtime for server-side code. Frameworks like [Express](https://expressjs.com/) are built on top of it and make it easier to write server applications in Node. This adapter provides access to Node's API and creates a script to run your Astro project that can be utilized in Node applications. +[Node.js](https://nodejs.org/en/) is a JavaScript runtime for server-side code. @astrojs/node can be used either in standalone mode or as middleware for other http servers, such as [Express](https://expressjs.com/). ## Installation @@ -42,23 +42,47 @@ If you prefer to install the adapter manually instead, complete the following tw 1. Add two new lines to your `astro.config.mjs` project configuration file. - ```js title="astro.config.mjs" ins={2, 5-6} + ```js title="astro.config.mjs" ins={2, 5-8} import { defineConfig } from 'astro/config'; import node from '@astrojs/node'; export default defineConfig({ output: 'server', - adapter: node(), + adapter: node({ + mode: 'standalone' + }), }); ``` +## Configuration + +@astrojs/node can be configured by passing options into the adapter function. The following options are available: + +### Mode + +Controls whether the adapter builds to `middleware` or `standalone` mode. + +- `middleware` mode allows the built output to be used as middleware for another Node.js server, like Express.js or Fastify. + ```js + import { defineConfig } from 'astro/config'; + import nodejs from '@astrojs/node'; + + export default defineConfig({ + output: 'server', + adapter: node({ + mode: 'middleware' + }), + }); + ``` +- `standalone` mode builds to server that automatically starts with the entry module is run. This allows you to more easily deploy your build to a host without any additional code. + ## Usage -After [performing a build](https://docs.astro.build/en/guides/deploy/#building-your-site-locally) there will be a `dist/server/entry.mjs` module that exposes a `handler` function. This works like a [middleware](https://expressjs.com/en/guide/using-middleware.html) function: it can handle incoming requests and respond accordingly. +First, [performing a build](https://docs.astro.build/en/guides/deploy/#building-your-site-locally). Depending on which `mode` selected (see above) follow the appropriate steps below: +### Middleware -### Using a middleware framework -You can use this `handler` with any framework that supports the Node `request` and `response` objects. +The server entrypoint is built to `./dist/server/entry.mjs` by default. This module exports a `handler` function that can be used with any framework that supports the Node `request` and `response` objects. For example, with Express: @@ -73,40 +97,27 @@ app.use(ssrHandler); app.listen(8080); ``` +Note that middleware mode does not do file servering. You'll need to configure your HTTP framework to do that for you. By default the client assets are written to `./dist/client/`. -### Using `http` - -This output script does not require you use Express and can work with even the built-in `http` and `https` node modules. The handler does follow the convention calling an error function when either +### Standalone -- A route is not found for the request. -- There was an error rendering. +In standalone mode a server starts when the server entrypoint is run. By default it is built to `./dist/server/entry.mjs`. You can run it with: -You can use these to implement your own 404 behavior like so: - -```js -import http from 'http'; -import { handler as ssrHandler } from './dist/server/entry.mjs'; - -http.createServer(function(req, res) { - ssrHandler(req, res, err => { - if(err) { - res.writeHead(500); - res.end(err.toString()); - } else { - // Serve your static assets here maybe? - // 404? - res.writeHead(404); - res.end(); - } - }); -}).listen(8080); +```shell +node ./dist/server/entry.mjs ``` +For standalone mode the server handles file servering in addition to the page and API routes. +#### HTTPS -## Configuration +By default the standalone server uses HTTP. This works well if you have a proxy server in front of it that does HTTPS. If you need the standalone server to run HTTPS itself you need to provide your SSL key and certificate. -This adapter does not expose any configuration options. +You can pass the path to your key and certification via the environment variables `SERVER_CERT_PATH` and `SERVER_KEY_PATH`. This is how you might pass them in bash: + +```bash +SERVER_KEY_PATH=./private/key.pem SERVER_CERT_PATH=./private/cert.pem node ./dist/server/entry.mjs +``` ## Troubleshooting diff --git a/packages/integrations/node/package.json b/packages/integrations/node/package.json index ffe8c07d8..77a027cd9 100644 --- a/packages/integrations/node/package.json +++ b/packages/integrations/node/package.json @@ -20,6 +20,7 @@ "exports": { ".": "./dist/index.js", "./server.js": "./dist/server.js", + "./preview.js": "./dist/preview.js", "./package.json": "./package.json" }, "scripts": { @@ -29,9 +30,11 @@ "test": "mocha --exit --timeout 20000 test/" }, "dependencies": { - "@astrojs/webapi": "^1.1.0" + "@astrojs/webapi": "^1.1.0", + "send": "^0.18.0" }, "devDependencies": { + "@types/send": "^0.17.1", "astro": "workspace:*", "astro-scripts": "workspace:*", "chai": "^4.3.6", 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; +} diff --git a/packages/integrations/node/test/api-route.test.js b/packages/integrations/node/test/api-route.test.js index cd074ef27..2cc15c761 100644 --- a/packages/integrations/node/test/api-route.test.js +++ b/packages/integrations/node/test/api-route.test.js @@ -10,7 +10,7 @@ describe('API routes', () => { fixture = await loadFixture({ root: './fixtures/api-route/', output: 'server', - adapter: nodejs(), + adapter: nodejs({ mode: 'middleware' }), }); await fixture.build(); }); |