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/serverless.ts CHANGED
@@ -1,38 +1,43 @@
1
1
  import fastifySend, { BaseSendResult, SendOptions } from "@fastify/send";
2
2
  import fastify, {
3
- FastifyInstance,
4
- FastifyReply,
5
- FastifyRequest,
6
- FastifyServerOptions,
3
+ FastifyInstance,
4
+ FastifyReply,
5
+ FastifyRequest,
6
+ FastifyServerOptions,
7
7
  } from "fastify";
8
- import { jsxToString } from "jsx-async-runtime";
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(`file://${join(CWD, "jeasx.config.js")}`)).default;
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(`file://${join(CWD, "dist", "[--metadata--].js")}`)).default;
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
- /** Path without query parameters */
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
- const FASTIFY_SEND_OPTIONS = CONFIG.FASTIFY_SEND_OPTIONS?.() as SendOptions;
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
- export default FASTIFY_SERVER(
56
- fastify(CONFIG.FASTIFY_SERVER_OPTIONS?.() as FastifyServerOptions)
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
- // Extract path from url
62
- const index = request.url.indexOf("?");
63
- request.path = index === -1 ? request.url : request.url.slice(0, index);
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
- try {
67
- const payload = await handler(request, reply);
68
- if (
69
- reply.getHeader("content-type") === undefined &&
70
- (typeof payload === "string" || Buffer.isBuffer(payload))
71
- ) {
72
- reply.type("text/html; charset=utf-8");
73
- }
74
- return payload;
75
- } catch (error) {
76
- request.log.error(error);
77
- throw error;
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 module based on the request path and execute it.
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
- try {
95
- // Check for static file and store result for later processing.
96
- reply.file = await tryFile(request);
97
-
98
- // Execute route handlers for current request
99
- for (const route of generateRoutes(request.path)) {
100
- // Try to serve static file when route matches path.
101
- if (route === request.path) {
102
- if (reply.file) {
103
- reply.status(reply.file.statusCode);
104
- reply.headers(reply.file.headers);
105
- response = reply.file.stream;
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
- // Resolve module or path to module
112
- let module = MODULE_BY_ROUTE[route];
133
+ // Resolve module or path to module
134
+ let module = MODULE_BY_ROUTE[route];
113
135
 
114
- // Skip processing if the route path was not initialized.
115
- if (module === undefined && !NODE_ENV_IS_DEVELOPMENT) {
116
- continue;
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
- // Module was not loaded yet?
120
- try {
121
- if (typeof module === "string") {
122
- // Production: Load and cache module only via pre-calculated path.
123
- // This avoids potential path traversal vulnerabilities caused
124
- // by unexpected `route` values.
125
- module = MODULE_BY_ROUTE[route] = await import(`file://${join(CWD, module)}`);
126
- } else if (module === undefined && NODE_ENV_IS_DEVELOPMENT) {
127
- // Only map module paths depending on `route` during development.
128
- const modulePath = join(CWD, "dist", `${route}.js`);
129
- if (typeof require === "function" && require.cache[modulePath]) {
130
- // Bun: Remove module from cache before importing
131
- // as query parameter for import is ignored.
132
- delete require.cache[modulePath];
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
- // Store current route in request.
153
- request.route = route;
154
-
155
- // Ensure module is a valid object before processing.
156
- if (module && typeof module === "object") {
157
- response =
158
- typeof module.default === "function"
159
- ? // Call functions with context as `this` and props as parameters,
160
- await module.default.call(context, props)
161
- : // otherwise return default export.
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
- if (reply.sent) {
166
- return;
167
- } else if (route.endsWith("/[404]")) {
168
- // Preserve existing status if a 404 page is requested directly.
169
- // If no status is defined, set status to 404 automatically.
170
- if (reply.statusCode === 200 && !request.path.endsWith("/404")) {
171
- reply.status(404);
172
- }
173
- break;
174
- } else if (
175
- typeof response === "string" ||
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
- return await renderResponse(context, response);
193
- } catch (error) {
194
- const errorHandler = context["errorHandler"];
195
- if (typeof errorHandler === "function") {
196
- reply.status(500);
197
- response = await errorHandler.call(context, error);
198
- return await renderResponse(context, response);
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
- throw error;
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
- * "/a/b/c",
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/", "/a/b/c"]
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 verbatim path for static file serving.
237
- yield path;
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
- if (lastSlash > 0) {
242
- yield `${edgeSegment.substring(0, lastSlash)}[${edgeSegment.substring(lastSlash)}]`;
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
- * Determines if a given object is a JSX element.
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 isJSX(obj: unknown): boolean {
261
- return !!obj && typeof obj === "object" && "type" in obj && "props" in obj;
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 to string and applies optional response handler.
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 = isJSX(response)
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 fastifySend(request.raw, file, FASTIFY_SEND_OPTIONS);
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 fastifySend(
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
- host: process.env.HOST || "::",
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
+ }