Skip to content

Commit

Permalink
Node.js standalone mode + support for astro preview (#5056)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* Update .changeset/metal-pumas-walk.md

Co-authored-by: Sarah Rainsberger <[email protected]>

* Update .changeset/metal-pumas-walk.md

Co-authored-by: Sarah Rainsberger <[email protected]>

* Update .changeset/stupid-points-refuse.md

Co-authored-by: Sarah Rainsberger <[email protected]>

* Update .changeset/stupid-points-refuse.md

Co-authored-by: Sarah Rainsberger <[email protected]>

* Link to build.server config

Co-authored-by: Fred K. Schott <[email protected]>
Co-authored-by: Sarah Rainsberger <[email protected]>
  • Loading branch information
3 people authored Oct 12, 2022
1 parent 1ae1b9b commit 28b0857
Show file tree
Hide file tree
Showing 10 changed files with 342 additions and 83 deletions.
81 changes: 46 additions & 35 deletions packages/adapters/node/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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:
Expand All @@ -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
Expand Down
5 changes: 4 additions & 1 deletion packages/adapters/node/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"exports": {
".": "./dist/index.js",
"./server.js": "./dist/server.js",
"./preview.js": "./dist/preview.js",
"./package.json": "./package.json"
},
"scripts": {
Expand All @@ -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",
Expand Down
77 changes: 77 additions & 0 deletions packages/adapters/node/src/http-server.ts
Original file line number Diff line number Diff line change
@@ -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)));
});
},
};
}
30 changes: 27 additions & 3 deletions packages/adapters/node/src/index.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}
},
};
}
53 changes: 53 additions & 0 deletions packages/adapters/node/src/middleware.ts
Original file line number Diff line number Diff line change
@@ -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();
}
54 changes: 54 additions & 0 deletions packages/adapters/node/src/preview.ts
Original file line number Diff line number Diff line change
@@ -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
};
Loading

0 comments on commit 28b0857

Please sign in to comment.