ketatlas 0.2.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - Add an opt-in two-port native renderer contract: KetAtlas serves the viewer while the declared React, Vue, KetJS, or other framework server owns screen HTML, assets, and same-origin behavior.
6
+ - Add `atlas.renderer.json`, its public schema, command-array validation, readiness checks, predictable renderer shutdown, and route-aware static audit.
7
+ - Add `screenBaseURL` to the browser API so screen routes can resolve on a different origin while external references continue to resolve beside `atlas.json`.
8
+ - Require the bundled agent skill to use the product's real framework and design-system components, share presenters/styles across atlases, and keep business data outside presentation components.
9
+
10
+ ## 0.3.0
11
+
12
+ - Standardize discoverable projects as `<name>.ketatlas/atlas.json` bundles, add `discover`, accept bundle directories in Atlas commands, and teach the bundled skill to backfill legacy projects safely.
13
+ - Accelerate drag, wheel, keyboard and pinch camera controls while preserving pointer-anchored zoom.
14
+ - Require agents to confirm a design system before visual mockup work, with Auto defaulting to Két Design System and explicit Carbon, Primer, Fluent 2, no-system, and custom-source options.
15
+
3
16
  ## 0.2.5
4
17
 
5
18
  - Show separate Checks and Implemented percentage badges per workflow. Implemented includes Verified screens; project verification remains separate on the top bar. Display a dash for Checks when no checklist is recorded.
package/README.md CHANGED
@@ -1,14 +1,17 @@
1
1
  # KetAtlas
2
2
 
3
- **Scaffold, serve, and audit interactive HTML workflow maps.**
3
+ **Scaffold, serve, and audit interactive framework-native workflow maps.**
4
4
 
5
- Turn a JSON file and your existing HTML screens into a canvas you can drag, zoom, and explore. Connect screens with labelled arrows, model decisions and recovery paths, and open the real HTML to try a step. The same tool supports mobile screens, web pages, and processes with no screens at all.
5
+ Turn a JSON file and screens from your existing HTML, React, Vue, KetJS, or other framework into a canvas you can drag, zoom, and explore. Connect screens with labelled arrows, model decisions and recovery paths, and open the real rendered page to try a step. The same tool supports mobile screens, web pages, and processes with no screens at all.
6
6
 
7
- KetAtlas has an English UI, zero runtime npm dependencies, and no build step. Node.js **22+** is needed for the CLI. Viewers use native ES modules and run on an HTTP server.
7
+ KetAtlas has an English UI and zero runtime npm dependencies. Node.js **22+** is needed for the CLI. Static projects need no build step. Framework-native projects reuse their product's own server and dependencies; KetAtlas supervises it on a separate localhost port.
8
8
 
9
9
  ## Demo
10
10
 
11
- See a mobile workflow map in action: explore connected screens and try the HTML prototype.
11
+ Open the live map at **[atlas.ketsuite.com](https://atlas.ketsuite.com)**: drag, zoom, follow the
12
+ labelled arrows, and open a real HTML prototype from any screen. Nothing to install.
13
+
14
+ The same map recorded as a video, for a quick look:
12
15
 
13
16
  https://github.com/user-attachments/assets/13f24fc8-7b8e-4bda-9e02-364c120ee163
14
17
 
@@ -17,25 +20,26 @@ https://github.com/user-attachments/assets/13f24fc8-7b8e-4bda-9e02-364c120ee163
17
20
  [KetAtlas is available on npm](https://www.npmjs.com/package/ketatlas). Use Node.js 22+ and run it from any directory:
18
21
 
19
22
  ```sh
20
- npx --yes ketatlas@0.2.5 scaffold my-atlas --template web
21
- npx --yes ketatlas@0.2.5 serve my-atlas/atlas.json
22
- npx --yes ketatlas@0.2.5 audit my-atlas/atlas.json --strict
23
+ npx --yes ketatlas scaffold my-atlas.ketatlas --template web
24
+ npx --yes ketatlas discover . --json
25
+ npx --yes ketatlas serve my-atlas.ketatlas
26
+ npx --yes ketatlas audit my-atlas.ketatlas --strict
23
27
  ```
24
28
 
25
29
  Or install the CLI once for your user account:
26
30
 
27
31
  ```sh
28
- npm install --global ketatlas@0.2.5
29
- ketatlas scaffold my-atlas
30
- ketatlas serve my-atlas/atlas.json
31
- ketatlas audit my-atlas/atlas.json --strict
32
+ npm install --global ketatlas
33
+ ketatlas scaffold my-atlas.ketatlas
34
+ ketatlas serve my-atlas.ketatlas
35
+ ketatlas audit my-atlas.ketatlas --strict
32
36
  ```
33
37
 
34
- The consumer folder contains `atlas.json`, its schema, product HTML/CSS/JavaScript, local assets, and documentation. It needs no `package.json`, lockfile, `node_modules`, build step, or copy of the viewer. The CLI supplies scaffold, serving, and static audit from its own installation. `audit` leaves project files unchanged unless an output file is explicitly requested. Viewing with `serve` does not write; explicit **Save progress** writes the sibling progress file. Use `--read-only` to disable editing.
38
+ The consumer bundle is named `<name>.ketatlas/` and contains `atlas.json`, its schema, documentation, and either static screen assets or an `atlas.renderer.json` sidecar. The stable directory suffix lets desktop tools discover atlases without parsing unrelated JSON. A bundle needs no KetAtlas package manifest, lockfile, `node_modules`, build step, or copy of the viewer. Framework dependencies and reusable UI stay in the product workspace, where multiple atlases can share one set of presenters, components, fixtures, and styles. The CLI supplies discovery, serving, validation, and audit. `audit` leaves project files unchanged unless an output file is explicitly requested. Viewing with `serve` does not write; explicit **Save progress** writes the sibling progress file. Use `--read-only` to disable editing.
35
39
 
36
- Pin the version in run commands or the global installation for reproducible team workflows. Product scripts implement mock screen interactions; they are authored content, not a local installation of KetAtlas. Framework tooling and tests stay in the KetAtlas repository.
40
+ Pin the version in run commands or the global installation for reproducible team workflows. Product scripts implement mock screen interactions; they are authored content, not a local installation of KetAtlas. A native renderer reuses the product's framework tooling and shared UI source rather than copying markup into each atlas.
37
41
 
38
- Edit the JSON and screen files, then refresh the browser. The default viewer address is **http://127.0.0.1:4178**.
42
+ Edit the JSON and product screen source, then refresh the browser. The default viewer address is **http://127.0.0.1:4178**.
39
43
 
40
44
  ## Create mockups with an agent
41
45
 
@@ -45,7 +49,31 @@ Install the KetAtlas skill in your product project:
45
49
  npx skills add ketvietlab/ketatlas --skill ketatlas
46
50
  ```
47
51
 
48
- Ask your agent to use the skill with a product brief: requested flows, target platforms, design references, and output directory. The agent creates actual HTML screens, a version 1 `atlas.json`, and run instructions, then audits the result.
52
+ Ask your agent to use the skill with a product brief: requested flows, target platforms, design references, and output directory. The agent creates native framework routes (or static HTML for a static product), a version 1 `atlas.json`, and run instructions, then audits the result.
53
+
54
+ ## Render with the product framework
55
+
56
+ KetAtlas 0.4.0 keeps the viewer and product renderer separate. Put `atlas.renderer.json` beside `atlas.json` when screens come from a framework:
57
+
58
+ ```json
59
+ {
60
+ "$schema": "https://unpkg.com/ketatlas@0.4.0/renderer.schema.json",
61
+ "version": 1,
62
+ "framework": "vue",
63
+ "command": ["npm", "run", "atlas:serve", "--", "--host", "{host}", "--port", "{port}"],
64
+ "cwd": "../..",
65
+ "readyPath": "/__atlas/ready",
66
+ "screenBasePath": "/__atlas/orders/"
67
+ }
68
+ ```
69
+
70
+ Then run:
71
+
72
+ ```sh
73
+ npx --yes ketatlas@0.4.0 serve ./tasks/orders.ketatlas --renderer --port 60550 --html-port 60551
74
+ ```
75
+
76
+ The first port serves the map and progress API. The second is owned by the declared React/Vue/KetJS/etc. server and serves screen routes. `--renderer` is explicit because it executes the local command array. Static projects continue to use the one-port command without this flag. See [Native renderers](docs/authoring.md#framework-native-screens).
49
77
 
50
78
  See [Agent mockups](docs/agent-mockups.md) for installation options, a ready-to-use request, and a reusable brief template. Agents without skill support can read the [single skill file](skills/ketatlas/SKILL.md) directly.
51
79
 
@@ -81,12 +109,13 @@ Open **Screens** to filter screen progress, review blockers and edit acceptance
81
109
 
82
110
  ## Commands
83
111
 
84
- | Command | Purpose |
85
- | ----------------------- | ------------------------------------------------------------------------------------------ |
86
- | `scaffold <directory>` | Create a project from `basic`, `web`, or `process`. Refuses to overwrite existing content. |
87
- | `serve <atlas.json>` | Start the viewer and serve local screens. Defaults to port 4178 on localhost. |
88
- | `audit <atlas.json>` | Check configuration, reachability, local files, and literal HTML/CSS references. |
89
- | `validate <atlas.json>` | Validate configuration only, without reading screen files. |
112
+ | Command | Purpose |
113
+ | -------------------------- | -------------------------------------------------------------------------------- |
114
+ | `scaffold <name.ketatlas>` | Create a bundle from `basic`, `web`, or `process`. Refuses existing content. |
115
+ | `discover <directory>` | Find valid `*.ketatlas/atlas.json` bundles and report invalid bundles. |
116
+ | `serve <bundle\|json>` | Start the viewer; optionally supervise a native renderer on a second port. |
117
+ | `audit <bundle\|json>` | Check configuration, reachability, local files, and literal HTML/CSS references. |
118
+ | `validate <bundle\|json>` | Validate configuration only, without reading screen files. |
90
119
 
91
120
  Use `--help` for options, `--root` when assets live above the JSON directory, and `audit --json` for CI reports. [Full CLI reference →](docs/cli.md)
92
121
 
@@ -114,7 +143,7 @@ npm ci
114
143
  npm run dev
115
144
  ```
116
145
 
117
- The playground includes mobile sign-in, desktop approval, and a fulfilment process. These development dependencies are not required in consumer projects. To try an unpublished checkout, use `npx --yes --package ~/dev/ketatlas ketatlas serve /path/to/my-atlas/atlas.json`.
146
+ The playground includes mobile sign-in, desktop approval, and a fulfilment process. These development dependencies are not required in consumer projects. To try an unpublished checkout, use `npx --yes --package ~/dev/ketatlas ketatlas serve /path/to/my-atlas.ketatlas`.
118
147
 
119
148
  Stable version increases merged into `develop` are automatically published after CI verifies the package. See [Releasing](docs/releasing.md).
120
149
 
package/bin/audit.js CHANGED
@@ -3,6 +3,7 @@ import { readFile, realpath, stat } from "node:fs/promises";
3
3
  import { resolve, dirname, relative, sep, extname } from "node:path";
4
4
  import { fileURLToPath, pathToFileURL } from "node:url";
5
5
  import { validateAtlas } from "../src/config.js";
6
+ import { readRendererConfig, rendererFileName } from "./renderer.js";
6
7
  const within = (root, path) => {
7
8
  const r = relative(root, path);
8
9
  return r === "" || (!r.startsWith(".." + sep) && r !== ".." && !r.startsWith(sep));
@@ -29,6 +30,12 @@ export async function auditAtlas(file, { root: rootOption } = {}) {
29
30
  warnings = [...result.warnings],
30
31
  checked = new Set(),
31
32
  remote = new Set();
33
+ let renderer;
34
+ try {
35
+ renderer = await readRendererConfig(resolve(dirname(absolute), rendererFileName));
36
+ } catch (error) {
37
+ if (error.code !== "ENOENT") errors.push({ path: rendererFileName, message: error.message });
38
+ }
32
39
  if (!within(root, absolute))
33
40
  errors.push({ path: "file", message: "The atlas JSON must be inside the serve root." });
34
41
  async function inspect(value, from, label) {
@@ -90,9 +97,12 @@ export async function auditAtlas(file, { root: rootOption } = {}) {
90
97
  }
91
98
  }
92
99
  if (result.valid) {
93
- for (const s of config.screens || []) await inspect(s.url, absolute, `screen:${s.id}`);
100
+ if (!renderer)
101
+ for (const s of config.screens || []) await inspect(s.url, absolute, `screen:${s.id}`);
94
102
  for (const f of config.flows)
95
- for (const n of f.nodes) if (n.url) await inspect(n.url, absolute, `node:${f.id}/${n.id}`);
103
+ for (const n of f.nodes)
104
+ if (n.url && (!renderer || (n.type || (n.screen ? "screen" : "note")) !== "screen"))
105
+ await inspect(n.url, absolute, `node:${f.id}/${n.id}`);
96
106
  }
97
107
  if (result.valid) {
98
108
  try {
@@ -118,7 +128,8 @@ export async function auditAtlas(file, { root: rootOption } = {}) {
118
128
  },
119
129
  errors,
120
130
  warnings,
121
- scope:
122
- "Static configuration and literal local HTML/CSS references. JavaScript behavior and remote embedding policies are not executed.",
131
+ scope: renderer
132
+ ? `Configuration and ${renderer.data.framework} renderer contract. The renderer command, routes, JavaScript behavior and embedding are not executed.`
133
+ : "Static configuration and literal local HTML/CSS references. JavaScript behavior and remote embedding policies are not executed.",
123
134
  };
124
135
  }
@@ -0,0 +1,90 @@
1
+ import { readFile, readdir, realpath, stat } from "node:fs/promises";
2
+ import { basename, join, resolve } from "node:path";
3
+ import { validateAtlas } from "../src/config.js";
4
+
5
+ export const atlasDirectorySuffix = ".ketatlas";
6
+ export const atlasManifestName = "atlas.json";
7
+
8
+ const excludedDirectories = new Set([
9
+ ".git",
10
+ ".cache",
11
+ ".next",
12
+ ".venv",
13
+ "build",
14
+ "dist",
15
+ "node_modules",
16
+ "vendor",
17
+ ]);
18
+
19
+ export function isAtlasDirectory(path) {
20
+ return basename(resolve(path)).toLowerCase().endsWith(atlasDirectorySuffix);
21
+ }
22
+
23
+ export function requireAtlasDirectory(path) {
24
+ const destination = resolve(path);
25
+ if (!isAtlasDirectory(destination))
26
+ throw new Error(`Atlas directories must end in ${atlasDirectorySuffix}.`);
27
+ return destination;
28
+ }
29
+
30
+ export async function resolveAtlasFile(target) {
31
+ const absolute = resolve(target);
32
+ const info = await stat(absolute);
33
+ if (!info.isDirectory()) return absolute;
34
+ if (!isAtlasDirectory(absolute))
35
+ throw new Error(`Atlas directories must end in ${atlasDirectorySuffix}.`);
36
+ return join(absolute, atlasManifestName);
37
+ }
38
+
39
+ async function describe(directory) {
40
+ const path = join(directory, atlasManifestName);
41
+ let config;
42
+ try {
43
+ config = JSON.parse(await readFile(path, "utf8"));
44
+ } catch (error) {
45
+ return { error: { path, message: error.message } };
46
+ }
47
+ const validation = validateAtlas(config);
48
+ if (!validation.valid)
49
+ return {
50
+ error: {
51
+ path,
52
+ message: validation.errors.map((issue) => `${issue.path}: ${issue.message}`).join("; "),
53
+ },
54
+ };
55
+ return {
56
+ atlas: {
57
+ path,
58
+ directory,
59
+ title: config.title,
60
+ screens: (config.screens || []).map(({ id, title, url }) => ({ id, title, url })),
61
+ flowCount: config.flows.length,
62
+ },
63
+ };
64
+ }
65
+
66
+ export async function discoverAtlases(root) {
67
+ const workspace = await realpath(resolve(root));
68
+ if (!(await stat(workspace)).isDirectory())
69
+ throw new Error("Discovery root must be a directory.");
70
+ const atlases = [],
71
+ errors = [],
72
+ directories = [workspace];
73
+ while (directories.length) {
74
+ const directory = directories.pop();
75
+ if (isAtlasDirectory(directory)) {
76
+ const result = await describe(directory);
77
+ if (result.atlas) atlases.push(result.atlas);
78
+ else errors.push(result.error);
79
+ continue;
80
+ }
81
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
82
+ if (!entry.isDirectory() || entry.isSymbolicLink() || excludedDirectories.has(entry.name))
83
+ continue;
84
+ directories.push(join(directory, entry.name));
85
+ }
86
+ }
87
+ atlases.sort((a, b) => a.path.localeCompare(b.path));
88
+ errors.sort((a, b) => a.path.localeCompare(b.path));
89
+ return { version: 1, root: workspace, atlases, errors };
90
+ }
package/bin/ketatlas.js CHANGED
@@ -8,23 +8,31 @@ import { validateAtlas } from "../src/config.js";
8
8
  import { serve } from "./server.js";
9
9
  import { serveAtlas } from "./viewer.js";
10
10
  import { auditAtlas } from "./audit.js";
11
+ import {
12
+ discoverAtlases,
13
+ isAtlasDirectory,
14
+ requireAtlasDirectory,
15
+ resolveAtlasFile,
16
+ } from "./discovery.js";
11
17
  const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
12
- const help = `KetAtlas — scaffold, serve, and audit HTML workflow maps
18
+ const help = `KetAtlas — scaffold, serve, and audit framework-native workflow maps
13
19
 
14
- ketatlas scaffold <directory> [--template basic|web|process]
15
- ketatlas serve <atlas.json> [--port 4178] [--root directory]
16
- ketatlas audit <atlas.json> [--root directory] [--json] [--strict]
17
- ketatlas validate <atlas.json>
18
- ketatlas progress <atlas.json> [--json] [--init]
19
- ketatlas progress <atlas.json> --set <screen-id> --record <record.json> --expect <revision>
20
+ ketatlas scaffold <name.ketatlas> [--template basic|web|process]
21
+ ketatlas discover <directory> [--json]
22
+ ketatlas serve <name.ketatlas|atlas.json> [--port 4178] [--root directory] [--renderer] [--html-port 4179]
23
+ ketatlas audit <name.ketatlas|atlas.json> [--root directory] [--json] [--strict]
24
+ ketatlas validate <name.ketatlas|atlas.json>
25
+ ketatlas progress <name.ketatlas|atlas.json> [--json] [--init]
26
+ ketatlas progress <name.ketatlas|atlas.json> --set <screen-id> --record <record.json> --expect <revision>
20
27
  ketatlas --version
21
28
 
22
29
  Examples:
23
- npx ketatlas scaffold my-atlas
24
- npx ketatlas serve my-atlas/atlas.json
25
- npx ketatlas audit my-atlas/atlas.json --strict
30
+ npx ketatlas scaffold my-atlas.ketatlas
31
+ npx ketatlas discover . --json
32
+ npx ketatlas serve my-atlas.ketatlas
33
+ npx ketatlas audit my-atlas.ketatlas --strict
26
34
 
27
- Serve binds to 127.0.0.1. No HTML wrapper, build, account, or backend required.
35
+ Serve binds to 127.0.0.1. Static screens need no build; --renderer starts the project's declared framework server.
28
36
  `;
29
37
  function parse(args, allowed) {
30
38
  const options = {},
@@ -61,7 +69,7 @@ async function main(args) {
61
69
  template = options.template || "basic";
62
70
  if (!["basic", "web", "process"].includes(template))
63
71
  throw new Error("Templates: basic, web, process.");
64
- const destination = resolve(target);
72
+ const destination = requireAtlasDirectory(target);
65
73
  let existing;
66
74
  try {
67
75
  existing = await readdir(destination);
@@ -79,10 +87,25 @@ async function main(args) {
79
87
  errorOnExist: true,
80
88
  });
81
89
  console.log(
82
- `Created ${destination}\n\nNext: npx ketatlas serve "${join(destination, "atlas.json")}"\nEdit atlas.json to make it yours.`,
90
+ `Created ${destination}\n\nNext: npx ketatlas serve "${destination}"\nEdit atlas.json to make it yours.`,
83
91
  );
84
92
  return;
85
93
  }
94
+ if (command === "discover") {
95
+ const { target, options } = parse(rest, { json: "boolean" }),
96
+ result = await discoverAtlases(target);
97
+ if (options.json) console.log(JSON.stringify(result, null, 2));
98
+ else {
99
+ for (const atlas of result.atlases)
100
+ console.log(`${atlas.title}\t${atlas.path}\t${atlas.screens.length} screens`);
101
+ for (const error of result.errors) console.error(`ERROR ${error.path}: ${error.message}`);
102
+ console.log(
103
+ `${result.atlases.length} atlas${result.atlases.length === 1 ? "" : "es"} in ${result.root}`,
104
+ );
105
+ }
106
+ if (result.errors.length) process.exitCode = 1;
107
+ return;
108
+ }
86
109
  if (command === "audit") {
87
110
  const { target, options } = parse(rest, {
88
111
  root: "string",
@@ -90,7 +113,7 @@ async function main(args) {
90
113
  strict: "boolean",
91
114
  output: "string",
92
115
  });
93
- const report = await auditAtlas(target, options),
116
+ const report = await auditAtlas(await resolveAtlasFile(target), options),
94
117
  pass = report.valid && (!options.strict || report.warnings.length === 0);
95
118
  if (options.output)
96
119
  await writeFile(resolve(options.output), JSON.stringify(report, null, 2) + "\n", {
@@ -109,7 +132,7 @@ async function main(args) {
109
132
  }
110
133
  if (command === "validate") {
111
134
  const { target } = parse(rest, {}),
112
- config = JSON.parse(await readFile(resolve(target), "utf8")),
135
+ config = JSON.parse(await readFile(await resolveAtlasFile(target), "utf8")),
113
136
  result = validateAtlas(config);
114
137
  for (const issue of result.errors) console.error(`ERROR ${issue.path}: ${issue.message}`);
115
138
  for (const issue of result.warnings) console.warn(`WARN ${issue.path}: ${issue.message}`);
@@ -130,7 +153,7 @@ async function main(args) {
130
153
  record: "string",
131
154
  expect: "string",
132
155
  });
133
- const file = resolve(target),
156
+ const file = await resolveAtlasFile(target),
134
157
  atlas = JSON.parse(await readFile(file, "utf8"));
135
158
  const valid = validateAtlas(atlas);
136
159
  if (!valid.valid) throw new Error(valid.errors.map((e) => e.message).join("; "));
@@ -173,22 +196,37 @@ async function main(args) {
173
196
  port: "string",
174
197
  root: "string",
175
198
  "read-only": "boolean",
199
+ renderer: "boolean",
200
+ "html-port": "string",
176
201
  }),
177
- port = Number(options.port || 4178);
202
+ port = Number(options.port || 4178),
203
+ htmlPort = Number(options["html-port"] || (port < 65535 ? port + 1 : 0));
178
204
  if (!Number.isInteger(port) || port < 1 || port > 65535)
179
205
  throw new Error("Port must be 1–65535.");
206
+ if (options.renderer && (!Number.isInteger(htmlPort) || htmlPort < 1 || htmlPort > 65535))
207
+ throw new Error("HTML port must be 1–65535.");
208
+ if (options.renderer && htmlPort === port)
209
+ throw new Error("Viewer and HTML ports must be different.");
180
210
  const directory = (await stat(resolve(target))).isDirectory();
181
211
  if (directory && options.root)
182
212
  throw new Error("--root is only needed when serving an atlas JSON file.");
183
- const server = directory
184
- ? await serve(target, { port })
185
- : await serveAtlas(target, { port, root: options.root, readOnly: options["read-only"] });
213
+ const server =
214
+ directory && !isAtlasDirectory(target)
215
+ ? await serve(target, { port })
216
+ : await serveAtlas(await resolveAtlasFile(target), {
217
+ port,
218
+ root: options.root,
219
+ readOnly: options["read-only"],
220
+ renderer: options.renderer,
221
+ htmlPort,
222
+ });
186
223
  console.log(
187
- `KetAtlas: http://127.0.0.1:${server.address().port}\nServing ${resolve(target)}\nPress Ctrl+C to stop.`,
224
+ `KetAtlas: http://127.0.0.1:${server.address().port}${server.htmlOrigin ? `\nHTML: ${server.htmlOrigin}` : ""}\nServing ${resolve(target)}\nPress Ctrl+C to stop.`,
188
225
  );
189
- const close = () => {
226
+ const close = async () => {
190
227
  server.close();
191
228
  server.closeAllConnections();
229
+ await server.closeRenderer?.();
192
230
  };
193
231
  process.once("SIGINT", close);
194
232
  process.once("SIGTERM", close);
@@ -0,0 +1,189 @@
1
+ import { spawn } from "node:child_process";
2
+ import { createServer } from "node:net";
3
+ import { readFile, realpath, stat } from "node:fs/promises";
4
+ import { dirname, resolve } from "node:path";
5
+
6
+ const object = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
7
+ const text = (value) => typeof value === "string" && value.trim().length > 0;
8
+
9
+ export const rendererFileName = "atlas.renderer.json";
10
+
11
+ export function validateRendererConfig(input) {
12
+ const errors = [];
13
+ const error = (path, message) => errors.push({ path, message });
14
+ if (!object(input))
15
+ return {
16
+ valid: false,
17
+ errors: [{ path: "$", message: "Expected a renderer object." }],
18
+ };
19
+ for (const key of Object.keys(input))
20
+ if (
21
+ ![
22
+ "$schema",
23
+ "version",
24
+ "framework",
25
+ "command",
26
+ "cwd",
27
+ "readyPath",
28
+ "screenBasePath",
29
+ "readyTimeoutMs",
30
+ ].includes(key)
31
+ )
32
+ error(key, "Unknown property.");
33
+ if (input.version !== 1) error("version", "Expected version: 1.");
34
+ if (!text(input.framework)) error("framework", "Expected a non-empty framework name.");
35
+ if (!Array.isArray(input.command) || input.command.length === 0)
36
+ error("command", "Expected a non-empty command array.");
37
+ else
38
+ for (const [index, value] of input.command.entries())
39
+ if (!text(value)) error(`command[${index}]`, "Expected a non-empty argument.");
40
+ if (input.$schema !== undefined && typeof input.$schema !== "string")
41
+ error("$schema", "Expected text.");
42
+ if (input.cwd !== undefined && !text(input.cwd)) error("cwd", "Expected a directory path.");
43
+ for (const key of ["readyPath", "screenBasePath"])
44
+ if (input[key] !== undefined && (!text(input[key]) || !input[key].startsWith("/")))
45
+ error(key, "Expected an origin-relative path beginning with /.");
46
+ if (input.screenBasePath !== undefined && !input.screenBasePath.endsWith("/"))
47
+ error("screenBasePath", "Expected a path ending with /.");
48
+ if (
49
+ input.readyTimeoutMs !== undefined &&
50
+ (!Number.isInteger(input.readyTimeoutMs) ||
51
+ input.readyTimeoutMs < 100 ||
52
+ input.readyTimeoutMs > 120000)
53
+ )
54
+ error("readyTimeoutMs", "Expected an integer from 100 to 120000.");
55
+ return { valid: errors.length === 0, errors };
56
+ }
57
+
58
+ export class RendererValidationError extends Error {
59
+ constructor(result) {
60
+ super(result.errors.map((issue) => `${issue.path}: ${issue.message}`).join("\n"));
61
+ this.name = "RendererValidationError";
62
+ this.errors = result.errors;
63
+ }
64
+ }
65
+
66
+ export async function readRendererConfig(file) {
67
+ const absolute = await realpath(resolve(file));
68
+ if (!(await stat(absolute)).isFile()) throw new Error("Expected a renderer JSON file.");
69
+ const data = JSON.parse(await readFile(absolute, "utf8"));
70
+ const result = validateRendererConfig(data);
71
+ if (!result.valid) throw new RendererValidationError(result);
72
+ const directory = dirname(absolute);
73
+ const cwd = await realpath(resolve(directory, data.cwd || "."));
74
+ if (!(await stat(cwd)).isDirectory()) throw new Error("Renderer cwd must be a directory.");
75
+ return {
76
+ file: absolute,
77
+ directory,
78
+ cwd,
79
+ data: {
80
+ ...data,
81
+ cwd: data.cwd || ".",
82
+ readyPath: data.readyPath || "/",
83
+ screenBasePath: data.screenBasePath || "/",
84
+ readyTimeoutMs: data.readyTimeoutMs || 15000,
85
+ },
86
+ };
87
+ }
88
+
89
+ const reservePort = (host) =>
90
+ new Promise((resolvePort, reject) => {
91
+ const probe = createServer();
92
+ probe.once("error", reject);
93
+ probe.listen(0, host, () => {
94
+ const port = probe.address().port;
95
+ probe.close((error) => (error ? reject(error) : resolvePort(port)));
96
+ });
97
+ });
98
+
99
+ function originPath(origin, value, name) {
100
+ const url = new URL(value, origin);
101
+ if (url.origin !== origin) throw new Error(`${name} must stay on the renderer origin.`);
102
+ return url.href;
103
+ }
104
+
105
+ function terminate(child) {
106
+ if (child.exitCode !== null || child.signalCode !== null) return;
107
+ try {
108
+ if (process.platform === "win32") child.kill("SIGTERM");
109
+ else process.kill(-child.pid, "SIGTERM");
110
+ } catch {}
111
+ }
112
+
113
+ async function stop(child) {
114
+ if (child.exitCode !== null || child.signalCode !== null) return;
115
+ terminate(child);
116
+ await Promise.race([
117
+ new Promise((done) => child.once("exit", done)),
118
+ new Promise((done) => setTimeout(done, 2000)),
119
+ ]);
120
+ if (child.exitCode === null && child.signalCode === null) {
121
+ try {
122
+ if (process.platform === "win32") child.kill("SIGKILL");
123
+ else process.kill(-child.pid, "SIGKILL");
124
+ } catch {}
125
+ }
126
+ }
127
+
128
+ export async function startRenderer(file, options = {}) {
129
+ const config = await readRendererConfig(file);
130
+ const host = options.host || "127.0.0.1";
131
+ const port = options.port || (await reservePort(host));
132
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
133
+ throw new Error("HTML port must be 1–65535.");
134
+ const values = {
135
+ host,
136
+ port: String(port),
137
+ atlasDirectory: config.directory,
138
+ };
139
+ const replace = (value) =>
140
+ value.replace(/\{(host|port|atlasDirectory)\}/g, (_, key) => values[key]);
141
+ const command = config.data.command.map(replace);
142
+ const origin = `http://${host}:${port}`;
143
+ const readyURL = originPath(origin, config.data.readyPath, "readyPath");
144
+ const screenBaseURL = originPath(origin, config.data.screenBasePath, "screenBasePath");
145
+ const child = spawn(command[0], command.slice(1), {
146
+ cwd: config.cwd,
147
+ detached: process.platform !== "win32",
148
+ env: {
149
+ ...process.env,
150
+ KETATLAS_HOST: host,
151
+ KETATLAS_HTML_PORT: String(port),
152
+ KETATLAS_PROJECT_DIR: config.directory,
153
+ },
154
+ stdio: options.stdio || "inherit",
155
+ });
156
+ let spawnError;
157
+ child.once("error", (error) => {
158
+ spawnError = error;
159
+ });
160
+ const started = Date.now();
161
+ while (Date.now() - started < config.data.readyTimeoutMs) {
162
+ if (spawnError) throw spawnError;
163
+ if (child.exitCode !== null || child.signalCode !== null)
164
+ throw new Error(`Renderer exited before ${readyURL} became ready.`);
165
+ try {
166
+ const response = await fetch(readyURL, { signal: AbortSignal.timeout(1000) });
167
+ if (response.ok) {
168
+ let closed = false;
169
+ return {
170
+ child,
171
+ config,
172
+ origin,
173
+ readyURL,
174
+ screenBaseURL,
175
+ async close() {
176
+ if (closed) return;
177
+ closed = true;
178
+ await stop(child);
179
+ },
180
+ };
181
+ }
182
+ } catch {}
183
+ await new Promise((done) => setTimeout(done, 100));
184
+ }
185
+ await stop(child);
186
+ throw new Error(
187
+ `Renderer did not become ready at ${readyURL} within ${config.data.readyTimeoutMs}ms.`,
188
+ );
189
+ }