Skip to content

Commit 305ed7a

Browse files
refactor(server): extract dashboard static plugin (#3154)
Phase 1 of server.ts decomposition — extract dashboard static file serving into standalone Fastify plugin. - New: src/plugins/dashboard-static.ts (175 lines) - server.ts: 1513 → 1384 lines (-8.5%) - No API changes, all behavior preserved
1 parent fa1141c commit 305ed7a

3 files changed

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

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)