aboutsummaryrefslogtreecommitdiff
path: root/packages/integrations/node/src/serve-static.ts
diff options
context:
space:
mode:
authorGravatar Alexander Niebuhr <alexander@nbhr.io> 2024-08-29 19:58:06 +0200
committerGravatar GitHub <noreply@github.com> 2024-08-29 19:58:06 +0200
commitb2d097b51e1d8845d955cee4d1e8838f96975638 (patch)
tree1593bbc71f60058579ed35219adf53b68ee3a24b /packages/integrations/node/src/serve-static.ts
parent93a1db68cd9cf3bb2a4d9f7a8af13cbd881eb701 (diff)
parent7897044c1d95ef905a4835dafe75d5b5b323b5bf (diff)
downloadastro-b2d097b51e1d8845d955cee4d1e8838f96975638.tar.gz
astro-b2d097b51e1d8845d955cee4d1e8838f96975638.tar.zst
astro-b2d097b51e1d8845d955cee4d1e8838f96975638.zip
Merge `vercel` and `node` into main #366
Diffstat (limited to 'packages/integrations/node/src/serve-static.ts')
-rw-r--r--packages/integrations/node/src/serve-static.ts135
1 files changed, 135 insertions, 0 deletions
diff --git a/packages/integrations/node/src/serve-static.ts b/packages/integrations/node/src/serve-static.ts
new file mode 100644
index 000000000..f4fa1fa5c
--- /dev/null
+++ b/packages/integrations/node/src/serve-static.ts
@@ -0,0 +1,135 @@
+import fs from 'node:fs';
+import type { IncomingMessage, ServerResponse } from 'node:http';
+import path from 'node:path';
+import url from 'node:url';
+import type { NodeApp } from 'astro/app/node';
+import send from 'send';
+import type { Options } from './types.js';
+
+// check for a dot followed by a extension made up of lowercase characters
+const isSubresourceRegex = /.+\.[a-z]+$/i;
+
+/**
+ * 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) {
+ const [urlPath, urlQuery] = req.url.split('?');
+ const filePath = path.join(client, app.removeBase(urlPath));
+
+ let pathname: string;
+ let isDirectory = false;
+ try {
+ isDirectory = fs.lstatSync(filePath).isDirectory();
+ } catch {}
+
+ const { trailingSlash = 'ignore' } = options;
+
+ const hasSlash = urlPath.endsWith('/');
+ switch (trailingSlash) {
+ case 'never':
+ // biome-ignore lint/suspicious/noDoubleEquals: <explanation>
+ if (isDirectory && urlPath != '/' && hasSlash) {
+ // biome-ignore lint/style/useTemplate: <explanation>
+ // biome-ignore lint/suspicious/noFallthroughSwitchClause: <explanation>
+ pathname = urlPath.slice(0, -1) + (urlQuery ? '?' + urlQuery : '');
+ res.statusCode = 301;
+ res.setHeader('Location', pathname);
+ return res.end();
+ // biome-ignore lint/style/noUselessElse: <explanation>
+ } else pathname = urlPath;
+ // intentionally fall through
+ case 'ignore':
+ {
+ if (isDirectory && !hasSlash) {
+ // biome-ignore lint/style/useTemplate: <explanation>
+ pathname = urlPath + '/index.html';
+ } else pathname = urlPath;
+ }
+ break;
+ case 'always':
+ // trailing slash is not added to "subresources"
+ if (!hasSlash && !isSubresourceRegex.test(urlPath)) {
+ // biome-ignore lint/style/useTemplate: <explanation>
+ pathname = urlPath + '/' + (urlQuery ? '?' + urlQuery : '');
+ res.statusCode = 301;
+ res.setHeader('Location', pathname);
+ return res.end();
+ // biome-ignore lint/style/noUselessElse: <explanation>
+ } else pathname = urlPath;
+ break;
+ }
+ // app.removeBase sometimes returns a path without a leading slash
+ pathname = prependForwardSlash(app.removeBase(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('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));
+
+ // walk up the parent folders until you find the one that is the root of the server entry folder. This is how we find the client folder relatively.
+ const serverFolder = path.basename(options.server);
+ let serverEntryFolderURL = path.dirname(import.meta.url);
+ while (!serverEntryFolderURL.endsWith(serverFolder)) {
+ serverEntryFolderURL = path.dirname(serverEntryFolderURL);
+ }
+ // biome-ignore lint/style/useTemplate: <explanation>
+ const serverEntryURL = serverEntryFolderURL + '/entry.mjs';
+ const clientURL = new URL(appendForwardSlash(rel), serverEntryURL);
+ const client = url.fileURLToPath(clientURL);
+ return client;
+}
+
+function prependForwardSlash(pth: string) {
+ // biome-ignore lint/style/useTemplate: <explanation>
+ return pth.startsWith('/') ? pth : '/' + pth;
+}
+
+function appendForwardSlash(pth: string) {
+ // biome-ignore lint/style/useTemplate: <explanation>
+ return pth.endsWith('/') ? pth : pth + '/';
+}