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/README.md +230 -0
- package/bin/cli.js +161 -0
- package/package.json +35 -0
- package/src/api/asset.js +283 -0
- package/src/api/bash.js +293 -0
- package/src/api/file.js +407 -0
- package/src/api/index.js +11 -0
- package/src/api/julia.js +345 -0
- package/src/api/project.js +296 -0
- package/src/api/pty.js +401 -0
- package/src/api/runtime.js +140 -0
- package/src/api/session.js +358 -0
- package/src/api/system.js +256 -0
- package/src/auth.js +60 -0
- package/src/events.js +50 -0
- package/src/index.js +9 -0
- package/src/server-v2.js +118 -0
- package/src/server.js +297 -0
- package/src/websocket.js +85 -0
- package/static/http-shim.js +371 -0
- package/static/index.html +171 -0
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';
|
package/src/server-v2.js
ADDED
|
@@ -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
|
+
}
|
package/src/websocket.js
ADDED
|
@@ -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
|
+
}
|