mrmd-server 0.2.3 → 0.2.5

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.
@@ -0,0 +1,377 @@
1
+ /**
2
+ * Cloud Seeding — materialize relay documents to the local filesystem
3
+ *
4
+ * When the editor container starts in cloud mode, this module fetches
5
+ * all the user's documents from the sync relay and writes them as .md
6
+ * files so they appear in the nav tree. It also writes Yjs binary
7
+ * snapshots so that local mrmd-sync loads the relay's exact Yjs state
8
+ * (avoiding Yjs content duplication when the bridge connects).
9
+ *
10
+ * Usage:
11
+ * import { seedFromRelay } from './cloud-seed.js';
12
+ * await seedFromRelay({
13
+ * relayUrl: 'http://localhost:3006',
14
+ * userId: '31bdffb9-...',
15
+ * homeDir: '/home/ubuntu',
16
+ * });
17
+ */
18
+
19
+ import { mkdirSync, writeFileSync, existsSync, readFileSync, readdirSync } from 'fs';
20
+ import { join, dirname } from 'path';
21
+ import { createHash } from 'crypto';
22
+ import { tmpdir } from 'os';
23
+
24
+ /**
25
+ * Seed the local filesystem with documents from the sync relay.
26
+ *
27
+ * @param {object} opts
28
+ * @param {string} opts.relayUrl - HTTP URL of the sync relay (e.g. 'http://localhost:3006')
29
+ * @param {string} opts.userId - User UUID
30
+ * @param {string} opts.homeDir - User's home directory (where projects are created)
31
+ * @returns {Promise<{seededProjects: string[], seededDocs: number, errors: string[]}>}
32
+ */
33
+ export async function seedFromRelay(opts) {
34
+ const { relayUrl, userId, homeDir } = opts;
35
+ const errors = [];
36
+ const seededProjects = [];
37
+ let seededDocs = 0;
38
+
39
+ console.log(`[cloud-seed] Seeding from relay ${relayUrl} for user ${userId}`);
40
+
41
+ // 1. Fetch all projects and their documents from the relay
42
+ let projectData;
43
+ try {
44
+ const listUrl = `${relayUrl}/api/documents/${encodeURIComponent(userId)}`;
45
+ const res = await fetch(listUrl, {
46
+ headers: { 'X-User-Id': userId },
47
+ signal: AbortSignal.timeout(15000),
48
+ });
49
+
50
+ if (!res.ok) {
51
+ const msg = `Failed to list documents: HTTP ${res.status}`;
52
+ console.error(`[cloud-seed] ${msg}`);
53
+ return { seededProjects: [], seededDocs: 0, errors: [msg] };
54
+ }
55
+
56
+ projectData = await res.json();
57
+ } catch (err) {
58
+ const msg = `Failed to fetch document list: ${err.message}`;
59
+ console.error(`[cloud-seed] ${msg}`);
60
+ return { seededProjects: [], seededDocs: 0, errors: [msg] };
61
+ }
62
+
63
+ if (!projectData?.projects) {
64
+ console.log('[cloud-seed] No projects found in relay');
65
+ return { seededProjects: [], seededDocs: 0, errors: [] };
66
+ }
67
+
68
+ // 2. For each project, fetch documents with content + Yjs state
69
+ for (const [projectName, projectInfo] of Object.entries(projectData.projects)) {
70
+ // Skip empty projects
71
+ if (!projectInfo.docCount || projectInfo.docCount === 0) continue;
72
+
73
+ // Skip 'desktop-e2e' test projects
74
+ if (projectName === 'desktop-e2e') continue;
75
+
76
+ console.log(`[cloud-seed] Seeding project "${projectName}" (${projectInfo.docCount} docs)`);
77
+
78
+ // Determine project directory
79
+ const projectDir = join(homeDir, projectName);
80
+
81
+ try {
82
+ // Fetch documents with content and Yjs state for this project
83
+ const docsUrl = `${relayUrl}/api/documents/${encodeURIComponent(userId)}/${encodeURIComponent(projectName)}?content=1&yjs=1`;
84
+ const docsRes = await fetch(docsUrl, {
85
+ headers: { 'X-User-Id': userId },
86
+ signal: AbortSignal.timeout(30000),
87
+ });
88
+
89
+ if (!docsRes.ok) {
90
+ errors.push(`Failed to fetch docs for ${projectName}: HTTP ${docsRes.status}`);
91
+ continue;
92
+ }
93
+
94
+ const docsData = await docsRes.json();
95
+ if (!docsData?.documents?.length) continue;
96
+
97
+ // Ensure project directory exists
98
+ mkdirSync(projectDir, { recursive: true });
99
+
100
+ // Create mrmd.md config if it doesn't exist
101
+ const mrmdPath = join(projectDir, 'mrmd.md');
102
+ if (!existsSync(mrmdPath)) {
103
+ const mrmdContent = `# ${projectName}\n\n\`\`\`yaml config\nname: "${projectName}"\nsession:\n python:\n venv: ".venv"\n\`\`\`\n`;
104
+ writeFileSync(mrmdPath, mrmdContent, 'utf8');
105
+ console.log(`[cloud-seed] Created ${mrmdPath}`);
106
+ }
107
+
108
+ // Compute dirHash for Yjs snapshot directory
109
+ const dirHash = createHash('sha256').update(projectDir).digest('hex').slice(0, 12);
110
+ const snapshotDir = join(tmpdir(), `mrmd-sync-${dirHash}`);
111
+ mkdirSync(snapshotDir, { recursive: true });
112
+
113
+ // Write each document
114
+ for (const doc of docsData.documents) {
115
+ try {
116
+ const filePath = join(projectDir, `${doc.docPath}.md`);
117
+ const fileDir = dirname(filePath);
118
+ mkdirSync(fileDir, { recursive: true });
119
+
120
+ // Write .md file (only if content changed or file doesn't exist)
121
+ const existingContent = existsSync(filePath)
122
+ ? readFileSync(filePath, 'utf8')
123
+ : null;
124
+
125
+ if (existingContent !== doc.content) {
126
+ writeFileSync(filePath, doc.content || '', 'utf8');
127
+ }
128
+
129
+ // Write Yjs snapshot (critical: prevents content duplication)
130
+ if (doc.yjsState) {
131
+ const safeSnapshotName = doc.docPath.replace(/\//g, '__').replace(/^_+/, '');
132
+ const snapshotPath = join(snapshotDir, `${safeSnapshotName}.yjs`);
133
+ writeFileSync(snapshotPath, doc.yjsState, 'utf8'); // Already base64
134
+ }
135
+
136
+ seededDocs++;
137
+ } catch (err) {
138
+ errors.push(`Failed to write ${doc.docPath}: ${err.message}`);
139
+ }
140
+ }
141
+
142
+ seededProjects.push(projectName);
143
+ console.log(`[cloud-seed] Seeded ${docsData.documents.length} docs to ${projectDir}`);
144
+ } catch (err) {
145
+ errors.push(`Failed to seed project ${projectName}: ${err.message}`);
146
+ }
147
+ }
148
+
149
+ console.log(`[cloud-seed] Done: ${seededProjects.length} projects, ${seededDocs} docs, ${errors.length} errors`);
150
+ if (errors.length > 0) {
151
+ console.warn('[cloud-seed] Errors:', errors);
152
+ }
153
+
154
+ return { seededProjects, seededDocs, errors };
155
+ }
156
+
157
+ /**
158
+ * Periodically poll the relay for new projects/documents and seed them.
159
+ * Returns a stop function to cancel the watcher.
160
+ *
161
+ * @param {object} opts
162
+ * @param {string} opts.relayUrl - HTTP URL of the sync relay
163
+ * @param {string} opts.userId - User UUID
164
+ * @param {string} opts.homeDir - User's home directory
165
+ * @param {number} [opts.intervalMs=30000] - Poll interval
166
+ * @param {function} [opts.onNewDocs] - Callback({project, docs[]}) when new docs are seeded
167
+ * @returns {{ stop: () => void }}
168
+ */
169
+ export function startProjectWatcher(opts) {
170
+ const { relayUrl, userId, homeDir, intervalMs = 30000, onNewDocs } = opts;
171
+
172
+ /** Set of "project/docPath" strings we've already seeded */
173
+ const seededSet = new Set();
174
+
175
+ // Pre-populate with what's already on disk
176
+ _scanExistingDocs(homeDir, seededSet);
177
+
178
+ let timer = null;
179
+ let running = false;
180
+
181
+ async function poll() {
182
+ if (running) return;
183
+ running = true;
184
+ try {
185
+ const listUrl = `${relayUrl}/api/documents/${encodeURIComponent(userId)}`;
186
+ const res = await fetch(listUrl, {
187
+ headers: { 'X-User-Id': userId },
188
+ signal: AbortSignal.timeout(10000),
189
+ });
190
+ if (!res.ok) return;
191
+ const projectData = await res.json();
192
+ if (!projectData?.projects) return;
193
+
194
+ for (const [projectName, projectInfo] of Object.entries(projectData.projects)) {
195
+ if (!projectInfo.docCount || projectName === 'desktop-e2e') continue;
196
+
197
+ // Check if any docs in this project are new
198
+ const knownCount = _countProjectDocs(projectName, seededSet);
199
+ if (knownCount >= projectInfo.docCount) continue;
200
+
201
+ // Fetch and seed new docs
202
+ console.log(`[cloud-seed:watcher] New docs detected in "${projectName}" (known: ${knownCount}, relay: ${projectInfo.docCount})`);
203
+ const docsUrl = `${relayUrl}/api/documents/${encodeURIComponent(userId)}/${encodeURIComponent(projectName)}?content=1&yjs=1`;
204
+ const docsRes = await fetch(docsUrl, {
205
+ headers: { 'X-User-Id': userId },
206
+ signal: AbortSignal.timeout(30000),
207
+ });
208
+ if (!docsRes.ok) continue;
209
+ const docsData = await docsRes.json();
210
+ if (!docsData?.documents?.length) continue;
211
+
212
+ const projectDir = join(homeDir, projectName);
213
+ mkdirSync(projectDir, { recursive: true });
214
+
215
+ // Create mrmd.md config if missing
216
+ const mrmdPath = join(projectDir, 'mrmd.md');
217
+ if (!existsSync(mrmdPath)) {
218
+ const mrmdContent = `# ${projectName}\n\n\`\`\`yaml config\nname: "${projectName}"\nsession:\n python:\n venv: ".venv"\n\`\`\`\n`;
219
+ writeFileSync(mrmdPath, mrmdContent, 'utf8');
220
+ }
221
+
222
+ const dirHash = createHash('sha256').update(projectDir).digest('hex').slice(0, 12);
223
+ const snapshotDir = join(tmpdir(), `mrmd-sync-${dirHash}`);
224
+ mkdirSync(snapshotDir, { recursive: true });
225
+
226
+ const newDocs = [];
227
+ for (const doc of docsData.documents) {
228
+ const key = `${projectName}/${doc.docPath}`;
229
+ if (seededSet.has(key)) continue;
230
+
231
+ try {
232
+ const filePath = join(projectDir, `${doc.docPath}.md`);
233
+ mkdirSync(dirname(filePath), { recursive: true });
234
+
235
+ const existingContent = existsSync(filePath)
236
+ ? readFileSync(filePath, 'utf8')
237
+ : null;
238
+ if (existingContent !== doc.content) {
239
+ writeFileSync(filePath, doc.content || '', 'utf8');
240
+ }
241
+
242
+ if (doc.yjsState) {
243
+ const safeSnapshotName = doc.docPath.replace(/\//g, '__').replace(/^_+/, '');
244
+ const snapshotPath = join(snapshotDir, `${safeSnapshotName}.yjs`);
245
+ writeFileSync(snapshotPath, doc.yjsState, 'utf8');
246
+ }
247
+
248
+ seededSet.add(key);
249
+ newDocs.push(doc.docPath);
250
+ } catch { /* ignore individual doc errors */ }
251
+ }
252
+
253
+ if (newDocs.length > 0) {
254
+ console.log(`[cloud-seed:watcher] Seeded ${newDocs.length} new docs for "${projectName}"`);
255
+ if (onNewDocs) {
256
+ try { onNewDocs({ project: projectName, projectDir, docs: newDocs }); } catch { /* ignore */ }
257
+ }
258
+ }
259
+ }
260
+ } catch (err) {
261
+ // Silent — don't spam logs on transient relay issues
262
+ } finally {
263
+ running = false;
264
+ }
265
+ }
266
+
267
+ /**
268
+ * Poll the catalog API to create stub files for docs that exist on machines
269
+ * but haven't been bridged yet (so they won't be in the documents table).
270
+ * This ensures the file browser shows all docs from all connected machines.
271
+ */
272
+ async function pollCatalog() {
273
+ try {
274
+ const catalogUrl = `${relayUrl}/api/catalog/${encodeURIComponent(userId)}`;
275
+ const res = await fetch(catalogUrl, {
276
+ headers: { 'X-User-Id': userId },
277
+ signal: AbortSignal.timeout(10000),
278
+ });
279
+ if (!res.ok) return;
280
+ const data = await res.json();
281
+ if (!data?.machines) return;
282
+
283
+ for (const machine of data.machines) {
284
+ for (const proj of machine.projects || []) {
285
+ const projectName = proj.name;
286
+ if (projectName === 'desktop-e2e') continue;
287
+
288
+ for (const doc of proj.documents || []) {
289
+ const key = `${projectName}/${doc.docPath}`;
290
+ if (seededSet.has(key)) continue;
291
+
292
+ // Create stub file so it appears in the nav tree
293
+ const projectDir = join(homeDir, projectName);
294
+ const filePath = join(projectDir, `${doc.docPath}.md`);
295
+
296
+ if (existsSync(filePath)) {
297
+ seededSet.add(key);
298
+ continue;
299
+ }
300
+
301
+ try {
302
+ mkdirSync(dirname(filePath), { recursive: true });
303
+
304
+ // Create mrmd.md config if missing
305
+ const mrmdPath = join(projectDir, 'mrmd.md');
306
+ if (!existsSync(mrmdPath)) {
307
+ const mrmdContent = `# ${projectName}\n\n\`\`\`yaml config\nname: "${projectName}"\nsession:\n python:\n venv: ".venv"\n\`\`\`\n`;
308
+ writeFileSync(mrmdPath, mrmdContent, 'utf8');
309
+ }
310
+
311
+ // Write empty stub — real content loads on-demand when user opens it
312
+ writeFileSync(filePath, '', 'utf8');
313
+ seededSet.add(key);
314
+ console.log(`[cloud-seed:catalog] Stub: ${projectName}/${doc.docPath}`);
315
+ } catch { /* ignore */ }
316
+ }
317
+ }
318
+ }
319
+ } catch {
320
+ // Silent
321
+ }
322
+ }
323
+
324
+ timer = setInterval(poll, intervalMs);
325
+ // Run first poll after a short delay (let initial seed + bridges settle)
326
+ setTimeout(poll, 5000);
327
+ // Also poll catalog periodically for stub creation
328
+ setInterval(pollCatalog, intervalMs * 2);
329
+ setTimeout(pollCatalog, 8000);
330
+
331
+ return {
332
+ stop() {
333
+ if (timer) { clearInterval(timer); timer = null; }
334
+ },
335
+ };
336
+ }
337
+
338
+ /** Scan existing .md files on disk and populate the seeded set */
339
+ function _scanExistingDocs(homeDir, seededSet) {
340
+ try {
341
+ const entries = readdirSync(homeDir, { withFileTypes: true });
342
+ for (const entry of entries) {
343
+ if (!entry.isDirectory() || entry.name.startsWith('.')) continue;
344
+ const projectName = entry.name;
345
+ const projectDir = join(homeDir, projectName);
346
+ _walkMd(projectDir, projectDir, projectName, seededSet);
347
+ }
348
+ } catch { /* ignore */ }
349
+ }
350
+
351
+ function _walkMd(dir, projectDir, projectName, seededSet) {
352
+ try {
353
+ const entries = readdirSync(dir, { withFileTypes: true });
354
+ for (const entry of entries) {
355
+ if (entry.name.startsWith('.') || entry.name === 'node_modules' ||
356
+ entry.name === '.venv' || entry.name === '__pycache__' ||
357
+ entry.name === '_assets') continue;
358
+ const full = join(dir, entry.name);
359
+ if (entry.isDirectory()) {
360
+ _walkMd(full, projectDir, projectName, seededSet);
361
+ } else if (entry.name.endsWith('.md') && entry.name !== 'mrmd.md') {
362
+ const rel = full.slice(projectDir.length + 1).replace(/\.md$/, '');
363
+ seededSet.add(`${projectName}/${rel}`);
364
+ }
365
+ }
366
+ } catch { /* ignore */ }
367
+ }
368
+
369
+ function _countProjectDocs(projectName, seededSet) {
370
+ let count = 0;
371
+ for (const key of seededSet) {
372
+ if (key.startsWith(projectName + '/')) count++;
373
+ }
374
+ return count;
375
+ }
376
+
377
+ export default { seedFromRelay, startProjectWatcher };
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Relay Bridge — bidirectional sync between local mrmd-sync and the cloud relay
3
+ *
4
+ * Runs inside the editor container. For each document, creates a DocBridge
5
+ * that forwards Yjs WebSocket messages between the local mrmd-sync server
6
+ * and the cloud relay. This keeps:
7
+ * - Local filesystem in sync with relay (via local mrmd-sync's file writer)
8
+ * - Relay in sync with local edits (via bridge forwarding)
9
+ * - Other devices (Electron, phone) see changes in real-time
10
+ *
11
+ * Uses X-User-Id header for auth (trusted internal network, host networking).
12
+ *
13
+ * Usage:
14
+ * import { RelayBridge } from './relay-bridge.js';
15
+ * const bridge = new RelayBridge({
16
+ * relayWsUrl: 'ws://localhost:3006',
17
+ * userId: '31bdffb9-...',
18
+ * });
19
+ * bridge.bridgeProject(localSyncPort, projectDir, projectName, docNames);
20
+ * bridge.stopAll();
21
+ */
22
+
23
+ import { WebSocket } from 'ws';
24
+
25
+ function encodePathSegments(value) {
26
+ return String(value).split('/').map(encodeURIComponent).join('/');
27
+ }
28
+
29
+ /**
30
+ * Bridges a single document between local mrmd-sync and cloud relay.
31
+ * Just forwards raw WS messages — no Yjs dependency needed.
32
+ */
33
+ class DocBridge {
34
+ constructor(opts) {
35
+ this.localUrl = opts.localUrl;
36
+ this.remoteUrl = opts.remoteUrl;
37
+ this.remoteHeaders = opts.remoteHeaders || {};
38
+ this.docName = opts.docName;
39
+
40
+ this.localWs = null;
41
+ this.remoteWs = null;
42
+ this._destroyed = false;
43
+ this._reconnectLocal = null;
44
+ this._reconnectRemote = null;
45
+ this._localReady = false;
46
+ this._remoteReady = false;
47
+ this._lastError = null;
48
+ this._startedAt = Date.now();
49
+ // Buffer messages when the other side isn't ready yet.
50
+ // Critical: without this, sync step 1/2 messages are dropped during
51
+ // the race between local and remote WS connections opening, causing
52
+ // the Yjs sync to never complete for many documents.
53
+ this._localBuffer = []; // messages from remote waiting for local
54
+ this._remoteBuffer = []; // messages from local waiting for remote
55
+ }
56
+
57
+ start() {
58
+ this._connectLocal();
59
+ this._connectRemote();
60
+ }
61
+
62
+ _connectLocal() {
63
+ if (this._destroyed) return;
64
+ try {
65
+ this.localWs = new WebSocket(this.localUrl);
66
+ } catch {
67
+ this._scheduleReconnect('local');
68
+ return;
69
+ }
70
+
71
+ this.localWs.binaryType = 'arraybuffer';
72
+
73
+ this.localWs.on('open', () => {
74
+ this._localReady = true;
75
+ // Flush buffered messages from remote that arrived before local was ready
76
+ for (const msg of this._localBuffer) {
77
+ try { this.localWs.send(msg.data, { binary: msg.isBinary }); } catch { /* ignore */ }
78
+ }
79
+ this._localBuffer = [];
80
+ });
81
+
82
+ this.localWs.on('message', (data, isBinary) => {
83
+ if (this._remoteReady && this.remoteWs?.readyState === WebSocket.OPEN) {
84
+ try { this.remoteWs.send(data, { binary: isBinary }); } catch { /* ignore */ }
85
+ } else {
86
+ this._remoteBuffer.push({ data, isBinary });
87
+ }
88
+ });
89
+
90
+ this.localWs.on('close', () => {
91
+ this._localReady = false;
92
+ this._localBuffer = [];
93
+ if (!this._destroyed) this._scheduleReconnect('local');
94
+ });
95
+
96
+ this.localWs.on('error', (err) => {
97
+ this._lastError = `local:${err?.code || err?.message || 'error'}`;
98
+ });
99
+ }
100
+
101
+ _connectRemote() {
102
+ if (this._destroyed) return;
103
+ try {
104
+ this.remoteWs = new WebSocket(this.remoteUrl, { headers: this.remoteHeaders });
105
+ } catch {
106
+ this._scheduleReconnect('remote');
107
+ return;
108
+ }
109
+
110
+ this.remoteWs.binaryType = 'arraybuffer';
111
+
112
+ this.remoteWs.on('open', () => {
113
+ this._remoteReady = true;
114
+ // Flush buffered messages from local that arrived before remote was ready
115
+ for (const msg of this._remoteBuffer) {
116
+ try { this.remoteWs.send(msg.data, { binary: msg.isBinary }); } catch { /* ignore */ }
117
+ }
118
+ this._remoteBuffer = [];
119
+ });
120
+
121
+ this.remoteWs.on('message', (data, isBinary) => {
122
+ if (this._localReady && this.localWs?.readyState === WebSocket.OPEN) {
123
+ try { this.localWs.send(data, { binary: isBinary }); } catch { /* ignore */ }
124
+ } else {
125
+ this._localBuffer.push({ data, isBinary });
126
+ }
127
+ });
128
+
129
+ this.remoteWs.on('close', () => {
130
+ this._remoteReady = false;
131
+ this._remoteBuffer = [];
132
+ if (!this._destroyed) this._scheduleReconnect('remote');
133
+ });
134
+
135
+ this.remoteWs.on('error', (err) => {
136
+ this._lastError = `remote:${err?.code || err?.message || 'error'}`;
137
+ });
138
+ }
139
+
140
+ _scheduleReconnect(which) {
141
+ if (this._destroyed) return;
142
+ const key = which === 'local' ? '_reconnectLocal' : '_reconnectRemote';
143
+ if (this[key]) return;
144
+ this[key] = setTimeout(() => {
145
+ this[key] = null;
146
+ if (which === 'local') this._connectLocal();
147
+ else this._connectRemote();
148
+ }, 3000);
149
+ }
150
+
151
+ getStatus() {
152
+ return {
153
+ docName: this.docName,
154
+ localReady: this._localReady,
155
+ remoteReady: this._remoteReady,
156
+ connected: this._localReady && this._remoteReady,
157
+ lastError: this._lastError,
158
+ };
159
+ }
160
+
161
+ async stop() {
162
+ this._destroyed = true;
163
+ clearTimeout(this._reconnectLocal);
164
+ clearTimeout(this._reconnectRemote);
165
+ try { this.localWs?.close(); } catch { /* ignore */ }
166
+ try { this.remoteWs?.close(); } catch { /* ignore */ }
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Manages document bridges between local mrmd-sync and the cloud relay.
172
+ */
173
+ export class RelayBridge {
174
+ /**
175
+ * @param {object} opts
176
+ * @param {string} opts.relayWsUrl - WebSocket URL of relay (e.g. 'ws://localhost:3006')
177
+ * @param {string} opts.userId - User UUID for auth + doc routing
178
+ */
179
+ constructor(opts) {
180
+ this.relayWsUrl = opts.relayWsUrl;
181
+ this.userId = opts.userId;
182
+
183
+ /** @type {Map<string, { bridges: Map<string, DocBridge>, port: number, projectName: string }>} */
184
+ this._projects = new Map();
185
+ }
186
+
187
+ /**
188
+ * Bridge a local project's sync server to the cloud relay.
189
+ *
190
+ * @param {number} localSyncPort - Local mrmd-sync port
191
+ * @param {string} projectDir - Local project directory
192
+ * @param {string} projectName - Project name/slug
193
+ * @param {string[]} docNames - Document names to bridge
194
+ */
195
+ bridgeProject(localSyncPort, projectDir, projectName, docNames = []) {
196
+ if (this._projects.has(projectDir)) {
197
+ // Already bridged — add any new docs
198
+ const existing = this._projects.get(projectDir);
199
+ for (const docName of docNames) {
200
+ if (!existing.bridges.has(docName)) {
201
+ this._bridgeDoc(projectDir, localSyncPort, projectName, docName);
202
+ }
203
+ }
204
+ return;
205
+ }
206
+
207
+ console.log(`[relay-bridge] Bridging "${projectName}" (port ${localSyncPort}, ${docNames.length} docs)`);
208
+
209
+ const bridges = new Map();
210
+ this._projects.set(projectDir, { bridges, port: localSyncPort, projectName });
211
+
212
+ for (const docName of docNames) {
213
+ this._bridgeDoc(projectDir, localSyncPort, projectName, docName);
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Add a single document to an existing project bridge.
219
+ */
220
+ bridgeDoc(projectDir, docName) {
221
+ const project = this._projects.get(projectDir);
222
+ if (!project || project.bridges.has(docName)) return;
223
+ this._bridgeDoc(projectDir, project.port, project.projectName, docName);
224
+ }
225
+
226
+ _bridgeDoc(projectDir, localSyncPort, projectName, docName) {
227
+ const project = this._projects.get(projectDir);
228
+ if (!project) return;
229
+
230
+ const encodedDoc = encodePathSegments(docName);
231
+ const encodedProject = encodePathSegments(projectName);
232
+ const encodedUserId = encodeURIComponent(this.userId);
233
+
234
+ const localUrl = `ws://127.0.0.1:${localSyncPort}/${encodedDoc}`;
235
+ const remoteUrl = `${this.relayWsUrl}/sync/${encodedUserId}/${encodedProject}/${encodedDoc}`;
236
+
237
+ const bridge = new DocBridge({
238
+ localUrl,
239
+ remoteUrl,
240
+ remoteHeaders: { 'X-User-Id': this.userId },
241
+ docName,
242
+ });
243
+
244
+ bridge.start();
245
+ project.bridges.set(docName, bridge);
246
+ }
247
+
248
+ /**
249
+ * Stop syncing a specific project.
250
+ */
251
+ async stopProject(projectDir) {
252
+ const project = this._projects.get(projectDir);
253
+ if (!project) return;
254
+
255
+ for (const bridge of project.bridges.values()) {
256
+ await bridge.stop();
257
+ }
258
+ project.bridges.clear();
259
+ this._projects.delete(projectDir);
260
+ console.log(`[relay-bridge] Stopped project: ${projectDir}`);
261
+ }
262
+
263
+ /**
264
+ * Stop all project bridges.
265
+ */
266
+ async stopAll() {
267
+ for (const projectDir of [...this._projects.keys()]) {
268
+ await this.stopProject(projectDir);
269
+ }
270
+ console.log('[relay-bridge] All bridges stopped');
271
+ }
272
+
273
+ /**
274
+ * Get sync status.
275
+ */
276
+ getStatus() {
277
+ const projects = [];
278
+ let connectedDocs = 0;
279
+ let totalDocs = 0;
280
+
281
+ for (const [dir, info] of this._projects) {
282
+ const docs = [];
283
+ for (const [name, bridge] of info.bridges) {
284
+ const status = bridge.getStatus();
285
+ docs.push(status);
286
+ totalDocs++;
287
+ if (status.connected) connectedDocs++;
288
+ }
289
+ projects.push({
290
+ dir,
291
+ projectName: info.projectName,
292
+ port: info.port,
293
+ documents: docs,
294
+ });
295
+ }
296
+
297
+ return { projects, connectedDocs, totalDocs };
298
+ }
299
+ }
300
+
301
+ export default RelayBridge;