jeasx 2.10.7 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +1485 -0
- package/README.md +31 -2
- package/build.js +37 -14
- package/cli.js +7 -1
- package/env.js +7 -1
- package/package.json +5 -5
- package/serverless.js +120 -105
- package/serverless.js.map +2 -2
- package/serverless.ts +216 -153
- package/start.js +13 -1
package/serverless.ts
CHANGED
|
@@ -5,34 +5,39 @@ import fastify, {
|
|
|
5
5
|
FastifyRequest,
|
|
6
6
|
FastifyServerOptions,
|
|
7
7
|
} from "fastify";
|
|
8
|
-
import {
|
|
8
|
+
import { isJsxNode, jsxToStream } from "jsx-async-runtime";
|
|
9
9
|
import { stat } from "node:fs/promises";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
import { Readable } from "node:stream";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
12
13
|
import env from "./env.js";
|
|
13
14
|
|
|
14
15
|
env();
|
|
15
16
|
|
|
16
17
|
const CWD = process.cwd();
|
|
17
|
-
const CONFIG = (await import(
|
|
18
|
+
const CONFIG = (await import(pathToFileURL(join(CWD, "jeasx.config.js")).href)).default;
|
|
18
19
|
const NODE_ENV_IS_DEVELOPMENT = process.env.NODE_ENV === "development";
|
|
19
20
|
|
|
20
21
|
// Map routes and files for non-development environments from metadata export.
|
|
21
22
|
// Module paths are initialized at startup but overwritten
|
|
22
23
|
// with resolved modules upon the first request.
|
|
23
|
-
const {
|
|
24
|
-
routes: MODULE_BY_ROUTE,
|
|
25
|
-
files: FILE_BY_PATH,
|
|
26
|
-
}: {
|
|
24
|
+
const METADATA: {
|
|
27
25
|
routes: Record<string, string | { default: Function }>;
|
|
28
26
|
files: Record<string, string>;
|
|
29
27
|
} = NODE_ENV_IS_DEVELOPMENT
|
|
30
28
|
? { routes: {}, files: {} }
|
|
31
|
-
: (await import(
|
|
29
|
+
: (await import(pathToFileURL(join(CWD, "dist", "[--metadata--].js")).href)).default;
|
|
30
|
+
|
|
31
|
+
// Without a prototype, lookups never return inherited keys like "constructor".
|
|
32
|
+
const MODULE_BY_ROUTE = Object.assign(
|
|
33
|
+
Object.create(null),
|
|
34
|
+
METADATA.routes,
|
|
35
|
+
) as typeof METADATA.routes;
|
|
36
|
+
const FILE_BY_PATH = Object.assign(Object.create(null), METADATA.files) as typeof METADATA.files;
|
|
32
37
|
|
|
33
38
|
declare module "fastify" {
|
|
34
39
|
interface FastifyRequest {
|
|
35
|
-
/**
|
|
40
|
+
/** Decoded path without query parameters */
|
|
36
41
|
path: string;
|
|
37
42
|
/** Path to resolved route handler */
|
|
38
43
|
route: string;
|
|
@@ -44,46 +49,53 @@ declare module "fastify" {
|
|
|
44
49
|
}
|
|
45
50
|
}
|
|
46
51
|
|
|
47
|
-
|
|
52
|
+
// All config options may be sync or async functions.
|
|
53
|
+
|
|
54
|
+
// Files are passed relative to CWD, so the root must always be CWD.
|
|
55
|
+
const FASTIFY_SEND_OPTIONS: SendOptions = {
|
|
56
|
+
...((await CONFIG.FASTIFY_SEND_OPTIONS?.()) as SendOptions),
|
|
57
|
+
root: CWD,
|
|
58
|
+
};
|
|
48
59
|
|
|
49
60
|
// Enhance Fastify server from userland
|
|
50
61
|
const FASTIFY_SERVER = (CONFIG.FASTIFY_SERVER ?? ((fastify) => fastify)) as (
|
|
51
62
|
fastify: FastifyInstance,
|
|
52
|
-
) => FastifyInstance
|
|
63
|
+
) => FastifyInstance | Promise<FastifyInstance>;
|
|
53
64
|
|
|
54
|
-
// Create and export a Fastify instance
|
|
55
|
-
|
|
56
|
-
|
|
65
|
+
// Create and export a Fastify instance.
|
|
66
|
+
// A Fastify instance is thenable: awaiting it loads the plugins registered so far
|
|
67
|
+
// and resolves to the instance itself, so further plugins and routes can still be added.
|
|
68
|
+
export default await FASTIFY_SERVER(
|
|
69
|
+
fastify((await CONFIG.FASTIFY_SERVER_OPTIONS?.()) as FastifyServerOptions)
|
|
57
70
|
.decorateRequest("route", "")
|
|
58
71
|
.decorateRequest("path", "")
|
|
59
72
|
.decorateReply("file", undefined)
|
|
60
|
-
.addHook("onRequest", async (request) => {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
73
|
+
.addHook("onRequest", async (request, reply) => {
|
|
74
|
+
const path = decodePath(request.url);
|
|
75
|
+
if (path === undefined) {
|
|
76
|
+
reply.code(400).send();
|
|
77
|
+
return reply;
|
|
78
|
+
}
|
|
79
|
+
request.path = path;
|
|
64
80
|
})
|
|
65
81
|
.all("*", async (request: FastifyRequest, reply: FastifyReply) => {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// otherwise its file handle leaks.
|
|
82
|
-
const stream = reply.file?.stream;
|
|
83
|
-
if (stream && stream !== payload && stream.readableFlowing === null) {
|
|
84
|
-
stream.destroy();
|
|
85
|
-
}
|
|
82
|
+
// Close the file stream once the response is finished or aborted,
|
|
83
|
+
// otherwise its file handle leaks if nobody reads it. Waiting for the
|
|
84
|
+
// response also covers routes which send the stream via reply.send().
|
|
85
|
+
reply.raw.once("close", () => reply.file?.stream.destroy());
|
|
86
|
+
|
|
87
|
+
const payload = await handler(request, reply);
|
|
88
|
+
if (
|
|
89
|
+
reply.getHeader("content-type") === undefined &&
|
|
90
|
+
(typeof payload === "string" ||
|
|
91
|
+
payload instanceof ReadableStream ||
|
|
92
|
+
// Static files bring their own content type (or none, e.g. for 304).
|
|
93
|
+
(payload instanceof Readable && payload !== reply.file?.stream) ||
|
|
94
|
+
Buffer.isBuffer(payload))
|
|
95
|
+
) {
|
|
96
|
+
reply.type("text/html; charset=utf-8");
|
|
86
97
|
}
|
|
98
|
+
return payload;
|
|
87
99
|
}),
|
|
88
100
|
);
|
|
89
101
|
|
|
@@ -91,7 +103,7 @@ export default FASTIFY_SERVER(
|
|
|
91
103
|
const STATIC_FILE_ROUTE = Symbol("static-file-route");
|
|
92
104
|
|
|
93
105
|
/**
|
|
94
|
-
* Resolves route
|
|
106
|
+
* Resolves route modules based on the request path and executes them.
|
|
95
107
|
*/
|
|
96
108
|
async function handler(request: FastifyRequest, reply: FastifyReply) {
|
|
97
109
|
let response: unknown;
|
|
@@ -102,118 +114,125 @@ async function handler(request: FastifyRequest, reply: FastifyReply) {
|
|
|
102
114
|
// Default props for route handlers
|
|
103
115
|
const props = { request, reply };
|
|
104
116
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
if (
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
break;
|
|
118
|
-
}
|
|
119
|
-
continue;
|
|
117
|
+
// Check for static file and store result for later processing.
|
|
118
|
+
reply.file = await tryFile(request);
|
|
119
|
+
|
|
120
|
+
// Execute route handlers for current request
|
|
121
|
+
for (const route of generateRoutes(request.path)) {
|
|
122
|
+
// Try to serve static file
|
|
123
|
+
if (route === STATIC_FILE_ROUTE) {
|
|
124
|
+
if (reply.file) {
|
|
125
|
+
reply.status(reply.file.statusCode);
|
|
126
|
+
reply.headers(reply.file.headers);
|
|
127
|
+
response = reply.file.stream;
|
|
128
|
+
break;
|
|
120
129
|
}
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
121
132
|
|
|
122
|
-
|
|
123
|
-
|
|
133
|
+
// Resolve module or path to module
|
|
134
|
+
let module = MODULE_BY_ROUTE[route];
|
|
124
135
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
136
|
+
// Skip processing if the route path was not initialized.
|
|
137
|
+
if (module === undefined && !NODE_ENV_IS_DEVELOPMENT) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
129
140
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
// Use timestamp as query parameter to update modules.
|
|
146
|
-
const mtime = (await stat(modulePath)).mtime.getTime();
|
|
147
|
-
// Dynamic imports are restricted to development environments;
|
|
148
|
-
// therefore, production-level path validation is not required here.
|
|
149
|
-
module = await import(`file://${modulePath}?${mtime}`);
|
|
150
|
-
}
|
|
151
|
-
} catch (e) {
|
|
152
|
-
switch ((e as any)?.code) {
|
|
153
|
-
case "ENOENT":
|
|
154
|
-
case "ENOTDIR":
|
|
155
|
-
case "ERR_MODULE_NOT_FOUND":
|
|
156
|
-
continue;
|
|
157
|
-
default:
|
|
158
|
-
// Module exists, but fails to load.
|
|
159
|
-
throw e;
|
|
141
|
+
// Module was not loaded yet?
|
|
142
|
+
try {
|
|
143
|
+
if (typeof module === "string") {
|
|
144
|
+
// Production: Load and cache module only via pre-calculated path.
|
|
145
|
+
// This avoids potential path traversal vulnerabilities caused
|
|
146
|
+
// by unexpected `route` values.
|
|
147
|
+
module = MODULE_BY_ROUTE[route] = await import(pathToFileURL(join(CWD, module)).href);
|
|
148
|
+
} else if (module === undefined && NODE_ENV_IS_DEVELOPMENT) {
|
|
149
|
+
// Only map module paths depending on `route` during development.
|
|
150
|
+
const modulePath = join(CWD, "dist", `${route}.js`);
|
|
151
|
+
if (typeof require === "function" && require.cache[modulePath]) {
|
|
152
|
+
// Bun: Remove module from cache before importing
|
|
153
|
+
// as query parameter for import is ignored.
|
|
154
|
+
delete require.cache[modulePath];
|
|
160
155
|
}
|
|
156
|
+
// Use timestamp as query parameter to update modules.
|
|
157
|
+
const mtime = (await stat(modulePath)).mtime.getTime();
|
|
158
|
+
// Dynamic imports are restricted to development environments;
|
|
159
|
+
// therefore, production-level path validation is not required here.
|
|
160
|
+
module = await import(`${pathToFileURL(modulePath).href}?${mtime}`);
|
|
161
161
|
}
|
|
162
|
-
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
module.default;
|
|
162
|
+
} catch (e) {
|
|
163
|
+
// Only stat() reports a missing route module (development).
|
|
164
|
+
// ERR_MODULE_NOT_FOUND isn't ignored, as it is also thrown
|
|
165
|
+
// for missing imports of an existing module.
|
|
166
|
+
switch ((e as any)?.code) {
|
|
167
|
+
case "ENOENT":
|
|
168
|
+
case "ENOTDIR":
|
|
169
|
+
continue;
|
|
170
|
+
default:
|
|
171
|
+
// Module exists, but fails to load.
|
|
172
|
+
throw e;
|
|
174
173
|
}
|
|
174
|
+
}
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
176
|
+
// Store current route in request.
|
|
177
|
+
request.route = route;
|
|
178
|
+
|
|
179
|
+
// Ensure module is a valid object before processing.
|
|
180
|
+
if (module && typeof module === "object") {
|
|
181
|
+
response =
|
|
182
|
+
typeof module.default === "function"
|
|
183
|
+
? // Call functions with context as `this` and props as parameters,
|
|
184
|
+
await module.default.call(context, props)
|
|
185
|
+
: // otherwise return default export.
|
|
186
|
+
module.default;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (reply.sent) {
|
|
190
|
+
return;
|
|
191
|
+
} else if (route.endsWith("/[404]")) {
|
|
192
|
+
// Preserve existing status if a 404 page is requested directly
|
|
193
|
+
// (e.g. "/a/404" answered by "/a/[404]", but not by "/[404]").
|
|
194
|
+
// If no status is defined, set status to 404 automatically.
|
|
195
|
+
if (
|
|
196
|
+
reply.statusCode === 200 &&
|
|
197
|
+
!(route.slice(0, -"[404]".length) + "404" === request.path.replace(/\/$/, ""))
|
|
192
198
|
) {
|
|
193
|
-
|
|
194
|
-
} else if (typeof response === "object" && route.endsWith("/[...guard]")) {
|
|
195
|
-
// Add object entries from guard to props
|
|
196
|
-
Object.assign(props, response);
|
|
197
|
-
continue;
|
|
198
|
-
} else if (response === undefined || reply.statusCode === 404) {
|
|
199
|
-
continue;
|
|
200
|
-
} else {
|
|
201
|
-
break;
|
|
199
|
+
reply.status(404);
|
|
202
200
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
201
|
+
break;
|
|
202
|
+
} else if (
|
|
203
|
+
typeof response === "string" ||
|
|
204
|
+
response instanceof Response ||
|
|
205
|
+
response instanceof Readable ||
|
|
206
|
+
response instanceof ReadableStream ||
|
|
207
|
+
Buffer.isBuffer(response) ||
|
|
208
|
+
isJsxNode(response)
|
|
209
|
+
) {
|
|
210
|
+
break;
|
|
211
|
+
} else if (typeof response === "object" && route.endsWith("/[...guard]")) {
|
|
212
|
+
// Add object entries from guard to props
|
|
213
|
+
Object.assign(props, response);
|
|
214
|
+
// Props are not a response: never send them if no other route answers.
|
|
215
|
+
response = undefined;
|
|
216
|
+
continue;
|
|
217
|
+
} else if (response == null) {
|
|
218
|
+
continue;
|
|
211
219
|
} else {
|
|
212
|
-
|
|
220
|
+
break;
|
|
213
221
|
}
|
|
214
222
|
}
|
|
223
|
+
// No route answered and no [404] exists: don't reply with an empty 200.
|
|
224
|
+
if (response == null && reply.statusCode === 200) {
|
|
225
|
+
reply.status(404);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return await renderResponse(context, response);
|
|
215
229
|
}
|
|
216
230
|
|
|
231
|
+
// Names of special routes which must not be requested as "/a/b/[c]".
|
|
232
|
+
// "--metadata--" is the route and file mapping of a production build, which must
|
|
233
|
+
// not be served if the server runs in development mode on a production build.
|
|
234
|
+
const SPECIAL_SEGMENTS = new Set(["index", "...guard", "...path", "--metadata--"]);
|
|
235
|
+
|
|
217
236
|
/**
|
|
218
237
|
* Generates all possible routes based on the given input path.
|
|
219
238
|
*
|
|
@@ -221,7 +240,7 @@ async function handler(request: FastifyRequest, reply: FastifyReply) {
|
|
|
221
240
|
*
|
|
222
241
|
* [
|
|
223
242
|
* "/[...guard]","/a/[...guard]","/a/b/[...guard]","/a/b/c/[...guard]",
|
|
224
|
-
*
|
|
243
|
+
* STATIC_FILE_ROUTE,
|
|
225
244
|
* "/a/b/[c]","/a/b/c/[index]",
|
|
226
245
|
* "/a/b/c/[...path]","/a/b/[...path]","/a/[...path]","/[...path]",
|
|
227
246
|
* "/a/b/c/[404]","/a/b/[404]","/a/[404]","/[404]"
|
|
@@ -229,7 +248,7 @@ async function handler(request: FastifyRequest, reply: FastifyReply) {
|
|
|
229
248
|
*/
|
|
230
249
|
function* generateRoutes(path: string): Generator<string | typeof STATIC_FILE_ROUTE> {
|
|
231
250
|
// Transform given path into array of all its segments.
|
|
232
|
-
// "/a/b/c" => ["", "/a", "/a/b
|
|
251
|
+
// "/a/b/c" => ["", "/a", "/a/b", "/a/b/c"]
|
|
233
252
|
const segments = [""];
|
|
234
253
|
let edgeSegment = "";
|
|
235
254
|
for (const segment of path.split("/")) {
|
|
@@ -249,9 +268,14 @@ function* generateRoutes(path: string): Generator<string | typeof STATIC_FILE_RO
|
|
|
249
268
|
yield STATIC_FILE_ROUTE;
|
|
250
269
|
|
|
251
270
|
// "/a/b/c" => ["/a/b/[c]", "/a/b/c/[index]"]
|
|
271
|
+
// Special routes aren't reachable by name ("/index", "/...guard", "/...path", "/--metadata--"),
|
|
272
|
+
// except "/a/404", which is answered by "/a/[404]" on purpose.
|
|
252
273
|
const lastSlash = edgeSegment.lastIndexOf("/") + 1;
|
|
253
|
-
|
|
254
|
-
|
|
274
|
+
const lastSegment = edgeSegment.substring(lastSlash);
|
|
275
|
+
// Compared in lower case, as case-insensitive file systems (macOS, Windows) would
|
|
276
|
+
// otherwise resolve e.g. "/--METADATA--" to "[--metadata--].js" in development.
|
|
277
|
+
if (lastSlash > 0 && !SPECIAL_SEGMENTS.has(lastSegment.toLowerCase())) {
|
|
278
|
+
yield `${edgeSegment.substring(0, lastSlash)}[${lastSegment}]`;
|
|
255
279
|
}
|
|
256
280
|
yield `${edgeSegment}/[index]`;
|
|
257
281
|
|
|
@@ -267,20 +291,32 @@ function* generateRoutes(path: string): Generator<string | typeof STATIC_FILE_RO
|
|
|
267
291
|
}
|
|
268
292
|
|
|
269
293
|
/**
|
|
270
|
-
*
|
|
294
|
+
* Extracts the path from a request url (dropping the query string) and decodes it.
|
|
295
|
+
* Backslashes become slashes (as in browsers) and repeated slashes are collapsed,
|
|
296
|
+
* so a path like "//evil.com" can't be mistaken for another host, e.g. in a <base> tag.
|
|
297
|
+
* Returns undefined for malformed encodings, null bytes and "." or ".." segments,
|
|
298
|
+
* as browsers never send them: ".." could escape the project directory, and "."
|
|
299
|
+
* would be resolved by the file system in development, but not in production.
|
|
271
300
|
*/
|
|
272
|
-
function
|
|
273
|
-
|
|
301
|
+
function decodePath(url: string): string | undefined {
|
|
302
|
+
try {
|
|
303
|
+
const index = url.indexOf("?");
|
|
304
|
+
const rawPath = index === -1 ? url : url.slice(0, index);
|
|
305
|
+
const path = decodeURIComponent(rawPath).replace(/[\\/]+/g, "/");
|
|
306
|
+
if (path.includes("\0") || /(^|\/)\.\.?(\/|$)/.test(path)) {
|
|
307
|
+
return undefined;
|
|
308
|
+
}
|
|
309
|
+
return path;
|
|
310
|
+
} catch {
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
274
313
|
}
|
|
275
314
|
|
|
276
315
|
/**
|
|
277
|
-
* Renders JSX
|
|
316
|
+
* Renders JSX as stream and applies the optional response handler to the response.
|
|
278
317
|
*/
|
|
279
318
|
async function renderResponse(context: any, response: unknown) {
|
|
280
|
-
const payload =
|
|
281
|
-
? await jsxToString.call(context, response as JSX.Element)
|
|
282
|
-
: response;
|
|
283
|
-
|
|
319
|
+
const payload = isJsxNode(response) ? await jsxToStream.call(context, response) : response;
|
|
284
320
|
// Post-process the payload with an optional response handler
|
|
285
321
|
const responseHandler = context["responseHandler"];
|
|
286
322
|
return typeof responseHandler === "function"
|
|
@@ -292,25 +328,33 @@ async function renderResponse(context: any, response: unknown) {
|
|
|
292
328
|
* Returns stream and metadata for requested file.
|
|
293
329
|
*/
|
|
294
330
|
async function tryFile(request: FastifyRequest): Promise<BaseSendResult | undefined> {
|
|
331
|
+
// Files can only be fetched; other methods (e.g. a POST to "/index.css") are left to the routes.
|
|
332
|
+
if (request.method !== "GET" && request.method !== "HEAD") {
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
|
|
295
336
|
// Production: Retrieve files only from pre-initialized mapping.
|
|
296
337
|
// This avoids potential path traversal vulnerabilities caused
|
|
297
338
|
// by unexpected `request.path` values.
|
|
298
339
|
const file = FILE_BY_PATH[request.path];
|
|
299
340
|
if (file) {
|
|
300
|
-
return await
|
|
341
|
+
return await sendFile(request, file);
|
|
301
342
|
}
|
|
302
343
|
|
|
303
344
|
if (NODE_ENV_IS_DEVELOPMENT) {
|
|
304
345
|
for (const directory of ["dist", "public"]) {
|
|
346
|
+
// Don't expose compiled server routes and their other outputs (sourcemaps, CSS).
|
|
347
|
+
// Only the last segment counts, like in the production build (see build.js).
|
|
348
|
+
// The suffix after "]." is not anchored, as some file systems ignore trailing
|
|
349
|
+
// dots and stream names (Windows: "[index].js.", "[index].js::$DATA").
|
|
350
|
+
if (directory === "dist" && /\[[^/]+\]\.[^/]*$/u.test(request.path)) {
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
305
353
|
try {
|
|
306
354
|
if ((await stat(join(CWD, directory, request.path))).isFile()) {
|
|
307
355
|
// Dynamic path loading is restricted to development environments;
|
|
308
356
|
// therefore, production-level path validation is not required here.
|
|
309
|
-
return await
|
|
310
|
-
request.raw,
|
|
311
|
-
`${directory}${request.path}`,
|
|
312
|
-
FASTIFY_SEND_OPTIONS,
|
|
313
|
-
);
|
|
357
|
+
return await sendFile(request, `${directory}${request.path}`);
|
|
314
358
|
}
|
|
315
359
|
} catch {
|
|
316
360
|
continue;
|
|
@@ -320,3 +364,22 @@ async function tryFile(request: FastifyRequest): Promise<BaseSendResult | undefi
|
|
|
320
364
|
|
|
321
365
|
return undefined;
|
|
322
366
|
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Prepares a file for sending. Returns undefined if there is nothing to send,
|
|
370
|
+
* so routes like [...path] or [404] can answer instead of the plain error page of fastifySend.
|
|
371
|
+
*/
|
|
372
|
+
async function sendFile(
|
|
373
|
+
request: FastifyRequest,
|
|
374
|
+
path: string,
|
|
375
|
+
): Promise<BaseSendResult | undefined> {
|
|
376
|
+
// fastifySend decodes the path, so it must be encoded.
|
|
377
|
+
const result = await fastifySend(request.raw, encodeURI(path), FASTIFY_SEND_OPTIONS);
|
|
378
|
+
// 404: file is missing or ignored, e.g. dotfiles by default.
|
|
379
|
+
// Other errors (403 for denied dotfiles, 412, 416 for invalid ranges) belong to the file.
|
|
380
|
+
if (result.type === "error" && result.statusCode === 404) {
|
|
381
|
+
result.stream.destroy();
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
return result;
|
|
385
|
+
}
|
package/start.js
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import serverless from "./serverless.js";
|
|
2
2
|
|
|
3
3
|
await serverless.listen({
|
|
4
|
-
|
|
4
|
+
// The dev server is only reachable from this machine, unless HOST is set (e.g. HOST=:: in a container).
|
|
5
|
+
host: process.env.HOST || (process.env.NODE_ENV === "development" ? "localhost" : "::"),
|
|
5
6
|
port: Number(process.env.PORT || 3000),
|
|
6
7
|
});
|
|
8
|
+
|
|
9
|
+
// Shut down gracefully on Ctrl+C (SIGINT) and SIGTERM. In a container, Node runs as PID 1,
|
|
10
|
+
// which ignores SIGINT unless a handler is registered. Exit explicitly afterwards, as other
|
|
11
|
+
// handles (e.g. the esbuild watcher in development) would keep the process running.
|
|
12
|
+
// "once": a second Ctrl+C kills the process if the shutdown hangs.
|
|
13
|
+
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
14
|
+
process.once(signal, async () => {
|
|
15
|
+
await serverless.close();
|
|
16
|
+
process.exit(0);
|
|
17
|
+
});
|
|
18
|
+
}
|