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/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
+ }