jeasx 2.10.6 → 3.0.0
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 +1454 -0
- package/README.md +28 -2
- package/build.js +37 -14
- package/cli.js +7 -1
- package/env.js +7 -1
- package/package.json +4 -4
- package/serverless.js +122 -100
- package/serverless.js.map +2 -2
- package/serverless.ts +225 -151
- package/start.js +13 -1
package/serverless.ts
CHANGED
|
@@ -1,38 +1,43 @@
|
|
|
1
1
|
import fastifySend, { BaseSendResult, SendOptions } from "@fastify/send";
|
|
2
2
|
import fastify, {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
3
|
+
FastifyInstance,
|
|
4
|
+
FastifyReply,
|
|
5
|
+
FastifyRequest,
|
|
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,43 +49,61 @@ 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
|
-
|
|
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");
|
|
78
97
|
}
|
|
98
|
+
return payload;
|
|
79
99
|
}),
|
|
80
100
|
);
|
|
81
101
|
|
|
102
|
+
// Used as marker for static file routes.
|
|
103
|
+
const STATIC_FILE_ROUTE = Symbol("static-file-route");
|
|
104
|
+
|
|
82
105
|
/**
|
|
83
|
-
* Resolves route
|
|
106
|
+
* Resolves route modules based on the request path and executes them.
|
|
84
107
|
*/
|
|
85
108
|
async function handler(request: FastifyRequest, reply: FastifyReply) {
|
|
86
109
|
let response: unknown;
|
|
@@ -91,117 +114,125 @@ async function handler(request: FastifyRequest, reply: FastifyReply) {
|
|
|
91
114
|
// Default props for route handlers
|
|
92
115
|
const props = { request, reply };
|
|
93
116
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
break;
|
|
107
|
-
}
|
|
108
|
-
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;
|
|
109
129
|
}
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
110
132
|
|
|
111
|
-
|
|
112
|
-
|
|
133
|
+
// Resolve module or path to module
|
|
134
|
+
let module = MODULE_BY_ROUTE[route];
|
|
113
135
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
136
|
+
// Skip processing if the route path was not initialized.
|
|
137
|
+
if (module === undefined && !NODE_ENV_IS_DEVELOPMENT) {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
118
140
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
}
|
|
134
|
-
// Use timestamp as query parameter to update modules.
|
|
135
|
-
const mtime = (await stat(modulePath)).mtime.getTime();
|
|
136
|
-
// Dynamic imports are restricted to development environments;
|
|
137
|
-
// therefore, production-level path validation is not required here.
|
|
138
|
-
module = await import(`file://${modulePath}?${mtime}`);
|
|
139
|
-
}
|
|
140
|
-
} catch (e) {
|
|
141
|
-
switch ((e as any)?.code) {
|
|
142
|
-
case "ENOENT":
|
|
143
|
-
case "ENOTDIR":
|
|
144
|
-
case "ERR_MODULE_NOT_FOUND":
|
|
145
|
-
continue;
|
|
146
|
-
default:
|
|
147
|
-
// Module exists, but fails to load.
|
|
148
|
-
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];
|
|
149
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}`);
|
|
150
161
|
}
|
|
151
|
-
|
|
152
|
-
//
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
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;
|
|
163
173
|
}
|
|
174
|
+
}
|
|
164
175
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
response instanceof Readable ||
|
|
177
|
-
Buffer.isBuffer(response) ||
|
|
178
|
-
isJSX(response)
|
|
179
|
-
) {
|
|
180
|
-
break;
|
|
181
|
-
} else if (typeof response === "object" && route.endsWith("/[...guard]")) {
|
|
182
|
-
// Add object entries from guard to props
|
|
183
|
-
Object.assign(props, response);
|
|
184
|
-
continue;
|
|
185
|
-
} else if (response === undefined || reply.statusCode === 404) {
|
|
186
|
-
continue;
|
|
187
|
-
} else {
|
|
188
|
-
break;
|
|
189
|
-
}
|
|
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;
|
|
190
187
|
}
|
|
191
188
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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(/\/$/, ""))
|
|
198
|
+
) {
|
|
199
|
+
reply.status(404);
|
|
200
|
+
}
|
|
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 === undefined) {
|
|
218
|
+
continue;
|
|
199
219
|
} else {
|
|
200
|
-
|
|
220
|
+
break;
|
|
201
221
|
}
|
|
202
222
|
}
|
|
223
|
+
// No route answered and no [404] exists: don't reply with an empty 200.
|
|
224
|
+
if (response === undefined && reply.statusCode === 200) {
|
|
225
|
+
reply.status(404);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return await renderResponse(context, response);
|
|
203
229
|
}
|
|
204
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
|
+
|
|
205
236
|
/**
|
|
206
237
|
* Generates all possible routes based on the given input path.
|
|
207
238
|
*
|
|
@@ -209,15 +240,15 @@ async function handler(request: FastifyRequest, reply: FastifyReply) {
|
|
|
209
240
|
*
|
|
210
241
|
* [
|
|
211
242
|
* "/[...guard]","/a/[...guard]","/a/b/[...guard]","/a/b/c/[...guard]",
|
|
212
|
-
*
|
|
243
|
+
* STATIC_FILE_ROUTE,
|
|
213
244
|
* "/a/b/[c]","/a/b/c/[index]",
|
|
214
245
|
* "/a/b/c/[...path]","/a/b/[...path]","/a/[...path]","/[...path]",
|
|
215
246
|
* "/a/b/c/[404]","/a/b/[404]","/a/[404]","/[404]"
|
|
216
247
|
* ]
|
|
217
248
|
*/
|
|
218
|
-
function* generateRoutes(path: string): Generator<string> {
|
|
249
|
+
function* generateRoutes(path: string): Generator<string | typeof STATIC_FILE_ROUTE> {
|
|
219
250
|
// Transform given path into array of all its segments.
|
|
220
|
-
// "/a/b/c" => ["", "/a", "/a/b
|
|
251
|
+
// "/a/b/c" => ["", "/a", "/a/b", "/a/b/c"]
|
|
221
252
|
const segments = [""];
|
|
222
253
|
let edgeSegment = "";
|
|
223
254
|
for (const segment of path.split("/")) {
|
|
@@ -233,13 +264,18 @@ function* generateRoutes(path: string): Generator<string> {
|
|
|
233
264
|
yield `${segments[i]}/[...guard]`;
|
|
234
265
|
}
|
|
235
266
|
|
|
236
|
-
// Append the
|
|
237
|
-
yield
|
|
267
|
+
// Append the marker for static files
|
|
268
|
+
yield STATIC_FILE_ROUTE;
|
|
238
269
|
|
|
239
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.
|
|
240
273
|
const lastSlash = edgeSegment.lastIndexOf("/") + 1;
|
|
241
|
-
|
|
242
|
-
|
|
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}]`;
|
|
243
279
|
}
|
|
244
280
|
yield `${edgeSegment}/[index]`;
|
|
245
281
|
|
|
@@ -255,20 +291,32 @@ function* generateRoutes(path: string): Generator<string> {
|
|
|
255
291
|
}
|
|
256
292
|
|
|
257
293
|
/**
|
|
258
|
-
*
|
|
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.
|
|
259
300
|
*/
|
|
260
|
-
function
|
|
261
|
-
|
|
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
|
+
}
|
|
262
313
|
}
|
|
263
314
|
|
|
264
315
|
/**
|
|
265
|
-
* Renders JSX
|
|
316
|
+
* Renders JSX as stream and applies the optional response handler to the response.
|
|
266
317
|
*/
|
|
267
318
|
async function renderResponse(context: any, response: unknown) {
|
|
268
|
-
const payload =
|
|
269
|
-
? await jsxToString.call(context, response as JSX.Element)
|
|
270
|
-
: response;
|
|
271
|
-
|
|
319
|
+
const payload = isJsxNode(response) ? await jsxToStream.call(context, response) : response;
|
|
272
320
|
// Post-process the payload with an optional response handler
|
|
273
321
|
const responseHandler = context["responseHandler"];
|
|
274
322
|
return typeof responseHandler === "function"
|
|
@@ -280,25 +328,32 @@ async function renderResponse(context: any, response: unknown) {
|
|
|
280
328
|
* Returns stream and metadata for requested file.
|
|
281
329
|
*/
|
|
282
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
|
+
|
|
283
336
|
// Production: Retrieve files only from pre-initialized mapping.
|
|
284
337
|
// This avoids potential path traversal vulnerabilities caused
|
|
285
338
|
// by unexpected `request.path` values.
|
|
286
339
|
const file = FILE_BY_PATH[request.path];
|
|
287
340
|
if (file) {
|
|
288
|
-
return await
|
|
341
|
+
return await sendFile(request, file);
|
|
289
342
|
}
|
|
290
343
|
|
|
291
344
|
if (NODE_ENV_IS_DEVELOPMENT) {
|
|
292
345
|
for (const directory of ["dist", "public"]) {
|
|
346
|
+
// Don't expose compiled server routes and their other outputs (sourcemaps, CSS).
|
|
347
|
+
// Not anchored at the end, as some file systems ignore trailing dots and
|
|
348
|
+
// stream names (Windows: "[index].js.", "[index].js::$DATA").
|
|
349
|
+
if (directory === "dist" && /\]\./u.test(request.path)) {
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
293
352
|
try {
|
|
294
353
|
if ((await stat(join(CWD, directory, request.path))).isFile()) {
|
|
295
354
|
// Dynamic path loading is restricted to development environments;
|
|
296
355
|
// therefore, production-level path validation is not required here.
|
|
297
|
-
return await
|
|
298
|
-
request.raw,
|
|
299
|
-
`${directory}${request.path}`,
|
|
300
|
-
FASTIFY_SEND_OPTIONS,
|
|
301
|
-
);
|
|
356
|
+
return await sendFile(request, `${directory}${request.path}`);
|
|
302
357
|
}
|
|
303
358
|
} catch {
|
|
304
359
|
continue;
|
|
@@ -308,3 +363,22 @@ async function tryFile(request: FastifyRequest): Promise<BaseSendResult | undefi
|
|
|
308
363
|
|
|
309
364
|
return undefined;
|
|
310
365
|
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Prepares a file for sending. Returns undefined if there is nothing to send,
|
|
369
|
+
* so routes like [...path] or [404] can answer instead of the plain error page of fastifySend.
|
|
370
|
+
*/
|
|
371
|
+
async function sendFile(
|
|
372
|
+
request: FastifyRequest,
|
|
373
|
+
path: string,
|
|
374
|
+
): Promise<BaseSendResult | undefined> {
|
|
375
|
+
// fastifySend decodes the path, so it must be encoded.
|
|
376
|
+
const result = await fastifySend(request.raw, encodeURI(path), FASTIFY_SEND_OPTIONS);
|
|
377
|
+
// 404: file is missing or ignored, e.g. dotfiles by default.
|
|
378
|
+
// Other errors (403 for denied dotfiles, 412, 416 for invalid ranges) belong to the file.
|
|
379
|
+
if (result.type === "error" && result.statusCode === 404) {
|
|
380
|
+
result.stream.destroy();
|
|
381
|
+
return undefined;
|
|
382
|
+
}
|
|
383
|
+
return result;
|
|
384
|
+
}
|
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
|
+
}
|