@1agh/maude 0.20.0 → 0.21.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 +350 -0
- package/cli/commands/doctor.test.mjs +185 -0
- package/cli/commands/help.mjs +24 -0
- package/cli/commands/hub.mjs +231 -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/y-protocols_awareness.js +587 -0
- package/plugins/design/dev-server/dist/runtime/y-protocols_sync.js +257 -0
- package/plugins/design/dev-server/dist/runtime/yjs.js +7107 -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 +8 -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 +35 -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,127 @@
|
|
|
1
|
+
// Phase 8 Task 4 — AI activity tracking with heartbeat-based TTL.
|
|
2
|
+
//
|
|
3
|
+
// `/design:edit` (or any external slash command driving Claude work) POSTs to
|
|
4
|
+
// /_api/ai/start when it begins editing a canvas, pings /_api/ai/heartbeat
|
|
5
|
+
// every 10 seconds, and POSTs /_api/ai/end on normal completion. The server
|
|
6
|
+
// maintains an in-memory map keyed by canvas file path, broadcasts changes
|
|
7
|
+
// over the inspector WS bus so every open client renders a yellow "Claude is
|
|
8
|
+
// editing this canvas" banner.
|
|
9
|
+
//
|
|
10
|
+
// Why not Awareness — Awareness is the y-protocols channel for connected
|
|
11
|
+
// peers. The AI is not a connected peer (it lives in a slash command, not a
|
|
12
|
+
// browser tab) and the banner has to reach BOTH collab and inspector clients,
|
|
13
|
+
// not just the per-canvas Y.Doc room. A separate bus event keeps the model
|
|
14
|
+
// honest: AI activity is a server-managed projection, not a peer state.
|
|
15
|
+
//
|
|
16
|
+
// 30-second grace TTL — if the slash command crashes (no /end POST) the
|
|
17
|
+
// banner auto-clears 30 s after the last heartbeat. A janitor runs every 5 s
|
|
18
|
+
// and broadcasts cleared state if the entry expired.
|
|
19
|
+
|
|
20
|
+
import type { Context } from '../context.ts';
|
|
21
|
+
|
|
22
|
+
const HEARTBEAT_GRACE_MS = 30_000;
|
|
23
|
+
const JANITOR_INTERVAL_MS = 5_000;
|
|
24
|
+
|
|
25
|
+
export interface AiActivityEntry {
|
|
26
|
+
file: string;
|
|
27
|
+
author: string;
|
|
28
|
+
startedAt: number;
|
|
29
|
+
lastHeartbeat: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AiActivity {
|
|
33
|
+
/** Begin tracking AI work on a canvas. Idempotent — replaces any prior. */
|
|
34
|
+
start(file: string, author: string): AiActivityEntry;
|
|
35
|
+
/** Refresh the lastHeartbeat timestamp. Returns null if no entry. */
|
|
36
|
+
heartbeat(file: string): AiActivityEntry | null;
|
|
37
|
+
/** Explicit end — clears immediately on normal completion. */
|
|
38
|
+
end(file: string): boolean;
|
|
39
|
+
/** Read the live state — used by the GET /_api/ai endpoint. */
|
|
40
|
+
list(): AiActivityEntry[];
|
|
41
|
+
/** Check one canvas — used by the bus broadcaster. */
|
|
42
|
+
get(file: string): AiActivityEntry | null;
|
|
43
|
+
/** Tear down the janitor (server shutdown). */
|
|
44
|
+
stop(): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createAiActivity(ctx: Context, now: () => number = Date.now): AiActivity {
|
|
48
|
+
const entries = new Map<string, AiActivityEntry>();
|
|
49
|
+
let janitor: ReturnType<typeof setInterval> | null = null;
|
|
50
|
+
|
|
51
|
+
function broadcast(file: string, entry: AiActivityEntry | null) {
|
|
52
|
+
ctx.bus.emit('ai-activity', { file, entry });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function expired(entry: AiActivityEntry, t: number): boolean {
|
|
56
|
+
return t - entry.lastHeartbeat > HEARTBEAT_GRACE_MS;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function start(file: string, author: string): AiActivityEntry {
|
|
60
|
+
const t = now();
|
|
61
|
+
const entry: AiActivityEntry = {
|
|
62
|
+
file,
|
|
63
|
+
author,
|
|
64
|
+
startedAt: entries.get(file)?.startedAt ?? t,
|
|
65
|
+
lastHeartbeat: t,
|
|
66
|
+
};
|
|
67
|
+
entries.set(file, entry);
|
|
68
|
+
broadcast(file, entry);
|
|
69
|
+
ensureJanitor();
|
|
70
|
+
return entry;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function heartbeat(file: string): AiActivityEntry | null {
|
|
74
|
+
const entry = entries.get(file);
|
|
75
|
+
if (!entry) return null;
|
|
76
|
+
entry.lastHeartbeat = now();
|
|
77
|
+
broadcast(file, entry);
|
|
78
|
+
return entry;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function end(file: string): boolean {
|
|
82
|
+
if (!entries.has(file)) return false;
|
|
83
|
+
entries.delete(file);
|
|
84
|
+
broadcast(file, null);
|
|
85
|
+
if (entries.size === 0 && janitor) {
|
|
86
|
+
clearInterval(janitor);
|
|
87
|
+
janitor = null;
|
|
88
|
+
}
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function list(): AiActivityEntry[] {
|
|
93
|
+
return Array.from(entries.values());
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function get(file: string): AiActivityEntry | null {
|
|
97
|
+
return entries.get(file) ?? null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function ensureJanitor() {
|
|
101
|
+
if (janitor || typeof setInterval === 'undefined') return;
|
|
102
|
+
janitor = setInterval(() => {
|
|
103
|
+
const t = now();
|
|
104
|
+
for (const [file, entry] of entries) {
|
|
105
|
+
if (expired(entry, t)) {
|
|
106
|
+
entries.delete(file);
|
|
107
|
+
broadcast(file, null);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (entries.size === 0 && janitor) {
|
|
111
|
+
clearInterval(janitor);
|
|
112
|
+
janitor = null;
|
|
113
|
+
}
|
|
114
|
+
}, JANITOR_INTERVAL_MS);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function stop(): void {
|
|
118
|
+
if (janitor) {
|
|
119
|
+
clearInterval(janitor);
|
|
120
|
+
janitor = null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return { start, heartbeat, end, list, get, stop };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export { HEARTBEAT_GRACE_MS, JANITOR_INTERVAL_MS };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Phase 8 Task 7 — `.git/HEAD` watcher. On branch switch (`git checkout`) or
|
|
2
|
+
// pull mid-session, force-snapshot every dirty Y.Doc to its on-disk JSON form
|
|
3
|
+
// BEFORE prompting peers to reload. Order matters per DDR-051 §3:
|
|
4
|
+
//
|
|
5
|
+
// 1. Synchronously flush every dirty room → JSON via registry.flushAll().
|
|
6
|
+
// 2. THEN broadcast a `git-lifecycle` bus event with the new HEAD ref.
|
|
7
|
+
// 3. Clients render a non-modal "Repo state changed — reload to sync?"
|
|
8
|
+
// banner; on confirm they call location.reload() and the next mount
|
|
9
|
+
// seeds the Y.Doc from the (now branch-current) JSON.
|
|
10
|
+
//
|
|
11
|
+
// Without step 1, the reload prompt can fire while in-flight edits are still
|
|
12
|
+
// buffered in the 800 ms debounce window — the user's "reload" click then
|
|
13
|
+
// silently discards them. Force-snapshot is the cheap escape valve: pay the
|
|
14
|
+
// full sync cost once at branch-switch time.
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync, watch } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
import type { Context } from '../context.ts';
|
|
20
|
+
import type { Registry } from './registry.ts';
|
|
21
|
+
|
|
22
|
+
export interface GitLifecycle {
|
|
23
|
+
/** Stop the underlying watcher (server shutdown). */
|
|
24
|
+
stop(): void;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface GitLifecycleEvent {
|
|
28
|
+
reason: 'head-changed';
|
|
29
|
+
head: string;
|
|
30
|
+
prevHead: string | null;
|
|
31
|
+
ts: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const HEAD_DEBOUNCE_MS = 250;
|
|
35
|
+
|
|
36
|
+
export function createGitLifecycle(ctx: Context, registry: Registry): GitLifecycle {
|
|
37
|
+
const gitDir = path.join(ctx.paths.repoRoot, '.git');
|
|
38
|
+
const headPath = path.join(gitDir, 'HEAD');
|
|
39
|
+
|
|
40
|
+
if (!existsSync(headPath)) {
|
|
41
|
+
// Not a git repo — no lifecycle to watch. Quietly no-op so the dev-server
|
|
42
|
+
// still works inside scratch / templated projects.
|
|
43
|
+
return { stop: () => {} };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let prevHead: string | null = readHeadSafe(headPath);
|
|
47
|
+
let debounce: ReturnType<typeof setTimeout> | null = null;
|
|
48
|
+
let watcher: ReturnType<typeof watch> | null = null;
|
|
49
|
+
let stopped = false;
|
|
50
|
+
|
|
51
|
+
async function onChange() {
|
|
52
|
+
if (stopped) return;
|
|
53
|
+
const next = readHeadSafe(headPath);
|
|
54
|
+
if (next === null) return; // transient mid-write read; ignore
|
|
55
|
+
if (next === prevHead) return;
|
|
56
|
+
const old = prevHead;
|
|
57
|
+
prevHead = next;
|
|
58
|
+
|
|
59
|
+
// STEP 1 — synchronous JSON flush BEFORE the prompt. Awaiting is critical;
|
|
60
|
+
// the broadcast must NOT race ahead of the disk write.
|
|
61
|
+
try {
|
|
62
|
+
await registry.flushAll();
|
|
63
|
+
} catch (err) {
|
|
64
|
+
console.error('[git-lifecycle] flushAll failed:', err);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// STEP 2 — broadcast. Clients render a reload prompt; user confirms → reload.
|
|
68
|
+
const evt: GitLifecycleEvent = {
|
|
69
|
+
reason: 'head-changed',
|
|
70
|
+
head: next,
|
|
71
|
+
prevHead: old,
|
|
72
|
+
ts: Date.now(),
|
|
73
|
+
};
|
|
74
|
+
ctx.bus.emit('git-lifecycle', evt);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function schedule() {
|
|
78
|
+
if (debounce) clearTimeout(debounce);
|
|
79
|
+
debounce = setTimeout(() => {
|
|
80
|
+
debounce = null;
|
|
81
|
+
void onChange();
|
|
82
|
+
}, HEAD_DEBOUNCE_MS);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
// Watch the HEAD file directly. fs.watch on .git/HEAD fires on every
|
|
87
|
+
// checkout (git rewrites the file with the new ref) and on pull (when
|
|
88
|
+
// git updates HEAD to a new commit via the `update-ref` path). Some
|
|
89
|
+
// filesystems / git implementations fire spurious events on packfile
|
|
90
|
+
// operations too — the readHead diff above filters noise.
|
|
91
|
+
watcher = watch(headPath, { persistent: false }, () => schedule());
|
|
92
|
+
} catch (err) {
|
|
93
|
+
console.warn(
|
|
94
|
+
'[git-lifecycle] failed to watch .git/HEAD:',
|
|
95
|
+
err instanceof Error ? err.message : err
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function stop() {
|
|
100
|
+
stopped = true;
|
|
101
|
+
if (debounce) {
|
|
102
|
+
clearTimeout(debounce);
|
|
103
|
+
debounce = null;
|
|
104
|
+
}
|
|
105
|
+
if (watcher) {
|
|
106
|
+
try {
|
|
107
|
+
watcher.close();
|
|
108
|
+
} catch {
|
|
109
|
+
/* ignore */
|
|
110
|
+
}
|
|
111
|
+
watcher = null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { stop };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readHeadSafe(headPath: string): string | null {
|
|
119
|
+
try {
|
|
120
|
+
return readFileSync(headPath, 'utf8').trim();
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Public surface of the collab module. Bundles the registry + persistence
|
|
2
|
+
// wiring so ws.ts + server.ts don't need to know about the internal split.
|
|
3
|
+
|
|
4
|
+
import type { Api } from '../api.ts';
|
|
5
|
+
import type { Context } from '../context.ts';
|
|
6
|
+
|
|
7
|
+
import { Y_TYPES, createPersistence } from './persistence.ts';
|
|
8
|
+
import { type Registry, createRegistry } from './registry.ts';
|
|
9
|
+
|
|
10
|
+
export { Y_TYPES };
|
|
11
|
+
export type { Registry } from './registry.ts';
|
|
12
|
+
export type { Room, RoomConn } from './room.ts';
|
|
13
|
+
export type { CollabConn } from './protocol.ts';
|
|
14
|
+
|
|
15
|
+
export interface Collab {
|
|
16
|
+
registry: Registry;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build the collab subsystem for this server instance. One registry +
|
|
21
|
+
* persistence binding per dev-server boot. The registry is also the surface
|
|
22
|
+
* the git-lifecycle path will call to force-snapshot dirty rooms (Phase 8
|
|
23
|
+
* Task 7).
|
|
24
|
+
*/
|
|
25
|
+
export function createCollab(ctx: Context, api: Api): Collab {
|
|
26
|
+
// Inverse of api.fileSlug — we have the URL slug, need the canonical
|
|
27
|
+
// repo-relative path the api expects. The current fileSlug() is destructive
|
|
28
|
+
// (loses the extension + the designRel prefix), so we round-trip by
|
|
29
|
+
// scanning loadAllComments — cheap enough at room-open time, and a fresh
|
|
30
|
+
// canvas with no comments yet just returns null (an empty Y.Doc is correct
|
|
31
|
+
// in that case).
|
|
32
|
+
//
|
|
33
|
+
// Task 3 will tighten this to a slug -> file lookup table maintained from
|
|
34
|
+
// the index/file-tree state so we don't need to read every JSON file.
|
|
35
|
+
async function fileForSlug(slug: string): Promise<string | null> {
|
|
36
|
+
const all = await api.loadAllComments();
|
|
37
|
+
for (const [file] of Object.entries(all)) {
|
|
38
|
+
if (api.fileSlug(file) === slug) return file;
|
|
39
|
+
}
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const persistence = createPersistence({ ctx, api, fileForSlug });
|
|
44
|
+
const registry = createRegistry(persistence);
|
|
45
|
+
|
|
46
|
+
return { registry };
|
|
47
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// DDR-051 persistence wiring — bridges Room callbacks to the existing JSON
|
|
2
|
+
// snapshots (Phase 6 _comments/<slug>.json) + Phase 5 annotations.svg + the
|
|
3
|
+
// new `.ydoc.bin` cache under _state/<slug>.ydoc.bin.
|
|
4
|
+
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import * as Y from 'yjs';
|
|
8
|
+
|
|
9
|
+
import type { Api } from '../api.ts';
|
|
10
|
+
import type { Context } from '../context.ts';
|
|
11
|
+
import { type RoomCallbacks, ensureStateDir } from './room.ts';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Y.Doc shared-type names. Frozen on Task 1 so client + server agree even
|
|
15
|
+
* before they're populated. Task 3 added `comments`; Task 5 adds `annotations`
|
|
16
|
+
* (a Y.Map holding the SVG string under the `svg` key — LWW shape, mirrors
|
|
17
|
+
* the current /_api/annotations PUT semantics).
|
|
18
|
+
*/
|
|
19
|
+
export const Y_TYPES = {
|
|
20
|
+
comments: 'comments',
|
|
21
|
+
annotations: 'annotations',
|
|
22
|
+
presentation: 'presentation',
|
|
23
|
+
} as const;
|
|
24
|
+
|
|
25
|
+
export interface PersistenceDeps {
|
|
26
|
+
ctx: Context;
|
|
27
|
+
api: Api;
|
|
28
|
+
/**
|
|
29
|
+
* Resolve a canvas slug back to the repo-relative `file` path the api
|
|
30
|
+
* expects. The collab WS sends slugs (URL-safe), the comments path
|
|
31
|
+
* round-trips through Api.fileSlug. Persistence callers may also call
|
|
32
|
+
* `noteFile(file)` to populate the lookup table — server-side write paths
|
|
33
|
+
* (comments + annotations) call this so the room can locate the canvas
|
|
34
|
+
* even when no prior JSON file existed.
|
|
35
|
+
*/
|
|
36
|
+
fileForSlug: (slug: string) => Promise<string | null>;
|
|
37
|
+
/** Best-effort cache primer — see fileForSlug above. */
|
|
38
|
+
noteFile?: (file: string) => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build the RoomCallbacks the registry plugs into createRoom().
|
|
43
|
+
*
|
|
44
|
+
* persistJson now serializes BOTH `comments` (Y.Array) and `annotations`
|
|
45
|
+
* (Y.Map → `svg` string) back to their existing on-disk formats. Each is
|
|
46
|
+
* skipped when the Y type is empty / unset — the JSON file stays whatever
|
|
47
|
+
* the prior legacy write produced.
|
|
48
|
+
*/
|
|
49
|
+
export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
50
|
+
const { ctx, api, fileForSlug } = deps;
|
|
51
|
+
const stateDir = ensureStateDir(ctx.paths.designRoot);
|
|
52
|
+
|
|
53
|
+
function ydocBinPath(slug: string): string {
|
|
54
|
+
return path.join(stateDir, `${slug}.ydoc.bin`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readBinary(slug: string): Promise<Uint8Array | null> {
|
|
58
|
+
try {
|
|
59
|
+
const file = Bun.file(ydocBinPath(slug));
|
|
60
|
+
if (!(await file.exists())) return null;
|
|
61
|
+
const buf = await file.arrayBuffer();
|
|
62
|
+
return new Uint8Array(buf);
|
|
63
|
+
} catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function seed(slug: string, doc: Y.Doc): Promise<void> {
|
|
69
|
+
// Step 1 — try the binary cache.
|
|
70
|
+
const binary = await readBinary(slug);
|
|
71
|
+
if (binary && binary.byteLength > 0) {
|
|
72
|
+
Y.applyUpdate(doc, binary);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Step 2 — seed each Y type from its existing on-disk source.
|
|
77
|
+
const file = await fileForSlug(slug);
|
|
78
|
+
if (!file) return;
|
|
79
|
+
|
|
80
|
+
const [comments, svg] = await Promise.all([
|
|
81
|
+
api.loadCommentsForFile(file),
|
|
82
|
+
api.loadAnnotations(file),
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
if (comments.length === 0 && !svg) return;
|
|
86
|
+
|
|
87
|
+
doc.transact(() => {
|
|
88
|
+
if (comments.length > 0) {
|
|
89
|
+
const arr = doc.getArray<unknown>(Y_TYPES.comments);
|
|
90
|
+
arr.push(comments);
|
|
91
|
+
}
|
|
92
|
+
if (svg && typeof svg === 'string') {
|
|
93
|
+
const map = doc.getMap<string>(Y_TYPES.annotations);
|
|
94
|
+
map.set('svg', svg);
|
|
95
|
+
}
|
|
96
|
+
}, 'seed');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function persistJson(slug: string, doc: Y.Doc): Promise<void> {
|
|
100
|
+
const file = await fileForSlug(slug);
|
|
101
|
+
if (!file) return;
|
|
102
|
+
|
|
103
|
+
// Comments — Y.Array projection back to JSON.
|
|
104
|
+
const arr = doc.getArray(Y_TYPES.comments);
|
|
105
|
+
if (arr.length > 0) {
|
|
106
|
+
const list = arr.toArray() as Parameters<Api['saveCommentsForFile']>[1];
|
|
107
|
+
await api.saveCommentsForFile(file, list);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Annotations — Y.Map.svg → annotations.svg file. Task 5.
|
|
111
|
+
const map = doc.getMap<unknown>(Y_TYPES.annotations);
|
|
112
|
+
const svg = map.get('svg');
|
|
113
|
+
if (typeof svg === 'string' && svg) {
|
|
114
|
+
await api.saveAnnotations(file, svg);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function persistBinary(slug: string, state: Uint8Array): Promise<void> {
|
|
119
|
+
await Bun.write(ydocBinPath(slug), state);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { seed, persistJson, persistBinary };
|
|
123
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// y-websocket binary protocol — message routing for the collab WS.
|
|
2
|
+
//
|
|
3
|
+
// Mirrors the reference implementation in y-websocket/bin/utils.js (140 lines),
|
|
4
|
+
// adapted to Bun's native ServerWebSocket. Two message types per the y-protocols
|
|
5
|
+
// spec: 0 = sync (Y.Doc state), 1 = awareness (cursors / presence). Both ride a
|
|
6
|
+
// varint framing of (messageType, payload).
|
|
7
|
+
//
|
|
8
|
+
// The functions are pure transport — Y.Doc lifecycle + persistence + room
|
|
9
|
+
// registry live in `room.ts` and `registry.ts`.
|
|
10
|
+
|
|
11
|
+
import * as decoding from 'lib0/decoding';
|
|
12
|
+
import * as encoding from 'lib0/encoding';
|
|
13
|
+
import * as awarenessProtocol from 'y-protocols/awareness';
|
|
14
|
+
import * as syncProtocol from 'y-protocols/sync';
|
|
15
|
+
import type * as Y from 'yjs';
|
|
16
|
+
|
|
17
|
+
export const MESSAGE_SYNC = 0;
|
|
18
|
+
export const MESSAGE_AWARENESS = 1;
|
|
19
|
+
|
|
20
|
+
export interface CollabConn {
|
|
21
|
+
/** Push a frame to this single peer. */
|
|
22
|
+
send(payload: Uint8Array): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Encode the initial sync handshake (sync step 1) + current awareness state for
|
|
27
|
+
* a freshly connected peer. The peer replies with sync step 2 (their missing
|
|
28
|
+
* updates) which we hand back to `handleMessage`.
|
|
29
|
+
*/
|
|
30
|
+
export function encodeHandshake(doc: Y.Doc, awareness: awarenessProtocol.Awareness): Uint8Array[] {
|
|
31
|
+
const frames: Uint8Array[] = [];
|
|
32
|
+
|
|
33
|
+
const sync = encoding.createEncoder();
|
|
34
|
+
encoding.writeVarUint(sync, MESSAGE_SYNC);
|
|
35
|
+
syncProtocol.writeSyncStep1(sync, doc);
|
|
36
|
+
frames.push(encoding.toUint8Array(sync));
|
|
37
|
+
|
|
38
|
+
const states = awareness.getStates();
|
|
39
|
+
if (states.size > 0) {
|
|
40
|
+
const aw = encoding.createEncoder();
|
|
41
|
+
encoding.writeVarUint(aw, MESSAGE_AWARENESS);
|
|
42
|
+
encoding.writeVarUint8Array(
|
|
43
|
+
aw,
|
|
44
|
+
awarenessProtocol.encodeAwarenessUpdate(awareness, Array.from(states.keys()))
|
|
45
|
+
);
|
|
46
|
+
frames.push(encoding.toUint8Array(aw));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return frames;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Encode a sync update frame (broadcast after a Y.Doc local update). */
|
|
53
|
+
export function encodeSyncUpdate(update: Uint8Array): Uint8Array {
|
|
54
|
+
const encoder = encoding.createEncoder();
|
|
55
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
56
|
+
syncProtocol.writeUpdate(encoder, update);
|
|
57
|
+
return encoding.toUint8Array(encoder);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Encode an awareness update frame (broadcast after applyAwarenessUpdate). */
|
|
61
|
+
export function encodeAwarenessFrame(
|
|
62
|
+
awareness: awarenessProtocol.Awareness,
|
|
63
|
+
changedClients: number[]
|
|
64
|
+
): Uint8Array {
|
|
65
|
+
const encoder = encoding.createEncoder();
|
|
66
|
+
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
|
|
67
|
+
encoding.writeVarUint8Array(
|
|
68
|
+
encoder,
|
|
69
|
+
awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients)
|
|
70
|
+
);
|
|
71
|
+
return encoding.toUint8Array(encoder);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Decode and dispatch one incoming binary frame. Returns an optional reply
|
|
76
|
+
* frame the caller MUST send back to the originating peer (sync step 2 from
|
|
77
|
+
* the server, in response to the peer's sync step 1).
|
|
78
|
+
*
|
|
79
|
+
* `conn` is the origin token threaded into awarenessProtocol so the awareness
|
|
80
|
+
* registry can attribute states to the right peer.
|
|
81
|
+
*/
|
|
82
|
+
export function handleMessage(
|
|
83
|
+
payload: Uint8Array,
|
|
84
|
+
doc: Y.Doc,
|
|
85
|
+
awareness: awarenessProtocol.Awareness,
|
|
86
|
+
conn: CollabConn
|
|
87
|
+
): Uint8Array | null {
|
|
88
|
+
const decoder = decoding.createDecoder(payload);
|
|
89
|
+
const messageType = decoding.readVarUint(decoder);
|
|
90
|
+
|
|
91
|
+
switch (messageType) {
|
|
92
|
+
case MESSAGE_SYNC: {
|
|
93
|
+
const encoder = encoding.createEncoder();
|
|
94
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
95
|
+
// readSyncMessage applies the peer's update to the doc and writes the
|
|
96
|
+
// response (sync step 2 / sync step 2 ack) into encoder.
|
|
97
|
+
syncProtocol.readSyncMessage(decoder, encoder, doc, conn);
|
|
98
|
+
if (encoding.length(encoder) > 1) return encoding.toUint8Array(encoder);
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
case MESSAGE_AWARENESS: {
|
|
102
|
+
awarenessProtocol.applyAwarenessUpdate(awareness, decoding.readVarUint8Array(decoder), conn);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
default:
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// Per-server registry of active collab rooms.
|
|
2
|
+
//
|
|
3
|
+
// Rooms are lazy: created on first connection for a given slug, retained while
|
|
4
|
+
// any peer is connected, torn down (with a final flush) when the last peer
|
|
5
|
+
// disconnects. The registry is also the surface the git-lifecycle handler will
|
|
6
|
+
// call into (Phase 8 Task 7) to force-snapshot every dirty room before a reload
|
|
7
|
+
// prompt — see DDR-051 §3.
|
|
8
|
+
|
|
9
|
+
import { Y_TYPES } from './persistence.ts';
|
|
10
|
+
import type { Room, RoomCallbacks } from './room.ts';
|
|
11
|
+
import { createRoom } from './room.ts';
|
|
12
|
+
|
|
13
|
+
export interface Registry {
|
|
14
|
+
/** Get-or-create. Reuses an existing room for the same slug. */
|
|
15
|
+
get(slug: string): Room;
|
|
16
|
+
/** Existence check — returns the live room if any, else null. NEVER creates. */
|
|
17
|
+
peek(slug: string): Room | null;
|
|
18
|
+
/**
|
|
19
|
+
* Phase 8 Task 3 bridge — inspector-channel writes (REST `/_api/comments*`
|
|
20
|
+
* or the legacy WS comments-add path) call this so the live Y.Array sees
|
|
21
|
+
* the change and broadcasts it to collab peers. No-op when no room is
|
|
22
|
+
* live; the next cold open will seed from the freshly-written JSON anyway.
|
|
23
|
+
*
|
|
24
|
+
* `comments` is the post-mutation JSON list (the same shape persistJson
|
|
25
|
+
* writes back). We replace the Y.Array contents wholesale inside a
|
|
26
|
+
* transaction tagged `'inspector-write'` so the doc.update broadcaster
|
|
27
|
+
* downstreams it to peers but skips the in-flight debounce loop.
|
|
28
|
+
*/
|
|
29
|
+
syncRoomFromComments(slug: string, comments: readonly unknown[]): void;
|
|
30
|
+
/**
|
|
31
|
+
* Phase 8 Task 5 bridge — same shape as syncRoomFromComments but for the
|
|
32
|
+
* `annotations` Y.Map. The PUT /_api/annotations endpoint passes the
|
|
33
|
+
* post-write SVG; the room replaces `Y.Map.svg` so collab peers see the
|
|
34
|
+
* updated stroke set without waiting for a cold-open re-seed.
|
|
35
|
+
*/
|
|
36
|
+
syncRoomFromAnnotations(slug: string, svg: string): void;
|
|
37
|
+
/** Flush every dirty room synchronously. DDR-051 branch-switch path. */
|
|
38
|
+
flushAll(): Promise<void>;
|
|
39
|
+
/** Tear down everything (e.g. on server shutdown). */
|
|
40
|
+
destroyAll(): Promise<void>;
|
|
41
|
+
/** Tear down a single room when its last peer leaves. */
|
|
42
|
+
drop(slug: string): Promise<void>;
|
|
43
|
+
/** Test/introspection. */
|
|
44
|
+
size(): number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createRegistry(callbacks: RoomCallbacks): Registry {
|
|
48
|
+
const rooms = new Map<string, Room>();
|
|
49
|
+
|
|
50
|
+
function get(slug: string): Room {
|
|
51
|
+
let room = rooms.get(slug);
|
|
52
|
+
if (!room) {
|
|
53
|
+
room = createRoom(slug, callbacks);
|
|
54
|
+
rooms.set(slug, room);
|
|
55
|
+
}
|
|
56
|
+
return room;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function peek(slug: string): Room | null {
|
|
60
|
+
return rooms.get(slug) ?? null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function syncRoomFromComments(slug: string, comments: readonly unknown[]): void {
|
|
64
|
+
const room = rooms.get(slug);
|
|
65
|
+
if (!room) return;
|
|
66
|
+
room.doc.transact(() => {
|
|
67
|
+
const arr = room.doc.getArray<unknown>(Y_TYPES.comments);
|
|
68
|
+
if (arr.length > 0) arr.delete(0, arr.length);
|
|
69
|
+
if (comments.length > 0) arr.push(comments as unknown[]);
|
|
70
|
+
}, 'inspector-write');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function syncRoomFromAnnotations(slug: string, svg: string): void {
|
|
74
|
+
const room = rooms.get(slug);
|
|
75
|
+
if (!room) return;
|
|
76
|
+
room.doc.transact(() => {
|
|
77
|
+
const map = room.doc.getMap<string>(Y_TYPES.annotations);
|
|
78
|
+
map.set('svg', svg);
|
|
79
|
+
}, 'inspector-write');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function flushAll(): Promise<void> {
|
|
83
|
+
await Promise.all(Array.from(rooms.values(), (r) => r.flush()));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function drop(slug: string): Promise<void> {
|
|
87
|
+
const room = rooms.get(slug);
|
|
88
|
+
if (!room) return;
|
|
89
|
+
if (room.size() > 0) return; // still active, leave it
|
|
90
|
+
rooms.delete(slug);
|
|
91
|
+
await room.destroy();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function destroyAll(): Promise<void> {
|
|
95
|
+
const all = Array.from(rooms.values());
|
|
96
|
+
rooms.clear();
|
|
97
|
+
await Promise.all(all.map((r) => r.destroy()));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
get,
|
|
102
|
+
peek,
|
|
103
|
+
syncRoomFromComments,
|
|
104
|
+
syncRoomFromAnnotations,
|
|
105
|
+
flushAll,
|
|
106
|
+
destroyAll,
|
|
107
|
+
drop,
|
|
108
|
+
size: () => rooms.size,
|
|
109
|
+
};
|
|
110
|
+
}
|