@katajs/devtools 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yaseer A. Okino
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,69 @@
1
+ # @katajs/devtools
2
+
3
+ > Live interactive devtools for [Kata](https://github.com/ookino/katajs) apps — module graph, drawer, routes table, queue producer/consumer view, Cmd+K palette. Hot-reloads as you edit.
4
+
5
+ `@katajs/devtools` is a dev-dependency-only package. It runs locally, reads your project's `scripts/modules.ts`, and serves a React UI from a small Node HTTP server with a chokidar watcher pushing updates over Server-Sent Events. No telemetry, no remote anything.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add -D @katajs/devtools
11
+ ```
12
+
13
+ A Kata project (`pnpm create katajs my-app`) ships with `scripts/modules.ts` that exports a canonical `modules` tuple — that's what devtools imports. If your project predates the `scripts/modules.ts` convention, devtools also accepts a legacy `scripts/graph.ts` that exports `modules`.
14
+
15
+ ## Run
16
+
17
+ ```bash
18
+ npx katajs-devtools
19
+ # katajs-devtools — interactive module graph
20
+ # ➜ Local: http://127.0.0.1:4242
21
+ ```
22
+
23
+ The bin opens a browser to the UI. Edit anything under `src/modules/**` or `scripts/modules.ts` and the canvas hot-reloads.
24
+
25
+ ## CLI flags
26
+
27
+ | Flag | Default | Notes |
28
+ |---|---|---|
29
+ | `--port <port>` | `4242` | Auto-bumps if busy. |
30
+ | `--host <host>` | `127.0.0.1` | Pass `0.0.0.0` to bind all interfaces. |
31
+ | `--no-open` | — | Don't auto-launch the browser. |
32
+ | `--modules-file <path>` | auto-detect | Override the location of the modules file. |
33
+
34
+ ## What you see
35
+
36
+ - **Graph canvas** — React Flow with dagre auto-layout. Each module is a card showing its prefix, provides count, requires count, route count, and a `consumes <BINDING>` line for queue consumers.
37
+ - **Module sidebar** — every module with at-a-glance counts; a Producers section appears below if `createApp({ queues })` is declared.
38
+ - **Module drawer** (right side, on selection) — full provides chips, requires with clickable backlinks, owned routes, and a "Consumes" section listing queue + DLQ + the producer that feeds it.
39
+ - **Producer drawer** — click a producer in the sidebar to see the typed `c.var.queues.<name>.send(body)` call and the consumer modules that read from the same binding.
40
+ - **Routes view** — flat searchable table; method-coloured chips with a free-text + per-method filter.
41
+ - **Cmd+K palette** — fuzzy-search across modules, producers, and routes; selecting jumps to the appropriate view.
42
+
43
+ ## How the data flows
44
+
45
+ ```
46
+ scripts/modules.ts (your modules tuple)
47
+ │ tsx ESM register
48
+
49
+ inspectModules() ──────► JSON over /api/graph.sse ──────► React UI
50
+ ▲ │
51
+ │ chokidar re-fires on src/modules/** change │ Cmd+K, click,
52
+ └─────────────────────────────────────────────────────┘ filter
53
+ ```
54
+
55
+ The data contract is the [`Inspection`](https://www.npmjs.com/package/@katajs/core) shape from `@katajs/core`. Anything renderable in the static `pnpm graph` snapshot is navigable here, with cross-module backlinks and live updates on top.
56
+
57
+ ## Endpoints (advanced)
58
+
59
+ While the bin is running:
60
+
61
+ - `GET /` — the React UI bundle.
62
+ - `GET /api/graph.json` — pretty-printed `Inspection` JSON, fresh on each request.
63
+ - `GET /api/graph.sse` — Server-Sent Events stream emitting `event: graph` with compact JSON on every snapshot change.
64
+
65
+ Useful if you want to feed the graph into something else (e.g. a CI artefact step).
66
+
67
+ ## License
68
+
69
+ MIT © Yaseer A. Okino
package/dist/cli.js ADDED
@@ -0,0 +1,344 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import { exec } from "child_process";
5
+ import { resolve as resolve3 } from "path";
6
+ import { cac } from "cac";
7
+ import { bold, cyan, dim, green, red, yellow } from "kolorist";
8
+
9
+ // src/server.ts
10
+ import { createServer } from "http";
11
+ import { readFile } from "fs/promises";
12
+ import { existsSync as existsSync2 } from "fs";
13
+ import { resolve as resolve2, join, normalize } from "path";
14
+ import { fileURLToPath } from "url";
15
+ import { watch as chokidarWatch } from "chokidar";
16
+
17
+ // src/loader.ts
18
+ import { existsSync } from "fs";
19
+ import { pathToFileURL } from "url";
20
+ import { resolve } from "path";
21
+ import { register } from "tsx/esm/api";
22
+ import { inspectModules } from "@katajs/core";
23
+ var tsxRegistered = false;
24
+ function ensureTsx() {
25
+ if (tsxRegistered) return;
26
+ register();
27
+ tsxRegistered = true;
28
+ }
29
+ function resolveModulesFile(options = {}) {
30
+ const cwd = options.cwd ?? process.cwd();
31
+ if (options.modulesFile) {
32
+ const abs = resolve(cwd, options.modulesFile);
33
+ if (!existsSync(abs)) {
34
+ throw new Error(
35
+ `katajs-devtools: modules file not found at ${options.modulesFile} (resolved: ${abs})`
36
+ );
37
+ }
38
+ return abs;
39
+ }
40
+ const preferred = resolve(cwd, "scripts/modules.ts");
41
+ if (existsSync(preferred)) return preferred;
42
+ const legacy = resolve(cwd, "scripts/graph.ts");
43
+ if (existsSync(legacy)) return legacy;
44
+ throw new Error(
45
+ `katajs-devtools: no modules file found. Looked for:
46
+ - scripts/modules.ts (preferred)
47
+ - scripts/graph.ts (legacy)
48
+ Either of these must export \`const modules = [...]\` for devtools to load.
49
+ Run \`katajs upgrade\` to migrate, or pass --modules-file <path> manually.`
50
+ );
51
+ }
52
+ async function loadInspection(options = {}) {
53
+ ensureTsx();
54
+ const resolvedAbsolutePath = resolveModulesFile(options);
55
+ const url = `${pathToFileURL(resolvedAbsolutePath).href}?t=${Date.now()}`;
56
+ const mod = await import(url);
57
+ if (!Array.isArray(mod.modules)) {
58
+ throw new Error(
59
+ `katajs-devtools: ${resolvedAbsolutePath} does not export a \`modules\` array.
60
+ Expected:
61
+ export const modules = [postsModule, /* ... */];`
62
+ );
63
+ }
64
+ const inspection = inspectModules(mod.modules, { producers: mod.producers });
65
+ return { inspection, resolvedPath: url, resolvedAbsolutePath };
66
+ }
67
+
68
+ // src/server.ts
69
+ var here = fileURLToPath(new URL(".", import.meta.url));
70
+ var uiStaticDir = resolve2(here, "ui");
71
+ function mimeFor(path) {
72
+ if (path.endsWith(".html")) return "text/html; charset=utf-8";
73
+ if (path.endsWith(".js")) return "application/javascript; charset=utf-8";
74
+ if (path.endsWith(".css")) return "text/css; charset=utf-8";
75
+ if (path.endsWith(".json")) return "application/json; charset=utf-8";
76
+ if (path.endsWith(".svg")) return "image/svg+xml";
77
+ if (path.endsWith(".ico")) return "image/x-icon";
78
+ return "application/octet-stream";
79
+ }
80
+ function send(res, status, body, contentType) {
81
+ res.writeHead(status, {
82
+ "content-type": contentType,
83
+ "cache-control": "no-store"
84
+ });
85
+ res.end(body);
86
+ }
87
+ function payloadOf(insp) {
88
+ return {
89
+ modules: insp.modules,
90
+ edges: insp.edges,
91
+ routes: insp.routes,
92
+ producers: insp.producers
93
+ };
94
+ }
95
+ function jsonPretty(insp) {
96
+ return JSON.stringify(payloadOf(insp), null, 2);
97
+ }
98
+ function jsonCompact(insp) {
99
+ return JSON.stringify(payloadOf(insp));
100
+ }
101
+ async function startServer(options) {
102
+ let lastResult = null;
103
+ let lastError = null;
104
+ const sseClients = /* @__PURE__ */ new Set();
105
+ let nextSseId = 1;
106
+ async function refresh() {
107
+ try {
108
+ lastResult = await loadInspection({ cwd: options.cwd, modulesFile: options.modulesFile });
109
+ lastError = null;
110
+ const payload = jsonCompact(lastResult.inspection);
111
+ for (const c of sseClients) {
112
+ c.res.write(`event: graph
113
+ data: ${payload}
114
+
115
+ `);
116
+ }
117
+ } catch (err) {
118
+ lastResult = null;
119
+ lastError = err instanceof Error ? err : new Error(String(err));
120
+ const payload = JSON.stringify({ message: lastError.message });
121
+ for (const c of sseClients) {
122
+ c.res.write(`event: error
123
+ data: ${payload}
124
+
125
+ `);
126
+ }
127
+ }
128
+ }
129
+ await refresh();
130
+ const watchPaths = [];
131
+ if (lastResult) watchPaths.push(lastResult.resolvedAbsolutePath);
132
+ const srcModulesDir = resolve2(options.cwd, "src/modules");
133
+ if (existsSync2(srcModulesDir)) watchPaths.push(srcModulesDir);
134
+ let watcher = null;
135
+ if (watchPaths.length > 0) {
136
+ watcher = chokidarWatch(watchPaths, {
137
+ ignoreInitial: true,
138
+ ignored: (p) => p.includes("/node_modules/") || p.includes("/dist/")
139
+ });
140
+ let pending = null;
141
+ const debounced = () => {
142
+ if (pending) clearTimeout(pending);
143
+ pending = setTimeout(() => {
144
+ pending = null;
145
+ void refresh();
146
+ }, 50);
147
+ };
148
+ watcher.on("add", debounced);
149
+ watcher.on("change", debounced);
150
+ watcher.on("unlink", debounced);
151
+ }
152
+ const handler = async (req, res) => {
153
+ const url2 = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
154
+ const pathname = url2.pathname;
155
+ if (pathname === "/api/graph.json") {
156
+ if (lastError) {
157
+ send(res, 500, JSON.stringify({ error: lastError.message }, null, 2), "application/json; charset=utf-8");
158
+ return;
159
+ }
160
+ if (!lastResult) {
161
+ send(res, 503, JSON.stringify({ error: "graph not loaded yet" }), "application/json; charset=utf-8");
162
+ return;
163
+ }
164
+ send(res, 200, jsonPretty(lastResult.inspection), "application/json; charset=utf-8");
165
+ return;
166
+ }
167
+ if (pathname === "/api/graph.sse") {
168
+ res.writeHead(200, {
169
+ "content-type": "text/event-stream; charset=utf-8",
170
+ "cache-control": "no-store",
171
+ connection: "keep-alive"
172
+ });
173
+ const id = nextSseId++;
174
+ const client = { res, id };
175
+ sseClients.add(client);
176
+ res.write(`: connected
177
+
178
+ `);
179
+ if (lastResult) {
180
+ res.write(`event: graph
181
+ data: ${jsonCompact(lastResult.inspection)}
182
+
183
+ `);
184
+ } else if (lastError) {
185
+ res.write(`event: error
186
+ data: ${JSON.stringify({ message: lastError.message })}
187
+
188
+ `);
189
+ }
190
+ const keepalive = setInterval(() => res.write(`: ping
191
+
192
+ `), 15e3);
193
+ req.on("close", () => {
194
+ clearInterval(keepalive);
195
+ sseClients.delete(client);
196
+ });
197
+ return;
198
+ }
199
+ if (pathname === "/" || pathname === "/index.html") {
200
+ const indexPath = join(uiStaticDir, "index.html");
201
+ if (existsSync2(indexPath)) {
202
+ const buf = await readFile(indexPath);
203
+ send(res, 200, buf, "text/html; charset=utf-8");
204
+ return;
205
+ }
206
+ send(
207
+ res,
208
+ 404,
209
+ "UI bundle not found \u2014 did you run `pnpm --filter @katajs/devtools build`?",
210
+ "text/plain; charset=utf-8"
211
+ );
212
+ return;
213
+ }
214
+ const safe = normalize(pathname).replace(/^([./\\])+/, "");
215
+ const filePath = join(uiStaticDir, safe);
216
+ if (filePath.startsWith(uiStaticDir) && existsSync2(filePath)) {
217
+ const buf = await readFile(filePath);
218
+ send(res, 200, buf, mimeFor(filePath));
219
+ return;
220
+ }
221
+ send(res, 404, "not found", "text/plain; charset=utf-8");
222
+ };
223
+ const server = createServer((req, res) => {
224
+ handler(req, res).catch((err) => {
225
+ const msg = err instanceof Error ? err.stack ?? err.message : String(err);
226
+ send(res, 500, msg, "text/plain; charset=utf-8");
227
+ });
228
+ });
229
+ await new Promise((resolveStart, reject) => {
230
+ server.once("error", reject);
231
+ server.listen(options.port, options.host, () => {
232
+ server.off("error", reject);
233
+ resolveStart();
234
+ });
235
+ });
236
+ const url = `http://${options.host === "0.0.0.0" ? "localhost" : options.host}:${options.port}`;
237
+ return {
238
+ url,
239
+ async close() {
240
+ for (const c of sseClients) c.res.end();
241
+ sseClients.clear();
242
+ if (watcher) await watcher.close();
243
+ await new Promise((r) => server.close(() => r()));
244
+ }
245
+ };
246
+ }
247
+
248
+ // src/cli.ts
249
+ function openBrowser(url) {
250
+ const platform = process.platform;
251
+ const cmd = platform === "darwin" ? `open "${url}"` : platform === "win32" ? `start "" "${url}"` : `xdg-open "${url}"`;
252
+ exec(cmd, (err) => {
253
+ if (err) {
254
+ process.stderr.write(dim(`(could not auto-open browser: ${err.message})
255
+ `));
256
+ }
257
+ });
258
+ }
259
+ async function findFreePort(start, host) {
260
+ const { createServer: createServer2 } = await import("net");
261
+ for (let port = start; port < start + 20; port++) {
262
+ const free = await new Promise((res) => {
263
+ const probe = createServer2();
264
+ probe.once("error", () => {
265
+ probe.close();
266
+ res(false);
267
+ });
268
+ probe.once("listening", () => {
269
+ probe.close(() => res(true));
270
+ });
271
+ probe.listen(port, host);
272
+ });
273
+ if (free) return port;
274
+ }
275
+ throw new Error(`No free port found in range ${start}-${start + 19}.`);
276
+ }
277
+ async function run(options) {
278
+ const cwd = resolve3(options.cwd ?? process.cwd());
279
+ const host = options.host ?? "127.0.0.1";
280
+ const requestedPort = options.port ?? 4242;
281
+ const port = await findFreePort(requestedPort, host);
282
+ process.stdout.write(`
283
+ ${bold(cyan("katajs-devtools"))} ${dim("\u2014 interactive module graph")}
284
+
285
+ `);
286
+ process.stdout.write(` ${dim("cwd:")} ${cwd}
287
+ `);
288
+ process.stdout.write(
289
+ ` ${dim("source:")} ${options.modulesFile ?? dim("(auto-detect: scripts/modules.ts \u2192 scripts/graph.ts)")}
290
+
291
+ `
292
+ );
293
+ let server;
294
+ try {
295
+ server = await startServer({
296
+ cwd,
297
+ modulesFile: options.modulesFile,
298
+ port,
299
+ host
300
+ });
301
+ } catch (err) {
302
+ const msg = err instanceof Error ? err.message : String(err);
303
+ process.stderr.write(` ${red("\u2717")} failed to start: ${msg}
304
+
305
+ `);
306
+ process.exit(1);
307
+ }
308
+ if (port !== requestedPort) {
309
+ process.stdout.write(
310
+ ` ${yellow("!")} port ${requestedPort} was busy, using ${port} instead
311
+ `
312
+ );
313
+ }
314
+ process.stdout.write(` ${green("\u279C")} ${bold("Local:")} ${cyan(server.url)}
315
+ `);
316
+ process.stdout.write(` ${dim("Press Ctrl+C to stop.")}
317
+
318
+ `);
319
+ if (options.open !== false) {
320
+ openBrowser(server.url);
321
+ }
322
+ const shutdown = async (signal) => {
323
+ process.stdout.write(`
324
+ ${dim(`received ${signal}, shutting down...`)}
325
+ `);
326
+ await server.close();
327
+ process.exit(0);
328
+ };
329
+ process.on("SIGINT", () => void shutdown("SIGINT"));
330
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
331
+ }
332
+ var cli = cac("katajs-devtools");
333
+ cli.command("[cwd]", "Start the interactive devtools server").option("--port <port>", "Port to listen on", { default: 4242 }).option("--host <host>", "Host to bind", { default: "127.0.0.1" }).option("--no-open", "Do not auto-open the browser").option("--modules-file <path>", "Override the modules file location").action((cwdArg, opts) => {
334
+ void run({
335
+ cwd: cwdArg,
336
+ port: typeof opts.port === "number" ? opts.port : Number(opts.port),
337
+ host: typeof opts.host === "string" ? opts.host : void 0,
338
+ open: opts.open !== false,
339
+ modulesFile: typeof opts.modulesFile === "string" ? opts.modulesFile : void 0
340
+ });
341
+ });
342
+ cli.help();
343
+ cli.version("0.1.0");
344
+ cli.parse();