@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@weborigami/origami",
3
- "version": "0.6.10",
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": "24.10.1",
16
+ "@types/node": "25.3.2",
17
17
  "typescript": "5.9.3"
18
18
  },
19
19
  "dependencies": {
20
- "@weborigami/async-tree": "0.6.10",
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.10",
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.2.0",
27
- "marked": "17.0.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.1"
32
+ "yaml": "2.8.2"
33
33
  },
34
34
  "scripts": {
35
35
  "test": "node --test --test-reporter=spec",
@@ -1,4 +1,10 @@
1
- import { args, trailingSlash, Tree } from "@weborigami/async-tree";
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?.toString && newValue?.toString) {
48
- const oldText = oldValue.toString();
49
- const newText = newValue.toString();
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
@@ -45,6 +45,9 @@ Dev:
45
45
  version:
46
46
  args: ()
47
47
  description: Return the version number of the Origami language
48
+ visit:
49
+ args: (tree)
50
+ description: Force reading of all values from the tree
48
51
  watch:
49
52
  args: (tree, fn)
50
53
  description: Reevaluate fn when tree changes
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 graphvizLoaded = false;
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 (!graphvizLoaded) {
19
- await graphviz.loadWASM();
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.layout(dotText, "svg");
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";
@@ -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.container);
20
+ return handleExtension(value, key, state?.parent);
21
21
  }
22
22
  fetchBuiltin.needsState = true;
@@ -61,6 +61,7 @@ ${list}
61
61
 
62
62
  /** @type {any} */
63
63
  const result = new String(html);
64
+ result.mediaType = "text/html; charset=utf-8";
64
65
  result.unpack = () => tree;
65
66
  return result;
66
67
  }
@@ -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
 
@@ -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
- extension,
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. Return null
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|null>}
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
- // Infer expected media type from file extension on request URL.
58
+ // Do we know the media type based on the URL extension?
62
59
  const ext = extension.extname(url.pathname).toLowerCase();
63
- mediaType = ext ? mediaTypeForExtension[ext] : undefined;
64
- }
65
-
66
- if (
67
- (mediaType === "application/json" || mediaType === "text/yaml") &&
68
- !isStringlike(resource)
69
- ) {
70
- // The request is for a JSON or YAML result, and the resource we got isn't
71
- // yet a string: convert the resource to JSON or YAML now.
72
- const tree = Tree.from(resource);
73
- resource =
74
- mediaType === "text/yaml"
75
- ? await serialize.toYaml(tree)
76
- : await serialize.toJson(tree);
77
- } else if (
78
- mediaType === undefined &&
79
- (isPlainObject(resource) || resource instanceof Array)
80
- ) {
81
- // The resource is data, try showing it as YAML.
82
- const tree = Tree.from(resource);
83
- resource = await serialize.toYaml(tree);
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
- console.error(
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
- }
@@ -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
- // Construct the response.
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 ends with a trailing slash, the final key will be an empty
88
- // string. Change that to "index.html".
89
- if (keys.at(-1) === "") {
90
- keys[keys.length - 1] = "index.html";
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
- // Ignore exceptions that come up with sending a Not Found response.
110
- try {
111
- response.writeHead(404, { "Content-Type": "text/html" });
112
- response.end(`Not found`, "utf-8");
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
  }