@1agh/maude 0.30.0 → 0.31.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 (90) hide show
  1. package/README.md +5 -5
  2. package/apps/studio/acp/bridge.ts +285 -0
  3. package/apps/studio/acp/env.ts +48 -0
  4. package/apps/studio/acp/index.ts +132 -0
  5. package/apps/studio/acp/probe.ts +112 -0
  6. package/apps/studio/acp/transcript.ts +149 -0
  7. package/apps/studio/annotations-layer.tsx +6 -1
  8. package/apps/studio/api.ts +225 -66
  9. package/apps/studio/bin/chat-open.sh +44 -0
  10. package/apps/studio/canvas-lib.tsx +112 -19
  11. package/apps/studio/canvas-list-watch.ts +177 -0
  12. package/apps/studio/canvas-shell.tsx +22 -2
  13. package/apps/studio/client/app.jsx +781 -26
  14. package/apps/studio/client/canvas-url.js +5 -0
  15. package/apps/studio/client/github.js +99 -0
  16. package/apps/studio/client/panels/ChatPanel.jsx +770 -0
  17. package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
  18. package/apps/studio/client/panels/CreateProject.jsx +334 -0
  19. package/apps/studio/client/panels/DiffView.jsx +590 -0
  20. package/apps/studio/client/panels/GitPanel.jsx +767 -0
  21. package/apps/studio/client/panels/IdentityBar.jsx +294 -0
  22. package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
  23. package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
  24. package/apps/studio/client/panels/acp-runtime.js +286 -0
  25. package/apps/studio/client/panels/chat-markdown.jsx +138 -0
  26. package/apps/studio/client/panels/git-grouping.js +86 -0
  27. package/apps/studio/client/styles/0-reset.css +4 -0
  28. package/apps/studio/client/styles/3-shell-maude.css +613 -3
  29. package/apps/studio/client/styles/5-maude-overrides.css +25 -0
  30. package/apps/studio/client/styles/6-acp-chat.css +771 -0
  31. package/apps/studio/client/styles/_index.css +2 -0
  32. package/apps/studio/client/tour/collab-tour.js +61 -0
  33. package/apps/studio/client/tour/overlay.jsx +11 -1
  34. package/apps/studio/client/whats-new.jsx +25 -10
  35. package/apps/studio/collab/registry.ts +13 -0
  36. package/apps/studio/collab/room.ts +36 -0
  37. package/apps/studio/cursors-overlay.tsx +17 -1
  38. package/apps/studio/dist/client.bundle.js +28780 -1534
  39. package/apps/studio/dist/comment-mount.js +4 -2
  40. package/apps/studio/dist/styles.css +5633 -1617
  41. package/apps/studio/git/endpoints.ts +338 -0
  42. package/apps/studio/git/service.ts +1334 -0
  43. package/apps/studio/git/watch.ts +97 -0
  44. package/apps/studio/github/endpoints.ts +358 -0
  45. package/apps/studio/github/service.ts +231 -0
  46. package/apps/studio/github/token.ts +53 -0
  47. package/apps/studio/hmr-broadcast.ts +9 -2
  48. package/apps/studio/http.ts +384 -1
  49. package/apps/studio/participants-chrome.tsx +69 -9
  50. package/apps/studio/paths.ts +12 -0
  51. package/apps/studio/scaffold-design.ts +57 -0
  52. package/apps/studio/server.ts +65 -2
  53. package/apps/studio/sync/agent.ts +81 -1
  54. package/apps/studio/sync/codec.ts +24 -0
  55. package/apps/studio/sync/cold-start.ts +40 -0
  56. package/apps/studio/sync/hub-link.ts +137 -0
  57. package/apps/studio/test/acp-bridge.test.ts +127 -0
  58. package/apps/studio/test/acp-env.test.ts +65 -0
  59. package/apps/studio/test/acp-origin-gate.test.ts +78 -0
  60. package/apps/studio/test/acp-transcript.test.ts +112 -0
  61. package/apps/studio/test/canvas-create-api.test.ts +72 -0
  62. package/apps/studio/test/canvas-list-watch.test.ts +322 -0
  63. package/apps/studio/test/canvas-meta-api.test.ts +161 -27
  64. package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
  65. package/apps/studio/test/chat-markdown.test.tsx +58 -0
  66. package/apps/studio/test/collab-session-survival.test.tsx +176 -0
  67. package/apps/studio/test/csrf-write-guard.test.ts +26 -0
  68. package/apps/studio/test/editing-presence.test.ts +103 -0
  69. package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
  70. package/apps/studio/test/git-api.test.ts +0 -0
  71. package/apps/studio/test/git-branches.test.ts +106 -0
  72. package/apps/studio/test/git-grouping.test.ts +106 -0
  73. package/apps/studio/test/git-watch.test.ts +97 -0
  74. package/apps/studio/test/github-api.test.ts +465 -0
  75. package/apps/studio/test/hub-link.test.ts +69 -0
  76. package/apps/studio/test/participants-chrome.test.ts +36 -1
  77. package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
  78. package/apps/studio/test/sync-cold-start.test.ts +61 -1
  79. package/apps/studio/test/tour-overlay.test.tsx +18 -0
  80. package/apps/studio/tool-palette.tsx +18 -9
  81. package/apps/studio/use-chrome-visibility.tsx +66 -0
  82. package/apps/studio/use-collab.tsx +414 -187
  83. package/apps/studio/whats-new.json +73 -0
  84. package/apps/studio/ws.ts +44 -1
  85. package/cli/commands/design.mjs +1 -0
  86. package/cli/lib/gitignore-block.mjs +16 -3
  87. package/cli/lib/gitignore-block.test.mjs +13 -1
  88. package/package.json +11 -9
  89. package/plugins/design/dependencies.json +17 -0
  90. package/plugins/design/templates/_shell.html +30 -8
@@ -25,6 +25,7 @@ import {
25
25
  useContext,
26
26
  useEffect,
27
27
  useMemo,
28
+ useReducer,
28
29
  useRef,
29
30
  useState,
30
31
  } from 'react';
@@ -103,6 +104,17 @@ export interface CollabAwarenessState {
103
104
  */
104
105
  annotationSelection: string[];
105
106
  viewport: { x: number; y: number; zoom: number };
107
+ /**
108
+ * Soft editing-presence (Phase 30). Set while THIS peer (a human editing via
109
+ * the CSS-inspector / a `/design:edit`-driven write, or a bridged agent) is
110
+ * actively editing the canvas body; `since` is the epoch-ms the edit session
111
+ * began. Null/absent = not editing. This is a SOFT, attributed heads-up that
112
+ * rides the same hub-bridged awareness channel as cursors — it is NOT a lock
113
+ * (no lease, no takeover, never blocks another peer). Cleared on idle + on
114
+ * disconnect (awareness GC). The visual conflict picker (DDR-116) remains the
115
+ * safety net for divergent saves.
116
+ */
117
+ editing?: { since: number } | null;
106
118
  /**
107
119
  * Server-side `disconnect` matches awareness states to outgoing peers by
108
120
  * this token (must equal the ws.data.id the server assigns at upgrade).
@@ -238,6 +250,18 @@ function sanitizeAnnotationSelection(raw: unknown): string[] {
238
250
  return out;
239
251
  }
240
252
 
253
+ // Soft editing-presence (Phase 30). `since` must be a finite POSITIVE epoch-ms
254
+ // that is not in the future (allow ±5 s clock skew). A future / NaN / Infinity /
255
+ // non-positive value is rejected → `null` (treated as not-editing), so a hostile
256
+ // hub peer can't pin a permanent "editing" badge with a far-future timestamp or
257
+ // poison a `Date.now() - since` age computation with a NaN.
258
+ function sanitizeEditingState(raw: unknown): { since: number } | null {
259
+ if (!raw || typeof raw !== 'object') return null;
260
+ const e = raw as { since?: unknown };
261
+ if (!isFiniteNum(e.since) || e.since <= 0 || e.since > Date.now() + 5000) return null;
262
+ return { since: e.since };
263
+ }
264
+
241
265
  /**
242
266
  * Validate + normalize one foreign awareness state at the trust boundary.
243
267
  * Returns null for states that can't be a peer (no usable name). `color` is
@@ -258,6 +282,7 @@ export function sanitizeForeignState(clientID: number, state: unknown): ForeignA
258
282
  selection: sanitizeSelection(s.selection),
259
283
  annotationSelection: sanitizeAnnotationSelection(s.annotationSelection),
260
284
  viewport: sanitizeViewport(s.viewport),
285
+ editing: sanitizeEditingState(s.editing),
261
286
  };
262
287
  }
263
288
 
@@ -333,6 +358,57 @@ export function useForeignAwareness(): ForeignAwareness[] {
333
358
  return peers;
334
359
  }
335
360
 
361
+ // ─────────────────────────────────────────────────────────────────────────────
362
+ // Hook: soft editing-presence (Phase 30).
363
+ //
364
+ // A SOFT, attributed "I'm editing this canvas" heads-up — NOT a lock. It rides
365
+ // the same per-canvas awareness channel as cursors (so it crosses the hub for
366
+ // free via the awareness bridge) and is surfaced by the peer overlay so two
367
+ // people (or a person + an agent) don't unknowingly edit the same canvas at the
368
+ // same moment. It never blocks anyone; the visual conflict picker (DDR-116)
369
+ // remains the safety net for divergent saves.
370
+
371
+ const EDITING_IDLE_MS = 5000;
372
+
373
+ /**
374
+ * Returns `setEditing()` / `clearEditing()`. Call `setEditing()` on each edit
375
+ * the local user makes (CSS-inspector tweak, `/design:edit`-driven write); it
376
+ * publishes `editing: { since }` once and auto-extends, then auto-clears after
377
+ * `EDITING_IDLE_MS` of no calls (and on unmount). A no-op outside a
378
+ * `CollabProvider` (returns callbacks that do nothing).
379
+ */
380
+ export function useEditingPresence(): { setEditing: () => void; clearEditing: () => void } {
381
+ const collab = useCollab();
382
+ const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
383
+ const sinceRef = useRef<number | null>(null);
384
+
385
+ const clearEditing = useCallback(() => {
386
+ if (idleTimerRef.current) {
387
+ clearTimeout(idleTimerRef.current);
388
+ idleTimerRef.current = null;
389
+ }
390
+ if (sinceRef.current !== null) {
391
+ sinceRef.current = null;
392
+ collab?.publishAwareness({ editing: null });
393
+ }
394
+ }, [collab]);
395
+
396
+ const setEditing = useCallback(() => {
397
+ if (!collab) return;
398
+ if (sinceRef.current === null) {
399
+ sinceRef.current = Date.now();
400
+ collab.publishAwareness({ editing: { since: sinceRef.current } });
401
+ }
402
+ if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
403
+ idleTimerRef.current = setTimeout(clearEditing, EDITING_IDLE_MS);
404
+ }, [collab, clearEditing]);
405
+
406
+ // Clear local editing-presence on unmount.
407
+ useEffect(() => () => clearEditing(), [clearEditing]);
408
+
409
+ return { setEditing, clearEditing };
410
+ }
411
+
336
412
  // ─────────────────────────────────────────────────────────────────────────────
337
413
  // Slug derivation — must match `api.fileSlug` server-side.
338
414
 
@@ -362,215 +438,365 @@ export function canvasSlugFromPath(canvasRel: string | null | undefined): string
362
438
  }
363
439
 
364
440
  // ─────────────────────────────────────────────────────────────────────────────
365
- // Provider opens WS, owns Y.Doc + Awareness lifecycle.
441
+ // Module-level, refcounted collab session (F4 presence survives a hot-swap).
442
+ //
443
+ // A cross-peer synced edit (or an agent edit) hot-swaps the canvas module in
444
+ // place, which REMOUNTS the whole canvas subtree — including <CollabProvider>.
445
+ // If the Y.Doc + Awareness + WebSocket were owned by the component (useMemo /
446
+ // useEffect), that remount would CLOSE the awareness socket and re-handshake, so
447
+ // every peer's cursor + avatar blinks out and back on every synced change (the
448
+ // F4 bug). Instead the live session lives HERE, keyed by slug + refcounted: a
449
+ // same-slug remount re-acquires the SAME doc/awareness/socket within a short
450
+ // grace window, so the awareness connection never drops and presence is stable.
451
+ //
452
+ // One canvas iframe is one realm and only ever holds one slug (switching
453
+ // canvases navigates the iframe → fresh realm → fresh module state), so the map
454
+ // holds at most one live entry plus, briefly, one draining one.
366
455
 
367
- interface CollabProviderProps {
368
- /** Canvas slug — must match server-side `parseCollabSlug`. */
456
+ const AWARENESS_THROTTLE_MS = 33; // ~30 Hz
457
+
458
+ interface CollabSession {
369
459
  slug: string;
370
- children: ReactNode;
460
+ doc: Y.Doc;
461
+ awareness: Awareness;
462
+ connId: string;
463
+ name: string;
464
+ color: string;
465
+ connected: boolean;
466
+ refCount: number;
467
+ destroyTimer: ReturnType<typeof setTimeout> | null;
468
+ /** React consumers subscribe so name/color/connected changes re-render. */
469
+ listeners: Set<() => void>;
470
+ /** Tear down the socket + listeners + destroy doc/awareness. */
471
+ stop: () => void;
371
472
  }
372
473
 
373
- const AWARENESS_THROTTLE_MS = 33; // ~30 Hz
474
+ // The session registry MUST live on `window`, not in module scope, and be
475
+ // resolved LAZILY (per access, not once at module load). A hot-swap (F4)
476
+ // re-imports the canvas bundle with a cache-busting `?v=` query, and use-collab
477
+ // is INLINED into that per-canvas bundle — so each hot-swap re-evaluates a FRESH
478
+ // module with a fresh module-level binding. A plain `const SESSIONS = new Map()`
479
+ // would therefore be empty on every hot-swap and we'd spin a new Y.Doc +
480
+ // Awareness + socket (new clientID) each time, leaving the prior clientID's
481
+ // awareness to linger on the hub → phantom "self" avatars pile up until the
482
+ // awareness timeout. Anchoring the map on the iframe's `window` (which survives
483
+ // module re-evaluation) is what makes the session — and thus presence — survive
484
+ // the swap. Lazy resolution also tolerates a `window` that becomes available
485
+ // after this module first evaluates (test harness: imports are hoisted above
486
+ // happy-dom registration).
487
+ //
488
+ // SECURITY (DDR-054): the canvas iframe is untrusted and shares this realm, so
489
+ // the registry holds live network handles in reach of canvas script. We key it
490
+ // by a NON-ENUMERABLE global Symbol (not an enumerable string property) so it
491
+ // can't be harvested by an opportunistic `for…in` / `Object.keys(window)` sweep
492
+ // — defense in depth, NOT a trust boundary: same-realm canvas code can already
493
+ // reach collab state through `useCollab()`, and `Symbol.for` is recoverable by a
494
+ // determined attacker. Closing the underlying "untrusted canvas can mutate the
495
+ // shared doc" surface is a separate, pre-existing concern (tracked as a
496
+ // follow-up); this keeps the hot-swap fix from WIDENING discovery. A global
497
+ // Symbol (shared registry) is required so the re-imported module resolves the
498
+ // SAME key — a per-module `Symbol()` would defeat the cross-re-import survival.
499
+ const SESSIONS_KEY = Symbol.for('maude.collab.sessions.v1');
500
+ let moduleFallbackSessions: Map<string, CollabSession> | null = null;
501
+ function getSessions(): Map<string, CollabSession> {
502
+ if (typeof window === 'undefined') {
503
+ if (!moduleFallbackSessions) moduleFallbackSessions = new Map<string, CollabSession>();
504
+ return moduleFallbackSessions;
505
+ }
506
+ const w = window as unknown as Record<symbol, Map<string, CollabSession> | undefined>;
507
+ let map = w[SESSIONS_KEY];
508
+ if (!map) {
509
+ map = new Map<string, CollabSession>();
510
+ Object.defineProperty(w, SESSIONS_KEY, {
511
+ value: map,
512
+ enumerable: false,
513
+ writable: false,
514
+ configurable: false,
515
+ });
516
+ }
517
+ return map;
518
+ }
374
519
 
375
- export function CollabProvider({ slug, children }: CollabProviderProps): JSX.Element {
376
- // Y.Doc + Awareness are recreated whenever the slug changes (switching
377
- // canvases tears down the prior session cleanly). The useMemo factory
378
- // bodies don't read `slug` slug IS the cache key, intentionally.
379
- // biome-ignore lint/correctness/useExhaustiveDependencies: slug is the cache key
380
- const doc = useMemo(() => new Y.Doc(), [slug]);
381
- const awareness = useMemo(() => new Awareness(doc), [doc]);
382
- // biome-ignore lint/correctness/useExhaustiveDependencies: slug is the cache key
383
- const myConnId = useMemo(() => crypto.randomUUID(), [slug]);
384
-
385
- const [myName, setMyName] = useState('anonymous');
386
- const [myColor, setMyColor] = useState(colorForName('anonymous'));
387
- const [connected, setConnected] = useState(false);
388
-
389
- // Fetch git user.name once per slug; falls back to anonymous-<short id>.
390
- useEffect(() => {
391
- let cancelled = false;
392
- fetch('/_api/git-user')
393
- .then((r) => r.json())
394
- .then((j) => {
395
- if (cancelled) return;
396
- const n = typeof j?.name === 'string' && j.name.trim() ? j.name.trim() : null;
397
- const finalName = n ?? `anonymous-${myConnId.slice(0, 6)}`;
398
- setMyName(finalName);
399
- setMyColor(colorForName(finalName));
400
- })
401
- .catch(() => {
402
- if (cancelled) return;
403
- const fallback = `anonymous-${myConnId.slice(0, 6)}`;
404
- setMyName(fallback);
405
- setMyColor(colorForName(fallback));
406
- });
407
- return () => {
408
- cancelled = true;
409
- };
410
- }, [myConnId]);
520
+ // Keep a refcount-0 session alive briefly so a hot-swap remount (which unmounts
521
+ // then immediately remounts the provider in the same commit) reuses the live
522
+ // socket instead of reconnecting. A genuine close (no re-acquire within the
523
+ // window) tears down so the peer leaves the room cleanly.
524
+ const SESSION_GRACE_MS = 4000;
411
525
 
412
- // Seed local awareness state immediately so foreign peers see our name even
413
- // before the cursor moves. Update when myName/myColor settles from the fetch.
414
- useEffect(() => {
526
+ function notifySession(s: CollabSession): void {
527
+ for (const l of s.listeners) l();
528
+ }
529
+
530
+ function createSession(slug: string): CollabSession {
531
+ const doc = new Y.Doc();
532
+ const awareness = new Awareness(doc);
533
+ const connId = crypto.randomUUID();
534
+
535
+ const session: CollabSession = {
536
+ slug,
537
+ doc,
538
+ awareness,
539
+ connId,
540
+ name: 'anonymous',
541
+ color: colorForName('anonymous'),
542
+ connected: false,
543
+ refCount: 0,
544
+ destroyTimer: null,
545
+ listeners: new Set(),
546
+ stop: () => {},
547
+ };
548
+
549
+ // Seed local awareness immediately so foreign peers see our name even before
550
+ // the first cursor move; preserves any cursor/selection already published.
551
+ const seedLocalAwareness = (name: string, color: string) => {
552
+ const cur = (awareness.getLocalState() ?? {}) as Partial<CollabAwarenessState>;
415
553
  awareness.setLocalState({
416
- name: myName,
417
- color: myColor,
418
- cursor: null,
419
- selection: null,
420
- annotationSelection: [],
421
- viewport: { x: 0, y: 0, zoom: 1 },
422
- __connId: myConnId,
554
+ name,
555
+ color,
556
+ cursor: cur.cursor ?? null,
557
+ selection: cur.selection ?? null,
558
+ annotationSelection: cur.annotationSelection ?? [],
559
+ viewport: cur.viewport ?? { x: 0, y: 0, zoom: 1 },
560
+ editing: cur.editing ?? null,
561
+ __connId: connId,
423
562
  } satisfies CollabAwarenessState);
424
- }, [awareness, myName, myColor, myConnId]);
563
+ };
564
+ seedLocalAwareness(session.name, session.color);
565
+
566
+ // Resolve identity from git user.name once per SESSION (not per mount) so a
567
+ // hot-swap remount doesn't re-fetch + re-publish (which would churn awareness).
568
+ let identityCancelled = false;
569
+ fetch('/_api/git-user')
570
+ .then((r) => r.json())
571
+ .then((j) => {
572
+ if (identityCancelled) return;
573
+ const n = typeof j?.name === 'string' && j.name.trim() ? j.name.trim() : null;
574
+ const finalName = n ?? `anonymous-${connId.slice(0, 6)}`;
575
+ session.name = finalName;
576
+ session.color = colorForName(finalName);
577
+ seedLocalAwareness(finalName, session.color);
578
+ notifySession(session);
579
+ })
580
+ .catch(() => {
581
+ if (identityCancelled) return;
582
+ const fallback = `anonymous-${connId.slice(0, 6)}`;
583
+ session.name = fallback;
584
+ session.color = colorForName(fallback);
585
+ seedLocalAwareness(fallback, session.color);
586
+ notifySession(session);
587
+ });
425
588
 
426
589
  // ── WebSocket lifecycle ──────────────────────────────────────────────────
427
- const wsRef = useRef<WebSocket | null>(null);
428
-
429
- useEffect(() => {
430
- let cancelled = false;
431
- let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
432
-
433
- function sendFrame(ws: WebSocket, payload: Uint8Array) {
434
- try {
435
- ws.send(payload);
436
- } catch {
437
- /* dead socket — close handler will reconnect */
438
- }
590
+ let cancelled = false;
591
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
592
+ let wsRef: WebSocket | null = null;
593
+
594
+ function sendFrame(ws: WebSocket, payload: Uint8Array) {
595
+ try {
596
+ ws.send(payload);
597
+ } catch {
598
+ /* dead socket — close handler will reconnect */
439
599
  }
600
+ }
440
601
 
441
- function broadcastAwareness(ws: WebSocket, changed: number[]) {
442
- if (changed.length === 0) return;
443
- const encoder = encoding.createEncoder();
444
- encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
445
- encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(awareness, changed));
446
- sendFrame(ws, encoding.toUint8Array(encoder));
447
- }
602
+ function broadcastAwareness(ws: WebSocket, changed: number[]) {
603
+ if (changed.length === 0) return;
604
+ const encoder = encoding.createEncoder();
605
+ encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
606
+ encoding.writeVarUint8Array(encoder, encodeAwarenessUpdate(awareness, changed));
607
+ sendFrame(ws, encoding.toUint8Array(encoder));
608
+ }
609
+
610
+ function broadcastSyncUpdate(ws: WebSocket, update: Uint8Array) {
611
+ const encoder = encoding.createEncoder();
612
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
613
+ writeUpdate(encoder, update);
614
+ sendFrame(ws, encoding.toUint8Array(encoder));
615
+ }
448
616
 
449
- function broadcastSyncUpdate(ws: WebSocket, update: Uint8Array) {
617
+ function connect() {
618
+ if (cancelled) return;
619
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
620
+ const ws = new WebSocket(`${proto}//${location.host}/_ws/collab/${slug}`);
621
+ ws.binaryType = 'arraybuffer';
622
+ wsRef = ws;
623
+
624
+ ws.addEventListener('open', () => {
625
+ session.connected = true;
626
+ notifySession(session);
627
+ // Sync step 1 — announce our state vector so the server can send the
628
+ // missing pieces (matches the encodeHandshake server path).
450
629
  const encoder = encoding.createEncoder();
451
630
  encoding.writeVarUint(encoder, MESSAGE_SYNC);
452
- writeUpdate(encoder, update);
631
+ writeSyncStep1(encoder, doc);
453
632
  sendFrame(ws, encoding.toUint8Array(encoder));
454
- }
455
-
456
- function connect() {
633
+ // Awareness initial state — fire our local state to the room.
634
+ broadcastAwareness(ws, [awareness.clientID]);
635
+ });
636
+
637
+ ws.addEventListener('close', () => {
638
+ session.connected = false;
639
+ notifySession(session);
640
+ wsRef = null;
457
641
  if (cancelled) return;
458
- const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
459
- const ws = new WebSocket(`${proto}//${location.host}/_ws/collab/${slug}`);
460
- ws.binaryType = 'arraybuffer';
461
- wsRef.current = ws;
462
-
463
- ws.addEventListener('open', () => {
464
- setConnected(true);
465
- // Sync step 1 — announce our state vector so the server can send the
466
- // missing pieces (matches the encodeHandshake server path).
467
- const encoder = encoding.createEncoder();
468
- encoding.writeVarUint(encoder, MESSAGE_SYNC);
469
- writeSyncStep1(encoder, doc);
470
- sendFrame(ws, encoding.toUint8Array(encoder));
471
- // Awareness initial state — fire our local state to the room.
472
- broadcastAwareness(ws, [awareness.clientID]);
473
- });
474
-
475
- ws.addEventListener('close', () => {
476
- setConnected(false);
477
- wsRef.current = null;
478
- if (cancelled) return;
479
- reconnectTimer = setTimeout(connect, 1000);
480
- });
481
-
482
- ws.addEventListener('error', () => {
483
- // Let close handler do the reconnect; error events without a close
484
- // would just retry-spam.
485
- });
486
-
487
- ws.addEventListener('message', (evt) => {
488
- const payload =
489
- evt.data instanceof ArrayBuffer
490
- ? new Uint8Array(evt.data)
491
- : evt.data instanceof Uint8Array
492
- ? evt.data
493
- : null;
494
- if (!payload) return;
495
- const decoder = decoding.createDecoder(payload);
496
- const messageType = decoding.readVarUint(decoder);
497
- switch (messageType) {
498
- case MESSAGE_SYNC: {
499
- const encoder = encoding.createEncoder();
500
- encoding.writeVarUint(encoder, MESSAGE_SYNC);
501
- readSyncMessage(decoder, encoder, doc, ws);
502
- if (encoding.length(encoder) > 1) sendFrame(ws, encoding.toUint8Array(encoder));
503
- break;
504
- }
505
- case MESSAGE_AWARENESS: {
506
- applyAwarenessUpdate(awareness, decoding.readVarUint8Array(decoder), ws);
507
- break;
508
- }
509
- default:
510
- break;
642
+ reconnectTimer = setTimeout(connect, 1000);
643
+ });
644
+
645
+ ws.addEventListener('error', () => {
646
+ // Let close handler do the reconnect; error events without a close
647
+ // would just retry-spam.
648
+ });
649
+
650
+ ws.addEventListener('message', (evt) => {
651
+ const payload =
652
+ evt.data instanceof ArrayBuffer
653
+ ? new Uint8Array(evt.data)
654
+ : evt.data instanceof Uint8Array
655
+ ? evt.data
656
+ : null;
657
+ if (!payload) return;
658
+ const decoder = decoding.createDecoder(payload);
659
+ const messageType = decoding.readVarUint(decoder);
660
+ switch (messageType) {
661
+ case MESSAGE_SYNC: {
662
+ const encoder = encoding.createEncoder();
663
+ encoding.writeVarUint(encoder, MESSAGE_SYNC);
664
+ readSyncMessage(decoder, encoder, doc, ws);
665
+ if (encoding.length(encoder) > 1) sendFrame(ws, encoding.toUint8Array(encoder));
666
+ break;
511
667
  }
512
- });
513
- }
514
-
515
- // Wire doc updates → broadcast to server. Origin tagged with the ws ref so
516
- // server-side updates we receive don't echo back.
517
- const onDocUpdate = (update: Uint8Array, origin: unknown) => {
518
- const ws = wsRef.current;
519
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
520
- if (origin === ws) return; // came from server, don't echo
521
- broadcastSyncUpdate(ws, update);
522
- };
523
- doc.on('update', onDocUpdate);
524
-
525
- // Wire awareness changes → broadcast. Same origin guard.
526
- const onAwarenessUpdate = (
527
- { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
528
- origin: unknown
529
- ) => {
530
- const ws = wsRef.current;
531
- if (!ws || ws.readyState !== WebSocket.OPEN) return;
532
- if (origin === ws) return;
533
- const changed = added.concat(updated, removed);
534
- broadcastAwareness(ws, changed);
535
- };
536
- awareness.on('update', onAwarenessUpdate);
537
-
538
- connect();
539
-
540
- return () => {
541
- cancelled = true;
542
- if (reconnectTimer) clearTimeout(reconnectTimer);
543
- doc.off('update', onDocUpdate);
544
- awareness.off('update', onAwarenessUpdate);
545
- const ws = wsRef.current;
546
- if (ws && ws.readyState === WebSocket.OPEN) {
547
- try {
548
- ws.close();
549
- } catch {
550
- /* ignore */
668
+ case MESSAGE_AWARENESS: {
669
+ applyAwarenessUpdate(awareness, decoding.readVarUint8Array(decoder), ws);
670
+ break;
551
671
  }
672
+ default:
673
+ break;
552
674
  }
553
- // Don't destroy doc/awareness here — the useMemo-tied lifetime handles
554
- // that when the slug changes.
555
- };
556
- }, [slug, doc, awareness]);
675
+ });
676
+ }
557
677
 
558
- // Per-slug cleanup of doc/awareness when slug changes (or provider unmounts).
559
- useEffect(
560
- () => () => {
561
- try {
562
- awareness.destroy();
563
- } catch {
564
- /* ignore */
565
- }
678
+ // Wire doc updates broadcast to server. Origin tagged with the ws so
679
+ // server-side updates we receive don't echo back.
680
+ const onDocUpdate = (update: Uint8Array, origin: unknown) => {
681
+ const ws = wsRef;
682
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
683
+ if (origin === ws) return; // came from server, don't echo
684
+ broadcastSyncUpdate(ws, update);
685
+ };
686
+ doc.on('update', onDocUpdate);
687
+
688
+ // Wire awareness changes → broadcast. Same origin guard.
689
+ const onAwarenessUpdate = (
690
+ { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
691
+ origin: unknown
692
+ ) => {
693
+ const ws = wsRef;
694
+ if (!ws || ws.readyState !== WebSocket.OPEN) return;
695
+ if (origin === ws) return;
696
+ const changed = added.concat(updated, removed);
697
+ broadcastAwareness(ws, changed);
698
+ };
699
+ awareness.on('update', onAwarenessUpdate);
700
+
701
+ session.stop = () => {
702
+ identityCancelled = true;
703
+ cancelled = true;
704
+ if (reconnectTimer) clearTimeout(reconnectTimer);
705
+ doc.off('update', onDocUpdate);
706
+ awareness.off('update', onAwarenessUpdate);
707
+ const ws = wsRef;
708
+ if (ws && ws.readyState === WebSocket.OPEN) {
566
709
  try {
567
- doc.destroy();
710
+ ws.close();
568
711
  } catch {
569
712
  /* ignore */
570
713
  }
571
- },
572
- [doc, awareness]
573
- );
714
+ }
715
+ try {
716
+ awareness.destroy();
717
+ } catch {
718
+ /* ignore */
719
+ }
720
+ try {
721
+ doc.destroy();
722
+ } catch {
723
+ /* ignore */
724
+ }
725
+ };
726
+
727
+ connect();
728
+ return session;
729
+ }
730
+
731
+ function scheduleSessionDestroy(s: CollabSession): void {
732
+ if (s.destroyTimer) return;
733
+ s.destroyTimer = setTimeout(() => {
734
+ getSessions().delete(s.slug);
735
+ s.stop();
736
+ }, SESSION_GRACE_MS);
737
+ }
738
+
739
+ /** Idempotent get-or-create (called in render so children get a live doc on
740
+ * first render). A freshly created session self-destructs after the grace
741
+ * window unless a mount effect retains it — so a thrown-away render can't leak
742
+ * a socket. */
743
+ function peekOrCreateSession(slug: string): CollabSession {
744
+ const sessions = getSessions();
745
+ let s = sessions.get(slug);
746
+ if (!s) {
747
+ s = createSession(slug);
748
+ sessions.set(slug, s);
749
+ scheduleSessionDestroy(s);
750
+ }
751
+ return s;
752
+ }
753
+
754
+ function retainSession(slug: string): CollabSession {
755
+ const s = peekOrCreateSession(slug);
756
+ if (s.destroyTimer) {
757
+ clearTimeout(s.destroyTimer);
758
+ s.destroyTimer = null;
759
+ }
760
+ s.refCount++;
761
+ return s;
762
+ }
763
+
764
+ function releaseSession(slug: string): void {
765
+ const s = getSessions().get(slug);
766
+ if (!s) return;
767
+ s.refCount = Math.max(0, s.refCount - 1);
768
+ if (s.refCount === 0) scheduleSessionDestroy(s);
769
+ }
770
+
771
+ // ─────────────────────────────────────────────────────────────────────────────
772
+ // Provider — thin consumer of the shared session (lifetime owned above).
773
+
774
+ interface CollabProviderProps {
775
+ /** Canvas slug — must match server-side `parseCollabSlug`. */
776
+ slug: string;
777
+ children: ReactNode;
778
+ }
779
+
780
+ export function CollabProvider({ slug, children }: CollabProviderProps): JSX.Element {
781
+ // Acquire (get-or-create) for render so children see a live doc immediately;
782
+ // the refcount + teardown lifetime is managed in the effect below so a
783
+ // hot-swap remount reuses the SAME socket (no presence blink — F4).
784
+ const session = peekOrCreateSession(slug);
785
+ const [, forceRender] = useReducer((c: number) => c + 1, 0);
786
+
787
+ useEffect(() => {
788
+ const s = retainSession(slug);
789
+ const listener = () => forceRender();
790
+ s.listeners.add(listener);
791
+ // Identity / connection may have resolved between render and this effect.
792
+ forceRender();
793
+ return () => {
794
+ s.listeners.delete(listener);
795
+ releaseSession(slug);
796
+ };
797
+ }, [slug]);
798
+
799
+ const { doc, awareness, connId: myConnId, name: myName, color: myColor, connected } = session;
574
800
 
575
801
  // ── Throttled awareness publish ─────────────────────────────────────────
576
802
  const pendingRef = useRef<Partial<Omit<CollabAwarenessState, '__connId'>> | null>(null);
@@ -587,18 +813,19 @@ export function CollabProvider({ slug, children }: CollabProviderProps): JSX.Ele
587
813
  if (!next) return;
588
814
  const current = (awareness.getLocalState() ?? {}) as Partial<CollabAwarenessState>;
589
815
  awareness.setLocalState({
590
- name: current.name ?? myName,
591
- color: current.color ?? myColor,
816
+ name: current.name ?? session.name,
817
+ color: current.color ?? session.color,
592
818
  cursor: current.cursor ?? null,
593
819
  selection: current.selection ?? null,
594
820
  annotationSelection: current.annotationSelection ?? [],
595
821
  viewport: current.viewport ?? { x: 0, y: 0, zoom: 1 },
822
+ editing: current.editing ?? null,
596
823
  __connId: myConnId,
597
824
  ...next,
598
825
  } satisfies CollabAwarenessState);
599
826
  }, AWARENESS_THROTTLE_MS);
600
827
  },
601
- [awareness, myName, myColor, myConnId]
828
+ [awareness, session, myConnId]
602
829
  );
603
830
 
604
831
  // ── Cleanup throttle timer on unmount ───────────────────────────────────