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.
- package/package.json +1 -1
- package/src/api/file.js +83 -0
- package/src/api/index.js +1 -0
- package/src/api/runtime.js +88 -0
- package/src/api/settings.js +4 -4
- package/src/api/voice.js +406 -0
- package/src/cloud-seed.js +377 -0
- package/src/relay-bridge.js +301 -0
- package/src/runtime-tunnel-client.js +734 -0
- package/src/server.js +286 -4
- package/src/sync-manager.js +91 -58
- package/static/http-shim.js +36 -84
|
@@ -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;
|