@weborigami/origami 0.6.14 → 0.6.16

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,6 +1,9 @@
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";
4
+ export { default as debugTransform } from "./src/dev/debug2/debugTransform.js";
3
5
  export * as Dev from "./src/dev/dev.js";
6
+ export * from "./src/handlers/origamiHandlers.js";
4
7
  export * as Origami from "./src/origami/origami.js";
5
8
  export { default as origamiHighlightDefinition } from "./src/origami/origamiHighlightDefinition.js";
6
9
  export { default as constructResponse } from "./src/server/constructResponse.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weborigami/origami",
3
- "version": "0.6.14",
3
+ "version": "0.6.16",
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.14",
21
+ "@weborigami/async-tree": "0.6.16",
22
22
  "@weborigami/json-feed-to-rss": "1.0.1",
23
- "@weborigami/language": "0.6.14",
23
+ "@weborigami/language": "0.6.16",
24
24
  "css-tree": "3.1.0",
25
25
  "highlight.js": "11.11.1",
26
26
  "jsdom": "28.1.0",
@@ -29,6 +29,7 @@
29
29
  "marked-highlight": "2.2.3",
30
30
  "marked-smartypants": "1.1.11",
31
31
  "sharp": "0.34.5",
32
+ "whatwg-mimetype": "5.0.0",
32
33
  "yaml": "2.8.2"
33
34
  },
34
35
  "scripts": {
@@ -0,0 +1,58 @@
1
+ import net from "node:net";
2
+
3
+ const DEFAULT_PORT = 5000;
4
+
5
+ /**
6
+ * Return the first open port number on or after the given port number.
7
+ *
8
+ * @param {number} startPort
9
+ * @returns {Promise<number>}
10
+ */
11
+ export async function findOpenPort(startPort = DEFAULT_PORT) {
12
+ for (let port = startPort; port <= 65535; port++) {
13
+ if (await isPortAvailable(port)) {
14
+ return port;
15
+ }
16
+ }
17
+
18
+ throw new Error(`No open port found on or after ${startPort}`);
19
+ }
20
+
21
+ /**
22
+ * Check whether a port is available on both IPv4 and IPv6 loopback addresses
23
+ * by attempting TCP connections. On macOS, IPv4 and IPv6 port spaces are
24
+ * independent (IPV6_V6ONLY=1 by default), so a server bound to [::]:PORT is
25
+ * invisible to a 127.0.0.1 bind check. Using connect probes on both loopbacks
26
+ * catches servers regardless of which protocol family they listen on. Any
27
+ * connection error (ECONNREFUSED, EADDRNOTAVAIL, etc.) means nothing is
28
+ * listening there, so the function is safe on systems without IPv6.
29
+ *
30
+ * @param {number} port
31
+ * @returns {Promise<boolean>}
32
+ */
33
+ async function isPortAvailable(port) {
34
+ const [v4, v6] = await Promise.all([
35
+ isPortListening("127.0.0.1", port),
36
+ isPortListening("::1", port),
37
+ ]);
38
+ return !v4 && !v6;
39
+ }
40
+
41
+ /**
42
+ * @param {string} host
43
+ * @param {number} port
44
+ * @returns {Promise<boolean>}
45
+ */
46
+ function isPortListening(host, port) {
47
+ return new Promise((resolve) => {
48
+ const socket = net.createConnection(port, host);
49
+ socket.once("connect", () => {
50
+ socket.destroy();
51
+ resolve(true);
52
+ });
53
+ socket.once("error", () => {
54
+ socket.destroy();
55
+ resolve(false);
56
+ });
57
+ });
58
+ }
@@ -1,37 +1,20 @@
1
1
  import { OrigamiFileMap } from "@weborigami/language";
2
- import { fork } from "node:child_process";
3
- import http from "node:http";
4
- import net from "node:net";
5
2
  import path from "node:path";
6
-
7
- const PUBLIC_HOST = "127.0.0.1";
8
- const DEFAULT_PORT = 5000;
9
-
10
- // Module that loads the server in the child process
11
- const childModuleUrl = new URL("./debugChild.js", import.meta.url);
12
-
13
- // The active child process and port
14
- /** @typedef {import("node:child_process").ChildProcess} ChildProcess */
15
- /** @typedef {{ process: ChildProcess, port: number | null }} ChildInfo */
16
- /** @type {ChildInfo | null} */
17
- let activeChild = null;
18
-
19
- // The most recently started child (may not be ready yet)
20
- /** @type {ChildInfo | null} */
21
- let pendingChild = null;
3
+ import debugParent from "./debugParent.js";
22
4
 
23
5
  /**
24
- * Given an Origami function, determine the runtime state's parent container,
25
- * then start a new debug server with that parent as the root of the resource
26
- * tree.
6
+ * Given an Origami expression, start a new debug server with that parent as the
7
+ * root of the resource tree.
8
+ *
9
+ * This function expects unevaluated arguments. This is what it allows it to
10
+ * extract the source code of the expression to be debugged. (If it were
11
+ * evaluated, the function will be called with the result of the expression.)
27
12
  *
28
- * This function expects an unevaluated expression. It will obtain the original
29
- * source of the expression and pass that to the child for evaluation. This
30
- * arrangement ensures the expression is evaluated in a clean Node context (not
31
- * polluted by previous evaluations).
13
+ * @typedef {import("@weborigami/language").RuntimeState} RuntimeState
14
+ * @typedef {import("@weborigami/language").AnnotatedCode} AnnotatedCode
32
15
  *
33
- * @param {import("@weborigami/language").AnnotatedCode} code
34
- * @param {import("@weborigami/language").RuntimeState} state
16
+ * @param {AnnotatedCode} code
17
+ * @param {RuntimeState} state
35
18
  */
36
19
  export default async function debug2(code, state) {
37
20
  if (
@@ -43,6 +26,9 @@ export default async function debug2(code, state) {
43
26
  "Dev.debug2 expects an Origami expression to evaluate: `debug2 <expression>`",
44
27
  );
45
28
  }
29
+
30
+ const expression = code.source;
31
+
46
32
  const { parent } = state;
47
33
  // @ts-ignore
48
34
  const parentPath = parent?.path;
@@ -50,301 +36,45 @@ export default async function debug2(code, state) {
50
36
  throw new Error("Dev.debug2 couldn't work out the parent path.");
51
37
  }
52
38
 
53
- const serverOptions = {
54
- expression: code.source,
55
- parent: parentPath,
56
- };
39
+ // Start the debug server
40
+ const server = await debugParent({
41
+ expression,
42
+ parentPath,
43
+ });
57
44
 
45
+ // Watch the parent files for changes
58
46
  const tree = new OrigamiFileMap(parentPath);
59
47
  tree.watch();
60
- tree.addEventListener?.("change", (event) => {
48
+ tree.addEventListener?.("change", async (event) => {
61
49
  // @ts-ignore
62
50
  const { filePath } = event.options;
63
51
  if (isJavaScriptFile(filePath)) {
64
52
  // Need to restart the child process
65
53
  console.log("JavaScript file changed, restarting server…");
66
- startChild(serverOptions);
67
- } else if (isPackageJsonFile(filePath)) {
54
+ await server.restart();
55
+ } else if (path.basename(filePath) === "package.json") {
68
56
  // Need to restart the child process
69
57
  console.log("package.json changed, restarting server…");
70
- startChild(serverOptions);
58
+ await server.restart();
71
59
  } else {
72
60
  // Just have the child reevaluate the expression
73
61
  console.log("File changed, reloading site…");
74
- activeChild?.process.send({ type: "REEVALUATE" });
62
+ await server.reevaluate();
75
63
  }
76
64
  });
77
65
 
78
- const port = await findOpenPort(PUBLIC_HOST);
79
-
80
- // ---- Public server
81
- const publicServer = http.createServer(proxyRequest);
82
- publicServer.listen(port, PUBLIC_HOST, () => {
83
- startChild(serverOptions);
84
- console.log(
85
- `Server running at http://localhost:${port}. Press Ctrl+C to stop.`,
86
- );
66
+ // When server closes, stop watching for file changes
67
+ server.on("close", () => {
68
+ tree.unwatch();
87
69
  });
70
+
71
+ console.log(`Server running at ${server.origin}. Press Ctrl+C to stop.`);
88
72
  }
89
73
  debug2.needsState = true;
90
74
  debug2.unevaluatedArgs = true;
91
75
 
92
- /**
93
- * Give a child process a chance to finish any in-flight requests before we kill
94
- * it.
95
- *
96
- * @param {ChildProcess} childProcess
97
- */
98
- async function drainAndStopChild(childProcess) {
99
- if (childProcess.killed) {
100
- return;
101
- }
102
-
103
- // Ask it to drain first.
104
- try {
105
- childProcess.send({ type: "DRAIN" });
106
- } catch {
107
- // ignore
108
- }
109
-
110
- const drained = new Promise((resolve) => {
111
- const onMessage = (msg) => {
112
- if (msg && typeof msg === "object" && msg.type === "DRAINED") {
113
- cleanup(resolve);
114
- }
115
- };
116
- const onExit = () => cleanup(resolve);
117
-
118
- function cleanup(done) {
119
- childProcess.off("message", onMessage);
120
- childProcess.off("exit", onExit);
121
- done();
122
- }
123
-
124
- childProcess.on("message", onMessage);
125
- childProcess.on("exit", onExit);
126
- });
127
-
128
- // Give it a short grace window to finish in-flight work.
129
- const GRACE_MS = 1500;
130
- await Promise.race([
131
- drained,
132
- new Promise((r) => setTimeout(r, GRACE_MS).unref()),
133
- ]);
134
-
135
- if (!childProcess.killed) {
136
- childProcess.kill("SIGTERM");
137
- }
138
-
139
- // Final escalation.
140
- setTimeout(() => {
141
- // Child should have exited by now, but if not kill it
142
- if (!childProcess.killed) {
143
- childProcess.kill("SIGKILL");
144
- }
145
- }, GRACE_MS).unref();
146
- }
147
-
148
- // Return the first open port number on or after the given port number.
149
- async function findOpenPort(host, startPort = DEFAULT_PORT) {
150
- for (let port = startPort; port <= 65535; port++) {
151
- const open = await isPortAvailable(host, port);
152
- if (open) {
153
- return port;
154
- }
155
- }
156
-
157
- throw new Error(`No open port found on or after ${startPort}`);
158
- }
159
-
160
76
  function isJavaScriptFile(filePath) {
161
77
  const extname = path.extname(filePath).toLowerCase();
162
78
  const jsExtensions = [".cjs", ".js", ".mjs", ".ts"];
163
79
  return jsExtensions.includes(extname);
164
80
  }
165
-
166
- function isPackageJsonFile(filePath) {
167
- return path.basename(filePath).toLowerCase() === "package.json";
168
- }
169
-
170
- /**
171
- * Check whether a TCP port can be bound.
172
- *
173
- * @param {string} host
174
- * @param {number} port
175
- * @returns {Promise<boolean>}
176
- */
177
- async function isPortAvailable(host, port) {
178
- return new Promise((resolve) => {
179
- const server = net.createServer();
180
-
181
- server.unref();
182
-
183
- server.once("error", (/** @type {any} */ error) => {
184
- // Port is unavailable or cannot be bound on this host.
185
- if (
186
- error.code === "EADDRINUSE" ||
187
- error.code === "EACCES" ||
188
- error.code === "EADDRNOTAVAIL"
189
- ) {
190
- resolve(false);
191
- } else {
192
- resolve(false);
193
- }
194
- });
195
-
196
- server.once("listening", () => {
197
- server.close(() => resolve(true));
198
- });
199
-
200
- server.listen(port, host);
201
- });
202
- }
203
-
204
- /**
205
- * Proxy incoming requests to the active child server, or return a 503 if not
206
- * ready.
207
- *
208
- * @param {import("node:http").IncomingMessage} request
209
- * @param {import("node:http").ServerResponse} response
210
- */
211
- function proxyRequest(request, response) {
212
- if (!activeChild) {
213
- response.statusCode = 503;
214
- response.setHeader("content-type", "text/plain; charset=utf-8");
215
- response.end("Dev server is starting…\n");
216
- return;
217
- }
218
-
219
- const { port } = activeChild;
220
-
221
- // Minimal hop-by-hop header stripping
222
- const headers = { ...request.headers };
223
- delete headers.connection;
224
- delete headers["proxy-connection"];
225
- delete headers["keep-alive"];
226
- delete headers.te;
227
- delete headers.trailer;
228
- delete headers["transfer-encoding"];
229
- delete headers.upgrade;
230
-
231
- const upstreamRequest = http.request(
232
- {
233
- host: PUBLIC_HOST,
234
- port,
235
- method: request.method,
236
- path: request.url,
237
- headers,
238
- },
239
- (upstreamResponse) => {
240
- response.writeHead(
241
- upstreamResponse.statusCode ?? 502,
242
- upstreamResponse.statusMessage,
243
- upstreamResponse.headers,
244
- );
245
- upstreamResponse.pipe(response);
246
- },
247
- );
248
-
249
- upstreamRequest.on("error", (err) => {
250
- // Stop piping the request body
251
- request.unpipe(upstreamRequest);
252
- upstreamRequest.destroy();
253
-
254
- // Only send error response if headers haven't been sent yet
255
- if (!response.headersSent) {
256
- response.statusCode = 502;
257
- response.setHeader("content-type", "text/plain; charset=utf-8");
258
- response.end(`Upstream error: ${err.message}\n`);
259
- } else {
260
- // Headers already sent, can't send error message - just close
261
- response.destroy();
262
- }
263
- });
264
-
265
- // Also handle errors on the incoming request
266
- request.on("error", () => {
267
- upstreamRequest.destroy();
268
- });
269
-
270
- request.pipe(upstreamRequest);
271
- }
272
-
273
- /**
274
- * Start a new child process.
275
- *
276
- * This will be a pending process until it sends a READY message, at which point
277
- * it becomes active and any previous active child is drained and stopped.
278
- */
279
- function startChild(serverOptions) {
280
- const { expression, parent } = serverOptions;
281
-
282
- // Start the child process, passing parent path via an environment variable.
283
- /** @type {ChildProcess} */
284
- let childProcess;
285
- try {
286
- childProcess = fork(childModuleUrl, [], {
287
- stdio: ["inherit", "inherit", "inherit", "ipc"],
288
- env: {
289
- ...process.env,
290
- ORIGAMI_EXPRESSION: expression,
291
- ORIGAMI_PARENT: parent,
292
- },
293
- });
294
- } catch (error) {
295
- throw new Error("Dev.debug2: failed to start child server:", {
296
- cause: error,
297
- });
298
- }
299
-
300
- // This becomes the pending child immediately
301
- pendingChild = { process: childProcess, port: null };
302
-
303
- // Listen for messages from the child about its status
304
- childProcess.on("message", (/** @type {any} */ message) => {
305
- if (!message || typeof message !== "object") {
306
- return;
307
- }
308
-
309
- if (message.type === "READY" && typeof message.port === "number") {
310
- // Only promote to active if this is still the pending child
311
- if (pendingChild?.process === childProcess) {
312
- const previousChild = activeChild;
313
-
314
- activeChild = pendingChild;
315
- pendingChild.port = message.port;
316
- pendingChild = null;
317
-
318
- // Drain previous child in background (don't wait)
319
- if (previousChild?.process && previousChild.process !== childProcess) {
320
- drainAndStopChild(previousChild.process).catch((err) =>
321
- console.error("[drain]", err),
322
- );
323
- }
324
- } else {
325
- // This child was superseded by a newer one, kill it
326
- // console.log("Child process superseded by newer one, killing it...");
327
- childProcess.kill("SIGTERM");
328
- }
329
- }
330
-
331
- if (message.type === "FATAL") {
332
- // Child couldn't start (import error, etc.)
333
- // Keep previous active child if any; otherwise we'll serve 500/503.
334
- console.error("[child fatal]", message.error ?? message);
335
- if (pendingChild?.process === childProcess) {
336
- pendingChild = null;
337
- }
338
- }
339
- });
340
-
341
- childProcess.on("exit", (code, signal) => {
342
- if (activeChild?.process === childProcess) {
343
- // Active child died unexpectedly.
344
- activeChild = null;
345
- }
346
- if (pendingChild?.process === childProcess) {
347
- pendingChild = null;
348
- }
349
- });
350
- }
@@ -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.
@@ -31,11 +31,13 @@ if (expression === undefined) {
31
31
 
32
32
  /** @type {string} */
33
33
  // @ts-ignore
34
- const parentPath = process.env.ORIGAMI_PARENT;
34
+ const parentPath = process.env.ORIGAMI_PARENT_PATH;
35
35
  if (parentPath === undefined) {
36
36
  fail("Missing Origami parent");
37
37
  }
38
38
 
39
+ const quiet = process.env.ORIGAMI_QUIET === "1";
40
+
39
41
  // An indirect pointer to the tree of resources;
40
42
  let treeHandle = {};
41
43
 
@@ -43,7 +45,7 @@ let treeHandle = {};
43
45
  await evaluateExpression();
44
46
 
45
47
  // Serve the tree of resources
46
- const listener = requestListener(treeHandle);
48
+ const listener = requestListener(treeHandle, { quiet });
47
49
  const server = http.createServer(listener);
48
50
 
49
51
  // Track live connections so we can drain/close cleanly.
@@ -88,7 +90,10 @@ function beginDrain() {
88
90
  }
89
91
 
90
92
  async function evaluateExpression() {
91
- const tree = await expressionTree(expression, parentPath);
93
+ const tree = await expressionTree({
94
+ expression,
95
+ parentPath,
96
+ });
92
97
  if (!tree) {
93
98
  fail("Dev.debug2: expression did not evaluate to a maplike resource tree");
94
99
  }
@@ -106,6 +111,8 @@ async function evaluateExpression() {
106
111
  } catch {
107
112
  // Ignore errors.
108
113
  }
114
+
115
+ process.send?.({ type: "EVALUATED" });
109
116
  }
110
117
 
111
118
  function maybeFinishDrain() {
@@ -0,0 +1,11 @@
1
+ // Subset of commands made available via debugTransform
2
+
3
+ import { Tree } from "@weborigami/async-tree";
4
+ export const keys = Tree.keys;
5
+ export const json = Tree.json;
6
+
7
+ export { default as index } from "../../origami/indexPage.js";
8
+ export { default as yaml } from "../../origami/yaml.js";
9
+ export { default as explore } from "../explore.js";
10
+ export { default as svg } from "../svg.js";
11
+ export { default as version } from "../version.js";