@weborigami/origami 0.6.13 → 0.6.15

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/main.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { default as documentObject } from "./src/common/documentObject.js";
2
2
  export * from "./src/common/serialize.js";
3
+ export { default as debugParent } from "./src/dev/debug2/debugParent.js";
3
4
  export * as Dev from "./src/dev/dev.js";
4
5
  export * as Origami from "./src/origami/origami.js";
5
6
  export { default as origamiHighlightDefinition } from "./src/origami/origamiHighlightDefinition.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weborigami/origami",
3
- "version": "0.6.13",
3
+ "version": "0.6.15",
4
4
  "description": "Web Origami language, CLI, framework, and server",
5
5
  "type": "module",
6
6
  "repository": {
@@ -18,9 +18,9 @@
18
18
  },
19
19
  "dependencies": {
20
20
  "@hpcc-js/wasm-graphviz": "^1.21.0",
21
- "@weborigami/async-tree": "0.6.13",
21
+ "@weborigami/async-tree": "0.6.15",
22
22
  "@weborigami/json-feed-to-rss": "1.0.1",
23
- "@weborigami/language": "0.6.13",
23
+ "@weborigami/language": "0.6.15",
24
24
  "css-tree": "3.1.0",
25
25
  "highlight.js": "11.11.1",
26
26
  "jsdom": "28.1.0",
@@ -1,38 +1,33 @@
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;
1
+ import { execute } from "@weborigami/language";
2
+ import debugParent from "./debugParent.js";
21
3
 
22
4
  /**
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.
5
+ * Given an Origami expression, start a new debug server with that parent as the
6
+ * root of the resource tree.
7
+ *
8
+ * This function expects unevaluated arguments. This is what it allows it to
9
+ * extract the source code of the expression to be debugged. (If it were
10
+ * evaluated, the function will be called with the result of the expression.)
26
11
  *
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).
12
+ * The `options` argument can include:
13
+ * - `enableUnsafeEval`: if true, enables the `!eval` debug command in the child
14
+ * process; default is false
15
+ * - `debugFilesPath`: path to resources that will be added to the served tree
31
16
  *
32
- * @param {import("@weborigami/language").AnnotatedCode} code
33
- * @param {import("@weborigami/language").RuntimeState} state
17
+ * @typedef {import("@weborigami/language").RuntimeState} RuntimeState
18
+ * @typedef {import("@weborigami/language").AnnotatedCode} AnnotatedCode
19
+ *
20
+ * @param {AnnotatedCode} code
21
+ * @param {any | RuntimeState} options
22
+ * @param {RuntimeState} state
34
23
  */
35
- export default async function debug2(code, state) {
24
+ export default async function debug2(code, options, state) {
25
+ if (state === undefined) {
26
+ // Options were omitted; shift arguments
27
+ state = options;
28
+ options = [];
29
+ }
30
+
36
31
  if (
37
32
  !(code instanceof Array) ||
38
33
  code.source === undefined ||
@@ -42,6 +37,9 @@ export default async function debug2(code, state) {
42
37
  "Dev.debug2 expects an Origami expression to evaluate: `debug2 <expression>`",
43
38
  );
44
39
  }
40
+
41
+ const expression = code.source;
42
+
45
43
  const { parent } = state;
46
44
  // @ts-ignore
47
45
  const parentPath = parent?.path;
@@ -49,253 +47,23 @@ export default async function debug2(code, state) {
49
47
  throw new Error("Dev.debug2 couldn't work out the parent path.");
50
48
  }
51
49
 
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
- });
50
+ // Need to evaluate options object
51
+ if (options.length > 0) {
52
+ options = await execute(options, state);
53
+ } else {
54
+ options = {};
55
+ }
76
56
 
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
- );
57
+ // @ts-ignore
58
+ const enableUnsafeEval = options.enableUnsafeEval ?? false;
59
+ const debugFilesPath = options.debugFilesPath ?? "";
60
+
61
+ await debugParent({
62
+ debugFilesPath,
63
+ enableUnsafeEval,
64
+ expression,
65
+ parentPath,
84
66
  });
85
67
  }
86
68
  debug2.needsState = true;
87
69
  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
- }
@@ -3,7 +3,7 @@ import { requestListener } from "../../server/server.js";
3
3
  import expressionTree from "./expressionTree.js";
4
4
 
5
5
  /**
6
- * The debug2 command runs this module in a child process, passing in a parent
6
+ * The debug parent runs this module in a child process, passing in a parent
7
7
  * path in an environment variable.
8
8
  *
9
9
  * This module starts an HTTP server that will serve resources from that tree.
@@ -22,6 +22,13 @@ function fail(message) {
22
22
  process.exit(1);
23
23
  }
24
24
 
25
+ /** @type {string} */
26
+ const debugFilesPath = process.env.ORIGAMI_DEBUG_FILES_PATH ?? "";
27
+
28
+ /** @type {boolean} */
29
+ // @ts-ignore
30
+ const enableUnsafeEval = process.env.ORIGAMI_ENABLE_UNSAFE_EVAL === "1";
31
+
25
32
  /** @type {string} */
26
33
  // @ts-ignore
27
34
  const expression = process.env.ORIGAMI_EXPRESSION;
@@ -31,7 +38,7 @@ if (expression === undefined) {
31
38
 
32
39
  /** @type {string} */
33
40
  // @ts-ignore
34
- const parentPath = process.env.ORIGAMI_PARENT;
41
+ const parentPath = process.env.ORIGAMI_PARENT_PATH;
35
42
  if (parentPath === undefined) {
36
43
  fail("Missing Origami parent");
37
44
  }
@@ -88,7 +95,12 @@ function beginDrain() {
88
95
  }
89
96
 
90
97
  async function evaluateExpression() {
91
- const tree = await expressionTree(expression, parentPath);
98
+ const tree = await expressionTree({
99
+ debugFilesPath,
100
+ expression,
101
+ parentPath,
102
+ enableUnsafeEval,
103
+ });
92
104
  if (!tree) {
93
105
  fail("Dev.debug2: expression did not evaluate to a maplike resource tree");
94
106
  }
@@ -0,0 +1,29 @@
1
+ // Subset of commands made available via debugTransform
2
+
3
+ import { Tree } from "@weborigami/async-tree";
4
+
5
+ import index from "../../origami/indexPage.js";
6
+ import yaml from "../../origami/yaml.js";
7
+ import explore from "../explore.js";
8
+ import svg from "../svg.js";
9
+ import version from "../version.js";
10
+ import oriEval from "./oriEval.js";
11
+
12
+ export default function debugCommands(enableUnsafeEval = false) {
13
+ return Object.assign(
14
+ {
15
+ keys: Tree.keys,
16
+ json: Tree.json,
17
+ index,
18
+ yaml,
19
+ explore,
20
+ svg,
21
+ version,
22
+ },
23
+ enableUnsafeEval
24
+ ? {
25
+ eval: oriEval,
26
+ }
27
+ : {},
28
+ );
29
+ }
@@ -0,0 +1,414 @@
1
+ import { OrigamiFileMap } from "@weborigami/language";
2
+ import { fork } from "node:child_process";
3
+ import { EventEmitter } from "node:events";
4
+ import http from "node:http";
5
+ import net from "node:net";
6
+ import path from "node:path";
7
+
8
+ const PUBLIC_HOST = "127.0.0.1";
9
+ const DEFAULT_PORT = 5000;
10
+
11
+ // Module that loads the server in the child process
12
+ const childModuleUrl = new URL("./debugChild.js", import.meta.url);
13
+
14
+ // The public-facing server that proxies to the child process
15
+ let publicServer;
16
+ let publicOrigin;
17
+
18
+ // The tree of files in the parent path, which we watch for changes
19
+ let tree;
20
+
21
+ // The active child process and port
22
+ /** @typedef {import("node:child_process").ChildProcess} ChildProcess */
23
+ /** @typedef {{ process: ChildProcess, port: number | null }} ChildInfo */
24
+ /** @type {ChildInfo | null} */
25
+ let activeChild = null;
26
+
27
+ // The most recently started child (may not be ready yet)
28
+ /** @type {ChildInfo | null} */
29
+ let pendingChild = null;
30
+
31
+ // Used to communicate errors to caller
32
+ let emitter = null;
33
+
34
+ /**
35
+ * Start a new debug parent server for the given Origami expression and runtime
36
+ * state.
37
+ *
38
+ * This function will start a child server that evaluates the given expression
39
+ * with the given parent path. This arrangement ensures the expression is
40
+ * evaluated in a clean Node context (not polluted by previous evaluations). The
41
+ * parent server proxies requests to the child server.
42
+ *
43
+ * The debug parent monitors the parent tree for changes, and restarts the child
44
+ * whenever files in the parent tree change.
45
+ *
46
+ * Supported `options`:
47
+ * - `debugFilesPath`: path to resources that will be added to the served tree
48
+ * - `enableUnsafeEval`: if true, enables the `!eval` debug command in the child
49
+ * process; default is false
50
+ * - `expression` (required): the Origami expression to evaluate in the child
51
+ * process
52
+ * - `parentPath` (required): the path to the parent tree used for evaluation
53
+ *
54
+ * The returned `emitter` is an EventEmitter that emits "error" events when the
55
+ * child server encounters an Origami error while handling a request.
56
+ *
57
+ * @param {Object} options
58
+ * @param {string} options.debugFilesPath
59
+ * @param {boolean} options.enableUnsafeEval
60
+ * @param {string} options.expression
61
+ * @param {string} options.parentPath
62
+ */
63
+ export default async function debugParent(options) {
64
+ const { parentPath } = options;
65
+
66
+ tree = new OrigamiFileMap(parentPath);
67
+ tree.watch();
68
+ tree.addEventListener?.("change", (event) => {
69
+ // @ts-ignore
70
+ const { filePath } = event.options;
71
+ if (isJavaScriptFile(filePath)) {
72
+ // Need to restart the child process
73
+ console.log("JavaScript file changed, restarting server…");
74
+ startChild(options);
75
+ } else if (isPackageJsonFile(filePath)) {
76
+ // Need to restart the child process
77
+ console.log("package.json changed, restarting server…");
78
+ startChild(options);
79
+ } else {
80
+ // Just have the child reevaluate the expression
81
+ console.log("File changed, reloading site…");
82
+ activeChild?.process.send({ type: "REEVALUATE" });
83
+ }
84
+ });
85
+
86
+ const port = await findOpenPort();
87
+ publicOrigin = `http://${PUBLIC_HOST}:${port}`;
88
+
89
+ publicServer = http.createServer(proxyRequest);
90
+ publicServer.listen(port, PUBLIC_HOST, () => {
91
+ startChild(options);
92
+ console.log(`Server running at ${publicOrigin}. Press Ctrl+C to stop.`);
93
+ });
94
+
95
+ emitter = Object.assign(new EventEmitter(), {
96
+ close,
97
+ });
98
+
99
+ return emitter;
100
+ }
101
+
102
+ /**
103
+ * Gracefully stop the parent server and any active child server, giving the
104
+ * child a chance to finish any in-flight requests before exiting.
105
+ */
106
+ async function close() {
107
+ // Stop accepting new connections and force-close any keep-alive connections
108
+ // so the close callback fires promptly.
109
+ const closed = new Promise((resolve) => publicServer.close(resolve));
110
+ publicServer.closeAllConnections();
111
+ await closed;
112
+ publicServer = null;
113
+ emitter = null;
114
+
115
+ // Drain and stop any children concurrently
116
+ const children = [pendingChild?.process, activeChild?.process].filter(
117
+ /** @returns {child is ChildProcess} */
118
+ (child) => child !== undefined,
119
+ );
120
+ pendingChild = null;
121
+ activeChild = null;
122
+ await Promise.all(children.map(drainAndStopChild));
123
+
124
+ // Stop watching for file changes
125
+ tree.unwatch();
126
+ tree = null;
127
+ }
128
+
129
+ /**
130
+ * Give a child process a chance to finish any in-flight requests before we kill
131
+ * it.
132
+ *
133
+ * @param {ChildProcess} childProcess
134
+ */
135
+ async function drainAndStopChild(childProcess) {
136
+ if (childProcess.killed) {
137
+ return;
138
+ }
139
+
140
+ // Ask it to drain first.
141
+ try {
142
+ childProcess.send({ type: "DRAIN" });
143
+ } catch {
144
+ // ignore
145
+ }
146
+
147
+ const drained = new Promise((resolve) => {
148
+ const onMessage = (msg) => {
149
+ if (msg && typeof msg === "object" && msg.type === "DRAINED") {
150
+ cleanup(resolve);
151
+ }
152
+ };
153
+ const onExit = () => cleanup(resolve);
154
+
155
+ function cleanup(done) {
156
+ childProcess.off("message", onMessage);
157
+ childProcess.off("exit", onExit);
158
+ done();
159
+ }
160
+
161
+ childProcess.on("message", onMessage);
162
+ childProcess.on("exit", onExit);
163
+ });
164
+
165
+ // Give it a short grace window to finish in-flight work.
166
+ const GRACE_MS = 1500;
167
+ await Promise.race([
168
+ drained,
169
+ new Promise((r) => setTimeout(r, GRACE_MS).unref()),
170
+ ]);
171
+
172
+ if (!childProcess.killed) {
173
+ childProcess.kill("SIGTERM");
174
+ }
175
+
176
+ // Final escalation.
177
+ setTimeout(() => {
178
+ // Child should have exited by now, but if not kill it
179
+ if (!childProcess.killed) {
180
+ childProcess.kill("SIGKILL");
181
+ }
182
+ }, GRACE_MS).unref();
183
+ }
184
+
185
+ // Return the first open port number on or after the given port number.
186
+ async function findOpenPort(startPort = DEFAULT_PORT) {
187
+ for (let port = startPort; port <= 65535; port++) {
188
+ if (await isPortAvailable(port)) {
189
+ return port;
190
+ }
191
+ }
192
+
193
+ throw new Error(`No open port found on or after ${startPort}`);
194
+ }
195
+
196
+ function isJavaScriptFile(filePath) {
197
+ const extname = path.extname(filePath).toLowerCase();
198
+ const jsExtensions = [".cjs", ".js", ".mjs", ".ts"];
199
+ return jsExtensions.includes(extname);
200
+ }
201
+
202
+ function isPackageJsonFile(filePath) {
203
+ return path.basename(filePath).toLowerCase() === "package.json";
204
+ }
205
+
206
+ /**
207
+ * Check whether a port is available on both IPv4 and IPv6 loopback addresses
208
+ * by attempting TCP connections. On macOS, IPv4 and IPv6 port spaces are
209
+ * independent (IPV6_V6ONLY=1 by default), so a server bound to [::]:PORT is
210
+ * invisible to a 127.0.0.1 bind check. Using connect probes on both loopbacks
211
+ * catches servers regardless of which protocol family they listen on. Any
212
+ * connection error (ECONNREFUSED, EADDRNOTAVAIL, etc.) means nothing is
213
+ * listening there, so the function is safe on systems without IPv6.
214
+ *
215
+ * @param {number} port
216
+ * @returns {Promise<boolean>}
217
+ */
218
+ async function isPortAvailable(port) {
219
+ const [v4, v6] = await Promise.all([
220
+ isPortListening("127.0.0.1", port),
221
+ isPortListening("::1", port),
222
+ ]);
223
+ return !v4 && !v6;
224
+ }
225
+
226
+ /**
227
+ * @param {string} host
228
+ * @param {number} port
229
+ * @returns {Promise<boolean>}
230
+ */
231
+ function isPortListening(host, port) {
232
+ return new Promise((resolve) => {
233
+ const socket = net.createConnection(port, host);
234
+ socket.once("connect", () => {
235
+ socket.destroy();
236
+ resolve(true);
237
+ });
238
+ socket.once("error", () => {
239
+ socket.destroy();
240
+ resolve(false);
241
+ });
242
+ });
243
+ }
244
+
245
+ /**
246
+ * Proxy incoming requests to the active child server, or return a 503 if not
247
+ * ready.
248
+ *
249
+ * @param {import("node:http").IncomingMessage} request
250
+ * @param {import("node:http").ServerResponse} response
251
+ */
252
+ function proxyRequest(request, response) {
253
+ if (!activeChild) {
254
+ response.statusCode = 503;
255
+ response.setHeader("content-type", "text/plain; charset=utf-8");
256
+ response.end("Dev server is starting…\n");
257
+ return;
258
+ }
259
+
260
+ const { port } = activeChild;
261
+
262
+ // Minimal hop-by-hop header stripping
263
+ const headers = { ...request.headers };
264
+ delete headers.connection;
265
+ delete headers["proxy-connection"];
266
+ delete headers["keep-alive"];
267
+ delete headers.te;
268
+ delete headers.trailer;
269
+ delete headers["transfer-encoding"];
270
+ delete headers.upgrade;
271
+
272
+ const upstreamRequest = http.request(
273
+ {
274
+ host: PUBLIC_HOST,
275
+ port,
276
+ method: request.method,
277
+ path: request.url,
278
+ headers,
279
+ },
280
+ (upstreamResponse) => {
281
+ const { statusCode } = upstreamResponse;
282
+ response.writeHead(
283
+ statusCode ?? 502,
284
+ upstreamResponse.statusMessage,
285
+ upstreamResponse.headers,
286
+ );
287
+ upstreamResponse.pipe(response);
288
+
289
+ // Let caller know about the Origami error messages
290
+ if (statusCode !== undefined && statusCode >= 500 && emitter) {
291
+ const rawHeader = upstreamResponse.headers["x-error-details"];
292
+ const raw = Array.isArray(rawHeader) ? rawHeader[0] : rawHeader;
293
+ const message = raw ? decodeURIComponent(raw) : undefined;
294
+ if (message) {
295
+ emitter.emit("origami-error", message);
296
+ }
297
+ }
298
+ },
299
+ );
300
+
301
+ upstreamRequest.on("error", (err) => {
302
+ // Stop piping the request body
303
+ request.unpipe(upstreamRequest);
304
+ upstreamRequest.destroy();
305
+
306
+ // Only send error response if headers haven't been sent yet
307
+ if (!response.headersSent) {
308
+ response.statusCode = 502;
309
+ response.setHeader("content-type", "text/plain; charset=utf-8");
310
+ response.end(`Upstream error: ${err.message}\n`);
311
+ } else {
312
+ // Headers already sent, can't send error message - just close
313
+ response.destroy();
314
+ }
315
+ });
316
+
317
+ // Also handle errors on the incoming request
318
+ request.on("error", () => {
319
+ upstreamRequest.destroy();
320
+ });
321
+
322
+ request.pipe(upstreamRequest);
323
+ }
324
+
325
+ /**
326
+ * Start a new child process.
327
+ *
328
+ * This will be a pending process until it sends a READY message, at which point
329
+ * it becomes active and any previous active child is drained and stopped.
330
+ */
331
+ function startChild(options) {
332
+ const { debugFilesPath, enableUnsafeEval, expression, parentPath } = options;
333
+
334
+ // Start the child process, passing parent path via an environment variable.
335
+ /** @type {ChildProcess} */
336
+ let childProcess;
337
+ try {
338
+ childProcess = fork(childModuleUrl, [], {
339
+ stdio: ["inherit", "inherit", "inherit", "ipc"],
340
+ env: {
341
+ ...process.env,
342
+ ORIGAMI_DEBUG_FILES_PATH: debugFilesPath,
343
+ ORIGAMI_ENABLE_UNSAFE_EVAL: enableUnsafeEval ? "1" : "0",
344
+ ORIGAMI_EXPRESSION: expression,
345
+ ORIGAMI_PARENT_PATH: parentPath,
346
+ },
347
+ });
348
+ } catch (error) {
349
+ throw new Error("Dev.debug2: failed to start child server:", {
350
+ cause: error,
351
+ });
352
+ }
353
+
354
+ // This becomes the pending child immediately
355
+ pendingChild = { process: childProcess, port: null };
356
+
357
+ // Listen for messages from the child about its status
358
+ childProcess.on("message", (/** @type {any} */ message) => {
359
+ if (!message || typeof message !== "object") {
360
+ return;
361
+ }
362
+
363
+ if (message.type === "READY" && typeof message.port === "number") {
364
+ // Only promote to active if this is still the pending child
365
+ if (pendingChild?.process === childProcess) {
366
+ const previousChild = activeChild;
367
+
368
+ activeChild = pendingChild;
369
+ pendingChild.port = message.port;
370
+ pendingChild = null;
371
+
372
+ // Drain previous child in background (don't wait)
373
+ if (previousChild?.process && previousChild.process !== childProcess) {
374
+ drainAndStopChild(previousChild.process).catch((err) =>
375
+ console.error("[drain]", err),
376
+ );
377
+ }
378
+
379
+ if (!emitter) {
380
+ console.warn(
381
+ "Dev.debug2: child server is ready but parent emitter is gone, cannot emit ready event",
382
+ );
383
+ return;
384
+ }
385
+ emitter.emit("ready", {
386
+ origin: publicOrigin,
387
+ });
388
+ } else {
389
+ // This child was superseded by a newer one, kill it
390
+ // console.log("Child process superseded by newer one, killing it...");
391
+ childProcess.kill("SIGTERM");
392
+ }
393
+ }
394
+
395
+ if (message.type === "FATAL") {
396
+ // Child couldn't start (import error, etc.)
397
+ // Keep previous active child if any; otherwise we'll serve 500/503.
398
+ console.error("[child fatal]", message.error ?? message);
399
+ if (pendingChild?.process === childProcess) {
400
+ pendingChild = null;
401
+ }
402
+ }
403
+ });
404
+
405
+ childProcess.on("exit", (code, signal) => {
406
+ if (activeChild?.process === childProcess) {
407
+ // Active child died unexpectedly.
408
+ activeChild = null;
409
+ }
410
+ if (pendingChild?.process === childProcess) {
411
+ pendingChild = null;
412
+ }
413
+ });
414
+ }
@@ -9,9 +9,10 @@ import {
9
9
  scope,
10
10
  trailingSlash,
11
11
  } from "@weborigami/async-tree";
12
- import { projectGlobals } from "@weborigami/language";
12
+ import { OrigamiFileMap } from "@weborigami/language";
13
13
  import indexPage from "../../origami/indexPage.js";
14
14
  import yaml from "../../origami/yaml.js";
15
+ import debugCommands from "./debugCommands.js";
15
16
 
16
17
  /**
17
18
  * Transform the given map-based tree to add debugging resources:
@@ -23,9 +24,19 @@ import yaml from "../../origami/yaml.js";
23
24
  * Also transform a simple object result to YAML for viewing.
24
25
  *
25
26
  * @param {import("@weborigami/async-tree").Maplike} maplike
27
+ * @param {string} debugFilesPath
28
+ * @param {boolean} enableUnsafeEval
26
29
  */
27
- export default function debugTransform(maplike) {
30
+ export default function debugTransform(
31
+ maplike,
32
+ debugFilesPath = "",
33
+ enableUnsafeEval = false,
34
+ ) {
28
35
  const source = Tree.from(maplike, { deep: true });
36
+ const commands = debugCommands(enableUnsafeEval);
37
+
38
+ const debugFiles = debugFilesPath ? new OrigamiFileMap(debugFilesPath) : null;
39
+
29
40
  return Object.assign(new AsyncMap(), {
30
41
  description: "debug resources",
31
42
 
@@ -33,15 +44,22 @@ export default function debugTransform(maplike) {
33
44
  // Ask the tree if it has the key.
34
45
  let value = await source.get(key);
35
46
 
47
+ if (value === undefined && debugFiles) {
48
+ // Try the debug files
49
+ value = await debugFiles.get(key);
50
+ }
51
+
36
52
  if (value === undefined) {
37
- // The tree doesn't have the key; try the defaults.
38
- if (key === "index.html") {
53
+ // Try the defaults and commands
54
+ if (key === "_debugger/") {
55
+ return debugFiles;
56
+ } else if (key === "index.html") {
39
57
  // Generate an index page for this site
40
58
  value = await indexPage(source);
41
59
  } else if (key === ".keys.json") {
42
60
  value = await jsonKeys.stringify(source);
43
61
  } else if (typeof key === "string" && key.startsWith("!")) {
44
- value = await invokeOrigamiCommand(source, key);
62
+ value = await invokeOrigamiCommand(commands, source, key);
45
63
  }
46
64
  }
47
65
 
@@ -60,8 +78,9 @@ export default function debugTransform(maplike) {
60
78
  }
61
79
 
62
80
  if (Tree.isMap(value)) {
63
- // Ensure this transform is applied to any map result
64
- value = debugTransform(value);
81
+ // Ensure this transform is applied to any map result.
82
+ // Note: debugFilesPath only needed at top level.
83
+ value = debugTransform(value, "", enableUnsafeEval);
65
84
  } else if (value?.unpack) {
66
85
  // If the value isn't a tree, but has a tree attached via an `unpack`
67
86
  // method, wrap the unpack method to add this transform.
@@ -95,13 +114,12 @@ export default function debugTransform(maplike) {
95
114
  });
96
115
  }
97
116
 
98
- async function invokeOrigamiCommand(tree, key) {
117
+ async function invokeOrigamiCommand(commands, tree, key) {
99
118
  // Key is an Origami command; invoke it.
100
- const globals = await projectGlobals(tree);
101
119
  const commandName = trailingSlash.remove(key.slice(1).trim());
102
120
 
103
- // Look for command as a global or Dev command
104
- const command = globals[commandName] ?? globals.Dev?.[commandName];
121
+ // Look for the indicated command
122
+ const command = commands[commandName];
105
123
  let value;
106
124
  if (command) {
107
125
  value = await command(tree);
@@ -14,10 +14,15 @@ let version = 0;
14
14
  * Evaluate the given expression using the indicated parent path to produce a
15
15
  * resource tree, then transform that tree with debug resources and return it.
16
16
  *
17
- * @param {string} expression
18
- * @param {string} parentPath
17
+ * @param {Object} options
18
+ * @param {string} options.debugFilesPath
19
+ * @param {boolean} options.enableUnsafeEval
20
+ * @param {string} options.expression
21
+ * @param {string} options.parentPath
19
22
  */
20
- export default async function expressionTree(expression, parentPath) {
23
+ export default async function expressionTree(options) {
24
+ const { debugFilesPath, expression, parentPath, enableUnsafeEval } = options;
25
+
21
26
  const parent = new OrigamiFileMap(parentPath);
22
27
  const globals = await projectGlobals(parent);
23
28
 
@@ -42,7 +47,7 @@ export default async function expressionTree(expression, parentPath) {
42
47
  setParent(maplike, parent);
43
48
 
44
49
  // Add debugging resources
45
- const tree = debugTransform(maplike);
50
+ const tree = debugTransform(maplike, debugFilesPath, enableUnsafeEval);
46
51
 
47
52
  /** @type {any} */ (tree).version = version++;
48
53
 
@@ -0,0 +1,17 @@
1
+ import ori from "../../origami/ori.js";
2
+
3
+ let lastExpression;
4
+ let lastResult;
5
+
6
+ export default async function oriEval(parent) {
7
+ return async (encodedExpression) => {
8
+ console.log(encodedExpression);
9
+ const expression = decodeURIComponent(encodedExpression);
10
+ if (expression === lastExpression) {
11
+ return lastResult;
12
+ }
13
+ lastExpression = expression;
14
+ lastResult = await ori(expression, { parent });
15
+ return lastResult;
16
+ };
17
+ }
package/src/dev/dev.js CHANGED
@@ -12,6 +12,7 @@ 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
14
  export { default as debug2 } from "./debug2/debug2.js";
15
+ export { default as eval } from "./debug2/oriEval.js";
15
16
  export { default as explore } from "./explore.js";
16
17
  export { default as help } from "./help.js";
17
18
  export { default as log } from "./log.js";
package/src/dev/help.yaml CHANGED
@@ -211,6 +211,9 @@ Tree:
211
211
  deepValues:
212
212
  args: (tree)
213
213
  description: The in-order leaf values of the tree
214
+ deflatePaths:
215
+ args: (tree)
216
+ description: Convert a tree to a mapping of paths to values
214
217
  delete:
215
218
  args: (map, key)
216
219
  description: Delete the value for the key from map
@@ -244,6 +247,9 @@ Tree:
244
247
  indent:
245
248
  args: "`…`"
246
249
  description: Tagged template literal for normalizing indentation
250
+ inflatePaths:
251
+ args: (map)
252
+ description: Convert mapping of paths to values into a tree
247
253
  inners:
248
254
  args: (tree)
249
255
  description: The tree's interior nodes
@@ -1,4 +1,9 @@
1
- import { Tree, keysFromPath, trailingSlash } from "@weborigami/async-tree";
1
+ import {
2
+ TraverseError,
3
+ Tree,
4
+ keysFromPath,
5
+ trailingSlash,
6
+ } from "@weborigami/async-tree";
2
7
  import { formatError } from "@weborigami/language";
3
8
  import { ServerResponse } from "node:http";
4
9
  import constructResponse from "./constructResponse.js";
@@ -138,9 +143,16 @@ ${message}
138
143
  </body>
139
144
  </html>
140
145
  `;
141
- response.writeHead(500, { "Content-Type": "text/html" });
146
+ response.writeHead(500, {
147
+ "Content-Type": "text/html",
148
+ "x-error-details": encodeURIComponent(message),
149
+ });
142
150
  response.end(html, "utf-8");
143
- console.error(message);
151
+
152
+ // Don't log traverse errors for requests like favicon.ico, com.chrome.devtools.json, etc.
153
+ if (!(error instanceof TraverseError)) {
154
+ console.error(message);
155
+ }
144
156
  }
145
157
 
146
158
  // Asynchronous tree router as Express middleware.