@1agh/maude 0.22.0 → 0.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli/commands/design-link.test.mjs +53 -1
- package/cli/commands/hub.test.mjs +10 -9
- package/cli/lib/design-link.mjs +154 -7
- package/cli/lib/hubs-config.mjs +42 -4
- package/package.json +9 -9
- package/plugins/design/dev-server/bin/check-runtime-bundles.sh +125 -0
- package/plugins/design/dev-server/bin/runtime-health.sh +47 -6
- package/plugins/design/dev-server/build.ts +87 -7
- package/plugins/design/dev-server/canvas-comment-mount.tsx +384 -0
- package/plugins/design/dev-server/canvas-lib.tsx +22 -6
- package/plugins/design/dev-server/canvas-shell.tsx +25 -226
- package/plugins/design/dev-server/client/app.jsx +37 -15
- package/plugins/design/dev-server/collab/awareness-bridge.ts +77 -0
- package/plugins/design/dev-server/collab/registry.ts +51 -0
- package/plugins/design/dev-server/config.schema.json +20 -0
- package/plugins/design/dev-server/context.ts +7 -0
- package/plugins/design/dev-server/dist/client.bundle.js +21 -12
- package/plugins/design/dev-server/dist/comment-mount.js +1801 -0
- package/plugins/design/dev-server/dist/runtime/.min-sizes.json +16 -0
- package/plugins/design/dev-server/dist/runtime/lib0_decoding.js +2 -6
- package/plugins/design/dev-server/dist/runtime/lib0_encoding.js +2 -6
- package/plugins/design/dev-server/dist/runtime/motion.js +4787 -8
- package/plugins/design/dev-server/dist/runtime/motion_react.js +9654 -7
- package/plugins/design/dev-server/dist/runtime/pixi-js.js +5865 -5469
- package/plugins/design/dev-server/dist/runtime/react-dom.js +4 -22
- package/plugins/design/dev-server/dist/runtime/react-dom_client.js +5 -23
- package/plugins/design/dev-server/dist/runtime/react.js +4 -22
- package/plugins/design/dev-server/dist/runtime/react_jsx-dev-runtime.js +4 -22
- package/plugins/design/dev-server/dist/runtime/react_jsx-runtime.js +4 -22
- package/plugins/design/dev-server/dist/runtime/y-protocols_awareness.js +2 -6
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +2 -6
- package/plugins/design/dev-server/dist/runtime/yjs.js +2 -6
- package/plugins/design/dev-server/dom-selection.ts +156 -0
- package/plugins/design/dev-server/hmr-broadcast.ts +51 -20
- package/plugins/design/dev-server/input-router.tsx +99 -61
- package/plugins/design/dev-server/server.ts +18 -0
- package/plugins/design/dev-server/sync/agent.ts +323 -0
- package/plugins/design/dev-server/sync/atomic-write.ts +103 -0
- package/plugins/design/dev-server/sync/codec.ts +169 -0
- package/plugins/design/dev-server/sync/echo-guard.ts +108 -0
- package/plugins/design/dev-server/sync/fs-mirror.ts +160 -0
- package/plugins/design/dev-server/sync/hubs-config.ts +87 -0
- package/plugins/design/dev-server/sync/index.ts +474 -0
- package/plugins/design/dev-server/test/collab-awareness-bridge.test.ts +223 -0
- package/plugins/design/dev-server/test/comment-mount.test.ts +87 -0
- package/plugins/design/dev-server/test/hmr-classify.test.ts +70 -0
- package/plugins/design/dev-server/test/sync-agent.test.ts +278 -0
- package/plugins/design/dev-server/test/sync-atomic-write.test.ts +63 -0
- package/plugins/design/dev-server/test/sync-codec.test.ts +165 -0
- package/plugins/design/dev-server/test/sync-echo-guard.test.ts +96 -0
- package/plugins/design/dev-server/test/sync-fs-mirror.test.ts +182 -0
- package/plugins/design/dev-server/test/sync-hardening.test.ts +520 -0
- package/plugins/design/dev-server/test/sync-hubs-config.test.ts +130 -0
- package/plugins/design/dev-server/test/sync-runtime.test.ts +285 -0
- package/plugins/design/dev-server/test/use-collab.test.ts +0 -0
- package/plugins/design/dev-server/use-collab.tsx +157 -13
- package/plugins/design/dev-server/use-selection-set.tsx +12 -0
- package/plugins/design/dev-server/use-tool-mode.tsx +12 -0
- package/plugins/design/templates/_shell.html +15 -5
- package/plugins/design/templates/design-system-inspiration/SUB-AGENT-PROMPTS.md +1 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
// Sync runtime entry point — Phase 9 Task 4 wiring.
|
|
2
|
+
//
|
|
3
|
+
// Public surface for the dev-server boot:
|
|
4
|
+
//
|
|
5
|
+
// const runtime = createSyncRuntime(ctx);
|
|
6
|
+
// if (runtime) await runtime.start();
|
|
7
|
+
// // ... later ...
|
|
8
|
+
// await runtime.stop();
|
|
9
|
+
//
|
|
10
|
+
// Returns null when the project is unlinked (`.design/config.json` has no
|
|
11
|
+
// `linkedHub` field) — preserves solo mode behavior bit-for-bit. When linked:
|
|
12
|
+
// resolves the per-machine token, opens one HocuspocusProvider + sync agent
|
|
13
|
+
// per canvas, and wires the existing ctx.bus 'fs:any' events through the
|
|
14
|
+
// agent's echo guard.
|
|
15
|
+
//
|
|
16
|
+
// HocuspocusProvider import is dynamic so a misconfigured project (linked but
|
|
17
|
+
// the provider lib didn't install for some reason) prints a useful error
|
|
18
|
+
// instead of crashing the dev-server boot.
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
|
+
import { readdir } from 'node:fs/promises';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
|
|
24
|
+
import type { Awareness } from 'y-protocols/awareness';
|
|
25
|
+
import * as Y from 'yjs';
|
|
26
|
+
|
|
27
|
+
import type { Context } from '../context.ts';
|
|
28
|
+
import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
|
|
29
|
+
import { type EchoGuard, createEchoGuard } from './echo-guard.ts';
|
|
30
|
+
import { type FsReader, createFsReader } from './fs-mirror.ts';
|
|
31
|
+
import { getHubToken } from './hubs-config.ts';
|
|
32
|
+
|
|
33
|
+
/** A minimum-surface stand-in for the HocuspocusProvider's runtime API. */
|
|
34
|
+
export interface SyncProvider {
|
|
35
|
+
readonly document: Y.Doc;
|
|
36
|
+
/**
|
|
37
|
+
* The provider's hub-synced Awareness, when it exposes one. Phase 9 Task 5
|
|
38
|
+
* bridges this to the collab Room's Awareness so cursors relay through the
|
|
39
|
+
* hub. Optional — a provider without awareness (or a test stub) just skips
|
|
40
|
+
* the bridge.
|
|
41
|
+
*/
|
|
42
|
+
readonly awareness?: Awareness;
|
|
43
|
+
/** Resolves when the first hub sync handshake completes. */
|
|
44
|
+
onceSynced(): Promise<void>;
|
|
45
|
+
destroy(): void;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Structural surface of the collab registry the runtime needs for Task 5.
|
|
50
|
+
* Defined here (rather than imported from collab/) to avoid a dev-server
|
|
51
|
+
* module cycle — the real `Registry` satisfies it.
|
|
52
|
+
*/
|
|
53
|
+
export interface AwarenessRegistry {
|
|
54
|
+
attachHubAwareness(slug: string, awareness: Awareness): () => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Factory the runtime calls per discovered canvas. Default uses Hocuspocus. */
|
|
58
|
+
export type ProviderFactory = (args: {
|
|
59
|
+
url: string;
|
|
60
|
+
token: string;
|
|
61
|
+
documentName: string;
|
|
62
|
+
}) => SyncProvider | Promise<SyncProvider>;
|
|
63
|
+
|
|
64
|
+
export interface SyncRuntime {
|
|
65
|
+
start(): Promise<void>;
|
|
66
|
+
stop(): Promise<void>;
|
|
67
|
+
/** Number of active per-canvas agents. */
|
|
68
|
+
size(): number;
|
|
69
|
+
/** Test inspection — get the agent for a slug if one was created. */
|
|
70
|
+
agentFor(slug: string): CanvasSyncAgent | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CreateSyncRuntimeOptions {
|
|
74
|
+
/** Override the HocuspocusProvider factory (test injection). */
|
|
75
|
+
providerFactory?: ProviderFactory;
|
|
76
|
+
/** Force-enable/disable adopt mode (overrides cfg.linkedHub.adopt). */
|
|
77
|
+
adopt?: boolean;
|
|
78
|
+
/** Discovery override — pass an explicit canvas list instead of scanning. */
|
|
79
|
+
canvases?: CanvasDescriptor[];
|
|
80
|
+
/**
|
|
81
|
+
* Collab registry — when provided, each provider's Awareness is bridged to
|
|
82
|
+
* the matching Room so cursors relay through the hub (Task 5). Omitted in
|
|
83
|
+
* unit tests that only exercise the file-sync path.
|
|
84
|
+
*/
|
|
85
|
+
registry?: AwarenessRegistry;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* One canvas the sync runtime tracks. `slug` is the stable doc-name shared
|
|
90
|
+
* with the hub; the three `paths` are absolute on-disk locations the agent
|
|
91
|
+
* mirrors.
|
|
92
|
+
*/
|
|
93
|
+
export interface CanvasDescriptor {
|
|
94
|
+
slug: string;
|
|
95
|
+
html: string;
|
|
96
|
+
comments: string;
|
|
97
|
+
annotations: string;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Build the sync runtime, or return null when the project isn't linked to a
|
|
102
|
+
* hub. Idempotent — callers can safely invoke this on every boot.
|
|
103
|
+
*/
|
|
104
|
+
export function createSyncRuntime(
|
|
105
|
+
ctx: Context,
|
|
106
|
+
opts: CreateSyncRuntimeOptions = {}
|
|
107
|
+
): SyncRuntime | null {
|
|
108
|
+
const linked = ctx.cfg.linkedHub;
|
|
109
|
+
if (!linked) return null;
|
|
110
|
+
const linkedHub = linked;
|
|
111
|
+
|
|
112
|
+
// DDR-054 §2a — CI environment gate. Closes the supply-chain side-door
|
|
113
|
+
// where a future CI workflow runs `maude design serve` and a PR-controlled
|
|
114
|
+
// linkedHub.url silently grants a remote actor write access in an
|
|
115
|
+
// environment carrying GITHUB_TOKEN. Override via MAUDE_SYNC_IN_CI=1.
|
|
116
|
+
if (
|
|
117
|
+
!process.env.MAUDE_SYNC_IN_CI &&
|
|
118
|
+
(process.env.CI === 'true' || process.env.CI === '1' || !!process.env.GITHUB_ACTIONS)
|
|
119
|
+
) {
|
|
120
|
+
console.warn(
|
|
121
|
+
'[sync] disabled in CI environment (CI / GITHUB_ACTIONS detected). DDR-054 §2a. Set MAUDE_SYNC_IN_CI=1 to override.'
|
|
122
|
+
);
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// DDR-054 §2e — scheme allowlist. Refuse plaintext to non-loopback hosts
|
|
127
|
+
// (closes attacker F9 cleartext token exfil and the F2 last-mile chain).
|
|
128
|
+
const schemeError = checkUrlScheme(linkedHub.url);
|
|
129
|
+
if (schemeError) {
|
|
130
|
+
console.error(`[sync] refusing to start: ${schemeError}`);
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const resolvedToken = getHubToken(linkedHub.url);
|
|
135
|
+
if (!resolvedToken) {
|
|
136
|
+
console.warn(
|
|
137
|
+
`[sync] linked to ${linkedHub.url} but no token in ~/.config/maude/hubs.json. Re-run 'maude design link' on this machine. Solo mode for now.`
|
|
138
|
+
);
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
const token: string = resolvedToken;
|
|
142
|
+
|
|
143
|
+
const providerFactory = opts.providerFactory ?? defaultProviderFactory;
|
|
144
|
+
const echoGuard = createEchoGuard();
|
|
145
|
+
const agents = new Map<string, CanvasSyncAgent>();
|
|
146
|
+
const providers = new Map<string, SyncProvider>();
|
|
147
|
+
const awarenessDetaches: Array<() => void> = [];
|
|
148
|
+
let fsReader: FsReader | null = null;
|
|
149
|
+
let busUnsub: (() => void) | null = null;
|
|
150
|
+
let started = false;
|
|
151
|
+
let stopped = false;
|
|
152
|
+
|
|
153
|
+
async function start(): Promise<void> {
|
|
154
|
+
if (started || stopped) return;
|
|
155
|
+
started = true;
|
|
156
|
+
|
|
157
|
+
const canvases = opts.canvases ?? (await discoverCanvases(ctx));
|
|
158
|
+
if (canvases.length === 0) {
|
|
159
|
+
console.log(
|
|
160
|
+
`[sync] linked to ${linkedHub.url} — no canvases discovered under ${ctx.paths.designRoot}.`
|
|
161
|
+
);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const reader = createFsReader({
|
|
166
|
+
rootDir: ctx.paths.designRoot,
|
|
167
|
+
accept: (rel) => {
|
|
168
|
+
const ext = path.extname(rel).toLowerCase();
|
|
169
|
+
return ext === '.html' || ext === '.json' || ext === '.svg';
|
|
170
|
+
},
|
|
171
|
+
onRead: (evt) => {
|
|
172
|
+
for (const agent of agents.values()) {
|
|
173
|
+
const abs = path.join(ctx.paths.designRoot, evt.path);
|
|
174
|
+
const changed = agent.applyFromFs({ path: abs, bytes: evt.bytes, hash: evt.hash });
|
|
175
|
+
if (changed) break; // a path belongs to at most one canvas
|
|
176
|
+
}
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
fsReader = reader;
|
|
180
|
+
|
|
181
|
+
busUnsub = ctx.bus.on('fs:any', (rel: string) => {
|
|
182
|
+
reader.notify(rel);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
const adoptOnce = opts.adopt ?? !!linkedHub.adopt;
|
|
186
|
+
let adoptReconciled = 0;
|
|
187
|
+
const adoptTarget = canvases.length;
|
|
188
|
+
|
|
189
|
+
for (const canvas of canvases) {
|
|
190
|
+
try {
|
|
191
|
+
const provider = await providerFactory({
|
|
192
|
+
url: linkedHub.url,
|
|
193
|
+
token,
|
|
194
|
+
documentName: canvas.slug,
|
|
195
|
+
});
|
|
196
|
+
providers.set(canvas.slug, provider);
|
|
197
|
+
const agent = createCanvasSyncAgent({
|
|
198
|
+
slug: canvas.slug,
|
|
199
|
+
doc: provider.document,
|
|
200
|
+
paths: {
|
|
201
|
+
html: canvas.html,
|
|
202
|
+
comments: canvas.comments,
|
|
203
|
+
annotations: canvas.annotations,
|
|
204
|
+
},
|
|
205
|
+
echoGuard,
|
|
206
|
+
adopt: adoptOnce,
|
|
207
|
+
});
|
|
208
|
+
agent.start();
|
|
209
|
+
agents.set(canvas.slug, agent);
|
|
210
|
+
|
|
211
|
+
// Task 5 — bridge the provider's hub-synced Awareness to the Room so
|
|
212
|
+
// browser cursors relay cross-machine. No-op when the provider exposes
|
|
213
|
+
// no awareness or no registry was passed (file-sync-only tests).
|
|
214
|
+
if (opts.registry && provider.awareness) {
|
|
215
|
+
awarenessDetaches.push(opts.registry.attachHubAwareness(canvas.slug, provider.awareness));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Cold-start reconcile fires once the provider has hub state.
|
|
219
|
+
void provider.onceSynced().then(async () => {
|
|
220
|
+
await agent.reconcile();
|
|
221
|
+
if (adoptOnce) {
|
|
222
|
+
adoptReconciled++;
|
|
223
|
+
if (adoptReconciled === adoptTarget) {
|
|
224
|
+
// All canvases adopted — clear the flag from .design/config.json
|
|
225
|
+
// so re-running serve doesn't re-trigger. DDR-054 §2i.
|
|
226
|
+
clearAdoptFlag(ctx);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
} catch (err) {
|
|
231
|
+
console.error(`[sync/${canvas.slug}] failed to start:`, err);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
console.log(
|
|
236
|
+
`[sync] linked to ${linkedHub.url} — ${agents.size}/${canvases.length} canvas(es) syncing${adoptOnce ? ' (adopt mode — pushing local up)' : ''}.`
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function stop(): Promise<void> {
|
|
241
|
+
if (stopped) return;
|
|
242
|
+
stopped = true;
|
|
243
|
+
for (const detach of awarenessDetaches) {
|
|
244
|
+
try {
|
|
245
|
+
detach();
|
|
246
|
+
} catch {
|
|
247
|
+
/* best-effort — registry teardown also clears bridges on destroyAll */
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
awarenessDetaches.length = 0;
|
|
251
|
+
busUnsub?.();
|
|
252
|
+
busUnsub = null;
|
|
253
|
+
fsReader?.stop();
|
|
254
|
+
fsReader = null;
|
|
255
|
+
for (const agent of agents.values()) {
|
|
256
|
+
try {
|
|
257
|
+
await agent.flush();
|
|
258
|
+
agent.stop();
|
|
259
|
+
} catch {
|
|
260
|
+
/* best-effort */
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
agents.clear();
|
|
264
|
+
for (const provider of providers.values()) {
|
|
265
|
+
try {
|
|
266
|
+
provider.destroy();
|
|
267
|
+
} catch {
|
|
268
|
+
/* best-effort */
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
providers.clear();
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return {
|
|
275
|
+
start,
|
|
276
|
+
stop,
|
|
277
|
+
size: () => agents.size,
|
|
278
|
+
agentFor: (slug) => agents.get(slug),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/* ---------------------------------------------------------------- discovery */
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Scan `<designRoot>/{ui,system}/` for `.html` canvas files and return one
|
|
286
|
+
* CanvasDescriptor per. Mirrors the existing api.ts file-tree scan but
|
|
287
|
+
* specialised for the sync runtime (we only need the three paths per canvas,
|
|
288
|
+
* not the full metadata).
|
|
289
|
+
*
|
|
290
|
+
* DDR-054 §2b — `.tsx` canvases are deliberately EXCLUDED from sync. The
|
|
291
|
+
* dev-server transpiles `.tsx` to JavaScript and serves it as
|
|
292
|
+
* `application/javascript` in iframe same-origin; a hostile hub pushing
|
|
293
|
+
* arbitrary TypeScript source would result in RCE. `.tsx` stays editable in
|
|
294
|
+
* solo mode; per-canvas opt-in via `.meta.json.syncable: true` is deferred to
|
|
295
|
+
* Task 8 (alongside CSP + iframe sandbox).
|
|
296
|
+
*/
|
|
297
|
+
export async function discoverCanvases(ctx: Context): Promise<CanvasDescriptor[]> {
|
|
298
|
+
const out: CanvasDescriptor[] = [];
|
|
299
|
+
for (const group of ctx.cfg.canvasGroups) {
|
|
300
|
+
const groupAbs = path.join(ctx.paths.designRoot, group.path);
|
|
301
|
+
if (!existsSync(groupAbs)) continue;
|
|
302
|
+
await walk(groupAbs, ctx.paths.designRoot, ctx.paths.commentsDir, ctx.paths.designRel, out);
|
|
303
|
+
}
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function walk(
|
|
308
|
+
dirAbs: string,
|
|
309
|
+
designRoot: string,
|
|
310
|
+
commentsDir: string,
|
|
311
|
+
designRel: string,
|
|
312
|
+
acc: CanvasDescriptor[]
|
|
313
|
+
): Promise<void> {
|
|
314
|
+
let entries: import('node:fs').Dirent[];
|
|
315
|
+
try {
|
|
316
|
+
entries = await readdir(dirAbs, { withFileTypes: true });
|
|
317
|
+
} catch {
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
for (const entry of entries) {
|
|
321
|
+
const abs = path.join(dirAbs, entry.name);
|
|
322
|
+
if (entry.isDirectory()) {
|
|
323
|
+
// Skip plugin runtime dirs.
|
|
324
|
+
if (entry.name.startsWith('_')) continue;
|
|
325
|
+
await walk(abs, designRoot, commentsDir, designRel, acc);
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
329
|
+
// DDR-054 §2b — refuse .tsx; only .html canvases sync.
|
|
330
|
+
if (ext !== '.html') continue;
|
|
331
|
+
const slug = slugFor(abs, designRoot, designRel);
|
|
332
|
+
acc.push({
|
|
333
|
+
slug,
|
|
334
|
+
html: abs,
|
|
335
|
+
comments: path.join(commentsDir, `${slug}.json`),
|
|
336
|
+
annotations: path.join(designRoot, `${slug}.annotations.svg`),
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function slugFor(absPath: string, designRoot: string, designRel: string): string {
|
|
342
|
+
let rel = path.relative(designRoot, absPath);
|
|
343
|
+
rel = rel.replace(/\\/g, '/');
|
|
344
|
+
// Mirror api.fileSlug(): collapse path separators to '-', strip extension.
|
|
345
|
+
const prefix = `${designRel.replace(/^\/+|\/+$/g, '')}/`;
|
|
346
|
+
if (rel.startsWith(prefix)) rel = rel.slice(prefix.length);
|
|
347
|
+
return rel
|
|
348
|
+
.replace(/\//g, '-')
|
|
349
|
+
.replace(/\s+/g, '_')
|
|
350
|
+
.replace(/\.(tsx|html)$/i, '')
|
|
351
|
+
.replace(/^\.+/, '')
|
|
352
|
+
.toLowerCase();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/* ---------------------------------------------------------------- default provider */
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* The production provider factory — instantiates a real HocuspocusProvider.
|
|
359
|
+
* Imported dynamically so tests / unlinked projects don't pay the load cost.
|
|
360
|
+
*/
|
|
361
|
+
async function defaultProviderFactory(args: {
|
|
362
|
+
url: string;
|
|
363
|
+
token: string;
|
|
364
|
+
documentName: string;
|
|
365
|
+
}): Promise<SyncProvider> {
|
|
366
|
+
// biome-ignore lint/suspicious/noExplicitAny: dynamic import of optional dep.
|
|
367
|
+
let mod: any;
|
|
368
|
+
try {
|
|
369
|
+
mod = await import('@hocuspocus/provider');
|
|
370
|
+
} catch (err) {
|
|
371
|
+
throw new Error(
|
|
372
|
+
`@hocuspocus/provider unavailable — install it under plugins/design/dev-server/. (${err instanceof Error ? err.message : String(err)})`
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
// Hocuspocus accepts ws:// or wss://; the linked URL is http(s)://, so swap
|
|
376
|
+
// the scheme. The provider also accepts http(s):// and upgrades internally
|
|
377
|
+
// in newer versions, but ws:// is explicit + portable.
|
|
378
|
+
const wsUrl = toWsUrl(args.url);
|
|
379
|
+
const document = new Y.Doc();
|
|
380
|
+
// biome-ignore lint/suspicious/noExplicitAny: provider runtime is typed at the call site.
|
|
381
|
+
const provider: any = new mod.HocuspocusProvider({
|
|
382
|
+
url: wsUrl,
|
|
383
|
+
name: args.documentName,
|
|
384
|
+
token: args.token,
|
|
385
|
+
document,
|
|
386
|
+
connect: true,
|
|
387
|
+
});
|
|
388
|
+
return {
|
|
389
|
+
document,
|
|
390
|
+
// HocuspocusProvider creates a hub-synced Awareness by default; expose it
|
|
391
|
+
// so the runtime can bridge it to the collab Room (Task 5).
|
|
392
|
+
awareness: provider.awareness as Awareness | undefined,
|
|
393
|
+
onceSynced(): Promise<void> {
|
|
394
|
+
return new Promise<void>((resolve) => {
|
|
395
|
+
if (provider.synced) {
|
|
396
|
+
resolve();
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const handler = () => {
|
|
400
|
+
provider.off('synced', handler);
|
|
401
|
+
resolve();
|
|
402
|
+
};
|
|
403
|
+
provider.on('synced', handler);
|
|
404
|
+
});
|
|
405
|
+
},
|
|
406
|
+
destroy() {
|
|
407
|
+
provider.destroy();
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Convert an http(s):// URL to ws(s):// for the HocuspocusProvider. */
|
|
413
|
+
export function toWsUrl(httpUrl: string): string {
|
|
414
|
+
if (httpUrl.startsWith('https://')) return `wss://${httpUrl.slice('https://'.length)}`;
|
|
415
|
+
if (httpUrl.startsWith('http://')) return `ws://${httpUrl.slice('http://'.length)}`;
|
|
416
|
+
return httpUrl;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Rewrite .design/config.json to drop `linkedHub.adopt`. Called once after
|
|
421
|
+
* all canvases finish their first adopt-reconcile. DDR-054 §2i (defender I5).
|
|
422
|
+
* Best-effort — failure logs and leaves the disk flag in place; the only
|
|
423
|
+
* downstream cost is the user being prompted to re-run adopt.
|
|
424
|
+
*/
|
|
425
|
+
function clearAdoptFlag(ctx: Context): void {
|
|
426
|
+
const cfgPath = path.join(ctx.paths.repoRoot, '.design', 'config.json');
|
|
427
|
+
if (!existsSync(cfgPath)) return;
|
|
428
|
+
try {
|
|
429
|
+
const raw = JSON.parse(readFileSync(cfgPath, 'utf8')) as {
|
|
430
|
+
linkedHub?: { adopt?: boolean; lastAdoptedAt?: number };
|
|
431
|
+
};
|
|
432
|
+
if (!raw?.linkedHub?.adopt) return;
|
|
433
|
+
raw.linkedHub.adopt = undefined;
|
|
434
|
+
raw.linkedHub.lastAdoptedAt = Date.now();
|
|
435
|
+
// Strip undefined values via stringify/parse so the JSON output is clean.
|
|
436
|
+
const cleaned = JSON.parse(JSON.stringify(raw));
|
|
437
|
+
writeFileSync(cfgPath, `${JSON.stringify(cleaned, null, 2)}\n`, 'utf8');
|
|
438
|
+
console.log('[sync] adopt complete — cleared linkedHub.adopt from .design/config.json');
|
|
439
|
+
} catch (err) {
|
|
440
|
+
console.warn(
|
|
441
|
+
'[sync] failed to clear linkedHub.adopt:',
|
|
442
|
+
err instanceof Error ? err.message : err
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Refuse non-loopback ws:// / http:// (cleartext token exposure to MITM).
|
|
449
|
+
* DDR-054 §2e (attacker F9). Returns null on accept, error string on refuse.
|
|
450
|
+
*
|
|
451
|
+
* Loopback hosts (localhost, 127.0.0.1, [::1], ::1) keep ws:// allowed for
|
|
452
|
+
* local hub development. Non-http(s)/ws(s) schemes are refused outright.
|
|
453
|
+
*/
|
|
454
|
+
export function checkUrlScheme(url: string): string | null {
|
|
455
|
+
let u: URL;
|
|
456
|
+
try {
|
|
457
|
+
u = new URL(url);
|
|
458
|
+
} catch {
|
|
459
|
+
return `invalid hub URL: ${url}`;
|
|
460
|
+
}
|
|
461
|
+
const proto = u.protocol.toLowerCase();
|
|
462
|
+
if (proto !== 'http:' && proto !== 'https:' && proto !== 'ws:' && proto !== 'wss:') {
|
|
463
|
+
return `unsupported hub URL scheme: ${proto} (expected https:// or wss://)`;
|
|
464
|
+
}
|
|
465
|
+
const isPlaintext = proto === 'http:' || proto === 'ws:';
|
|
466
|
+
if (!isPlaintext) return null;
|
|
467
|
+
const host = u.hostname.toLowerCase();
|
|
468
|
+
const isLoopback =
|
|
469
|
+
host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
|
|
470
|
+
if (!isLoopback) {
|
|
471
|
+
return `plaintext URL (${proto}//) is only allowed for loopback hosts. Use wss:// for ${host} or change the host to localhost.`;
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// Phase 9 Task 5 — awareness over WSS.
|
|
2
|
+
//
|
|
3
|
+
// Unit-tests the in-process Awareness bridge (collab Room ↔ sync provider) and
|
|
4
|
+
// the registry wiring that owns its lifecycle, plus an end-to-end cross-peer
|
|
5
|
+
// cursor relay through a simulated hub.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from 'bun:test';
|
|
8
|
+
|
|
9
|
+
import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate } from 'y-protocols/awareness';
|
|
10
|
+
import * as Y from 'yjs';
|
|
11
|
+
|
|
12
|
+
import { bridgeAwareness } from '../collab/awareness-bridge.ts';
|
|
13
|
+
import { createRegistry } from '../collab/registry.ts';
|
|
14
|
+
import type { RoomCallbacks } from '../collab/room.ts';
|
|
15
|
+
|
|
16
|
+
function noopCallbacks(): RoomCallbacks {
|
|
17
|
+
return {
|
|
18
|
+
async seed() {},
|
|
19
|
+
async persistJson() {},
|
|
20
|
+
async persistBinary() {},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function mkAwareness(): Awareness {
|
|
25
|
+
return new Awareness(new Y.Doc());
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Simulate the Hocuspocus hub relay: two Awareness instances cross-linked so a
|
|
29
|
+
// change on one appears on the other (keyed by the originating clientID).
|
|
30
|
+
function linkAwareness(x: Awareness, y: Awareness): () => void {
|
|
31
|
+
const T = { transport: true };
|
|
32
|
+
function fwd(from: Awareness, to: Awareness) {
|
|
33
|
+
return (
|
|
34
|
+
{ added, updated, removed }: { added: number[]; updated: number[]; removed: number[] },
|
|
35
|
+
origin: unknown
|
|
36
|
+
) => {
|
|
37
|
+
if (origin === T) return;
|
|
38
|
+
const changed = added.concat(updated, removed);
|
|
39
|
+
if (changed.length === 0) return;
|
|
40
|
+
applyAwarenessUpdate(to, encodeAwarenessUpdate(from, changed), T);
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
const xy = fwd(x, y);
|
|
44
|
+
const yx = fwd(y, x);
|
|
45
|
+
x.on('update', xy);
|
|
46
|
+
y.on('update', yx);
|
|
47
|
+
return () => {
|
|
48
|
+
x.off('update', xy);
|
|
49
|
+
y.off('update', yx);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
describe('bridgeAwareness', () => {
|
|
54
|
+
test('relays a local state set on a → b', () => {
|
|
55
|
+
const a = mkAwareness();
|
|
56
|
+
const b = mkAwareness();
|
|
57
|
+
bridgeAwareness(a, b);
|
|
58
|
+
|
|
59
|
+
a.setLocalState({ name: 'Alice', color: '#f00', cursor: { x: 1, y: 2 } });
|
|
60
|
+
|
|
61
|
+
const seen = b.getStates().get(a.clientID) as { name?: string; color?: string } | undefined;
|
|
62
|
+
expect(seen?.name).toBe('Alice');
|
|
63
|
+
expect(seen?.color).toBe('#f00');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test('relays both directions', () => {
|
|
67
|
+
const a = mkAwareness();
|
|
68
|
+
const b = mkAwareness();
|
|
69
|
+
bridgeAwareness(a, b);
|
|
70
|
+
|
|
71
|
+
b.setLocalState({ name: 'Bob' });
|
|
72
|
+
expect((a.getStates().get(b.clientID) as { name?: string } | undefined)?.name).toBe('Bob');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('propagates removal when the source clears its state', () => {
|
|
76
|
+
const a = mkAwareness();
|
|
77
|
+
const b = mkAwareness();
|
|
78
|
+
bridgeAwareness(a, b);
|
|
79
|
+
|
|
80
|
+
a.setLocalState({ name: 'Alice' });
|
|
81
|
+
expect(b.getStates().has(a.clientID)).toBe(true);
|
|
82
|
+
|
|
83
|
+
a.setLocalState(null);
|
|
84
|
+
expect(b.getStates().has(a.clientID)).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('exchanges pre-existing states at wire time', () => {
|
|
88
|
+
const a = mkAwareness();
|
|
89
|
+
const b = mkAwareness();
|
|
90
|
+
a.setLocalState({ name: 'Alice' });
|
|
91
|
+
b.setLocalState({ name: 'Bob' });
|
|
92
|
+
|
|
93
|
+
bridgeAwareness(a, b);
|
|
94
|
+
|
|
95
|
+
expect((b.getStates().get(a.clientID) as { name?: string } | undefined)?.name).toBe('Alice');
|
|
96
|
+
expect((a.getStates().get(b.clientID) as { name?: string } | undefined)?.name).toBe('Bob');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('does not echo — a single update yields a single foreign state, no storm', () => {
|
|
100
|
+
const a = mkAwareness();
|
|
101
|
+
const b = mkAwareness();
|
|
102
|
+
bridgeAwareness(a, b);
|
|
103
|
+
|
|
104
|
+
let bUpdates = 0;
|
|
105
|
+
b.on('update', () => {
|
|
106
|
+
bUpdates++;
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
a.setLocalState({ name: 'Alice' });
|
|
110
|
+
|
|
111
|
+
// Exactly one relayed update; a's own state count stays 1 (no bounce-back
|
|
112
|
+
// creating phantom clients).
|
|
113
|
+
expect(bUpdates).toBe(1);
|
|
114
|
+
expect(a.getStates().size).toBe(1);
|
|
115
|
+
expect(b.getStates().size).toBe(2); // a (relayed) + b's own
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('detach stops further relay', () => {
|
|
119
|
+
const a = mkAwareness();
|
|
120
|
+
const b = mkAwareness();
|
|
121
|
+
const detach = bridgeAwareness(a, b);
|
|
122
|
+
|
|
123
|
+
detach();
|
|
124
|
+
a.setLocalState({ name: 'Alice' });
|
|
125
|
+
expect(b.getStates().has(a.clientID)).toBe(false);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe('Registry.attachHubAwareness', () => {
|
|
130
|
+
test('attach-then-create wires the bridge so room state reaches the hub', () => {
|
|
131
|
+
const r = createRegistry(noopCallbacks());
|
|
132
|
+
const hub = mkAwareness();
|
|
133
|
+
r.attachHubAwareness('s', hub);
|
|
134
|
+
|
|
135
|
+
const room = r.get('s');
|
|
136
|
+
room.awareness.setLocalState({ name: 'Alice' });
|
|
137
|
+
|
|
138
|
+
expect((hub.getStates().get(room.awareness.clientID) as { name?: string })?.name).toBe('Alice');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('create-then-attach wires immediately', () => {
|
|
142
|
+
const r = createRegistry(noopCallbacks());
|
|
143
|
+
const room = r.get('s');
|
|
144
|
+
room.awareness.setLocalState({ name: 'Alice' });
|
|
145
|
+
|
|
146
|
+
const hub = mkAwareness();
|
|
147
|
+
r.attachHubAwareness('s', hub);
|
|
148
|
+
|
|
149
|
+
// Pre-existing room state is exchanged at wire time.
|
|
150
|
+
expect((hub.getStates().get(room.awareness.clientID) as { name?: string })?.name).toBe('Alice');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('detach stops relaying and prevents re-wire on a fresh room', async () => {
|
|
154
|
+
const r = createRegistry(noopCallbacks());
|
|
155
|
+
const hub = mkAwareness();
|
|
156
|
+
const detach = r.attachHubAwareness('s', hub);
|
|
157
|
+
r.get('s');
|
|
158
|
+
|
|
159
|
+
detach();
|
|
160
|
+
await r.drop('s'); // room had no conns → destroyed
|
|
161
|
+
|
|
162
|
+
const room2 = r.get('s');
|
|
163
|
+
room2.awareness.setLocalState({ name: 'Alice' });
|
|
164
|
+
expect(hub.getStates().has(room2.awareness.clientID)).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('room churn re-wires the bridge (hub awareness persists)', async () => {
|
|
168
|
+
const r = createRegistry(noopCallbacks());
|
|
169
|
+
const hub = mkAwareness();
|
|
170
|
+
r.attachHubAwareness('s', hub);
|
|
171
|
+
|
|
172
|
+
const room1 = r.get('s');
|
|
173
|
+
room1.awareness.setLocalState({ name: 'Alice' });
|
|
174
|
+
expect(hub.getStates().has(room1.awareness.clientID)).toBe(true);
|
|
175
|
+
|
|
176
|
+
await r.drop('s'); // last browser left
|
|
177
|
+
|
|
178
|
+
const room2 = r.get('s'); // browser reconnects
|
|
179
|
+
room2.awareness.setLocalState({ name: 'Alice2' });
|
|
180
|
+
expect((hub.getStates().get(room2.awareness.clientID) as { name?: string })?.name).toBe(
|
|
181
|
+
'Alice2'
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test('destroyAll tears down bridges without throwing', async () => {
|
|
186
|
+
const r = createRegistry(noopCallbacks());
|
|
187
|
+
const hub = mkAwareness();
|
|
188
|
+
r.attachHubAwareness('s', hub);
|
|
189
|
+
r.get('s').awareness.setLocalState({ name: 'Alice' });
|
|
190
|
+
|
|
191
|
+
await r.destroyAll();
|
|
192
|
+
// Hub Awareness is no longer relayed-to; setting a fresh room would not
|
|
193
|
+
// re-wire because destroyAll cleared the attachment.
|
|
194
|
+
const room = r.get('s');
|
|
195
|
+
room.awareness.setLocalState({ name: 'Bob' });
|
|
196
|
+
expect(hub.getStates().has(room.awareness.clientID)).toBe(false);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
describe('cross-peer cursor relay (Room A → hub → Room B)', () => {
|
|
201
|
+
test('a cursor published in one peer reaches the other peer', () => {
|
|
202
|
+
// Peer A: room A ↔ hubA. Peer B: room B ↔ hubB. hubA ↔ hubB is the
|
|
203
|
+
// Hocuspocus relay (simulated by linkAwareness).
|
|
204
|
+
const regA = createRegistry(noopCallbacks());
|
|
205
|
+
const regB = createRegistry(noopCallbacks());
|
|
206
|
+
const hubA = mkAwareness();
|
|
207
|
+
const hubB = mkAwareness();
|
|
208
|
+
linkAwareness(hubA, hubB);
|
|
209
|
+
regA.attachHubAwareness('canvas', hubA);
|
|
210
|
+
regB.attachHubAwareness('canvas', hubB);
|
|
211
|
+
|
|
212
|
+
const roomA = regA.get('canvas');
|
|
213
|
+
const roomB = regB.get('canvas');
|
|
214
|
+
|
|
215
|
+
roomA.awareness.setLocalState({ name: 'Alice', cursor: { x: 10, y: 20 } });
|
|
216
|
+
|
|
217
|
+
const onB = roomB.awareness.getStates().get(roomA.awareness.clientID) as
|
|
218
|
+
| { name?: string; cursor?: { x: number; y: number } }
|
|
219
|
+
| undefined;
|
|
220
|
+
expect(onB?.name).toBe('Alice');
|
|
221
|
+
expect(onB?.cursor).toEqual({ x: 10, y: 20 });
|
|
222
|
+
});
|
|
223
|
+
});
|