mrmd-server 0.1.0 → 0.1.2
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/LICENSE +21 -0
- package/bin/cli.js +4 -20
- package/package.json +20 -3
- package/src/api/bash.js +72 -189
- package/src/api/file.js +26 -20
- package/src/api/index.js +5 -0
- package/src/api/notebook.js +290 -0
- package/src/api/project.js +178 -12
- package/src/api/pty.js +73 -293
- package/src/api/r.js +337 -0
- package/src/api/session.js +96 -251
- package/src/api/settings.js +782 -0
- package/src/api/system.js +199 -1
- package/src/server.js +133 -8
- package/src/services.js +42 -0
- package/src/sync-manager.js +223 -0
- package/static/favicon.png +0 -0
- package/static/http-shim.js +172 -3
- package/static/index.html +1 -0
package/src/api/r.js
ADDED
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R Session API routes
|
|
3
|
+
*
|
|
4
|
+
* Mirrors electronAPI.r.* (if it existed in preload)
|
|
5
|
+
* Provides R runtime management similar to Julia
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { Router } from 'express';
|
|
9
|
+
import { spawn } from 'child_process';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import net from 'net';
|
|
12
|
+
|
|
13
|
+
// Session registry: sessionName -> { port, process, cwd }
|
|
14
|
+
const sessions = new Map();
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Create R routes
|
|
18
|
+
* @param {import('../server.js').ServerContext} ctx
|
|
19
|
+
*/
|
|
20
|
+
export function createRRoutes(ctx) {
|
|
21
|
+
const router = Router();
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* GET /api/r
|
|
25
|
+
* List all running R sessions
|
|
26
|
+
*/
|
|
27
|
+
router.get('/', async (req, res) => {
|
|
28
|
+
try {
|
|
29
|
+
const list = [];
|
|
30
|
+
for (const [name, session] of sessions) {
|
|
31
|
+
list.push({
|
|
32
|
+
name,
|
|
33
|
+
port: session.port,
|
|
34
|
+
cwd: session.cwd,
|
|
35
|
+
running: session.process && !session.process.killed,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
res.json(list);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
console.error('[r:list]', err);
|
|
41
|
+
res.status(500).json({ error: err.message });
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* GET /api/r/available
|
|
47
|
+
* Check if R is available on the system
|
|
48
|
+
*/
|
|
49
|
+
router.get('/available', async (req, res) => {
|
|
50
|
+
try {
|
|
51
|
+
const available = await isRAvailable();
|
|
52
|
+
res.json({ available });
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error('[r:available]', err);
|
|
55
|
+
res.json({ available: false });
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* POST /api/r
|
|
61
|
+
* Start a new R session
|
|
62
|
+
*/
|
|
63
|
+
router.post('/', async (req, res) => {
|
|
64
|
+
try {
|
|
65
|
+
const { config } = req.body;
|
|
66
|
+
const { name, cwd } = config || {};
|
|
67
|
+
|
|
68
|
+
if (!name) {
|
|
69
|
+
return res.status(400).json({ error: 'config.name required' });
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Check if R is available
|
|
73
|
+
if (!await isRAvailable()) {
|
|
74
|
+
return res.status(503).json({ error: 'R is not available on this system' });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Check if session already exists
|
|
78
|
+
if (sessions.has(name)) {
|
|
79
|
+
const existing = sessions.get(name);
|
|
80
|
+
if (existing.process && !existing.process.killed) {
|
|
81
|
+
return res.json({
|
|
82
|
+
name,
|
|
83
|
+
port: existing.port,
|
|
84
|
+
cwd: existing.cwd,
|
|
85
|
+
reused: true,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Find free port
|
|
91
|
+
const port = await findFreePort(9101, 9200);
|
|
92
|
+
const workDir = cwd ? path.resolve(ctx.projectDir, cwd) : ctx.projectDir;
|
|
93
|
+
|
|
94
|
+
// Start R MRP server
|
|
95
|
+
// Note: This assumes mrmd-r is installed and provides an MRP-compatible server
|
|
96
|
+
const proc = spawn('Rscript', [
|
|
97
|
+
'-e',
|
|
98
|
+
`mrmd.r::serve(${port})`,
|
|
99
|
+
], {
|
|
100
|
+
cwd: workDir,
|
|
101
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// Wait for server to start (with timeout)
|
|
105
|
+
try {
|
|
106
|
+
await waitForPort(port, 15000);
|
|
107
|
+
} catch (err) {
|
|
108
|
+
proc.kill();
|
|
109
|
+
return res.status(500).json({ error: `R server failed to start: ${err.message}` });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
sessions.set(name, {
|
|
113
|
+
port,
|
|
114
|
+
process: proc,
|
|
115
|
+
cwd: workDir,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
proc.on('exit', (code) => {
|
|
119
|
+
console.log(`[r] ${name} exited with code ${code}`);
|
|
120
|
+
sessions.delete(name);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
res.json({
|
|
124
|
+
name,
|
|
125
|
+
port,
|
|
126
|
+
cwd: workDir,
|
|
127
|
+
url: `http://localhost:${port}/mrp/v1`,
|
|
128
|
+
});
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.error('[r:start]', err);
|
|
131
|
+
res.status(500).json({ error: err.message });
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* DELETE /api/r/:name
|
|
137
|
+
* Stop an R session
|
|
138
|
+
*/
|
|
139
|
+
router.delete('/:name', async (req, res) => {
|
|
140
|
+
try {
|
|
141
|
+
const { name } = req.params;
|
|
142
|
+
const session = sessions.get(name);
|
|
143
|
+
|
|
144
|
+
if (!session) {
|
|
145
|
+
return res.json({ success: true, message: 'Session not found' });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (session.process && !session.process.killed) {
|
|
149
|
+
session.process.kill();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
sessions.delete(name);
|
|
153
|
+
res.json({ success: true });
|
|
154
|
+
} catch (err) {
|
|
155
|
+
console.error('[r:stop]', err);
|
|
156
|
+
res.status(500).json({ error: err.message });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* POST /api/r/:name/restart
|
|
162
|
+
* Restart an R session
|
|
163
|
+
*/
|
|
164
|
+
router.post('/:name/restart', async (req, res) => {
|
|
165
|
+
try {
|
|
166
|
+
const { name } = req.params;
|
|
167
|
+
const session = sessions.get(name);
|
|
168
|
+
|
|
169
|
+
if (!session) {
|
|
170
|
+
return res.status(404).json({ error: 'Session not found' });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Kill existing
|
|
174
|
+
if (session.process && !session.process.killed) {
|
|
175
|
+
session.process.kill();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Re-create
|
|
179
|
+
const cwd = session.cwd;
|
|
180
|
+
sessions.delete(name);
|
|
181
|
+
|
|
182
|
+
// Forward to start handler
|
|
183
|
+
req.body.config = { name, cwd };
|
|
184
|
+
return res.redirect(307, '/api/r');
|
|
185
|
+
} catch (err) {
|
|
186
|
+
console.error('[r:restart]', err);
|
|
187
|
+
res.status(500).json({ error: err.message });
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* POST /api/r/for-document
|
|
193
|
+
* Get or create R session for a document
|
|
194
|
+
*/
|
|
195
|
+
router.post('/for-document', async (req, res) => {
|
|
196
|
+
try {
|
|
197
|
+
const { documentPath } = req.body;
|
|
198
|
+
if (!documentPath) {
|
|
199
|
+
return res.status(400).json({ error: 'documentPath required' });
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Check if R is available
|
|
203
|
+
if (!await isRAvailable()) {
|
|
204
|
+
return res.json(null);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
const docName = `r-${path.basename(documentPath, '.md')}`;
|
|
208
|
+
|
|
209
|
+
// Check if session exists
|
|
210
|
+
if (sessions.has(docName)) {
|
|
211
|
+
const session = sessions.get(docName);
|
|
212
|
+
return res.json({
|
|
213
|
+
name: docName,
|
|
214
|
+
port: session.port,
|
|
215
|
+
cwd: session.cwd,
|
|
216
|
+
url: `http://localhost:${session.port}/mrp/v1`,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Create session
|
|
221
|
+
const fullPath = path.resolve(ctx.projectDir, documentPath);
|
|
222
|
+
const port = await findFreePort(9101, 9200);
|
|
223
|
+
const workDir = path.dirname(fullPath);
|
|
224
|
+
|
|
225
|
+
const proc = spawn('Rscript', [
|
|
226
|
+
'-e',
|
|
227
|
+
`mrmd.r::serve(${port})`,
|
|
228
|
+
], {
|
|
229
|
+
cwd: workDir,
|
|
230
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
await waitForPort(port, 15000);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
proc.kill();
|
|
237
|
+
return res.json(null);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
sessions.set(docName, {
|
|
241
|
+
port,
|
|
242
|
+
process: proc,
|
|
243
|
+
cwd: workDir,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
res.json({
|
|
247
|
+
name: docName,
|
|
248
|
+
port,
|
|
249
|
+
cwd: workDir,
|
|
250
|
+
url: `http://localhost:${port}/mrp/v1`,
|
|
251
|
+
});
|
|
252
|
+
} catch (err) {
|
|
253
|
+
console.error('[r:forDocument]', err);
|
|
254
|
+
res.status(500).json({ error: err.message });
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
return router;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Check if R is available
|
|
263
|
+
*/
|
|
264
|
+
async function isRAvailable() {
|
|
265
|
+
return new Promise((resolve) => {
|
|
266
|
+
const proc = spawn('R', ['--version'], {
|
|
267
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
proc.on('close', (code) => {
|
|
271
|
+
resolve(code === 0);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
proc.on('error', () => {
|
|
275
|
+
resolve(false);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
// Timeout after 5 seconds
|
|
279
|
+
setTimeout(() => {
|
|
280
|
+
proc.kill();
|
|
281
|
+
resolve(false);
|
|
282
|
+
}, 5000);
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Find a free port in range
|
|
288
|
+
*/
|
|
289
|
+
async function findFreePort(start, end) {
|
|
290
|
+
for (let port = start; port <= end; port++) {
|
|
291
|
+
if (await isPortFree(port)) {
|
|
292
|
+
return port;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
throw new Error(`No free port found in range ${start}-${end}`);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Check if port is free
|
|
300
|
+
*/
|
|
301
|
+
function isPortFree(port) {
|
|
302
|
+
return new Promise((resolve) => {
|
|
303
|
+
const server = net.createServer();
|
|
304
|
+
server.once('error', () => resolve(false));
|
|
305
|
+
server.once('listening', () => {
|
|
306
|
+
server.close();
|
|
307
|
+
resolve(true);
|
|
308
|
+
});
|
|
309
|
+
server.listen(port, '127.0.0.1');
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Wait for port to be open
|
|
315
|
+
*/
|
|
316
|
+
function waitForPort(port, timeout = 10000) {
|
|
317
|
+
return new Promise((resolve, reject) => {
|
|
318
|
+
const start = Date.now();
|
|
319
|
+
|
|
320
|
+
function check() {
|
|
321
|
+
const socket = net.connect(port, '127.0.0.1');
|
|
322
|
+
socket.once('connect', () => {
|
|
323
|
+
socket.end();
|
|
324
|
+
resolve();
|
|
325
|
+
});
|
|
326
|
+
socket.once('error', () => {
|
|
327
|
+
if (Date.now() - start > timeout) {
|
|
328
|
+
reject(new Error(`Timeout waiting for port ${port}`));
|
|
329
|
+
} else {
|
|
330
|
+
setTimeout(check, 200);
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
check();
|
|
336
|
+
});
|
|
337
|
+
}
|