@1agh/maude 0.19.1 → 0.21.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.
Files changed (105) hide show
  1. package/README.md +7 -0
  2. package/cli/bin/maude.mjs +5 -1
  3. package/cli/commands/design-link.test.mjs +207 -0
  4. package/cli/commands/design.mjs +42 -12
  5. package/cli/commands/doctor.mjs +350 -0
  6. package/cli/commands/doctor.test.mjs +185 -0
  7. package/cli/commands/help.mjs +24 -0
  8. package/cli/commands/hub.mjs +231 -0
  9. package/cli/commands/hub.test.mjs +87 -0
  10. package/cli/lib/config-lint.mjs +141 -0
  11. package/cli/lib/config-lint.test.mjs +117 -0
  12. package/cli/lib/design-link.mjs +216 -0
  13. package/cli/lib/hubs-config.mjs +123 -0
  14. package/cli/lib/hubs-config.test.mjs +100 -0
  15. package/cli/lib/preflight.mjs +232 -0
  16. package/cli/lib/stack-detect.mjs +344 -0
  17. package/cli/lib/stack-detect.test.mjs +121 -0
  18. package/package.json +17 -9
  19. package/plugins/design/dependencies.json +147 -0
  20. package/plugins/design/dependencies.schema.json +107 -0
  21. package/plugins/design/dev-server/ai-banner.tsx +188 -0
  22. package/plugins/design/dev-server/annotations-context-toolbar.tsx +115 -41
  23. package/plugins/design/dev-server/annotations-layer.tsx +256 -107
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +10 -6
  26. package/plugins/design/dev-server/bin/asset-sweep.sh +180 -0
  27. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  28. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  29. package/plugins/design/dev-server/bin/visual-sanity.sh +221 -0
  30. package/plugins/design/dev-server/canvas-lib.tsx +534 -32
  31. package/plugins/design/dev-server/canvas-shell.tsx +479 -29
  32. package/plugins/design/dev-server/client/app.jsx +72 -0
  33. package/plugins/design/dev-server/client/hmr.mjs +28 -0
  34. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  35. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  36. package/plugins/design/dev-server/collab/index.ts +47 -0
  37. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  38. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  39. package/plugins/design/dev-server/collab/registry.ts +110 -0
  40. package/plugins/design/dev-server/collab/room.ts +215 -0
  41. package/plugins/design/dev-server/commands/annotation-strokes-command.ts +127 -0
  42. package/plugins/design/dev-server/commands/equal-spacing-command.ts +28 -0
  43. package/plugins/design/dev-server/commands/move-artboards-command.ts +158 -0
  44. package/plugins/design/dev-server/comments-overlay.tsx +41 -4
  45. package/plugins/design/dev-server/contextual-toolbar.tsx +241 -0
  46. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  47. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  48. package/plugins/design/dev-server/dist/runtime/motion.js +165 -0
  49. package/plugins/design/dev-server/dist/runtime/motion_react.js +409 -0
  50. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +587 -0
  51. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
  52. package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -0
  53. package/plugins/design/dev-server/equal-spacing-detector.ts +98 -0
  54. package/plugins/design/dev-server/equal-spacing-handles.tsx +289 -0
  55. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  56. package/plugins/design/dev-server/handoff.ts +24 -0
  57. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  58. package/plugins/design/dev-server/http.ts +91 -1
  59. package/plugins/design/dev-server/input-router.tsx +52 -2
  60. package/plugins/design/dev-server/marquee-overlay.tsx +282 -0
  61. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  62. package/plugins/design/dev-server/runtime-bundle.ts +15 -0
  63. package/plugins/design/dev-server/server.ts +78 -11
  64. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  65. package/plugins/design/dev-server/test/annotation-strokes-command.test.ts +149 -0
  66. package/plugins/design/dev-server/test/annotations-layer.test.ts +44 -0
  67. package/plugins/design/dev-server/test/canvas-lib-motion.test.ts +131 -0
  68. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  69. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  70. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  71. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  72. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  73. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  74. package/plugins/design/dev-server/test/equal-spacing-detector.test.ts +93 -0
  75. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  76. package/plugins/design/dev-server/test/input-router.test.ts +87 -1
  77. package/plugins/design/dev-server/test/marquee-overlay.test.ts +94 -0
  78. package/plugins/design/dev-server/test/move-artboards-command.test.ts +108 -0
  79. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  80. package/plugins/design/dev-server/test/undo-stack.test.ts +211 -0
  81. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  82. package/plugins/design/dev-server/test/use-cursor-modifiers.test.ts +59 -0
  83. package/plugins/design/dev-server/test/use-keyboard-discipline.test.ts +27 -0
  84. package/plugins/design/dev-server/test/use-undo-stack.test.tsx +193 -0
  85. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  86. package/plugins/design/dev-server/undo-hud.tsx +95 -0
  87. package/plugins/design/dev-server/undo-stack.ts +240 -0
  88. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  89. package/plugins/design/dev-server/use-artboard-drag.tsx +6 -3
  90. package/plugins/design/dev-server/use-collab.tsx +478 -0
  91. package/plugins/design/dev-server/use-cursor-modifiers.tsx +122 -0
  92. package/plugins/design/dev-server/use-keyboard-discipline.tsx +125 -0
  93. package/plugins/design/dev-server/use-undo-stack.tsx +355 -0
  94. package/plugins/design/dev-server/ws.ts +123 -7
  95. package/plugins/design/templates/_shell.html +51 -6
  96. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +245 -0
  97. package/plugins/design/templates/design-system-inspiration/_MAPPING.md +2 -2
  98. package/plugins/design/templates/design-system-inspiration/core/preview/_components.css.tpl +129 -0
  99. package/plugins/design/templates/design-system-inspiration/core/preview/_motion-readme.md.tpl +63 -0
  100. package/plugins/design/templates/design-system-inspiration/core/preview/motion.css.tpl +106 -0
  101. package/plugins/design/templates/design-system-inspiration/core/preview/motion.tsx.tpl +208 -0
  102. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  103. package/plugins/flow/dependencies.json +143 -0
  104. package/plugins/flow/dependencies.schema.json +107 -0
  105. /package/plugins/design/templates/design-system-inspiration/core/preview/{motion.html → .archive/motion.html} +0 -0
@@ -1483,6 +1483,10 @@ function App() {
1483
1483
  const [activePath, setActivePath] = useState(null);
1484
1484
  const [selected, setSelected] = useState(null);
1485
1485
  const [wsConnected, setWsConnected] = useState(false);
1486
+ // Phase 8 Task 7 — git lifecycle reload prompt. Server has already flushed
1487
+ // every dirty Y.Doc to disk by the time this state populates, so accepting
1488
+ // the reload is data-loss-safe (DDR-051 §3).
1489
+ const [gitLifecycle, setGitLifecycle] = useState(null);
1486
1490
  const [search, setSearch] = useState('');
1487
1491
  const [systemData, setSystemData] = useState(null);
1488
1492
  // Loaded once at boot from /_config — informs canvasUrl() so TSX iframes
@@ -1658,6 +1662,23 @@ function App() {
1658
1662
  setSelected(m.selected);
1659
1663
  } else if (m.type === 'comments' && typeof m.file === 'string') {
1660
1664
  setCommentsByFile(prev => ({ ...prev, [m.file]: m.comments || [] }));
1665
+ } else if (m.type === 'ai-activity' && typeof m.file === 'string') {
1666
+ // Phase 8 Task 4 — relay to every open iframe; each canvas's
1667
+ // AiBanner filters by its own file path. Lightweight broadcast
1668
+ // (one envelope per change, not per iframe count).
1669
+ for (const el of iframesRef.current.values()) {
1670
+ try { el.contentWindow.postMessage({ dgn: 'ai-activity', file: m.file, entry: m.entry }, '*'); } catch {}
1671
+ }
1672
+ } else if (m.type === 'git-lifecycle' && m.payload) {
1673
+ // Phase 8 Task 7 — branch switch / pull mid-session. Server has
1674
+ // already flushed every dirty Y.Doc to JSON; just prompt the user.
1675
+ // Single confirm covers all open iframes — reload reseeds them all.
1676
+ setGitLifecycle(m.payload);
1677
+ // Also relay to iframes so canvas-level "Reload?" UI (if any)
1678
+ // can react. Outer banner is the primary prompt.
1679
+ for (const el of iframesRef.current.values()) {
1680
+ try { el.contentWindow.postMessage({ dgn: 'git-lifecycle', payload: m.payload }, '*'); } catch {}
1681
+ }
1661
1682
  }
1662
1683
  } catch {}
1663
1684
  });
@@ -2000,6 +2021,57 @@ function App() {
2000
2021
  className={'app' + (commentsPanelOpen ? ' with-rsidebar' : '') + (sidebarOpen ? '' : ' no-sidebar')}
2001
2022
  onContextMenu={onShellContextMenu}
2002
2023
  >
2024
+ {gitLifecycle && (
2025
+ <div
2026
+ role="status"
2027
+ aria-live="polite"
2028
+ style={{
2029
+ position: 'fixed',
2030
+ top: 12,
2031
+ left: '50%',
2032
+ transform: 'translateX(-50%)',
2033
+ zIndex: 10002,
2034
+ display: 'flex',
2035
+ alignItems: 'center',
2036
+ gap: 12,
2037
+ padding: '8px 14px',
2038
+ background: '#dbeafe',
2039
+ color: '#1e40af',
2040
+ border: '1px solid #93c5fd',
2041
+ borderRadius: 999,
2042
+ boxShadow: '0 2px 8px rgba(0,0,0,0.12)',
2043
+ font: '500 13px/1.2 system-ui, -apple-system, sans-serif',
2044
+ }}
2045
+ >
2046
+ <span>Repo state changed — reload to sync?</span>
2047
+ <button
2048
+ type="button"
2049
+ onClick={() => { try { window.location.reload(); } catch {} }}
2050
+ style={{
2051
+ padding: '3px 10px',
2052
+ background: '#2563eb',
2053
+ color: '#fff',
2054
+ border: 'none',
2055
+ borderRadius: 4,
2056
+ font: '500 11px/1.2 system-ui',
2057
+ cursor: 'pointer',
2058
+ }}
2059
+ >Reload</button>
2060
+ <button
2061
+ type="button"
2062
+ onClick={() => setGitLifecycle(null)}
2063
+ style={{
2064
+ padding: '3px 10px',
2065
+ background: 'transparent',
2066
+ color: '#1e40af',
2067
+ border: '1px solid #93c5fd',
2068
+ borderRadius: 4,
2069
+ font: '500 11px/1.2 system-ui',
2070
+ cursor: 'pointer',
2071
+ }}
2072
+ >Dismiss</button>
2073
+ </div>
2074
+ )}
2003
2075
  <Sidebar
2004
2076
  groups={groups}
2005
2077
  activePath={activePath}
@@ -65,6 +65,34 @@ function handle(evt) {
65
65
  }
66
66
  }
67
67
  }
68
+ return;
69
+ }
70
+ if (evt.type === 'fs:json' && evt.file) {
71
+ // Phase 20 (DDR-049) — external edits to canvas `.meta.json` or
72
+ // `.annotations.svg` invalidate the iframe's in-memory undo stack
73
+ // (stale before/after snapshots would otherwise restore wrong state).
74
+ // The iframe's UndoStackProvider self-echo-dampens our own PATCH-es
75
+ // via window.__maude_last_meta_self_write_at, so we forward
76
+ // indiscriminately here.
77
+ const file = evt.file;
78
+ const isMeta = file.endsWith('.meta.json');
79
+ if (!isMeta) return;
80
+ const iframes = document.querySelectorAll('iframe[data-canvas-path]');
81
+ for (const f of iframes) {
82
+ if (!f.dataset.canvasPath) continue;
83
+ // Match by the canvas filename root (foo.tsx ↔ foo.meta.json).
84
+ const root = f.dataset.canvasPath.replace(/\.(tsx|jsx|ts|js|html)$/i, '');
85
+ if (!file.includes(`${root}.meta.json`)) continue;
86
+ try {
87
+ f.contentWindow?.dispatchEvent(
88
+ new CustomEvent('maude:invalidate-undo', {
89
+ detail: { reason: 'Edit history reset (external change)' },
90
+ })
91
+ );
92
+ } catch {
93
+ /* cross-origin / detached — ignore */
94
+ }
95
+ }
68
96
  }
69
97
  }
70
98
 
@@ -0,0 +1,127 @@
1
+ // Phase 8 Task 4 — AI activity tracking with heartbeat-based TTL.
2
+ //
3
+ // `/design:edit` (or any external slash command driving Claude work) POSTs to
4
+ // /_api/ai/start when it begins editing a canvas, pings /_api/ai/heartbeat
5
+ // every 10 seconds, and POSTs /_api/ai/end on normal completion. The server
6
+ // maintains an in-memory map keyed by canvas file path, broadcasts changes
7
+ // over the inspector WS bus so every open client renders a yellow "Claude is
8
+ // editing this canvas" banner.
9
+ //
10
+ // Why not Awareness — Awareness is the y-protocols channel for connected
11
+ // peers. The AI is not a connected peer (it lives in a slash command, not a
12
+ // browser tab) and the banner has to reach BOTH collab and inspector clients,
13
+ // not just the per-canvas Y.Doc room. A separate bus event keeps the model
14
+ // honest: AI activity is a server-managed projection, not a peer state.
15
+ //
16
+ // 30-second grace TTL — if the slash command crashes (no /end POST) the
17
+ // banner auto-clears 30 s after the last heartbeat. A janitor runs every 5 s
18
+ // and broadcasts cleared state if the entry expired.
19
+
20
+ import type { Context } from '../context.ts';
21
+
22
+ const HEARTBEAT_GRACE_MS = 30_000;
23
+ const JANITOR_INTERVAL_MS = 5_000;
24
+
25
+ export interface AiActivityEntry {
26
+ file: string;
27
+ author: string;
28
+ startedAt: number;
29
+ lastHeartbeat: number;
30
+ }
31
+
32
+ export interface AiActivity {
33
+ /** Begin tracking AI work on a canvas. Idempotent — replaces any prior. */
34
+ start(file: string, author: string): AiActivityEntry;
35
+ /** Refresh the lastHeartbeat timestamp. Returns null if no entry. */
36
+ heartbeat(file: string): AiActivityEntry | null;
37
+ /** Explicit end — clears immediately on normal completion. */
38
+ end(file: string): boolean;
39
+ /** Read the live state — used by the GET /_api/ai endpoint. */
40
+ list(): AiActivityEntry[];
41
+ /** Check one canvas — used by the bus broadcaster. */
42
+ get(file: string): AiActivityEntry | null;
43
+ /** Tear down the janitor (server shutdown). */
44
+ stop(): void;
45
+ }
46
+
47
+ export function createAiActivity(ctx: Context, now: () => number = Date.now): AiActivity {
48
+ const entries = new Map<string, AiActivityEntry>();
49
+ let janitor: ReturnType<typeof setInterval> | null = null;
50
+
51
+ function broadcast(file: string, entry: AiActivityEntry | null) {
52
+ ctx.bus.emit('ai-activity', { file, entry });
53
+ }
54
+
55
+ function expired(entry: AiActivityEntry, t: number): boolean {
56
+ return t - entry.lastHeartbeat > HEARTBEAT_GRACE_MS;
57
+ }
58
+
59
+ function start(file: string, author: string): AiActivityEntry {
60
+ const t = now();
61
+ const entry: AiActivityEntry = {
62
+ file,
63
+ author,
64
+ startedAt: entries.get(file)?.startedAt ?? t,
65
+ lastHeartbeat: t,
66
+ };
67
+ entries.set(file, entry);
68
+ broadcast(file, entry);
69
+ ensureJanitor();
70
+ return entry;
71
+ }
72
+
73
+ function heartbeat(file: string): AiActivityEntry | null {
74
+ const entry = entries.get(file);
75
+ if (!entry) return null;
76
+ entry.lastHeartbeat = now();
77
+ broadcast(file, entry);
78
+ return entry;
79
+ }
80
+
81
+ function end(file: string): boolean {
82
+ if (!entries.has(file)) return false;
83
+ entries.delete(file);
84
+ broadcast(file, null);
85
+ if (entries.size === 0 && janitor) {
86
+ clearInterval(janitor);
87
+ janitor = null;
88
+ }
89
+ return true;
90
+ }
91
+
92
+ function list(): AiActivityEntry[] {
93
+ return Array.from(entries.values());
94
+ }
95
+
96
+ function get(file: string): AiActivityEntry | null {
97
+ return entries.get(file) ?? null;
98
+ }
99
+
100
+ function ensureJanitor() {
101
+ if (janitor || typeof setInterval === 'undefined') return;
102
+ janitor = setInterval(() => {
103
+ const t = now();
104
+ for (const [file, entry] of entries) {
105
+ if (expired(entry, t)) {
106
+ entries.delete(file);
107
+ broadcast(file, null);
108
+ }
109
+ }
110
+ if (entries.size === 0 && janitor) {
111
+ clearInterval(janitor);
112
+ janitor = null;
113
+ }
114
+ }, JANITOR_INTERVAL_MS);
115
+ }
116
+
117
+ function stop(): void {
118
+ if (janitor) {
119
+ clearInterval(janitor);
120
+ janitor = null;
121
+ }
122
+ }
123
+
124
+ return { start, heartbeat, end, list, get, stop };
125
+ }
126
+
127
+ export { HEARTBEAT_GRACE_MS, JANITOR_INTERVAL_MS };
@@ -0,0 +1,124 @@
1
+ // Phase 8 Task 7 — `.git/HEAD` watcher. On branch switch (`git checkout`) or
2
+ // pull mid-session, force-snapshot every dirty Y.Doc to its on-disk JSON form
3
+ // BEFORE prompting peers to reload. Order matters per DDR-051 §3:
4
+ //
5
+ // 1. Synchronously flush every dirty room → JSON via registry.flushAll().
6
+ // 2. THEN broadcast a `git-lifecycle` bus event with the new HEAD ref.
7
+ // 3. Clients render a non-modal "Repo state changed — reload to sync?"
8
+ // banner; on confirm they call location.reload() and the next mount
9
+ // seeds the Y.Doc from the (now branch-current) JSON.
10
+ //
11
+ // Without step 1, the reload prompt can fire while in-flight edits are still
12
+ // buffered in the 800 ms debounce window — the user's "reload" click then
13
+ // silently discards them. Force-snapshot is the cheap escape valve: pay the
14
+ // full sync cost once at branch-switch time.
15
+
16
+ import { existsSync, readFileSync, watch } from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ import type { Context } from '../context.ts';
20
+ import type { Registry } from './registry.ts';
21
+
22
+ export interface GitLifecycle {
23
+ /** Stop the underlying watcher (server shutdown). */
24
+ stop(): void;
25
+ }
26
+
27
+ interface GitLifecycleEvent {
28
+ reason: 'head-changed';
29
+ head: string;
30
+ prevHead: string | null;
31
+ ts: number;
32
+ }
33
+
34
+ const HEAD_DEBOUNCE_MS = 250;
35
+
36
+ export function createGitLifecycle(ctx: Context, registry: Registry): GitLifecycle {
37
+ const gitDir = path.join(ctx.paths.repoRoot, '.git');
38
+ const headPath = path.join(gitDir, 'HEAD');
39
+
40
+ if (!existsSync(headPath)) {
41
+ // Not a git repo — no lifecycle to watch. Quietly no-op so the dev-server
42
+ // still works inside scratch / templated projects.
43
+ return { stop: () => {} };
44
+ }
45
+
46
+ let prevHead: string | null = readHeadSafe(headPath);
47
+ let debounce: ReturnType<typeof setTimeout> | null = null;
48
+ let watcher: ReturnType<typeof watch> | null = null;
49
+ let stopped = false;
50
+
51
+ async function onChange() {
52
+ if (stopped) return;
53
+ const next = readHeadSafe(headPath);
54
+ if (next === null) return; // transient mid-write read; ignore
55
+ if (next === prevHead) return;
56
+ const old = prevHead;
57
+ prevHead = next;
58
+
59
+ // STEP 1 — synchronous JSON flush BEFORE the prompt. Awaiting is critical;
60
+ // the broadcast must NOT race ahead of the disk write.
61
+ try {
62
+ await registry.flushAll();
63
+ } catch (err) {
64
+ console.error('[git-lifecycle] flushAll failed:', err);
65
+ }
66
+
67
+ // STEP 2 — broadcast. Clients render a reload prompt; user confirms → reload.
68
+ const evt: GitLifecycleEvent = {
69
+ reason: 'head-changed',
70
+ head: next,
71
+ prevHead: old,
72
+ ts: Date.now(),
73
+ };
74
+ ctx.bus.emit('git-lifecycle', evt);
75
+ }
76
+
77
+ function schedule() {
78
+ if (debounce) clearTimeout(debounce);
79
+ debounce = setTimeout(() => {
80
+ debounce = null;
81
+ void onChange();
82
+ }, HEAD_DEBOUNCE_MS);
83
+ }
84
+
85
+ try {
86
+ // Watch the HEAD file directly. fs.watch on .git/HEAD fires on every
87
+ // checkout (git rewrites the file with the new ref) and on pull (when
88
+ // git updates HEAD to a new commit via the `update-ref` path). Some
89
+ // filesystems / git implementations fire spurious events on packfile
90
+ // operations too — the readHead diff above filters noise.
91
+ watcher = watch(headPath, { persistent: false }, () => schedule());
92
+ } catch (err) {
93
+ console.warn(
94
+ '[git-lifecycle] failed to watch .git/HEAD:',
95
+ err instanceof Error ? err.message : err
96
+ );
97
+ }
98
+
99
+ function stop() {
100
+ stopped = true;
101
+ if (debounce) {
102
+ clearTimeout(debounce);
103
+ debounce = null;
104
+ }
105
+ if (watcher) {
106
+ try {
107
+ watcher.close();
108
+ } catch {
109
+ /* ignore */
110
+ }
111
+ watcher = null;
112
+ }
113
+ }
114
+
115
+ return { stop };
116
+ }
117
+
118
+ function readHeadSafe(headPath: string): string | null {
119
+ try {
120
+ return readFileSync(headPath, 'utf8').trim();
121
+ } catch {
122
+ return null;
123
+ }
124
+ }
@@ -0,0 +1,47 @@
1
+ // Public surface of the collab module. Bundles the registry + persistence
2
+ // wiring so ws.ts + server.ts don't need to know about the internal split.
3
+
4
+ import type { Api } from '../api.ts';
5
+ import type { Context } from '../context.ts';
6
+
7
+ import { Y_TYPES, createPersistence } from './persistence.ts';
8
+ import { type Registry, createRegistry } from './registry.ts';
9
+
10
+ export { Y_TYPES };
11
+ export type { Registry } from './registry.ts';
12
+ export type { Room, RoomConn } from './room.ts';
13
+ export type { CollabConn } from './protocol.ts';
14
+
15
+ export interface Collab {
16
+ registry: Registry;
17
+ }
18
+
19
+ /**
20
+ * Build the collab subsystem for this server instance. One registry +
21
+ * persistence binding per dev-server boot. The registry is also the surface
22
+ * the git-lifecycle path will call to force-snapshot dirty rooms (Phase 8
23
+ * Task 7).
24
+ */
25
+ export function createCollab(ctx: Context, api: Api): Collab {
26
+ // Inverse of api.fileSlug — we have the URL slug, need the canonical
27
+ // repo-relative path the api expects. The current fileSlug() is destructive
28
+ // (loses the extension + the designRel prefix), so we round-trip by
29
+ // scanning loadAllComments — cheap enough at room-open time, and a fresh
30
+ // canvas with no comments yet just returns null (an empty Y.Doc is correct
31
+ // in that case).
32
+ //
33
+ // Task 3 will tighten this to a slug -> file lookup table maintained from
34
+ // the index/file-tree state so we don't need to read every JSON file.
35
+ async function fileForSlug(slug: string): Promise<string | null> {
36
+ const all = await api.loadAllComments();
37
+ for (const [file] of Object.entries(all)) {
38
+ if (api.fileSlug(file) === slug) return file;
39
+ }
40
+ return null;
41
+ }
42
+
43
+ const persistence = createPersistence({ ctx, api, fileForSlug });
44
+ const registry = createRegistry(persistence);
45
+
46
+ return { registry };
47
+ }
@@ -0,0 +1,123 @@
1
+ // DDR-051 persistence wiring — bridges Room callbacks to the existing JSON
2
+ // snapshots (Phase 6 _comments/<slug>.json) + Phase 5 annotations.svg + the
3
+ // new `.ydoc.bin` cache under _state/<slug>.ydoc.bin.
4
+
5
+ import path from 'node:path';
6
+
7
+ import * as Y from 'yjs';
8
+
9
+ import type { Api } from '../api.ts';
10
+ import type { Context } from '../context.ts';
11
+ import { type RoomCallbacks, ensureStateDir } from './room.ts';
12
+
13
+ /**
14
+ * Y.Doc shared-type names. Frozen on Task 1 so client + server agree even
15
+ * before they're populated. Task 3 added `comments`; Task 5 adds `annotations`
16
+ * (a Y.Map holding the SVG string under the `svg` key — LWW shape, mirrors
17
+ * the current /_api/annotations PUT semantics).
18
+ */
19
+ export const Y_TYPES = {
20
+ comments: 'comments',
21
+ annotations: 'annotations',
22
+ presentation: 'presentation',
23
+ } as const;
24
+
25
+ export interface PersistenceDeps {
26
+ ctx: Context;
27
+ api: Api;
28
+ /**
29
+ * Resolve a canvas slug back to the repo-relative `file` path the api
30
+ * expects. The collab WS sends slugs (URL-safe), the comments path
31
+ * round-trips through Api.fileSlug. Persistence callers may also call
32
+ * `noteFile(file)` to populate the lookup table — server-side write paths
33
+ * (comments + annotations) call this so the room can locate the canvas
34
+ * even when no prior JSON file existed.
35
+ */
36
+ fileForSlug: (slug: string) => Promise<string | null>;
37
+ /** Best-effort cache primer — see fileForSlug above. */
38
+ noteFile?: (file: string) => void;
39
+ }
40
+
41
+ /**
42
+ * Build the RoomCallbacks the registry plugs into createRoom().
43
+ *
44
+ * persistJson now serializes BOTH `comments` (Y.Array) and `annotations`
45
+ * (Y.Map → `svg` string) back to their existing on-disk formats. Each is
46
+ * skipped when the Y type is empty / unset — the JSON file stays whatever
47
+ * the prior legacy write produced.
48
+ */
49
+ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
50
+ const { ctx, api, fileForSlug } = deps;
51
+ const stateDir = ensureStateDir(ctx.paths.designRoot);
52
+
53
+ function ydocBinPath(slug: string): string {
54
+ return path.join(stateDir, `${slug}.ydoc.bin`);
55
+ }
56
+
57
+ async function readBinary(slug: string): Promise<Uint8Array | null> {
58
+ try {
59
+ const file = Bun.file(ydocBinPath(slug));
60
+ if (!(await file.exists())) return null;
61
+ const buf = await file.arrayBuffer();
62
+ return new Uint8Array(buf);
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ async function seed(slug: string, doc: Y.Doc): Promise<void> {
69
+ // Step 1 — try the binary cache.
70
+ const binary = await readBinary(slug);
71
+ if (binary && binary.byteLength > 0) {
72
+ Y.applyUpdate(doc, binary);
73
+ return;
74
+ }
75
+
76
+ // Step 2 — seed each Y type from its existing on-disk source.
77
+ const file = await fileForSlug(slug);
78
+ if (!file) return;
79
+
80
+ const [comments, svg] = await Promise.all([
81
+ api.loadCommentsForFile(file),
82
+ api.loadAnnotations(file),
83
+ ]);
84
+
85
+ if (comments.length === 0 && !svg) return;
86
+
87
+ doc.transact(() => {
88
+ if (comments.length > 0) {
89
+ const arr = doc.getArray<unknown>(Y_TYPES.comments);
90
+ arr.push(comments);
91
+ }
92
+ if (svg && typeof svg === 'string') {
93
+ const map = doc.getMap<string>(Y_TYPES.annotations);
94
+ map.set('svg', svg);
95
+ }
96
+ }, 'seed');
97
+ }
98
+
99
+ async function persistJson(slug: string, doc: Y.Doc): Promise<void> {
100
+ const file = await fileForSlug(slug);
101
+ if (!file) return;
102
+
103
+ // Comments — Y.Array projection back to JSON.
104
+ const arr = doc.getArray(Y_TYPES.comments);
105
+ if (arr.length > 0) {
106
+ const list = arr.toArray() as Parameters<Api['saveCommentsForFile']>[1];
107
+ await api.saveCommentsForFile(file, list);
108
+ }
109
+
110
+ // Annotations — Y.Map.svg → annotations.svg file. Task 5.
111
+ const map = doc.getMap<unknown>(Y_TYPES.annotations);
112
+ const svg = map.get('svg');
113
+ if (typeof svg === 'string' && svg) {
114
+ await api.saveAnnotations(file, svg);
115
+ }
116
+ }
117
+
118
+ async function persistBinary(slug: string, state: Uint8Array): Promise<void> {
119
+ await Bun.write(ydocBinPath(slug), state);
120
+ }
121
+
122
+ return { seed, persistJson, persistBinary };
123
+ }
@@ -0,0 +1,108 @@
1
+ // y-websocket binary protocol — message routing for the collab WS.
2
+ //
3
+ // Mirrors the reference implementation in y-websocket/bin/utils.js (140 lines),
4
+ // adapted to Bun's native ServerWebSocket. Two message types per the y-protocols
5
+ // spec: 0 = sync (Y.Doc state), 1 = awareness (cursors / presence). Both ride a
6
+ // varint framing of (messageType, payload).
7
+ //
8
+ // The functions are pure transport — Y.Doc lifecycle + persistence + room
9
+ // registry live in `room.ts` and `registry.ts`.
10
+
11
+ import * as decoding from 'lib0/decoding';
12
+ import * as encoding from 'lib0/encoding';
13
+ import * as awarenessProtocol from 'y-protocols/awareness';
14
+ import * as syncProtocol from 'y-protocols/sync';
15
+ import type * as Y from 'yjs';
16
+
17
+ export const MESSAGE_SYNC = 0;
18
+ export const MESSAGE_AWARENESS = 1;
19
+
20
+ export interface CollabConn {
21
+ /** Push a frame to this single peer. */
22
+ send(payload: Uint8Array): void;
23
+ }
24
+
25
+ /**
26
+ * Encode the initial sync handshake (sync step 1) + current awareness state for
27
+ * a freshly connected peer. The peer replies with sync step 2 (their missing
28
+ * updates) which we hand back to `handleMessage`.
29
+ */
30
+ export function encodeHandshake(doc: Y.Doc, awareness: awarenessProtocol.Awareness): Uint8Array[] {
31
+ const frames: Uint8Array[] = [];
32
+
33
+ const sync = encoding.createEncoder();
34
+ encoding.writeVarUint(sync, MESSAGE_SYNC);
35
+ syncProtocol.writeSyncStep1(sync, doc);
36
+ frames.push(encoding.toUint8Array(sync));
37
+
38
+ const states = awareness.getStates();
39
+ if (states.size > 0) {
40
+ const aw = encoding.createEncoder();
41
+ encoding.writeVarUint(aw, MESSAGE_AWARENESS);
42
+ encoding.writeVarUint8Array(
43
+ aw,
44
+ awarenessProtocol.encodeAwarenessUpdate(awareness, Array.from(states.keys()))
45
+ );
46
+ frames.push(encoding.toUint8Array(aw));
47
+ }
48
+
49
+ return frames;
50
+ }
51
+
52
+ /** Encode a sync update frame (broadcast after a Y.Doc local update). */
53
+ export function encodeSyncUpdate(update: Uint8Array): Uint8Array {
54
+ const encoder = encoding.createEncoder();
55
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
56
+ syncProtocol.writeUpdate(encoder, update);
57
+ return encoding.toUint8Array(encoder);
58
+ }
59
+
60
+ /** Encode an awareness update frame (broadcast after applyAwarenessUpdate). */
61
+ export function encodeAwarenessFrame(
62
+ awareness: awarenessProtocol.Awareness,
63
+ changedClients: number[]
64
+ ): Uint8Array {
65
+ const encoder = encoding.createEncoder();
66
+ encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
67
+ encoding.writeVarUint8Array(
68
+ encoder,
69
+ awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients)
70
+ );
71
+ return encoding.toUint8Array(encoder);
72
+ }
73
+
74
+ /**
75
+ * Decode and dispatch one incoming binary frame. Returns an optional reply
76
+ * frame the caller MUST send back to the originating peer (sync step 2 from
77
+ * the server, in response to the peer's sync step 1).
78
+ *
79
+ * `conn` is the origin token threaded into awarenessProtocol so the awareness
80
+ * registry can attribute states to the right peer.
81
+ */
82
+ export function handleMessage(
83
+ payload: Uint8Array,
84
+ doc: Y.Doc,
85
+ awareness: awarenessProtocol.Awareness,
86
+ conn: CollabConn
87
+ ): Uint8Array | null {
88
+ const decoder = decoding.createDecoder(payload);
89
+ const messageType = decoding.readVarUint(decoder);
90
+
91
+ switch (messageType) {
92
+ case MESSAGE_SYNC: {
93
+ const encoder = encoding.createEncoder();
94
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
95
+ // readSyncMessage applies the peer's update to the doc and writes the
96
+ // response (sync step 2 / sync step 2 ack) into encoder.
97
+ syncProtocol.readSyncMessage(decoder, encoder, doc, conn);
98
+ if (encoding.length(encoder) > 1) return encoding.toUint8Array(encoder);
99
+ return null;
100
+ }
101
+ case MESSAGE_AWARENESS: {
102
+ awarenessProtocol.applyAwarenessUpdate(awareness, decoding.readVarUint8Array(decoder), conn);
103
+ return null;
104
+ }
105
+ default:
106
+ return null;
107
+ }
108
+ }