@stacksjs/error-handling
Version:
Type safe error handling.
79 lines (78 loc) • 7.76 kB
JavaScript
var {require}=import.meta;import{ERROR_PAGE_CSS}from"./error-page-styles";export function escapeHtml(str){return str.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'")}export function readCodeSnippet(filePath,line,radius=6){try{const fs=require("node:fs");if(!filePath||!fs.existsSync(filePath))return null;const allLines=fs.readFileSync(filePath,"utf-8").split(`
`),start=Math.max(0,line-radius-1),end=Math.min(allLines.length,line+radius);return{lines:allLines.slice(start,end).map((code,i)=>({number:start+i+1,content:code,highlight:start+i+1===line}))}}catch{return null}}export function renderCodeSnippet(snippet){return`<div class="code-block">${snippet.lines.map((l)=>`
<div class="code-line${l.highlight?" highlight":""}">
<span class="code-ln">${l.number}</span>
<span class="code-txt">${escapeHtml(l.content||" ")}</span>
</div>`).join("")}
</div>`}export function renderTraceFrame(frame,index,snippetLines){const snippet=readCodeSnippet(frame.absoluteFile,frame.line,snippetLines),snippetHtml=snippet?renderCodeSnippet(snippet):"";return`<div class="trace-frame${index===0?" expanded":""}" data-frame>
<div class="trace-frame-header" onclick="this.parentElement.classList.toggle('expanded')">
<span class="trace-frame-fn">${escapeHtml(frame.function||"<anonymous>")}</span>
<span class="trace-frame-file">${escapeHtml(frame.file)}:${frame.line}</span>
</div>
${snippetHtml?`<div class="trace-frame-body">${snippetHtml}</div>`:""}
</div>`}export function renderVendorGroup(frames,snippetLines){const count=frames.length,label=count===1?"1 framework frame":`${count} framework frames`,inner=frames.map((f,i)=>renderTraceFrame(f,i+1,snippetLines)).join("");return`<div class="trace-vendor" data-vendor-group>
<div class="trace-vendor-toggle" onclick="this.parentElement.classList.toggle('expanded')">
<span>${escapeHtml(label)}</span>
</div>
<div class="trace-vendor-frames">${inner}</div>
</div>`}export function groupTraceFrames(frames){const groups=[];let vendorBatch=[];const flushVendor=()=>{if(vendorBatch.length>0){groups.push(vendorBatch.length===1?vendorBatch[0]:[...vendorBatch]);vendorBatch=[]}};for(const frame of frames)if(frame.isFramework)vendorBatch.push(frame);else{flushVendor();groups.push(frame)}flushVendor();return groups}export function renderExceptionTrace(frames,snippetLines){if(frames.length===0)return"";return`<section class="trace">
<h2 class="trace-title">Exception trace</h2>
${groupTraceFrames(frames).map((group,i)=>{if(Array.isArray(group))return renderVendorGroup(group,snippetLines);return renderTraceFrame(group,i,snippetLines)}).join("")}
</section>`}export function renderContextTabs(opts){const tabs=[];if(opts.request){const headerRows=Object.entries(opts.request.headers).map(([k,v])=>`<tr><td>${escapeHtml(k)}</td><td>${escapeHtml(v)}</td></tr>`).join("");tabs.push({id:"headers",label:"Headers",html:headerRows?`<table class="kv-table">${headerRows}</table>`:'<p style="color:var(--neutral-500);font-size:0.875rem">No headers</p>'});const bodyContent=opts.request.body?`<div class="json-block">${escapeHtml(JSON.stringify(opts.request.body,null,2))}</div>`:'<p style="color:var(--neutral-500);font-size:0.875rem">// No request body</p>';tabs.push({id:"body",label:"Body",html:bodyContent})}if(opts.routing){const rows=[opts.routing.controller?`<tr><td>controller</td><td>${escapeHtml(opts.routing.controller)}</td></tr>`:"",opts.routing.routeName?`<tr><td>route name</td><td>${escapeHtml(opts.routing.routeName)}</td></tr>`:"",opts.routing.middleware?.length?`<tr><td>middleware</td><td>${escapeHtml(opts.routing.middleware.join(", "))}</td></tr>`:""].filter(Boolean).join("");if(rows)tabs.push({id:"routing",label:"Routing",html:`<table class="kv-table">${rows}</table>`})}if(opts.user){const rows=[opts.user.id!==void 0?`<tr><td>id</td><td>${escapeHtml(String(opts.user.id))}</td></tr>`:"",opts.user.email?`<tr><td>email</td><td>${escapeHtml(opts.user.email)}</td></tr>`:"",opts.user.name?`<tr><td>name</td><td>${escapeHtml(opts.user.name)}</td></tr>`:""].filter(Boolean).join("");if(rows)tabs.push({id:"user",label:"User",html:`<table class="kv-table">${rows}</table>`})}if(opts.queries&&opts.queries.length>0)tabs.push({id:"queries",label:`Queries (${opts.queries.length})`,html:opts.queries.map((q)=>`
<div class="query-item">
${escapeHtml(q.query)}
${q.time!==void 0?`<div class="query-time">${q.time.toFixed(2)}ms${q.connection?` \u2022 ${escapeHtml(q.connection)}`:""}</div>`:""}
</div>`).join("")});if(opts.showEnvironment)tabs.push({id:"environment",label:"Environment",html:`<table class="kv-table">
<tr><td>runtime</td><td>${typeof process<"u"?escapeHtml(process.version):"N/A"}</td></tr>
<tr><td>platform</td><td>${typeof process<"u"?escapeHtml(process.platform):"N/A"}</td></tr>
<tr><td>arch</td><td>${typeof process<"u"?escapeHtml(process.arch):"N/A"}</td></tr>
</table>`});if(tabs.length===0)return"";const tabButtons=tabs.map((t,i)=>`<button class="context-tab${i===0?" active":""}" data-tab="${t.id}" type="button">${escapeHtml(t.label)}</button>`).join(""),panels=tabs.map((t,i)=>`<div class="context-panel${i===0?" active":""}" data-panel="${t.id}">${t.html}</div>`).join("");return`<section class="context">
<div class="context-tabs">${tabButtons}</div>
${panels}
</section>`}export function buildErrorMarkdown(opts){const lines=[`# ${opts.statusTitle}`,"",`## ${opts.errorName}`,"",opts.errorMessage,""];if(opts.file)lines.push(`**${opts.file}${opts.line?`:${opts.line}`:""}**`,"");if(opts.framework)lines.push(`**${opts.framework.name.toUpperCase()}** ${opts.framework.version??""}`.trim(),"");lines.push(`**${opts.status}**`,"");if(opts.request)lines.push(`\`${opts.request.method}\` ${opts.request.url}`,"");if(opts.frames.length>0){lines.push("## Exception trace","");for(const frame of opts.frames.slice(0,20))lines.push(`\`${frame.function||"<anonymous>"}\` \u2014 ${frame.file}:${frame.line}`)}return lines.join(`
`)}export const ERROR_PAGE_SCRIPT=`
function initErrorPage() {
const copyBtn = document.getElementById('copy-markdown');
const markdown = document.getElementById('error-markdown');
if (copyBtn && markdown) {
copyBtn.addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(markdown.textContent || '');
copyBtn.textContent = 'Copied!';
copyBtn.classList.add('copied');
setTimeout(() => {
copyBtn.textContent = 'Copy as Markdown';
copyBtn.classList.remove('copied');
}, 2000);
} catch {
copyBtn.textContent = 'Copy failed';
}
});
}
document.querySelectorAll('.context-tab').forEach(tab => {
tab.addEventListener('click', () => {
const id = tab.getAttribute('data-tab');
document.querySelectorAll('.context-tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.context-panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
const panel = document.querySelector('[data-panel="' + id + '"]');
if (panel) panel.classList.add('active');
});
});
}
document.addEventListener('DOMContentLoaded', initErrorPage);
`;export function wrapErrorPage(body,markdown){return`<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>${ERROR_PAGE_CSS}</style>
</head>
<body>
<div class="page">
${body}
</div>
<script id="error-markdown" type="text/plain">${escapeHtml(markdown)}</script>
<script>${ERROR_PAGE_SCRIPT}</script>
</body>
</html>`}