feat: add compression, security headers, and caching optimizations

Implement several performance and security improvements for Express 5:

Performance:
- Add compression middleware with Brotli support for all responses
- Enable WebSocket compression (perMessageDeflate) for terminal data
- Exclude compression for SSE streams (/api/sessions/:id/stream) to prevent asciicast issues
- Add intelligent caching headers for static assets:
  - Immutable assets (JS, CSS, fonts, images): 1 year cache
  - HTML files: 1 hour cache
  - Enable ETags and Last-Modified headers

Security:
- Add Helmet middleware for security headers
- Disable CSP and COEP to maintain compatibility with terminal features

Additional improvements:
- Remove obsolete @ts-expect-error directives (Express 5 properly types res.flush)
- Balanced compression level (6) for optimal performance

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Peter Steinberger 2025-07-12 19:35:17 +02:00
parent 3beb90a0fd
commit 93ba0064bd
5 changed files with 124 additions and 7 deletions

View file

@ -68,7 +68,9 @@
"authenticate-pam": "^1.0.5",
"bonjour-service": "^1.3.0",
"chalk": "^5.4.1",
"compression": "^1.8.0",
"express": "^5.1.0",
"helmet": "^8.1.0",
"http-proxy-middleware": "^3.0.5",
"jsonwebtoken": "^9.0.2",
"lit": "^3.3.1",
@ -87,6 +89,7 @@
"@playwright/test": "^1.54.1",
"@prettier/plugin-oxc": "^0.0.4",
"@testing-library/dom": "^10.4.0",
"@types/compression": "^1.8.1",
"@types/express": "^5.0.3",
"@types/jsonwebtoken": "^9.0.10",
"@types/mime-types": "^3.0.1",

View file

@ -50,9 +50,15 @@ importers:
chalk:
specifier: ^5.4.1
version: 5.4.1
compression:
specifier: ^1.8.0
version: 1.8.0
express:
specifier: ^5.1.0
version: 5.1.0
helmet:
specifier: ^8.1.0
version: 8.1.0
http-proxy-middleware:
specifier: ^3.0.5
version: 3.0.5
@ -102,6 +108,9 @@ importers:
'@testing-library/dom':
specifier: ^10.4.0
version: 10.4.0
'@types/compression':
specifier: ^1.8.1
version: 1.8.1
'@types/express':
specifier: ^5.0.3
version: 5.0.3
@ -839,6 +848,9 @@ packages:
'@types/co-body@6.1.3':
resolution: {integrity: sha512-UhuhrQ5hclX6UJctv5m4Rfp52AfG9o9+d9/HwjxhVB5NjXxr5t9oKgJxN8xRHgr35oo8meUEHUPFWiKg6y71aA==}
'@types/compression@1.8.1':
resolution: {integrity: sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==}
'@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@ -1363,6 +1375,14 @@ packages:
component-emitter@1.3.1:
resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==}
compressible@2.0.18:
resolution: {integrity: sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==}
engines: {node: '>= 0.6'}
compression@1.8.0:
resolution: {integrity: sha512-k6WLKfunuqCYD3t6AsuPGvQWaKwuLLh2/xHNcX4qE+vIfDNXpSqnrhwA7O53R7WVQUnt8dVAIW+YHr7xTgOgGA==}
engines: {node: '>= 0.8.0'}
concat-stream@2.0.0:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
@ -1434,6 +1454,14 @@ packages:
debounce@1.2.1:
resolution: {integrity: sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==}
debug@2.6.9:
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
peerDependencies:
supports-color: '*'
peerDependenciesMeta:
supports-color:
optional: true
debug@3.2.7:
resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==}
peerDependencies:
@ -1829,6 +1857,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
helmet@8.1.0:
resolution: {integrity: sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==}
engines: {node: '>=18.0.0'}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
@ -2266,6 +2298,9 @@ packages:
resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
engines: {node: '>=10'}
ms@2.0.0:
resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -2299,6 +2334,10 @@ packages:
resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
engines: {node: '>= 0.6'}
negotiator@0.6.4:
resolution: {integrity: sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==}
engines: {node: '>= 0.6'}
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
@ -2353,6 +2392,10 @@ packages:
resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
engines: {node: '>= 0.8'}
on-headers@1.0.2:
resolution: {integrity: sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA==}
engines: {node: '>= 0.8'}
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
@ -3794,6 +3837,11 @@ snapshots:
'@types/node': 24.0.13
'@types/qs': 6.14.0
'@types/compression@1.8.1':
dependencies:
'@types/express': 5.0.3
'@types/node': 24.0.13
'@types/connect@3.4.38':
dependencies:
'@types/node': 24.0.13
@ -4420,6 +4468,22 @@ snapshots:
component-emitter@1.3.1: {}
compressible@2.0.18:
dependencies:
mime-db: 1.54.0
compression@1.8.0:
dependencies:
bytes: 3.1.2
compressible: 2.0.18
debug: 2.6.9
negotiator: 0.6.4
on-headers: 1.0.2
safe-buffer: 5.2.1
vary: 1.1.2
transitivePeerDependencies:
- supports-color
concat-stream@2.0.0:
dependencies:
buffer-from: 1.1.2
@ -4485,6 +4549,10 @@ snapshots:
debounce@1.2.1: {}
debug@2.6.9:
dependencies:
ms: 2.0.0
debug@3.2.7:
dependencies:
ms: 2.1.3
@ -4901,6 +4969,8 @@ snapshots:
dependencies:
function-bind: 1.1.2
helmet@8.1.0: {}
html-escaper@2.0.2: {}
http-assert@1.5.0:
@ -5370,6 +5440,8 @@ snapshots:
mrmime@2.0.1: {}
ms@2.0.0: {}
ms@2.1.3: {}
multer@2.0.1:
@ -5403,6 +5475,8 @@ snapshots:
negotiator@0.6.3: {}
negotiator@0.6.4: {}
negotiator@1.0.0: {}
netmask@2.0.2: {}
@ -5441,6 +5515,8 @@ snapshots:
dependencies:
ee-first: 1.1.1
on-headers@1.0.2: {}
once@1.4.0:
dependencies:
wrappy: 1.0.2

View file

@ -846,7 +846,6 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router {
// Send initial connection event
res.write(':ok\n\n');
// @ts-expect-error - flush exists but not in types
if (res.flush) res.flush();
// Add client to stream watcher
@ -856,7 +855,6 @@ export function createSessionRoutes(config: SessionRoutesConfig): Router {
// Send heartbeat every 30 seconds to keep connection alive
const heartbeat = setInterval(() => {
res.write(':heartbeat\n\n');
// @ts-expect-error - flush exists but not in types
if (res.flush) res.flush();
}, 30000);

View file

@ -1,7 +1,9 @@
import chalk from 'chalk';
import compression from 'compression';
import type { Response as ExpressResponse } from 'express';
import express from 'express';
import * as fs from 'fs';
import helmet from 'helmet';
import type * as http from 'http';
import { createServer } from 'http';
import * as os from 'os';
@ -374,7 +376,34 @@ export async function createApp(): Promise<AppInstance> {
logger.log('Initializing VibeTunnel server components');
const app = express();
const server = createServer(app);
const wss = new WebSocketServer({ noServer: true });
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: true });
// Add security headers with Helmet
app.use(
helmet({
contentSecurityPolicy: false, // We handle CSP ourselves for the web terminal
crossOriginEmbedderPolicy: false, // Allow embedding in iframes for integrations
})
);
logger.debug('Configured security headers with helmet');
// Add compression middleware with Brotli support
// Skip compression for SSE streams (asciicast)
app.use(
compression({
filter: (req, res) => {
// Skip compression for Server-Sent Events (asciicast streams)
if (req.path.match(/\/api\/sessions\/[^/]+\/stream$/)) {
return false;
}
// Use default filter for other requests
return compression.filter(req, res);
},
// Enable Brotli compression with highest priority
level: 6, // Balanced compression level
})
);
logger.debug('Configured compression middleware (with asciicast exclusion)');
// Add JSON body parser middleware with size limit
app.use(express.json({ limit: '10mb' }));
@ -519,14 +548,27 @@ export async function createApp(): Promise<AppInstance> {
localAuthToken: config.localAuthToken || undefined,
});
// Serve static files with .html extension handling
// Serve static files with .html extension handling and caching headers
const publicPath = path.join(process.cwd(), 'public');
app.use(
express.static(publicPath, {
extensions: ['html'], // This allows /logs to resolve to /logs.html
maxAge: '1d', // Cache static assets for 1 day
etag: true, // Enable ETag generation
lastModified: true, // Enable Last-Modified header
setHeaders: (res, filePath) => {
// Set longer cache for immutable assets
if (filePath.match(/\.(js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|ico)$/)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
// Shorter cache for HTML files
else if (filePath.endsWith('.html')) {
res.setHeader('Cache-Control', 'public, max-age=3600'); // 1 hour
}
},
})
);
logger.debug(`Serving static files from: ${publicPath}`);
logger.debug(`Serving static files from: ${publicPath} with caching headers`);
// Health check endpoint (no auth required)
app.get('/api/health', (_req, res) => {

View file

@ -461,7 +461,6 @@ export class StreamWatcher {
try {
client.response.write(clientData);
// @ts-expect-error - flush exists but not in types
if (client.response.flush) client.response.flush();
} catch (error) {
logger.debug(
@ -482,7 +481,6 @@ export class StreamWatcher {
try {
client.response.write(clientData);
// @ts-expect-error - flush exists but not in types
if (client.response.flush) client.response.flush();
} catch (error) {
logger.debug(