@stacksjs/router
Version:
The Stacks framework router.
311 lines (310 loc) • 9.98 kB
JavaScript
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
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" }
});
}