Skip to content

Commit 17bdda2

Browse files
refactor(server): extract dashboard static plugin (#3154)
Phase 1, extraction #3: Dashboard static file serving. Moves 175 lines of dashboard serving code from server.ts to a new Fastify plugin at src/plugins/dashboard-static.ts: - CSP headers, cache control, static file serving - SPA fallback for dashboard routes - Root redirect to /dashboard/ - manifest.json for PWA install - Content-Length defensive header server.ts: 1513 → 1384 lines (-129 lines, -8.5%) No API changes. All dashboard behavior preserved.
1 parent 3bfb088 commit 17bdda2

3 files changed

Lines changed: 183 additions & 138 deletions

File tree

src/__tests__/dashboard-static.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,10 @@ describe('Dashboard static serving (Issue #105)', () => {
215215

216216
describe('8. Dashboard CSP policy', () => {
217217
it('should allow npm registry update checks in connect-src', async () => {
218-
const serverPath = join(process.cwd(), 'src', 'server.ts');
219-
const serverContent = await readFile(serverPath, 'utf-8');
220-
expect(serverContent).toContain("connect-src 'self' ws: wss: https://registry.npmjs.org");
218+
// #3154: CSP moved to plugins/dashboard-static.ts
219+
const pluginPath = join(process.cwd(), 'src', 'plugins', 'dashboard-static.ts');
220+
const pluginContent = await readFile(pluginPath, 'utf-8');
221+
expect(pluginContent).toContain("connect-src 'self' ws: wss: https://registry.npmjs.org");
221222
});
222223
});
223224
});

src/plugins/dashboard-static.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
/**
2+
* Dashboard static file serving plugin.
3+
*
4+
* Extracted from server.ts (#3154 — server decomposition).
5+
* Handles:
6+
* - Static file serving via @fastify/static
7+
* - Cache-Control headers (hashed assets cached, HTML no-cache)
8+
* - SPA fallback for dashboard routes
9+
* - Root redirect to /dashboard/
10+
* - manifest.json serving for PWA install
11+
*
12+
* @packageDocumentation
13+
*/
14+
15+
import { FastifyInstance, FastifyReply } from 'fastify';
16+
import fastifyStatic from '@fastify/static';
17+
import fs from 'node:fs/promises';
18+
import { statSync } from 'node:fs';
19+
import path from 'node:path';
20+
21+
import { logger } from '../logger.js';
22+
23+
// ── Constants ──────────────────────────────────────────────────────────────
24+
25+
const DASHBOARD_CSP = [
26+
"default-src 'self'",
27+
"script-src 'self'",
28+
"style-src 'self' 'unsafe-inline'",
29+
"img-src 'self' data:",
30+
"font-src 'self' data:",
31+
"connect-src 'self' ws: wss: https://registry.npmjs.org",
32+
"frame-ancestors 'none'",
33+
"frame-src 'none'",
34+
"base-uri 'self'",
35+
"form-action 'self'",
36+
"object-src 'none'",
37+
"worker-src blob 'self'",
38+
].join('; ');
39+
40+
const DASHBOARD_RESPONSE_HEADERS = {
41+
'X-Frame-Options': 'DENY',
42+
'X-Content-Type-Options': 'nosniff',
43+
'Referrer-Policy': 'strict-origin-when-cross-origin',
44+
'Content-Security-Policy': DASHBOARD_CSP,
45+
} as const;
46+
47+
// ── Helper functions ───────────────────────────────────────────────────────
48+
49+
export function applyDashboardResponseHeaders(reply: FastifyReply): void {
50+
for (const [header, value] of Object.entries(DASHBOARD_RESPONSE_HEADERS)) {
51+
reply.header(header, value);
52+
}
53+
}
54+
55+
function normalizeDashboardStaticPath(dashboardRoot: string, pathname: string): string {
56+
const urlLike = pathname.replace(/\\/g, '/');
57+
if (urlLike === '/' || urlLike === '/index.html' || urlLike.startsWith('/dashboard/') || urlLike.startsWith('/assets/')) {
58+
return urlLike.replace(/^\/(?:dashboard\/)?/, '');
59+
}
60+
const normalized = path.normalize(pathname);
61+
const relative = path.isAbsolute(normalized)
62+
? path.relative(dashboardRoot, normalized)
63+
: normalized.replace(/^[\\/]+/, '');
64+
return relative.split(path.sep).join('/');
65+
}
66+
67+
function dashboardCacheControl(dashboardRoot: string, pathname: string): string {
68+
const relative = normalizeDashboardStaticPath(dashboardRoot, pathname);
69+
const basename = path.posix.basename(relative);
70+
if (relative === '' || relative === 'index.html' || basename.endsWith('.html')) {
71+
return 'no-cache, no-store, must-revalidate';
72+
}
73+
if (relative.startsWith('assets/') && /-[A-Za-z0-9_-]{6,}\.[A-Za-z0-9]+$/.test(basename)) {
74+
return 'public, max-age=31536000, immutable';
75+
}
76+
return 'public, max-age=0, must-revalidate';
77+
}
78+
79+
// ── Plugin ─────────────────────────────────────────────────────────────────
80+
81+
export interface DashboardStaticOptions {
82+
/** Whether the dashboard is enabled (default: true). */
83+
enabled?: boolean;
84+
}
85+
86+
/**
87+
* Register dashboard static file serving, SPA fallback, root redirect,
88+
* and manifest.json endpoint.
89+
*
90+
* Gracefully handles missing dashboard build (warns, serves nothing).
91+
* Returns true if dashboard is available, false otherwise.
92+
*/
93+
export async function registerDashboardStatic(
94+
app: FastifyInstance,
95+
options: DashboardStaticOptions = {}
96+
): Promise<boolean> {
97+
const dashboardRoot = path.join(__dirname, '..', 'dashboard');
98+
const dashboardEnabled = options.enabled !== false;
99+
let dashboardAvailable = false;
100+
101+
if (dashboardEnabled) {
102+
try {
103+
await fs.access(path.join(dashboardRoot, 'index.html'));
104+
dashboardAvailable = true;
105+
} catch {
106+
logger.warn({
107+
component: 'server',
108+
operation: 'dashboard_static_unavailable',
109+
errorCode: 'DASHBOARD_DIR_MISSING',
110+
attributes: {
111+
dashboardRoot,
112+
hint: 'Run "npm run build" to populate dist/dashboard/',
113+
},
114+
});
115+
}
116+
}
117+
118+
if (!dashboardAvailable) return false;
119+
120+
// Register static file serving
121+
await app.register(fastifyStatic, {
122+
root: dashboardRoot,
123+
prefix: "/dashboard/",
124+
// #2345: Prevent send() from overwriting our Cache-Control with its own default.
125+
cacheControl: false,
126+
// #146: Cache hashed assets aggressively, no-cache for index.html
127+
setHeaders: (reply, pathname) => {
128+
for (const [header, value] of Object.entries(DASHBOARD_RESPONSE_HEADERS)) {
129+
reply.setHeader(header, value);
130+
}
131+
reply.setHeader('Cache-Control', dashboardCacheControl(dashboardRoot, pathname));
132+
133+
// Defensive: ensure Content-Length is present and correct for static assets
134+
try {
135+
if (!reply.getHeader('Content-Length')) {
136+
const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
137+
const full = path.join(dashboardRoot, rel);
138+
const st = statSync(full);
139+
reply.setHeader('Content-Length', String(st.size));
140+
}
141+
} catch {
142+
// ignore: let the static plugin handle headers if stat fails
143+
}
144+
},
145+
});
146+
147+
// Issue #3076: Redirect root / to /dashboard/
148+
app.get('/', async (_req, reply) => {
149+
return reply.redirect('/dashboard/');
150+
});
151+
152+
// Issue #3092: Serve manifest.json at root for PWA install (no auth required).
153+
app.get('/manifest.json', async (_req, reply) => {
154+
const manifestPath = path.join(dashboardRoot, 'manifest.json');
155+
try {
156+
const data = await fs.readFile(manifestPath, 'utf-8');
157+
reply.header('Content-Type', 'application/manifest+json');
158+
reply.header('Cache-Control', 'public, max-age=3600');
159+
return reply.send(data);
160+
} catch {
161+
return reply.status(404).send({ error: 'manifest.json not found' });
162+
}
163+
});
164+
165+
// SPA fallback for dashboard routes (Issue #105)
166+
app.setNotFoundHandler(async (req, reply) => {
167+
if (req.url === "/dashboard" || req.url?.startsWith("/dashboard/") || req.url?.startsWith("/dashboard?")) {
168+
applyDashboardResponseHeaders(reply);
169+
return reply.sendFile("index.html", dashboardRoot);
170+
}
171+
return reply.status(404).send({ error: "Not found" });
172+
});
173+
174+
return true;
175+
}

src/server.ts

Lines changed: 4 additions & 135 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,7 @@
1111
import Fastify, { type FastifyRequest, type FastifyReply } from 'fastify';
1212
import fastifyRateLimit from '@fastify/rate-limit';
1313
import fs from 'node:fs/promises';
14-
import { statSync, watch, type FSWatcher } from 'node:fs';
15-
import fastifyStatic from '@fastify/static';
14+
import { watch, type FSWatcher } from 'node:fs';
1615
import fastifyWebsocket from '@fastify/websocket';
1716
import fastifyCors from '@fastify/cors';
1817
import crypto from 'node:crypto';
@@ -51,6 +50,7 @@ import { AuditLogger } from './audit.js';
5150
import { MetricsCollector } from './metrics.js';
5251

5352
import { registerHookRoutes } from './hooks.js';
53+
import { registerDashboardStatic } from './plugins/dashboard-static.js';
5454

5555
import { registerMemoryRoutes } from './memory-routes.js';
5656

@@ -131,57 +131,6 @@ declare module 'fastify' {
131131
// Tightened in #1924: adds frame-ancestors, base-uri, form-action, object-src.
132132
// 'unsafe-inline' remains on style-src because Tailwind / xterm inject inline styles;
133133
// script-src deliberately excludes 'unsafe-inline' and 'unsafe-eval'.
134-
const DASHBOARD_CSP = [
135-
"default-src 'self'",
136-
"script-src 'self'",
137-
"style-src 'self' 'unsafe-inline'",
138-
"img-src 'self' data:",
139-
"font-src 'self' data:",
140-
"connect-src 'self' ws: wss: https://registry.npmjs.org",
141-
"frame-ancestors 'none'",
142-
"frame-src 'none'",
143-
"base-uri 'self'",
144-
"form-action 'self'",
145-
"object-src 'none'",
146-
"worker-src blob 'self'",
147-
].join('; ');
148-
149-
const DASHBOARD_RESPONSE_HEADERS = {
150-
'X-Frame-Options': 'DENY',
151-
'X-Content-Type-Options': 'nosniff',
152-
'Referrer-Policy': 'strict-origin-when-cross-origin',
153-
'Content-Security-Policy': DASHBOARD_CSP,
154-
} as const;
155-
156-
function applyDashboardResponseHeaders(reply: FastifyReply): void {
157-
for (const [header, value] of Object.entries(DASHBOARD_RESPONSE_HEADERS)) {
158-
reply.header(header, value);
159-
}
160-
}
161-
162-
function normalizeDashboardStaticPath(dashboardRoot: string, pathname: string): string {
163-
const urlLike = pathname.replace(/\\/g, '/');
164-
if (urlLike === '/' || urlLike === '/index.html' || urlLike.startsWith('/dashboard/') || urlLike.startsWith('/assets/')) {
165-
return urlLike.replace(/^\/(?:dashboard\/)?/, '');
166-
}
167-
const normalized = path.normalize(pathname);
168-
const relative = path.isAbsolute(normalized)
169-
? path.relative(dashboardRoot, normalized)
170-
: normalized.replace(/^[\\/]+/, '');
171-
return relative.split(path.sep).join('/');
172-
}
173-
174-
function dashboardCacheControl(dashboardRoot: string, pathname: string): string {
175-
const relative = normalizeDashboardStaticPath(dashboardRoot, pathname);
176-
const basename = path.posix.basename(relative);
177-
if (relative === '' || relative === 'index.html' || basename.endsWith('.html')) {
178-
return 'no-cache, no-store, must-revalidate';
179-
}
180-
if (relative.startsWith('assets/') && /-[A-Za-z0-9_-]{6,}\.[A-Za-z0-9]+$/.test(basename)) {
181-
return 'public, max-age=31536000, immutable';
182-
}
183-
return 'public, max-age=0, must-revalidate';
184-
}
185134

186135
// Config loaded at startup; env vars override file values
187136
let config: Config;
@@ -1393,88 +1342,8 @@ async function main(): Promise<void> {
13931342
});
13941343

13951344

1396-
// #127: Serve dashboard static files (Issue #105) — graceful if missing
1397-
// Issue #539: Dashboard is copied into dist/dashboard/ during build
1398-
// Issue #1699: Validates index.html presence for clearer diagnostics
1399-
const dashboardRoot = path.join(__dirname, "dashboard");
1400-
const dashboardEnabled = config.dashboardEnabled !== false;
1401-
let dashboardAvailable = false;
1402-
if (dashboardEnabled) {
1403-
try {
1404-
await fs.access(path.join(dashboardRoot, 'index.html'));
1405-
dashboardAvailable = true;
1406-
} catch {
1407-
logger.warn({
1408-
component: 'server',
1409-
operation: 'dashboard_static_unavailable',
1410-
errorCode: 'DASHBOARD_DIR_MISSING',
1411-
attributes: {
1412-
dashboardRoot,
1413-
hint: 'Run "npm run build" to populate dist/dashboard/',
1414-
},
1415-
});
1416-
}
1417-
}
1418-
1419-
if (dashboardAvailable) {
1420-
await app.register(fastifyStatic, {
1421-
root: dashboardRoot,
1422-
prefix: "/dashboard/",
1423-
// #2345: Prevent send() from overwriting our Cache-Control with its own default.
1424-
cacheControl: false,
1425-
// #146: Cache hashed assets aggressively, no-cache for index.html
1426-
setHeaders: (reply, pathname) => {
1427-
for (const [header, value] of Object.entries(DASHBOARD_RESPONSE_HEADERS)) {
1428-
reply.setHeader(header, value);
1429-
}
1430-
reply.setHeader('Cache-Control', dashboardCacheControl(dashboardRoot, pathname));
1431-
1432-
// Defensive: ensure Content-Length is present and correct for static assets
1433-
// Only set header when not already provided by the static plugin (avoid interfering with compression plugins).
1434-
try {
1435-
if (!reply.getHeader('Content-Length')) {
1436-
const rel = pathname === '/' ? 'index.html' : pathname.replace(/^\//, '');
1437-
const full = path.join(dashboardRoot, rel);
1438-
const st = statSync(full);
1439-
reply.setHeader('Content-Length', String(st.size));
1440-
}
1441-
} catch {
1442-
// ignore: let the static plugin handle headers if stat fails
1443-
}
1444-
},
1445-
});
1446-
}
1447-
1448-
// Issue #3076: Redirect root / to /dashboard/ when dashboard is available
1449-
if (dashboardAvailable) {
1450-
app.get('/', async (_req, reply) => {
1451-
return reply.redirect('/dashboard/');
1452-
});
1453-
}
1454-
1455-
// Issue #3092: Serve manifest.json at root for PWA install (no auth required).
1456-
if (dashboardAvailable) {
1457-
app.get('/manifest.json', async (_req, reply) => {
1458-
const manifestPath = path.join(dashboardRoot, 'manifest.json');
1459-
try {
1460-
const data = await fs.readFile(manifestPath, 'utf-8');
1461-
reply.header('Content-Type', 'application/manifest+json');
1462-
reply.header('Cache-Control', 'public, max-age=3600');
1463-
return reply.send(data);
1464-
} catch {
1465-
return reply.status(404).send({ error: 'manifest.json not found' });
1466-
}
1467-
});
1468-
}
1469-
1470-
// SPA fallback for dashboard routes (Issue #105)
1471-
app.setNotFoundHandler(async (req, reply) => {
1472-
if (dashboardAvailable && (req.url === "/dashboard" || req.url?.startsWith("/dashboard/") || req.url?.startsWith("/dashboard?"))) {
1473-
applyDashboardResponseHeaders(reply);
1474-
return reply.sendFile("index.html", dashboardRoot);
1475-
}
1476-
return reply.status(404).send({ error: "Not found" });
1477-
});
1345+
// #3154: Dashboard static serving extracted to plugins/dashboard-static.ts
1346+
await registerDashboardStatic(app, { enabled: config.dashboardEnabled !== false });
14781347
await container.assertHealthy();
14791348
await listenWithRetry(app, config.port, config.host, config.stateDir);
14801349
pidFilePath = await writePidFile(config.stateDir);

0 commit comments

Comments
 (0)