| name | express5-api-patterns |
| description | Express 5 patterns for peek-stash-browser — TypeScript backend serving a React SPA with HLS video proxying, multi-instance Stash server routing, JWT auth via HTTP-only cookies, and SQLite via Prisma. Use when writing or modifying Express route handlers, middleware, proxy controllers, or streaming endpoints in the peek project. |
Express 5 API Patterns (peek-stash-browser)
Express 5.x patterns extracted from peek-stash-browser. This project uses Express ^5.1.0 with TypeScript strict mode, Prisma/SQLite, and JWT auth.
1. Express 5 Breaking Changes from v4
Async Error Handling (the big one)
Express 5 automatically catches rejected promises from async route handlers and forwards them to the error handler. No need for asyncHandler wrappers or try/catch in every handler.
app.get("/api/thing", asyncHandler(async (req, res) => { ... }));
app.get("/api/thing", async (req, res) => {
const data = await db.find(req.params.id);
if (!data) throw new NotFoundError("Not found");
res.json(data);
});
Path-to-regexp v8 Changes
- Wildcards must be named:
/* becomes /*splat (or /{*splat} to also match root)
- Optional params use braces:
/:file.:ext? becomes /:file{.:ext}
- No regex in path strings:
app.get('/[discussion|page]/:slug') must use array: app.get(['/discussion/:slug', '/page/:slug'])
- Reserved chars must be escaped:
()[]?+! need backslash escaping in paths
- Unmatched optional params are omitted from
req.params (not set to undefined)
- Wildcard params are arrays:
req.params.splat returns ['foo', 'bar'] for /foo/bar
req.query Is Read-Only
req.query is a getter in Express 5 — cannot be reassigned. Default parser changed from "extended" to "simple".
req.host Includes Port
req.host now returns example.com:8080 instead of just example.com.
req.body Is undefined When Not Parsed
Without body-parsing middleware, req.body is undefined (was {} in v4).
Removed APIs
app.del() — use app.delete()
req.param(name) — use req.params.id, req.body.field, or req.query.key explicitly
res.redirect('back') — use res.redirect(req.get('Referrer') || '/')
res.send(status) with a number — use res.sendStatus(200) or res.status(200).send()
express.static.mime — use the mime-types package directly
Changed Signatures
res.redirect(url, status) arg order reversed: res.redirect(301, '/new-url')
res.json(obj, status) removed: use res.status(201).json(obj)
res.status() only accepts integers 100-999 (no strings, no values < 100)
Other Behavioral Changes
express.urlencoded defaults to extended: false (was true)
express.static defaults to dotfiles: 'ignore' (dot-prefixed files return 404)
res.clearCookie() ignores maxAge and expires options
res.vary() throws when field argument is missing (was silent warning)
2. Async Error Handling
Express 5 catches rejected promises automatically. The project uses explicit try/catch only when it needs custom error responses or cleanup.
Simple Throws vs Explicit Try/Catch
export const getScene = async (req: Request, res: Response) => {
const scene = await prisma.stashScene.findFirst({ where: { id: req.params.id } });
if (!scene) throw new NotFoundError("Scene not found");
res.json(scene);
};
export const proxyScenePreview = async (req: Request, res: Response) => {
if (!req.params.id) return res.status(400).json({ error: "Missing scene ID" });
try {
const creds = getInstanceCredentials(scene.stashInstanceId ?? undefined);
} catch (error) {
logger.error("Failed to get credentials", { error });
return res.status(500).json({ error: "Stash configuration missing" });
}
};
Error Middleware (must have 4 params for Express to recognize it)
app.use((err: Error, req: Request, res: Response, next: NextFunction) => {
if (err instanceof AppError) return res.status(err.statusCode).json({ error: err.message });
logger.error("Unhandled error", { error: err.message, stack: err.stack });
res.status(500).json({ error: "Internal server error" });
});
class AppError extends Error {
constructor(public message: string, public statusCode: number = 500) { super(message); }
}
class NotFoundError extends AppError { constructor(msg = "Not found") { super(msg, 404); } }
class ValidationError extends AppError { constructor(msg: string) { super(msg, 400); } }
3. TypeScript Patterns
Typed Request/Response Helpers (server/types/api/express.ts)
interface TypedRequest<
TBody = unknown,
TParams extends Record<string, string> = Record<string, string>,
TQuery extends Record<string, string | string[] | undefined> = Record<string, string | undefined>
> extends Request { body: TBody; params: TParams; query: TQuery; user?: RequestUser; }
interface TypedAuthRequest<TBody, TParams, TQuery> extends TypedRequest<TBody, TParams, TQuery> {
user: RequestUser;
}
type TypedResponse<T> = Response<T>;
Using Typed Handlers
interface UpdateRatingRequest { rating?: number | null; favorite?: boolean; instanceId?: string; }
interface UpdateSceneRatingParams { sceneId: string; }
export async function updateSceneRating(
req: TypedAuthRequest<UpdateRatingRequest, UpdateSceneRatingParams>,
res: TypedResponse<UpdateRatingResponse | ApiErrorResponse>
) {
const userId = req.user.id;
const { sceneId } = req.params;
const { rating, favorite } = req.body;
}
Type Bridge for Router Registration
Express's RequestHandler doesn't match custom typed handlers. Bridge with cast:
export function authenticated(handler: (...args: any[]) => any): RequestHandler {
return handler as unknown as RequestHandler;
}
router.put("/scene/:sceneId", authenticated(updateSceneRating));
AuthenticatedRequest for Middleware
interface AuthenticatedRequest extends Request { user: RequestUser; }
4. Proxy Middleware
Based on server/controllers/proxy.ts. All media requests to upstream Stash servers go through Peek's proxy to hide API keys from clients.
Connection Pooling
const httpAgent = new http.Agent({
keepAlive: true,
maxSockets: 6,
keepAliveMsecs: 30000,
});
const httpsAgent = new https.Agent({
keepAlive: true,
maxSockets: 6,
keepAliveMsecs: 30000,
});
Concurrency Limiting
Queue-based limiter prevents overwhelming upstream servers:
const MAX_CONCURRENT_REQUESTS = 6;
let activeRequests = 0;
const requestQueue: Array<() => void> = [];
function acquireConcurrencySlot(): Promise<void> {
return new Promise((resolve) => {
if (activeRequests < MAX_CONCURRENT_REQUESTS) {
activeRequests++;
resolve();
} else {
requestQueue.push(() => { activeRequests++; resolve(); });
}
});
}
function releaseConcurrencySlot(): void {
activeRequests--;
const next = requestQueue.shift();
if (next) next();
}
Shared Proxy Helper with Cleanup
Key patterns: double-release guard, client disconnect cleanup, timeout handling.
function proxyHttpRequest({ fullUrl, res, label, defaultCacheControl, timeoutMs }: ProxyOptions): void {
let slotReleased = false;
const releaseOnce = () => {
if (!slotReleased) { slotReleased = true; releaseConcurrencySlot(); }
};
const proxyReq = httpModule.get(fullUrl, { agent }, (proxyRes) => {
if (proxyRes.headers["content-type"]) res.setHeader("Content-Type", proxyRes.headers["content-type"]);
res.status(proxyRes.statusCode || 200);
proxyRes.pipe(res);
proxyRes.on("end", releaseOnce);
proxyRes.on("error", releaseOnce);
});
res.on("close", () => {
if (!proxyReq.destroyed) proxyReq.destroy();
releaseOnce();
});
proxyReq.on("error", (error) => {
releaseOnce();
if ((error as NodeJS.ErrnoException).code === "ECONNRESET") return;
if (!res.headersSent) res.status(500).json({ error: "Proxy request failed" });
});
proxyReq.setTimeout(timeoutMs, () => {
releaseOnce();
proxyReq.destroy();
if (!res.headersSent) res.status(504).json({ error: "Proxy request timeout" });
});
}
API Key Injection
API keys are appended as query params to upstream URLs, never exposed to the client:
const fullUrl = `${stashUrl}/scene/${id}/preview?apikey=${apiKey}`;
logger.debug("Proxying", { url: fullUrl.replace(apiKey, "***") });
5. HLS Video Streaming
Based on server/controllers/video.ts. Peek proxies Stash's HLS streams and rewrites playlist URLs.
Stream Proxy with Fetch + AbortController
export const proxyStashStream = async (req: Request, res: Response) => {
const { sceneId, streamPath, subPath } = req.params;
const instanceId = req.query.instanceId as string | undefined;
const fullStreamPath = subPath ? `${streamPath}/${subPath}` : streamPath;
const abortController = new AbortController();
res.on('close', () => abortController.abort());
const headers: Record<string, string> = { 'ApiKey': apiKey };
if (req.headers.range) headers['Range'] = req.headers.range;
const response = await fetch(stashUrl, { headers, signal: abortController.signal });
};
HLS Playlist Rewriting
Strip API keys from segment URLs and route through Peek's proxy:
function rewriteHlsPlaylist(content: string, sceneId: string, stashBaseUrl: string, instanceId?: string): string {
return content.split('\n').map(line => {
if (!line.trim() || line.startsWith('#')) return line;
queryParams.delete('apikey');
if (instanceId) queryParams.set('instanceId', instanceId);
return `/api/scene/${sceneId}/proxy-stream/${streamPath}${queryString}`;
}).join('\n');
}
HLS Content-Type Detection
const isHlsPlaylist = fullStreamPath.endsWith('.m3u8') ||
contentType.includes('mpegurl') ||
contentType.includes('x-mpegURL');
Stream Piping with Backpressure (server/utils/streamProxy.ts)
Uses Readable.fromWeb() + stream.pipeline() for proper backpressure. Silently swallows AbortError and ERR_STREAM_PREMATURE_CLOSE since they are expected when client disconnects (seek, refresh, navigate away).
const nodeStream = Readable.fromWeb(fetchResponse.body as import("stream/web").ReadableStream);
try {
await pipeline(nodeStream, res);
} catch (err: unknown) {
if (err instanceof Error && (err.name === "AbortError" ||
(err as NodeJS.ErrnoException).code === "ERR_STREAM_PREMATURE_CLOSE")) return;
logger.error(`${label} Stream pipeline error`, { error: (err as Error).message });
}
Route Registration for Multi-Segment Paths
Two routes handle both single-segment and nested HLS paths:
router.get("/scene/:sceneId/proxy-stream/:streamPath/:subPath", proxyStashStream);
router.get("/scene/:sceneId/proxy-stream/:streamPath", proxyStashStream);
6. Multi-Instance Routing
Peek routes requests to different Stash servers based on instanceId.
Instance Credential Resolution
function getInstanceCredentials(instanceId?: string): { baseUrl: string; apiKey: string } {
if (instanceId && instanceId !== "default") {
const instance = stashInstanceManager.get(instanceId);
if (!instance) throw new Error(`Stash instance not found: ${instanceId}`);
return {
baseUrl: stashInstanceManager.getBaseUrl(instanceId),
apiKey: stashInstanceManager.getApiKey(instanceId),
};
}
return {
baseUrl: stashInstanceManager.getBaseUrl(),
apiKey: stashInstanceManager.getApiKey(),
};
}
Per-Entity Instance Lookup
Entities store their stashInstanceId in the database. Controllers look it up before proxying:
const scene = await prisma.stashScene.findFirst({
where: { id, deletedAt: null },
select: { stashInstanceId: true },
});
const creds = getInstanceCredentials(scene.stashInstanceId ?? undefined);
instanceId in Query Params
For stream proxying, instanceId travels as a query param and is stripped before forwarding:
const instanceId = req.query.instanceId as string | undefined;
const urlParams = new URLSearchParams(req.url.split('?')[1] || '');
urlParams.delete('instanceId');
7. Authentication Middleware
Based on server/middleware/auth.ts. JWT tokens stored in HTTP-only cookies.
Token Cookie Configuration
res.cookie("token", token, {
httpOnly: true,
secure: process.env.SECURE_COOKIES === "true",
sameSite: "strict",
maxAge: TOKEN_EXPIRY_HOURS * 60 * 60 * 1000,
});
Dual Auth: Cookie + Bearer Token
const token = req.cookies?.token || req.header("Authorization")?.replace("Bearer ", "");
Silent Token Refresh
Tokens older than 1 hour are automatically refreshed for cookie-based auth (not Bearer tokens):
if (req.cookies?.token && decoded.iat) {
const tokenAgeHours = (Date.now() / 1000 - decoded.iat) / 3600;
if (tokenAgeHours > TOKEN_REFRESH_THRESHOLD_HOURS) {
const newToken = generateToken({ id: user.id, username: user.username, role: user.role });
setTokenCookie(res, newToken);
}
}
Reverse Proxy Auth Header Support
Check for a configurable header before falling back to JWT:
export const authenticate = async (req: Request, res: Response, next: NextFunction) => {
const proxyAuthHeader = process.env.PROXY_AUTH_HEADER;
if (proxyAuthHeader) {
const username = req.header(proxyAuthHeader);
if (username) return await authenticateUser(username, req, res, next);
}
return await authenticateToken(req, res, next);
};
Role-Based Middleware
export const requireAdmin = (req: Request, res: Response, next: NextFunction) => {
const authReq = req as AuthenticatedRequest;
if (!authReq.user || authReq.user.role !== "ADMIN") {
return res.status(403).json({ error: "Admin access required." });
}
next();
};
Middleware Chaining on Routes
app.get("/api/stats", authenticate, requireAdmin, statsController.getStats);
app.get("/api/scenes/:id/clips", authenticate, requireCacheReady, getClipsForScene);
const router = express.Router();
router.use(authenticate);
router.put("/scene/:sceneId", authenticated(updateSceneRating));
8. HTTP Caching
Cache-Control Headers by Content Type
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
res.setHeader("Cache-Control", "public, max-age=86400");
res.setHeader("Cache-Control", "no-cache");
Forwarding Upstream Cache Headers
When proxying, prefer upstream's Cache-Control but provide a default:
if (proxyRes.headers["cache-control"]) {
res.setHeader("Cache-Control", proxyRes.headers["cache-control"]);
} else {
res.setHeader("Cache-Control", defaultCacheControl);
}
Headers Forwarded for Streaming
const headersToForward = [
'content-type', 'content-length', 'accept-ranges',
'content-range', 'cache-control', 'last-modified', 'etag',
];
9. Response Patterns
Consistent JSON Error Responses
All API errors use { error: string } shape, defined in types/api/common.ts:
interface ApiErrorResponse {
error: string;
message?: string;
details?: string;
}
res.status(400).json({ error: "Missing scene ID" });
res.status(404).json({ error: "Scene not found" });
res.status(500).json({ error: "Internal server error" });
res.status(503).json({ error: "Server is initializing", message: "Cache is still loading.", ready: false });
Success Responses with Entity Data
res.json({ success: true, rating: sceneRating });
res.json({ status: "healthy", timestamp: new Date().toISOString(), version: "3.3.5" });
Streaming Responses
Two patterns for streaming: Node.js http module pipe and fetch-to-Express pipeline.
http module pipe (proxy.ts):
proxyRes.pipe(res);
fetch + Readable.fromWeb (video.ts, streamProxy.ts):
const nodeStream = Readable.fromWeb(fetchResponse.body as ReadableStream);
await pipeline(nodeStream, res);
Guard Against Double-Send
Always check res.headersSent before sending error responses in catch blocks or event handlers:
if (!res.headersSent) {
res.status(500).json({ error: "Internal server error" });
}
10. Middleware Organization
App-Level Middleware Order (server/initializers/api.ts)
app.set("trust proxy", ...) -- must be first for rate limiting / IP detection
cors({ credentials: true, origin: [...] }) -- CORS
express.json() -- body parsing
cookieParser() -- needed for JWT extraction from cookies
- Public routes (no auth) -- health, version, media proxy endpoints
- Auth routes (public) -- login/register via
app.use("/api/auth", authRoutes)
- Protected routes -- each router applies
authenticate internally
- Video/streaming routes (last) -- catch-all patterns via
app.use("/api", videoRoutes)
Router-Level Auth Pattern
Apply auth to all routes in a router, then use authenticated() wrapper for type safety:
const router = express.Router();
router.use(authenticate);
router.put("/scene/:sceneId", authenticated(updateSceneRating));
router.put("/performer/:performerId", authenticated(updatePerformerRating));
export default router;
Mixed Auth Routers
Some routers have both public and protected endpoints:
router.get("/status", getSetupStatus);
router.post("/complete", authenticate, requireAdmin, completeSetup);
Middleware Factories
import rateLimit from "express-rate-limit";
const authLimiter = rateLimit({ windowMs: 15 * 60 * 1000, max: 10 });
router.post("/login", authLimiter, loginHandler);
Conditional Middleware
const trustProxy = process.env.TRUST_PROXY;
if (trustProxy) {
if (trustProxy === "true") app.set("trust proxy", true);
else if (/^\d+$/.test(trustProxy)) app.set("trust proxy", parseInt(trustProxy, 10));
else app.set("trust proxy", trustProxy);
}
Service Readiness Guard
Block API access until cache is warm:
export const requireCacheReady = async (_req: Request, res: Response, next: NextFunction) => {
const isReady = await stashEntityService.isReady();
if (!isReady) {
return res.status(503).json({ error: "Server is initializing", ready: false });
}
next();
};