diff options
author | 2024-08-29 19:58:06 +0200 | |
---|---|---|
committer | 2024-08-29 19:58:06 +0200 | |
commit | b2d097b51e1d8845d955cee4d1e8838f96975638 (patch) | |
tree | 1593bbc71f60058579ed35219adf53b68ee3a24b /packages/integrations/node/src/serve-static.ts | |
parent | 93a1db68cd9cf3bb2a4d9f7a8af13cbd881eb701 (diff) | |
parent | 7897044c1d95ef905a4835dafe75d5b5b323b5bf (diff) | |
download | astro-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.ts | 135 |
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 + '/'; +} |