@jsenv/server 17.0.1 → 17.0.3

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.
@@ -3970,7 +3970,7 @@ const createPluginsController = async ({
3970
3970
  addPluginToHookSetMap(plugin);
3971
3971
  };
3972
3972
 
3973
- const pluginCandidates = plugins;
3973
+ const pluginCandidates = plugins.flat(Infinity);
3974
3974
  const activePlugins = [];
3975
3975
  const pluginsWithEffect = [];
3976
3976
 
@@ -5370,25 +5370,24 @@ const createResourcePattern = (pattern) => {
5370
5370
  });
5371
5371
  }
5372
5372
 
5373
- const patternEndsWithSlash =
5374
- pathnamePatternString.length > 1 && pathnamePatternString.endsWith("/");
5373
+ const patternEndsWithSlash = pathnamePatternString.endsWith("/");
5375
5374
 
5376
5375
  return {
5377
5376
  match: (resource) => {
5378
5377
  const [pathname, search, hash] = resourceToParts(resource);
5379
5378
  let decodedPathname = decodeURIComponent(pathname);
5380
- if (
5381
- patternEndsWithSlash &&
5382
- decodedPathname.startsWith(pathnamePatternString)
5383
- ) {
5384
- return {
5379
+ let result;
5380
+ if (patternEndsWithSlash && !searchPattern && !hashPattern) {
5381
+ if (!decodedPathname.startsWith(pathnamePatternString)) {
5382
+ return null;
5383
+ }
5384
+ result = {
5385
5385
  named: {},
5386
5386
  stars: [decodedPathname.slice(pathnamePatternString.length)],
5387
5387
  };
5388
+ } else {
5389
+ result = pathnamePattern.match(decodedPathname);
5388
5390
  }
5389
- let result = patternEndsWithSlash
5390
- ? null
5391
- : pathnamePattern.match(decodedPathname);
5392
5391
  if (!result) {
5393
5392
  return null;
5394
5393
  }
@@ -8029,6 +8028,76 @@ const bufferToEtag = (buffer) => {
8029
8028
  return `"${length.toString(16)}-${hashBase64StringSubset}"`;
8030
8029
  };
8031
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
+
8032
8101
  const convertFileSystemErrorToResponseProperties = (error) => {
8033
8102
  // https://iojs.org/api/errors.html#errors_eacces_permission_denied
8034
8103
  if (isErrorWithCode(error, "EACCES")) {
@@ -8114,76 +8183,6 @@ const fileSystemPathToUrl = (value) => {
8114
8183
  return String(pathToFileURL(value));
8115
8184
  };
8116
8185
 
8117
- const serveDirectory = (
8118
- url,
8119
- { headers = {}, rootDirectoryUrl } = {},
8120
- ) => {
8121
- url = String(url);
8122
- url = url[url.length - 1] === "/" ? url : `${url}/`;
8123
- const directoryContentArray = readdirSync(new URL(url));
8124
- const responseProducers = {
8125
- "application/json": () => {
8126
- const directoryContentJson = JSON.stringify(
8127
- directoryContentArray,
8128
- null,
8129
- " ",
8130
- );
8131
- return {
8132
- status: 200,
8133
- headers: {
8134
- "content-type": "application/json",
8135
- "content-length": directoryContentJson.length,
8136
- },
8137
- body: directoryContentJson,
8138
- };
8139
- },
8140
- "text/html": () => {
8141
- const directoryAsHtml = `<!DOCTYPE html>
8142
- <html>
8143
- <head>
8144
- <title>Directory explorer</title>
8145
- <meta charset="utf-8" />
8146
- <link rel="icon" href="data:," />
8147
- </head>
8148
-
8149
- <body>
8150
- <h1>Content of directory ${url}</h1>
8151
- <ul>
8152
- ${directoryContentArray.map((filename) => {
8153
- const fileUrlObject = new URL(filename, url);
8154
- const fileUrl = String(fileUrlObject);
8155
- let fileUrlRelativeToServer = fileUrl.slice(
8156
- String(rootDirectoryUrl).length,
8157
- );
8158
- if (lstatSync(fileUrlObject).isDirectory()) {
8159
- fileUrlRelativeToServer += "/";
8160
- }
8161
- return `<li>
8162
- <a href="/${fileUrlRelativeToServer}">${fileUrlRelativeToServer}</a>
8163
- </li>`;
8164
- }).join(`
8165
- `)}
8166
- </ul>
8167
- </body>
8168
- </html>`;
8169
-
8170
- return {
8171
- status: 200,
8172
- headers: {
8173
- "content-type": "text/html",
8174
- "content-length": Buffer.byteLength(directoryAsHtml),
8175
- },
8176
- body: directoryAsHtml,
8177
- };
8178
- },
8179
- };
8180
- const bestContentType = pickContentType(
8181
- { headers },
8182
- Object.keys(responseProducers),
8183
- );
8184
- return responseProducers[bestContentType || "application/json"]();
8185
- };
8186
-
8187
8186
  /*
8188
8187
  * This function returns response properties in a plain object like
8189
8188
  * { status: 200, body: "Hello world" }.
@@ -8202,17 +8201,17 @@ const fetchFileSystem = async (
8202
8201
  helpers,
8203
8202
  directoryUrl,
8204
8203
  {
8204
+ mainFileRelativeUrl = null,
8205
8205
  etagEnabled = false,
8206
8206
  etagMemory = true,
8207
8207
  etagMemoryMaxSize = 1000,
8208
8208
  mtimeEnabled = false,
8209
8209
  compressionEnabled = false,
8210
8210
  compressionSizeThreshold = 1024,
8211
- cacheControl = etagEnabled || mtimeEnabled
8212
- ? "private,max-age=0,must-revalidate"
8213
- : "no-store",
8211
+ cacheControl,
8212
+ isVersioned = defaultIsVersioned,
8214
8213
  canReadDirectory = false,
8215
- ENOENTFallback = () => {},
8214
+ ENOENTFallback = () => null,
8216
8215
  } = {},
8217
8216
  ) => {
8218
8217
  let directoryUrlString = asUrlString(directoryUrl);
@@ -8238,7 +8237,11 @@ const fetchFileSystem = async (
8238
8237
  const filesystemUrl = new URL(resource, directoryUrl);
8239
8238
  const urlString = asUrlString(filesystemUrl);
8240
8239
 
8241
- 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") {
8242
8245
  cacheControl = cacheControl(request);
8243
8246
  }
8244
8247
 
@@ -8274,12 +8277,15 @@ const fetchFileSystem = async (
8274
8277
 
8275
8278
  if (fileStat.isDirectory()) {
8276
8279
  if (canReadDirectory) {
8277
- return serveDirectory(fileUrl, {
8280
+ return fetchDirectory(fileUrl, {
8278
8281
  headers: request.headers,
8279
8282
  canReadDirectory,
8280
8283
  rootDirectoryUrl: directoryUrl,
8281
8284
  });
8282
8285
  }
8286
+ if (mainFileRelativeUrl && fileUrl === directoryUrlString) {
8287
+ return serveFile(new URL(mainFileRelativeUrl, directoryUrl).href);
8288
+ }
8283
8289
  return {
8284
8290
  status: 403,
8285
8291
  statusText: "not allowed to read directory",
@@ -8350,6 +8356,15 @@ const fetchFileSystem = async (
8350
8356
  return composeTwoResponses(intermediateResponse, clientCacheResponse);
8351
8357
  } catch (e) {
8352
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
+ }
8353
8368
  const fallbackFileUrl = ENOENTFallback();
8354
8369
  if (fallbackFileUrl) {
8355
8370
  return serveFile(fallbackFileUrl);
@@ -8621,50 +8636,15 @@ const asUrlString = (value) => {
8621
8636
  return null;
8622
8637
  };
8623
8638
 
8624
- const serverPluginStaticFiles = ({
8625
- serverRelativeUrl = "/",
8626
- directoryUrl,
8627
- directoryMainFileRelativeUrl = "index.html",
8628
- }) => {
8629
- return {
8630
- name: "jsenv:static_files",
8631
- routes: [
8632
- {
8633
- endpoint: `GET ${serverRelativeUrl}`,
8634
- description: "Serve static files.",
8635
- fetch: (request, helpers) => {
8636
- const urlIsVersioned = new URL(request.url).searchParams.has("v");
8637
- if (directoryMainFileRelativeUrl && request.resource === "/") {
8638
- request = {
8639
- ...request,
8640
- resource: `/${directoryMainFileRelativeUrl}`,
8641
- };
8642
- }
8643
- const urlObject = new URL(request.resource.slice(1), directoryUrl);
8644
- return createFileSystemFetch(directoryUrl, {
8645
- cacheControl: urlIsVersioned
8646
- ? `private,max-age=${SECONDS_IN_30_DAYS},immutable`
8647
- : "private,max-age=0,must-revalidate",
8648
- etagEnabled: true,
8649
- compressionEnabled: true,
8650
- canReadDirectory: true,
8651
- ENOENTFallback: () => {
8652
- if (
8653
- !urlToExtension(urlObject) &&
8654
- !urlToPathname(urlObject).endsWith("/")
8655
- ) {
8656
- return new URL(directoryMainFileRelativeUrl, directoryUrl);
8657
- }
8658
- return null;
8659
- },
8660
- })(request, helpers);
8661
- },
8662
- },
8663
- ],
8664
- };
8665
- };
8666
8639
  const SECONDS_IN_30_DAYS = 60 * 60 * 24 * 30;
8667
8640
 
8641
+ const defaultIsVersioned = (request) => {
8642
+ if (!request.url) {
8643
+ return false;
8644
+ }
8645
+ return new URL(request.url).searchParams.has("v");
8646
+ };
8647
+
8668
8648
  const serverPluginResponseAcceptanceCheck = () => {
8669
8649
  return {
8670
8650
  name: "jsenv:response_acceptance_check",
@@ -8754,4 +8734,4 @@ const replaceResource = (resourceBeforeAlias, newValue) => {
8754
8734
  return resource;
8755
8735
  };
8756
8736
 
8757
- 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.1",
3
+ "version": "17.0.3",
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
+ };
@@ -73,7 +73,7 @@ export const createPluginsController = async ({
73
73
  addPluginToHookSetMap(plugin);
74
74
  };
75
75
 
76
- const pluginCandidates = plugins;
76
+ const pluginCandidates = plugins.flat(Infinity);
77
77
  const activePlugins = [];
78
78
  const pluginsWithEffect = [];
79
79
 
@@ -1,48 +0,0 @@
1
- import { urlToExtension, urlToPathname } from "@jsenv/urls";
2
-
3
- import { createFileSystemFetch } from "./fetch_filesystem.js";
4
-
5
- export const serverPluginStaticFiles = ({
6
- serverRelativeUrl = "/",
7
- directoryUrl,
8
- directoryMainFileRelativeUrl = "index.html",
9
- }) => {
10
- return {
11
- name: "jsenv:static_files",
12
- routes: [
13
- {
14
- endpoint: `GET ${serverRelativeUrl}`,
15
- description: "Serve static files.",
16
- fetch: (request, helpers) => {
17
- const urlIsVersioned = new URL(request.url).searchParams.has("v");
18
- if (directoryMainFileRelativeUrl && request.resource === "/") {
19
- request = {
20
- ...request,
21
- resource: `/${directoryMainFileRelativeUrl}`,
22
- };
23
- }
24
- const urlObject = new URL(request.resource.slice(1), directoryUrl);
25
- return createFileSystemFetch(directoryUrl, {
26
- cacheControl: urlIsVersioned
27
- ? `private,max-age=${SECONDS_IN_30_DAYS},immutable`
28
- : "private,max-age=0,must-revalidate",
29
- etagEnabled: true,
30
- compressionEnabled: true,
31
- rootDirectoryUrl: directoryUrl,
32
- canReadDirectory: true,
33
- ENOENTFallback: () => {
34
- if (
35
- !urlToExtension(urlObject) &&
36
- !urlToPathname(urlObject).endsWith("/")
37
- ) {
38
- return new URL(directoryMainFileRelativeUrl, directoryUrl);
39
- }
40
- return null;
41
- },
42
- })(request, helpers);
43
- },
44
- },
45
- ],
46
- };
47
- };
48
- const SECONDS_IN_30_DAYS = 60 * 60 * 24 * 30;