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/serverless.ts CHANGED
@@ -5,34 +5,39 @@ import fastify, {
5
5
  FastifyRequest,
6
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,46 +49,53 @@ 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
- let payload: unknown;
67
- try {
68
- payload = await handler(request, reply);
69
- if (
70
- reply.getHeader("content-type") === undefined &&
71
- (typeof payload === "string" || Buffer.isBuffer(payload))
72
- ) {
73
- reply.type("text/html; charset=utf-8");
74
- }
75
- return payload;
76
- } catch (error) {
77
- request.log.error(error);
78
- throw error;
79
- } finally {
80
- // Close file stream if it isn't sent and nobody started reading it,
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 module based on the request path and execute it.
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
- try {
106
- // Check for static file and store result for later processing.
107
- reply.file = await tryFile(request);
108
-
109
- // Execute route handlers for current request
110
- for (const route of generateRoutes(request.path)) {
111
- // Try to serve static file
112
- if (route === STATIC_FILE_ROUTE) {
113
- if (reply.file) {
114
- reply.status(reply.file.statusCode);
115
- reply.headers(reply.file.headers);
116
- response = reply.file.stream;
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
- // Resolve module or path to module
123
- let module = MODULE_BY_ROUTE[route];
133
+ // Resolve module or path to module
134
+ let module = MODULE_BY_ROUTE[route];
124
135
 
125
- // Skip processing if the route path was not initialized.
126
- if (module === undefined && !NODE_ENV_IS_DEVELOPMENT) {
127
- continue;
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
- // Module was not loaded yet?
131
- try {
132
- if (typeof module === "string") {
133
- // Production: Load and cache module only via pre-calculated path.
134
- // This avoids potential path traversal vulnerabilities caused
135
- // by unexpected `route` values.
136
- module = MODULE_BY_ROUTE[route] = await import(`file://${join(CWD, module)}`);
137
- } else if (module === undefined && NODE_ENV_IS_DEVELOPMENT) {
138
- // Only map module paths depending on `route` during development.
139
- const modulePath = join(CWD, "dist", `${route}.js`);
140
- if (typeof require === "function" && require.cache[modulePath]) {
141
- // Bun: Remove module from cache before importing
142
- // as query parameter for import is ignored.
143
- delete require.cache[modulePath];
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
- // Store current route in request.
164
- request.route = route;
165
-
166
- // Ensure module is a valid object before processing.
167
- if (module && typeof module === "object") {
168
- response =
169
- typeof module.default === "function"
170
- ? // Call functions with context as `this` and props as parameters,
171
- await module.default.call(context, props)
172
- : // otherwise return default export.
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
- if (reply.sent) {
177
- return;
178
- } else if (route.endsWith("/[404]")) {
179
- // Preserve existing status if a 404 page is requested directly.
180
- // If no status is defined, set status to 404 automatically.
181
- if (reply.statusCode === 200 && !request.path.endsWith("/404")) {
182
- reply.status(404);
183
- }
184
- break;
185
- } else if (
186
- typeof response === "string" ||
187
- response instanceof Response ||
188
- response instanceof Readable ||
189
- response instanceof ReadableStream ||
190
- Buffer.isBuffer(response) ||
191
- isJSX(response)
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
- break;
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
- return await renderResponse(context, response);
205
- } catch (error) {
206
- const errorHandler = context["errorHandler"];
207
- if (typeof errorHandler === "function") {
208
- reply.status(500);
209
- response = await errorHandler.call(context, error);
210
- return await renderResponse(context, response);
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
- throw error;
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
- * STATIC_FILE,
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/", "/a/b/c"]
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
- if (lastSlash > 0) {
254
- 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}]`;
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
- * 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.
271
300
  */
272
- function isJSX(obj: unknown): boolean {
273
- 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
+ }
274
313
  }
275
314
 
276
315
  /**
277
- * Renders JSX to string and applies optional response handler.
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 = isJSX(response)
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 fastifySend(request.raw, file, FASTIFY_SEND_OPTIONS);
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 fastifySend(
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
- 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
+ }