viteboard 0.1.1 → 0.1.2

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 (81) hide show
  1. package/content/docs/index.md +1 -1
  2. package/content/docs/product-roadmap.board.json +4 -4
  3. package/content/machine/board-v1.schema.json +138 -0
  4. package/content/machine/example.board.json +217 -0
  5. package/content/machine/guide.md +270 -0
  6. package/content/machine/llms.txt +31 -0
  7. package/content/machine/viteboard.json +62 -0
  8. package/dist/assets/index-D2W76uu9.js +96 -0
  9. package/dist/assets/index-rMkUjGXP.css +1 -0
  10. package/dist/index.html +4 -2
  11. package/package.json +10 -12
  12. package/vite.config.ts +619 -38
  13. package/dist/assets/index-C30zRPsW.js +0 -60
  14. package/dist/assets/index-e5aqtN8W.css +0 -1
  15. package/index.html +0 -13
  16. package/src/app/AppController.ts +0 -962
  17. package/src/app/BoardState.ts +0 -130
  18. package/src/app/ClipboardService.ts +0 -159
  19. package/src/app/CommandManager.ts +0 -66
  20. package/src/app/ElementActions.ts +0 -152
  21. package/src/app/EmbedRegionSelector.ts +0 -103
  22. package/src/app/ExportPng.ts +0 -107
  23. package/src/app/ImageInsertService.ts +0 -141
  24. package/src/app/KeyboardShortcuts.ts +0 -124
  25. package/src/app/main.ts +0 -6
  26. package/src/board/BoardPreview.ts +0 -189
  27. package/src/board/BoardService.ts +0 -222
  28. package/src/canvas/CanvasRenderer.ts +0 -253
  29. package/src/canvas/CanvasSurface.ts +0 -70
  30. package/src/canvas/HitTester.ts +0 -110
  31. package/src/canvas/ImageCache.ts +0 -123
  32. package/src/canvas/PerformanceMonitor.ts +0 -31
  33. package/src/canvas/RenderScheduler.ts +0 -26
  34. package/src/canvas/Viewport.ts +0 -77
  35. package/src/content/ContentApi.ts +0 -258
  36. package/src/content/Markdown.ts +0 -431
  37. package/src/content/MarkdownView.ts +0 -70
  38. package/src/editor/DocEditor.ts +0 -799
  39. package/src/editor/htmlToMarkdown.ts +0 -333
  40. package/src/elements/renderElement.ts +0 -598
  41. package/src/elements/types.ts +0 -119
  42. package/src/shell/Shell.ts +0 -2948
  43. package/src/shell/Sidebar.ts +0 -1651
  44. package/src/storage/AssetStore.ts +0 -86
  45. package/src/storage/BoardSerializer.ts +0 -114
  46. package/src/storage/ImportExportService.ts +0 -153
  47. package/src/storage/IndexedDbStore.ts +0 -92
  48. package/src/storage/LocalBoardStore.ts +0 -104
  49. package/src/styles.css +0 -3332
  50. package/src/templates/handoffTemplate.ts +0 -34
  51. package/src/templates/helpers.ts +0 -132
  52. package/src/templates/index.ts +0 -79
  53. package/src/templates/journeyMapTemplate.ts +0 -52
  54. package/src/templates/opportunityTreeTemplate.ts +0 -45
  55. package/src/templates/personaTemplate.ts +0 -41
  56. package/src/templates/prioritizationMatrixTemplate.ts +0 -44
  57. package/src/templates/requirementsFlowTemplate.ts +0 -45
  58. package/src/templates/screenshotReviewTemplate.ts +0 -52
  59. package/src/templates/systemDiagramTemplate.ts +0 -41
  60. package/src/templates/workflowTemplate.ts +0 -35
  61. package/src/testing/StressTestGenerator.ts +0 -134
  62. package/src/testing/StressTestPanel.ts +0 -64
  63. package/src/tools/ArrowTool.ts +0 -97
  64. package/src/tools/ImageTool.ts +0 -39
  65. package/src/tools/PanTool.ts +0 -34
  66. package/src/tools/SelectTool.ts +0 -377
  67. package/src/tools/ShapeTool.ts +0 -106
  68. package/src/tools/StickyTool.ts +0 -69
  69. package/src/tools/TaskTool.ts +0 -52
  70. package/src/tools/TextTool.ts +0 -45
  71. package/src/tools/ToolContext.ts +0 -51
  72. package/src/tools/ToolController.ts +0 -178
  73. package/src/ui/Modal.ts +0 -58
  74. package/src/ui/PerformanceOverlay.ts +0 -75
  75. package/src/ui/StorageManager.ts +0 -94
  76. package/src/ui/TemplatePicker.ts +0 -67
  77. package/src/ui/TextEditorOverlay.ts +0 -262
  78. package/src/ui/Toolbar.ts +0 -189
  79. package/src/ui/TopControls.ts +0 -137
  80. package/src/ui/icons.ts +0 -56
  81. package/tsconfig.json +0 -19
package/vite.config.ts CHANGED
@@ -3,8 +3,10 @@ import { defineConfig, type Plugin, type Connect } from "vite";
3
3
  import { promises as fs } from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { execFile } from "node:child_process";
6
+ import { createHash } from "node:crypto";
6
7
  import { promisify } from "node:util";
7
8
  import type { IncomingMessage, ServerResponse } from "node:http";
9
+ import { fileURLToPath } from "node:url";
8
10
 
9
11
  const WORKSPACE_DIR = ".workspace";
10
12
  const WORKSPACE_FILE = "workspace.json";
@@ -29,6 +31,18 @@ const MIME: Record<string, string> = {
29
31
  ".json": "application/json; charset=utf-8",
30
32
  };
31
33
 
34
+ const MIME_EXT: Record<string, string> = {
35
+ "image/png": ".png",
36
+ "image/jpeg": ".jpg",
37
+ "image/webp": ".webp",
38
+ "image/gif": ".gif",
39
+ "image/svg+xml": ".svg",
40
+ };
41
+ const MANAGED_ASSET_RE = /^assets\/[a-f0-9]{64}\.(png|jpe?g|webp|gif|svg)$/i;
42
+ const ACTOR_TTL_MS = 30000;
43
+ const LOCK_TTL_MS = 30000;
44
+ const ROOT_LINK_HEADER = '</llms.txt>; rel="describedby"; type="text/plain", </.well-known/viteboard.json>; rel="alternate"; type="application/json"';
45
+
32
46
  const execFileAsync = promisify(execFile);
33
47
 
34
48
  type WorkspaceManifest = {
@@ -59,6 +73,7 @@ type WorkspaceResponse = {
59
73
  projects: Array<{
60
74
  id: string;
61
75
  label: string;
76
+ rootPath: string;
62
77
  docsRoots: Array<{
63
78
  id: string;
64
79
  projectId: string;
@@ -73,10 +88,51 @@ type TreeNode =
73
88
  | { type: "file"; name: string; path: string }
74
89
  | { type: "dir"; name: string; path: string; children: TreeNode[] };
75
90
 
91
+ type ActorState =
92
+ | "idle"
93
+ | "viewing-doc"
94
+ | "editing-doc"
95
+ | "viewing-board"
96
+ | "editing-board"
97
+ | "viewing-project"
98
+ | "writing";
99
+
100
+ type ActorSessionRecord = {
101
+ actorId: string;
102
+ actorType: "human" | "agent";
103
+ label: string;
104
+ route: string;
105
+ state: ActorState;
106
+ rootId?: string;
107
+ projectId?: string;
108
+ docPath?: string;
109
+ boardId?: string;
110
+ updatedAt: number;
111
+ expiresAt: number;
112
+ };
113
+
114
+ type LockRecord = {
115
+ lockId: string;
116
+ actorId: string;
117
+ actorType: "human" | "agent";
118
+ label: string;
119
+ targetType: "doc" | "board";
120
+ rootId: string;
121
+ path?: string;
122
+ boardId?: string;
123
+ acquiredAt: number;
124
+ expiresAt: number;
125
+ };
126
+
76
127
  function contentApiPlugin(): Plugin {
77
128
  const cwd = process.cwd();
129
+ const packageRoot = path.dirname(fileURLToPath(import.meta.url));
78
130
  const workspaceDir = path.resolve(cwd, WORKSPACE_DIR);
79
131
  const workspaceFile = path.join(workspaceDir, WORKSPACE_FILE);
132
+ const machineDir = path.join(packageRoot, "content", "machine");
133
+ const actorSessions = new Map<string, ActorSessionRecord>();
134
+ const locks = new Map<string, LockRecord>();
135
+ const sseClients = new Set<ServerResponse>();
80
136
 
81
137
  function send(res: ServerResponse, status: number, body: unknown): void {
82
138
  res.statusCode = status;
@@ -90,6 +146,87 @@ function contentApiPlugin(): Plugin {
90
146
  res.end(body);
91
147
  }
92
148
 
149
+ function sendSse(res: ServerResponse, event: string, body: unknown): void {
150
+ res.write(`event: ${event}\n`);
151
+ res.write(`data: ${JSON.stringify(body)}\n\n`);
152
+ }
153
+
154
+ function broadcast(event: string, body: unknown): void {
155
+ for (const client of sseClients) {
156
+ try {
157
+ sendSse(client, event, body);
158
+ } catch {
159
+ sseClients.delete(client);
160
+ }
161
+ }
162
+ }
163
+
164
+ function actorFromHeaders(req: IncomingMessage): { actorId: string; actorType: "human" | "agent"; label: string } | null {
165
+ const actorId = String(req.headers["x-viteboard-actor-id"] ?? "").trim();
166
+ if (!actorId) return null;
167
+ const actorType = String(req.headers["x-viteboard-actor-type"] ?? "human") === "agent" ? "agent" : "human";
168
+ const label = String(req.headers["x-viteboard-actor-label"] ?? "").trim() || (actorType === "agent" ? "Agent" : "ViteBoard UI");
169
+ return { actorId, actorType, label };
170
+ }
171
+
172
+ function lockKey(target: { targetType: "doc" | "board"; rootId: string; path?: string; boardId?: string }): string {
173
+ const suffix = target.targetType === "doc" ? target.path ?? "" : target.boardId ?? "";
174
+ return `${target.targetType}:${target.rootId}:${suffix}`;
175
+ }
176
+
177
+ function activeActors(): ActorSessionRecord[] {
178
+ return [...actorSessions.values()].sort((a, b) => a.label.localeCompare(b.label));
179
+ }
180
+
181
+ function activeLocks(): LockRecord[] {
182
+ return [...locks.values()].sort((a, b) => a.acquiredAt - b.acquiredAt);
183
+ }
184
+
185
+ function broadcastPresence(): void {
186
+ broadcast("presence", { actors: activeActors(), locks: activeLocks() });
187
+ }
188
+
189
+ function broadcastLocks(): void {
190
+ broadcast("lock-changed", { locks: activeLocks() });
191
+ }
192
+
193
+ function pruneEphemeral(): void {
194
+ const now = Date.now();
195
+ let changedPresence = false;
196
+ let changedLocks = false;
197
+ for (const [actorId, actor] of actorSessions) {
198
+ if (actor.expiresAt <= now) {
199
+ actorSessions.delete(actorId);
200
+ changedPresence = true;
201
+ }
202
+ }
203
+ for (const [key, lock] of locks) {
204
+ if (lock.expiresAt <= now || !actorSessions.has(lock.actorId)) {
205
+ locks.delete(key);
206
+ changedLocks = true;
207
+ }
208
+ }
209
+ if (changedPresence) broadcastPresence();
210
+ else if (changedLocks) broadcastLocks();
211
+ }
212
+
213
+ function extendActorLocks(actorId: string): void {
214
+ const now = Date.now();
215
+ for (const lock of locks.values()) {
216
+ if (lock.actorId === actorId) lock.expiresAt = now + LOCK_TTL_MS;
217
+ }
218
+ }
219
+
220
+ async function readMachineAsset(fileName: string): Promise<string> {
221
+ return fs.readFile(path.join(machineDir, fileName), "utf-8");
222
+ }
223
+
224
+ function revisionForText(content: string): string {
225
+ return createHash("sha1").update(content).digest("hex");
226
+ }
227
+
228
+ setInterval(pruneEphemeral, 10000).unref?.();
229
+
93
230
  function readBody(req: IncomingMessage): Promise<string> {
94
231
  return new Promise((resolve, reject) => {
95
232
  let data = "";
@@ -114,6 +251,48 @@ function contentApiPlugin(): Plugin {
114
251
  return (hash >>> 0).toString(36);
115
252
  }
116
253
 
254
+ async function canonicalizeProjectRoot(rootPath: string): Promise<string> {
255
+ const resolved = path.resolve(rootPath);
256
+ try {
257
+ return path.resolve(await fs.realpath(resolved));
258
+ } catch {
259
+ return resolved;
260
+ }
261
+ }
262
+
263
+ function isNestedPath(parent: string, child: string): boolean {
264
+ const rel = path.relative(parent, child);
265
+ return rel !== "" && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel);
266
+ }
267
+
268
+ function findOverlappingProject(
269
+ projects: WorkspaceProjectRecord[],
270
+ candidateRoot: string,
271
+ ignoreProjectId?: string
272
+ ): WorkspaceProjectRecord | null {
273
+ for (const project of projects) {
274
+ if (ignoreProjectId && project.id === ignoreProjectId) continue;
275
+ if (
276
+ project.rootPath === candidateRoot ||
277
+ isNestedPath(project.rootPath, candidateRoot) ||
278
+ isNestedPath(candidateRoot, project.rootPath)
279
+ ) {
280
+ return project;
281
+ }
282
+ }
283
+ return null;
284
+ }
285
+
286
+ function overlapError(existing: WorkspaceProjectRecord, candidateRoot: string): string {
287
+ if (existing.rootPath === candidateRoot) {
288
+ return `\"${existing.label}\" is already added. Pick a different folder, or remove the existing project first.`;
289
+ }
290
+ if (isNestedPath(existing.rootPath, candidateRoot)) {
291
+ return `That folder is inside the existing workspace project \"${existing.label}\". ViteBoard only allows one project root per nested path.`;
292
+ }
293
+ return `That folder contains the existing workspace project \"${existing.label}\". Add the more specific project instead, or remove the current one first.`;
294
+ }
295
+
117
296
  async function ensureWorkspaceDir(): Promise<void> {
118
297
  await fs.mkdir(workspaceDir, { recursive: true });
119
298
  }
@@ -122,17 +301,26 @@ function contentApiPlugin(): Plugin {
122
301
  try {
123
302
  const raw = await fs.readFile(workspaceFile, "utf-8");
124
303
  const parsed = JSON.parse(raw) as Partial<WorkspaceManifest>;
125
- const projects = Array.isArray(parsed.projects)
126
- ? parsed.projects
127
- .filter((item): item is WorkspaceProjectRecord => {
128
- return !!item && typeof item.id === "string" && typeof item.rootPath === "string";
129
- })
130
- .map((item) => ({
131
- id: item.id,
132
- label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : path.basename(item.rootPath),
133
- rootPath: path.resolve(item.rootPath),
134
- }))
304
+ const rawProjects = Array.isArray(parsed.projects)
305
+ ? parsed.projects.filter((item): item is WorkspaceProjectRecord => {
306
+ return !!item && typeof item.id === "string" && typeof item.rootPath === "string";
307
+ })
135
308
  : [];
309
+ const projects: WorkspaceProjectRecord[] = [];
310
+ const seenIds = new Set<string>();
311
+ const seenPaths = new Set<string>();
312
+ for (const item of rawProjects) {
313
+ const rootPath = await canonicalizeProjectRoot(item.rootPath);
314
+ const id = item.id.trim() || `project-${hashPath(rootPath)}`;
315
+ if (seenIds.has(id) || seenPaths.has(rootPath)) continue;
316
+ seenIds.add(id);
317
+ seenPaths.add(rootPath);
318
+ projects.push({
319
+ id,
320
+ label: typeof item.label === "string" && item.label.trim() ? item.label.trim() : path.basename(rootPath),
321
+ rootPath,
322
+ });
323
+ }
136
324
  return { version: 1, projects };
137
325
  } catch {
138
326
  return { version: 1, projects: [] };
@@ -153,10 +341,7 @@ function contentApiPlugin(): Plugin {
153
341
  }
154
342
 
155
343
  async function loadProjects(): Promise<WorkspaceProjectRecord[]> {
156
- const manifest = await loadManifest();
157
- return manifest.projects.filter((item, index, list) => {
158
- return list.findIndex((other) => other.rootPath === item.rootPath) === index;
159
- });
344
+ return (await loadManifest()).projects;
160
345
  }
161
346
 
162
347
  async function openProjectPicker(): Promise<string | null> {
@@ -290,6 +475,7 @@ function contentApiPlugin(): Plugin {
290
475
  projects: projects.map((project) => ({
291
476
  id: project.id,
292
477
  label: project.label,
478
+ rootPath: project.rootPath,
293
479
  docsRoots: project.docsRoots.map((root) => ({
294
480
  id: root.id,
295
481
  projectId: root.projectId,
@@ -323,6 +509,142 @@ function contentApiPlugin(): Plugin {
323
509
  return abs;
324
510
  }
325
511
 
512
+ function normalizeRelativePath(input: string): string {
513
+ const parts: string[] = [];
514
+ for (const raw of String(input ?? "").replace(/\\/g, "/").split("/")) {
515
+ const part = raw.trim();
516
+ if (!part || part === ".") continue;
517
+ if (part === "..") {
518
+ if (parts.length > 0) parts.pop();
519
+ continue;
520
+ }
521
+ parts.push(part);
522
+ }
523
+ return parts.join("/");
524
+ }
525
+
526
+ function dirnamePosix(filePath: string): string {
527
+ const normalized = normalizeRelativePath(filePath);
528
+ const idx = normalized.lastIndexOf("/");
529
+ return idx === -1 ? "" : normalized.slice(0, idx);
530
+ }
531
+
532
+ function resolveDocRelativePath(fromFile: string, target: string): string | null {
533
+ const raw = String(target ?? "").trim();
534
+ if (!raw || /^(https?:|mailto:|tel:|#|data:)/i.test(raw)) return null;
535
+ if (raw.startsWith("/")) return normalizeRelativePath(raw);
536
+ const base = dirnamePosix(fromFile);
537
+ return normalizeRelativePath(base ? `${base}/${raw}` : raw);
538
+ }
539
+
540
+ function isManagedAssetPath(relPath: string): boolean {
541
+ return MANAGED_ASSET_RE.test(normalizeRelativePath(relPath));
542
+ }
543
+
544
+ async function collectFiles(dir: string, base = ""): Promise<string[]> {
545
+ let entries: import("node:fs").Dirent[];
546
+ try {
547
+ entries = await fs.readdir(dir, { withFileTypes: true });
548
+ } catch {
549
+ return [];
550
+ }
551
+
552
+ const files: string[] = [];
553
+ for (const entry of entries) {
554
+ if (entry.name.startsWith(".")) continue;
555
+ const rel = base ? `${base}/${entry.name}` : entry.name;
556
+ const abs = path.join(dir, entry.name);
557
+ if (entry.isDirectory()) {
558
+ files.push(...(await collectFiles(abs, rel)));
559
+ } else {
560
+ files.push(rel);
561
+ }
562
+ }
563
+ return files;
564
+ }
565
+
566
+ function collectBoardAssetRefs(raw: unknown): Set<string> {
567
+ const refs = new Set<string>();
568
+ if (typeof raw !== "object" || raw === null) return refs;
569
+ const elements = Array.isArray((raw as { elements?: unknown[] }).elements)
570
+ ? (raw as { elements: unknown[] }).elements
571
+ : [];
572
+ for (const element of elements) {
573
+ if (typeof element !== "object" || element === null) continue;
574
+ const data = (element as { data?: Record<string, unknown> }).data;
575
+ const src = typeof data?.src === "string" ? normalizeRelativePath(data.src) : "";
576
+ if (isManagedAssetPath(src)) refs.add(src);
577
+ }
578
+ return refs;
579
+ }
580
+
581
+ function collectMarkdownAssetRefs(markdown: string, docPath: string): Set<string> {
582
+ const refs = new Set<string>();
583
+ const imagePatterns = [
584
+ /!\[[^\]]*\]\(([^)\s]+)\)/g,
585
+ /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi,
586
+ ];
587
+
588
+ for (const pattern of imagePatterns) {
589
+ pattern.lastIndex = 0;
590
+ let match: RegExpExecArray | null;
591
+ while ((match = pattern.exec(markdown))) {
592
+ const resolved = resolveDocRelativePath(docPath, match[1]);
593
+ if (resolved && isManagedAssetPath(resolved)) refs.add(resolved);
594
+ }
595
+ }
596
+
597
+ return refs;
598
+ }
599
+
600
+ async function cleanupManagedAssets(rootAbsPath: string, preservePaths: string[] = []): Promise<number> {
601
+ const allFiles = await collectFiles(rootAbsPath);
602
+ const managedAssets = allFiles.filter((file) => isManagedAssetPath(file));
603
+ if (managedAssets.length === 0) return 0;
604
+
605
+ const referenced = new Set<string>();
606
+ for (const relPath of allFiles) {
607
+ const abs = safeResolve(rootAbsPath, relPath);
608
+ if (!abs) continue;
609
+ if (relPath.endsWith(".board.json")) {
610
+ try {
611
+ const raw = JSON.parse(await fs.readFile(abs, "utf-8"));
612
+ for (const ref of collectBoardAssetRefs(raw)) referenced.add(ref);
613
+ } catch {
614
+ // Ignore unreadable or invalid boards; cleanup stays conservative.
615
+ }
616
+ continue;
617
+ }
618
+ if (relPath.endsWith(".md")) {
619
+ try {
620
+ const markdown = await fs.readFile(abs, "utf-8");
621
+ for (const ref of collectMarkdownAssetRefs(markdown, relPath)) referenced.add(ref);
622
+ } catch {
623
+ // Ignore unreadable docs; cleanup stays conservative.
624
+ }
625
+ }
626
+ }
627
+
628
+ for (const relPath of preservePaths) {
629
+ const normalized = normalizeRelativePath(relPath);
630
+ if (isManagedAssetPath(normalized)) referenced.add(normalized);
631
+ }
632
+
633
+ let removed = 0;
634
+ for (const relPath of managedAssets) {
635
+ if (referenced.has(relPath)) continue;
636
+ const abs = safeResolve(rootAbsPath, relPath);
637
+ if (!abs) continue;
638
+ try {
639
+ await fs.unlink(abs);
640
+ removed++;
641
+ } catch (err) {
642
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
643
+ }
644
+ }
645
+ return removed;
646
+ }
647
+
326
648
  async function walkDocsRoot(dir: string, base: string, nestedRoots: Set<string>): Promise<TreeNode[]> {
327
649
  let entries: import("node:fs").Dirent[];
328
650
  try {
@@ -389,29 +711,226 @@ function contentApiPlugin(): Plugin {
389
711
  return nodes;
390
712
  }
391
713
 
714
+ async function sendMachineFile(res: ServerResponse, fileName: string, contentType: string): Promise<void> {
715
+ return sendText(res, 200, await readMachineAsset(fileName), contentType);
716
+ }
717
+
392
718
  const handler: Connect.NextHandleFunction = (req, res, next) => {
393
719
  const url = req.url ?? "";
394
- if (!url.startsWith("/__api/")) return next();
720
+ const parsed = new URL(url, "http://localhost");
721
+ const route = parsed.pathname;
722
+
723
+ if (route === "/" || route === "/index.html") {
724
+ res.setHeader("Link", ROOT_LINK_HEADER);
725
+ return next();
726
+ }
727
+
728
+ if (route === "/llms.txt") {
729
+ void sendMachineFile(res, "llms.txt", "text/plain; charset=utf-8");
730
+ return;
731
+ }
732
+
733
+ if (route === "/.well-known/viteboard.json") {
734
+ void sendMachineFile(res, "viteboard.json", "application/json; charset=utf-8");
735
+ return;
736
+ }
737
+
738
+ if (route === "/viteboard-guide.md") {
739
+ void sendMachineFile(res, "guide.md", "text/markdown; charset=utf-8");
740
+ return;
741
+ }
742
+
743
+ if (route === "/board-v1.schema.json") {
744
+ void sendMachineFile(res, "board-v1.schema.json", "application/schema+json; charset=utf-8");
745
+ return;
746
+ }
747
+
748
+ if (route === "/viteboard-example.board.json") {
749
+ void sendMachineFile(res, "example.board.json", "application/json; charset=utf-8");
750
+ return;
751
+ }
752
+
753
+ if (!route.startsWith("/__api/")) return next();
395
754
 
396
755
  void (async () => {
397
756
  try {
398
- const parsed = new URL(url, "http://localhost");
399
- const route = parsed.pathname;
400
757
  const method = (req.method ?? "GET").toUpperCase();
758
+ const actor = actorFromHeaders(req);
759
+ pruneEphemeral();
401
760
 
402
761
  if (route === "/__api/workspace" && method === "GET") {
403
762
  return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
404
763
  }
405
764
 
765
+ if (route === "/__api/events" && method === "GET") {
766
+ res.statusCode = 200;
767
+ res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
768
+ res.setHeader("Cache-Control", "no-cache, no-transform");
769
+ res.setHeader("Connection", "keep-alive");
770
+ res.write(": connected\n\n");
771
+ sseClients.add(res);
772
+ sendSse(res, "hello", { actors: activeActors(), locks: activeLocks() });
773
+ req.on("close", () => {
774
+ sseClients.delete(res);
775
+ });
776
+ return;
777
+ }
778
+
779
+ if (route === "/__api/actors/session" && method === "GET") {
780
+ return send(res, 200, { actors: activeActors() });
781
+ }
782
+
783
+ if (route === "/__api/actors/session" && method === "POST") {
784
+ const body = JSON.parse(await readBody(req));
785
+ const actorId = String(body.actorId ?? actor?.actorId ?? "").trim();
786
+ if (!actorId) return send(res, 400, { error: "actorId is required" });
787
+ const actorType = String(body.actorType ?? actor?.actorType ?? "human") === "agent" ? "agent" : "human";
788
+ const label = String(body.label ?? actor?.label ?? "").trim() || (actorType === "agent" ? "Agent" : "ViteBoard UI");
789
+ const now = Date.now();
790
+ actorSessions.set(actorId, {
791
+ actorId,
792
+ actorType,
793
+ label,
794
+ route: String(body.route ?? "#/"),
795
+ state: String(body.state ?? "idle") as ActorState,
796
+ rootId: typeof body.rootId === "string" ? body.rootId : undefined,
797
+ projectId: typeof body.projectId === "string" ? body.projectId : undefined,
798
+ docPath: typeof body.docPath === "string" ? body.docPath : undefined,
799
+ boardId: typeof body.boardId === "string" ? body.boardId : undefined,
800
+ selectedElementIds: Array.isArray(body.selectedElementIds)
801
+ ? body.selectedElementIds.filter((item: unknown): item is string => typeof item === "string")
802
+ : undefined,
803
+ updatedAt: now,
804
+ expiresAt: now + ACTOR_TTL_MS,
805
+ });
806
+ extendActorLocks(actorId);
807
+ broadcastPresence();
808
+ return send(res, 200, { ok: true });
809
+ }
810
+
811
+ if (route === "/__api/actors/heartbeat" && method === "POST") {
812
+ const body = JSON.parse(await readBody(req));
813
+ const actorId = String(body.actorId ?? actor?.actorId ?? "").trim();
814
+ if (!actorId) return send(res, 400, { error: "actorId is required" });
815
+ const current = actorSessions.get(actorId);
816
+ if (!current) return send(res, 404, { error: "Actor session not found" });
817
+ const now = Date.now();
818
+ const next: ActorSessionRecord = {
819
+ ...current,
820
+ route: typeof body.route === "string" ? body.route : current.route,
821
+ state: typeof body.state === "string" ? (body.state as ActorState) : current.state,
822
+ rootId: typeof body.rootId === "string" ? body.rootId : current.rootId,
823
+ projectId: typeof body.projectId === "string" ? body.projectId : current.projectId,
824
+ docPath: typeof body.docPath === "string" ? body.docPath : current.docPath,
825
+ boardId: typeof body.boardId === "string" ? body.boardId : current.boardId,
826
+ selectedElementIds: Array.isArray(body.selectedElementIds)
827
+ ? body.selectedElementIds.filter((item: unknown): item is string => typeof item === "string")
828
+ : current.selectedElementIds,
829
+ updatedAt: now,
830
+ expiresAt: now + ACTOR_TTL_MS,
831
+ };
832
+ actorSessions.set(actorId, next);
833
+ extendActorLocks(actorId);
834
+ broadcastPresence();
835
+ return send(res, 200, { ok: true });
836
+ }
837
+
838
+ if (route === "/__api/actors/session" && method === "DELETE") {
839
+ const actorId = parsed.searchParams.get("actorId") ?? actor?.actorId ?? "";
840
+ if (!actorId) return send(res, 400, { error: "actorId is required" });
841
+ actorSessions.delete(actorId);
842
+ let changedLocks = false;
843
+ for (const [key, lock] of locks) {
844
+ if (lock.actorId === actorId) {
845
+ locks.delete(key);
846
+ changedLocks = true;
847
+ }
848
+ }
849
+ if (changedLocks) broadcastLocks();
850
+ broadcastPresence();
851
+ return send(res, 200, { ok: true });
852
+ }
853
+
854
+ if (route === "/__api/locks" && method === "GET") {
855
+ return send(res, 200, { locks: activeLocks() });
856
+ }
857
+
858
+ if (route === "/__api/locks/acquire" && method === "POST") {
859
+ const body = JSON.parse(await readBody(req));
860
+ if (!actor?.actorId) return send(res, 400, { error: "actor identity is required" });
861
+ const targetType = body.targetType === "board" ? "board" : body.targetType === "doc" ? "doc" : null;
862
+ const rootId = String(body.rootId ?? "").trim();
863
+ if (!targetType || !rootId) return send(res, 400, { error: "targetType and rootId are required" });
864
+ const resolved = await resolveRoot(rootId);
865
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
866
+ const targetPath = targetType === "doc" ? String(body.path ?? "").trim() : undefined;
867
+ const targetBoardId = targetType === "board" ? String(body.boardId ?? "").trim() : undefined;
868
+ const targetAbs = targetType === "doc"
869
+ ? safeResolve(resolved.root.absPath, targetPath ?? "")
870
+ : safeResolve(resolved.root.absPath, `${targetBoardId}.board.json`);
871
+ if (!targetAbs) {
872
+ return send(res, 400, { error: targetType === "doc" ? "Invalid path" : "Invalid boardId" });
873
+ }
874
+ const key = lockKey({ targetType, rootId, path: targetPath, boardId: targetBoardId });
875
+ const now = Date.now();
876
+ const existing = locks.get(key);
877
+ if (existing && existing.actorId !== actor.actorId && existing.expiresAt > now) {
878
+ return send(res, 409, { error: `${existing.label} is editing this ${targetType}.`, lock: existing });
879
+ }
880
+ const nextLock: LockRecord = existing && existing.actorId === actor.actorId
881
+ ? { ...existing, expiresAt: now + LOCK_TTL_MS }
882
+ : {
883
+ lockId: `lock-${hashPath(`${key}:${now}`)}`,
884
+ actorId: actor.actorId,
885
+ actorType: actor.actorType,
886
+ label: actor.label,
887
+ targetType,
888
+ rootId,
889
+ path: targetPath,
890
+ boardId: targetBoardId,
891
+ acquiredAt: now,
892
+ expiresAt: now + LOCK_TTL_MS,
893
+ };
894
+ locks.set(key, nextLock);
895
+ broadcastLocks();
896
+ broadcastPresence();
897
+ return send(res, 200, { ok: true, lock: nextLock });
898
+ }
899
+
900
+ if (route === "/__api/locks/release" && method === "POST") {
901
+ const body = JSON.parse(await readBody(req));
902
+ if (!actor?.actorId) return send(res, 400, { error: "actor identity is required" });
903
+ const targetType = body.targetType === "board" ? "board" : body.targetType === "doc" ? "doc" : null;
904
+ const rootId = String(body.rootId ?? "").trim();
905
+ if (!targetType || !rootId) return send(res, 400, { error: "targetType and rootId are required" });
906
+ const key = lockKey({
907
+ targetType,
908
+ rootId,
909
+ path: targetType === "doc" ? String(body.path ?? "").trim() : undefined,
910
+ boardId: targetType === "board" ? String(body.boardId ?? "").trim() : undefined,
911
+ });
912
+ const existing = locks.get(key);
913
+ if (!existing) return send(res, 200, { ok: true });
914
+ if (existing.actorId !== actor.actorId) return send(res, 403, { error: "Lock owned by another actor" });
915
+ locks.delete(key);
916
+ broadcastLocks();
917
+ broadcastPresence();
918
+ return send(res, 200, { ok: true });
919
+ }
920
+
406
921
  if (route === "/__api/workspace/project" && method === "POST") {
407
922
  const { rootPath, label } = JSON.parse(await readBody(req));
408
- const absRoot = path.resolve(String(rootPath ?? "").trim());
923
+ const absRoot = await canonicalizeProjectRoot(String(rootPath ?? "").trim());
409
924
  if (!absRoot || !(await dirExists(absRoot))) {
410
925
  return send(res, 400, { error: "Project root not found" });
411
926
  }
412
927
  const manifest = await loadManifest();
928
+ const existingProject = manifest.projects.find((item) => item.rootPath === absRoot);
929
+ const projectId = existingProject?.id ?? `project-${hashPath(absRoot)}`;
930
+ const overlap = findOverlappingProject(manifest.projects, absRoot, projectId);
931
+ if (overlap) return send(res, 409, { error: overlapError(overlap, absRoot) });
413
932
  const project: WorkspaceProjectRecord = {
414
- id: `project-${hashPath(absRoot)}`,
933
+ id: projectId,
415
934
  label: String(label ?? "").trim() || path.basename(absRoot) || absRoot,
416
935
  rootPath: absRoot,
417
936
  };
@@ -419,17 +938,22 @@ function contentApiPlugin(): Plugin {
419
938
  if (existingIndex === -1) manifest.projects.push(project);
420
939
  else manifest.projects.splice(existingIndex, 1, project);
421
940
  await saveManifest(manifest);
941
+ broadcast("workspace-changed", { action: "project-added", projectId: project.id, actorId: actor?.actorId ?? null });
422
942
  return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
423
943
  }
424
944
 
425
945
  if (route === "/__api/workspace/pick-project" && method === "POST") {
426
946
  const picked = await openProjectPicker();
427
947
  if (!picked) return send(res, 200, { cancelled: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
428
- const absRoot = path.resolve(picked);
948
+ const absRoot = await canonicalizeProjectRoot(picked);
429
949
  if (!(await dirExists(absRoot))) return send(res, 400, { error: "Project root not found" });
430
950
  const manifest = await loadManifest();
951
+ const existingProject = manifest.projects.find((item) => item.rootPath === absRoot);
952
+ const projectId = existingProject?.id ?? `project-${hashPath(absRoot)}`;
953
+ const overlap = findOverlappingProject(manifest.projects, absRoot, projectId);
954
+ if (overlap) return send(res, 409, { error: overlapError(overlap, absRoot) });
431
955
  const project: WorkspaceProjectRecord = {
432
- id: `project-${hashPath(absRoot)}`,
956
+ id: projectId,
433
957
  label: path.basename(absRoot) || absRoot,
434
958
  rootPath: absRoot,
435
959
  };
@@ -437,6 +961,7 @@ function contentApiPlugin(): Plugin {
437
961
  if (existingIndex === -1) manifest.projects.push(project);
438
962
  else manifest.projects.splice(existingIndex, 1, project);
439
963
  await saveManifest(manifest);
964
+ broadcast("workspace-changed", { action: "project-added", projectId: project.id, actorId: actor?.actorId ?? null });
440
965
  return send(res, 200, { cancelled: false, workspace: await toWorkspaceResponse(await getWorkspace()), addedProjectId: project.id });
441
966
  }
442
967
 
@@ -452,6 +977,7 @@ function contentApiPlugin(): Plugin {
452
977
  const root = refreshedProject?.docsRoots.find((item) => item.absPath === docsPath)
453
978
  ?? refreshedProject?.docsRoots[0];
454
979
  if (!root) return send(res, 500, { error: "Could not create docs root" });
980
+ broadcast("workspace-changed", { action: "docs-root-created", projectId: project.id, rootId: root.id, actorId: actor?.actorId ?? null });
455
981
  return send(res, 200, {
456
982
  root: {
457
983
  id: root.id,
@@ -476,6 +1002,7 @@ function contentApiPlugin(): Plugin {
476
1002
  const refreshedProject = workspace.find((item) => item.id === project.id);
477
1003
  const root = refreshedProject?.docsRoots.find((item) => item.absPath === docsPath);
478
1004
  if (!root) return send(res, 500, { error: "Could not create docs area" });
1005
+ broadcast("workspace-changed", { action: "docs-area-created", projectId: project.id, rootId: root.id, actorId: actor?.actorId ?? null });
479
1006
  return send(res, 200, {
480
1007
  root: {
481
1008
  id: root.id,
@@ -495,6 +1022,7 @@ function contentApiPlugin(): Plugin {
495
1022
  if (!project) return send(res, 404, { error: "Project not found" });
496
1023
  const docsPath = path.join(project.rootPath, String(parentPath ?? ""), "docs");
497
1024
  await fs.mkdir(docsPath, { recursive: true });
1025
+ broadcast("workspace-changed", { action: "docs-folder-created", projectId: project.id, actorId: actor?.actorId ?? null });
498
1026
  return send(res, 200, { ok: true, workspace: await toWorkspaceResponse(await getWorkspace()) });
499
1027
  }
500
1028
 
@@ -503,6 +1031,7 @@ function contentApiPlugin(): Plugin {
503
1031
  const manifest = await loadManifest();
504
1032
  manifest.projects = manifest.projects.filter((item) => item.id !== id);
505
1033
  await saveManifest(manifest);
1034
+ broadcast("workspace-changed", { action: "project-removed", projectId: id, actorId: actor?.actorId ?? null });
506
1035
  return send(res, 200, await toWorkspaceResponse(await getWorkspace()));
507
1036
  }
508
1037
 
@@ -532,21 +1061,38 @@ function contentApiPlugin(): Plugin {
532
1061
  if (!abs) return send(res, 400, { error: "Invalid path" });
533
1062
  try {
534
1063
  const content = await fs.readFile(abs, "utf-8");
535
- return send(res, 200, { path: rel, content });
1064
+ return send(res, 200, { path: rel, content, revision: revisionForText(content) });
536
1065
  } catch {
537
1066
  return send(res, 404, { error: "Not found" });
538
1067
  }
539
1068
  }
540
1069
 
541
1070
  if (route === "/__api/file" && method === "PUT") {
542
- const { rootId, path: rel, content } = JSON.parse(await readBody(req));
1071
+ const { rootId, path: rel, content, expectedRevision } = JSON.parse(await readBody(req));
543
1072
  const resolved = await resolveRoot(String(rootId ?? ""));
544
1073
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
545
1074
  const abs = safeResolve(resolved.root.absPath, String(rel));
546
1075
  if (!abs) return send(res, 400, { error: "Invalid path" });
1076
+ let currentContent: string | null = null;
1077
+ try {
1078
+ currentContent = await fs.readFile(abs, "utf-8");
1079
+ } catch (err) {
1080
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
1081
+ }
1082
+ const currentRevision = currentContent === null ? null : revisionForText(currentContent);
1083
+ if (typeof expectedRevision === "string" && expectedRevision !== currentRevision) {
1084
+ return send(res, 409, {
1085
+ error: "File changed on disk",
1086
+ currentRevision,
1087
+ path: rel,
1088
+ });
1089
+ }
547
1090
  await fs.mkdir(path.dirname(abs), { recursive: true });
548
- await fs.writeFile(abs, String(content ?? ""), "utf-8");
549
- return send(res, 200, { ok: true, path: rel });
1091
+ const nextContent = String(content ?? "");
1092
+ await fs.writeFile(abs, nextContent, "utf-8");
1093
+ const revision = revisionForText(nextContent);
1094
+ broadcast("file-changed", { rootId: resolved.root.id, path: String(rel), action: "write", actorId: actor?.actorId ?? null });
1095
+ return send(res, 200, { ok: true, path: rel, revision });
550
1096
  }
551
1097
 
552
1098
  if (route === "/__api/directory" && method === "PUT") {
@@ -556,6 +1102,7 @@ function contentApiPlugin(): Plugin {
556
1102
  const abs = safeResolve(resolved.root.absPath, String(rel));
557
1103
  if (!abs) return send(res, 400, { error: "Invalid path" });
558
1104
  await fs.mkdir(abs, { recursive: true });
1105
+ broadcast("workspace-changed", { action: "directory-created", rootId: resolved.root.id, path: String(rel), actorId: actor?.actorId ?? null });
559
1106
  return send(res, 200, { ok: true, path: rel });
560
1107
  }
561
1108
 
@@ -571,6 +1118,7 @@ function contentApiPlugin(): Plugin {
571
1118
  } catch (err) {
572
1119
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
573
1120
  }
1121
+ broadcast("file-changed", { rootId: resolved.root.id, path: rel, action: "delete", actorId: actor?.actorId ?? null });
574
1122
  return send(res, 200, { ok: true });
575
1123
  }
576
1124
 
@@ -591,6 +1139,7 @@ function contentApiPlugin(): Plugin {
591
1139
  }
592
1140
  throw err;
593
1141
  }
1142
+ broadcast("file-changed", { rootId: resolved.root.id, path: String(newPath), oldPath: String(oldPath), action: "rename", actorId: actor?.actorId ?? null });
594
1143
  return send(res, 200, { ok: true, path: newPath });
595
1144
  }
596
1145
 
@@ -603,21 +1152,38 @@ function contentApiPlugin(): Plugin {
603
1152
  if (!abs) return send(res, 400, { error: "Invalid id" });
604
1153
  try {
605
1154
  const content = await fs.readFile(abs, "utf-8");
606
- return send(res, 200, { id, doc: JSON.parse(content) });
1155
+ return send(res, 200, { id, doc: JSON.parse(content), revision: revisionForText(content) });
607
1156
  } catch {
608
1157
  return send(res, 404, { error: "Not found" });
609
1158
  }
610
1159
  }
611
1160
 
612
1161
  if (route === "/__api/board" && method === "PUT") {
613
- const { rootId, id, doc } = JSON.parse(await readBody(req));
1162
+ const { rootId, id, doc, expectedRevision } = JSON.parse(await readBody(req));
614
1163
  const resolved = await resolveRoot(String(rootId ?? ""));
615
1164
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
616
1165
  const abs = safeResolve(resolved.root.absPath, `${String(id)}.board.json`);
617
1166
  if (!abs) return send(res, 400, { error: "Invalid id" });
1167
+ let currentContent: string | null = null;
1168
+ try {
1169
+ currentContent = await fs.readFile(abs, "utf-8");
1170
+ } catch (err) {
1171
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
1172
+ }
1173
+ const currentRevision = currentContent === null ? null : revisionForText(currentContent);
1174
+ if (typeof expectedRevision === "string" && expectedRevision !== currentRevision) {
1175
+ return send(res, 409, {
1176
+ error: "Board changed on disk",
1177
+ currentRevision,
1178
+ id,
1179
+ });
1180
+ }
618
1181
  await fs.mkdir(path.dirname(abs), { recursive: true });
619
- await fs.writeFile(abs, JSON.stringify(doc, null, 2) + "\n", "utf-8");
620
- return send(res, 200, { ok: true, id });
1182
+ const nextContent = JSON.stringify(doc, null, 2) + "\n";
1183
+ await fs.writeFile(abs, nextContent, "utf-8");
1184
+ const revision = revisionForText(nextContent);
1185
+ broadcast("board-changed", { rootId: resolved.root.id, boardId: String(id), action: "write", actorId: actor?.actorId ?? null });
1186
+ return send(res, 200, { ok: true, id, revision });
621
1187
  }
622
1188
 
623
1189
  if (route === "/__api/board" && method === "DELETE") {
@@ -632,6 +1198,7 @@ function contentApiPlugin(): Plugin {
632
1198
  } catch (err) {
633
1199
  if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
634
1200
  }
1201
+ broadcast("board-changed", { rootId: resolved.root.id, boardId: id, action: "delete", actorId: actor?.actorId ?? null });
635
1202
  return send(res, 200, { ok: true, id });
636
1203
  }
637
1204
 
@@ -654,23 +1221,37 @@ function contentApiPlugin(): Plugin {
654
1221
  }
655
1222
 
656
1223
  if (route === "/__api/asset" && method === "POST") {
657
- const { rootId, name, base64 } = JSON.parse(await readBody(req));
1224
+ const { rootId, name, base64, mimeType } = JSON.parse(await readBody(req));
658
1225
  const resolved = await resolveRoot(String(rootId ?? ""));
659
1226
  if (!resolved) return send(res, 404, { error: "Docs root not found" });
660
- const ext = (path.extname(String(name ?? "")) || ".png").toLowerCase();
661
- const safeBase = String(name ?? "image")
662
- .replace(/\.[^.]*$/, "")
663
- .replace(/[^a-z0-9-_]/gi, "-")
664
- .slice(0, 40);
665
- const unique = `${safeBase || "image"}-${Date.now().toString(36)}${ext}`;
666
- const rel = `assets/${unique}`;
1227
+ const bytes = Buffer.from(String(base64), "base64");
1228
+ const ext = MIME_EXT[String(mimeType ?? "")] ?? (path.extname(String(name ?? "")) || ".png").toLowerCase();
1229
+ const digest = createHash("sha256").update(bytes).digest("hex");
1230
+ const rel = `assets/${digest}${ext}`;
667
1231
  const abs = safeResolve(resolved.root.absPath, rel);
668
1232
  if (!abs) return send(res, 400, { error: "Invalid path" });
669
1233
  await fs.mkdir(path.dirname(abs), { recursive: true });
670
- await fs.writeFile(abs, Buffer.from(String(base64), "base64"));
1234
+ try {
1235
+ await fs.access(abs);
1236
+ } catch {
1237
+ await fs.writeFile(abs, bytes);
1238
+ }
1239
+ broadcast("asset-changed", { rootId: resolved.root.id, path: rel, action: "write", actorId: actor?.actorId ?? null });
671
1240
  return send(res, 200, { ok: true, path: rel });
672
1241
  }
673
1242
 
1243
+ if (route === "/__api/asset/cleanup" && method === "POST") {
1244
+ const { rootId, preservePaths } = JSON.parse(await readBody(req));
1245
+ const resolved = await resolveRoot(String(rootId ?? ""));
1246
+ if (!resolved) return send(res, 404, { error: "Docs root not found" });
1247
+ const removed = await cleanupManagedAssets(
1248
+ resolved.root.absPath,
1249
+ Array.isArray(preservePaths) ? preservePaths.filter((item): item is string => typeof item === "string") : []
1250
+ );
1251
+ if (removed > 0) broadcast("asset-changed", { rootId: resolved.root.id, action: "cleanup", removed, actorId: actor?.actorId ?? null });
1252
+ return send(res, 200, { ok: true, removed });
1253
+ }
1254
+
674
1255
  if (route === "/__api/workspace-file" && method === "GET") {
675
1256
  await ensureWorkspaceDir();
676
1257
  const manifest = await loadManifest();