@weborigami/origami 0.6.10 → 0.6.12
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/package.json +8 -8
- package/src/dev/changes.js +10 -4
- package/src/dev/debug2/ReadMe.md +29 -0
- package/src/dev/debug2/debug2.js +301 -0
- package/src/dev/debug2/debugChild.js +143 -0
- package/src/dev/debug2/debugTransform.js +152 -0
- package/src/dev/debug2/expressionTree.js +50 -0
- package/src/dev/dev.js +1 -0
- package/src/dev/findOpenPort.js +15 -0
- package/src/dev/help.yaml +3 -0
- package/src/dev/svg.js +5 -6
- package/src/origami/fetch.js +1 -1
- package/src/origami/indexPage.js +1 -0
- package/src/origami/ori.js +8 -0
- package/src/origami/origami.js +2 -2
- package/src/server/constructResponse.js +37 -88
- package/src/server/server.js +13 -13
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@weborigami/origami",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.12",
|
|
4
4
|
"description": "Web Origami language, CLI, framework, and server",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -13,23 +13,23 @@
|
|
|
13
13
|
"main": "./main.js",
|
|
14
14
|
"types": "./index.ts",
|
|
15
15
|
"devDependencies": {
|
|
16
|
-
"@types/node": "
|
|
16
|
+
"@types/node": "25.3.2",
|
|
17
17
|
"typescript": "5.9.3"
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"@
|
|
20
|
+
"@hpcc-js/wasm-graphviz": "^1.21.0",
|
|
21
|
+
"@weborigami/async-tree": "0.6.12",
|
|
21
22
|
"@weborigami/json-feed-to-rss": "1.0.1",
|
|
22
|
-
"@weborigami/language": "0.6.
|
|
23
|
+
"@weborigami/language": "0.6.12",
|
|
23
24
|
"css-tree": "3.1.0",
|
|
24
|
-
"graphviz-wasm": "3.0.2",
|
|
25
25
|
"highlight.js": "11.11.1",
|
|
26
|
-
"jsdom": "
|
|
27
|
-
"marked": "17.0.
|
|
26
|
+
"jsdom": "28.1.0",
|
|
27
|
+
"marked": "17.0.3",
|
|
28
28
|
"marked-gfm-heading-id": "4.1.3",
|
|
29
29
|
"marked-highlight": "2.2.3",
|
|
30
30
|
"marked-smartypants": "1.1.11",
|
|
31
31
|
"sharp": "0.34.5",
|
|
32
|
-
"yaml": "2.8.
|
|
32
|
+
"yaml": "2.8.2"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"test": "node --test --test-reporter=spec",
|
package/src/dev/changes.js
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
args,
|
|
3
|
+
isStringlike,
|
|
4
|
+
toString,
|
|
5
|
+
trailingSlash,
|
|
6
|
+
Tree,
|
|
7
|
+
} from "@weborigami/async-tree";
|
|
2
8
|
|
|
3
9
|
/**
|
|
4
10
|
* Given an old tree and a new tree, return a tree of changes indicated
|
|
@@ -44,9 +50,9 @@ export default async function changes(oldMaplike, newMaplike) {
|
|
|
44
50
|
result ??= {};
|
|
45
51
|
result[oldKey] = treeChanges;
|
|
46
52
|
}
|
|
47
|
-
} else if (oldValue
|
|
48
|
-
const oldText =
|
|
49
|
-
const newText =
|
|
53
|
+
} else if (isStringlike(oldValue) && isStringlike(newValue)) {
|
|
54
|
+
const oldText = toString(oldValue);
|
|
55
|
+
const newText = toString(newValue);
|
|
50
56
|
if (oldText !== newText) {
|
|
51
57
|
result ??= {};
|
|
52
58
|
result[oldKey] = "changed";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
This folder implements the Origami debugger, a local web server whose architecture is complex enough to warrant documenting here.
|
|
2
|
+
|
|
3
|
+
## Design goals
|
|
4
|
+
|
|
5
|
+
1. Easily start a debug server. An Origami dev can start a server with `ori debug2 <expression>, [port]`, where `<expr>` is an Origami expression yielding a map-based tree and `port` is an optional port (default: 5000). This command should start the server and display console text indicating that the server is running on the indicated port. The dev can browse to that location to view their site.
|
|
6
|
+
|
|
7
|
+
2. Watch the local project for changes and reload accordingly. If the developer edits a file and refreshes the browser, they should see the result of their edit.
|
|
8
|
+
|
|
9
|
+
3. Load JavaScript code in a clean Node environment, and recreate a clean environment whenever the developer edits a file. If the dev edits a file `fn.js` to remove a global definition, the server should reload its state such that the old global is gone.
|
|
10
|
+
|
|
11
|
+
4. Deliver reasonable performance for local development. Minimize the amount of interprocess communication (IPC) where possible.
|
|
12
|
+
|
|
13
|
+
5. Keep the architecture simple enough that it's reliable and maintainable.
|
|
14
|
+
|
|
15
|
+
## Architecture
|
|
16
|
+
|
|
17
|
+
Goal #1 (easily start server) implies that the parent debug2 process is the one establishing the server port number that the dev can see, and that the port number is kept stable across reloads.
|
|
18
|
+
|
|
19
|
+
To achieve goal #3 (clean Node environment), the debug2 command loads the Origami project in a child process that can be killed and restarted on each reload.
|
|
20
|
+
|
|
21
|
+
These points are in tension: it would be faster (goal #4) for the child process to directly respond to requests, but it's impossible for that port number to be stable while also having a clean Node environment.
|
|
22
|
+
|
|
23
|
+
As a reliable and maintainable compromise (goal #5), the child process starts its own server on an ephemeral local port. (An ephemeral port has a port number is dynamically chosen by the OS from a designated range, and which is expected to be used only for a short period of time.) The child then communicates its port number to the parent process. The parent's server (the one the dev can see) then uses that port to proxy HTTP requests to the child's server. The parent is acting as a _reverse proxy_.
|
|
24
|
+
|
|
25
|
+
Requests are routed to the resource tree as follows:
|
|
26
|
+
|
|
27
|
+
browser → parent server → child server → tree → child server → parent server → browser
|
|
28
|
+
|
|
29
|
+
When the local project changes, the parent server creates a new child process and begins routing requests to it. In the background, it tells the previous child to drain any in-flight requests; when that's complete, the child process is killed.
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { OrigamiFileMap } from "@weborigami/language";
|
|
2
|
+
import { fork } from "node:child_process";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
const PUBLIC_HOST = "127.0.0.1";
|
|
7
|
+
const PUBLIC_PORT = 5000;
|
|
8
|
+
|
|
9
|
+
// Module that loads the server in the child process
|
|
10
|
+
const childModuleUrl = new URL("./debugChild.js", import.meta.url);
|
|
11
|
+
|
|
12
|
+
// The active child process and port
|
|
13
|
+
/** @typedef {import("node:child_process").ChildProcess} ChildProcess */
|
|
14
|
+
/** @typedef {{ process: ChildProcess, port: number | null }} ChildInfo */
|
|
15
|
+
/** @type {ChildInfo | null} */
|
|
16
|
+
let activeChild = null;
|
|
17
|
+
|
|
18
|
+
// The most recently started child (may not be ready yet)
|
|
19
|
+
/** @type {ChildInfo | null} */
|
|
20
|
+
let pendingChild = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Given an Origami function, determine the runtime state's parent container,
|
|
24
|
+
* then start a new debug server with that parent as the root of the resource
|
|
25
|
+
* tree.
|
|
26
|
+
*
|
|
27
|
+
* This function expects an unevaluated expression. It will obtain the original
|
|
28
|
+
* source of the expression and pass that to the child for evaluation. This
|
|
29
|
+
* arrangement ensures the expression is evaluated in a clean Node context (not
|
|
30
|
+
* polluted by previous evaluations).
|
|
31
|
+
*
|
|
32
|
+
* @param {import("@weborigami/language").AnnotatedCode} code
|
|
33
|
+
* @param {import("@weborigami/language").RuntimeState} state
|
|
34
|
+
*/
|
|
35
|
+
export default async function debug2(code, state) {
|
|
36
|
+
if (
|
|
37
|
+
!(code instanceof Array) ||
|
|
38
|
+
code.source === undefined ||
|
|
39
|
+
arguments.length < 2
|
|
40
|
+
) {
|
|
41
|
+
throw new TypeError(
|
|
42
|
+
"Dev.debug2 expects an Origami expression to evaluate: `debug2 <expression>`",
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
const { parent } = state;
|
|
46
|
+
// @ts-ignore
|
|
47
|
+
const parentPath = parent?.path;
|
|
48
|
+
if (parentPath === undefined) {
|
|
49
|
+
throw new Error("Dev.debug2 couldn't work out the parent path.");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const serverOptions = {
|
|
53
|
+
expression: code.source,
|
|
54
|
+
parent: parentPath,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const tree = new OrigamiFileMap(parentPath);
|
|
58
|
+
tree.watch();
|
|
59
|
+
tree.addEventListener?.("change", (event) => {
|
|
60
|
+
// @ts-ignore
|
|
61
|
+
const { filePath } = event.options;
|
|
62
|
+
if (isJavaScriptFile(filePath)) {
|
|
63
|
+
// Need to restart the child process
|
|
64
|
+
console.log("JavaScript file changed, restarting server…");
|
|
65
|
+
startChild(serverOptions);
|
|
66
|
+
} else if (isPackageJsonFile(filePath)) {
|
|
67
|
+
// Need to restart the child process
|
|
68
|
+
console.log("package.json changed, restarting server…");
|
|
69
|
+
startChild(serverOptions);
|
|
70
|
+
} else {
|
|
71
|
+
// Just have the child reevaluate the expression
|
|
72
|
+
console.log("File changed, reloading site…");
|
|
73
|
+
activeChild?.process.send({ type: "REEVALUATE" });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
// ---- Public server
|
|
78
|
+
const publicServer = http.createServer(proxyRequest);
|
|
79
|
+
publicServer.listen(PUBLIC_PORT, PUBLIC_HOST, () => {
|
|
80
|
+
startChild(serverOptions);
|
|
81
|
+
console.log(
|
|
82
|
+
`Server running at http://localhost:${PUBLIC_PORT}. Press Ctrl+C to stop.`,
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
debug2.needsState = true;
|
|
87
|
+
debug2.unevaluatedArgs = true;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Give a child process a chance to finish any in-flight requests before we kill
|
|
91
|
+
* it.
|
|
92
|
+
*
|
|
93
|
+
* @param {ChildProcess} childProcess
|
|
94
|
+
*/
|
|
95
|
+
async function drainAndStopChild(childProcess) {
|
|
96
|
+
if (childProcess.killed) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Ask it to drain first.
|
|
101
|
+
try {
|
|
102
|
+
childProcess.send({ type: "DRAIN" });
|
|
103
|
+
} catch {
|
|
104
|
+
// ignore
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const drained = new Promise((resolve) => {
|
|
108
|
+
const onMessage = (msg) => {
|
|
109
|
+
if (msg && typeof msg === "object" && msg.type === "DRAINED") {
|
|
110
|
+
cleanup(resolve);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
const onExit = () => cleanup(resolve);
|
|
114
|
+
|
|
115
|
+
function cleanup(done) {
|
|
116
|
+
childProcess.off("message", onMessage);
|
|
117
|
+
childProcess.off("exit", onExit);
|
|
118
|
+
done();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
childProcess.on("message", onMessage);
|
|
122
|
+
childProcess.on("exit", onExit);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// Give it a short grace window to finish in-flight work.
|
|
126
|
+
const GRACE_MS = 1500;
|
|
127
|
+
await Promise.race([
|
|
128
|
+
drained,
|
|
129
|
+
new Promise((r) => setTimeout(r, GRACE_MS).unref()),
|
|
130
|
+
]);
|
|
131
|
+
|
|
132
|
+
if (!childProcess.killed) {
|
|
133
|
+
childProcess.kill("SIGTERM");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Final escalation.
|
|
137
|
+
setTimeout(() => {
|
|
138
|
+
// Child should have exited by now, but if not kill it
|
|
139
|
+
if (!childProcess.killed) {
|
|
140
|
+
childProcess.kill("SIGKILL");
|
|
141
|
+
}
|
|
142
|
+
}, GRACE_MS).unref();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isJavaScriptFile(filePath) {
|
|
146
|
+
const extname = path.extname(filePath).toLowerCase();
|
|
147
|
+
const jsExtensions = [".cjs", ".js", ".mjs", ".ts"];
|
|
148
|
+
return jsExtensions.includes(extname);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isPackageJsonFile(filePath) {
|
|
152
|
+
return path.basename(filePath).toLowerCase() === "package.json";
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Proxy incoming requests to the active child server, or return a 503 if not
|
|
157
|
+
* ready.
|
|
158
|
+
*
|
|
159
|
+
* @param {import("node:http").IncomingMessage} request
|
|
160
|
+
* @param {import("node:http").ServerResponse} response
|
|
161
|
+
*/
|
|
162
|
+
function proxyRequest(request, response) {
|
|
163
|
+
if (!activeChild) {
|
|
164
|
+
response.statusCode = 503;
|
|
165
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
166
|
+
response.end("Dev server is starting…\n");
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const { port } = activeChild;
|
|
171
|
+
|
|
172
|
+
// Minimal hop-by-hop header stripping
|
|
173
|
+
const headers = { ...request.headers };
|
|
174
|
+
delete headers.connection;
|
|
175
|
+
delete headers["proxy-connection"];
|
|
176
|
+
delete headers["keep-alive"];
|
|
177
|
+
delete headers.te;
|
|
178
|
+
delete headers.trailer;
|
|
179
|
+
delete headers["transfer-encoding"];
|
|
180
|
+
delete headers.upgrade;
|
|
181
|
+
|
|
182
|
+
const upstreamRequest = http.request(
|
|
183
|
+
{
|
|
184
|
+
host: PUBLIC_HOST,
|
|
185
|
+
port,
|
|
186
|
+
method: request.method,
|
|
187
|
+
path: request.url,
|
|
188
|
+
headers,
|
|
189
|
+
},
|
|
190
|
+
(upstreamResponse) => {
|
|
191
|
+
response.writeHead(
|
|
192
|
+
upstreamResponse.statusCode ?? 502,
|
|
193
|
+
upstreamResponse.statusMessage,
|
|
194
|
+
upstreamResponse.headers,
|
|
195
|
+
);
|
|
196
|
+
upstreamResponse.pipe(response);
|
|
197
|
+
},
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
upstreamRequest.on("error", (err) => {
|
|
201
|
+
// Stop piping the request body
|
|
202
|
+
request.unpipe(upstreamRequest);
|
|
203
|
+
upstreamRequest.destroy();
|
|
204
|
+
|
|
205
|
+
// Only send error response if headers haven't been sent yet
|
|
206
|
+
if (!response.headersSent) {
|
|
207
|
+
response.statusCode = 502;
|
|
208
|
+
response.setHeader("content-type", "text/plain; charset=utf-8");
|
|
209
|
+
response.end(`Upstream error: ${err.message}\n`);
|
|
210
|
+
} else {
|
|
211
|
+
// Headers already sent, can't send error message - just close
|
|
212
|
+
response.destroy();
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Also handle errors on the incoming request
|
|
217
|
+
request.on("error", () => {
|
|
218
|
+
upstreamRequest.destroy();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
request.pipe(upstreamRequest);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Start a new child process.
|
|
226
|
+
*
|
|
227
|
+
* This will be a pending process until it sends a READY message, at which point
|
|
228
|
+
* it becomes active and any previous active child is drained and stopped.
|
|
229
|
+
*/
|
|
230
|
+
function startChild(serverOptions) {
|
|
231
|
+
const { expression, parent } = serverOptions;
|
|
232
|
+
|
|
233
|
+
// Start the child process, passing parent path via an environment variable.
|
|
234
|
+
/** @type {ChildProcess} */
|
|
235
|
+
let childProcess;
|
|
236
|
+
try {
|
|
237
|
+
childProcess = fork(childModuleUrl, [], {
|
|
238
|
+
stdio: ["inherit", "inherit", "inherit", "ipc"],
|
|
239
|
+
env: {
|
|
240
|
+
...process.env,
|
|
241
|
+
ORIGAMI_EXPRESSION: expression,
|
|
242
|
+
ORIGAMI_PARENT: parent,
|
|
243
|
+
},
|
|
244
|
+
});
|
|
245
|
+
} catch (error) {
|
|
246
|
+
throw new Error("Dev.debug2: failed to start child server:", {
|
|
247
|
+
cause: error,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// This becomes the pending child immediately
|
|
252
|
+
pendingChild = { process: childProcess, port: null };
|
|
253
|
+
|
|
254
|
+
// Listen for messages from the child about its status
|
|
255
|
+
childProcess.on("message", (/** @type {any} */ message) => {
|
|
256
|
+
if (!message || typeof message !== "object") {
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (message.type === "READY" && typeof message.port === "number") {
|
|
261
|
+
// Only promote to active if this is still the pending child
|
|
262
|
+
if (pendingChild?.process === childProcess) {
|
|
263
|
+
const previousChild = activeChild;
|
|
264
|
+
|
|
265
|
+
activeChild = pendingChild;
|
|
266
|
+
pendingChild.port = message.port;
|
|
267
|
+
pendingChild = null;
|
|
268
|
+
|
|
269
|
+
// Drain previous child in background (don't wait)
|
|
270
|
+
if (previousChild?.process && previousChild.process !== childProcess) {
|
|
271
|
+
drainAndStopChild(previousChild.process).catch((err) =>
|
|
272
|
+
console.error("[drain]", err),
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
} else {
|
|
276
|
+
// This child was superseded by a newer one, kill it
|
|
277
|
+
// console.log("Child process superseded by newer one, killing it...");
|
|
278
|
+
childProcess.kill("SIGTERM");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (message.type === "FATAL") {
|
|
283
|
+
// Child couldn't start (import error, etc.)
|
|
284
|
+
// Keep previous active child if any; otherwise we'll serve 500/503.
|
|
285
|
+
console.error("[child fatal]", message.error ?? message);
|
|
286
|
+
if (pendingChild?.process === childProcess) {
|
|
287
|
+
pendingChild = null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
childProcess.on("exit", (code, signal) => {
|
|
293
|
+
if (activeChild?.process === childProcess) {
|
|
294
|
+
// Active child died unexpectedly.
|
|
295
|
+
activeChild = null;
|
|
296
|
+
}
|
|
297
|
+
if (pendingChild?.process === childProcess) {
|
|
298
|
+
pendingChild = null;
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
import { requestListener } from "../../server/server.js";
|
|
3
|
+
import expressionTree from "./expressionTree.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The debug2 command runs this module in a child process, passing in a parent
|
|
7
|
+
* path in an environment variable.
|
|
8
|
+
*
|
|
9
|
+
* This module starts an HTTP server that will serve resources from that tree.
|
|
10
|
+
* When the server is ready, it sends a message to the parent process with the
|
|
11
|
+
* port number. The parent then proxies incoming requests to that port.
|
|
12
|
+
*
|
|
13
|
+
* If the parent needs to start a new child process, it will tell the old one to
|
|
14
|
+
* drain any in-flight requests and stop accepting new ones.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const PUBLIC_HOST = "127.0.0.1";
|
|
18
|
+
|
|
19
|
+
function fail(message) {
|
|
20
|
+
console.error(message);
|
|
21
|
+
process.send?.({ type: "FATAL", error: message });
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @type {string} */
|
|
26
|
+
// @ts-ignore
|
|
27
|
+
const expression = process.env.ORIGAMI_EXPRESSION;
|
|
28
|
+
if (expression === undefined) {
|
|
29
|
+
fail("Missing Origami expression");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** @type {string} */
|
|
33
|
+
// @ts-ignore
|
|
34
|
+
const parentPath = process.env.ORIGAMI_PARENT;
|
|
35
|
+
if (parentPath === undefined) {
|
|
36
|
+
fail("Missing Origami parent");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// An indirect pointer to the tree of resources;
|
|
40
|
+
let treeHandle = {};
|
|
41
|
+
|
|
42
|
+
// Initial evaluation of the expression
|
|
43
|
+
await evaluateExpression();
|
|
44
|
+
|
|
45
|
+
// Serve the tree of resources
|
|
46
|
+
const listener = requestListener(treeHandle);
|
|
47
|
+
const server = http.createServer(listener);
|
|
48
|
+
|
|
49
|
+
// Track live connections so we can drain/close cleanly.
|
|
50
|
+
const sockets = new Set();
|
|
51
|
+
server.on("connection", (socket) => {
|
|
52
|
+
sockets.add(socket);
|
|
53
|
+
socket.on("close", () => sockets.delete(socket));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Helpful to avoid the old child keeping idle sockets around forever during drain.
|
|
57
|
+
server.keepAliveTimeout = 1000;
|
|
58
|
+
server.headersTimeout = 5000;
|
|
59
|
+
|
|
60
|
+
// Draining state
|
|
61
|
+
let draining = false;
|
|
62
|
+
let serverClosed = false;
|
|
63
|
+
|
|
64
|
+
function beginDrain() {
|
|
65
|
+
if (draining) return;
|
|
66
|
+
draining = true;
|
|
67
|
+
|
|
68
|
+
// Stop accepting new connections.
|
|
69
|
+
server.close(() => {
|
|
70
|
+
serverClosed = true;
|
|
71
|
+
maybeFinishDrain();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// Give in-flight requests a moment, then force-close remaining sockets.
|
|
75
|
+
const GRACE_MS = 1200;
|
|
76
|
+
setTimeout(() => {
|
|
77
|
+
for (const socket of sockets) {
|
|
78
|
+
// This will also abort any in-flight requests on that socket if still active.
|
|
79
|
+
socket.destroy();
|
|
80
|
+
}
|
|
81
|
+
// socket "close" events will shrink the set; check again soon.
|
|
82
|
+
setTimeout(maybeFinishDrain, 50).unref();
|
|
83
|
+
}, GRACE_MS).unref();
|
|
84
|
+
|
|
85
|
+
// Absolute last resort: don’t hang forever.
|
|
86
|
+
const HARD_MS = 3000;
|
|
87
|
+
setTimeout(() => process.exit(0), HARD_MS).unref();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function evaluateExpression() {
|
|
91
|
+
const tree = await expressionTree(expression, parentPath);
|
|
92
|
+
if (!tree) {
|
|
93
|
+
fail("Dev.debug2: expression did not evaluate to a maplike resource tree");
|
|
94
|
+
}
|
|
95
|
+
Object.setPrototypeOf(treeHandle, tree);
|
|
96
|
+
|
|
97
|
+
// Clean the handle of any named properties or symbols that have been set
|
|
98
|
+
// directly on it.
|
|
99
|
+
try {
|
|
100
|
+
for (const key of Object.getOwnPropertyNames(treeHandle)) {
|
|
101
|
+
delete treeHandle[key];
|
|
102
|
+
}
|
|
103
|
+
for (const key of Object.getOwnPropertySymbols(treeHandle)) {
|
|
104
|
+
delete treeHandle[key];
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
// Ignore errors.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function maybeFinishDrain() {
|
|
112
|
+
if (!draining) return;
|
|
113
|
+
if (serverClosed && sockets.size === 0) {
|
|
114
|
+
process.send?.({ type: "DRAINED" });
|
|
115
|
+
process.exit(0);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Drain when instructed by parent, or if parent dies.
|
|
120
|
+
process.on("message", async (/** @type {any} */ message) => {
|
|
121
|
+
if (message?.type === "DRAIN") {
|
|
122
|
+
beginDrain();
|
|
123
|
+
} else if (message?.type === "REEVALUATE") {
|
|
124
|
+
await evaluateExpression();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
process.on("SIGTERM", beginDrain);
|
|
128
|
+
process.on("SIGINT", beginDrain);
|
|
129
|
+
|
|
130
|
+
process.on("disconnect", () => {
|
|
131
|
+
// Parent process died, exit immediately
|
|
132
|
+
// console.log("Parent process disconnected, exiting...");
|
|
133
|
+
process.exit(0);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Listen on ephemeral port
|
|
137
|
+
server.listen(0, PUBLIC_HOST, () => {
|
|
138
|
+
// Tell parent we're ready to receive requests on our port
|
|
139
|
+
const address = server.address();
|
|
140
|
+
const port = typeof address === "object" && address ? address.port : null;
|
|
141
|
+
process.send?.({ type: "READY", port });
|
|
142
|
+
// console.log(`Child server running at http://${PUBLIC_HOST}:${port}.`);
|
|
143
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AsyncMap,
|
|
3
|
+
Tree,
|
|
4
|
+
box,
|
|
5
|
+
isPlainObject,
|
|
6
|
+
isPrimitive,
|
|
7
|
+
isUnpackable,
|
|
8
|
+
jsonKeys,
|
|
9
|
+
scope,
|
|
10
|
+
trailingSlash,
|
|
11
|
+
} from "@weborigami/async-tree";
|
|
12
|
+
import { projectGlobals } from "@weborigami/language";
|
|
13
|
+
import indexPage from "../../origami/indexPage.js";
|
|
14
|
+
import yaml from "../../origami/yaml.js";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Transform the given map-based tree to add debugging resources:
|
|
18
|
+
*
|
|
19
|
+
* - default index.html page
|
|
20
|
+
* - default .keys.json resource
|
|
21
|
+
* - support for invoking Origami commands via keys starting with '!'
|
|
22
|
+
*
|
|
23
|
+
* Also transform a simple object result to YAML for viewing.
|
|
24
|
+
*
|
|
25
|
+
* @param {import("@weborigami/async-tree").Maplike} maplike
|
|
26
|
+
*/
|
|
27
|
+
export default function debugTransform(maplike) {
|
|
28
|
+
const source = Tree.from(maplike, { deep: true });
|
|
29
|
+
return Object.assign(new AsyncMap(), {
|
|
30
|
+
description: "debug resources",
|
|
31
|
+
|
|
32
|
+
async get(key) {
|
|
33
|
+
// Ask the tree if it has the key.
|
|
34
|
+
let value = await source.get(key);
|
|
35
|
+
|
|
36
|
+
if (value === undefined) {
|
|
37
|
+
// The tree doesn't have the key; try the defaults.
|
|
38
|
+
if (key === "index.html") {
|
|
39
|
+
// Generate an index page for this site
|
|
40
|
+
value = await indexPage(source);
|
|
41
|
+
} else if (key === ".keys.json") {
|
|
42
|
+
value = await jsonKeys.stringify(source);
|
|
43
|
+
} else if (typeof key === "string" && key.startsWith("!")) {
|
|
44
|
+
value = await invokeOrigamiCommand(source, key);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (isSimpleObject(value)) {
|
|
49
|
+
// Serialize to YAML, but also allow the result to be further traversed
|
|
50
|
+
const object = value;
|
|
51
|
+
const yamlText = await yaml(object);
|
|
52
|
+
value = box(yamlText);
|
|
53
|
+
value.unpack = () =>
|
|
54
|
+
Tree.merge(object, {
|
|
55
|
+
"index.html": yamlText,
|
|
56
|
+
});
|
|
57
|
+
} else if (Tree.isMaplike(value) && !Tree.isMap(value)) {
|
|
58
|
+
// Make it a map so we can debug it
|
|
59
|
+
value = Tree.from(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (Tree.isMap(value)) {
|
|
63
|
+
// Ensure this transform is applied to any map result
|
|
64
|
+
value = debugTransform(value);
|
|
65
|
+
} else if (value?.unpack) {
|
|
66
|
+
// If the value isn't a tree, but has a tree attached via an `unpack`
|
|
67
|
+
// method, wrap the unpack method to add this transform.
|
|
68
|
+
const original = value.unpack.bind(value);
|
|
69
|
+
value.unpack = async () => {
|
|
70
|
+
const content = await original();
|
|
71
|
+
if (!Tree.isTraversable(content) || typeof content === "function") {
|
|
72
|
+
return content;
|
|
73
|
+
}
|
|
74
|
+
/** @type {any} */
|
|
75
|
+
let tree = Tree.from(content);
|
|
76
|
+
return debugTransform(tree);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return value;
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async keys() {
|
|
83
|
+
return source.keys();
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
// If this value is given to the server, the server will call this pack()
|
|
87
|
+
// method. We respond with the index page.
|
|
88
|
+
async pack() {
|
|
89
|
+
return this.get("index.html");
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
source,
|
|
93
|
+
|
|
94
|
+
trailingSlashKeys: true,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function invokeOrigamiCommand(tree, key) {
|
|
99
|
+
// Key is an Origami command; invoke it.
|
|
100
|
+
const globals = await projectGlobals(tree);
|
|
101
|
+
const commandName = trailingSlash.remove(key.slice(1).trim());
|
|
102
|
+
|
|
103
|
+
// Look for command as a global or Dev command
|
|
104
|
+
const command = globals[commandName] ?? globals.Dev?.[commandName];
|
|
105
|
+
let value;
|
|
106
|
+
if (command) {
|
|
107
|
+
value = await command(tree);
|
|
108
|
+
} else {
|
|
109
|
+
// Look for command in scope
|
|
110
|
+
const parentScope = await scope(tree);
|
|
111
|
+
value = await parentScope.get(commandName);
|
|
112
|
+
|
|
113
|
+
if (value === undefined) {
|
|
114
|
+
throw new Error(`Unknown Origami command: ${commandName}`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (trailingSlash.has(key) && isUnpackable(value)) {
|
|
119
|
+
value = await value.unpack();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Returns true if the object is "simple": a plain object or array that does not
|
|
127
|
+
* have any getters in its deep structure.
|
|
128
|
+
*
|
|
129
|
+
* This test is used to avoid serializing complex objects to YAML.
|
|
130
|
+
*
|
|
131
|
+
* @param {any} object
|
|
132
|
+
*/
|
|
133
|
+
function isSimpleObject(object) {
|
|
134
|
+
if (!(object instanceof Array || isPlainObject(object))) {
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
for (const key of Object.keys(object)) {
|
|
139
|
+
const descriptor = Object.getOwnPropertyDescriptor(object, key);
|
|
140
|
+
if (!descriptor) {
|
|
141
|
+
continue; // not sure why this would happen
|
|
142
|
+
} else if (typeof descriptor.get === "function") {
|
|
143
|
+
return false; // Getters aren't simple
|
|
144
|
+
} else if (isPrimitive(descriptor.value)) {
|
|
145
|
+
continue; // Primitives are simple
|
|
146
|
+
} else if (!isSimpleObject(descriptor.value)) {
|
|
147
|
+
return false; // Deep structure wasn't simple
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return true;
|
|
152
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConstantMap,
|
|
3
|
+
isUnpackable,
|
|
4
|
+
setParent,
|
|
5
|
+
Tree,
|
|
6
|
+
} from "@weborigami/async-tree";
|
|
7
|
+
import { evaluate, OrigamiFileMap, projectGlobals } from "@weborigami/language";
|
|
8
|
+
import debugTransform from "./debugTransform.js";
|
|
9
|
+
|
|
10
|
+
// So we can distinguish different trees in the debugger
|
|
11
|
+
let version = 0;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Evaluate the given expression using the indicated parent path to produce a
|
|
15
|
+
* resource tree, then transform that tree with debug resources and return it.
|
|
16
|
+
*
|
|
17
|
+
* @param {string} expression
|
|
18
|
+
* @param {string} parentPath
|
|
19
|
+
*/
|
|
20
|
+
export default async function expressionTree(expression, parentPath) {
|
|
21
|
+
const parent = new OrigamiFileMap(parentPath);
|
|
22
|
+
const globals = await projectGlobals(parent);
|
|
23
|
+
|
|
24
|
+
let maplike;
|
|
25
|
+
try {
|
|
26
|
+
// Evaluate the expression
|
|
27
|
+
maplike = await evaluate(expression, { globals, mode: "shell", parent });
|
|
28
|
+
if (isUnpackable(maplike)) {
|
|
29
|
+
maplike = await maplike.unpack();
|
|
30
|
+
}
|
|
31
|
+
} catch (/** @type {any} */ error) {
|
|
32
|
+
return new ConstantMap(error.message);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (!Tree.isMaplike(maplike)) {
|
|
36
|
+
return new ConstantMap(
|
|
37
|
+
`Dev.debug2: expression did not evaluate to a resource tree: ${expression}`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Set the parent so that Origami debug commands can find things in scope
|
|
42
|
+
setParent(maplike, parent);
|
|
43
|
+
|
|
44
|
+
// Add debugging resources
|
|
45
|
+
const tree = debugTransform(maplike);
|
|
46
|
+
|
|
47
|
+
/** @type {any} */ (tree).version = version++;
|
|
48
|
+
|
|
49
|
+
return tree;
|
|
50
|
+
}
|
package/src/dev/dev.js
CHANGED
|
@@ -11,6 +11,7 @@ export { default as copy } from "./copy.js";
|
|
|
11
11
|
export { default as audit } from "./crawler/audit.js";
|
|
12
12
|
export { default as crawl } from "./crawler/crawl.js";
|
|
13
13
|
export { default as debug } from "./debug.js";
|
|
14
|
+
export { default as debug2 } from "./debug2/debug2.js";
|
|
14
15
|
export { default as explore } from "./explore.js";
|
|
15
16
|
export { default as help } from "./help.js";
|
|
16
17
|
export { default as log } from "./log.js";
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
|
|
3
|
+
// Return the first open port number on or after the given port number.
|
|
4
|
+
// From https://gist.github.com/mikeal/1840641?permalink_comment_id=2896667#gistcomment-2896667
|
|
5
|
+
export default function findOpenPort(port) {
|
|
6
|
+
const server = createServer();
|
|
7
|
+
return new Promise((resolve, reject) =>
|
|
8
|
+
server
|
|
9
|
+
.on("error", (/** @type {any} */ error) =>
|
|
10
|
+
error.code === "EADDRINUSE" ? server.listen(++port) : reject(error),
|
|
11
|
+
)
|
|
12
|
+
.on("listening", () => server.close(() => resolve(port)))
|
|
13
|
+
.listen(port),
|
|
14
|
+
);
|
|
15
|
+
}
|
package/src/dev/help.yaml
CHANGED
package/src/dev/svg.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
+
import { Graphviz } from "@hpcc-js/wasm-graphviz";
|
|
1
2
|
import { args } from "@weborigami/async-tree";
|
|
2
|
-
import graphviz from "graphviz-wasm";
|
|
3
3
|
|
|
4
4
|
import dot from "./treeDot.js";
|
|
5
5
|
|
|
6
|
-
let
|
|
6
|
+
let graphviz;
|
|
7
7
|
|
|
8
8
|
/**
|
|
9
9
|
* Render a tree visually in SVG format.
|
|
@@ -15,16 +15,15 @@ let graphvizLoaded = false;
|
|
|
15
15
|
* @param {PlainObject} [options]
|
|
16
16
|
*/
|
|
17
17
|
export default async function svg(maplike, options = {}) {
|
|
18
|
-
if (!
|
|
19
|
-
await
|
|
20
|
-
graphvizLoaded = true;
|
|
18
|
+
if (!graphviz) {
|
|
19
|
+
graphviz = await Graphviz.load();
|
|
21
20
|
}
|
|
22
21
|
const tree = await args.map(maplike, "Dev.svg", { deep: true });
|
|
23
22
|
const dotText = await dot(tree, options);
|
|
24
23
|
if (dotText === undefined) {
|
|
25
24
|
return undefined;
|
|
26
25
|
}
|
|
27
|
-
const svgText = await graphviz.
|
|
26
|
+
const svgText = await graphviz.dot(dotText);
|
|
28
27
|
/** @type {any} */
|
|
29
28
|
const result = new String(svgText);
|
|
30
29
|
result.mediaType = "image/svg+xml";
|
package/src/origami/fetch.js
CHANGED
|
@@ -17,6 +17,6 @@ export default async function fetchBuiltin(resource, options, state) {
|
|
|
17
17
|
const url = new URL(resource);
|
|
18
18
|
const key = url.pathname;
|
|
19
19
|
|
|
20
|
-
return handleExtension(value, key, state
|
|
20
|
+
return handleExtension(value, key, state?.parent);
|
|
21
21
|
}
|
|
22
22
|
fetchBuiltin.needsState = true;
|
package/src/origami/indexPage.js
CHANGED
package/src/origami/ori.js
CHANGED
|
@@ -54,6 +54,14 @@ async function format(result) {
|
|
|
54
54
|
return result;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
if (result instanceof Response) {
|
|
58
|
+
if (!result.ok) {
|
|
59
|
+
console.warn(`Response not OK: ${result.status} ${result.statusText}`);
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return await result.arrayBuffer();
|
|
63
|
+
}
|
|
64
|
+
|
|
57
65
|
/** @type {string|String|undefined} */
|
|
58
66
|
let text;
|
|
59
67
|
|
package/src/origami/origami.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
export { extension } from "@weborigami/async-tree";
|
|
2
2
|
export { default as help } from "../dev/help.js"; // Alias
|
|
3
|
-
export { default as document } from "./document.js";
|
|
4
|
-
// export { default as htmlDom } from "./htmlDom.js";
|
|
5
3
|
export { default as basename } from "./basename.js";
|
|
6
4
|
export { default as csv } from "./csv.js";
|
|
5
|
+
export { default as document } from "./document.js";
|
|
7
6
|
export { default as fetch } from "./fetch.js";
|
|
7
|
+
export { default as htmlDom } from "./htmlDom.js";
|
|
8
8
|
export { default as htmlEscape } from "./htmlEscape.js";
|
|
9
9
|
export { default as format } from "./image/format.js";
|
|
10
10
|
export * as image from "./image/image.js";
|
|
@@ -1,32 +1,19 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
isPacked,
|
|
4
|
-
isPlainObject,
|
|
5
|
-
isStringlike,
|
|
6
|
-
SiteMap,
|
|
7
|
-
toString,
|
|
8
|
-
Tree,
|
|
9
|
-
} from "@weborigami/async-tree";
|
|
10
|
-
import * as serialize from "../common/serialize.js";
|
|
1
|
+
import { extension, isPacked, toString, Tree } from "@weborigami/async-tree";
|
|
2
|
+
import { computedMIMEType } from "whatwg-mimetype";
|
|
11
3
|
import { mediaTypeForExtension } from "./mediaTypes.js";
|
|
12
4
|
|
|
13
|
-
const TypedArray = Object.getPrototypeOf(Uint8Array);
|
|
14
|
-
|
|
15
5
|
/**
|
|
16
6
|
* Given a resource that was returned from a route, construct an appropriate
|
|
17
|
-
* HTTP Response indicating what should be sent to the client.
|
|
18
|
-
* if the resource is not a valid response.
|
|
7
|
+
* HTTP Response indicating what should be sent to the client.
|
|
19
8
|
*
|
|
20
9
|
* @param {import("node:http").IncomingMessage} request
|
|
21
10
|
* @param {any} resource
|
|
22
|
-
* @returns {Promise<Response
|
|
11
|
+
* @returns {Promise<Response>}
|
|
23
12
|
*/
|
|
24
13
|
export default async function constructResponse(request, resource) {
|
|
25
14
|
if (resource instanceof Response) {
|
|
26
15
|
// Already a Response, return as is.
|
|
27
16
|
return resource;
|
|
28
|
-
} else if (resource == null) {
|
|
29
|
-
return null;
|
|
30
17
|
}
|
|
31
18
|
|
|
32
19
|
// Determine media type, what data we'll send, and encoding.
|
|
@@ -53,59 +40,44 @@ export default async function constructResponse(request, resource) {
|
|
|
53
40
|
}
|
|
54
41
|
}
|
|
55
42
|
|
|
43
|
+
let body = resource;
|
|
44
|
+
if (!isPacked(resource)) {
|
|
45
|
+
// Can we treat it as text?
|
|
46
|
+
const text = toString(resource);
|
|
47
|
+
if (text) {
|
|
48
|
+
body = text;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Determine MIME type
|
|
56
53
|
let mediaType;
|
|
57
54
|
if (resource.mediaType) {
|
|
58
55
|
// Resource indicates its own media type.
|
|
59
56
|
mediaType = resource.mediaType;
|
|
60
57
|
} else {
|
|
61
|
-
//
|
|
58
|
+
// Do we know the media type based on the URL extension?
|
|
62
59
|
const ext = extension.extname(url.pathname).toLowerCase();
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
mediaType = "text/yaml";
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// By default, the body will be the resource we got
|
|
88
|
-
let body = resource;
|
|
89
|
-
if (!mediaType) {
|
|
90
|
-
// Maybe it's HTML?
|
|
91
|
-
const text = toString(resource);
|
|
92
|
-
if (text) {
|
|
93
|
-
mediaType = maybeHtml(text) ? "text/html" : "text/plain";
|
|
94
|
-
mediaType += "; charset=utf-8";
|
|
95
|
-
body = text;
|
|
96
|
-
}
|
|
97
|
-
} else if (
|
|
98
|
-
body instanceof TypedArray &&
|
|
99
|
-
mediaType &&
|
|
100
|
-
SiteMap.mediaTypeIsText(mediaType) &&
|
|
101
|
-
!mediaType.includes("charset")
|
|
102
|
-
) {
|
|
103
|
-
// See if text is encoded in UTF-8.
|
|
104
|
-
const text = toString(resource);
|
|
105
|
-
if (text !== null) {
|
|
106
|
-
// We were able to decode the TypedArray as UTF-8 text.
|
|
107
|
-
body = text;
|
|
108
|
-
mediaType += "; charset=utf-8";
|
|
60
|
+
const extensionMediaType = ext ? mediaTypeForExtension[ext] : undefined;
|
|
61
|
+
if (extensionMediaType) {
|
|
62
|
+
mediaType = extensionMediaType;
|
|
63
|
+
} else {
|
|
64
|
+
// Use MIME Sniffing Standard to determine media type
|
|
65
|
+
const isString = typeof body === "string" || body instanceof String;
|
|
66
|
+
const bytes = isString ? new TextEncoder().encode(String(body)) : body;
|
|
67
|
+
let sniffedType;
|
|
68
|
+
try {
|
|
69
|
+
sniffedType = computedMIMEType(bytes);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
// Ignore sniffing errors
|
|
72
|
+
}
|
|
73
|
+
if (sniffedType) {
|
|
74
|
+
if (isString && sniffedType.essence === "application/octet-stream") {
|
|
75
|
+
// Prefer text/plain for strings
|
|
76
|
+
mediaType = "text/plain";
|
|
77
|
+
} else {
|
|
78
|
+
mediaType = sniffedType.toString();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
109
81
|
}
|
|
110
82
|
}
|
|
111
83
|
|
|
@@ -114,35 +86,12 @@ export default async function constructResponse(request, resource) {
|
|
|
114
86
|
const validResponse = isPacked(body);
|
|
115
87
|
if (!validResponse) {
|
|
116
88
|
const typeName = body?.constructor?.name ?? typeof body;
|
|
117
|
-
|
|
89
|
+
throw new Error(
|
|
118
90
|
`A served tree must return a string or a TypedArray but returned an instance of ${typeName}.`,
|
|
119
91
|
);
|
|
120
|
-
return null;
|
|
121
92
|
}
|
|
122
93
|
|
|
123
94
|
const options = mediaType ? { headers: { "Content-Type": mediaType } } : {};
|
|
124
95
|
const response = new Response(body, options);
|
|
125
96
|
return response;
|
|
126
97
|
}
|
|
127
|
-
|
|
128
|
-
// Return true if the resource appears to represent HTML
|
|
129
|
-
function maybeHtml(text) {
|
|
130
|
-
if (!text) {
|
|
131
|
-
return false;
|
|
132
|
-
}
|
|
133
|
-
if (text.startsWith("<!DOCTYPE html>")) {
|
|
134
|
-
return true;
|
|
135
|
-
}
|
|
136
|
-
if (text.startsWith("<!--")) {
|
|
137
|
-
return true;
|
|
138
|
-
}
|
|
139
|
-
// Check if the text starts with an HTML tag.
|
|
140
|
-
// - start with possible whitespace
|
|
141
|
-
// - followed by '<'
|
|
142
|
-
// - followed by a letter
|
|
143
|
-
// - followed maybe by letters, digits, hyphens, underscores, colons, or periods
|
|
144
|
-
// - followed by '>', or
|
|
145
|
-
// - followed by whitespace, anything that's not '>', then a '>'
|
|
146
|
-
const tagRegex = /^\s*<[a-zA-Z][a-zA-Z0-9-_:\.]*(>|[\s]+[^>]*>)/;
|
|
147
|
-
return tagRegex.test(text);
|
|
148
|
-
}
|
package/src/server/server.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Tree, keysFromPath } from "@weborigami/async-tree";
|
|
1
|
+
import { Tree, keysFromPath, trailingSlash } from "@weborigami/async-tree";
|
|
2
2
|
import { formatError } from "@weborigami/language";
|
|
3
3
|
import { ServerResponse } from "node:http";
|
|
4
4
|
import constructResponse from "./constructResponse.js";
|
|
@@ -64,12 +64,13 @@ export async function handleRequest(request, response, map) {
|
|
|
64
64
|
resource = data ? await resource(data) : await resource();
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
|
|
68
|
-
const constructed = await constructResponse(request, resource);
|
|
69
|
-
if (!constructed) {
|
|
67
|
+
if (resource == null) {
|
|
70
68
|
return false;
|
|
71
69
|
}
|
|
72
70
|
|
|
71
|
+
// Construct the response.
|
|
72
|
+
const constructed = await constructResponse(request, resource);
|
|
73
|
+
|
|
73
74
|
// Copy the construct response to the ServerResponse and return true if
|
|
74
75
|
// the response was valid.
|
|
75
76
|
return copyResponse(constructed, response);
|
|
@@ -84,10 +85,10 @@ export function keysFromUrl(url) {
|
|
|
84
85
|
const encodedKeys = keysFromPath(url.pathname);
|
|
85
86
|
const keys = encodedKeys.map((key) => decodeURIComponent(key));
|
|
86
87
|
|
|
87
|
-
// If the path
|
|
88
|
-
//
|
|
89
|
-
if (keys.at(-1)
|
|
90
|
-
keys
|
|
88
|
+
// If the keys array is empty (the path was just a trailing slash) or if the
|
|
89
|
+
// path ended with a slash, add "index.html" to the end of the keys.
|
|
90
|
+
if (keys.length === 0 || trailingSlash.has(keys.at(-1))) {
|
|
91
|
+
keys.push("index.html");
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
return keys;
|
|
@@ -106,11 +107,10 @@ export function requestListener(maplike) {
|
|
|
106
107
|
console.log(decodeURI(request.url));
|
|
107
108
|
const handled = await handleRequest(request, response, tree);
|
|
108
109
|
if (!handled) {
|
|
109
|
-
//
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
} catch (error) {}
|
|
110
|
+
// Not found, return a 404.
|
|
111
|
+
response.statusCode = 404;
|
|
112
|
+
response.statusMessage = "Not Found";
|
|
113
|
+
response.end("Not Found", "utf-8");
|
|
114
114
|
}
|
|
115
115
|
};
|
|
116
116
|
}
|