groove-dev 0.27.101 → 0.27.102
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/CLAUDE.md +7 -0
- package/node_modules/@groove-dev/cli/package.json +1 -1
- package/node_modules/@groove-dev/daemon/package.json +1 -1
- package/node_modules/@groove-dev/daemon/src/preview.js +148 -2
- package/node_modules/@groove-dev/gui/package.json +1 -1
- package/package.json +1 -1
- package/packages/cli/package.json +1 -1
- package/packages/daemon/package.json +1 -1
- package/packages/daemon/src/preview.js +148 -2
- package/packages/gui/package.json +1 -1
- package/packages/launch-page/dist/assets/index-Bo186ysq.js +4180 -0
- package/packages/launch-page/dist/assets/index-CP4c4yxe.css +1 -0
- package/packages/launch-page/dist/index.html +2 -2
- package/packages/launch-page/src/App.css +438 -137
- package/packages/launch-page/src/App.tsx +171 -123
- package/packages/launch-page/src/index.css +9 -2
- package/packages/launch-page/dist/assets/index-BK3nAvHG.js +0 -4180
- package/packages/launch-page/dist/assets/index-jrLVZW5U.css +0 -2
package/CLAUDE.md
CHANGED
|
@@ -263,3 +263,10 @@ Audit-driven release. Multi-agent orchestration system with 7 coordination layer
|
|
|
263
263
|
- Dashboard: routing donut, cache panel, context health gauges
|
|
264
264
|
- Monitor/QC agent mode (stay active, loop)
|
|
265
265
|
- Distribution: demo video, HN launch, Twitter content
|
|
266
|
+
|
|
267
|
+
<!-- GROOVE:START -->
|
|
268
|
+
## GROOVE Orchestration (auto-injected)
|
|
269
|
+
Active agents: 0
|
|
270
|
+
See AGENTS_REGISTRY.md for full agent state.
|
|
271
|
+
**Memory policy:** GROOVE manages project memory automatically. Do not read or write MEMORY.md or .groove/memory/ files directly.
|
|
272
|
+
<!-- GROOVE:END -->
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// kills the previous one. Previews are also killed on team delete and on
|
|
12
12
|
// daemon shutdown.
|
|
13
13
|
|
|
14
|
-
import { spawn as cpSpawn } from 'child_process';
|
|
14
|
+
import { spawn as cpSpawn, execSync } from 'child_process';
|
|
15
15
|
import { resolve, extname } from 'path';
|
|
16
16
|
import { existsSync, readFileSync, statSync } from 'fs';
|
|
17
17
|
import { createServer } from 'http';
|
|
@@ -101,11 +101,55 @@ export class PreviewService {
|
|
|
101
101
|
return result;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
const installResult = this._ensureDependencies(teamId, baseDir);
|
|
105
|
+
if (installResult?.failed) {
|
|
106
|
+
this.daemon.audit?.log('preview.failed', { teamId, reason: installResult.reason });
|
|
107
|
+
return { launched: false, reason: installResult.reason };
|
|
108
|
+
}
|
|
109
|
+
|
|
104
110
|
let result;
|
|
105
111
|
if (preview.kind === 'static-html') {
|
|
106
|
-
|
|
112
|
+
if (this._needsBuild(baseDir, preview)) {
|
|
113
|
+
const buildResult = this._runBuild(teamId, baseDir);
|
|
114
|
+
if (buildResult?.failed) {
|
|
115
|
+
this.daemon.audit?.log('preview.failed', { teamId, reason: buildResult.reason });
|
|
116
|
+
return { launched: false, reason: buildResult.reason };
|
|
117
|
+
}
|
|
118
|
+
const distDir = resolve(baseDir, 'dist');
|
|
119
|
+
if (existsSync(distDir)) {
|
|
120
|
+
result = await this._launchStatic(teamId, distDir, { ...preview, openPath: preview.openPath || 'index.html' });
|
|
121
|
+
} else {
|
|
122
|
+
result = await this._launchStatic(teamId, baseDir, preview);
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
result = await this._launchStatic(teamId, baseDir, preview);
|
|
126
|
+
}
|
|
107
127
|
} else if (preview.kind === 'dev-server') {
|
|
128
|
+
if (this._needsPreBuild(baseDir)) {
|
|
129
|
+
const preBuild = this._runBuild(teamId, baseDir);
|
|
130
|
+
if (preBuild?.failed) {
|
|
131
|
+
this.daemon.audit?.log('preview.prebuild-failed', { teamId, reason: preBuild.reason });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
108
134
|
result = await this._launchDevServer(teamId, baseDir, preview);
|
|
135
|
+
// Fallback: if dev-server failed, try building and serving statically
|
|
136
|
+
if (!result.launched) {
|
|
137
|
+
const pkgPath = resolve(baseDir, 'package.json');
|
|
138
|
+
if (existsSync(pkgPath)) {
|
|
139
|
+
try {
|
|
140
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
141
|
+
if (pkg.scripts?.build) {
|
|
142
|
+
const buildResult = this._runBuild(teamId, baseDir);
|
|
143
|
+
if (!buildResult?.failed) {
|
|
144
|
+
const distDir = resolve(baseDir, 'dist');
|
|
145
|
+
if (existsSync(resolve(distDir, 'index.html'))) {
|
|
146
|
+
result = await this._launchStatic(teamId, distDir, { ...preview, openPath: 'index.html' });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch { /* fallback failed, keep original error */ }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
109
153
|
} else {
|
|
110
154
|
result = { launched: false, reason: `unknown_kind: ${preview.kind}` };
|
|
111
155
|
}
|
|
@@ -118,6 +162,99 @@ export class PreviewService {
|
|
|
118
162
|
return result;
|
|
119
163
|
}
|
|
120
164
|
|
|
165
|
+
_ensureDependencies(teamId, baseDir) {
|
|
166
|
+
const pkgPath = resolve(baseDir, 'package.json');
|
|
167
|
+
const nodeModules = resolve(baseDir, 'node_modules');
|
|
168
|
+
if (!existsSync(pkgPath) || existsSync(nodeModules)) return null;
|
|
169
|
+
try {
|
|
170
|
+
console.log(`[Groove:Preview] Running npm install in ${baseDir}`);
|
|
171
|
+
this.daemon.audit?.log('preview.npm-install', { teamId, baseDir });
|
|
172
|
+
execSync('npm install', { cwd: baseDir, timeout: 120_000, stdio: 'pipe' });
|
|
173
|
+
return null;
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return { failed: true, reason: `npm install failed: ${err.message?.slice(0, 300)}` };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_needsBuild(baseDir, preview) {
|
|
180
|
+
const pkgPath = resolve(baseDir, 'package.json');
|
|
181
|
+
let hasBuildScript = false;
|
|
182
|
+
try {
|
|
183
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
184
|
+
hasBuildScript = !!pkg.scripts?.build;
|
|
185
|
+
} catch { /* no package.json or malformed */ }
|
|
186
|
+
|
|
187
|
+
const distDir = resolve(baseDir, 'dist');
|
|
188
|
+
const distExists = existsSync(distDir);
|
|
189
|
+
|
|
190
|
+
// Primary: build script exists and dist/ doesn't
|
|
191
|
+
if (hasBuildScript && !distExists) return true;
|
|
192
|
+
|
|
193
|
+
// Stale check: dist/ exists but package.json is newer than dist/index.html
|
|
194
|
+
if (hasBuildScript && distExists) {
|
|
195
|
+
const distIndex = resolve(distDir, 'index.html');
|
|
196
|
+
if (existsSync(distIndex) && existsSync(pkgPath)) {
|
|
197
|
+
try {
|
|
198
|
+
const distMtime = statSync(distIndex).mtimeMs;
|
|
199
|
+
const pkgMtime = statSync(pkgPath).mtimeMs;
|
|
200
|
+
if (pkgMtime > distMtime) return true;
|
|
201
|
+
} catch { /* ignore stat errors */ }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Secondary: entry file references .tsx/.jsx sources (needs transpilation)
|
|
206
|
+
const openPath = (preview.openPath || 'index.html').replace(/^\/+/, '');
|
|
207
|
+
const entryFile = resolve(baseDir, openPath);
|
|
208
|
+
if (existsSync(entryFile)) {
|
|
209
|
+
try {
|
|
210
|
+
const html = readFileSync(entryFile, 'utf8');
|
|
211
|
+
if (/src=["'][^"']*\.(tsx?|jsx)["']/i.test(html)) return true;
|
|
212
|
+
} catch { /* ignore */ }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Entry file missing — check if a build might create it
|
|
216
|
+
if (!existsSync(entryFile) && hasBuildScript) {
|
|
217
|
+
const frameworkConfigs = ['vite.config', 'next.config', 'webpack.config'];
|
|
218
|
+
for (const cfg of frameworkConfigs) {
|
|
219
|
+
for (const ext of ['.js', '.ts', '.mjs', '.cjs']) {
|
|
220
|
+
if (existsSync(resolve(baseDir, cfg + ext))) return true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
_needsPreBuild(baseDir) {
|
|
229
|
+
const pkgPath = resolve(baseDir, 'package.json');
|
|
230
|
+
if (!existsSync(pkgPath)) return false;
|
|
231
|
+
try {
|
|
232
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
233
|
+
const startScript = pkg.scripts?.start || '';
|
|
234
|
+
if (/\bnext\s+start\b/.test(startScript)) return true;
|
|
235
|
+
if (/\bserve\b/.test(startScript) && !pkg.scripts?.dev) return true;
|
|
236
|
+
if (/\bhttp-server\b/.test(startScript)) return true;
|
|
237
|
+
} catch { /* ignore */ }
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
_runBuild(teamId, baseDir) {
|
|
242
|
+
const pkgPath = resolve(baseDir, 'package.json');
|
|
243
|
+
if (!existsSync(pkgPath)) return { failed: true, reason: 'no package.json for build' };
|
|
244
|
+
try {
|
|
245
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
246
|
+
if (!pkg.scripts?.build) return { failed: true, reason: 'no build script' };
|
|
247
|
+
} catch { return { failed: true, reason: 'malformed package.json' }; }
|
|
248
|
+
try {
|
|
249
|
+
console.log(`[Groove:Preview] Running npm run build in ${baseDir}`);
|
|
250
|
+
this.daemon.audit?.log('preview.build', { teamId, baseDir });
|
|
251
|
+
execSync('npm run build', { cwd: baseDir, timeout: 120_000, stdio: 'pipe' });
|
|
252
|
+
return null;
|
|
253
|
+
} catch (err) {
|
|
254
|
+
return { failed: true, reason: `build failed: ${err.message?.slice(0, 300)}` };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
121
258
|
_launchStatic(teamId, baseDir, preview) {
|
|
122
259
|
const openPath = (preview.openPath || 'index.html').replace(/^\/+/, '');
|
|
123
260
|
const entryFile = resolve(baseDir, openPath);
|
|
@@ -130,6 +267,15 @@ export class PreviewService {
|
|
|
130
267
|
const filePath = resolve(baseDir, rel);
|
|
131
268
|
if (!filePath.startsWith(baseDir)) { res.statusCode = 403; return res.end(); }
|
|
132
269
|
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
270
|
+
// SPA fallback: serve index.html for HTML requests (client-side routing)
|
|
271
|
+
const acceptsHtml = (req.headers.accept || '').includes('text/html');
|
|
272
|
+
if (acceptsHtml) {
|
|
273
|
+
const fallback = resolve(baseDir, openPath);
|
|
274
|
+
if (existsSync(fallback) && statSync(fallback).isFile()) {
|
|
275
|
+
res.setHeader('Content-Type', 'text/html');
|
|
276
|
+
return res.end(readFileSync(fallback));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
133
279
|
res.statusCode = 404; return res.end('Not found');
|
|
134
280
|
}
|
|
135
281
|
res.setHeader('Content-Type', mimeLookup(extname(filePath)) || 'application/octet-stream');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "groove-dev",
|
|
3
|
-
"version": "0.27.
|
|
3
|
+
"version": "0.27.102",
|
|
4
4
|
"description": "Open-source agent orchestration layer — the AI company OS. Local model agent engine (GGUF/Ollama/llama-server), HuggingFace model browser, MCP integrations (Slack, Gmail, Stripe, 15+), agent scheduling (cron), business roles (CMO, CFO, EA). GUI dashboard, multi-agent coordination, zero cold-start, infinite sessions. Works with Claude Code, Codex, Gemini CLI, Ollama, any local model.",
|
|
5
5
|
"license": "FSL-1.1-Apache-2.0",
|
|
6
6
|
"author": "Groove Dev <hello@groovedev.ai> (https://groovedev.ai)",
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// kills the previous one. Previews are also killed on team delete and on
|
|
12
12
|
// daemon shutdown.
|
|
13
13
|
|
|
14
|
-
import { spawn as cpSpawn } from 'child_process';
|
|
14
|
+
import { spawn as cpSpawn, execSync } from 'child_process';
|
|
15
15
|
import { resolve, extname } from 'path';
|
|
16
16
|
import { existsSync, readFileSync, statSync } from 'fs';
|
|
17
17
|
import { createServer } from 'http';
|
|
@@ -101,11 +101,55 @@ export class PreviewService {
|
|
|
101
101
|
return result;
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
+
const installResult = this._ensureDependencies(teamId, baseDir);
|
|
105
|
+
if (installResult?.failed) {
|
|
106
|
+
this.daemon.audit?.log('preview.failed', { teamId, reason: installResult.reason });
|
|
107
|
+
return { launched: false, reason: installResult.reason };
|
|
108
|
+
}
|
|
109
|
+
|
|
104
110
|
let result;
|
|
105
111
|
if (preview.kind === 'static-html') {
|
|
106
|
-
|
|
112
|
+
if (this._needsBuild(baseDir, preview)) {
|
|
113
|
+
const buildResult = this._runBuild(teamId, baseDir);
|
|
114
|
+
if (buildResult?.failed) {
|
|
115
|
+
this.daemon.audit?.log('preview.failed', { teamId, reason: buildResult.reason });
|
|
116
|
+
return { launched: false, reason: buildResult.reason };
|
|
117
|
+
}
|
|
118
|
+
const distDir = resolve(baseDir, 'dist');
|
|
119
|
+
if (existsSync(distDir)) {
|
|
120
|
+
result = await this._launchStatic(teamId, distDir, { ...preview, openPath: preview.openPath || 'index.html' });
|
|
121
|
+
} else {
|
|
122
|
+
result = await this._launchStatic(teamId, baseDir, preview);
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
result = await this._launchStatic(teamId, baseDir, preview);
|
|
126
|
+
}
|
|
107
127
|
} else if (preview.kind === 'dev-server') {
|
|
128
|
+
if (this._needsPreBuild(baseDir)) {
|
|
129
|
+
const preBuild = this._runBuild(teamId, baseDir);
|
|
130
|
+
if (preBuild?.failed) {
|
|
131
|
+
this.daemon.audit?.log('preview.prebuild-failed', { teamId, reason: preBuild.reason });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
108
134
|
result = await this._launchDevServer(teamId, baseDir, preview);
|
|
135
|
+
// Fallback: if dev-server failed, try building and serving statically
|
|
136
|
+
if (!result.launched) {
|
|
137
|
+
const pkgPath = resolve(baseDir, 'package.json');
|
|
138
|
+
if (existsSync(pkgPath)) {
|
|
139
|
+
try {
|
|
140
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
141
|
+
if (pkg.scripts?.build) {
|
|
142
|
+
const buildResult = this._runBuild(teamId, baseDir);
|
|
143
|
+
if (!buildResult?.failed) {
|
|
144
|
+
const distDir = resolve(baseDir, 'dist');
|
|
145
|
+
if (existsSync(resolve(distDir, 'index.html'))) {
|
|
146
|
+
result = await this._launchStatic(teamId, distDir, { ...preview, openPath: 'index.html' });
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
} catch { /* fallback failed, keep original error */ }
|
|
151
|
+
}
|
|
152
|
+
}
|
|
109
153
|
} else {
|
|
110
154
|
result = { launched: false, reason: `unknown_kind: ${preview.kind}` };
|
|
111
155
|
}
|
|
@@ -118,6 +162,99 @@ export class PreviewService {
|
|
|
118
162
|
return result;
|
|
119
163
|
}
|
|
120
164
|
|
|
165
|
+
_ensureDependencies(teamId, baseDir) {
|
|
166
|
+
const pkgPath = resolve(baseDir, 'package.json');
|
|
167
|
+
const nodeModules = resolve(baseDir, 'node_modules');
|
|
168
|
+
if (!existsSync(pkgPath) || existsSync(nodeModules)) return null;
|
|
169
|
+
try {
|
|
170
|
+
console.log(`[Groove:Preview] Running npm install in ${baseDir}`);
|
|
171
|
+
this.daemon.audit?.log('preview.npm-install', { teamId, baseDir });
|
|
172
|
+
execSync('npm install', { cwd: baseDir, timeout: 120_000, stdio: 'pipe' });
|
|
173
|
+
return null;
|
|
174
|
+
} catch (err) {
|
|
175
|
+
return { failed: true, reason: `npm install failed: ${err.message?.slice(0, 300)}` };
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
_needsBuild(baseDir, preview) {
|
|
180
|
+
const pkgPath = resolve(baseDir, 'package.json');
|
|
181
|
+
let hasBuildScript = false;
|
|
182
|
+
try {
|
|
183
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
184
|
+
hasBuildScript = !!pkg.scripts?.build;
|
|
185
|
+
} catch { /* no package.json or malformed */ }
|
|
186
|
+
|
|
187
|
+
const distDir = resolve(baseDir, 'dist');
|
|
188
|
+
const distExists = existsSync(distDir);
|
|
189
|
+
|
|
190
|
+
// Primary: build script exists and dist/ doesn't
|
|
191
|
+
if (hasBuildScript && !distExists) return true;
|
|
192
|
+
|
|
193
|
+
// Stale check: dist/ exists but package.json is newer than dist/index.html
|
|
194
|
+
if (hasBuildScript && distExists) {
|
|
195
|
+
const distIndex = resolve(distDir, 'index.html');
|
|
196
|
+
if (existsSync(distIndex) && existsSync(pkgPath)) {
|
|
197
|
+
try {
|
|
198
|
+
const distMtime = statSync(distIndex).mtimeMs;
|
|
199
|
+
const pkgMtime = statSync(pkgPath).mtimeMs;
|
|
200
|
+
if (pkgMtime > distMtime) return true;
|
|
201
|
+
} catch { /* ignore stat errors */ }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Secondary: entry file references .tsx/.jsx sources (needs transpilation)
|
|
206
|
+
const openPath = (preview.openPath || 'index.html').replace(/^\/+/, '');
|
|
207
|
+
const entryFile = resolve(baseDir, openPath);
|
|
208
|
+
if (existsSync(entryFile)) {
|
|
209
|
+
try {
|
|
210
|
+
const html = readFileSync(entryFile, 'utf8');
|
|
211
|
+
if (/src=["'][^"']*\.(tsx?|jsx)["']/i.test(html)) return true;
|
|
212
|
+
} catch { /* ignore */ }
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Entry file missing — check if a build might create it
|
|
216
|
+
if (!existsSync(entryFile) && hasBuildScript) {
|
|
217
|
+
const frameworkConfigs = ['vite.config', 'next.config', 'webpack.config'];
|
|
218
|
+
for (const cfg of frameworkConfigs) {
|
|
219
|
+
for (const ext of ['.js', '.ts', '.mjs', '.cjs']) {
|
|
220
|
+
if (existsSync(resolve(baseDir, cfg + ext))) return true;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
_needsPreBuild(baseDir) {
|
|
229
|
+
const pkgPath = resolve(baseDir, 'package.json');
|
|
230
|
+
if (!existsSync(pkgPath)) return false;
|
|
231
|
+
try {
|
|
232
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
233
|
+
const startScript = pkg.scripts?.start || '';
|
|
234
|
+
if (/\bnext\s+start\b/.test(startScript)) return true;
|
|
235
|
+
if (/\bserve\b/.test(startScript) && !pkg.scripts?.dev) return true;
|
|
236
|
+
if (/\bhttp-server\b/.test(startScript)) return true;
|
|
237
|
+
} catch { /* ignore */ }
|
|
238
|
+
return false;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
_runBuild(teamId, baseDir) {
|
|
242
|
+
const pkgPath = resolve(baseDir, 'package.json');
|
|
243
|
+
if (!existsSync(pkgPath)) return { failed: true, reason: 'no package.json for build' };
|
|
244
|
+
try {
|
|
245
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
246
|
+
if (!pkg.scripts?.build) return { failed: true, reason: 'no build script' };
|
|
247
|
+
} catch { return { failed: true, reason: 'malformed package.json' }; }
|
|
248
|
+
try {
|
|
249
|
+
console.log(`[Groove:Preview] Running npm run build in ${baseDir}`);
|
|
250
|
+
this.daemon.audit?.log('preview.build', { teamId, baseDir });
|
|
251
|
+
execSync('npm run build', { cwd: baseDir, timeout: 120_000, stdio: 'pipe' });
|
|
252
|
+
return null;
|
|
253
|
+
} catch (err) {
|
|
254
|
+
return { failed: true, reason: `build failed: ${err.message?.slice(0, 300)}` };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
121
258
|
_launchStatic(teamId, baseDir, preview) {
|
|
122
259
|
const openPath = (preview.openPath || 'index.html').replace(/^\/+/, '');
|
|
123
260
|
const entryFile = resolve(baseDir, openPath);
|
|
@@ -130,6 +267,15 @@ export class PreviewService {
|
|
|
130
267
|
const filePath = resolve(baseDir, rel);
|
|
131
268
|
if (!filePath.startsWith(baseDir)) { res.statusCode = 403; return res.end(); }
|
|
132
269
|
if (!existsSync(filePath) || !statSync(filePath).isFile()) {
|
|
270
|
+
// SPA fallback: serve index.html for HTML requests (client-side routing)
|
|
271
|
+
const acceptsHtml = (req.headers.accept || '').includes('text/html');
|
|
272
|
+
if (acceptsHtml) {
|
|
273
|
+
const fallback = resolve(baseDir, openPath);
|
|
274
|
+
if (existsSync(fallback) && statSync(fallback).isFile()) {
|
|
275
|
+
res.setHeader('Content-Type', 'text/html');
|
|
276
|
+
return res.end(readFileSync(fallback));
|
|
277
|
+
}
|
|
278
|
+
}
|
|
133
279
|
res.statusCode = 404; return res.end('Not found');
|
|
134
280
|
}
|
|
135
281
|
res.setHeader('Content-Type', mimeLookup(extname(filePath)) || 'application/octet-stream');
|