mrmd-server 0.1.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/src/events.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Event bus for server-side events that need to be pushed to clients
3
+ *
4
+ * Events:
5
+ * - files-update: File list changed
6
+ * - venv-found: Venv discovered during scan
7
+ * - venv-scan-done: Venv scan complete
8
+ * - project:changed: Project files changed
9
+ * - sync-server-died: Sync server crashed
10
+ */
11
+
12
+ import { EventEmitter } from 'events';
13
+
14
+ export class EventBus extends EventEmitter {
15
+ constructor() {
16
+ super();
17
+ this.setMaxListeners(100); // Allow many WebSocket connections
18
+ }
19
+
20
+ /**
21
+ * Emit an event to all connected clients
22
+ * @param {string} event - Event name
23
+ * @param {any} data - Event data
24
+ */
25
+ broadcast(event, data) {
26
+ this.emit('broadcast', { event, data });
27
+ }
28
+
29
+ // Convenience methods for specific events
30
+
31
+ filesUpdated(files) {
32
+ this.broadcast('files-update', { files });
33
+ }
34
+
35
+ venvFound(venv) {
36
+ this.broadcast('venv-found', venv);
37
+ }
38
+
39
+ venvScanDone() {
40
+ this.broadcast('venv-scan-done', {});
41
+ }
42
+
43
+ projectChanged(projectRoot) {
44
+ this.broadcast('project:changed', { projectRoot });
45
+ }
46
+
47
+ syncServerDied(data) {
48
+ this.broadcast('sync-server-died', data);
49
+ }
50
+ }
package/src/index.js ADDED
@@ -0,0 +1,9 @@
1
+ /**
2
+ * mrmd-server - HTTP server for mrmd
3
+ *
4
+ * Provides the same API as Electron's main process, but over HTTP.
5
+ * This allows running mrmd in any browser, accessing from anywhere.
6
+ */
7
+
8
+ export { createServer, startServer } from './server.js';
9
+ export { EventBus } from './events.js';
@@ -0,0 +1,118 @@
1
+ /**
2
+ * mrmd-server v2 - Uses shared handlers from mrmd-electron
3
+ *
4
+ * This version imports the handler definitions from mrmd-electron,
5
+ * so any new handlers added there automatically work here.
6
+ */
7
+
8
+ import express from 'express';
9
+ import cors from 'cors';
10
+ import { createServer as createHttpServer } from 'http';
11
+ import { WebSocketServer } from 'ws';
12
+ import path from 'path';
13
+ import { fileURLToPath } from 'url';
14
+
15
+ import { createAuthMiddleware, generateToken } from './auth.js';
16
+ import { EventBus } from './events.js';
17
+ import { setupWebSocket } from './websocket.js';
18
+
19
+ // Import shared handlers from mrmd-electron
20
+ import {
21
+ handlers,
22
+ registerHttpHandlers,
23
+ generateHttpShim,
24
+ } from '../../mrmd-electron/src/handlers/index.js';
25
+
26
+ // Import services (these could also be shared)
27
+ import { ProjectService } from '../../mrmd-electron/src/services/project-service.js';
28
+ import { FileService } from '../../mrmd-electron/src/services/file-service.js';
29
+ // ... other services
30
+
31
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
+
33
+ /**
34
+ * Create the mrmd server using shared handlers
35
+ */
36
+ export function createServerV2(config) {
37
+ const {
38
+ port = 8080,
39
+ host = '0.0.0.0',
40
+ projectDir,
41
+ token = generateToken(),
42
+ noAuth = false,
43
+ } = config;
44
+
45
+ if (!projectDir) {
46
+ throw new Error('projectDir is required');
47
+ }
48
+
49
+ const app = express();
50
+ const server = createHttpServer(app);
51
+ const eventBus = new EventBus();
52
+
53
+ // Initialize services (same as Electron would)
54
+ const projectService = new ProjectService(projectDir);
55
+ const fileService = new FileService(projectDir);
56
+ // ... initialize other services
57
+
58
+ // Create context (same shape as Electron's context)
59
+ const context = {
60
+ projectDir: path.resolve(projectDir),
61
+ projectService,
62
+ fileService,
63
+ // sessionService,
64
+ // bashService,
65
+ // assetService,
66
+ // venvService,
67
+ // pythonService,
68
+ // runtimeService,
69
+ eventBus,
70
+ // shell: null, // No shell in server mode
71
+ };
72
+
73
+ // Middleware
74
+ app.use(cors({ origin: true, credentials: true }));
75
+ app.use(express.json({ limit: '50mb' }));
76
+
77
+ // Auth
78
+ const authMiddleware = createAuthMiddleware(token, noAuth);
79
+ app.use('/api', authMiddleware);
80
+
81
+ // Health check
82
+ app.get('/health', (req, res) => res.json({ status: 'ok' }));
83
+
84
+ // Register ALL handlers from mrmd-electron automatically!
85
+ registerHttpHandlers(app, context);
86
+
87
+ // Serve auto-generated http-shim.js
88
+ app.get('/http-shim.js', (req, res) => {
89
+ res.type('application/javascript');
90
+ res.send(generateHttpShim());
91
+ });
92
+
93
+ // WebSocket for events
94
+ const wss = new WebSocketServer({ server, path: '/events' });
95
+ setupWebSocket(wss, eventBus, token, noAuth);
96
+
97
+ return {
98
+ app,
99
+ server,
100
+ context,
101
+ token,
102
+
103
+ async start() {
104
+ return new Promise((resolve) => {
105
+ server.listen(port, host, () => {
106
+ console.log(`mrmd-server running at http://${host}:${port}`);
107
+ console.log(`Token: ${token}`);
108
+ resolve({ url: `http://${host}:${port}`, token });
109
+ });
110
+ });
111
+ },
112
+
113
+ async stop() {
114
+ wss.clients.forEach(client => client.close());
115
+ return new Promise((resolve) => server.close(resolve));
116
+ },
117
+ };
118
+ }
package/src/server.js ADDED
@@ -0,0 +1,297 @@
1
+ /**
2
+ * Express server that mirrors Electron's electronAPI
3
+ */
4
+
5
+ import express from 'express';
6
+ import cors from 'cors';
7
+ import { createServer as createHttpServer } from 'http';
8
+ import { WebSocketServer } from 'ws';
9
+ import path from 'path';
10
+ import fs from 'fs/promises';
11
+ import { existsSync } from 'fs';
12
+ import { fileURLToPath } from 'url';
13
+
14
+ import { createAuthMiddleware, generateToken } from './auth.js';
15
+ import { EventBus } from './events.js';
16
+ import { createProjectRoutes } from './api/project.js';
17
+ import { createSessionRoutes } from './api/session.js';
18
+ import { createBashRoutes } from './api/bash.js';
19
+ import { createFileRoutes } from './api/file.js';
20
+ import { createAssetRoutes } from './api/asset.js';
21
+ import { createSystemRoutes } from './api/system.js';
22
+ import { createRuntimeRoutes } from './api/runtime.js';
23
+ import { createJuliaRoutes } from './api/julia.js';
24
+ import { createPtyRoutes } from './api/pty.js';
25
+ import { createNotebookRoutes } from './api/notebook.js';
26
+ import { setupWebSocket } from './websocket.js';
27
+
28
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
29
+
30
+ /**
31
+ * @typedef {Object} ServerConfig
32
+ * @property {number} port - HTTP port (default: 8080)
33
+ * @property {string} host - Bind host (default: '0.0.0.0')
34
+ * @property {string} projectDir - Root project directory
35
+ * @property {string} [token] - Auth token (generated if not provided)
36
+ * @property {boolean} [noAuth] - Disable auth (for local dev only!)
37
+ * @property {string} [staticDir] - Custom static files directory
38
+ * @property {string} [electronDir] - Path to mrmd-electron for index.html
39
+ * @property {number} [syncPort] - mrmd-sync port (default: 4444)
40
+ * @property {number} [pythonPort] - mrmd-python port (default: 8000)
41
+ * @property {number} [aiPort] - mrmd-ai port (default: 51790)
42
+ */
43
+
44
+ /**
45
+ * Create the mrmd server (async)
46
+ * @param {ServerConfig} config
47
+ */
48
+ export async function createServer(config) {
49
+ const {
50
+ port = 8080,
51
+ host = '0.0.0.0',
52
+ projectDir,
53
+ token = generateToken(),
54
+ noAuth = false,
55
+ staticDir,
56
+ electronDir,
57
+ syncPort = 4444,
58
+ pythonPort = 8000,
59
+ aiPort = 51790,
60
+ } = config;
61
+
62
+ if (!projectDir) {
63
+ throw new Error('projectDir is required');
64
+ }
65
+
66
+ const app = express();
67
+ const server = createHttpServer(app);
68
+ const eventBus = new EventBus();
69
+
70
+ // Service context passed to all route handlers
71
+ const context = {
72
+ projectDir: path.resolve(projectDir),
73
+ syncPort,
74
+ pythonPort,
75
+ aiPort,
76
+ eventBus,
77
+ // These will be populated by services
78
+ syncProcess: null,
79
+ pythonProcess: null,
80
+ monitorProcesses: new Map(),
81
+ watchers: new Map(),
82
+ };
83
+
84
+ // Middleware
85
+ app.use(cors({
86
+ origin: true,
87
+ credentials: true,
88
+ }));
89
+ app.use(express.json({ limit: '50mb' }));
90
+
91
+ // Auth middleware (skip for static files and health check)
92
+ const authMiddleware = createAuthMiddleware(token, noAuth);
93
+ app.use('/api', authMiddleware);
94
+
95
+ // Health check (no auth)
96
+ app.get('/health', (req, res) => {
97
+ res.json({ status: 'ok', version: '0.1.0' });
98
+ });
99
+
100
+ // Token info endpoint (no auth - used to validate tokens)
101
+ app.get('/auth/validate', (req, res) => {
102
+ const providedToken = req.query.token || req.headers.authorization?.replace('Bearer ', '');
103
+ if (noAuth || providedToken === token) {
104
+ res.json({ valid: true });
105
+ } else {
106
+ res.status(401).json({ valid: false });
107
+ }
108
+ });
109
+
110
+ // API routes - mirror electronAPI structure
111
+ app.use('/api/project', createProjectRoutes(context));
112
+ app.use('/api/session', createSessionRoutes(context));
113
+ app.use('/api/bash', createBashRoutes(context));
114
+ app.use('/api/file', createFileRoutes(context));
115
+ app.use('/api/asset', createAssetRoutes(context));
116
+ app.use('/api/system', createSystemRoutes(context));
117
+ app.use('/api/runtime', createRuntimeRoutes(context));
118
+ app.use('/api/julia', createJuliaRoutes(context));
119
+ app.use('/api/pty', createPtyRoutes(context));
120
+ app.use('/api/notebook', createNotebookRoutes(context));
121
+
122
+ // Serve http-shim.js
123
+ app.get('/http-shim.js', (req, res) => {
124
+ res.sendFile(path.join(__dirname, '../static/http-shim.js'));
125
+ });
126
+
127
+ // Find mrmd-electron directory for UI assets
128
+ const electronPath = electronDir || findElectronDir(__dirname);
129
+
130
+ if (electronPath) {
131
+ // Serve mrmd-electron assets (fonts, icons)
132
+ app.use('/assets', express.static(path.join(electronPath, 'assets')));
133
+
134
+ // Serve mrmd-editor dist
135
+ const editorDistPath = path.join(electronPath, '../mrmd-editor/dist');
136
+ app.use('/dist', express.static(editorDistPath));
137
+
138
+ // Serve transformed index.html at root
139
+ app.get('/', async (req, res) => {
140
+ try {
141
+ const indexPath = path.join(electronPath, 'index.html');
142
+ let html = await fs.readFile(indexPath, 'utf-8');
143
+
144
+ // Transform for browser mode:
145
+ // 1. Inject http-shim.js as first script in head
146
+ // 2. Update CSP to allow HTTP connections to this server
147
+ html = transformIndexHtml(html, host, port);
148
+
149
+ res.type('html').send(html);
150
+ } catch (err) {
151
+ console.error('[index.html]', err);
152
+ res.sendFile(path.join(__dirname, '../static/index.html'));
153
+ }
154
+ });
155
+ } else {
156
+ // Fallback: serve placeholder
157
+ console.warn('[server] mrmd-electron not found, serving placeholder UI');
158
+ app.use(express.static(path.join(__dirname, '../static')));
159
+ }
160
+
161
+ // Serve custom static files if provided
162
+ if (staticDir) {
163
+ app.use(express.static(staticDir));
164
+ }
165
+
166
+ // WebSocket for push events
167
+ const wss = new WebSocketServer({ server, path: '/events' });
168
+ setupWebSocket(wss, eventBus, token, noAuth);
169
+
170
+ return {
171
+ app,
172
+ server,
173
+ context,
174
+ eventBus,
175
+ token,
176
+ electronPath,
177
+
178
+ /**
179
+ * Start the server
180
+ */
181
+ async start() {
182
+ return new Promise((resolve) => {
183
+ server.listen(port, host, () => {
184
+ const url = `http://${host === '0.0.0.0' ? 'localhost' : host}:${port}`;
185
+ console.log('');
186
+ console.log('\x1b[36m mrmd-server\x1b[0m');
187
+ console.log(' ' + '─'.repeat(50));
188
+ console.log(` Server: ${url}`);
189
+ console.log(` Project: ${context.projectDir}`);
190
+ if (electronPath) {
191
+ console.log(` UI: ${electronPath}`);
192
+ }
193
+ if (!noAuth) {
194
+ console.log(` Token: ${token}`);
195
+ console.log('');
196
+ console.log(` \x1b[33mAccess URL:\x1b[0m`);
197
+ console.log(` ${url}?token=${token}`);
198
+ }
199
+ console.log('');
200
+ resolve({ url, token });
201
+ });
202
+ });
203
+ },
204
+
205
+ /**
206
+ * Stop the server
207
+ */
208
+ async stop() {
209
+ // Clean up watchers
210
+ for (const watcher of context.watchers.values()) {
211
+ await watcher.close();
212
+ }
213
+
214
+ // Kill child processes
215
+ if (context.syncProcess) {
216
+ context.syncProcess.kill();
217
+ }
218
+ if (context.pythonProcess) {
219
+ context.pythonProcess.kill();
220
+ }
221
+ for (const proc of context.monitorProcesses.values()) {
222
+ proc.kill();
223
+ }
224
+
225
+ // Close WebSocket connections
226
+ wss.clients.forEach(client => client.close());
227
+
228
+ // Close server
229
+ return new Promise((resolve) => {
230
+ server.close(resolve);
231
+ });
232
+ },
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Find mrmd-electron directory
238
+ */
239
+ function findElectronDir(fromDir) {
240
+ const candidates = [
241
+ path.join(fromDir, '../../mrmd-electron'),
242
+ path.join(fromDir, '../../../mrmd-electron'),
243
+ path.join(process.cwd(), '../mrmd-electron'),
244
+ path.join(process.cwd(), 'mrmd-electron'),
245
+ // In npx/installed context, it might be in node_modules
246
+ path.join(fromDir, '../../node_modules/mrmd-electron'),
247
+ path.join(process.cwd(), 'node_modules/mrmd-electron'),
248
+ ];
249
+
250
+ for (const candidate of candidates) {
251
+ const indexPath = path.join(candidate, 'index.html');
252
+ if (existsSync(indexPath)) {
253
+ return path.resolve(candidate);
254
+ }
255
+ }
256
+
257
+ return null;
258
+ }
259
+
260
+ /**
261
+ * Transform index.html for browser mode
262
+ * - Inject http-shim.js as first script
263
+ * - Update CSP to allow HTTP connections
264
+ */
265
+ function transformIndexHtml(html, host, port) {
266
+ // 1. Inject http-shim.js right after <head>
267
+ const shimScript = `
268
+ <!-- HTTP shim for browser mode (injected by mrmd-server) -->
269
+ <script src="/http-shim.js"></script>
270
+ `;
271
+ html = html.replace('<head>', '<head>' + shimScript);
272
+
273
+ // 2. Update CSP to allow connections to this server and any host
274
+ // Replace the strict CSP with a more permissive one for HTTP mode
275
+ const browserCSP = `default-src 'self' 'unsafe-inline' 'unsafe-eval' data: blob: ws: wss: http: https:; connect-src 'self' ws: wss: http: https: data: blob:; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://esm.sh https://unpkg.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com blob: http:; font-src 'self' https://fonts.gstatic.com https://www.openresponses.org data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https:; img-src 'self' data: blob: https: http:; frame-src 'self' blob: data:`;
276
+
277
+ html = html.replace(
278
+ /<meta http-equiv="Content-Security-Policy" content="[^"]*">/,
279
+ `<meta http-equiv="Content-Security-Policy" content="${browserCSP}">`
280
+ );
281
+
282
+ // 3. Remove Electron-specific CSS (window drag regions)
283
+ html = html.replace(/-webkit-app-region:\s*drag;/g, '/* -webkit-app-region: drag; */');
284
+ html = html.replace(/-webkit-app-region:\s*no-drag;/g, '/* -webkit-app-region: no-drag; */');
285
+
286
+ return html;
287
+ }
288
+
289
+ /**
290
+ * Convenience function to create and start server
291
+ * @param {ServerConfig} config
292
+ */
293
+ export async function startServer(config) {
294
+ const server = await createServer(config);
295
+ await server.start();
296
+ return server;
297
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * WebSocket handler for push events
3
+ *
4
+ * Clients connect to /events?token=xxx and receive JSON messages:
5
+ * { "event": "project:changed", "data": { ... } }
6
+ */
7
+
8
+ import { validateWsToken } from './auth.js';
9
+
10
+ /**
11
+ * Setup WebSocket server
12
+ * @param {import('ws').WebSocketServer} wss
13
+ * @param {import('./events.js').EventBus} eventBus
14
+ * @param {string} validToken
15
+ * @param {boolean} noAuth
16
+ */
17
+ export function setupWebSocket(wss, eventBus, validToken, noAuth) {
18
+ // Track connected clients
19
+ const clients = new Set();
20
+
21
+ wss.on('connection', (ws, req) => {
22
+ // Validate token from query string
23
+ const url = new URL(req.url, 'http://localhost');
24
+ const token = url.searchParams.get('token');
25
+
26
+ if (!validateWsToken(token, validToken, noAuth)) {
27
+ ws.close(4001, 'Invalid token');
28
+ return;
29
+ }
30
+
31
+ clients.add(ws);
32
+ console.log(`[WS] Client connected (${clients.size} total)`);
33
+
34
+ // Send welcome message
35
+ ws.send(JSON.stringify({
36
+ event: 'connected',
37
+ data: { message: 'Connected to mrmd-server events' },
38
+ }));
39
+
40
+ ws.on('close', () => {
41
+ clients.delete(ws);
42
+ console.log(`[WS] Client disconnected (${clients.size} total)`);
43
+ });
44
+
45
+ ws.on('error', (err) => {
46
+ console.error('[WS] Error:', err.message);
47
+ clients.delete(ws);
48
+ });
49
+
50
+ // Handle ping/pong for connection health
51
+ ws.isAlive = true;
52
+ ws.on('pong', () => {
53
+ ws.isAlive = true;
54
+ });
55
+ });
56
+
57
+ // Broadcast events to all connected clients
58
+ eventBus.on('broadcast', ({ event, data }) => {
59
+ const message = JSON.stringify({ event, data });
60
+ for (const client of clients) {
61
+ if (client.readyState === 1) { // OPEN
62
+ client.send(message);
63
+ }
64
+ }
65
+ });
66
+
67
+ // Ping clients periodically to detect dead connections
68
+ const pingInterval = setInterval(() => {
69
+ for (const client of clients) {
70
+ if (!client.isAlive) {
71
+ client.terminate();
72
+ clients.delete(client);
73
+ continue;
74
+ }
75
+ client.isAlive = false;
76
+ client.ping();
77
+ }
78
+ }, 30000);
79
+
80
+ wss.on('close', () => {
81
+ clearInterval(pingInterval);
82
+ });
83
+
84
+ return { clients };
85
+ }