@oevery/rsp 4.0.0 → 4.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.
@@ -0,0 +1 @@
1
+ {"identity":"11922cc76a15ff0a77664ed9c47ca4dee3b0315f12662e33de330c6ece574b29"}
@@ -0,0 +1,551 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ MAX_CONTENT_BYTES,
4
+ digest,
5
+ documentHeadings,
6
+ isWithin,
7
+ projectPath,
8
+ queryArchitecture,
9
+ readProjectFile,
10
+ readProjectRuntime,
11
+ selectArchitecture
12
+ } from "./chunk-JO6NDJM4.mjs";
13
+ import {
14
+ VIEWER_PROTOCOL,
15
+ snapshotViewerRuntime,
16
+ viewerRuntime
17
+ } from "./chunk-U56XMIOO.mjs";
18
+ import {
19
+ readCurrentDocument
20
+ } from "./chunk-LGDCIQYL.mjs";
21
+ import {
22
+ clearConfigCache,
23
+ parseMarkdown,
24
+ walkMarkdown
25
+ } from "./chunk-P3KO3GZW.mjs";
26
+
27
+ // src/viewer/service.ts
28
+ import { Buffer as Buffer2 } from "buffer";
29
+ import { timingSafeEqual } from "crypto";
30
+ import { realpath as realpath2, rm, writeFile } from "fs/promises";
31
+ import { createServer } from "http";
32
+
33
+ // src/viewer/documents.ts
34
+ import { Buffer } from "buffer";
35
+ import { extname, posix, resolve } from "path";
36
+ var escape = (value) => value.replace(/[&<>"']/g, (char) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[char]);
37
+ var imageTypes = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
38
+ function permitted(model, file) {
39
+ projectPath(file);
40
+ const parts = file.split("/");
41
+ if (parts.some((part) => /^(?:secrets?|credentials?|private)(?:[._-]|$)|^id_(?:rsa|dsa|ecdsa|ed25519)(?:[._-]|$)/i.test(part)))
42
+ return false;
43
+ const rspDocument = file.startsWith(".rsp/specs/") || model.documents.some((document) => document.startsWith(".rsp/") && isWithin(posix.dirname(document), file));
44
+ return parts.every((part, index) => !part.startsWith(".") || index === 0 && part === ".rsp" && rspDocument);
45
+ }
46
+ function localTarget(model, source, value, image) {
47
+ if (!value || value.startsWith("#") || /^[a-z][\w+.-]*:|^(?:\/|\\)/i.test(value))
48
+ return void 0;
49
+ try {
50
+ const decoded = decodeURIComponent(value.split(/[?#]/)[0]);
51
+ const target = posix.normalize(posix.join(posix.dirname(source), decoded));
52
+ return permitted(model, target) && (image ? imageTypes[extname(target).toLowerCase()] : /\.md$/i.test(target)) ? target : void 0;
53
+ } catch {
54
+ return void 0;
55
+ }
56
+ }
57
+ function definitionsIn(document) {
58
+ const definitions = /* @__PURE__ */ new Map();
59
+ walkMarkdown(document.root, (node) => {
60
+ if (node.type === "definition" && !definitions.has(node.identifier.toLowerCase()))
61
+ definitions.set(node.identifier.toLowerCase(), node);
62
+ });
63
+ return definitions;
64
+ }
65
+ async function authorizedContent(root, model, file, image = false) {
66
+ if (!permitted(model, file) || !(image ? imageTypes[extname(file).toLowerCase()] : /\.md$/i.test(file)))
67
+ throw new Error("Unregistered/unsupported document or image");
68
+ if (!image && model.documents.includes(file))
69
+ return readProjectFile(root, file);
70
+ const pending = [...model.documents];
71
+ const scheduled = new Set(pending);
72
+ const visited = /* @__PURE__ */ new Set();
73
+ let bytes = 0;
74
+ while (pending.length) {
75
+ const current = pending.shift();
76
+ if (visited.has(current) || !permitted(model, current))
77
+ continue;
78
+ visited.add(current);
79
+ let content;
80
+ try {
81
+ content = await readProjectFile(root, current);
82
+ } catch (error) {
83
+ if (current === file)
84
+ throw error;
85
+ continue;
86
+ }
87
+ if (!image && current === file)
88
+ return content;
89
+ bytes += Buffer.byteLength(content);
90
+ if (bytes > MAX_CONTENT_BYTES)
91
+ throw new Error("Document authorization exceeds the Markdown inspection limit");
92
+ const document = parseMarkdown(content);
93
+ const definitions = definitionsIn(document);
94
+ let found = false;
95
+ walkMarkdown(document.root, (node) => {
96
+ const isImage = node.type === "image" || node.type === "imageReference";
97
+ const value = node.type === "link" || node.type === "image" ? node.url : node.type === "linkReference" || node.type === "imageReference" ? definitions.get(node.identifier.toLowerCase())?.url : void 0;
98
+ const target = value && localTarget(model, current, value, isImage);
99
+ if (!target)
100
+ return;
101
+ if (image && isImage && target === file)
102
+ found = true;
103
+ if (!isImage && !scheduled.has(target)) {
104
+ scheduled.add(target);
105
+ pending.push(target);
106
+ }
107
+ });
108
+ if (found)
109
+ return void 0;
110
+ }
111
+ throw new Error("Document or image is not reachable from an architecture binding");
112
+ }
113
+ async function readAsset(root, model, file) {
114
+ await authorizedContent(root, model, file, true);
115
+ const path = resolve(root, file);
116
+ const current = await readCurrentDocument(path, root, file, MAX_CONTENT_BYTES, true);
117
+ return { type: imageTypes[extname(file).toLowerCase()], content: current.data, revision: digest(current.data) };
118
+ }
119
+ async function renderDocument(root, model, file) {
120
+ const content = await authorizedContent(root, model, file);
121
+ const document = parseMarkdown(content);
122
+ const headings = documentHeadings(document);
123
+ const definitions = definitionsIn(document);
124
+ let headingIndex = 0;
125
+ function url(value, image = false) {
126
+ if (value.startsWith("#"))
127
+ return image ? void 0 : escape(value);
128
+ if (/^[a-z][\w+.-]*:/i.test(value))
129
+ return !image && /^(?:https?:|mailto:)/i.test(value) ? escape(value) : void 0;
130
+ if (value.startsWith("//") || value.startsWith("/"))
131
+ return void 0;
132
+ try {
133
+ const parsed = new URL(value, "http://document.local/");
134
+ const target = localTarget(model, file, value, image);
135
+ if (!target)
136
+ return void 0;
137
+ if (image)
138
+ return escape(target);
139
+ return `/?${escape(new URLSearchParams({ file: target, ...parsed.hash ? { anchor: decodeURIComponent(parsed.hash.slice(1)) } : {} }).toString())}`;
140
+ } catch {
141
+ return void 0;
142
+ }
143
+ }
144
+ function render(node) {
145
+ const children = () => "children" in node ? node.children.map(render).join("") : "";
146
+ switch (node.type) {
147
+ case "root":
148
+ return children();
149
+ case "text":
150
+ return escape(node.value);
151
+ case "heading":
152
+ return `<h${node.depth} id="${escape(headings[headingIndex++].anchor)}">${children()}</h${node.depth}>`;
153
+ case "paragraph":
154
+ return `<p>${children()}</p>`;
155
+ case "strong":
156
+ return `<strong>${children()}</strong>`;
157
+ case "emphasis":
158
+ return `<em>${children()}</em>`;
159
+ case "delete":
160
+ return `<del>${children()}</del>`;
161
+ case "inlineCode":
162
+ return `<code>${escape(node.value)}</code>`;
163
+ case "code":
164
+ return `<pre><code>${escape(node.value)}</code></pre>`;
165
+ case "blockquote":
166
+ return `<blockquote>${children()}</blockquote>`;
167
+ case "list":
168
+ return node.ordered ? `<ol start="${node.start ?? 1}">${children()}</ol>` : `<ul>${children()}</ul>`;
169
+ case "listItem":
170
+ return `<li>${node.checked === null || node.checked === void 0 ? "" : node.checked ? "\u2611 " : "\u2610 "}${children()}</li>`;
171
+ case "table": {
172
+ const rows = node.children.map((row, index) => `<tr>${row.children.map((cell, column) => {
173
+ const tag = index === 0 ? "th" : "td";
174
+ const align = node.align?.[column] ?? "left";
175
+ return `<${tag}${index === 0 ? ' scope="col"' : ""} style="text-align:${align}">${cell.children.map(render).join("")}</${tag}>`;
176
+ }).join("")}</tr>`);
177
+ return `<table><thead>${rows[0] ?? ""}</thead><tbody>${rows.slice(1).join("")}</tbody></table>`;
178
+ }
179
+ case "break":
180
+ return "<br>";
181
+ case "thematicBreak":
182
+ return "<hr>";
183
+ case "link":
184
+ case "linkReference": {
185
+ const value = node.type === "link" ? node.url : definitions.get(node.identifier.toLowerCase())?.url;
186
+ const href = value === void 0 ? void 0 : url(value);
187
+ return href ? `<a href="${href}" rel="noopener noreferrer"${/^https?:/i.test(href) ? ' target="_blank"' : ""}>${children()}</a>` : `${children()}<span data-rsp-reading-status="unavailableLink"> (unavailable link)</span>`;
188
+ }
189
+ case "image": {
190
+ const path = url(node.url, true);
191
+ return path ? `<img data-asset="${path}" alt="${escape(node.alt ?? "")}">` : `<span>${escape(node.alt ?? "")}<span data-rsp-reading-status="unsupportedImage"> (external/unsupported image)</span></span>`;
192
+ }
193
+ case "imageReference": {
194
+ const definition = definitions.get(node.identifier.toLowerCase());
195
+ const path = definition && url(definition.url, true);
196
+ return path ? `<img data-asset="${path}" alt="${escape(node.alt ?? "")}">` : `<span>${escape(node.alt ?? "")}<span data-rsp-reading-status="unsupportedImage"> (external/unsupported image)</span></span>`;
197
+ }
198
+ default:
199
+ return "";
200
+ }
201
+ }
202
+ return { file, revision: digest(content), headings, html: render(document.root) };
203
+ }
204
+
205
+ // src/viewer/layout.ts
206
+ import { Builder } from "@likec4/core/builder";
207
+ import { layoutLikeC4Model } from "@likec4/layouts";
208
+ function diagramAliases(architecture) {
209
+ const aliases = new Map(architecture.nodes.map((node, index) => [node.id, `n${index}`]));
210
+ const fqn = (id) => {
211
+ const node = architecture.nodes.find((node2) => node2.id === id);
212
+ return node.parent ? `${fqn(node.parent)}.${aliases.get(id)}` : aliases.get(id);
213
+ };
214
+ return Object.fromEntries(architecture.nodes.map((node) => [node.id, fqn(node.id)]));
215
+ }
216
+ async function layoutArchitecture(architecture, workspace, focus) {
217
+ const diagramNodes = focus ? architecture.nodes.map(({ parent: _parent, ...node }) => node) : architecture.nodes;
218
+ const names = diagramAliases({ ...architecture, nodes: diagramNodes });
219
+ const nodeById = new Map(architecture.nodes.map((node) => [node.id, node]));
220
+ const needsReference = (edge) => names[edge.source] === names[edge.target] || names[edge.source].startsWith(`${names[edge.target]}.`) || names[edge.target].startsWith(`${names[edge.source]}.`);
221
+ const containers = new Set(diagramNodes.flatMap((node) => node.parent ? [node.parent] : []));
222
+ const referencedTargets = /* @__PURE__ */ new Set([...architecture.relations.filter(needsReference).map((edge) => edge.target), ...architecture.relations.flatMap((edge) => [edge.source, edge.target].filter((id) => containers.has(id)))]);
223
+ const primaryReferences = Object.fromEntries([...referencedTargets].map((id, index) => [`r${index}`, id]));
224
+ const referenceNames = Object.fromEntries(Object.entries(primaryReferences).map(([alias, id]) => [id, alias]));
225
+ const selfReferences = Object.fromEntries([...new Set(architecture.relations.filter((edge) => edge.source === edge.target && containers.has(edge.source)).map((edge) => edge.source))].map((id, index) => [`s${index}`, id]));
226
+ const selfNames = Object.fromEntries(Object.entries(selfReferences).map(([alias, id]) => [id, alias]));
227
+ const references = { ...primaryReferences, ...selfReferences };
228
+ const sourceName = (edge) => containers.has(edge.source) ? referenceNames[edge.source] : names[edge.source];
229
+ const targetName = (edge) => edge.source === edge.target && containers.has(edge.source) ? selfNames[edge.target] : needsReference(edge) || containers.has(edge.target) ? referenceNames[edge.target] : names[edge.target];
230
+ const views = focus ? [{ id: "", title: `${nodeById.get(focus)?.title ?? focus} relationships`, node: "", include: [focus] }] : [{ id: "", title: "All registered nodes", node: "", include: architecture.nodes.map((node) => node.id) }, ...architecture.views];
231
+ const viewNames = Object.fromEntries(views.map((view, index) => [view.id, `v${index}`]));
232
+ viewNames[""] = "v0";
233
+ const focusView = focus ? `v${views.length - 1}` : void 0;
234
+ const relationTags = new Map(architecture.relations.map((edge, index) => [edge.id, `e${index}`]));
235
+ const builder = Builder.specification({ elements: { component: {} }, relationships: { relation: { multiple: true } }, tags: Object.fromEntries([...relationTags.values()].map((tag) => [tag, {}])) }).model((helpers, compose) => {
236
+ const h = helpers;
237
+ const pipe = compose;
238
+ const properties = (node) => ({
239
+ title: node.title,
240
+ color: "green",
241
+ description: { txt: node.description },
242
+ links: node.bindings.map((binding) => ({ title: binding.file, url: `/?${new URLSearchParams({ workspace, file: binding.file, ...binding.headings ? { headings: JSON.stringify(binding.headings) } : {} })}` }))
243
+ });
244
+ const element = (node) => h.component(names[node.id].split(".").at(-1), properties(node)).with(...diagramNodes.filter((child) => child.parent === node.id).map(element));
245
+ return pipe(...diagramNodes.filter((node) => !node.parent).map(element), ...Object.entries(references).map(([alias, id]) => {
246
+ const node = nodeById.get(id);
247
+ return h.component(alias, { ...properties(node), title: `${node.title} (relationship reference)`, description: { txt: `Reference to ${node.id}. ${node.description}` } });
248
+ }), ...architecture.relations.map((edge) => h.rel(sourceName(edge), targetName(edge), { kind: "relation", title: edge.type, tags: [relationTags.get(edge.id)], description: { txt: edge.description } })));
249
+ }).views((helpers, compose) => {
250
+ const h = helpers;
251
+ const pipe = compose;
252
+ return pipe(...views.map((view, index) => {
253
+ const isFocus = !!focus && index === views.length - 1;
254
+ const projection = selectArchitecture(architecture, isFocus ? { kind: "focus", id: focus } : view.id ? { kind: "view", id: view.id } : { kind: "full" });
255
+ const include = [...projection.visible].map((id) => names[id]);
256
+ include.push(...projection.relations.flatMap((edge) => [sourceName(edge), targetName(edge)].filter((name) => name in references)));
257
+ const rules = [...new Set(include)].map((name) => {
258
+ const id = references[name] ?? architecture.nodes.find((node2) => names[node2.id] === name).id;
259
+ const targets = architecture.views.filter((candidate) => candidate.node === id);
260
+ const node = nodeById.get(id);
261
+ const title = `${node.title}${references[name] ? " [reference]" : ""}${projection.selected.has(id) ? "" : " [boundary]"}`;
262
+ return h.$include(name, { with: { title, description: { txt: "" }, size: "sm", padding: "xs", textSize: "sm", ...targets.length === 1 && targets[0].id !== view.id && viewNames[targets[0].id] ? { navigateTo: viewNames[targets[0].id] } : {} } });
263
+ });
264
+ rules.push(...architecture.relations.filter((edge) => !projection.relations.includes(edge)).map((edge) => h.$exclude("* -> *", { where: `tag is #${relationTags.get(edge.id)}` })));
265
+ const options = { title: view.title };
266
+ const alias = isFocus ? focusView : viewNames[view.id];
267
+ return include.length && view.node && nodeById.has(view.node) ? h.viewOf(alias, names[view.node], options, h.$rules(...rules, h.$autoLayout("LR", { rank: 70, node: 32 }))) : h.view(alias, options, h.$rules(...rules, h.$autoLayout("LR", { rank: 70, node: 32 })));
268
+ }));
269
+ });
270
+ const data = (await layoutLikeC4Model(builder.toLikeC4Model({ id: workspace }))).$data;
271
+ const relationIds = Object.fromEntries(Object.values(data.relations).map((edge) => [edge.id, architecture.relations.find((item) => relationTags.get(item.id) === edge.tags?.[0]).id]));
272
+ return { data, names, viewNames, references, relationIds, focusView };
273
+ }
274
+
275
+ // src/viewer/workspaces.ts
276
+ import { execFile } from "child_process";
277
+ import { realpath } from "fs/promises";
278
+ import { basename, dirname } from "path";
279
+ import { promisify } from "util";
280
+ var execute = promisify(execFile);
281
+ async function describeWorkspace(workspace) {
282
+ const summary = { ...workspace, title: basename(workspace.root) };
283
+ const env = { ...process.env };
284
+ for (const key of Object.keys(env)) {
285
+ if (key.startsWith("GIT_"))
286
+ delete env[key];
287
+ }
288
+ const git = async (...args) => (await execute("git", ["-C", workspace.root, ...args], { env, timeout: 2e3, maxBuffer: 64 * 1024 })).stdout.trim();
289
+ try {
290
+ const [directory, common, branch] = await Promise.all([
291
+ git("rev-parse", "--absolute-git-dir").then((path) => realpath(path)),
292
+ git("rev-parse", "--path-format=absolute", "--git-common-dir").then((path) => realpath(path)),
293
+ git("symbolic-ref", "--quiet", "--short", "HEAD").catch(() => void 0)
294
+ ]);
295
+ summary.git = {
296
+ repository: digest(common),
297
+ title: basename(common) === ".git" ? basename(dirname(common)) : basename(common),
298
+ linked: directory !== common,
299
+ ...branch ? { branch } : { commit: await git("rev-parse", "--short", "HEAD") }
300
+ };
301
+ } catch {
302
+ }
303
+ return summary;
304
+ }
305
+
306
+ // src/viewer/service.ts
307
+ var IDLE_MS = 30 * 60 * 1e3;
308
+ var WORKSPACE_GRACE_MS = 5 * 60 * 1e3;
309
+ var ReadingActivity = class {
310
+ lastReading;
311
+ workspaces = /* @__PURE__ */ new Map();
312
+ constructor(now) {
313
+ this.lastReading = now;
314
+ }
315
+ heartbeat(id, now) {
316
+ this.lastReading = now;
317
+ this.workspaces.set(id, now);
318
+ }
319
+ expired(now) {
320
+ return now - this.lastReading >= IDLE_MS;
321
+ }
322
+ inactive(id, now) {
323
+ return now - (this.workspaces.get(id) ?? 0) >= WORKSPACE_GRACE_MS;
324
+ }
325
+ };
326
+ function authorized(actual, token) {
327
+ const expected = Buffer2.from(`Bearer ${token}`);
328
+ const received = Buffer2.from(actual ?? "");
329
+ return received.length === expected.length && timingSafeEqual(received, expected);
330
+ }
331
+ async function createViewerService(options) {
332
+ const runtime = await snapshotViewerRuntime(options.root, options.identity);
333
+ const workspaces = /* @__PURE__ */ new Map();
334
+ const activity = new ReadingActivity(Date.now());
335
+ let layoutQueue = Promise.resolve();
336
+ let server;
337
+ let closing = false;
338
+ let timer;
339
+ async function close() {
340
+ if (closing)
341
+ return;
342
+ closing = true;
343
+ clearInterval(timer);
344
+ workspaces.clear();
345
+ server.closeAllConnections();
346
+ await new Promise((resolve2) => server.close(() => resolve2()));
347
+ if (options.recordPath)
348
+ await rm(options.recordPath, { force: true });
349
+ }
350
+ async function validateRuntime(root) {
351
+ const selected = await readProjectRuntime(root);
352
+ if (selected.mode === "package" && selected.version && selected.version !== runtime.version)
353
+ throw new Error(`Workspace selects RSP ${selected.version}; open it using that runtime`);
354
+ }
355
+ async function load(workspace) {
356
+ if (workspace.pending)
357
+ return workspace.pending;
358
+ workspace.pending = (async () => {
359
+ try {
360
+ await validateRuntime(workspace.root);
361
+ clearConfigCache();
362
+ const model = await queryArchitecture({ root: workspace.root });
363
+ if (!model.configured)
364
+ throw new Error("Architecture is not configured: .rsp/architecture.json");
365
+ const graphKey = digest(JSON.stringify({ nodes: model.nodes.map(({ bindings, ...node }) => ({ ...node, bindings: bindings.map(({ anchor: _anchor, ...binding }) => binding) })), relations: model.relations, views: model.views }));
366
+ if (workspace.graphKey !== graphKey || !workspace.layout) {
367
+ const queued = layoutQueue.then(async () => {
368
+ const started = performance.now();
369
+ const layout = await layoutArchitecture(model, workspace.id);
370
+ workspace.layoutMs = performance.now() - started;
371
+ workspace.layout = layout;
372
+ workspace.graphKey = graphKey;
373
+ });
374
+ layoutQueue = queued.catch(() => {
375
+ });
376
+ await queued;
377
+ }
378
+ workspace.model = model;
379
+ workspace.error = void 0;
380
+ } catch (error) {
381
+ workspace.error = error.message;
382
+ throw error;
383
+ } finally {
384
+ workspace.pending = void 0;
385
+ }
386
+ })();
387
+ return workspace.pending;
388
+ }
389
+ server = createServer(async (request, response) => {
390
+ const origin = `http://127.0.0.1:${server.address().port}`;
391
+ const send = (status, type, value) => {
392
+ response.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer", "Content-Security-Policy": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: blob:; connect-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'none'" });
393
+ response.end(request.method === "HEAD" ? void 0 : value);
394
+ };
395
+ const json = (value) => send(200, "application/json; charset=utf-8", JSON.stringify(value));
396
+ try {
397
+ if (request.headers.host !== origin.slice(7) || request.headers.origin && request.headers.origin !== origin) {
398
+ send(403, "text/plain", "Local origin only");
399
+ return;
400
+ }
401
+ const url = new URL(request.url, origin);
402
+ if (url.pathname.startsWith("/control/")) {
403
+ if (!authorized(request.headers.authorization, options.adminToken)) {
404
+ send(403, "text/plain", "Unauthorized registration/control");
405
+ return;
406
+ }
407
+ const base = { identity: options.identity, protocol: VIEWER_PROTOCOL };
408
+ if (url.pathname === "/control/status" && request.method === "GET") {
409
+ json({ ...base, pid: process.pid, port: server.address().port, workspaces: [...workspaces.values()].map((workspace2) => ({ id: workspace2.id, root: workspace2.root, active: !!workspace2.model, revision: workspace2.model?.source.revision, error: workspace2.error, layoutMs: workspace2.layoutMs })) });
410
+ return;
411
+ }
412
+ if (request.method !== "POST") {
413
+ send(405, "text/plain", "POST required");
414
+ return;
415
+ }
416
+ if (url.pathname === "/control/stop") {
417
+ json(base);
418
+ setImmediate(() => void close());
419
+ return;
420
+ }
421
+ if (url.pathname === "/control/register") {
422
+ let body = "";
423
+ for await (const chunk of request) {
424
+ body += chunk;
425
+ if (body.length > 16384)
426
+ throw new Error("Registration request too large");
427
+ }
428
+ const root = await realpath2(JSON.parse(body).root);
429
+ await validateRuntime(root);
430
+ const id2 = digest(root).slice(0, 24);
431
+ const workspace2 = workspaces.get(id2) ?? { id: id2, root };
432
+ workspaces.set(id2, workspace2);
433
+ activity.heartbeat(id2, Date.now());
434
+ await load(workspace2);
435
+ json({ ...base, workspace: id2 });
436
+ return;
437
+ }
438
+ send(404, "text/plain", "Unknown control request");
439
+ return;
440
+ }
441
+ if (!["GET", "HEAD"].includes(request.method ?? "")) {
442
+ send(405, "text/plain", "Read-only");
443
+ return;
444
+ }
445
+ if (url.pathname === "/") {
446
+ send(200, "text/html; charset=utf-8", '<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>RSP Architecture</title><link rel="stylesheet" href="/app.css"></head><body><div id="root"></div><script type="module" src="/app.js"></script></body></html>');
447
+ return;
448
+ }
449
+ if (["/app.js", "/app.css"].includes(url.pathname)) {
450
+ send(200, url.pathname.endsWith(".css") ? "text/css" : "text/javascript", runtime.assets.get(url.pathname));
451
+ return;
452
+ }
453
+ if (!authorized(request.headers.authorization, options.readToken)) {
454
+ send(403, "text/plain", "Unauthorized reading session");
455
+ return;
456
+ }
457
+ if (url.pathname === "/api/workspaces") {
458
+ json(await Promise.all([...workspaces.values()].map(({ id: id2, root }) => describeWorkspace({ id: id2, root }))));
459
+ return;
460
+ }
461
+ const match = /^\/api\/workspaces\/([a-f0-9]{24})\/(model|heartbeat|document|asset)$/.exec(url.pathname);
462
+ const workspace = match ? workspaces.get(match[1]) : void 0;
463
+ if (!match || !workspace) {
464
+ send(404, "text/plain", "Unknown workspace");
465
+ return;
466
+ }
467
+ const id = workspace.id;
468
+ if (match[2] === "heartbeat") {
469
+ activity.heartbeat(id, Date.now());
470
+ json({ workspace: id });
471
+ return;
472
+ }
473
+ activity.heartbeat(id, Date.now());
474
+ await load(workspace);
475
+ if (match[2] === "model") {
476
+ const model = workspace.model;
477
+ const graphRevision = workspace.graphKey;
478
+ const focus = url.searchParams.get("focus");
479
+ let layout = workspace.layout;
480
+ if (focus) {
481
+ if (!model.nodes.some((node) => node.id === focus)) {
482
+ json({ workspace: id, root: workspace.root, revision: model.source.revision, graphRevision, architecture: model, layout, missingFocus: focus, focus });
483
+ return;
484
+ }
485
+ if (workspace.focus?.id !== focus || workspace.focus.graphKey !== workspace.graphKey) {
486
+ const pending = layoutQueue.then(() => layoutArchitecture(model, id, focus));
487
+ layoutQueue = pending.then(() => {
488
+ }, () => {
489
+ });
490
+ workspace.focus = { id: focus, graphKey: workspace.graphKey, pending };
491
+ }
492
+ layout = await workspace.focus.pending;
493
+ }
494
+ json({ workspace: id, root: workspace.root, revision: model.source.revision, graphRevision, architecture: model, layout, focus: focus ?? "" });
495
+ return;
496
+ }
497
+ const file = url.searchParams.get("file") ?? "";
498
+ if (match[2] === "document") {
499
+ json({ workspace: id, ...await renderDocument(workspace.root, workspace.model, file) });
500
+ return;
501
+ }
502
+ const asset = await readAsset(workspace.root, workspace.model, file);
503
+ response.setHeader("ETag", `"${asset.revision}"`);
504
+ send(200, asset.type, asset.content);
505
+ } catch (error) {
506
+ send(409, "text/plain; charset=utf-8", error.message);
507
+ }
508
+ });
509
+ await new Promise((resolve2, reject) => {
510
+ server.once("error", reject);
511
+ server.listen(0, "127.0.0.1", resolve2);
512
+ });
513
+ async function sweep(now = Date.now()) {
514
+ if (activity.expired(now)) {
515
+ await close();
516
+ return;
517
+ }
518
+ for (const workspace of workspaces.values()) {
519
+ if (activity.inactive(workspace.id, now) && !workspace.pending) {
520
+ workspace.model = void 0;
521
+ workspace.layout = void 0;
522
+ workspace.focus = void 0;
523
+ workspace.graphKey = void 0;
524
+ }
525
+ }
526
+ }
527
+ timer = setInterval(() => void sweep(), 1e4);
528
+ timer.unref();
529
+ return { server, close, sweep, activity, port: server.address().port };
530
+ }
531
+ async function runServiceMain() {
532
+ if (!process.env.RSP_VIEWER_BOOT)
533
+ return;
534
+ const boot = JSON.parse(process.env.RSP_VIEWER_BOOT);
535
+ delete process.env.RSP_VIEWER_BOOT;
536
+ const runtime = await viewerRuntime();
537
+ if (runtime.identity !== boot.identity || runtime.root !== boot.root)
538
+ throw new Error("Viewer build changed during startup; retry");
539
+ const service = await createViewerService(boot);
540
+ await writeFile(boot.recordPath, JSON.stringify({ protocol: VIEWER_PROTOCOL, identity: boot.identity, port: service.port, pid: process.pid, adminToken: boot.adminToken, readToken: boot.readToken }), { mode: 384 });
541
+ for (const signal of ["SIGINT", "SIGTERM"]) process.once(signal, () => void service.close());
542
+ }
543
+ void runServiceMain().catch(() => {
544
+ process.exitCode = 1;
545
+ });
546
+ export {
547
+ IDLE_MS,
548
+ ReadingActivity,
549
+ WORKSPACE_GRACE_MS,
550
+ createViewerService
551
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@oevery/rsp",
3
3
  "type": "module",
4
- "version": "4.0.0",
4
+ "version": "4.1.0",
5
5
  "description": "RSP (Reliable Software Practice), a repository-native engineering workflow for humans and AI agents",
6
6
  "author": "oevery",
7
7
  "license": "MIT",
@@ -40,10 +40,11 @@
40
40
  "skills:package-check": "node scripts/skill-package-check.mjs",
41
41
  "prepublishOnly": "pnpm run release:check",
42
42
  "release": "release-it",
43
- "build": "tsup",
43
+ "build": "tsup && node scripts/build-viewer.mjs",
44
44
  "dev": "tsup --watch",
45
45
  "docs:build": "vitepress build docs",
46
46
  "docs:check": "node scripts/docs-check.mjs",
47
+ "doctor": "node dist/cli.mjs doctor",
47
48
  "docs:deploy": "wrangler deploy",
48
49
  "docs:dev": "vitepress dev docs",
49
50
  "docs:preview": "vitepress preview docs",
@@ -55,9 +56,12 @@
55
56
  "test:diagnostics": "vitest run --config tests/diagnostics/vitest.config.ts",
56
57
  "test:pack": "node tests/code/package/package-check.mjs",
57
58
  "test:skills": "uv run --frozen --project tests/skills python tests/skills/evaluate.py",
58
- "release:check": "pnpm run build && pnpm run typecheck && pnpm run lint && pnpm run test:code && pnpm run test:skills check && pnpm run skills:security-check && pnpm run docs:check"
59
+ "release:check": "pnpm run build && pnpm run doctor && pnpm run typecheck && pnpm run lint && pnpm run test:code && pnpm run test:skills check && pnpm run skills:security-check && pnpm run docs:check",
60
+ "test:viewer:browser": "node tests/code/architecture/browser-check.mjs"
59
61
  },
60
62
  "dependencies": {
63
+ "@likec4/core": "1.60.0",
64
+ "@likec4/layouts": "1.60.0",
61
65
  "citty": "^0.2.2",
62
66
  "hast-util-from-html": "^2.0.3",
63
67
  "ink": "~8.0.0",
@@ -72,15 +76,29 @@
72
76
  },
73
77
  "devDependencies": {
74
78
  "@antfu/eslint-config": "^9.5.1",
79
+ "@likec4/diagram": "1.60.0",
80
+ "@radix-ui/react-dialog": "1.3.1",
81
+ "@radix-ui/react-select": "2.4.1",
82
+ "@radix-ui/react-slot": "1.5.0",
83
+ "@radix-ui/react-tooltip": "1.4.1",
75
84
  "@release-it/conventional-changelog": "12.0.2",
85
+ "@tailwindcss/cli": "4.3.3",
76
86
  "@types/hast": "^3.0.5",
77
87
  "@types/mdast": "^4.0.4",
78
88
  "@types/node": "^22.20.1",
79
89
  "@types/react": "^19.3.0",
90
+ "@types/react-dom": "^19.3.0",
91
+ "class-variance-authority": "0.7.1",
92
+ "clsx": "2.1.1",
93
+ "esbuild": "^0.28.2",
80
94
  "eslint": "^10.13.0",
81
95
  "ink-testing-library": "^4.0.0",
96
+ "playwright-core": "1.64.1",
97
+ "react-dom": "~19.3.1",
82
98
  "release-it": "21.1.1",
83
99
  "smol-toml": "^1.9.1",
100
+ "tailwind-merge": "3.7.0",
101
+ "tailwindcss": "4.3.3",
84
102
  "tsup": "^9.0.0",
85
103
  "typescript": "^5.9.3",
86
104
  "vitepress": "^1.6.4",