@1agh/maude 0.22.2 → 0.23.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 (44) hide show
  1. package/cli/commands/design-link.test.mjs +53 -1
  2. package/cli/commands/hub.test.mjs +10 -9
  3. package/cli/lib/design-link.mjs +154 -7
  4. package/cli/lib/hubs-config.mjs +42 -4
  5. package/package.json +8 -8
  6. package/plugins/design/dev-server/build.ts +69 -3
  7. package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
  8. package/plugins/design/dev-server/canvas-lib.tsx +22 -6
  9. package/plugins/design/dev-server/canvas-shell.tsx +25 -226
  10. package/plugins/design/dev-server/client/app.jsx +37 -15
  11. package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
  12. package/plugins/design/dev-server/collab/registry.ts +51 -0
  13. package/plugins/design/dev-server/config.schema.json +20 -0
  14. package/plugins/design/dev-server/context.ts +7 -0
  15. package/plugins/design/dev-server/dist/client.bundle.js +21 -12
  16. package/plugins/design/dev-server/dist/comment-mount.js +1801 -0
  17. package/plugins/design/dev-server/dom-selection.ts +156 -0
  18. package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
  19. package/plugins/design/dev-server/input-router.tsx +99 -61
  20. package/plugins/design/dev-server/server.ts +18 -0
  21. package/plugins/design/dev-server/sync/agent.ts +323 -0
  22. package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
  23. package/plugins/design/dev-server/sync/codec.ts +169 -0
  24. package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
  25. package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
  26. package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
  27. package/plugins/design/dev-server/sync/index.ts +474 -0
  28. package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
  29. package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
  30. package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
  31. package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
  32. package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
  33. package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
  34. package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
  35. package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
  36. package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
  37. package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
  38. package/plugins/design/dev-server/test/sync-runtime.test.ts +285 -0
  39. package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
  40. package/plugins/design/dev-server/use-collab.tsx +157 -13
  41. package/plugins/design/dev-server/use-selection-set.tsx +12 -0
  42. package/plugins/design/dev-server/use-tool-mode.tsx +12 -0
  43. package/plugins/design/templates/_shell.html +15 -5
  44. package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -0
@@ -0,0 +1,323 @@
1
+ // Per-canvas bidirectional sync agent — Phase 9 Task 4.
2
+ //
3
+ // Wires together the Y.Doc the HocuspocusProvider keeps in sync with the hub
4
+ // and the local on-disk files Claude Code reads + writes:
5
+ //
6
+ // `.design/<slug>.html` ←→ Y.Text (Y_SYNC_TYPES.html)
7
+ // `.design/_comments/<slug>.json` ←→ Y.Array (Y_TYPES.comments)
8
+ // `.design/<slug>.annotations.svg`←→ Y.Map.svg (Y_TYPES.annotations)
9
+ //
10
+ // Provider is INJECTED — the agent doesn't import @hocuspocus/provider. This
11
+ // keeps the orchestration testable with an in-memory pair of Y.Docs (no hub
12
+ // process required) and makes the wiring layer (sync/index.ts) responsible
13
+ // for the HocuspocusProvider lifecycle.
14
+ //
15
+ // Flow A — local edit (Claude `Write`, designer ⌘S) → hub:
16
+ // 1. fs.watch fires → fs-mirror debounces 250ms → onRead({bytes, hash})
17
+ // 2. echoGuard.consume(path, hash) returns false (genuine edit)
18
+ // 3. applyHtmlToDoc(doc, str, agentOrigin) — emits Y op
19
+ // 4. HocuspocusProvider broadcasts the op to hub → other peers
20
+ //
21
+ // Flow B — hub broadcasts other peer's edit → us:
22
+ // 1. Provider applies update to doc with NON-agent origin
23
+ // 2. doc.on('update') schedules a 800ms-debounced flush
24
+ // 3. On flush: htmlFromDoc(doc) → echoGuard.record(path, hash) → atomicWrite
25
+ // 4. fs.watch fires → fs-mirror reads → onRead matches the recorded echo
26
+ // → echoGuard.consume returns true → event dropped (no infinite loop)
27
+ //
28
+ // 800ms quiescence matches the existing Phase 8 collab room flush (DDR-051).
29
+
30
+ import { existsSync, readFileSync } from 'node:fs';
31
+
32
+ import type * as Y from 'yjs';
33
+
34
+ import { atomicWrite } from './atomic-write.ts';
35
+ import {
36
+ Y_SYNC_TYPES,
37
+ annotationsFromDoc,
38
+ applyAnnotationsToDoc,
39
+ applyCommentsToDoc,
40
+ applyHtmlToDoc,
41
+ commentsFromDoc,
42
+ htmlFromDoc,
43
+ } from './codec.ts';
44
+ import { type EchoGuard, hashBytes } from './echo-guard.ts';
45
+
46
+ export const DOC_FLUSH_MS = 800;
47
+
48
+ export interface CanvasSyncPaths {
49
+ /** Absolute path to <designRoot>/<canvas>.html. */
50
+ html: string;
51
+ /** Absolute path to <designRoot>/_comments/<slug>.json. */
52
+ comments: string;
53
+ /** Absolute path to <designRoot>/<slug>.annotations.svg. */
54
+ annotations: string;
55
+ }
56
+
57
+ export interface CanvasSyncAgentOptions {
58
+ slug: string;
59
+ doc: Y.Doc;
60
+ paths: CanvasSyncPaths;
61
+ echoGuard: EchoGuard;
62
+ /** When true, the first reconcile() pushes local disk state up to the doc
63
+ * instead of overwriting disk with the doc state. Cleared after first run. */
64
+ adopt?: boolean;
65
+ /** Override the 800ms flush. Tests use 0 to flush synchronously. */
66
+ flushMs?: number;
67
+ /** Injected for tests — defaults to atomicWrite. */
68
+ writer?: (path: string, bytes: string | Uint8Array) => void;
69
+ }
70
+
71
+ export interface CanvasSyncAgent {
72
+ readonly slug: string;
73
+ /** Set up the doc.on('update') listener. Idempotent. */
74
+ start(): void;
75
+ /**
76
+ * Reconcile disk ↔ doc once. In adopt mode, disk wins; otherwise doc
77
+ * (= hub) wins. Call this AFTER the provider's `synced` event.
78
+ */
79
+ reconcile(): Promise<void>;
80
+ /** Apply an fs event (from fs-mirror) to the doc, honoring echo guard. */
81
+ applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean;
82
+ /** Force the pending flush timer immediately. */
83
+ flush(): Promise<void>;
84
+ /** Stop all timers + listeners. */
85
+ stop(): void;
86
+ /** Test/inspection: the origin tag used on agent-applied transactions. */
87
+ readonly origin: object;
88
+ }
89
+
90
+ export function createCanvasSyncAgent(opts: CanvasSyncAgentOptions): CanvasSyncAgent {
91
+ const { slug, doc, paths, echoGuard } = opts;
92
+ const flushMs = opts.flushMs ?? DOC_FLUSH_MS;
93
+ const writer = opts.writer ?? atomicWrite;
94
+ let adopt = !!opts.adopt;
95
+
96
+ const origin = Object.freeze({ agent: 'sync', slug });
97
+
98
+ let started = false;
99
+ let stopped = false;
100
+ let dirty = false;
101
+ let flushTimer: ReturnType<typeof setTimeout> | null = null;
102
+
103
+ // Tracks the last-written contents for each path so we don't issue redundant
104
+ // disk writes when a flush fires but the projection hasn't changed.
105
+ let lastHtml: string | null = null;
106
+ let lastComments: string | null = null;
107
+ let lastAnnotations: string | null = null;
108
+
109
+ function onDocUpdate(_update: Uint8Array, updateOrigin: unknown): void {
110
+ if (stopped) return;
111
+ // Self-applied (we just synced from disk) — disk is already current.
112
+ if (updateOrigin === origin) return;
113
+ scheduleFlush();
114
+ }
115
+
116
+ function scheduleFlush(): void {
117
+ dirty = true;
118
+ if (flushMs === 0) {
119
+ // Synchronous mode for tests — fire on next microtask so the doc
120
+ // observer has fully run.
121
+ queueMicrotask(() => {
122
+ void flush();
123
+ });
124
+ return;
125
+ }
126
+ if (flushTimer) clearTimeout(flushTimer);
127
+ flushTimer = setTimeout(() => {
128
+ flushTimer = null;
129
+ void flush();
130
+ }, flushMs);
131
+ }
132
+
133
+ async function flush(): Promise<void> {
134
+ if (!dirty || stopped) return;
135
+ dirty = false;
136
+ if (flushTimer) {
137
+ clearTimeout(flushTimer);
138
+ flushTimer = null;
139
+ }
140
+ try {
141
+ writeHtmlIfChanged();
142
+ writeCommentsIfChanged();
143
+ writeAnnotationsIfChanged();
144
+ } catch (err) {
145
+ dirty = true;
146
+ console.error(`[sync/${slug}] flush failed:`, err);
147
+ }
148
+ }
149
+
150
+ function writeHtmlIfChanged(): void {
151
+ const next = htmlFromDoc(doc);
152
+ if (next === lastHtml) return;
153
+ const hash = hashBytes(next);
154
+ echoGuard.record(paths.html, hash);
155
+ writer(paths.html, next);
156
+ lastHtml = next;
157
+ }
158
+
159
+ function writeCommentsIfChanged(): void {
160
+ const next = commentsFromDoc(doc);
161
+ const serialized = next.length > 0 ? `${JSON.stringify(next, null, 2)}\n` : '';
162
+ if (serialized === lastComments) return;
163
+ if (serialized === '') {
164
+ // Empty comments — don't create an empty file; just remember the state.
165
+ lastComments = serialized;
166
+ return;
167
+ }
168
+ const hash = hashBytes(serialized);
169
+ echoGuard.record(paths.comments, hash);
170
+ writer(paths.comments, serialized);
171
+ lastComments = serialized;
172
+ }
173
+
174
+ function writeAnnotationsIfChanged(): void {
175
+ const next = annotationsFromDoc(doc);
176
+ const value = next ?? '';
177
+ if (value === lastAnnotations) return;
178
+ if (value === '') {
179
+ // Empty annotations — same handling as comments.
180
+ lastAnnotations = value;
181
+ return;
182
+ }
183
+ const hash = hashBytes(value);
184
+ echoGuard.record(paths.annotations, hash);
185
+ writer(paths.annotations, value);
186
+ lastAnnotations = value;
187
+ }
188
+
189
+ function applyFromFs(evt: { path: string; bytes: Uint8Array; hash: string }): boolean {
190
+ if (stopped) return false;
191
+ // Echo of our own atomicWrite — drop.
192
+ if (echoGuard.consume(evt.path, evt.hash)) return false;
193
+
194
+ const str = bytesToString(evt.bytes);
195
+ if (evt.path === paths.html) {
196
+ const changed = applyHtmlToDoc(doc, str, origin);
197
+ if (changed) lastHtml = htmlFromDoc(doc);
198
+ return changed;
199
+ }
200
+ if (evt.path === paths.comments) {
201
+ const parsed = tryParseJsonArray(str);
202
+ if (parsed === null) return false;
203
+ const changed = applyCommentsToDoc(doc, parsed, origin);
204
+ if (changed) lastComments = str;
205
+ return changed;
206
+ }
207
+ if (evt.path === paths.annotations) {
208
+ const changed = applyAnnotationsToDoc(doc, str, origin);
209
+ if (changed) lastAnnotations = str;
210
+ return changed;
211
+ }
212
+ return false;
213
+ }
214
+
215
+ async function reconcile(): Promise<void> {
216
+ if (stopped) return;
217
+ const localHtml = readLocal(paths.html);
218
+ const localComments = readLocal(paths.comments);
219
+ const localAnnotations = readLocal(paths.annotations);
220
+
221
+ const docHtml = htmlFromDoc(doc);
222
+ const docComments = commentsFromDoc(doc);
223
+ const docCommentsStr =
224
+ docComments.length > 0 ? `${JSON.stringify(docComments, null, 2)}\n` : '';
225
+ const docAnnotations = annotationsFromDoc(doc) ?? '';
226
+
227
+ if (adopt) {
228
+ // Push local up: doc takes its values from disk. Hub becomes our
229
+ // canonical view of this canvas. One-shot.
230
+ if (localHtml !== null) applyHtmlToDoc(doc, localHtml, origin);
231
+ if (localComments !== null) {
232
+ const parsed = tryParseJsonArray(localComments);
233
+ if (parsed !== null) applyCommentsToDoc(doc, parsed, origin);
234
+ }
235
+ if (localAnnotations !== null) applyAnnotationsToDoc(doc, localAnnotations, origin);
236
+ lastHtml = localHtml ?? '';
237
+ lastComments = localComments ?? '';
238
+ lastAnnotations = localAnnotations ?? '';
239
+ adopt = false;
240
+ return;
241
+ }
242
+
243
+ // Hub-wins (default): overwrite disk from doc when they differ.
244
+ lastHtml = docHtml;
245
+ lastComments = docCommentsStr;
246
+ lastAnnotations = docAnnotations;
247
+ if (localHtml !== docHtml) {
248
+ const hash = hashBytes(docHtml);
249
+ echoGuard.record(paths.html, hash);
250
+ writer(paths.html, docHtml);
251
+ }
252
+ if (docCommentsStr !== '' && localComments !== docCommentsStr) {
253
+ const hash = hashBytes(docCommentsStr);
254
+ echoGuard.record(paths.comments, hash);
255
+ writer(paths.comments, docCommentsStr);
256
+ }
257
+ if (docAnnotations !== '' && localAnnotations !== docAnnotations) {
258
+ const hash = hashBytes(docAnnotations);
259
+ echoGuard.record(paths.annotations, hash);
260
+ writer(paths.annotations, docAnnotations);
261
+ }
262
+ }
263
+
264
+ return {
265
+ slug,
266
+ origin,
267
+ start() {
268
+ if (started) return;
269
+ doc.on('update', onDocUpdate);
270
+ started = true;
271
+ },
272
+ reconcile,
273
+ applyFromFs,
274
+ flush,
275
+ stop() {
276
+ stopped = true;
277
+ doc.off('update', onDocUpdate);
278
+ if (flushTimer) {
279
+ clearTimeout(flushTimer);
280
+ flushTimer = null;
281
+ }
282
+ },
283
+ };
284
+ }
285
+
286
+ /* ---------------------------------------------------------------- helpers */
287
+
288
+ function readLocal(p: string): string | null {
289
+ if (!existsSync(p)) return null;
290
+ try {
291
+ return readFileSync(p, 'utf8');
292
+ } catch {
293
+ return null;
294
+ }
295
+ }
296
+
297
+ function bytesToString(bytes: Uint8Array): string {
298
+ return new TextDecoder().decode(bytes);
299
+ }
300
+
301
+ // Reviver strips dangerous keys at parse time so a malicious hub-pushed
302
+ // payload (or a planted commit) can't seed `__proto__` / `constructor` /
303
+ // `prototype` own-properties into the comment objects yjs subsequently
304
+ // serializes to other peers. Modern V8/Bun block direct Object.prototype
305
+ // pollution at parse, but the reviver also closes the cross-machine
306
+ // propagation surface where an unsafe `for…in` on a peer would re-pollute.
307
+ // DDR-054 §2g (defender M2).
308
+ function tryParseJsonArray(s: string): unknown[] | null {
309
+ try {
310
+ const parsed = JSON.parse(s, (key, value) => {
311
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
312
+ return undefined;
313
+ }
314
+ return value;
315
+ });
316
+ return Array.isArray(parsed) ? parsed : null;
317
+ } catch {
318
+ return null;
319
+ }
320
+ }
321
+
322
+ // Re-export for the wiring layer to know which shared type holds HTML.
323
+ export { Y_SYNC_TYPES };
@@ -0,0 +1,103 @@
1
+ // Atomic file writes for the bidirectional file sync agent (Phase 9 Task 4).
2
+ //
3
+ // fs.watch on macOS/Linux fires on rename, so a non-atomic write (open →
4
+ // truncate → write chunks → close) emits multiple watch events with partial
5
+ // content. The agent's echo guard hashes the final content, so a partial-write
6
+ // event would miss the hash match and incorrectly bubble the (truncated) state
7
+ // up to Y.Doc — corrupting the live state.
8
+ //
9
+ // Pattern: open `<path>.tmp.<random-128-bit>` with O_CREAT|O_EXCL ('wx' in
10
+ // Node), write bytes, close, `renameSync` to the final path. POSIX rename is
11
+ // atomic when source + destination are on the same filesystem; the watch sees
12
+ // the final-content-already-present rename event rather than a stream of
13
+ // in-flight chunks. O_EXCL + 128-bit suffix defeats pre-created symlinks on
14
+ // shared-tenant hosts (DDR-054 §2c — closes attacker Chain C + defender L1).
15
+ //
16
+ // File mode 0o600 on the staging file so other tenants on shared hosts can't
17
+ // read in-flight content before the rename.
18
+ //
19
+ // Windows note: rename of a file the watcher has open returns EBUSY. The
20
+ // plan's Task 4 step 4 acknowledges this as a known minor risk for v1.1;
21
+ // the agent treats EBUSY as a transient error and retries once after a 25ms
22
+ // jitter.
23
+
24
+ import { randomBytes } from 'node:crypto';
25
+ import { closeSync, mkdirSync, openSync, renameSync, unlinkSync, writeSync } from 'node:fs';
26
+ import { dirname } from 'node:path';
27
+
28
+ const RETRY_DELAY_MS = 25;
29
+ // 'wx' = O_CREAT | O_EXCL — fails if the target already exists, including as
30
+ // a dangling/live symlink. Plus O_TRUNC is implied; mode 0o600 sets owner-only.
31
+ const TMP_OPEN_FLAGS = 'wx' as const;
32
+ const TMP_OPEN_MODE = 0o600;
33
+
34
+ /**
35
+ * Write `bytes` to `path` atomically. Returns the absolute path written
36
+ * (same as input — convenience for chaining).
37
+ *
38
+ * Bytes are written via Node's synchronous `openSync` with `O_CREAT|O_EXCL`
39
+ * (fails on pre-existing tmp / symlink), then `renameSync` moves it into
40
+ * place. 128-bit random suffix; mode 0o600. On POSIX the rename is atomic;
41
+ * on Windows there is a small window where the watcher may see a brief gap,
42
+ * retried once on EBUSY.
43
+ *
44
+ * `bytes` accepts string (UTF-8 encoded) or Uint8Array (written verbatim).
45
+ */
46
+ export function atomicWrite(path: string, bytes: string | Uint8Array): string {
47
+ // Ensure the parent directory exists. Cheap (mkdirSync recursive is a no-op
48
+ // when present) and removes the per-canvas burden of creating _comments/
49
+ // before the agent's first flush.
50
+ mkdirSync(dirname(path), { recursive: true });
51
+ // 16 bytes = 32 hex chars = 128 bits. Unforgeable in practice against a
52
+ // local racer (vs. the prior 32-bit value which was brute-forceable in ms).
53
+ const suffix = randomBytes(16).toString('hex');
54
+ const tmp = `${path}.tmp.${suffix}`;
55
+ let fd: number | null = null;
56
+ try {
57
+ fd = openSync(tmp, TMP_OPEN_FLAGS, TMP_OPEN_MODE);
58
+ const buf = typeof bytes === 'string' ? Buffer.from(bytes, 'utf8') : Buffer.from(bytes);
59
+ writeSync(fd, buf, 0, buf.byteLength, 0);
60
+ closeSync(fd);
61
+ fd = null;
62
+ try {
63
+ renameSync(tmp, path);
64
+ } catch (err) {
65
+ if (isWindowsBusy(err)) {
66
+ // Brief retry — Windows watcher may hold a handle while reading the
67
+ // pre-rename target. 25ms is short enough to be invisible to the user
68
+ // and long enough to clear the typical fs.watch poll interval.
69
+ const start = Date.now();
70
+ while (Date.now() - start < RETRY_DELAY_MS) {
71
+ // Tight loop is OK for 25ms — keeps the call synchronous so callers
72
+ // can record the echo-guard hash before any fs event fires.
73
+ }
74
+ renameSync(tmp, path);
75
+ } else {
76
+ throw err;
77
+ }
78
+ }
79
+ } catch (err) {
80
+ // Best-effort cleanup of the .tmp + fd on failure — don't mask the
81
+ // original exception with a cleanup error.
82
+ if (fd !== null) {
83
+ try {
84
+ closeSync(fd);
85
+ } catch {
86
+ /* ignore */
87
+ }
88
+ }
89
+ try {
90
+ unlinkSync(tmp);
91
+ } catch {
92
+ /* ignore */
93
+ }
94
+ throw err;
95
+ }
96
+ return path;
97
+ }
98
+
99
+ function isWindowsBusy(err: unknown): boolean {
100
+ if (!err || typeof err !== 'object') return false;
101
+ const code = (err as { code?: string }).code;
102
+ return code === 'EBUSY' || code === 'EPERM' || code === 'EACCES';
103
+ }
@@ -0,0 +1,169 @@
1
+ // Y.Doc ↔ disk codecs for the bidirectional file sync agent (Phase 9 Task 4).
2
+ //
3
+ // The agent shuttles three classes of files between disk and the Y.Doc the
4
+ // HocuspocusProvider holds:
5
+ //
6
+ // `.design/<canvas>.html` -> Y.Text (Y_SYNC_TYPES.html)
7
+ // `.design/_comments/<slug>.json` -> Y.Array (Y_TYPES.comments — Phase 6)
8
+ // `.design/<slug>.annotations.svg` -> Y.Map.svg (Y_TYPES.annotations — Phase 5)
9
+ //
10
+ // v1.1 design decision (plan §"Key insight"): HTML body is treated as opaque
11
+ // Y.Text rather than structured Y.XmlFragment. Round-trip drift would
12
+ // otherwise cause infinite sync churn — every time we serialize the structured
13
+ // CRDT back to HTML the formatting whitespace would shift and re-enter the
14
+ // system as a new mutation. Structured CRDT (true element-level co-editing) is
15
+ // Phase 10 / v1.2.
16
+ //
17
+ // The "diff-aware" applyHtmlToDoc replaces the Y.Text contents using a
18
+ // longest-common-prefix + suffix elimination so other peers see a minimal
19
+ // op (e.g. "user changed character 42 only"). This isn't a true textual diff
20
+ // (no LCS), but it's drastically cheaper for typical edits than `delete-all +
21
+ // insert-all`, and crucially preserves cursor positions other peers may have
22
+ // in the unchanged regions.
23
+
24
+ import type * as Y from 'yjs';
25
+
26
+ import { Y_TYPES } from '../collab/persistence.ts';
27
+
28
+ /**
29
+ * Y.Doc shared-type names introduced by Task 4. Distinct namespace from
30
+ * Y_TYPES so existing comments / annotations stay untouched; new fields
31
+ * land here.
32
+ */
33
+ export const Y_SYNC_TYPES = {
34
+ /** The canvas HTML body, as opaque Y.Text. */
35
+ html: 'html',
36
+ } as const;
37
+
38
+ /**
39
+ * Hard caps on hub-pushed content (DDR-054 §2d — closes attacker F7). yjs
40
+ * has no upstream-enforced size cap; the codec is the consumer's guard.
41
+ * Mirrors the existing /_api/annotations 1 MB cap (api.ts) so the sync path
42
+ * doesn't bypass the HTTP-layer guard.
43
+ */
44
+ export const MAX_HTML_BYTES = 4 * 1024 * 1024;
45
+ export const MAX_COMMENTS_BYTES = 1 * 1024 * 1024;
46
+ export const MAX_ANNOTATIONS_BYTES = 1 * 1024 * 1024;
47
+
48
+ function byteLengthUtf8(s: string): number {
49
+ return Buffer.byteLength(s, 'utf8');
50
+ }
51
+
52
+ /* ---------------------------------------------------------------- HTML */
53
+
54
+ export function htmlFromDoc(doc: Y.Doc): string {
55
+ return doc.getText(Y_SYNC_TYPES.html).toString();
56
+ }
57
+
58
+ /**
59
+ * Apply `next` to the Y.Text inside `doc`. Uses a minimal common-prefix /
60
+ * common-suffix replace so peers see a small op rather than a full replace.
61
+ *
62
+ * Pass `origin` as the transaction origin so a downstream observer can
63
+ * distinguish self-originated updates from peer/remote ones.
64
+ */
65
+ export function applyHtmlToDoc(doc: Y.Doc, next: string, origin?: unknown): boolean {
66
+ if (byteLengthUtf8(next) > MAX_HTML_BYTES) {
67
+ console.warn(
68
+ `[sync/codec] refusing HTML apply > ${MAX_HTML_BYTES} bytes (got ${byteLengthUtf8(next)}). DDR-054 §2d.`
69
+ );
70
+ return false;
71
+ }
72
+ const yText = doc.getText(Y_SYNC_TYPES.html);
73
+ const current = yText.toString();
74
+ if (current === next) return false;
75
+
76
+ // Find longest common prefix.
77
+ let prefix = 0;
78
+ const maxPrefix = Math.min(current.length, next.length);
79
+ while (prefix < maxPrefix && current.charCodeAt(prefix) === next.charCodeAt(prefix)) {
80
+ prefix++;
81
+ }
82
+ // Find longest common suffix that doesn't overlap the prefix.
83
+ let suffix = 0;
84
+ const maxSuffix = Math.min(current.length - prefix, next.length - prefix);
85
+ while (
86
+ suffix < maxSuffix &&
87
+ current.charCodeAt(current.length - 1 - suffix) === next.charCodeAt(next.length - 1 - suffix)
88
+ ) {
89
+ suffix++;
90
+ }
91
+
92
+ const deleteLen = current.length - prefix - suffix;
93
+ const insertStr = next.slice(prefix, next.length - suffix);
94
+
95
+ doc.transact(() => {
96
+ if (deleteLen > 0) yText.delete(prefix, deleteLen);
97
+ if (insertStr.length > 0) yText.insert(prefix, insertStr);
98
+ }, origin);
99
+
100
+ return true;
101
+ }
102
+
103
+ /* ---------------------------------------------------------------- comments */
104
+
105
+ /**
106
+ * Comments JSON payload — opaque to the codec, just the array of objects the
107
+ * Y.Array holds.
108
+ */
109
+ export type CommentsSnapshot = unknown[];
110
+
111
+ export function commentsFromDoc(doc: Y.Doc): CommentsSnapshot {
112
+ const arr = doc.getArray(Y_TYPES.comments);
113
+ return arr.toArray();
114
+ }
115
+
116
+ export function applyCommentsToDoc(doc: Y.Doc, next: CommentsSnapshot, origin?: unknown): boolean {
117
+ const arr = doc.getArray(Y_TYPES.comments);
118
+ // Comments are LWW on the JSON file (the snapshot is the source of truth);
119
+ // collapse Y.Array to the new state. For v1.1 we just replace wholesale —
120
+ // structural comment-level merge is deferred along with structured HTML.
121
+ // Check whether anything actually changed to avoid no-op transactions
122
+ // (transactions still fire `update` events, which would re-enter the loop).
123
+ const before = JSON.stringify(arr.toArray());
124
+ const after = JSON.stringify(next);
125
+ if (byteLengthUtf8(after) > MAX_COMMENTS_BYTES) {
126
+ console.warn(
127
+ `[sync/codec] refusing comments apply > ${MAX_COMMENTS_BYTES} bytes (got ${byteLengthUtf8(after)}). DDR-054 §2d.`
128
+ );
129
+ return false;
130
+ }
131
+ if (before === after) return false;
132
+
133
+ doc.transact(() => {
134
+ if (arr.length > 0) arr.delete(0, arr.length);
135
+ if (next.length > 0) arr.push(next);
136
+ }, origin);
137
+ return true;
138
+ }
139
+
140
+ /* ---------------------------------------------------------------- annotations */
141
+
142
+ /** Returns the annotations SVG string, or null if unset. */
143
+ export function annotationsFromDoc(doc: Y.Doc): string | null {
144
+ const map = doc.getMap<unknown>(Y_TYPES.annotations);
145
+ const svg = map.get('svg');
146
+ return typeof svg === 'string' ? svg : null;
147
+ }
148
+
149
+ export function applyAnnotationsToDoc(doc: Y.Doc, next: string | null, origin?: unknown): boolean {
150
+ if (next !== null && byteLengthUtf8(next) > MAX_ANNOTATIONS_BYTES) {
151
+ console.warn(
152
+ `[sync/codec] refusing annotations apply > ${MAX_ANNOTATIONS_BYTES} bytes (got ${byteLengthUtf8(next)}). DDR-054 §2d.`
153
+ );
154
+ return false;
155
+ }
156
+ const map = doc.getMap<unknown>(Y_TYPES.annotations);
157
+ const current = map.get('svg');
158
+ const currentStr = typeof current === 'string' ? current : null;
159
+ if (currentStr === next) return false;
160
+
161
+ doc.transact(() => {
162
+ if (next === null || next === '') {
163
+ map.delete('svg');
164
+ } else {
165
+ map.set('svg', next);
166
+ }
167
+ }, origin);
168
+ return true;
169
+ }