@1agh/maude 0.20.0 → 0.22.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 (71) 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 +361 -0
  6. package/cli/commands/doctor.test.mjs +185 -0
  7. package/cli/commands/help.mjs +24 -0
  8. package/cli/commands/hub.mjs +245 -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 +16 -8
  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 +5 -5
  23. package/plugins/design/dev-server/annotations-layer.tsx +52 -12
  24. package/plugins/design/dev-server/api.ts +17 -1
  25. package/plugins/design/dev-server/artboard-marquee.tsx +2 -2
  26. package/plugins/design/dev-server/bin/preflight.sh +32 -0
  27. package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
  28. package/plugins/design/dev-server/canvas-lib.tsx +33 -7
  29. package/plugins/design/dev-server/canvas-shell.tsx +127 -9
  30. package/plugins/design/dev-server/client/app.jsx +72 -0
  31. package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
  32. package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
  33. package/plugins/design/dev-server/collab/index.ts +47 -0
  34. package/plugins/design/dev-server/collab/persistence.ts +123 -0
  35. package/plugins/design/dev-server/collab/protocol.ts +108 -0
  36. package/plugins/design/dev-server/collab/registry.ts +110 -0
  37. package/plugins/design/dev-server/collab/room.ts +215 -0
  38. package/plugins/design/dev-server/comments-overlay.tsx +29 -0
  39. package/plugins/design/dev-server/contextual-toolbar.tsx +1 -1
  40. package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
  41. package/plugins/design/dev-server/dist/client.bundle.js +75 -3
  42. package/plugins/design/dev-server/dist/runtime/lib0_decoding.js +624 -0
  43. package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +608 -0
  44. package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +380 -0
  45. package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +104 -0
  46. package/plugins/design/dev-server/dist/runtime/yjs.js +6691 -0
  47. package/plugins/design/dev-server/export-dialog.tsx +1 -1
  48. package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
  49. package/plugins/design/dev-server/http.ts +64 -1
  50. package/plugins/design/dev-server/marquee-overlay.tsx +2 -2
  51. package/plugins/design/dev-server/participants-chrome.tsx +261 -0
  52. package/plugins/design/dev-server/runtime-bundle.ts +19 -0
  53. package/plugins/design/dev-server/server.ts +78 -11
  54. package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
  55. package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
  56. package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
  57. package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
  58. package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
  59. package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
  60. package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
  61. package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
  62. package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
  63. package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
  64. package/plugins/design/dev-server/tool-palette.tsx +7 -7
  65. package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
  66. package/plugins/design/dev-server/use-collab.tsx +478 -0
  67. package/plugins/design/dev-server/ws.ts +123 -7
  68. package/plugins/design/templates/_shell.html +37 -1
  69. package/plugins/flow/.claude-plugin/config.schema.json +12 -0
  70. package/plugins/flow/dependencies.json +143 -0
  71. package/plugins/flow/dependencies.schema.json +107 -0
@@ -0,0 +1,478 @@
1
+ /**
2
+ * @file use-collab.tsx — client-side Yjs collab provider for canvas iframes
3
+ * @scope plugins/design/dev-server/use-collab.tsx
4
+ * @purpose Mounts a single Y.Doc + Awareness per canvas iframe. Opens a
5
+ * WebSocket to `/_ws/collab/:slug`, speaks the y-websocket binary
6
+ * protocol, exposes hooks for the cursor overlay + Task 3 comments
7
+ * binding.
8
+ *
9
+ * Boundary:
10
+ * - Server-side equivalent is `collab/protocol.ts` + `collab/room.ts`.
11
+ * - This file mirrors the message framing (varint-prefixed sync + awareness
12
+ * frames) so the two sides converge over a binary WS without intermediate
13
+ * JSON.
14
+ * - Imports `yjs` + `y-protocols/{sync,awareness}` via the canvas-shell
15
+ * importmap (RUNTIME_PACKAGES additions). Canvas bundles that don't mount
16
+ * <CollabProvider> never resolve these specifiers and pay zero bundle cost.
17
+ */
18
+
19
+ import {
20
+ type ReactNode,
21
+ createContext,
22
+ useCallback,
23
+ useContext,
24
+ useEffect,
25
+ useMemo,
26
+ useRef,
27
+ useState,
28
+ } from 'react';
29
+
30
+ import * as decoding from 'lib0/decoding';
31
+ import * as encoding from 'lib0/encoding';
32
+ import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate } from 'y-protocols/awareness';
33
+ import { readSyncMessage, writeSyncStep1, writeUpdate } from 'y-protocols/sync';
34
+ import * as Y from 'yjs';
35
+
36
+ const MESSAGE_SYNC = 0;
37
+ const MESSAGE_AWARENESS = 1;
38
+
39
+ // ─────────────────────────────────────────────────────────────────────────────
40
+ // Color hash — stable per peer identity.
41
+
42
+ /**
43
+ * djb2 string hash → 0xRRGGBB color in a curated palette. Determinism per
44
+ * input name is the load-bearing property: every peer hashing "Alice" must
45
+ * land on the SAME color. 12 hues spread evenly around the wheel; saturation
46
+ * + lightness fixed so all colors stay readable on light + dark surfaces.
47
+ */
48
+ const COLOR_PALETTE = [
49
+ '#e91e63', // pink
50
+ '#f44336', // red
51
+ '#ff9800', // orange
52
+ '#ffc107', // amber
53
+ '#cddc39', // lime
54
+ '#4caf50', // green
55
+ '#009688', // teal
56
+ '#00bcd4', // cyan
57
+ '#03a9f4', // light blue
58
+ '#3f51b5', // indigo
59
+ '#673ab7', // deep purple
60
+ '#9c27b0', // purple
61
+ ] as const;
62
+
63
+ export function colorForName(name: string): string {
64
+ // COLOR_PALETTE is a non-empty const tuple; the explicit `?? '#000'`
65
+ // fallback is unreachable but satisfies `noUncheckedIndexedAccess`.
66
+ const FALLBACK = '#000000';
67
+ if (!name) return COLOR_PALETTE[0] ?? FALLBACK;
68
+ let hash = 5381;
69
+ for (let i = 0; i < name.length; i++) {
70
+ hash = ((hash << 5) + hash + name.charCodeAt(i)) | 0;
71
+ }
72
+ const idx = ((hash % COLOR_PALETTE.length) + COLOR_PALETTE.length) % COLOR_PALETTE.length;
73
+ return COLOR_PALETTE[idx] ?? FALLBACK;
74
+ }
75
+
76
+ // ─────────────────────────────────────────────────────────────────────────────
77
+ // Awareness state shape.
78
+
79
+ export interface CollabAwarenessState {
80
+ name: string;
81
+ color: string;
82
+ /**
83
+ * Cursor position in **world coords** (canvas-lib viewport space) so foreign
84
+ * peers see the same conceptual point even when their local viewport is
85
+ * panned/zoomed differently. Null = peer is not over the canvas surface.
86
+ */
87
+ cursor: { x: number; y: number } | null;
88
+ /**
89
+ * Most-recently selected element. `cssPath` is the locator id chain the
90
+ * canvas-shell already uses; `bounds` are screen-px rect at the moment of
91
+ * publish (so it's a hint, not a live ref). Null when nothing selected.
92
+ */
93
+ selection: { cssPath: string; bounds: { x: number; y: number; w: number; h: number } } | null;
94
+ /**
95
+ * Currently-selected annotation stroke IDs (Phase 5). Strokes are addressed
96
+ * by their stable `data-id` attribute, so peers can resolve halos via
97
+ * `document.querySelectorAll('[data-id="<id>"]')`. Empty when nothing
98
+ * annotation-shaped is selected.
99
+ */
100
+ annotationSelection: string[];
101
+ viewport: { x: number; y: number; zoom: number };
102
+ /**
103
+ * Server-side `disconnect` matches awareness states to outgoing peers by
104
+ * this token (must equal the ws.data.id the server assigns at upgrade).
105
+ * Until the server pushes the assigned id back to the client, we use a
106
+ * client-generated UUID — collisions are negligible and disconnect cleanup
107
+ * tolerates a stale state (the next awareness GC pass drops it).
108
+ */
109
+ __connId: string;
110
+ }
111
+
112
+ export type ForeignAwareness = Omit<CollabAwarenessState, '__connId'> & { clientID: number };
113
+
114
+ // ─────────────────────────────────────────────────────────────────────────────
115
+ // Context.
116
+
117
+ interface CollabValue {
118
+ doc: Y.Doc;
119
+ awareness: Awareness;
120
+ /** Local peer's session-stable color (derived from git user.name). */
121
+ myColor: string;
122
+ /** Local peer's display name (git user.name or anonymous fallback). */
123
+ myName: string;
124
+ /** Local peer's connection id (matches server-side ws.data.id pattern). */
125
+ myConnId: string;
126
+ /** True when the WS is OPEN. Cursor overlay can use this to gate rendering. */
127
+ connected: boolean;
128
+ /** Publish (debounce-coalesced) an updated local awareness state. */
129
+ publishAwareness: (patch: Partial<Omit<CollabAwarenessState, '__connId'>>) => void;
130
+ }
131
+
132
+ const CollabContext = createContext<CollabValue | null>(null);
133
+
134
+ export function useCollab(): CollabValue | null {
135
+ return useContext(CollabContext);
136
+ }
137
+
138
+ // ─────────────────────────────────────────────────────────────────────────────
139
+ // Hook: foreign awareness peers (the cursor overlay subscribes to this).
140
+
141
+ /**
142
+ * Returns the current set of foreign peers (excludes the local client). The
143
+ * returned array is stable-reference between awareness updates — useful for
144
+ * downstream React.memo cursor components.
145
+ */
146
+ export function useForeignAwareness(): ForeignAwareness[] {
147
+ const collab = useCollab();
148
+ const [peers, setPeers] = useState<ForeignAwareness[]>([]);
149
+
150
+ useEffect(() => {
151
+ if (!collab) {
152
+ setPeers([]);
153
+ return;
154
+ }
155
+ const { awareness } = collab;
156
+ function compute(): ForeignAwareness[] {
157
+ const out: ForeignAwareness[] = [];
158
+ const myId = awareness.clientID;
159
+ for (const [clientID, state] of awareness.getStates() as Map<number, unknown>) {
160
+ if (clientID === myId) continue;
161
+ if (!state || typeof state !== 'object') continue;
162
+ const s = state as Partial<CollabAwarenessState>;
163
+ // Guard against partial states from stale peers / older protocol versions.
164
+ if (typeof s.name !== 'string' || typeof s.color !== 'string') continue;
165
+ out.push({
166
+ clientID,
167
+ name: s.name,
168
+ color: s.color,
169
+ cursor: s.cursor ?? null,
170
+ selection: s.selection ?? null,
171
+ annotationSelection: Array.isArray(s.annotationSelection) ? s.annotationSelection : [],
172
+ viewport: s.viewport ?? { x: 0, y: 0, zoom: 1 },
173
+ });
174
+ }
175
+ return out;
176
+ }
177
+ setPeers(compute());
178
+ const onChange = () => setPeers(compute());
179
+ awareness.on('change', onChange);
180
+ return () => {
181
+ awareness.off('change', onChange);
182
+ };
183
+ }, [collab]);
184
+
185
+ return peers;
186
+ }
187
+
188
+ // ─────────────────────────────────────────────────────────────────────────────
189
+ // Slug derivation — must match `api.fileSlug` server-side.
190
+
191
+ /**
192
+ * Mirror of server-side `api.fileSlug`. The input is the canvas path as the
193
+ * shell stored it on `window.__canvas_meta_file__` (e.g. `.design/ui/Foo.tsx`).
194
+ * Strip the designRel prefix (read from `window.__canvas_design_rel__`, set
195
+ * by _shell.html) so both sides land on the same slug — without this both
196
+ * tabs open a `design-ui-foo` room while the server's inspector bridge
197
+ * pushes into `ui-foo`, and the rooms never converge.
198
+ */
199
+ export function canvasSlugFromPath(canvasRel: string | null | undefined): string | null {
200
+ if (!canvasRel) return null;
201
+ let p = canvasRel.replace(/^\/+|\/+$/g, '');
202
+ if (typeof window !== 'undefined') {
203
+ const w = window as unknown as { __canvas_design_rel__?: string };
204
+ const designRel = (w.__canvas_design_rel__ ?? '').replace(/^\/+|\/+$/g, '');
205
+ if (designRel && p.startsWith(`${designRel}/`)) p = p.slice(designRel.length + 1);
206
+ }
207
+ const slug = p
208
+ .replace(/\//g, '-')
209
+ .replace(/\s+/g, '_')
210
+ .replace(/\.(tsx|html)$/i, '')
211
+ .replace(/^\.+/, '')
212
+ .toLowerCase();
213
+ return /^[a-z0-9_-]+$/.test(slug) ? slug : null;
214
+ }
215
+
216
+ // ─────────────────────────────────────────────────────────────────────────────
217
+ // Provider — opens WS, owns Y.Doc + Awareness lifecycle.
218
+
219
+ interface CollabProviderProps {
220
+ /** Canvas slug — must match server-side `parseCollabSlug`. */
221
+ slug: string;
222
+ children: ReactNode;
223
+ }
224
+
225
+ const AWARENESS_THROTTLE_MS = 33; // ~30 Hz
226
+
227
+ export function CollabProvider({ slug, children }: CollabProviderProps): JSX.Element {
228
+ // Y.Doc + Awareness are recreated whenever the slug changes (switching
229
+ // canvases tears down the prior session cleanly). The useMemo factory
230
+ // bodies don't read `slug` — slug IS the cache key, intentionally.
231
+ // biome-ignore lint/correctness/useExhaustiveDependencies: slug is the cache key
232
+ const doc = useMemo(() => new Y.Doc(), [slug]);
233
+ const awareness = useMemo(() => new Awareness(doc), [doc]);
234
+ // biome-ignore lint/correctness/useExhaustiveDependencies: slug is the cache key
235
+ const myConnId = useMemo(() => crypto.randomUUID(), [slug]);
236
+
237
+ const [myName, setMyName] = useState('anonymous');
238
+ const [myColor, setMyColor] = useState(colorForName('anonymous'));
239
+ const [connected, setConnected] = useState(false);
240
+
241
+ // Fetch git user.name once per slug; falls back to anonymous-<short id>.
242
+ useEffect(() => {
243
+ let cancelled = false;
244
+ fetch('/_api/git-user')
245
+ .then((r) => r.json())
246
+ .then((j) => {
247
+ if (cancelled) return;
248
+ const n = typeof j?.name === 'string' && j.name.trim() ? j.name.trim() : null;
249
+ const finalName = n ?? `anonymous-${myConnId.slice(0, 6)}`;
250
+ setMyName(finalName);
251
+ setMyColor(colorForName(finalName));
252
+ })
253
+ .catch(() => {
254
+ if (cancelled) return;
255
+ const fallback = `anonymous-${myConnId.slice(0, 6)}`;
256
+ setMyName(fallback);
257
+ setMyColor(colorForName(fallback));
258
+ });
259
+ return () => {
260
+ cancelled = true;
261
+ };
262
+ }, [myConnId]);
263
+
264
+ // Seed local awareness state immediately so foreign peers see our name even
265
+ // before the cursor moves. Update when myName/myColor settles from the fetch.
266
+ useEffect(() => {
267
+ awareness.setLocalState({
268
+ name: myName,
269
+ color: myColor,
270
+ cursor: null,
271
+ selection: null,
272
+ annotationSelection: [],
273
+ viewport: { x: 0, y: 0, zoom: 1 },
274
+ __connId: myConnId,
275
+ } satisfies CollabAwarenessState);
276
+ }, [awareness, myName, myColor, myConnId]);
277
+
278
+ // ── WebSocket lifecycle ──────────────────────────────────────────────────
279
+ const wsRef = useRef<WebSocket | null>(null);
280
+
281
+ useEffect(() => {
282
+ let cancelled = false;
283
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
284
+
285
+ function sendFrame(ws: WebSocket, payload: Uint8Array) {
286
+ try {
287
+ ws.send(payload);
288
+ } catch {
289
+ /* dead socket — close handler will reconnect */
290
+ }
291
+ }
292
+
293
+ function broadcastAwareness(ws: WebSocket, changed: number[]) {
294
+ if (changed.length === 0) return;
295
+ const encoder = encoding.createEncoder();
296
+ encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
297
+ encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(awareness, changed));
298
+ sendFrame(ws, encoding.toUint8Array(encoder));
299
+ }
300
+
301
+ function broadcastSyncUpdate(ws: WebSocket, update: Uint8Array) {
302
+ const encoder = encoding.createEncoder();
303
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
304
+ writeUpdate(encoder, update);
305
+ sendFrame(ws, encoding.toUint8Array(encoder));
306
+ }
307
+
308
+ function connect() {
309
+ if (cancelled) return;
310
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
311
+ const ws = new WebSocket(`${proto}//${location.host}/_ws/collab/${slug}`);
312
+ ws.binaryType = 'arraybuffer';
313
+ wsRef.current = ws;
314
+
315
+ ws.addEventListener('open', () => {
316
+ setConnected(true);
317
+ // Sync step 1 — announce our state vector so the server can send the
318
+ // missing pieces (matches the encodeHandshake server path).
319
+ const encoder = encoding.createEncoder();
320
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
321
+ writeSyncStep1(encoder, doc);
322
+ sendFrame(ws, encoding.toUint8Array(encoder));
323
+ // Awareness initial state — fire our local state to the room.
324
+ broadcastAwareness(ws, [awareness.clientID]);
325
+ });
326
+
327
+ ws.addEventListener('close', () => {
328
+ setConnected(false);
329
+ wsRef.current = null;
330
+ if (cancelled) return;
331
+ reconnectTimer = setTimeout(connect, 1000);
332
+ });
333
+
334
+ ws.addEventListener('error', () => {
335
+ // Let close handler do the reconnect; error events without a close
336
+ // would just retry-spam.
337
+ });
338
+
339
+ ws.addEventListener('message', (evt) => {
340
+ const payload =
341
+ evt.data instanceof ArrayBuffer
342
+ ? new Uint8Array(evt.data)
343
+ : evt.data instanceof Uint8Array
344
+ ? evt.data
345
+ : null;
346
+ if (!payload) return;
347
+ const decoder = decoding.createDecoder(payload);
348
+ const messageType = decoding.readVarUint(decoder);
349
+ switch (messageType) {
350
+ case MESSAGE_SYNC: {
351
+ const encoder = encoding.createEncoder();
352
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
353
+ readSyncMessage(decoder, encoder, doc, ws);
354
+ if (encoding.length(encoder) > 1) sendFrame(ws, encoding.toUint8Array(encoder));
355
+ break;
356
+ }
357
+ case MESSAGE_AWARENESS: {
358
+ applyAwarenessUpdate(awareness, decoding.readVarUint8Array(decoder), ws);
359
+ break;
360
+ }
361
+ default:
362
+ break;
363
+ }
364
+ });
365
+ }
366
+
367
+ // Wire doc updates → broadcast to server. Origin tagged with the ws ref so
368
+ // server-side updates we receive don't echo back.
369
+ const onDocUpdate = (update: Uint8Array, origin: unknown) => {
370
+ const ws = wsRef.current;
371
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
372
+ if (origin === ws) return; // came from server, don't echo
373
+ broadcastSyncUpdate(ws, update);
374
+ };
375
+ doc.on('update', onDocUpdate);
376
+
377
+ // Wire awareness changes → broadcast. Same origin guard.
378
+ const onAwarenessUpdate = (
379
+ { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
380
+ origin: unknown
381
+ ) => {
382
+ const ws = wsRef.current;
383
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
384
+ if (origin === ws) return;
385
+ const changed = added.concat(updated, removed);
386
+ broadcastAwareness(ws, changed);
387
+ };
388
+ awareness.on('update', onAwarenessUpdate);
389
+
390
+ connect();
391
+
392
+ return () => {
393
+ cancelled = true;
394
+ if (reconnectTimer) clearTimeout(reconnectTimer);
395
+ doc.off('update', onDocUpdate);
396
+ awareness.off('update', onAwarenessUpdate);
397
+ const ws = wsRef.current;
398
+ if (ws && ws.readyState === WebSocket.OPEN) {
399
+ try {
400
+ ws.close();
401
+ } catch {
402
+ /* ignore */
403
+ }
404
+ }
405
+ // Don't destroy doc/awareness here — the useMemo-tied lifetime handles
406
+ // that when the slug changes.
407
+ };
408
+ }, [slug, doc, awareness]);
409
+
410
+ // Per-slug cleanup of doc/awareness when slug changes (or provider unmounts).
411
+ useEffect(
412
+ () => () => {
413
+ try {
414
+ awareness.destroy();
415
+ } catch {
416
+ /* ignore */
417
+ }
418
+ try {
419
+ doc.destroy();
420
+ } catch {
421
+ /* ignore */
422
+ }
423
+ },
424
+ [doc, awareness]
425
+ );
426
+
427
+ // ── Throttled awareness publish ─────────────────────────────────────────
428
+ const pendingRef = useRef<Partial<Omit<CollabAwarenessState, '__connId'>> | null>(null);
429
+ const throttleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
430
+
431
+ const publishAwareness = useCallback(
432
+ (patch: Partial<Omit<CollabAwarenessState, '__connId'>>) => {
433
+ pendingRef.current = { ...(pendingRef.current ?? {}), ...patch };
434
+ if (throttleTimerRef.current) return;
435
+ throttleTimerRef.current = setTimeout(() => {
436
+ throttleTimerRef.current = null;
437
+ const next = pendingRef.current;
438
+ pendingRef.current = null;
439
+ if (!next) return;
440
+ const current = (awareness.getLocalState() ?? {}) as Partial<CollabAwarenessState>;
441
+ awareness.setLocalState({
442
+ name: current.name ?? myName,
443
+ color: current.color ?? myColor,
444
+ cursor: current.cursor ?? null,
445
+ selection: current.selection ?? null,
446
+ annotationSelection: current.annotationSelection ?? [],
447
+ viewport: current.viewport ?? { x: 0, y: 0, zoom: 1 },
448
+ __connId: myConnId,
449
+ ...next,
450
+ } satisfies CollabAwarenessState);
451
+ }, AWARENESS_THROTTLE_MS);
452
+ },
453
+ [awareness, myName, myColor, myConnId]
454
+ );
455
+
456
+ // ── Cleanup throttle timer on unmount ───────────────────────────────────
457
+ useEffect(
458
+ () => () => {
459
+ if (throttleTimerRef.current) clearTimeout(throttleTimerRef.current);
460
+ },
461
+ []
462
+ );
463
+
464
+ const value = useMemo<CollabValue>(
465
+ () => ({
466
+ doc,
467
+ awareness,
468
+ myColor,
469
+ myName,
470
+ myConnId,
471
+ connected,
472
+ publishAwareness,
473
+ }),
474
+ [doc, awareness, myColor, myName, myConnId, connected, publishAwareness]
475
+ );
476
+
477
+ return <CollabContext.Provider value={value}>{children}</CollabContext.Provider>;
478
+ }
@@ -4,13 +4,60 @@
4
4
  import type { ServerWebSocket, WebSocketHandler } from 'bun';
5
5
 
6
6
  import type { Api } from './api.ts';
7
+ import type { Collab, RoomConn } from './collab/index.ts';
7
8
  import type { Context } from './context.ts';
8
9
  import { createHmrBroadcaster } from './hmr-broadcast.ts';
9
10
  import type { Inspect } from './inspect.ts';
10
11
 
11
- export interface WsData {
12
- id: string;
13
- remote: string;
12
+ /**
13
+ * Per-connection state. `kind` discriminates between the legacy JSON
14
+ * inspector channel (`/_ws`) and the Phase 8 binary collab channel
15
+ * (`/_ws/collab/:slug`). server.ts sets this at upgrade time.
16
+ */
17
+ export type WsData =
18
+ | {
19
+ id: string;
20
+ remote: string;
21
+ kind: 'inspector';
22
+ }
23
+ | {
24
+ id: string;
25
+ remote: string;
26
+ kind: 'collab';
27
+ slug: string;
28
+ };
29
+
30
+ /**
31
+ * Match the collab URL pattern and return the slug, or `null` if the path
32
+ * isn't a collab endpoint. Exported so server.ts owns the routing decision.
33
+ *
34
+ * Slug grammar matches `api.fileSlug` output: `[a-z0-9_-]+`. URL-encoded chars
35
+ * are rejected here — the legacy inspector path stays the catch-all.
36
+ */
37
+ export function parseCollabSlug(pathname: string): string | null {
38
+ const m = pathname.match(/^\/_ws\/collab\/([a-z0-9_-]+)$/i);
39
+ return m ? (m[1] ?? null) : null;
40
+ }
41
+
42
+ /**
43
+ * DDR-047 — collab WS upgrades MUST come from a loopback host. The header
44
+ * carries `<host>:<port>` (or `<host>` if default-port); strip the port and
45
+ * compare against the loopback aliases. Anything else returns false → server.ts
46
+ * answers 403.
47
+ */
48
+ export function isLoopbackHost(host: string | null): boolean {
49
+ if (!host) return false;
50
+ // Strip port. `[::1]:4399` keeps the `::1` bracketed; handle both shapes.
51
+ let h = host.trim().toLowerCase();
52
+ if (h.startsWith('[')) {
53
+ const close = h.indexOf(']');
54
+ if (close === -1) return false;
55
+ h = h.slice(1, close);
56
+ } else {
57
+ const colon = h.lastIndexOf(':');
58
+ if (colon !== -1) h = h.slice(0, colon);
59
+ }
60
+ return h === '127.0.0.1' || h === '::1' || h === 'localhost';
14
61
  }
15
62
 
16
63
  export interface Ws {
@@ -19,7 +66,7 @@ export interface Ws {
19
66
  clientCount(): number;
20
67
  }
21
68
 
22
- export function createWs(ctx: Context, api: Api, inspect: Inspect): Ws {
69
+ export function createWs(ctx: Context, api: Api, inspect: Inspect, collab: Collab): Ws {
23
70
  const clients = new Set<ServerWebSocket<WsData>>();
24
71
 
25
72
  function send(ws: ServerWebSocket<WsData>, payload: unknown) {
@@ -32,7 +79,10 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect): Ws {
32
79
 
33
80
  function broadcast(payload: unknown) {
34
81
  const msg = typeof payload === 'string' ? payload : JSON.stringify(payload);
35
- for (const ws of clients) send(ws, msg);
82
+ for (const ws of clients) {
83
+ if (ws.data.kind !== 'inspector') continue;
84
+ send(ws, msg);
85
+ }
36
86
  }
37
87
 
38
88
  // Wire bus -> WS broadcasts. inspect.ts emits 'selected' / 'active' after every
@@ -45,20 +95,86 @@ export function createWs(ctx: Context, api: Api, inspect: Inspect): Ws {
45
95
  ctx.bus.on('comments', ({ file, comments }: { file: string; comments: unknown[] }) =>
46
96
  broadcast({ type: 'comments', file, comments })
47
97
  );
98
+ // Phase 8 Task 4 — AI activity banner. `entry` is null on clear / explicit
99
+ // end / heartbeat-grace expiry; non-null carries { file, author, …timestamps }.
100
+ ctx.bus.on('ai-activity', ({ file, entry }: { file: string; entry: unknown }) =>
101
+ broadcast({ type: 'ai-activity', file, entry })
102
+ );
103
+ // Phase 8 Task 7 — git lifecycle. `.git/HEAD` watcher emits this AFTER
104
+ // registry.flushAll() so any in-flight Y.Doc state is already on disk by
105
+ // the time the client renders the reload prompt. Inspector clients +
106
+ // canvas iframes both subscribe.
107
+ ctx.bus.on('git-lifecycle', (payload: unknown) => broadcast({ type: 'git-lifecycle', payload }));
48
108
 
49
109
  // HMR broadcaster — turns fs:any change events into `canvas-hmr` messages.
50
110
  // The iframe-side client (in _shell.html) decides reload strategy from `mode`.
51
111
  createHmrBroadcaster(ctx, (msg) => broadcast(msg));
52
112
 
113
+ // Bind a connection to its room. Stored per-socket so close() can find the
114
+ // right room to disconnect from. Multiplexed via ws.data.id.
115
+ const collabConns = new Map<string, { roomSlug: string; conn: RoomConn }>();
116
+
117
+ function bindCollab(ws: ServerWebSocket<WsData>, slug: string): RoomConn {
118
+ const conn: RoomConn = {
119
+ id: ws.data.id,
120
+ send(payload: Uint8Array) {
121
+ try {
122
+ // Bun's ws.send accepts Uint8Array directly as binary.
123
+ ws.send(payload);
124
+ } catch {
125
+ /* close handler will clean up */
126
+ }
127
+ },
128
+ };
129
+ collabConns.set(ws.data.id, { roomSlug: slug, conn });
130
+ return conn;
131
+ }
132
+
53
133
  const handler: WebSocketHandler<WsData> = {
54
- open(ws) {
134
+ async open(ws) {
135
+ if (ws.data.kind === 'collab') {
136
+ const room = collab.registry.get(ws.data.slug);
137
+ const conn = bindCollab(ws, ws.data.slug);
138
+ await room.connect(conn);
139
+ return;
140
+ }
55
141
  clients.add(ws);
56
142
  send(ws, { type: 'snapshot', state: inspect.state });
57
143
  },
58
- close(ws) {
144
+ async close(ws) {
145
+ if (ws.data.kind === 'collab') {
146
+ const binding = collabConns.get(ws.data.id);
147
+ if (binding) {
148
+ collabConns.delete(ws.data.id);
149
+ const room = collab.registry.get(binding.roomSlug);
150
+ room.disconnect(binding.conn);
151
+ if (room.size() === 0) {
152
+ // Drop the room when the last peer leaves so memory doesn't grow
153
+ // unbounded across canvases over a long session.
154
+ await collab.registry.drop(binding.roomSlug);
155
+ }
156
+ }
157
+ return;
158
+ }
59
159
  clients.delete(ws);
60
160
  },
61
161
  async message(ws, raw) {
162
+ if (ws.data.kind === 'collab') {
163
+ const binding = collabConns.get(ws.data.id);
164
+ if (!binding) return;
165
+ // y-websocket frames are always binary. Coerce whatever Bun handed us.
166
+ const bytes =
167
+ typeof raw === 'string'
168
+ ? new TextEncoder().encode(raw)
169
+ : raw instanceof Uint8Array
170
+ ? raw
171
+ : new Uint8Array(raw as ArrayBufferLike);
172
+ const room = collab.registry.get(binding.roomSlug);
173
+ room.receive(binding.conn, bytes);
174
+ return;
175
+ }
176
+
177
+ // Inspector channel — legacy JSON message protocol.
62
178
  // biome-ignore lint/suspicious/noExplicitAny: JSON.parse result; narrowed by runtime discriminator checks below.
63
179
  let msg: any;
64
180
  try {