mrmd-server 0.2.1 → 0.2.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mrmd-server",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "HTTP server for mrmd - run mrmd in any browser, access from anywhere",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -3,6 +3,11 @@
3
3
  *
4
4
  * Ported from mrmd-electron/main.js to provide dynamic per-project sync servers.
5
5
  * Allows mrmd-server to handle files from any project, not just a fixed projectDir.
6
+ *
7
+ * Supports three sync modes (via SYNC_MODE env var):
8
+ * - legacy: spawn local mrmd-sync per project (default)
9
+ * - mirror: same as legacy (mirroring handled by orchestrator WS proxy)
10
+ * - relay_primary: use relay-client to sync project dir with cloud relay
6
11
  */
7
12
 
8
13
  import { spawn } from 'child_process';
@@ -19,6 +24,10 @@ import { SYNC_SERVER_MEMORY_MB, DIR_HASH_LENGTH } from 'mrmd-electron/src/config
19
24
  const __filename = fileURLToPath(import.meta.url);
20
25
  const __dirname = path.dirname(__filename);
21
26
 
27
+ const SYNC_MODE = process.env.SYNC_MODE || 'legacy';
28
+ const SYNC_RELAY_URL = process.env.SYNC_RELAY_URL || 'ws://localhost:3006';
29
+ const CLOUD_USER_ID = process.env.CLOUD_USER_ID || '';
30
+
22
31
  // Track active sync servers by directory hash
23
32
  const syncServers = new Map();
24
33
 
@@ -94,6 +103,9 @@ export function onSyncDeath(listener) {
94
103
  * Get or start a sync server for a project directory
95
104
  * Uses reference counting so multiple documents can share a sync server
96
105
  *
106
+ * In relay_primary mode, creates a relay client instead of a local sync server.
107
+ * The relay client syncs the project dir with the cloud relay bidirectionally.
108
+ *
97
109
  * @param {string} projectDir - The project directory to sync
98
110
  * @returns {Promise<{port: number, dir: string, refCount: number}>}
99
111
  */
@@ -104,10 +116,56 @@ export async function acquireSyncServer(projectDir) {
104
116
  if (syncServers.has(dirHash)) {
105
117
  const server = syncServers.get(dirHash);
106
118
  server.refCount++;
107
- console.log(`[sync] Reusing server for ${projectDir} on port ${server.port} (refCount: ${server.refCount})`);
119
+ console.log(`[sync] Reusing server for ${projectDir} (refCount: ${server.refCount})`);
108
120
  return server;
109
121
  }
110
122
 
123
+ // ── Relay-primary mode: use relay client instead of local sync server ──
124
+ if (SYNC_MODE === 'relay_primary' && CLOUD_USER_ID) {
125
+ // Derive project name from the directory name
126
+ const projectName = path.basename(projectDir);
127
+
128
+ console.log(`[sync] Starting relay client for ${projectDir} (project: ${projectName})`);
129
+
130
+ try {
131
+ // Dynamic import so this doesn't fail in environments without relay-client
132
+ const { createRelayClient } = await import('mrmd-sync/src/relay-client.js');
133
+
134
+ const relayClient = createRelayClient({
135
+ relayUrl: SYNC_RELAY_URL,
136
+ projectDir,
137
+ userId: CLOUD_USER_ID,
138
+ project: projectName,
139
+ writeDebounceMs: 1000,
140
+ log: (msg) => console.log(msg),
141
+ });
142
+
143
+ await relayClient.start();
144
+
145
+ // Return a server-like object that the rest of the code can use.
146
+ // port=0 signals that sync is handled by the relay (not a local WS server).
147
+ // The orchestrator's WS proxy routes sync traffic directly to the relay.
148
+ const server = {
149
+ proc: null,
150
+ port: 0,
151
+ dir: projectDir,
152
+ refCount: 1,
153
+ owned: true,
154
+ isRelay: true,
155
+ relayClient,
156
+ };
157
+ syncServers.set(dirHash, server);
158
+ console.log(`[sync] Relay client started for ${projectDir}`);
159
+ return server;
160
+ } catch (err) {
161
+ console.error(`[sync] Failed to start relay client for ${projectDir}: ${err.message}`);
162
+ console.warn(`[sync] Falling back to local sync server`);
163
+ // Fall through to local server below
164
+ }
165
+ }
166
+
167
+ // ── Legacy / mirror mode: spawn local mrmd-sync process ──
168
+
111
169
  // Check for existing server from a PID file (in case of restart)
112
170
  const syncStatePath = path.join(os.tmpdir(), `mrmd-sync-${dirHash}`, 'server.pid');
113
171
  try {
@@ -110,6 +110,39 @@
110
110
  const POST = (path, body) => apiCall('POST', path, body);
111
111
  const DELETE = (path) => apiCall('DELETE', path);
112
112
 
113
+ const DOC_EXTENSIONS = ['.md', '.qmd'];
114
+
115
+ function basenameFromPath(filePath = '') {
116
+ const normalized = String(filePath).replace(/\\/g, '/').replace(/\/+/g, '/');
117
+ const parts = normalized.split('/').filter(Boolean);
118
+ return parts.length ? parts[parts.length - 1] : '';
119
+ }
120
+
121
+ function dirnameFromPath(filePath = '') {
122
+ const normalized = String(filePath).replace(/\\/g, '/').replace(/\/+/g, '/');
123
+ const lastSlash = normalized.lastIndexOf('/');
124
+ if (lastSlash <= 0) return normalized.startsWith('/') ? '/' : '.';
125
+ return normalized.slice(0, lastSlash) || '/';
126
+ }
127
+
128
+ function stripDocExtension(fileName = '') {
129
+ const lower = fileName.toLowerCase();
130
+ for (const ext of DOC_EXTENSIONS) {
131
+ if (lower.endsWith(ext)) {
132
+ return fileName.slice(0, -ext.length);
133
+ }
134
+ }
135
+ return fileName;
136
+ }
137
+
138
+ function makeRuntimeIdFromVenv(venvPath = '', forceNew = false) {
139
+ const venvName = basenameFromPath(venvPath).replace(/^\.+/, '') || 'venv';
140
+ const projectName = basenameFromPath(dirnameFromPath(venvPath)).replace(/^\.+/, '') || 'project';
141
+ let name = `${projectName}:${venvName}`.replace(/[^a-zA-Z0-9-:]/g, '-');
142
+ if (forceNew) name += '-' + Date.now().toString(36).slice(-4);
143
+ return name;
144
+ }
145
+
113
146
  // ==========================================================================
114
147
  // WebSocket for Events
115
148
  // ==========================================================================
@@ -322,8 +355,20 @@
322
355
  GET(`/api/file/preview?path=${encodeURIComponent(filePath)}&lines=${lines || 40}`)
323
356
  .then(r => r.content),
324
357
 
325
- getFileInfo: (filePath) =>
326
- GET(`/api/file/info?path=${encodeURIComponent(filePath)}`),
358
+ getFileInfo: async (filePath) => {
359
+ try {
360
+ const info = await GET(`/api/file/info?path=${encodeURIComponent(filePath)}`);
361
+ return {
362
+ success: true,
363
+ ...info,
364
+ };
365
+ } catch (err) {
366
+ return {
367
+ success: false,
368
+ error: err.message,
369
+ };
370
+ }
371
+ },
327
372
 
328
373
  // ========================================================================
329
374
  // Venv creation (still useful for setup flows)
@@ -332,6 +377,109 @@
332
377
  createVenv: (venvPath) =>
333
378
  POST('/api/system/create-venv', { venvPath }),
334
379
 
380
+ installMrmdPython: (venvPath) =>
381
+ POST('/api/system/install-mrmd-python', { venvPath }),
382
+
383
+ startPython: async (venvPath, forceNew = false) => {
384
+ try {
385
+ const runtimeId = makeRuntimeIdFromVenv(venvPath, forceNew);
386
+ const cwd = dirnameFromPath(venvPath);
387
+ const result = await POST('/api/runtime', {
388
+ config: {
389
+ name: runtimeId,
390
+ language: 'python',
391
+ cwd,
392
+ venv: venvPath,
393
+ },
394
+ });
395
+
396
+ return {
397
+ success: true,
398
+ port: result.port,
399
+ runtimeId,
400
+ venv: result.venv || venvPath,
401
+ url: result.url,
402
+ };
403
+ } catch (err) {
404
+ return {
405
+ success: false,
406
+ error: err.message,
407
+ };
408
+ }
409
+ },
410
+
411
+ attachRuntime: async (runtimeId) => {
412
+ try {
413
+ const result = await POST(`/api/runtime/${encodeURIComponent(runtimeId)}/attach`, {});
414
+ return {
415
+ success: true,
416
+ port: result.port,
417
+ url: result.url,
418
+ venv: result.venv,
419
+ };
420
+ } catch (err) {
421
+ return {
422
+ success: false,
423
+ error: err.message,
424
+ };
425
+ }
426
+ },
427
+
428
+ openFile: async (filePath) => {
429
+ try {
430
+ // Ensure project is detected and sync server is available.
431
+ const project = await GET(`/api/project?path=${encodeURIComponent(filePath)}`);
432
+ const projectDir = project?.root || dirnameFromPath(filePath);
433
+
434
+ let syncPort = project?.syncPort;
435
+ if (!syncPort) {
436
+ const sync = await POST('/api/project/sync/acquire', { projectDir });
437
+ syncPort = sync.port;
438
+ }
439
+
440
+ // Mirror Electron behavior: track recent file usage.
441
+ try {
442
+ await POST('/api/system/recent', { file: filePath });
443
+ } catch (err) {
444
+ console.warn('[http-shim] Failed to update recent file:', err.message);
445
+ }
446
+
447
+ const fileName = basenameFromPath(filePath);
448
+ const docName = stripDocExtension(fileName);
449
+
450
+ return {
451
+ success: true,
452
+ syncPort,
453
+ docName,
454
+ pythonPort: null,
455
+ projectDir,
456
+ };
457
+ } catch (err) {
458
+ return {
459
+ success: false,
460
+ error: err.message,
461
+ };
462
+ }
463
+ },
464
+
465
+ listRuntimes: async () => {
466
+ try {
467
+ const runtimes = await GET('/api/runtime');
468
+ return { runtimes };
469
+ } catch (err) {
470
+ return { runtimes: [], error: err.message };
471
+ }
472
+ },
473
+
474
+ killRuntime: async (runtimeId) => {
475
+ try {
476
+ await DELETE(`/api/runtime/${encodeURIComponent(runtimeId)}`);
477
+ return { success: true };
478
+ } catch (err) {
479
+ return { success: false, error: err.message };
480
+ }
481
+ },
482
+
335
483
  // ========================================================================
336
484
  // PROJECT SERVICE
337
485
  // ========================================================================
@@ -374,7 +522,19 @@
374
522
 
375
523
  restart: (sessionName) => POST(`/api/runtime/${encodeURIComponent(sessionName)}/restart`, {}),
376
524
 
377
- forDocument: (documentPath) => POST('/api/runtime/for-document', { documentPath }),
525
+ forDocument: async (documentPath) => {
526
+ const result = await POST('/api/runtime/for-document', { documentPath });
527
+
528
+ // Backwards compatibility: legacy renderer expects a single Python session object.
529
+ // Unified runtime API returns { python, bash, r, julia, pty }.
530
+ if (result && typeof result === 'object' && !Array.isArray(result)) {
531
+ if (result.python && typeof result.python === 'object') {
532
+ return result.python;
533
+ }
534
+ }
535
+
536
+ return result;
537
+ },
378
538
 
379
539
  forDocumentLanguage: (documentPath, language) =>
380
540
  POST(`/api/runtime/for-document/${encodeURIComponent(language)}`, { documentPath }),