@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.
- package/README.md +5 -5
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/annotations-layer.tsx +6 -1
- package/apps/studio/api.ts +225 -66
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +22 -2
- package/apps/studio/client/app.jsx +781 -26
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +613 -3
- package/apps/studio/client/styles/5-maude-overrides.css +25 -0
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +11 -1
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/cursors-overlay.tsx +17 -1
- package/apps/studio/dist/client.bundle.js +28780 -1534
- package/apps/studio/dist/comment-mount.js +4 -2
- package/apps/studio/dist/styles.css +5633 -1617
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +384 -1
- package/apps/studio/participants-chrome.tsx +69 -9
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +65 -2
- package/apps/studio/sync/agent.ts +81 -1
- package/apps/studio/sync/codec.ts +24 -0
- package/apps/studio/sync/cold-start.ts +40 -0
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +35 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csrf-write-guard.test.ts +26 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +61 -1
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/tool-palette.tsx +18 -9
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +414 -187
- package/apps/studio/whats-new.json +73 -0
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design.mjs +1 -0
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- 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
|
-
//
|
|
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
|
-
|
|
368
|
-
|
|
456
|
+
const AWARENESS_THROTTLE_MS = 33; // ~30 Hz
|
|
457
|
+
|
|
458
|
+
interface CollabSession {
|
|
369
459
|
slug: string;
|
|
370
|
-
|
|
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
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
|
417
|
-
color
|
|
418
|
-
cursor: null,
|
|
419
|
-
selection: null,
|
|
420
|
-
annotationSelection: [],
|
|
421
|
-
viewport: { x: 0, y: 0, zoom: 1 },
|
|
422
|
-
|
|
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
|
-
}
|
|
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
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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
|
-
|
|
631
|
+
writeSyncStep1(encoder, doc);
|
|
453
632
|
sendFrame(ws, encoding.toUint8Array(encoder));
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
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
|
-
|
|
554
|
-
|
|
555
|
-
};
|
|
556
|
-
}, [slug, doc, awareness]);
|
|
675
|
+
});
|
|
676
|
+
}
|
|
557
677
|
|
|
558
|
-
//
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
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
|
-
|
|
710
|
+
ws.close();
|
|
568
711
|
} catch {
|
|
569
712
|
/* ignore */
|
|
570
713
|
}
|
|
571
|
-
}
|
|
572
|
-
|
|
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 ??
|
|
591
|
-
color: current.color ??
|
|
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,
|
|
828
|
+
[awareness, session, myConnId]
|
|
602
829
|
);
|
|
603
830
|
|
|
604
831
|
// ── Cleanup throttle timer on unmount ───────────────────────────────────
|