devglide 0.1.2 → 0.1.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 +5 -1
- package/src/project-context.ts +36 -0
- package/src/public/app.js +701 -0
- package/src/public/favicon.svg +7 -0
- package/src/public/index.html +78 -0
- package/src/public/state.js +84 -0
- package/src/public/style.css +1213 -0
- package/src/routers/coder.ts +157 -0
- package/src/routers/dashboard.ts +158 -0
- package/src/routers/kanban.ts +38 -0
- package/src/routers/log.ts +42 -0
- package/src/routers/prompts.ts +134 -0
- package/src/routers/shell/index.ts +47 -0
- package/src/routers/shell/pty-manager.ts +107 -0
- package/src/routers/shell/shell-config.ts +38 -0
- package/src/routers/shell/shell-routes.ts +108 -0
- package/src/routers/shell/shell-socket.ts +321 -0
- package/src/routers/shell/shell-state.ts +59 -0
- package/src/routers/test.ts +254 -0
- package/src/routers/vocabulary.ts +149 -0
- package/src/routers/voice.ts +10 -0
- package/src/routers/workflow.ts +243 -0
- package/src/server.ts +325 -0
package/src/server.ts
ADDED
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Devglide Unified Server
|
|
3
|
+
*
|
|
4
|
+
* Consolidates all 9 Devglide micro-services into a single Express/Socket.io
|
|
5
|
+
* server. Each app's routes live in src/routers/<app>.ts and are mounted under
|
|
6
|
+
* /api/<app>. Static assets are served from the original app public dirs.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import express from 'express';
|
|
10
|
+
import { createServer } from 'http';
|
|
11
|
+
import { Server, type Namespace } from 'socket.io';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { fileURLToPath } from 'url';
|
|
14
|
+
|
|
15
|
+
// Shared packages
|
|
16
|
+
import { isLocalhostOrigin } from './packages/auth-middleware.js';
|
|
17
|
+
import { LOGS_DIR } from './packages/paths.js';
|
|
18
|
+
import { snifferSource, runnerSource } from './packages/devtools-middleware.js';
|
|
19
|
+
import { initServerSniffer } from './packages/server-sniffer.js';
|
|
20
|
+
import { mountMcpHttp } from './packages/mcp-utils/src/index.js';
|
|
21
|
+
|
|
22
|
+
// Project context
|
|
23
|
+
import { getActiveProject, setActiveProject } from './project-context.js';
|
|
24
|
+
|
|
25
|
+
// Initial stored project
|
|
26
|
+
import { getActiveProject as getStoredProject } from './packages/project-store.js';
|
|
27
|
+
|
|
28
|
+
// Routers
|
|
29
|
+
import { router as dashboardRouter, initDashboard } from './routers/dashboard.js';
|
|
30
|
+
import { router as kanbanRouter, createKanbanMcpServer } from './routers/kanban.js';
|
|
31
|
+
import { router as logRouter, initLog, shutdownLog, createLogMcpServer } from './routers/log.js';
|
|
32
|
+
import { recordSession } from './apps/log/src/routes/log.js';
|
|
33
|
+
import { router as testRouter, initTest, shutdownTest, createTestMcpServer } from './routers/test.js';
|
|
34
|
+
import { router as shellRouter, initShell, mountShellMcp, shutdownShell } from './routers/shell/index.js';
|
|
35
|
+
import { router as coderRouter } from './routers/coder.js';
|
|
36
|
+
import { router as workflowRouter, initWorkflow, shutdownWorkflow, createWorkflowMcpServer } from './routers/workflow.js';
|
|
37
|
+
import { router as voiceRouter, createVoiceMcpServer } from './routers/voice.js';
|
|
38
|
+
import { router as vocabularyRouter, createVocabularyMcpServer } from './routers/vocabulary.js';
|
|
39
|
+
import { router as promptsRouter, createPromptsMcpServer } from './routers/prompts.js';
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Constants
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
47
|
+
const __dirname = path.dirname(__filename);
|
|
48
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
49
|
+
const PORT = parseInt(process.env.PORT || '7000', 10);
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Server sniffer — captures server-side console output to disk
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
initServerSniffer({ service: 'devglide', targetPath: path.join(ROOT, 'server.log'), logPort: PORT });
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Express + HTTP + Socket.io
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
const app = express();
|
|
62
|
+
const httpServer = createServer(app);
|
|
63
|
+
|
|
64
|
+
const io = new Server(httpServer, {
|
|
65
|
+
cors: {
|
|
66
|
+
origin: (origin: string | undefined, cb: (err: Error | null, allow?: boolean) => void) => {
|
|
67
|
+
if (!origin || isLocalhostOrigin(origin)) return cb(null, true);
|
|
68
|
+
cb(null, false);
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// No auth — local-only dev tool; CORS restricts cross-origin access.
|
|
74
|
+
|
|
75
|
+
// ---------------------------------------------------------------------------
|
|
76
|
+
// Security headers
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
app.use((_req, res, next) => {
|
|
80
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
81
|
+
res.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
82
|
+
res.setHeader('X-XSS-Protection', '0');
|
|
83
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
84
|
+
res.setHeader('Permissions-Policy', 'camera=(), microphone=(self), geolocation=()');
|
|
85
|
+
next();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Shared CORS middleware
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
app.use((req, res, next) => {
|
|
93
|
+
const origin = req.headers.origin;
|
|
94
|
+
if (origin && isLocalhostOrigin(origin)) {
|
|
95
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
96
|
+
}
|
|
97
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
98
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
99
|
+
if (req.method === 'OPTIONS') return res.sendStatus(204);
|
|
100
|
+
next();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
// Body parser — 1 MB default, 25 MB for voice transcription uploads
|
|
105
|
+
// ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
const jsonDefault = express.json({ limit: '1mb' });
|
|
108
|
+
const jsonLarge = express.json({ limit: '25mb' });
|
|
109
|
+
|
|
110
|
+
app.use((req, res, next) => {
|
|
111
|
+
const isVoiceUpload = req.path.startsWith('/api/voice/transcribe') || req.path === '/api/transcribe';
|
|
112
|
+
(isVoiceUpload ? jsonLarge : jsonDefault)(req, res, next);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
// Static file serving
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
app.use('/shared-assets', express.static(path.join(ROOT, 'src/packages/shared-assets')));
|
|
120
|
+
app.use('/df', express.static(path.join(ROOT, 'src/packages/design-tokens/dist')));
|
|
121
|
+
app.use('/design-tokens', express.static(path.join(ROOT, 'src/packages/design-tokens/dist')));
|
|
122
|
+
|
|
123
|
+
// App-specific static dirs
|
|
124
|
+
app.use('/app/kanban', express.static(path.join(ROOT, 'src/apps/kanban/public')));
|
|
125
|
+
app.use('/app/log', express.static(path.join(ROOT, 'src/apps/log/public')));
|
|
126
|
+
app.use('/app/test', express.static(path.join(ROOT, 'src/apps/test/public')));
|
|
127
|
+
app.use('/app/shell', express.static(path.join(ROOT, 'src/apps/shell/public')));
|
|
128
|
+
app.use('/app/coder', express.static(path.join(ROOT, 'src/apps/coder/public')));
|
|
129
|
+
app.use('/app/workflow', express.static(path.join(ROOT, 'src/apps/workflow/public')));
|
|
130
|
+
app.use('/app/voice', express.static(path.join(ROOT, 'src/apps/voice/public')));
|
|
131
|
+
app.use('/app/vocabulary', express.static(path.join(ROOT, 'src/apps/vocabulary/public')));
|
|
132
|
+
app.use('/app/keymap', express.static(path.join(ROOT, 'src/apps/keymap/public')));
|
|
133
|
+
app.use('/app/prompts', express.static(path.join(ROOT, 'src/apps/prompts/public')));
|
|
134
|
+
app.use('/app/documentation', express.static(path.join(ROOT, 'src/apps/documentation/public')));
|
|
135
|
+
|
|
136
|
+
// App shell (unified SPA) is the default landing page at root
|
|
137
|
+
app.use('/', express.static(path.join(ROOT, 'src/public')));
|
|
138
|
+
|
|
139
|
+
// ---------------------------------------------------------------------------
|
|
140
|
+
// Rate limiter — per-IP request throttling for sensitive endpoints
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
const rateLimitState = new Map<string, { count: number; resetAt: number }>();
|
|
144
|
+
|
|
145
|
+
function rateLimit(maxRequests: number, windowMs: number) {
|
|
146
|
+
return (req: express.Request, res: express.Response, next: express.NextFunction) => {
|
|
147
|
+
const ip = req.ip ?? 'unknown';
|
|
148
|
+
const now = Date.now();
|
|
149
|
+
let entry = rateLimitState.get(ip);
|
|
150
|
+
if (!entry || now > entry.resetAt) {
|
|
151
|
+
entry = { count: 0, resetAt: now + windowMs };
|
|
152
|
+
rateLimitState.set(ip, entry);
|
|
153
|
+
}
|
|
154
|
+
entry.count++;
|
|
155
|
+
if (entry.count > maxRequests) {
|
|
156
|
+
res.status(429).json({ error: 'Too many requests' });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
next();
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Clean up stale entries periodically
|
|
164
|
+
setInterval(() => {
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
for (const [key, entry] of rateLimitState) {
|
|
167
|
+
if (now > entry.resetAt) rateLimitState.delete(key);
|
|
168
|
+
}
|
|
169
|
+
}, 60_000);
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// No auth middleware — local-only dev tool; CORS handles access control.
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
// ---------------------------------------------------------------------------
|
|
176
|
+
// Devtools — serves /__devtools.js with inlined sniffer + runner.
|
|
177
|
+
// Placed after auth to prevent unauthenticated path disclosure.
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
|
|
180
|
+
app.get('/__devtools.js', (_req, res) => {
|
|
181
|
+
let script = `window.__devglideSnifferConfig=${JSON.stringify({
|
|
182
|
+
serverOrigin: `http://localhost:${PORT}`,
|
|
183
|
+
targetPath: path.join(LOGS_DIR, 'devglide-console.log'),
|
|
184
|
+
persistent: true,
|
|
185
|
+
allowedTypes: {},
|
|
186
|
+
})};\n`;
|
|
187
|
+
|
|
188
|
+
script += `window.__devglideRunnerConfig=${JSON.stringify({
|
|
189
|
+
serverOrigin: `http://localhost:${PORT}`,
|
|
190
|
+
target: ROOT,
|
|
191
|
+
})};\n`;
|
|
192
|
+
|
|
193
|
+
script += snifferSource + '\n' + runnerSource;
|
|
194
|
+
res.type('application/javascript').send(script);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
app.get('/devtools.js', (req, res) => {
|
|
198
|
+
const dir = (req.query.target as string) || getActiveProject()?.path;
|
|
199
|
+
if (!dir) {
|
|
200
|
+
return res.type('application/javascript').send('/* devtools: no target */');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
let script = `window.__devglideSnifferConfig=${JSON.stringify({
|
|
204
|
+
serverOrigin: `http://localhost:${PORT}`,
|
|
205
|
+
targetPath: path.join(LOGS_DIR, path.basename(dir) + '-console.log'),
|
|
206
|
+
persistent: true,
|
|
207
|
+
allowedTypes: {},
|
|
208
|
+
})};\n`;
|
|
209
|
+
|
|
210
|
+
script += `window.__devglideRunnerConfig=${JSON.stringify({
|
|
211
|
+
serverOrigin: `http://localhost:${PORT}`,
|
|
212
|
+
target: dir,
|
|
213
|
+
})};\n`;
|
|
214
|
+
|
|
215
|
+
script += snifferSource + '\n' + runnerSource;
|
|
216
|
+
res.type('application/javascript').send(script);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// API routers
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
app.use('/api/dashboard', dashboardRouter);
|
|
224
|
+
app.use('/api/kanban', kanbanRouter);
|
|
225
|
+
app.use('/api/log', logRouter);
|
|
226
|
+
app.use('/api/test', testRouter);
|
|
227
|
+
app.use('/api/shell', rateLimit(100, 60_000), shellRouter);
|
|
228
|
+
app.use('/api/coder', coderRouter);
|
|
229
|
+
app.use('/api/workflow', workflowRouter);
|
|
230
|
+
app.use('/api/voice', rateLimit(30, 60_000), voiceRouter);
|
|
231
|
+
app.use('/api/vocabulary', vocabularyRouter);
|
|
232
|
+
app.use('/api/prompts', promptsRouter);
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
app.use('/', rateLimit(60, 60_000), shellRouter); // /preview, /proxy
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// MCP endpoints
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
mountMcpHttp(app, () => createKanbanMcpServer(), '/mcp/kanban');
|
|
242
|
+
mountMcpHttp(app, createLogMcpServer, '/mcp/log');
|
|
243
|
+
mountMcpHttp(app, createTestMcpServer, '/mcp/test');
|
|
244
|
+
mountMcpHttp(app, createVoiceMcpServer, '/mcp/voice');
|
|
245
|
+
mountMcpHttp(app, createWorkflowMcpServer, '/mcp/workflow');
|
|
246
|
+
mountMcpHttp(app, createVocabularyMcpServer, '/mcp/vocabulary');
|
|
247
|
+
mountMcpHttp(app, createPromptsMcpServer, '/mcp/prompts');
|
|
248
|
+
|
|
249
|
+
mountShellMcp(app, '/mcp/shell');
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Socket.io namespaces
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
// Dashboard and shell events use the default namespace for backward
|
|
256
|
+
// compatibility — the iframe-loaded frontends connect with io() which
|
|
257
|
+
// hits the default namespace. The event names don't conflict (dashboard
|
|
258
|
+
// uses project:*, shell uses terminal:*/state:*/browser:*).
|
|
259
|
+
initDashboard(io.of('/'));
|
|
260
|
+
initShell(io.of('/'));
|
|
261
|
+
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
// Service initialization
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
async function bootstrap() {
|
|
267
|
+
initLog();
|
|
268
|
+
|
|
269
|
+
// Register the server log session directly (same process — no HTTP needed).
|
|
270
|
+
// The sniffer's initial SESSION_START POST fires before the server is listening,
|
|
271
|
+
// so this ensures the session is always discoverable in the log UI.
|
|
272
|
+
const serverLogPath = path.join(ROOT, 'server.log');
|
|
273
|
+
recordSession({
|
|
274
|
+
type: 'SESSION_START',
|
|
275
|
+
session: 'devglide-server',
|
|
276
|
+
ts: new Date().toISOString(),
|
|
277
|
+
url: 'server://devglide',
|
|
278
|
+
ua: `node/${process.version}`,
|
|
279
|
+
persistent: true,
|
|
280
|
+
targetPath: serverLogPath,
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
await initTest();
|
|
284
|
+
initWorkflow();
|
|
285
|
+
|
|
286
|
+
// Restore active project from persistent store
|
|
287
|
+
const stored = getStoredProject();
|
|
288
|
+
if (stored) setActiveProject(stored);
|
|
289
|
+
|
|
290
|
+
// Start listening
|
|
291
|
+
httpServer.listen(PORT, () => {
|
|
292
|
+
console.log(`[devglide] unified server listening on http://localhost:${PORT}`);
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
bootstrap().catch((err) => {
|
|
297
|
+
console.error('[devglide] bootstrap failed:', err);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
// Graceful shutdown
|
|
303
|
+
// ---------------------------------------------------------------------------
|
|
304
|
+
|
|
305
|
+
function shutdown() {
|
|
306
|
+
console.log('[devglide] shutting down...');
|
|
307
|
+
shutdownLog();
|
|
308
|
+
shutdownTest();
|
|
309
|
+
shutdownWorkflow();
|
|
310
|
+
shutdownShell();
|
|
311
|
+
io.close();
|
|
312
|
+
httpServer.close(() => {
|
|
313
|
+
console.log('[devglide] server closed');
|
|
314
|
+
process.exit(0);
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
// Force exit after 5 s if open connections prevent graceful shutdown
|
|
318
|
+
setTimeout(() => {
|
|
319
|
+
console.warn('[devglide] forced exit — open connections did not close in time');
|
|
320
|
+
process.exit(1);
|
|
321
|
+
}, 5000).unref();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
process.on('SIGINT', shutdown);
|
|
325
|
+
process.on('SIGTERM', shutdown);
|