@jsenv/server 17.0.0 → 17.0.2

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.
@@ -5370,10 +5370,24 @@ const createResourcePattern = (pattern) => {
5370
5370
  });
5371
5371
  }
5372
5372
 
5373
+ const patternEndsWithSlash = pathnamePatternString.endsWith("/");
5374
+
5373
5375
  return {
5374
5376
  match: (resource) => {
5375
5377
  const [pathname, search, hash] = resourceToParts(resource);
5376
- let result = pathnamePattern.match(decodeURIComponent(pathname));
5378
+ let decodedPathname = decodeURIComponent(pathname);
5379
+ let result;
5380
+ if (patternEndsWithSlash && !searchPattern && !hashPattern) {
5381
+ if (!decodedPathname.startsWith(pathnamePatternString)) {
5382
+ return null;
5383
+ }
5384
+ result = {
5385
+ named: {},
5386
+ stars: [decodedPathname.slice(pathnamePatternString.length)],
5387
+ };
5388
+ } else {
5389
+ result = pathnamePattern.match(decodedPathname);
5390
+ }
5377
5391
  if (!result) {
5378
5392
  return null;
5379
5393
  }
@@ -8014,6 +8028,76 @@ const bufferToEtag = (buffer) => {
8014
8028
  return `"${length.toString(16)}-${hashBase64StringSubset}"`;
8015
8029
  };
8016
8030
 
8031
+ const fetchDirectory = (
8032
+ url,
8033
+ { headers = {}, rootDirectoryUrl } = {},
8034
+ ) => {
8035
+ url = String(url);
8036
+ url = url[url.length - 1] === "/" ? url : `${url}/`;
8037
+ const directoryContentArray = readdirSync(new URL(url));
8038
+ const responseProducers = {
8039
+ "application/json": () => {
8040
+ const directoryContentJson = JSON.stringify(
8041
+ directoryContentArray,
8042
+ null,
8043
+ " ",
8044
+ );
8045
+ return {
8046
+ status: 200,
8047
+ headers: {
8048
+ "content-type": "application/json",
8049
+ "content-length": directoryContentJson.length,
8050
+ },
8051
+ body: directoryContentJson,
8052
+ };
8053
+ },
8054
+ "text/html": () => {
8055
+ const directoryAsHtml = `<!DOCTYPE html>
8056
+ <html>
8057
+ <head>
8058
+ <title>Directory explorer</title>
8059
+ <meta charset="utf-8" />
8060
+ <link rel="icon" href="data:," />
8061
+ </head>
8062
+
8063
+ <body>
8064
+ <h1>Content of directory ${url}</h1>
8065
+ <ul>
8066
+ ${directoryContentArray.map((filename) => {
8067
+ const fileUrlObject = new URL(filename, url);
8068
+ const fileUrl = String(fileUrlObject);
8069
+ let fileUrlRelativeToServer = fileUrl.slice(
8070
+ String(rootDirectoryUrl).length,
8071
+ );
8072
+ if (lstatSync(fileUrlObject).isDirectory()) {
8073
+ fileUrlRelativeToServer += "/";
8074
+ }
8075
+ return `<li>
8076
+ <a href="/${fileUrlRelativeToServer}">${fileUrlRelativeToServer}</a>
8077
+ </li>`;
8078
+ }).join(`
8079
+ `)}
8080
+ </ul>
8081
+ </body>
8082
+ </html>`;
8083
+
8084
+ return {
8085
+ status: 200,
8086
+ headers: {
8087
+ "content-type": "text/html",
8088
+ "content-length": Buffer.byteLength(directoryAsHtml),
8089
+ },
8090
+ body: directoryAsHtml,
8091
+ };
8092
+ },
8093
+ };
8094
+ const bestContentType = pickContentType(
8095
+ { headers },
8096
+ Object.keys(responseProducers),
8097
+ );
8098
+ return responseProducers[bestContentType || "application/json"]();
8099
+ };
8100
+
8017
8101
  const convertFileSystemErrorToResponseProperties = (error) => {
8018
8102
  // https://iojs.org/api/errors.html#errors_eacces_permission_denied
8019
8103
  if (isErrorWithCode(error, "EACCES")) {
@@ -8099,76 +8183,6 @@ const fileSystemPathToUrl = (value) => {
8099
8183
  return String(pathToFileURL(value));
8100
8184
  };
8101
8185
 
8102
- const serveDirectory = (
8103
- url,
8104
- { headers = {}, rootDirectoryUrl } = {},
8105
- ) => {
8106
- url = String(url);
8107
- url = url[url.length - 1] === "/" ? url : `${url}/`;
8108
- const directoryContentArray = readdirSync(new URL(url));
8109
- const responseProducers = {
8110
- "application/json": () => {
8111
- const directoryContentJson = JSON.stringify(
8112
- directoryContentArray,
8113
- null,
8114
- " ",
8115
- );
8116
- return {
8117
- status: 200,
8118
- headers: {
8119
- "content-type": "application/json",
8120
- "content-length": directoryContentJson.length,
8121
- },
8122
- body: directoryContentJson,
8123
- };
8124
- },
8125
- "text/html": () => {
8126
- const directoryAsHtml = `<!DOCTYPE html>
8127
- <html>
8128
- <head>
8129
- <title>Directory explorer</title>
8130
- <meta charset="utf-8" />
8131
- <link rel="icon" href="data:," />
8132
- </head>
8133
-
8134
- <body>
8135
- <h1>Content of directory ${url}</h1>
8136
- <ul>
8137
- ${directoryContentArray.map((filename) => {
8138
- const fileUrlObject = new URL(filename, url);
8139
- const fileUrl = String(fileUrlObject);
8140
- let fileUrlRelativeToServer = fileUrl.slice(
8141
- String(rootDirectoryUrl).length,
8142
- );
8143
- if (lstatSync(fileUrlObject).isDirectory()) {
8144
- fileUrlRelativeToServer += "/";
8145
- }
8146
- return `<li>
8147
- <a href="/${fileUrlRelativeToServer}">${fileUrlRelativeToServer}</a>
8148
- </li>`;
8149
- }).join(`
8150
- `)}
8151
- </ul>
8152
- </body>
8153
- </html>`;
8154
-
8155
- return {
8156
- status: 200,
8157
- headers: {
8158
- "content-type": "text/html",
8159
- "content-length": Buffer.byteLength(directoryAsHtml),
8160
- },
8161
- body: directoryAsHtml,
8162
- };
8163
- },
8164
- };
8165
- const bestContentType = pickContentType(
8166
- { headers },
8167
- Object.keys(responseProducers),
8168
- );
8169
- return responseProducers[bestContentType || "application/json"]();
8170
- };
8171
-
8172
8186
  /*
8173
8187
  * This function returns response properties in a plain object like
8174
8188
  * { status: 200, body: "Hello world" }.
@@ -8187,17 +8201,17 @@ const fetchFileSystem = async (
8187
8201
  helpers,
8188
8202
  directoryUrl,
8189
8203
  {
8204
+ mainFileRelativeUrl = null,
8190
8205
  etagEnabled = false,
8191
8206
  etagMemory = true,
8192
8207
  etagMemoryMaxSize = 1000,
8193
8208
  mtimeEnabled = false,
8194
8209
  compressionEnabled = false,
8195
8210
  compressionSizeThreshold = 1024,
8196
- cacheControl = etagEnabled || mtimeEnabled
8197
- ? "private,max-age=0,must-revalidate"
8198
- : "no-store",
8211
+ cacheControl,
8212
+ isVersioned = defaultIsVersioned,
8199
8213
  canReadDirectory = false,
8200
- ENOENTFallback = () => {},
8214
+ ENOENTFallback = () => null,
8201
8215
  } = {},
8202
8216
  ) => {
8203
8217
  let directoryUrlString = asUrlString(directoryUrl);
@@ -8223,7 +8237,11 @@ const fetchFileSystem = async (
8223
8237
  const filesystemUrl = new URL(resource, directoryUrl);
8224
8238
  const urlString = asUrlString(filesystemUrl);
8225
8239
 
8226
- if (typeof cacheControl === "function") {
8240
+ if (cacheControl === undefined) {
8241
+ cacheControl = isVersioned(request)
8242
+ ? `private,max-age=${SECONDS_IN_30_DAYS},immutable`
8243
+ : "private,max-age=0,must-revalidate";
8244
+ } else if (typeof cacheControl === "function") {
8227
8245
  cacheControl = cacheControl(request);
8228
8246
  }
8229
8247
 
@@ -8259,12 +8277,15 @@ const fetchFileSystem = async (
8259
8277
 
8260
8278
  if (fileStat.isDirectory()) {
8261
8279
  if (canReadDirectory) {
8262
- return serveDirectory(fileUrl, {
8280
+ return fetchDirectory(fileUrl, {
8263
8281
  headers: request.headers,
8264
8282
  canReadDirectory,
8265
8283
  rootDirectoryUrl: directoryUrl,
8266
8284
  });
8267
8285
  }
8286
+ if (mainFileRelativeUrl && fileUrl === directoryUrlString) {
8287
+ return serveFile(new URL(mainFileRelativeUrl, directoryUrl).href);
8288
+ }
8268
8289
  return {
8269
8290
  status: 403,
8270
8291
  statusText: "not allowed to read directory",
@@ -8335,6 +8356,15 @@ const fetchFileSystem = async (
8335
8356
  return composeTwoResponses(intermediateResponse, clientCacheResponse);
8336
8357
  } catch (e) {
8337
8358
  if (e.code === "ENOENT") {
8359
+ if (mainFileRelativeUrl) {
8360
+ if (
8361
+ !urlToExtension(fileUrl) &&
8362
+ !urlToPathname(fileUrl).endsWith("/")
8363
+ ) {
8364
+ const mainFileUrl = new URL(mainFileRelativeUrl, directoryUrl);
8365
+ return serveFile(mainFileUrl);
8366
+ }
8367
+ }
8338
8368
  const fallbackFileUrl = ENOENTFallback();
8339
8369
  if (fallbackFileUrl) {
8340
8370
  return serveFile(fallbackFileUrl);
@@ -8606,46 +8636,15 @@ const asUrlString = (value) => {
8606
8636
  return null;
8607
8637
  };
8608
8638
 
8609
- const serverPluginStaticFiles = ({ directoryUrl, mainFilePath }) => {
8610
- return {
8611
- name: "jsenv:static_files",
8612
- routes: [
8613
- {
8614
- endpoint: "GET *",
8615
- description: "Serve static files.",
8616
- fetch: (request, helpers) => {
8617
- const urlIsVersioned = new URL(request.url).searchParams.has("v");
8618
- if (mainFilePath && request.resource === "/") {
8619
- request = {
8620
- ...request,
8621
- resource: `/${mainFilePath}`,
8622
- };
8623
- }
8624
- const urlObject = new URL(request.resource.slice(1), directoryUrl);
8625
- return createFileSystemFetch(directoryUrl, {
8626
- cacheControl: urlIsVersioned
8627
- ? `private,max-age=${SECONDS_IN_30_DAYS},immutable`
8628
- : "private,max-age=0,must-revalidate",
8629
- etagEnabled: true,
8630
- compressionEnabled: true,
8631
- canReadDirectory: true,
8632
- ENOENTFallback: () => {
8633
- if (
8634
- !urlToExtension(urlObject) &&
8635
- !urlToPathname(urlObject).endsWith("/")
8636
- ) {
8637
- return new URL(mainFilePath, directoryUrl);
8638
- }
8639
- return null;
8640
- },
8641
- })(request, helpers);
8642
- },
8643
- },
8644
- ],
8645
- };
8646
- };
8647
8639
  const SECONDS_IN_30_DAYS = 60 * 60 * 24 * 30;
8648
8640
 
8641
+ const defaultIsVersioned = (request) => {
8642
+ if (!request.url) {
8643
+ return false;
8644
+ }
8645
+ return new URL(request.url).searchParams.has("v");
8646
+ };
8647
+
8649
8648
  const serverPluginResponseAcceptanceCheck = () => {
8650
8649
  return {
8651
8650
  name: "jsenv:response_acceptance_check",
@@ -8735,4 +8734,4 @@ const replaceResource = (resourceBeforeAlias, newValue) => {
8735
8734
  return resource;
8736
8735
  };
8737
8736
 
8738
- export { LazyServerEvents, ProgressiveResponse, STOP_REASON_INTERNAL_ERROR, STOP_REASON_NOT_SPECIFIED, STOP_REASON_PROCESS_BEFORE_EXIT, STOP_REASON_PROCESS_EXIT, STOP_REASON_PROCESS_SIGHUP, STOP_REASON_PROCESS_SIGINT, STOP_REASON_PROCESS_SIGTERM, ServerEvents, WebSocketResponse, composeTwoResponses, createFileSystemFetch, createPluginsController, fetchFileSystem, findFreePort, jsenvAccessControlAllowedHeaders, jsenvAccessControlAllowedMethods, pickContentEncoding, pickContentLanguage, pickContentType, serveDirectory, serverPluginCORS, serverPluginErrorHandler, serverPluginRequestAliases, serverPluginResponseAcceptanceCheck, serverPluginStaticFiles, startServer };
8737
+ export { LazyServerEvents, ProgressiveResponse, STOP_REASON_INTERNAL_ERROR, STOP_REASON_NOT_SPECIFIED, STOP_REASON_PROCESS_BEFORE_EXIT, STOP_REASON_PROCESS_EXIT, STOP_REASON_PROCESS_SIGHUP, STOP_REASON_PROCESS_SIGINT, STOP_REASON_PROCESS_SIGTERM, ServerEvents, WebSocketResponse, composeTwoResponses, createFileSystemFetch, createPluginsController, fetchDirectory, fetchFileSystem, findFreePort, jsenvAccessControlAllowedHeaders, jsenvAccessControlAllowedMethods, pickContentEncoding, pickContentLanguage, pickContentType, serverPluginCORS, serverPluginErrorHandler, serverPluginRequestAliases, serverPluginResponseAcceptanceCheck, startServer };
package/index.js CHANGED
@@ -37,9 +37,8 @@ export {
37
37
  export {
38
38
  createFileSystemFetch,
39
39
  fetchFileSystem,
40
- } from "./src/plugins/filesystem/fetch_filesystem.js";
41
- export { serveDirectory } from "./src/plugins/filesystem/serve_directory.js";
42
- export { serverPluginStaticFiles } from "./src/plugins/filesystem/server_plugin_static_files.js";
40
+ } from "./src/plugins/filesystem/fetch_file.js";
41
+ export { fetchDirectory } from "./src/plugins/filesystem/fetch_directory.js";
43
42
 
44
43
  // content-negotiation
45
44
  export { pickContentType } from "./src/content_negotiation/pick_content_type.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jsenv/server",
3
- "version": "17.0.0",
3
+ "version": "17.0.2",
4
4
  "description": "A modern Node.js HTTP server with declarative routing, content negotiation, and WebSocket support",
5
5
  "repository": {
6
6
  "type": "git",
@@ -2,7 +2,7 @@ import { lstatSync, readdirSync } from "node:fs";
2
2
 
3
3
  import { pickContentType } from "../../content_negotiation/pick_content_type.js";
4
4
 
5
- export const serveDirectory = (
5
+ export const fetchDirectory = (
6
6
  url,
7
7
  { headers = {}, rootDirectoryUrl } = {},
8
8
  ) => {
@@ -4,18 +4,19 @@
4
4
  * It is meant to be used inside "requestToResponse"
5
5
  */
6
6
 
7
+ import { urlToExtension, urlToPathname } from "@jsenv/urls";
7
8
  import { CONTENT_TYPE } from "@jsenv/utils/src/content_type/content_type.js";
8
9
  import { createReadStream, readFile, statSync } from "node:fs";
9
10
 
10
11
  import { pickContentEncoding } from "../../content_negotiation/pick_content_encoding.js";
11
12
  import { composeTwoResponses } from "../../internal/response_composition.js";
12
13
  import { bufferToEtag } from "./etag.js";
14
+ import { fetchDirectory } from "./fetch_directory.js";
13
15
  import { convertFileSystemErrorToResponseProperties } from "./filesystem_error_to_response.js";
14
16
  import {
15
17
  fileSystemPathToUrl,
16
18
  isFileSystemPath,
17
19
  } from "./filesystem_path_and_url.js";
18
- import { serveDirectory } from "./serve_directory.js";
19
20
 
20
21
  export const createFileSystemFetch = (directoryUrl, options) => {
21
22
  return (request, helpers) => {
@@ -28,17 +29,17 @@ export const fetchFileSystem = async (
28
29
  helpers,
29
30
  directoryUrl,
30
31
  {
32
+ mainFileRelativeUrl = null,
31
33
  etagEnabled = false,
32
34
  etagMemory = true,
33
35
  etagMemoryMaxSize = 1000,
34
36
  mtimeEnabled = false,
35
37
  compressionEnabled = false,
36
38
  compressionSizeThreshold = 1024,
37
- cacheControl = etagEnabled || mtimeEnabled
38
- ? "private,max-age=0,must-revalidate"
39
- : "no-store",
39
+ cacheControl,
40
+ isVersioned = defaultIsVersioned,
40
41
  canReadDirectory = false,
41
- ENOENTFallback = () => {},
42
+ ENOENTFallback = () => null,
42
43
  } = {},
43
44
  ) => {
44
45
  let directoryUrlString = asUrlString(directoryUrl);
@@ -64,7 +65,11 @@ export const fetchFileSystem = async (
64
65
  const filesystemUrl = new URL(resource, directoryUrl);
65
66
  const urlString = asUrlString(filesystemUrl);
66
67
 
67
- if (typeof cacheControl === "function") {
68
+ if (cacheControl === undefined) {
69
+ cacheControl = isVersioned(request)
70
+ ? `private,max-age=${SECONDS_IN_30_DAYS},immutable`
71
+ : "private,max-age=0,must-revalidate";
72
+ } else if (typeof cacheControl === "function") {
68
73
  cacheControl = cacheControl(request);
69
74
  }
70
75
 
@@ -100,12 +105,15 @@ export const fetchFileSystem = async (
100
105
 
101
106
  if (fileStat.isDirectory()) {
102
107
  if (canReadDirectory) {
103
- return serveDirectory(fileUrl, {
108
+ return fetchDirectory(fileUrl, {
104
109
  headers: request.headers,
105
110
  canReadDirectory,
106
111
  rootDirectoryUrl: directoryUrl,
107
112
  });
108
113
  }
114
+ if (mainFileRelativeUrl && fileUrl === directoryUrlString) {
115
+ return serveFile(new URL(mainFileRelativeUrl, directoryUrl).href);
116
+ }
109
117
  return {
110
118
  status: 403,
111
119
  statusText: "not allowed to read directory",
@@ -176,6 +184,15 @@ export const fetchFileSystem = async (
176
184
  return composeTwoResponses(intermediateResponse, clientCacheResponse);
177
185
  } catch (e) {
178
186
  if (e.code === "ENOENT") {
187
+ if (mainFileRelativeUrl) {
188
+ if (
189
+ !urlToExtension(fileUrl) &&
190
+ !urlToPathname(fileUrl).endsWith("/")
191
+ ) {
192
+ const mainFileUrl = new URL(mainFileRelativeUrl, directoryUrl);
193
+ return serveFile(mainFileUrl);
194
+ }
195
+ }
179
196
  const fallbackFileUrl = ENOENTFallback();
180
197
  if (fallbackFileUrl) {
181
198
  return serveFile(fallbackFileUrl);
@@ -446,3 +463,12 @@ const asUrlString = (value) => {
446
463
  }
447
464
  return null;
448
465
  };
466
+
467
+ const SECONDS_IN_30_DAYS = 60 * 60 * 24 * 30;
468
+
469
+ const defaultIsVersioned = (request) => {
470
+ if (!request.url) {
471
+ return false;
472
+ }
473
+ return new URL(request.url).searchParams.has("v");
474
+ };
@@ -1,44 +0,0 @@
1
- import { urlToExtension, urlToPathname } from "@jsenv/urls";
2
-
3
- import { createFileSystemFetch } from "./fetch_filesystem.js";
4
-
5
- export const serverPluginStaticFiles = ({ directoryUrl, mainFilePath }) => {
6
- return {
7
- name: "jsenv:static_files",
8
- routes: [
9
- {
10
- endpoint: "GET *",
11
- description: "Serve static files.",
12
- fetch: (request, helpers) => {
13
- const urlIsVersioned = new URL(request.url).searchParams.has("v");
14
- if (mainFilePath && request.resource === "/") {
15
- request = {
16
- ...request,
17
- resource: `/${mainFilePath}`,
18
- };
19
- }
20
- const urlObject = new URL(request.resource.slice(1), directoryUrl);
21
- return createFileSystemFetch(directoryUrl, {
22
- cacheControl: urlIsVersioned
23
- ? `private,max-age=${SECONDS_IN_30_DAYS},immutable`
24
- : "private,max-age=0,must-revalidate",
25
- etagEnabled: true,
26
- compressionEnabled: true,
27
- rootDirectoryUrl: directoryUrl,
28
- canReadDirectory: true,
29
- ENOENTFallback: () => {
30
- if (
31
- !urlToExtension(urlObject) &&
32
- !urlToPathname(urlObject).endsWith("/")
33
- ) {
34
- return new URL(mainFilePath, directoryUrl);
35
- }
36
- return null;
37
- },
38
- })(request, helpers);
39
- },
40
- },
41
- ],
42
- };
43
- };
44
- const SECONDS_IN_30_DAYS = 60 * 60 * 24 * 30;