1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
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 + '/';
}
|