@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.
- package/README.md +7 -0
- package/cli/bin/maude.mjs +5 -1
- package/cli/commands/design-link.test.mjs +207 -0
- package/cli/commands/design.mjs +42 -12
- package/cli/commands/doctor.mjs +361 -0
- package/cli/commands/doctor.test.mjs +185 -0
- package/cli/commands/help.mjs +24 -0
- package/cli/commands/hub.mjs +245 -0
- package/cli/commands/hub.test.mjs +87 -0
- package/cli/lib/config-lint.mjs +141 -0
- package/cli/lib/config-lint.test.mjs +117 -0
- package/cli/lib/design-link.mjs +216 -0
- package/cli/lib/hubs-config.mjs +123 -0
- package/cli/lib/hubs-config.test.mjs +100 -0
- package/cli/lib/preflight.mjs +232 -0
- package/cli/lib/stack-detect.mjs +344 -0
- package/cli/lib/stack-detect.test.mjs +121 -0
- package/package.json +16 -8
- package/plugins/design/dependencies.json +147 -0
- package/plugins/design/dependencies.schema.json +107 -0
- package/plugins/design/dev-server/ai-banner.tsx +188 -0
- package/plugins/design/dev-server/annotations-context-toolbar.tsx +5 -5
- package/plugins/design/dev-server/annotations-layer.tsx +52 -12
- package/plugins/design/dev-server/api.ts +17 -1
- package/plugins/design/dev-server/artboard-marquee.tsx +2 -2
- package/plugins/design/dev-server/bin/preflight.sh +32 -0
- package/plugins/design/dev-server/bin/runtime-health.sh +169 -0
- package/plugins/design/dev-server/canvas-lib.tsx +33 -7
- package/plugins/design/dev-server/canvas-shell.tsx +127 -9
- package/plugins/design/dev-server/client/app.jsx +72 -0
- package/plugins/design/dev-server/collab/ai-activity.ts +127 -0
- package/plugins/design/dev-server/collab/git-lifecycle.ts +124 -0
- package/plugins/design/dev-server/collab/index.ts +47 -0
- package/plugins/design/dev-server/collab/persistence.ts +123 -0
- package/plugins/design/dev-server/collab/protocol.ts +108 -0
- package/plugins/design/dev-server/collab/registry.ts +110 -0
- package/plugins/design/dev-server/collab/room.ts +215 -0
- package/plugins/design/dev-server/comments-overlay.tsx +29 -0
- package/plugins/design/dev-server/contextual-toolbar.tsx +1 -1
- package/plugins/design/dev-server/cursors-overlay.tsx +296 -0
- package/plugins/design/dev-server/dist/client.bundle.js +75 -3
- package/plugins/design/dev-server/dist/runtime/lib0_decoding.js +624 -0
- package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +608 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +380 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +104 -0
- package/plugins/design/dev-server/dist/runtime/yjs.js +6691 -0
- package/plugins/design/dev-server/export-dialog.tsx +1 -1
- package/plugins/design/dev-server/hmr-broadcast.ts +15 -2
- package/plugins/design/dev-server/http.ts +64 -1
- package/plugins/design/dev-server/marquee-overlay.tsx +2 -2
- package/plugins/design/dev-server/participants-chrome.tsx +261 -0
- package/plugins/design/dev-server/runtime-bundle.ts +19 -0
- package/plugins/design/dev-server/server.ts +78 -11
- package/plugins/design/dev-server/test/ai-activity.test.ts +113 -0
- package/plugins/design/dev-server/test/collab-annotations-bridge.test.ts +55 -0
- package/plugins/design/dev-server/test/collab-bridge.test.ts +81 -0
- package/plugins/design/dev-server/test/collab-loopback.test.ts +63 -0
- package/plugins/design/dev-server/test/collab-protocol.test.ts +146 -0
- package/plugins/design/dev-server/test/collab-room.test.ts +182 -0
- package/plugins/design/dev-server/test/collab-stress.test.ts +121 -0
- package/plugins/design/dev-server/test/git-lifecycle.test.ts +101 -0
- package/plugins/design/dev-server/test/participants-chrome.test.ts +30 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +71 -0
- package/plugins/design/dev-server/tool-palette.tsx +7 -7
- package/plugins/design/dev-server/use-annotation-resize.tsx +1 -1
- package/plugins/design/dev-server/use-collab.tsx +478 -0
- package/plugins/design/dev-server/ws.ts +123 -7
- package/plugins/design/templates/_shell.html +37 -1
- package/plugins/flow/.claude-plugin/config.schema.json +12 -0
- package/plugins/flow/dependencies.json +143 -0
- 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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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)
|
|
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 {
|