@stacksjs/error-handling
Version:
Type safe error handling.
30 lines • 11.4 kB
JavaScript
var {require}=import.meta;const DOCS_BASE="https://stacksjs.org/docs/errors";export const HTTP_ERRORS={400:{status:400,title:"Bad Request",message:"The request was malformed or invalid.",docLink:`${DOCS_BASE}/400`,commonCauses:["JSON body is missing or has a syntax error","A required field is absent from the payload","Content-Type header does not match the body format"],suggestion:"Inspect the request body and Content-Type \u2014 most 400s come from malformed JSON or a missing required field."},401:{status:401,title:"Unauthorized",message:"Authentication is required to access this resource.",docLink:`${DOCS_BASE}/401`,commonCauses:["No Authorization header was sent","The bearer token expired","The session cookie was cleared"],suggestion:"Confirm a valid `Authorization: Bearer <token>` header is sent and the token has not expired."},403:{status:403,title:"Forbidden",message:"You do not have permission to access this resource.",docLink:`${DOCS_BASE}/403`,commonCauses:["The authenticated user lacks the required ability or role","A Gate or policy denied access (see app/Gates.ts)","The token was issued without the needed ability"],suggestion:"Check Gates / policies and the abilities encoded in the access token."},404:{status:404,title:"Not Found",message:"The requested resource could not be found.",docLink:`${DOCS_BASE}/404`,commonCauses:["The route is not registered in app/Routes.ts","A typo in the URL path","A model lookup returned no row (ModelNotFoundError)"],suggestion:"Run `buddy route:list` to see registered routes, or verify the model exists with the given id."},405:{status:405,title:"Method Not Allowed",message:"The request method is not supported for this resource.",docLink:`${DOCS_BASE}/405`,commonCauses:["The route is registered for a different HTTP method","A form posted GET when the route expects POST"],suggestion:"Confirm the HTTP method in `app/Routes.ts` matches what the client sent."},408:{status:408,title:"Request Timeout",message:"The request took too long to complete.",docLink:`${DOCS_BASE}/408`,commonCauses:["A long-running query or external API call exceeded the timeout","The client uploaded a slow body that stalled"],suggestion:"Move slow work into a queued job, or raise the route timeout if the work is genuinely long."},409:{status:409,title:"Conflict",message:"The request conflicts with the current state of the resource.",docLink:`${DOCS_BASE}/409`,commonCauses:["A unique-constraint violation (duplicate email, slug, etc.)","Optimistic locking detected a stale write"],suggestion:"Re-fetch the resource and retry, or surface the conflict to the user."},410:{status:410,title:"Gone",message:"The requested resource is no longer available.",docLink:`${DOCS_BASE}/410`,commonCauses:["The resource was permanently deleted","A signed URL expired"],suggestion:"Issue a fresh signed URL or fall back to the canonical resource."},422:{status:422,title:"Unprocessable Entity",message:"The request was well-formed but could not be processed.",docLink:`${DOCS_BASE}/422`,commonCauses:["Validation rules from the action / model rejected the payload","A field value is outside the allowed range or shape"],suggestion:"Inspect `errors` in the response body \u2014 each key maps to a failing field."},429:{status:429,title:"Too Many Requests",message:"You have exceeded the rate limit.",docLink:`${DOCS_BASE}/429`,commonCauses:["Rate-limit middleware tripped on this IP / token","A retry loop is hammering the endpoint"],suggestion:"Honor the `Retry-After` response header and back off before retrying."},500:{status:500,title:"Internal Server Error",message:"An unexpected error occurred on the server.",docLink:`${DOCS_BASE}/500`,commonCauses:["An unhandled exception in an action or middleware","A failing database connection or migration","A misconfigured environment variable"],suggestion:"Check server logs for the original stack trace \u2014 the error page above shows the throw site in dev."},502:{status:502,title:"Bad Gateway",message:"The server received an invalid response from an upstream server.",docLink:`${DOCS_BASE}/502`,commonCauses:["An upstream HTTP API returned a malformed response","A reverse proxy could not reach the origin"],suggestion:"Verify the upstream service is healthy and returning the expected content type."},503:{status:503,title:"Service Unavailable",message:"The service is temporarily unavailable.",docLink:`${DOCS_BASE}/503`,commonCauses:["Maintenance mode is enabled","A health check is failing","A dependency (db, redis, queue) is down"],suggestion:"Run `buddy doctor` and check dependent services."},504:{status:504,title:"Gateway Timeout",message:"The upstream server did not respond in time.",docLink:`${DOCS_BASE}/504`,commonCauses:["An upstream HTTP call exceeded its deadline","A long-running database query timed out"],suggestion:"Move the work to a queued job or raise the upstream timeout if the latency is expected."}};import{buildErrorMarkdown,escapeHtml,renderExceptionTrace,wrapErrorPage}from"./error-page-template";import{ERROR_PAGE_CSS}from"./error-page-styles";export{ERROR_PAGE_CSS};const FRAMEWORK_FRAME_FRAGMENTS=["/storage/framework/core/","/node_modules/@stacksjs/","node:internal/","bun:wrap"];export function isFrameworkFrame(file){if(!file)return!1;return FRAMEWORK_FRAME_FRAGMENTS.some((f)=>file.includes(f))}function parseStackTrace(stack,basePaths,options={}){if(!stack)return[];const lines=stack.split(`
`).slice(1),frames=[],includeAll=options.includeFrameworkFrames===!0;for(const line of lines){const match=line.match(/^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/);if(match){let file=match[2];if(file===void 0)continue;const original=file,isFramework=isFrameworkFrame(original);if(basePaths){for(const basePath of basePaths)if(file.startsWith(basePath)){file=file.slice(basePath.length+1);break}}if(!includeAll&&isFramework)continue;frames.push({function:match[1]||"<anonymous>",file,absoluteFile:original,isFramework,line:parseInt(match[3]??"0",10),column:parseInt(match[4]??"0",10)})}}if(!includeAll&&frames.length===0)return parseStackTrace(stack,basePaths,{includeFrameworkFrames:!0});return frames}function loadCustomErrorPage(status,title,message){let resourcesPath;try{const mod=require("@stacksjs/path");resourcesPath=mod.resourcesPath??mod.path?.resourcesPath}catch{}if(!resourcesPath)return null;const fs=require("node:fs"),candidates=[resourcesPath(`views/errors/${status}.html`),resourcesPath("views/errors/error.html")];for(const filePath of candidates)try{if(!fs.existsSync(filePath))continue;return fs.readFileSync(filePath,"utf-8").replace(/\{\{\s*status\s*\}\}/g,escapeHtml(String(status))).replace(/\{\{\s*title\s*\}\}/g,escapeHtml(title)).replace(/\{\{\s*message\s*\}\}/g,escapeHtml(message))}catch{}return null}export function renderProductionErrorPage(status){const httpError=HTTP_ERRORS[status]||{status,title:"Error",message:"An unexpected error occurred."},custom=loadCustomErrorPage(httpError.status,httpError.title,httpError.message);if(custom!==null)return custom;return`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>${httpError.status} - ${httpError.title}</title>
<style>${ERROR_PAGE_CSS}</style>
</head>
<body>
<div class="production-page">
<div class="production-status">${httpError.status}</div>
<h1 class="production-title">${escapeHtml(httpError.title)}</h1>
<p class="production-message">${escapeHtml(httpError.message)}</p>
<a href="/" class="production-link">\u2190 Back to Home</a>
</div>
</body>
</html>`}export function renderHttpErrorHints(status){const info=HTTP_ERRORS[status];if(!info)return"";const hasCauses=Array.isArray(info.commonCauses)&&info.commonCauses.length>0,hasSuggestion=typeof info.suggestion==="string"&&info.suggestion.length>0,hasDoc=typeof info.docLink==="string"&&info.docLink.length>0;if(!hasCauses&&!hasSuggestion&&!hasDoc)return"";const causes=hasCauses?`<ul class="error-hint-causes">${info.commonCauses.map((c)=>`<li>${escapeHtml(c)}</li>`).join("")}</ul>`:"",suggestion=hasSuggestion?`<p class="error-hint-suggestion">${escapeHtml(info.suggestion)}</p>`:"",doc=hasDoc?`<a class="text-blue-600 text-sm dark:text-blue-500 hover:underline" href="${escapeHtml(info.docLink)}" target="_blank" rel="noreferrer noopener">Read the docs \u2192</a>`:"";return`<section class="p-4 bg-amber-200/30 dark:bg-amber-950/40 border border-amber-200 rounded-xl dark:border-amber-800 shadow-xs">
<div class="mb-2 font-semibold text-amber-900 text-sm dark:text-amber-300">Likely causes & next steps</div>
<div class="text-neutral-700 text-sm dark:text-neutral-300">${causes}${suggestion}${doc}</div>
</section>`}export class ErrorPageHandler{config;framework;request;routing;user;queries=[];constructor(config){this.config={appName:"App",theme:"auto",showEnvironment:!0,showQueries:!0,showRequest:!0,enableCopyMarkdown:!0,snippetLines:8,...config}}setFramework(name,version){this.framework={name,version};return this}setRequest(request){if(request instanceof Request){const url=new URL(request.url);this.request={method:request.method,url:request.url,headers:Object.fromEntries(request.headers.entries()),queryParams:Object.fromEntries(url.searchParams.entries())}}else this.request=request;return this}setRouting(routing){this.routing=routing;return this}setUser(user){this.user=user;return this}addQuery(query,time,connection){this.queries.push({query,time,connection});return this}async render(error,status=500){try{const{renderDevErrorPage}=await import("./error-page-renderer");return await renderDevErrorPage({error,status,config:this.config,framework:this.framework,request:this.request,routing:this.routing,user:this.user,queries:this.queries})}catch(renderError){console.error("[ErrorPageHandler] STX render failed, using fallback:",renderError);return this.renderFallback(error,status)}}renderFallback(error,status){const frames=parseStackTrace(error.stack,this.config.basePaths,{includeFrameworkFrames:this.config.showFrameworkFrames===!0}),statusTitle=HTTP_ERRORS[status]?.title??"Error",topFrame=frames[0],body=`
<header class="header"><div class="header-title"><span class="header-dot"></span><span>${escapeHtml(statusTitle)}</span></div></header>
<section class="summary">
<h1>${escapeHtml(error.name||"Error")}</h1>
${topFrame?`<div class="summary-file">${escapeHtml(topFrame.file)}:${topFrame.line}</div>`:""}
<p class="summary-message">${escapeHtml(error.message)}</p>
</section>
${renderHttpErrorHints(status)}
${renderExceptionTrace(frames,this.config.snippetLines??8)}
`,markdown=buildErrorMarkdown({statusTitle,errorName:error.name||"Error",errorMessage:error.message,status,file:topFrame?.file,line:topFrame?.line,request:this.request,framework:this.framework,frames});return wrapErrorPage(body,markdown)}async handleError(error,status=500){const html=await this.render(error,status);return new Response(html,{status,headers:{"Content-Type":"text/html; charset=utf-8"}})}}export function createErrorHandler(config){return new ErrorPageHandler(config)}export async function renderErrorPage(error,status=500,config){return createErrorHandler(config).render(error,status)}export async function renderError(error,status=500){return renderErrorPage(error,status)}export async function errorResponse(error,status=500,config){return createErrorHandler(config).handleError(error,status)}