UNPKG

@stacksjs/router

Version:
311 lines (310 loc) 9.98 kB
import process from "node:process"; import { log } from "@stacksjs/logging"; import { createErrorHandler, renderProductionErrorPage } from "@stacksjs/error-handling"; import { isApiRequest } from "./api-shape"; import { getCurrentRequest } from "./request-context"; function buildErrorJson(opts) { const body = { error: opts.error, message: opts.message, status: opts.status, timestamp: new Date().toISOString() }; if (opts.details) body.details = opts.details; return JSON.stringify(body); } function isDebugAllowed() { const appEnv = (process.env.APP_ENV ?? "").toLowerCase(); if (appEnv === "development") return !0; if (!appEnv && process.env.NODE_ENV === "development") return !0; return !1; } function getJsonHeaders() { return { "Content-Type": "application/json" }; } function getJsonHeadersFull() { return getJsonHeaders(); } const MAX_QUERIES = 50, N1_THRESHOLD = 5; function newQueryTrack() { return { buffer: Array(MAX_QUERIES).fill(null), writeIndex: 0, count: 0, shapeCounts: new Map, n1Warned: new Set }; } const REQUEST_QUERY_TRACK_KEY = Symbol.for("stacks.queryTracking"); let fallbackTrack = newQueryTrack(); function getQueryTrack() { const req = getCurrentRequest(); if (!req) return fallbackTrack; let track = req[REQUEST_QUERY_TRACK_KEY]; if (!track) { track = newQueryTrack(); req[REQUEST_QUERY_TRACK_KEY] = track; } return track; } function normalizeQueryShape(query) { return query.replace(/'(?:[^']|'')*'/g, "?").replace(/"(?:[^"]|"")*"/g, "?").replace(/\b\d+(?:\.\d+)?\b/g, "?").replace(/IN\s*\([^)]*\)/gi, "IN (?)").replace(/\s+/g, " ").trim().toUpperCase(); } export function trackQuery(query, time, connection) { const track = getQueryTrack(); track.buffer[track.writeIndex] = { query, time, connection }; track.writeIndex = (track.writeIndex + 1) % MAX_QUERIES; if (track.count < MAX_QUERIES) track.count++; if (!isDebugAllowed()) return; const shape = normalizeQueryShape(query); if (shape.startsWith("INSERT INTO QUERY_LOGS") || shape.startsWith("EXPLAIN")) return; const next = (track.shapeCounts.get(shape) ?? 0) + 1; track.shapeCounts.set(shape, next); if (next === N1_THRESHOLD + 1 && !track.n1Warned.has(shape)) { track.n1Warned.add(shape); import("@stacksjs/logging").then(({ log }) => { log.warn(`[orm] Possible N+1 \u2014 query shape ran ${next}\xD7 in this request: ${shape} Hint: load related rows with .with('relation') or eager-load via includes() before iterating.`); }).catch(() => {}); } } function getRecentQueries() { const track = getQueryTrack(); if (track.count === 0) return []; const result = [], start = track.count < MAX_QUERIES ? 0 : track.writeIndex; for (let i = 0;i < track.count; i++) { const entry = track.buffer[(start + i) % MAX_QUERIES]; if (entry) result.push(entry); } return result; } export function getQueryShapeCounts() { return new Map(getQueryTrack().shapeCounts); } export function clearTrackedQueries() { const req = getCurrentRequest(); if (req && req[REQUEST_QUERY_TRACK_KEY]) { req[REQUEST_QUERY_TRACK_KEY] = newQueryTrack(); return; } fallbackTrack = newQueryTrack(); } function getErrorHandlerConfig() { return { appName: "Stacks", theme: "auto", showEnvironment: !0, showQueries: !0, showRequest: !0, enableCopyMarkdown: !0, snippetLines: 8, basePaths: [process.cwd()] }; } const SENSITIVE_PATTERNS = [ "password", "secret", "token", "api_key", "apikey", "access_key", "accesskey", "private_key", "privatekey", "credit_card", "creditcard", "card_number", "cardnumber", "cvv", "ssn", "authorization", "credential", "aws_secret", "aws_access", "database_password", "db_password", "encryption_key", "signing_key", "bearer", "session_id", "sessionid", "cookie" ], MAX_SANITIZE_DEPTH = 10, CIRCULAR_PLACEHOLDER = "[Circular]"; function sanitizeData(data, depth = 0, seen = new WeakSet) { if (!data || typeof data !== "object" || depth >= MAX_SANITIZE_DEPTH) return data; if (seen.has(data)) return CIRCULAR_PLACEHOLDER; seen.add(data); if (Array.isArray(data)) return data.map((item) => sanitizeData(item, depth + 1, seen)); const sanitized = {}; for (const [key, value] of Object.entries(data)) { const lowerKey = key.toLowerCase(); if (SENSITIVE_PATTERNS.some((pattern) => lowerKey.includes(pattern))) sanitized[key] = "********"; else if (typeof value === "object" && value !== null) sanitized[key] = sanitizeData(value, depth + 1, seen); else sanitized[key] = value; } return sanitized; } function getRequestBody(request) { const req = request; if (req.jsonBody) return sanitizeData(req.jsonBody); if (req.formBody) return sanitizeData(req.formBody); return; } async function getUserContext(request) { const authed = request._authenticatedUser; if (authed) return { id: authed.id, email: authed.email, name: authed.name || authed.username }; return; } export async function createErrorResponse(error, request, options) { const status = options?.status || 500; log.debug(`[error] ${status} ${error.message}`); if (!isDebugAllowed()) { if (isApiRequest(request)) { const isClientError = status >= 400 && status < 500, errDetails = error.details; return new Response(buildErrorJson({ error: isClientError ? error.name || "Client Error" : "Internal Server Error", message: isClientError ? error.message : "An unexpected error occurred.", status, details: isClientError && errDetails && typeof errDetails === "object" ? errDetails : void 0 }), { status, headers: getJsonHeaders() }); } return new Response(renderProductionErrorPage(status), { status, headers: { "Content-Type": "text/html; charset=utf-8" } }); } try { const handler = createErrorHandler(getErrorHandlerConfig()); handler.setFramework("Stacks", "0.70.0"); const requestBody = getRequestBody(request); if (requestBody) { const url = new URL(request.url); handler.setRequest({ method: request.method, url: request.url, headers: Object.fromEntries(request.headers.entries()), queryParams: Object.fromEntries(url.searchParams.entries()), body: requestBody }); } else handler.setRequest(request); const userContext = await getUserContext(request); if (userContext) handler.setUser(userContext); if (options?.routingContext) handler.setRouting(options.routingContext); else if (options?.handlerPath) handler.setRouting({ controller: options.handlerPath }); for (const query of getRecentQueries()) handler.addQuery(query.query, query.time, query.connection); if (isApiRequest(request)) { const details = { handler: options?.handlerPath }; if (isDebugAllowed()) { details.stack = error.stack?.split(` `).slice(0, 10); details.queries = getRecentQueries().slice(-10); } return new Response(buildErrorJson({ error: error.name || "Error", message: error.message, status, details }), { status, headers: getJsonHeadersFull() }); } const corsOrigin = process.env.APP_URL ? process.env.APP_URL.startsWith("http") ? process.env.APP_URL : `https://${process.env.APP_URL}` : isDebugAllowed() ? "*" : request.headers.get("origin") ?? "null", html = await handler.render(error, status); return new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8", "Access-Control-Allow-Origin": corsOrigin } }); } catch (renderError) { console.error("[Error Handler] Failed to render error page:", renderError); const escapeHtml = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); return new Response(` <html> <head><title>Error</title></head> <body> <h1>Error</h1> <p>${escapeHtml(error.message)}</p> <pre>${escapeHtml(error.stack || "")}</pre> </body> </html> `, { status, headers: { "Content-Type": "text/html; charset=utf-8" } }); } } export async function createMiddlewareErrorResponse(error, request) { const status = error.statusCode ?? error.status ?? 500, isDevelopment = isDebugAllowed(); if (status >= 400 && status < 500) { const headers = error.headers ? { ...error.headers, ...getJsonHeaders() } : getJsonHeaders(); return new Response(buildErrorJson({ error: error.name || "ClientError", message: error.message, status }), { status, headers }); } if (isDevelopment) return await createErrorResponse(error, request, { status }); return new Response(buildErrorJson({ error: "Internal Server Error", message: "An unexpected error occurred.", status }), { status, headers: getJsonHeaders() }); } export function createValidationErrorResponse(errors, _request) { return new Response(buildErrorJson({ error: "ValidationError", message: "Validation failed", status: 422, details: { errors } }), { status: 422, headers: getJsonHeaders() }); } export async function createNotFoundResponse(path, request) { if (isDebugAllowed()) { const error = Error(`Route not found: ${path}`); error.name = "NotFoundError"; return await createErrorResponse(error, request, { status: 404 }); } if (isApiRequest(request)) return new Response(buildErrorJson({ error: "NotFound", message: `Route not found: ${path}`, status: 404 }), { status: 404, headers: getJsonHeaders() }); return new Response(renderProductionErrorPage(404), { status: 404, headers: { "Content-Type": "text/html; charset=utf-8" } }); }