@imenam/mcp-documentor 1.0.91 → 1.0.93

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.
Files changed (83) hide show
  1. package/README.md +343 -327
  2. package/dist/src/core/git-sync.js +1 -0
  3. package/dist/src/core/publication-store.d.ts +76 -0
  4. package/dist/src/core/publication-store.js +180 -0
  5. package/dist/src/gui-worker.js +183 -67
  6. package/dist/src/index.js +118 -4
  7. package/gui/dist/assets/DocContent-BxncLHIS.css +1 -0
  8. package/gui/dist/assets/DocContent-qNz5QMPa.js +346 -0
  9. package/gui/dist/assets/arc-DvP0UOkD.js +1 -0
  10. package/gui/dist/assets/architectureDiagram-3BPJPVTR-3wEHUA1o.js +36 -0
  11. package/gui/dist/assets/{blockDiagram-GPEHLZMM-G4A-rV4c.js → blockDiagram-GPEHLZMM-C01nZdXH.js} +1 -1
  12. package/gui/dist/assets/{c4Diagram-AAUBKEIU-Dq3aMw8v.js → c4Diagram-AAUBKEIU-C995ER45.js} +1 -1
  13. package/gui/dist/assets/channel-Cjefy81D.js +1 -0
  14. package/gui/dist/assets/chunk-2J33WTMH-B7Z-cZqf.js +1 -0
  15. package/gui/dist/assets/chunk-4BX2VUAB-BRtaRRAZ.js +1 -0
  16. package/gui/dist/assets/chunk-55IACEB6-CZbPaYTs.js +1 -0
  17. package/gui/dist/assets/{chunk-727SXJPM-Dv3Ay2KV.js → chunk-727SXJPM-BMKPsW_V.js} +1 -1
  18. package/gui/dist/assets/{chunk-AQP2D5EJ-BycHHbwh.js → chunk-AQP2D5EJ-BQJRaqJz.js} +1 -1
  19. package/gui/dist/assets/{chunk-FMBD7UC4-DBoCiqpY.js → chunk-FMBD7UC4-BiKjOUxU.js} +1 -1
  20. package/gui/dist/assets/chunk-ND2GUHAM-BQYUzZi5.js +1 -0
  21. package/gui/dist/assets/chunk-QZHKN3VN-C0mI_9k0.js +1 -0
  22. package/gui/dist/assets/classDiagram-4FO5ZUOK-U-TA2l-f.js +1 -0
  23. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-U-TA2l-f.js +1 -0
  24. package/gui/dist/assets/cose-bilkent-S5V4N54A-9QkakK6h.js +1 -0
  25. package/gui/dist/assets/dagre-BM42HDAG-p_TitJwo.js +4 -0
  26. package/gui/dist/assets/diagram-2AECGRRQ-T3ul_-C1.js +43 -0
  27. package/gui/dist/assets/diagram-5GNKFQAL-Cv4-mYjj.js +10 -0
  28. package/gui/dist/assets/{diagram-KO2AKTUF-CZAde8Sg.js → diagram-KO2AKTUF-GEuY7REm.js} +1 -1
  29. package/gui/dist/assets/diagram-LMA3HP47-BlZVA1ZL.js +24 -0
  30. package/gui/dist/assets/diagram-OG6HWLK6-DZ9muekA.js +24 -0
  31. package/gui/dist/assets/{erDiagram-TEJ5UH35-DPUEmQfe.js → erDiagram-TEJ5UH35-BQ_TWeSA.js} +1 -1
  32. package/gui/dist/assets/{flowDiagram-I6XJVG4X-p2HfkBjW.js → flowDiagram-I6XJVG4X-BVxlCJhG.js} +1 -1
  33. package/gui/dist/assets/ganttDiagram-6RSMTGT7-DlHxknKg.js +292 -0
  34. package/gui/dist/assets/gitGraphDiagram-PVQCEYII-DYHNhcQ1.js +106 -0
  35. package/gui/dist/assets/infoDiagram-5YYISTIA-usWWgsgX.js +2 -0
  36. package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-DZ61Cc8a.js → ishikawaDiagram-YF4QCWOH-GXA4mXdI.js} +1 -1
  37. package/gui/dist/assets/{journeyDiagram-JHISSGLW-qYtEmFuB.js → journeyDiagram-JHISSGLW-DuiQ_voI.js} +1 -1
  38. package/gui/dist/assets/{kanban-definition-UN3LZRKU-Bs-W313s.js → kanban-definition-UN3LZRKU-Cf5Zx4Ai.js} +1 -1
  39. package/gui/dist/assets/linear-Dxg5B9lZ.js +1 -0
  40. package/gui/dist/assets/main-BoIDzkiC.js +10 -0
  41. package/gui/dist/assets/main-DDy8gF40.css +1 -0
  42. package/gui/dist/assets/{mindmap-definition-RKZ34NQL-CvBqAH4c.js → mindmap-definition-RKZ34NQL-BI-GTubt.js} +1 -1
  43. package/gui/dist/assets/{pieDiagram-4H26LBE5-BD2UcQce.js → pieDiagram-4H26LBE5-jm6JjM7-.js} +1 -1
  44. package/gui/dist/assets/public-5AzxUEZY.css +1 -0
  45. package/gui/dist/assets/public-BbfNIL4J.js +1 -0
  46. package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-DEuynlP9.js → quadrantDiagram-W4KKPZXB-D_FMBvP7.js} +1 -1
  47. package/gui/dist/assets/{requirementDiagram-4Y6WPE33-DPxfzOF3.js → requirementDiagram-4Y6WPE33-DrAnq7B5.js} +1 -1
  48. package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-C70_cePR.js → sankeyDiagram-5OEKKPKP-BBxMYinl.js} +1 -1
  49. package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-FDb_1B1W.js → sequenceDiagram-3UESZ5HK-CzlReNxZ.js} +1 -1
  50. package/gui/dist/assets/stateDiagram-AJRCARHV-CAj_FaGh.js +1 -0
  51. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-DL8IxfA-.js +1 -0
  52. package/gui/dist/assets/{timeline-definition-PNZ67QCA-DNUx0JIp.js → timeline-definition-PNZ67QCA-CHzi4ypa.js} +1 -1
  53. package/gui/dist/assets/{vennDiagram-CIIHVFJN-DEAfzbjb.js → vennDiagram-CIIHVFJN-DT2hW75o.js} +1 -1
  54. package/gui/dist/assets/{wardley-L42UT6IY-_REPWPb-.js → wardley-L42UT6IY-BBPqyjj6.js} +1 -1
  55. package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-CZFLXFE3.js → wardleyDiagram-YWT4CUSO-CoB6ihhL.js} +1 -1
  56. package/gui/dist/assets/{xychartDiagram-2RQKCTM6-P2Nhif6M.js → xychartDiagram-2RQKCTM6-FgTwWkea.js} +1 -1
  57. package/gui/dist/index.html +4 -2
  58. package/gui/dist/public.html +17 -0
  59. package/package.json +1 -1
  60. package/gui/dist/assets/arc-VljQAUvA.js +0 -1
  61. package/gui/dist/assets/architectureDiagram-3BPJPVTR-lbkjtvmN.js +0 -36
  62. package/gui/dist/assets/channel-DFvG2cZZ.js +0 -1
  63. package/gui/dist/assets/chunk-2J33WTMH-VSzyIJHo.js +0 -1
  64. package/gui/dist/assets/chunk-4BX2VUAB-DER41q8w.js +0 -1
  65. package/gui/dist/assets/chunk-55IACEB6-BlOGGRk4.js +0 -1
  66. package/gui/dist/assets/chunk-ND2GUHAM-DGKypjhb.js +0 -1
  67. package/gui/dist/assets/chunk-QZHKN3VN-B8Gn_mBo.js +0 -1
  68. package/gui/dist/assets/classDiagram-4FO5ZUOK-B8xEoAMI.js +0 -1
  69. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-B8xEoAMI.js +0 -1
  70. package/gui/dist/assets/cose-bilkent-S5V4N54A-DyamTuYj.js +0 -1
  71. package/gui/dist/assets/dagre-BM42HDAG-BKC5D2WN.js +0 -4
  72. package/gui/dist/assets/diagram-2AECGRRQ-BV2lBpwH.js +0 -43
  73. package/gui/dist/assets/diagram-5GNKFQAL-DGwbAj4W.js +0 -10
  74. package/gui/dist/assets/diagram-LMA3HP47-fQfo0z7i.js +0 -24
  75. package/gui/dist/assets/diagram-OG6HWLK6-BlD3VAFM.js +0 -24
  76. package/gui/dist/assets/ganttDiagram-6RSMTGT7-CWfFoy4g.js +0 -292
  77. package/gui/dist/assets/gitGraphDiagram-PVQCEYII-DkTKzmes.js +0 -106
  78. package/gui/dist/assets/index-BJ4WCWig.css +0 -1
  79. package/gui/dist/assets/index-C8LVeAsA.js +0 -358
  80. package/gui/dist/assets/infoDiagram-5YYISTIA-CXHTqZoU.js +0 -2
  81. package/gui/dist/assets/linear-CU6J91M5.js +0 -1
  82. package/gui/dist/assets/stateDiagram-AJRCARHV-BH1bq2it.js +0 -1
  83. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-BfpDu1Qi.js +0 -1
@@ -22,6 +22,7 @@ const GITIGNORE_CONTENT = [
22
22
  "export-bundles.json",
23
23
  "local-state.json",
24
24
  "blob-cache/",
25
+ "publications/",
25
26
  "",
26
27
  ].join("\n");
27
28
  /**
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Published documents, in `<data dir>/publications/`. The sync repository's .gitignore excludes the
3
+ * directory: a publication is served by this machine, and its link only works here.
4
+ *
5
+ * publications/index.json every publication
6
+ * publications/<id>/<snapshot>/content.md the frozen content
7
+ * publications/<id>/<snapshot>/img/<p>/<f> the images it references, copied when frozen
8
+ *
9
+ * Each freeze writes a new snapshot directory before the index points at it, so a reader always
10
+ * finds a complete snapshot.
11
+ */
12
+ export interface Publication {
13
+ id: string;
14
+ /** Secret part of the public link. Whoever holds it can read the frozen content. */
15
+ token: string;
16
+ project: string;
17
+ docId: string;
18
+ /** Title of the document when it was frozen. */
19
+ title: string;
20
+ /** History version frozen, or null when the document has no recorded history. */
21
+ version: number | null;
22
+ /** Version note, "" when none was given. */
23
+ note: string;
24
+ /** Hash of the frozen title and content, compared with the current document. */
25
+ contentHash: string;
26
+ /** Snapshot directory under `<id>/`. */
27
+ snapshot: string;
28
+ createdAt: string;
29
+ /** When the current snapshot was frozen. */
30
+ publishedAt: string;
31
+ }
32
+ /** Content and images of a document at the moment it is frozen. */
33
+ export interface FreezeInput {
34
+ project: string;
35
+ docId: string;
36
+ title: string;
37
+ content: string;
38
+ version: number | null;
39
+ note: string;
40
+ }
41
+ export declare const MAX_NOTE_LENGTH = 500;
42
+ export declare function hashDocContent(title: string, content: string): string;
43
+ /** Project assets referenced by a document, as decoded `{ project, file }` pairs. */
44
+ export declare function referencedImages(content: string): Array<{
45
+ project: string;
46
+ file: string;
47
+ }>;
48
+ /** A single path segment that cannot climb out of its directory. */
49
+ export declare function isSafeSegment(segment: string): boolean;
50
+ export declare class PublicationStore {
51
+ private readonly dataDir;
52
+ constructor(dataDir: string);
53
+ private get rootDir();
54
+ private get indexPath();
55
+ list(): Publication[];
56
+ get(id: string): Publication | undefined;
57
+ findByDoc(project: string, docId: string): Publication | undefined;
58
+ /** The publication a public link names, compared in constant time. */
59
+ findByToken(token: string): Publication | undefined;
60
+ /** Frozen markdown of a publication. */
61
+ readContent(publication: Publication): string;
62
+ /** Path of a frozen image, or undefined when the snapshot holds no such image. */
63
+ imagePath(publication: Publication, project: string, file: string): string | undefined;
64
+ publish(input: FreezeInput): Publication;
65
+ /** Freezes the current content under the same link. */
66
+ update(id: string, input: FreezeInput): Publication;
67
+ /** Revokes the link: the publication and its snapshots are deleted. */
68
+ unpublish(id: string): Publication;
69
+ /** Follows a document to its new place. Returns true when a publication was moved. */
70
+ moveDoc(project: string, docId: string, newProject: string, newDocId: string): boolean;
71
+ private require;
72
+ private snapshotDir;
73
+ /** Writes a new snapshot directory and returns its name. */
74
+ private freeze;
75
+ private writeIndex;
76
+ }
@@ -0,0 +1,180 @@
1
+ import crypto from "crypto";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ /** Image URLs a document uses for project assets: `/api/images/<project>/<file>`. */
5
+ const IMAGE_URL = /(?:^|[\s("'/])\/?api\/images\/([^/\s)"'?#]+)\/([^/\s)"'?#]+)/g;
6
+ export const MAX_NOTE_LENGTH = 500;
7
+ export function hashDocContent(title, content) {
8
+ return crypto.createHash("sha256").update(title).update("\n").update(content).digest("hex");
9
+ }
10
+ /** Project assets referenced by a document, as decoded `{ project, file }` pairs. */
11
+ export function referencedImages(content) {
12
+ const found = new Map();
13
+ for (const match of content.matchAll(IMAGE_URL)) {
14
+ let project;
15
+ let file;
16
+ try {
17
+ project = decodeURIComponent(match[1]);
18
+ file = decodeURIComponent(match[2]);
19
+ }
20
+ catch {
21
+ continue;
22
+ }
23
+ if (!isSafeSegment(project) || !isSafeSegment(file))
24
+ continue;
25
+ found.set(`${project}/${file}`, { project, file });
26
+ }
27
+ return [...found.values()];
28
+ }
29
+ /** A single path segment that cannot climb out of its directory. */
30
+ export function isSafeSegment(segment) {
31
+ return segment !== "" && segment !== "." && segment !== ".." && !/[\\/\0]/.test(segment);
32
+ }
33
+ function normalizeNote(note) {
34
+ if (note === undefined || note === null)
35
+ return "";
36
+ if (typeof note !== "string")
37
+ throw new Error("note must be a string");
38
+ const trimmed = note.trim();
39
+ if (trimmed.length > MAX_NOTE_LENGTH) {
40
+ throw new Error(`note is limited to ${MAX_NOTE_LENGTH} characters`);
41
+ }
42
+ return trimmed;
43
+ }
44
+ export class PublicationStore {
45
+ dataDir;
46
+ constructor(dataDir) {
47
+ this.dataDir = dataDir;
48
+ }
49
+ get rootDir() {
50
+ return path.join(this.dataDir, "publications");
51
+ }
52
+ get indexPath() {
53
+ return path.join(this.rootDir, "index.json");
54
+ }
55
+ list() {
56
+ if (!fs.existsSync(this.indexPath))
57
+ return [];
58
+ try {
59
+ const parsed = JSON.parse(fs.readFileSync(this.indexPath, "utf-8"));
60
+ if (!Array.isArray(parsed))
61
+ throw new Error("not an array");
62
+ return parsed;
63
+ }
64
+ catch (err) {
65
+ throw new Error(`${this.indexPath} is not a valid publication index (${err.message}) — fix or delete it.`);
66
+ }
67
+ }
68
+ get(id) {
69
+ return this.list().find((p) => p.id === id);
70
+ }
71
+ findByDoc(project, docId) {
72
+ return this.list().find((p) => p.project === project && p.docId === docId);
73
+ }
74
+ /** The publication a public link names, compared in constant time. */
75
+ findByToken(token) {
76
+ const given = Buffer.from(token);
77
+ return this.list().find((p) => {
78
+ const expected = Buffer.from(p.token);
79
+ return expected.length === given.length && crypto.timingSafeEqual(expected, given);
80
+ });
81
+ }
82
+ /** Frozen markdown of a publication. */
83
+ readContent(publication) {
84
+ return fs.readFileSync(path.join(this.snapshotDir(publication), "content.md"), "utf-8");
85
+ }
86
+ /** Path of a frozen image, or undefined when the snapshot holds no such image. */
87
+ imagePath(publication, project, file) {
88
+ if (!isSafeSegment(project) || !isSafeSegment(file))
89
+ return undefined;
90
+ const filePath = path.join(this.snapshotDir(publication), "img", project, file);
91
+ return fs.existsSync(filePath) ? filePath : undefined;
92
+ }
93
+ publish(input) {
94
+ if (this.findByDoc(input.project, input.docId)) {
95
+ throw new Error(`"${input.docId}" is already published in project "${input.project}"`);
96
+ }
97
+ const now = new Date().toISOString();
98
+ const id = crypto.randomBytes(8).toString("hex");
99
+ const snapshot = this.freeze(id, input);
100
+ const publication = {
101
+ id,
102
+ token: crypto.randomBytes(32).toString("base64url"),
103
+ project: input.project,
104
+ docId: input.docId,
105
+ title: input.title,
106
+ version: input.version,
107
+ note: normalizeNote(input.note),
108
+ contentHash: hashDocContent(input.title, input.content),
109
+ snapshot,
110
+ createdAt: now,
111
+ publishedAt: now,
112
+ };
113
+ this.writeIndex([...this.list(), publication]);
114
+ return publication;
115
+ }
116
+ /** Freezes the current content under the same link. */
117
+ update(id, input) {
118
+ const previous = this.require(id);
119
+ const snapshot = this.freeze(id, input);
120
+ const publication = {
121
+ ...previous,
122
+ title: input.title,
123
+ version: input.version,
124
+ note: normalizeNote(input.note),
125
+ contentHash: hashDocContent(input.title, input.content),
126
+ snapshot,
127
+ publishedAt: new Date().toISOString(),
128
+ };
129
+ this.writeIndex(this.list().map((p) => (p.id === id ? publication : p)));
130
+ fs.rmSync(path.join(this.rootDir, id, previous.snapshot), { recursive: true, force: true });
131
+ return publication;
132
+ }
133
+ /** Revokes the link: the publication and its snapshots are deleted. */
134
+ unpublish(id) {
135
+ const publication = this.require(id);
136
+ this.writeIndex(this.list().filter((p) => p.id !== id));
137
+ fs.rmSync(path.join(this.rootDir, id), { recursive: true, force: true });
138
+ return publication;
139
+ }
140
+ /** Follows a document to its new place. Returns true when a publication was moved. */
141
+ moveDoc(project, docId, newProject, newDocId) {
142
+ const publications = this.list();
143
+ const target = publications.find((p) => p.project === project && p.docId === docId);
144
+ if (!target)
145
+ return false;
146
+ this.writeIndex(publications.map((p) => (p === target ? { ...p, project: newProject, docId: newDocId } : p)));
147
+ return true;
148
+ }
149
+ require(id) {
150
+ const publication = this.get(id);
151
+ if (!publication)
152
+ throw new Error(`Publication "${id}" does not exist`);
153
+ return publication;
154
+ }
155
+ snapshotDir(publication) {
156
+ return path.join(this.rootDir, publication.id, publication.snapshot);
157
+ }
158
+ /** Writes a new snapshot directory and returns its name. */
159
+ freeze(id, input) {
160
+ const snapshot = `${Date.now().toString(36)}-${crypto.randomBytes(4).toString("hex")}`;
161
+ const dir = path.join(this.rootDir, id, snapshot);
162
+ fs.mkdirSync(dir, { recursive: true });
163
+ fs.writeFileSync(path.join(dir, "content.md"), input.content, "utf-8");
164
+ for (const { project, file } of referencedImages(input.content)) {
165
+ const source = path.join(this.dataDir, "projects", project, "assets", file);
166
+ if (!fs.existsSync(source))
167
+ continue;
168
+ const target = path.join(dir, "img", project, file);
169
+ fs.mkdirSync(path.dirname(target), { recursive: true });
170
+ fs.copyFileSync(source, target);
171
+ }
172
+ return snapshot;
173
+ }
174
+ writeIndex(publications) {
175
+ fs.mkdirSync(this.rootDir, { recursive: true });
176
+ const temp = `${this.indexPath}.${process.pid}.tmp`;
177
+ fs.writeFileSync(temp, JSON.stringify(publications, null, 2), "utf-8");
178
+ fs.renameSync(temp, this.indexPath);
179
+ }
180
+ }
@@ -1,7 +1,6 @@
1
1
  import { serve } from "@hono/node-server";
2
2
  import { serveStatic } from "@hono/node-server/serve-static";
3
3
  import { Hono } from "hono";
4
- import { streamSSE } from "hono/streaming";
5
4
  import { GUI_LISTEN_HOST, ProxyClient, honoGuiGuard } from "@imenam/mcp-gui-interface";
6
5
  import { IPCMessageSchema } from "../shared/schemas/doc.schema.js";
7
6
  import { isReadOnly } from "../shared/read-only.js";
@@ -9,12 +8,14 @@ import path from "path";
9
8
  import fs from "fs";
10
9
  import { execFile, spawn } from "child_process";
11
10
  import { Readable } from "stream";
11
+ import { randomUUID } from "crypto";
12
12
  import { promisify } from "util";
13
13
  const execFileAsync = promisify(execFile);
14
14
  import { fileURLToPath } from "url";
15
15
  import { createLogger, setupLogging } from "@imenam/mcp-gui-interface";
16
16
  import { resolveLogDir } from "./core/log-dir.js";
17
17
  import { isLogSource, logFileInfo, readGitHubLog, readServerLog } from "./core/log-reader.js";
18
+ import { isSafeSegment, PublicationStore } from "./core/publication-store.js";
18
19
  import matter from "gray-matter";
19
20
  import JSZip from "jszip";
20
21
  import puppeteer from "puppeteer-core";
@@ -129,7 +130,6 @@ let guiPublicUrl = '';
129
130
  const app = new Hono();
130
131
  app.use("*", honoGuiGuard());
131
132
  let proxyClient = null;
132
- const sseControllers = new Set();
133
133
  const pendingRequests = new Map();
134
134
  function safeSend(message) {
135
135
  if (process.send && process.connected) {
@@ -171,6 +171,28 @@ function ipcRequest(type, data, timeout = 5000) {
171
171
  }
172
172
  });
173
173
  }
174
+ /** Message types the master pushes to every GUI, journaled for `/api/changes`. */
175
+ const BROADCAST_TYPES = [
176
+ "INITIAL_STATE",
177
+ "CONFIG_UPDATED",
178
+ "BUNDLES_UPDATED",
179
+ "PROJECTS_CHANGED",
180
+ "DOCS_CHANGED",
181
+ "CODE_SOURCES_CHANGED",
182
+ "PUBLICATIONS_CHANGED",
183
+ ];
184
+ /** Number of pushed messages kept for clients catching up. */
185
+ const CHANGE_JOURNAL_SIZE = 200;
186
+ /** Identifies this worker run: a client holding another epoch has missed everything since. */
187
+ const workerEpoch = randomUUID();
188
+ let changeSeq = 0;
189
+ const changeJournal = [];
190
+ function recordChange(message) {
191
+ changeSeq += 1;
192
+ changeJournal.push({ seq: changeSeq, message });
193
+ if (changeJournal.length > CHANGE_JOURNAL_SIZE)
194
+ changeJournal.shift();
195
+ }
174
196
  function setupIpcErrorHandlers() {
175
197
  process.on("error", (err) => {
176
198
  if (err.code === "ERR_IPC_CHANNEL_CLOSED")
@@ -179,7 +201,7 @@ function setupIpcErrorHandlers() {
179
201
  });
180
202
  process.on("disconnect", () => {
181
203
  logger.warn("IPC disconnected from MCP master (parent closed the channel or process exited). " +
182
- "GUI worker will exit; EventSource clients will show Disconnected.");
204
+ "GUI worker will exit.");
183
205
  process.exit(0);
184
206
  });
185
207
  process.on("message", (msg) => {
@@ -199,24 +221,8 @@ function setupIpcErrorHandlers() {
199
221
  });
200
222
  return;
201
223
  }
202
- const BROADCAST_TYPES = [
203
- "INITIAL_STATE",
204
- "CONFIG_UPDATED",
205
- "BUNDLES_UPDATED",
206
- "PROJECTS_CHANGED",
207
- "DOCS_CHANGED",
208
- "CODE_SOURCES_CHANGED",
209
- ];
210
- if (msg && BROADCAST_TYPES.includes(msg.type)) {
211
- for (const controller of sseControllers) {
212
- try {
213
- controller.writeSSE({ data: JSON.stringify(msg), event: "message" });
214
- }
215
- catch {
216
- sseControllers.delete(controller);
217
- }
218
- }
219
- }
224
+ if (msg && BROADCAST_TYPES.includes(msg.type))
225
+ recordChange(msg);
220
226
  });
221
227
  }
222
228
  // --- HTTP Routes ---
@@ -229,6 +235,8 @@ const READ_ONLY_WRITE_ROUTES = [
229
235
  { method: "POST", prefix: "/api/sync" },
230
236
  { method: "POST", prefix: "/api/code-sources/apply" },
231
237
  { method: "POST", prefix: "/api/code-roots/apply" },
238
+ { method: "POST", prefix: "/api/publications" },
239
+ { method: "DELETE", prefix: "/api/publications" },
232
240
  ];
233
241
  app.use("*", async (c, next) => {
234
242
  if (isReadOnly()) {
@@ -251,54 +259,34 @@ app.get("/proxy/health", (c) => {
251
259
  timestamp: new Date().toISOString(),
252
260
  });
253
261
  });
254
- app.get("/api/events", (c) => {
255
- return streamSSE(c, async (stream) => {
256
- const clientId = Math.random().toString(36).slice(2, 11);
257
- const openedAt = Date.now();
258
- sseControllers.add(stream);
259
- logger.info(`SSE /api/events: client connected (id=${clientId})`);
260
- stream.onAbort(() => {
261
- sseControllers.delete(stream);
262
- logger.warn(`SSE /api/events: stream aborted (id=${clientId}, durationMs=${Date.now() - openedAt}). ` +
263
- "Typical causes: user navigated away, proxy closed idle connection, or network drop. " +
264
- "Browser will retry EventSource and may log client-diagnostics with readyState.");
265
- });
266
- const initialSent = safeSend({ type: "GET_INITIAL_STATE", timestamp: new Date().toISOString() });
267
- if (!initialSent) {
268
- logger.warn(`SSE /api/events: GET_INITIAL_STATE not sent to master (id=${clientId}). ` +
269
- `IPC unavailable: process.connected=${process.connected}, process.send=${typeof process.send}. ` +
270
- "Dashboard will stay Disconnected until IPC to the MCP process works.");
271
- }
272
- try {
273
- await stream.writeSSE({ data: "connected", event: "ping" });
274
- }
275
- catch (err) {
276
- logger.warn(`SSE /api/events: initial writeSSE failed (id=${clientId}): ${err?.message ?? err}`);
277
- sseControllers.delete(stream);
278
- return;
279
- }
280
- while (true) {
281
- await stream.sleep(30000);
282
- try {
283
- await stream.writeSSE({ data: "ping", event: "ping" });
284
- }
285
- catch (err) {
286
- logger.warn(`SSE /api/events: keepalive ping failed (id=${clientId}): ${err?.message ?? err}`);
287
- sseControllers.delete(stream);
288
- break;
289
- }
290
- }
262
+ // Answers immediately with the pushed messages after `since`, or `reset: true` when the
263
+ // client must reload the whole state: no cursor, another worker run, or messages dropped.
264
+ app.get("/api/changes", (c) => {
265
+ const since = c.req.query("since");
266
+ const epoch = c.req.query("epoch");
267
+ if (since !== undefined && !/^\d+$/.test(since)) {
268
+ return c.json({ error: `since must be a non-negative integer, got "${since}"` }, 400);
269
+ }
270
+ const reset = { epoch: workerEpoch, seq: changeSeq, reset: true };
271
+ if (since === undefined || epoch !== workerEpoch)
272
+ return c.json(reset);
273
+ const from = Number(since);
274
+ const oldestKept = changeJournal.length > 0 ? changeJournal[0].seq : changeSeq + 1;
275
+ if (from > changeSeq || from < oldestKept - 1)
276
+ return c.json(reset);
277
+ return c.json({
278
+ epoch: workerEpoch,
279
+ seq: changeSeq,
280
+ reset: false,
281
+ events: changeJournal.filter((entry) => entry.seq > from).map((entry) => entry.message),
291
282
  });
292
283
  });
293
- app.post("/api/client-diagnostics", async (c) => {
294
- try {
295
- const body = await c.req.json();
296
- logger.warn(`Client diagnostics (SSE/EventSource): ${JSON.stringify(body)}`);
297
- }
298
- catch (e) {
299
- logger.warn(`Client diagnostics: invalid JSON body (${e?.message ?? e})`);
300
- }
301
- return c.json({ ok: true });
284
+ // Server-side state a client loads on start and after each reset.
285
+ app.get("/api/initial-state", async (c) => {
286
+ const result = await ipcRequest("GET_INITIAL_STATE");
287
+ if (result.error)
288
+ return c.json({ error: result.error }, 500);
289
+ return c.json(result.data);
302
290
  });
303
291
  app.get("/api/configurations", async (c) => {
304
292
  const configRes = await ipcRequest("GET_CONFIG");
@@ -429,6 +417,134 @@ app.post("/api/code-roots/apply", async (c) => {
429
417
  app.get("/api/github-token", (c) => relay(c, "GET_GITHUB_TOKEN", {}, 15000));
430
418
  app.get("/api/code-source-migration", (c) => relay(c, "GET_CODE_SOURCE_MIGRATION", {}, 5000));
431
419
  app.post("/api/code-source-migration/ack", (c) => relay(c, "ACK_CODE_SOURCE_MIGRATION", {}, 5000));
420
+ // --- Publications ---
421
+ // Written by the master, which owns the documents and their history.
422
+ app.get("/api/publications", (c) => relay(c, "LIST_PUBLICATIONS", { project: c.req.query("project") }, 15000));
423
+ app.post("/api/publications", async (c) => {
424
+ const { project, id, note } = await readJson(c);
425
+ if (!project || !id)
426
+ return c.json({ error: "project and id are required" }, 400);
427
+ return relay(c, "PUBLISH_DOC", { project, id, note }, 15000, 400);
428
+ });
429
+ app.post("/api/publications/:publicationId/update", async (c) => {
430
+ const { note } = await readJson(c);
431
+ return relay(c, "UPDATE_PUBLICATION", { publicationId: c.req.param("publicationId"), note }, 15000, 400);
432
+ });
433
+ app.delete("/api/publications/:publicationId", (c) => relay(c, "UNPUBLISH_DOC", { publicationId: c.req.param("publicationId") }, 15000, 400));
434
+ // --- Public pages ---
435
+ // `/public/<token>/` serves a frozen document to anyone holding the link, with no session. It is
436
+ // the only route a hub relays without authentication: everything it answers stays under it.
437
+ // Read straight from the publication store, so a page never waits on the master.
438
+ const publicationStore = new PublicationStore(dataDir);
439
+ /** Tokens are 32 random bytes in base64url. */
440
+ const PUBLIC_TOKEN = /^[A-Za-z0-9_-]{43}$/;
441
+ const PUBLIC_HEADERS = {
442
+ "X-Robots-Tag": "noindex, nofollow, noarchive",
443
+ "Referrer-Policy": "no-referrer",
444
+ "X-Content-Type-Options": "nosniff",
445
+ };
446
+ const PUBLIC_PAGE_CSP = [
447
+ "default-src 'self'",
448
+ "script-src 'self'",
449
+ "style-src 'self' 'unsafe-inline'",
450
+ "img-src 'self' data: https:",
451
+ "font-src 'self' data:",
452
+ "connect-src 'self'",
453
+ "object-src 'none'",
454
+ "base-uri 'none'",
455
+ "form-action 'none'",
456
+ "frame-ancestors 'none'",
457
+ ].join("; ");
458
+ const ASSET_MIME = {
459
+ js: "text/javascript; charset=utf-8",
460
+ css: "text/css; charset=utf-8",
461
+ svg: "image/svg+xml",
462
+ png: "image/png",
463
+ woff: "font/woff",
464
+ woff2: "font/woff2",
465
+ ttf: "font/ttf",
466
+ };
467
+ function publicResponse(body, status, headers) {
468
+ return new Response(body, { status, headers: { ...PUBLIC_HEADERS, ...headers } });
469
+ }
470
+ function publicNotFound() {
471
+ return publicResponse("Not found", 404, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
472
+ }
473
+ /** Safe for the inside of a `<script type="application/json">` element. */
474
+ function jsonForScript(value) {
475
+ return JSON.stringify(value).replace(/</g, "\\u003c");
476
+ }
477
+ function escapeHtml(value) {
478
+ return value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
479
+ }
480
+ app.all("/public/*", (c) => {
481
+ if (c.req.method !== "GET" && c.req.method !== "HEAD") {
482
+ return publicResponse("Method not allowed", 405, { Allow: "GET, HEAD", "Cache-Control": "no-store" });
483
+ }
484
+ let segments;
485
+ try {
486
+ segments = new URL(c.req.url).pathname.slice("/public/".length).split("/").map(decodeURIComponent);
487
+ }
488
+ catch {
489
+ return publicNotFound();
490
+ }
491
+ const [token, ...tail] = segments;
492
+ if (!PUBLIC_TOKEN.test(token))
493
+ return publicNotFound();
494
+ const publication = publicationStore.findByToken(token);
495
+ if (!publication)
496
+ return publicNotFound();
497
+ // Relative URLs of the page resolve under the token only when its URL ends with a slash.
498
+ if (tail.length === 0) {
499
+ return publicResponse(null, 302, { Location: `${encodeURIComponent(token)}/`, "Cache-Control": "no-store" });
500
+ }
501
+ if (tail.length === 1 && tail[0] === "") {
502
+ const shellPath = path.join(absoluteStaticRoot, "public.html");
503
+ if (!fs.existsSync(shellPath)) {
504
+ return publicResponse("GUI build not found", 500, { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" });
505
+ }
506
+ const data = {
507
+ project: publication.project,
508
+ title: publication.title,
509
+ version: publication.version,
510
+ note: publication.note,
511
+ publishedAt: publication.publishedAt,
512
+ content: publicationStore.readContent(publication),
513
+ };
514
+ const html = fs
515
+ .readFileSync(shellPath, "utf8")
516
+ .replace(/<title>[^<]*<\/title>/, `<title>${escapeHtml(publication.title)}</title>`)
517
+ .replace("</head>", ` <script id="publication-data" type="application/json">${jsonForScript(data)}</script>\n </head>`);
518
+ return publicResponse(html, 200, {
519
+ "Content-Type": "text/html; charset=utf-8",
520
+ "Cache-Control": "no-store",
521
+ "Content-Security-Policy": PUBLIC_PAGE_CSP,
522
+ });
523
+ }
524
+ if (tail.length === 2 && tail[0] === "assets" && isSafeSegment(tail[1])) {
525
+ const filePath = path.join(absoluteStaticRoot, "assets", tail[1]);
526
+ if (!fs.existsSync(filePath))
527
+ return publicNotFound();
528
+ const ext = path.extname(filePath).toLowerCase().slice(1);
529
+ return publicResponse(fs.readFileSync(filePath), 200, {
530
+ "Content-Type": ASSET_MIME[ext] ?? "application/octet-stream",
531
+ "Cache-Control": "public, max-age=31536000, immutable",
532
+ });
533
+ }
534
+ if (tail.length === 3 && tail[0] === "img") {
535
+ const filePath = publicationStore.imagePath(publication, tail[1], tail[2]);
536
+ if (!filePath)
537
+ return publicNotFound();
538
+ const ext = path.extname(filePath).toLowerCase().slice(1);
539
+ return publicResponse(fs.readFileSync(filePath), 200, {
540
+ "Content-Type": IMAGE_MIME[ext] ?? "application/octet-stream",
541
+ "Cache-Control": "no-store",
542
+ // An SVG opened on its own must not run scripts under the hub's origin.
543
+ "Content-Security-Policy": "default-src 'none'; style-src 'unsafe-inline'; sandbox",
544
+ });
545
+ }
546
+ return publicNotFound();
547
+ });
432
548
  // --- Logs ---
433
549
  // Read straight from the log directory: both processes write there, and reading needs no IPC.
434
550
  const logDir = resolveLogDir(rootDir);