aboutsummaryrefslogtreecommitdiff
path: root/packages/integrations/node/src
diff options
context:
space:
mode:
authorGravatar Matthew Phillips <matthew@skypack.dev> 2022-10-12 17:25:51 -0400
committerGravatar GitHub <noreply@github.com> 2022-10-12 17:25:51 -0400
commite55af8a23233b6335f45b7a04b9d026990fb616c (patch)
tree62f47ae6e1fa56c04c045318c3a0d34674cb4a63 /packages/integrations/node/src
parent2b7fb848bbe18942960c17a135c5a3769780512b (diff)
downloadastro-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/src')
-rw-r--r--packages/integrations/node/src/http-server.ts77
-rw-r--r--packages/integrations/node/src/index.ts30
-rw-r--r--packages/integrations/node/src/middleware.ts53
-rw-r--r--packages/integrations/node/src/preview.ts54
-rw-r--r--packages/integrations/node/src/server.ts53
-rw-r--r--packages/integrations/node/src/standalone.ts53
-rw-r--r--packages/integrations/node/src/types.ts17
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;
+}