@jsenv/server 15.5.2 → 16.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.
Files changed (55) hide show
  1. package/README.md +79 -86
  2. package/dist/html/4xx.html +196 -0
  3. package/dist/html/500.html +17 -0
  4. package/dist/html/route_inspector.html +2435 -0
  5. package/dist/js/formidable.js +17 -0
  6. package/dist/js/vendors.js +2001 -0
  7. package/dist/js/ws.js +6863 -0
  8. package/dist/jsenv_server.js +8829 -0
  9. package/package.json +20 -9
  10. package/src/content_negotiation/pick_content_version.js +63 -0
  11. package/src/{fetch_filesystem.js → filesystem/fetch_filesystem.js} +57 -72
  12. package/src/{serve_directory.js → filesystem/serve_directory.js} +1 -2
  13. package/src/interfacing_with_node/from_node_request.js +455 -0
  14. package/src/interfacing_with_node/get_observable_value_type.js +43 -0
  15. package/src/interfacing_with_node/observable.js +91 -75
  16. package/src/interfacing_with_node/observable_from.js +34 -0
  17. package/src/interfacing_with_node/observable_from_file_handle.js +30 -0
  18. package/src/interfacing_with_node/observable_from_node_stream.js +44 -46
  19. package/src/interfacing_with_node/observable_from_node_web_readable_stream.js +32 -0
  20. package/src/interfacing_with_node/observable_from_promise.js +13 -0
  21. package/src/interfacing_with_node/write_node_response.js +80 -35
  22. package/src/internal/colorizeResponseStatus.js +2 -0
  23. package/src/internal/headers_composition.js +42 -15
  24. package/src/internal/multiple-header.js +27 -12
  25. package/src/internal/object_composition.js +2 -2
  26. package/src/internal/response_composition.js +16 -3
  27. package/src/main.js +15 -11
  28. package/src/progressive_response.js +38 -0
  29. package/src/replace_placeholder_in_html.js +12 -0
  30. package/src/request_body_handling.js +22 -0
  31. package/src/router/router.js +878 -0
  32. package/src/server_events.js +431 -0
  33. package/src/service_controller.js +79 -73
  34. package/src/services/autoreload_on_server_restart/client/autoreload_on_server_restart.js +125 -0
  35. package/src/services/autoreload_on_server_restart/jsenv_service_autoreload_on_server_restart.js +45 -0
  36. package/src/services/cors/jsenv_service_cors.js +10 -18
  37. package/src/services/default_body_4xx_5xx/client/4xx.html +65 -0
  38. package/src/services/default_body_4xx_5xx/jsenv_service_default_body_4xx_5xx.js +82 -0
  39. package/src/services/error_handler/client/500.html +17 -0
  40. package/src/services/error_handler/jsenv_service_error_handler.js +47 -28
  41. package/src/services/internal_client_files/jsenv_service_internal_client_files.js +34 -0
  42. package/src/services/open_file/jsenv_service_open_file.js +37 -0
  43. package/src/services/route_inspector/client/code_block.js +215 -0
  44. package/src/services/route_inspector/client/route_inspector.html +1363 -0
  45. package/src/services/route_inspector/jsenv_service_route_inspector.js +39 -0
  46. package/src/start_server.js +575 -607
  47. package/src/web_socket_response.js +53 -0
  48. package/src/from_fetch_response.js +0 -15
  49. package/src/interfacing_with_node/body.js +0 -81
  50. package/src/interfacing_with_node/request_factory.js +0 -125
  51. package/src/readRequestBody.js +0 -32
  52. package/src/server_timing/timing_measure.js +0 -26
  53. package/src/service_composition/path_to_regexp.js +0 -436
  54. package/src/service_composition/routing.js +0 -31
  55. package/src/sse/sse_room.js +0 -313
@@ -0,0 +1,125 @@
1
+ const css = /* css */ `
2
+ dialog {
3
+ opacity: 0;
4
+ transition: all 0.3s allow-discrete;
5
+ }
6
+ dialog::backdrop {
7
+ background-image: linear-gradient(
8
+ 45deg,
9
+ magenta,
10
+ rebeccapurple,
11
+ dodgerblue,
12
+ green
13
+ );
14
+ opacity: 0;
15
+ transition: all 0.3s allow-discrete;
16
+ }
17
+ dialog:open {
18
+ opacity: 1;
19
+ }
20
+ dialog:open::backdrop {
21
+ opacity: 0.75;
22
+ }`;
23
+
24
+ const html = /* html */ `<style>
25
+ ${css}
26
+ </style>
27
+ <dialog>
28
+ <p>Connection with server is lost</p>
29
+ <button autofocus name="reconnect">Retry</button>
30
+ </dialog>`;
31
+
32
+ class JsenvAutoreloadOnServerRestart extends HTMLElement {
33
+ constructor({ url } = {}) {
34
+ super();
35
+ const root = this.attachShadow({ mode: "open" });
36
+ root.innerHTML = html;
37
+
38
+ const dialog = root.querySelector("dialog");
39
+ const reconnectButton = dialog.querySelector('button[name="reconnect"]');
40
+
41
+ (async () => {
42
+ const connect = () => {
43
+ const websocket = new WebSocket(url);
44
+ const connectedPromise = new Promise((resolve) => {
45
+ websocket.onerror = () => {
46
+ resolve(null);
47
+ };
48
+ websocket.onopen = () => {
49
+ resolve(websocket);
50
+ };
51
+ });
52
+ return connectedPromise;
53
+ };
54
+
55
+ const initialWebSocket = await connect();
56
+ initialWebSocket.onclose = async () => {
57
+ console.info("connection to server lost, trying to reconnect");
58
+ const retry = async () => {
59
+ let attemptCount = 0;
60
+ let timeToWait = 100;
61
+ const tryToReconnect = async () => {
62
+ const newWebsocket = await connect();
63
+ if (newWebsocket) {
64
+ dialog.close();
65
+ console.info("reconnected to server, reloading the page...");
66
+ window.location.reload(true);
67
+ return;
68
+ }
69
+ attemptCount++;
70
+ if (attemptCount === 10) {
71
+ dialog.showModal();
72
+ reconnectButton.onclick = async () => {
73
+ reconnectButton.disabled = true;
74
+ const previousContext = reconnectButton.textContent;
75
+ reconnectButton.textContent = "Retrying...";
76
+ await retry();
77
+ reconnectButton.textContent = previousContext;
78
+ reconnectButton.disabled = false;
79
+ };
80
+ return;
81
+ }
82
+ timeToWait += 100;
83
+ await new Promise((resolve) => setTimeout(resolve, timeToWait));
84
+ await tryToReconnect();
85
+ };
86
+ await tryToReconnect();
87
+ };
88
+ retry();
89
+ };
90
+ })();
91
+ }
92
+ }
93
+ if (!customElements.get("jsenv-autoreload-on-server-restart")) {
94
+ customElements.define(
95
+ "jsenv-autoreload-on-server-restart",
96
+ JsenvAutoreloadOnServerRestart,
97
+ );
98
+ }
99
+
100
+ const jsenvAutoreloadOnServerRestartElement =
101
+ new JsenvAutoreloadOnServerRestart({
102
+ url: (() => {
103
+ const websocketScheme =
104
+ self.location.protocol === "https:" ? "wss" : "ws";
105
+ const websocketUrl = `${websocketScheme}://${self.location.host}/.internal/alive.websocket`;
106
+ return websocketUrl;
107
+ })(),
108
+ });
109
+
110
+ // script is injected into document.body
111
+ if (document.body) {
112
+ document.currentScript.parentNode.replaceChild(
113
+ jsenvAutoreloadOnServerRestartElement,
114
+ document.currentScript,
115
+ );
116
+ }
117
+ // script injected outisde document.body (likely in head)
118
+ else {
119
+ document.currentScript.parentNode.removeChild(document.currentScript);
120
+ document.onreadystatechange = () => {
121
+ if (document.readyState === "interactive") {
122
+ document.body.appendChild(jsenvAutoreloadOnServerRestartElement);
123
+ }
124
+ };
125
+ }
@@ -0,0 +1,45 @@
1
+ import { LazyServerEvents } from "../../server_events.js";
2
+
3
+ export const jsenvServiceAutoreloadOnRestart = () => {
4
+ const aliveServerEvents = new LazyServerEvents(() => {});
5
+
6
+ return {
7
+ name: "jsenv:autoreload_on_server_restart",
8
+
9
+ routes: [
10
+ {
11
+ endpoint: "GET /.internal/alive.websocket",
12
+ description:
13
+ "Client can connect to this websocket endpoint to detect when server connection is lost.",
14
+ declarationSource: import.meta.url,
15
+ /* eslint-disable no-undef */
16
+ clientCodeExample: () => {
17
+ const websocket = new WebSocket(
18
+ "ws://localhost/.internal/alive.websocket",
19
+ );
20
+ websocket.onclose = () => {
21
+ // server connection closed
22
+ window.location.reload();
23
+ };
24
+ },
25
+ fetch: aliveServerEvents.fetch,
26
+ },
27
+ {
28
+ endpoint: "GET /.internal/alive.eventsource",
29
+ description: `Client can connect to this eventsource endpoint to detect when server connection is lost.
30
+ This endpoint exists mostly to demo eventsource as there is already the websocket endpoint.`,
31
+ declarationSource: import.meta.url,
32
+ /* eslint-disable no-undef */
33
+ clientCodeExample: async () => {
34
+ const eventSource = new EventSource("/.internal/alive.eventsource");
35
+ eventSource.onerror = () => {
36
+ // server connection closed
37
+ window.location.reload();
38
+ };
39
+ },
40
+ /* eslint-enable no-undef */
41
+ fetch: aliveServerEvents.fetch,
42
+ },
43
+ ],
44
+ };
45
+ };
@@ -1,3 +1,8 @@
1
+ /**
2
+ * @jsenv/server is already registering a route to handle OPTIONS request
3
+ * so here we just need to add the CORS headers to the response
4
+ */
5
+
1
6
  export const jsenvAccessControlAllowedHeaders = ["x-requested-with"];
2
7
 
3
8
  export const jsenvAccessControlAllowedMethods = [
@@ -21,7 +26,7 @@ export const jsenvServiceCORS = ({
21
26
  accessControlMaxAge = 600,
22
27
  timingAllowOrigin = false,
23
28
  } = {}) => {
24
- // TODO: we should check access control params to throw or warn if we find strange values
29
+ // TODO: we should check access control params and throw/warn if we find strange values
25
30
 
26
31
  const corsEnabled =
27
32
  accessControlAllowRequestOrigin || accessControlAllowedOrigins.length;
@@ -32,22 +37,7 @@ export const jsenvServiceCORS = ({
32
37
 
33
38
  return {
34
39
  name: "jsenv:cors",
35
-
36
- handleRequest: (request) => {
37
- // when request method is "OPTIONS" we must return a 200 without body
38
- // So we bypass "requestToResponse" in that scenario using shortcircuitResponse
39
- if (request.method === "OPTIONS") {
40
- return {
41
- status: 200,
42
- headers: {
43
- "content-length": 0,
44
- },
45
- };
46
- }
47
- return null;
48
- },
49
-
50
- injectResponseHeaders: (response, { request }) => {
40
+ injectResponseProperties: (request) => {
51
41
  const accessControlHeaders = generateAccessControlHeaders({
52
42
  request,
53
43
  accessControlAllowedOrigins,
@@ -60,7 +50,9 @@ export const jsenvServiceCORS = ({
60
50
  accessControlMaxAge,
61
51
  timingAllowOrigin,
62
52
  });
63
- return accessControlHeaders;
53
+ return {
54
+ headers: accessControlHeaders,
55
+ };
64
56
  },
65
57
  };
66
58
  };
@@ -0,0 +1,65 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>${status} ${statusText}</title>
7
+ <style>
8
+ body {
9
+ font-family: Arial, sans-serif;
10
+ background-color: #f8f9fa;
11
+ color: #333;
12
+ display: flex;
13
+ align-items: center;
14
+ justify-content: center;
15
+ min-height: 100vh;
16
+ margin: 0;
17
+ }
18
+ .error_container {
19
+ text-align: center;
20
+ background: #fff;
21
+ padding: 20px;
22
+ border-radius: 8px;
23
+ box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
24
+ }
25
+ .error_status {
26
+ font-size: 5rem;
27
+ color: #dc3545;
28
+ margin: 0;
29
+ }
30
+ .error_status_text {
31
+ font-size: 1.5rem;
32
+ margin: 10px 0;
33
+ word-break: break-word;
34
+ text-align: left;
35
+ }
36
+ .error_message {
37
+ font-size: 1rem;
38
+ color: #555;
39
+ word-break: break-word;
40
+ text-align: left;
41
+ }
42
+ .home_link {
43
+ margin-top: 15px;
44
+ display: inline-block;
45
+ padding: 10px 15px;
46
+ background-color: #007bff;
47
+ color: #fff;
48
+ text-decoration: none;
49
+ border-radius: 5px;
50
+ }
51
+ .home_link:hover {
52
+ background-color: #0056b3;
53
+ }
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <div class="error_container">
58
+ <p class="error_status">${status}</p>
59
+ <p class="error_status_text">${statusText}</p>
60
+ <p class="error_message">${statusMessage}</p>
61
+ <a href="/" jsenv-ignore class="home_link">Return to Home</a>
62
+ </div>
63
+ <script src="/@jsenv/server/src/services/autoreload_on_server_restart/client/autoreload_on_server_restart.js?inline"></script>
64
+ </body>
65
+ </html>
@@ -0,0 +1,82 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { pickContentType } from "../../content_negotiation/pick_content_type.js";
3
+ import { replacePlaceholdersInHtml } from "../../replace_placeholder_in_html.js";
4
+
5
+ const clientErrorHtmlTemplateFileUrl = import.meta.resolve("./client/4xx.html");
6
+
7
+ export const jsenvServiceDefaultBody4xx5xx = () => {
8
+ return {
9
+ name: "jsenv:default_body_4xx_5xx",
10
+
11
+ injectResponseProperties: (request, responseProperties) => {
12
+ if (responseProperties.body !== undefined) {
13
+ return null;
14
+ }
15
+ if (responseProperties.status >= 400 && responseProperties.status < 500) {
16
+ return generateBadStatusResponse(request, responseProperties);
17
+ }
18
+ if (responseProperties.status >= 500 && responseProperties.status < 600) {
19
+ return generateBadStatusResponse(request, responseProperties);
20
+ }
21
+ return null;
22
+ },
23
+ };
24
+ };
25
+
26
+ const generateBadStatusResponse = (
27
+ request,
28
+ { status, statusText, statusMessage },
29
+ ) => {
30
+ const contentTypeNegotiated = pickContentType(request, [
31
+ "text/html",
32
+ "text/plain",
33
+ "application/json",
34
+ ]);
35
+ if (contentTypeNegotiated === "text/html") {
36
+ const htmlTemplate = readFileSync(
37
+ new URL(clientErrorHtmlTemplateFileUrl),
38
+ "utf8",
39
+ );
40
+ if (statusMessage) {
41
+ statusMessage = statusMessage.replace(/https?:\/\/\S+/g, (url) => {
42
+ return `<a href="${url}">${url}</a>`;
43
+ });
44
+ statusMessage = statusMessage.replace(
45
+ /(^|\s)(\/\S+)/g,
46
+ (match, startOrSpace, resource) => {
47
+ let end = "";
48
+ if (resource[resource.length - 1] === ".") {
49
+ resource = resource.slice(0, -1);
50
+ end = ".";
51
+ }
52
+ return `${startOrSpace}<a href="${resource}">${resource}</a>${end}`;
53
+ },
54
+ );
55
+ statusMessage = statusMessage.replace(/\r\n|\r|\n/g, "<br />");
56
+ }
57
+
58
+ const html = replacePlaceholdersInHtml(htmlTemplate, {
59
+ status,
60
+ statusText,
61
+ statusMessage: statusMessage || "",
62
+ });
63
+ return new Response(html, {
64
+ headers: { "content-type": "text/html" },
65
+ status,
66
+ statusText,
67
+ });
68
+ }
69
+ if (contentTypeNegotiated === "text/plain") {
70
+ return new Response(statusMessage, {
71
+ status,
72
+ statusText,
73
+ });
74
+ }
75
+ return Response.json(
76
+ { statusMessage },
77
+ {
78
+ status,
79
+ statusText,
80
+ },
81
+ );
82
+ };
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html>
3
+ <head>
4
+ <title>Internal server error</title>
5
+ <meta charset="utf-8" />
6
+ <link rel="icon" href="data:," />
7
+ </head>
8
+
9
+ <body>
10
+ <h1>Internal server error</h1>
11
+ <p>${errorMessage}</p>
12
+ <details>
13
+ <summary>See internal error details</summary>
14
+ ${errorDetailsContent}
15
+ </details>
16
+ </body>
17
+ </html>
@@ -1,4 +1,8 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { pickContentType } from "../../content_negotiation/pick_content_type.js";
3
+ import { replacePlaceholdersInHtml } from "../../replace_placeholder_in_html.js";
4
+
5
+ const internalErrorHtmlFileUrl = import.meta.resolve("./client/500.html");
2
6
 
3
7
  export const jsenvServiceErrorHandler = ({ sendErrorDetails = false } = {}) => {
4
8
  return {
@@ -31,7 +35,6 @@ export const jsenvServiceErrorHandler = ({ sendErrorDetails = false } = {}) => {
31
35
  const renderHtmlForErrorWithoutDetails = () => {
32
36
  return `<p>Details not available: to enable them use jsenvServiceErrorHandler({ sendErrorDetails: true }).</p>`;
33
37
  };
34
-
35
38
  const renderHtmlForErrorWithDetails = () => {
36
39
  if (serverInternalErrorIsAPrimitive) {
37
40
  return `<pre>${JSON.stringify(
@@ -43,38 +46,54 @@ export const jsenvServiceErrorHandler = ({ sendErrorDetails = false } = {}) => {
43
46
  return `<pre>${serverInternalError.stack}</pre>`;
44
47
  };
45
48
 
46
- const body = `<!DOCTYPE html>
47
- <html>
48
- <head>
49
- <title>Internal server error</title>
50
- <meta charset="utf-8" />
51
- <link rel="icon" href="data:," />
52
- </head>
53
-
54
- <body>
55
- <h1>Internal server error</h1>
56
- <p>${
57
- serverInternalErrorIsAPrimitive
58
- ? `Code inside server has thrown a literal.`
59
- : `Code inside server has thrown an error.`
60
- }</p>
61
- <details>
62
- <summary>See internal error details</summary>
63
- ${
64
- sendErrorDetails
65
- ? renderHtmlForErrorWithDetails()
66
- : renderHtmlForErrorWithoutDetails()
67
- }
68
- </details>
69
- </body>
70
- </html>`;
49
+ const internalErrorHtmlTemplate = readFileSync(
50
+ new URL(internalErrorHtmlFileUrl),
51
+ "utf8",
52
+ );
53
+ const internalErrorHtml = replacePlaceholdersInHtml(
54
+ internalErrorHtmlTemplate,
55
+ {
56
+ errorMessage: serverInternalErrorIsAPrimitive
57
+ ? `Code inside server has thrown a literal.`
58
+ : `Code inside server has thrown an error.`,
59
+ errorDetailsContent: sendErrorDetails
60
+ ? renderHtmlForErrorWithDetails()
61
+ : renderHtmlForErrorWithoutDetails(),
62
+ },
63
+ );
71
64
 
72
65
  return {
73
66
  headers: {
74
67
  "content-type": "text/html",
75
- "content-length": Buffer.byteLength(body),
68
+ "content-length": Buffer.byteLength(internalErrorHtml),
76
69
  },
77
- body,
70
+ body: internalErrorHtml,
71
+ };
72
+ },
73
+ "text/plain": () => {
74
+ let internalErrorMessage = serverInternalErrorIsAPrimitive
75
+ ? `Code inside server has thrown a literal:`
76
+ : `Code inside server has thrown an error:`;
77
+ if (sendErrorDetails) {
78
+ if (serverInternalErrorIsAPrimitive) {
79
+ internalErrorMessage += `\n${JSON.stringify(
80
+ serverInternalError,
81
+ null,
82
+ " ",
83
+ )}`;
84
+ } else {
85
+ internalErrorMessage += `\n${serverInternalError.stack}`;
86
+ }
87
+ } else {
88
+ internalErrorMessage += `\nDetails not available: to enable them use jsenvServiceErrorHandler({ sendErrorDetails: true }).`;
89
+ }
90
+
91
+ return {
92
+ headers: {
93
+ "content-type": "text/plain",
94
+ "content-length": Buffer.byteLength(internalErrorMessage),
95
+ },
96
+ body: internalErrorMessage,
78
97
  };
79
98
  },
80
99
  "application/json": () => {
@@ -0,0 +1,34 @@
1
+ import { CONTENT_TYPE } from "@jsenv/utils/src/content_type/content_type.js";
2
+ import { readFileSync } from "node:fs";
3
+
4
+ const jsenvServerRootDirectoryUrl = import.meta.resolve("../../../");
5
+
6
+ export const jsenvServiceInternalClientFiles = () => {
7
+ return {
8
+ name: "jsenv:internal_client_files",
9
+
10
+ routes: [
11
+ {
12
+ endpoint: "GET /@jsenv/server/*",
13
+ description: "Serve @jsenv/server internal files.",
14
+ availableMediaTypes: ["text/javascript"],
15
+ declarationSource: import.meta.url,
16
+ fetch: (request) => {
17
+ const path = request.params[0];
18
+ const jsenvServerClientFileUrl = new URL(
19
+ `./${path}`,
20
+ jsenvServerRootDirectoryUrl,
21
+ );
22
+ const fileContent = readFileSync(jsenvServerClientFileUrl, "utf8");
23
+ return new Response(fileContent, {
24
+ headers: {
25
+ "content-type": CONTENT_TYPE.fromUrlExtension(
26
+ jsenvServerClientFileUrl,
27
+ ),
28
+ },
29
+ });
30
+ },
31
+ },
32
+ ],
33
+ };
34
+ };
@@ -0,0 +1,37 @@
1
+ import { urlToFileSystemPath } from "@jsenv/urls";
2
+ import { createRequire } from "node:module";
3
+
4
+ export const jsenvServiceOpenFile = () => {
5
+ return {
6
+ name: "jsenv:open_file",
7
+ routes: [
8
+ {
9
+ endpoint: "GET /.internal/open_file/*",
10
+ description: "Can be used to open a given file in your editor.",
11
+ declarationSource: import.meta.url,
12
+ fetch: (request) => {
13
+ let file = decodeURIComponent(request.params[0]);
14
+ if (!file) {
15
+ return {
16
+ status: 400,
17
+ body: "Missing file in url",
18
+ };
19
+ }
20
+ const fileUrl = new URL(file);
21
+ const filePath = urlToFileSystemPath(fileUrl);
22
+ const require = createRequire(import.meta.url);
23
+ const launch = require("launch-editor");
24
+ launch(filePath, () => {
25
+ // ignore error for now
26
+ });
27
+ return {
28
+ status: 200,
29
+ headers: {
30
+ "cache-control": "no-store",
31
+ },
32
+ };
33
+ },
34
+ },
35
+ ],
36
+ };
37
+ };