Skip to content

Commit a5332f1

Browse files
committed
chore: wip
1 parent d4dc32d commit a5332f1

File tree

10 files changed

+621
-34
lines changed

10 files changed

+621
-34
lines changed

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@
9292
"Bash(kill:*)",
9393
"Bash(pgrep:*)",
9494
"Bash(zig build:*)",
95-
"Bash(/usr/bin/grep:*)"
95+
"Bash(/usr/bin/grep:*)",
96+
"Bash(node -e:*)"
9697
],
9798
"deny": [],
9899
"ask": []

app/Actions/TestErrorAction.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
/**
2+
* Test Error Action
3+
*
4+
* This action is used to test the Ignition-style error pages.
5+
* Visit /api/test-error to see the error page in action.
6+
*/
7+
8+
import type { EnhancedRequest } from 'bun-router'
9+
10+
class DatabaseError extends Error {
11+
constructor(message: string, public query?: string) {
12+
super(message)
13+
this.name = 'DatabaseError'
14+
}
15+
}
16+
17+
class ValidationError extends Error {
18+
constructor(message: string, public field?: string) {
19+
super(message)
20+
this.name = 'ValidationError'
21+
}
22+
}
23+
24+
// Simulate a deep call stack
25+
function innerDatabaseCall(): never {
26+
throw new DatabaseError(
27+
`SQLSTATE[42S02]: Base table or view not found: 1146 Table 'app.nonexistent_table' doesn't exist`,
28+
`SELECT * FROM nonexistent_table WHERE id = 1`,
29+
)
30+
}
31+
32+
function queryBuilder() {
33+
return innerDatabaseCall()
34+
}
35+
36+
function repository() {
37+
return queryBuilder()
38+
}
39+
40+
function service() {
41+
return repository()
42+
}
43+
44+
export default {
45+
name: 'Test Error Action',
46+
description: 'Demonstrates the Ignition-style error page',
47+
48+
async handle(request: EnhancedRequest): Promise<Response> {
49+
const url = new URL(request.url)
50+
const errorType = url.searchParams.get('type') || 'database'
51+
52+
switch (errorType) {
53+
case 'validation':
54+
throw new ValidationError('The email field must be a valid email address.', 'email')
55+
56+
case 'auth':
57+
const authError = new Error('Unauthorized: Invalid or expired authentication token.')
58+
authError.name = 'AuthenticationError'
59+
throw authError
60+
61+
case 'notfound':
62+
const notFoundError = new Error('Resource not found: User with ID 42 does not exist.')
63+
notFoundError.name = 'NotFoundError'
64+
throw notFoundError
65+
66+
case 'generic':
67+
throw new Error('Something went wrong while processing your request.')
68+
69+
case 'database':
70+
default:
71+
// This will create a deep stack trace
72+
service()
73+
}
74+
75+
// This should never be reached
76+
return Response.json({ message: 'No error occurred' })
77+
},
78+
}

routes/api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ route.get('/coming-soon', 'Controllers/ComingSoonController@index')
3838

3939
// route.email('/welcome')
4040
route.health() // adds a GET `/health` route
41+
42+
// Test error page (development only)
43+
route.get('/test-error', 'Actions/TestErrorAction') // Visit /test-error?type=database|validation|auth|notfound|generic
4144
route.get('/install', 'Actions/InstallAction')
4245
route.post('/ai/ask', 'Actions/AI/AskAction')
4346
route.post('/ai/summary', 'Actions/AI/SummaryAction')

storage/framework/core/database/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,8 @@
5555
"devDependencies": {
5656
"@stacksjs/cli": "workspace:*",
5757
"@stacksjs/config": "workspace:*",
58+
"@stacksjs/logging": "workspace:*",
59+
"@stacksjs/router": "workspace:*",
5860
"better-dx": "^0.2.5",
5961
"@stacksjs/path": "workspace:*",
6062
"@stacksjs/query-builder": "workspace:*",

storage/framework/core/database/src/query-logger.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { memoryUsage } from 'node:process'
22
import { config } from '@stacksjs/config'
33
import { log } from '@stacksjs/logging'
4+
import { trackQuery } from '@stacksjs/router'
45
import { parseQuery } from './query-parser'
56
import { db } from './utils'
67

@@ -46,13 +47,21 @@ interface QueryLogRecord {
4647
*/
4748
export async function logQuery(event: LogEvent): Promise<void> {
4849
try {
49-
// Skip if database logging is disabled
50-
if (!config.database?.queryLogging?.enabled)
51-
return
52-
5350
// Extract basic information from the event
5451
const { query, durationMs, error, bindings } = extractQueryInfo(event)
5552

53+
// Always track query for error page context (even if logging is disabled)
54+
try {
55+
trackQuery(query, durationMs, config.database?.default || 'unknown')
56+
}
57+
catch {
58+
// Ignore if router not available
59+
}
60+
61+
// Skip database logging if disabled
62+
if (!config.database?.queryLogging?.enabled)
63+
return
64+
5665
// Determine query status based on duration and error
5766
const status = determineQueryStatus(durationMs, error)
5867

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,204 @@
1+
import {
2+
createErrorHandler,
3+
HTTP_ERRORS,
4+
renderProductionErrorPage,
5+
} from 'ts-error-handling'
6+
import type {
7+
ErrorPageConfig,
8+
HttpStatusCode,
9+
RequestContext,
10+
RoutingContext,
11+
} from 'ts-error-handling'
12+
113
export class HttpError extends Error {
214
constructor(public status: number, message: string) {
315
super(message)
416
this.name = 'Server Error!'
517
}
618
}
19+
20+
/**
21+
* HTTP error handler with Ignition-style error pages
22+
*/
23+
export class HttpErrorHandler {
24+
private handler = createErrorHandler()
25+
private isDevelopment: boolean
26+
27+
constructor(options?: {
28+
isDevelopment?: boolean
29+
config?: ErrorPageConfig
30+
}) {
31+
this.isDevelopment = options?.isDevelopment ?? process.env.NODE_ENV !== 'production'
32+
33+
if (options?.config) {
34+
this.handler = createErrorHandler(options.config)
35+
}
36+
37+
// Set framework info
38+
this.handler.setFramework('Stacks')
39+
}
40+
41+
/**
42+
* Set request context
43+
*/
44+
setRequest(request: Request | RequestContext): this {
45+
this.handler.setRequest(request)
46+
return this
47+
}
48+
49+
/**
50+
* Set routing context
51+
*/
52+
setRouting(routing: RoutingContext): this {
53+
this.handler.setRouting(routing)
54+
return this
55+
}
56+
57+
/**
58+
* Add a query to track
59+
*/
60+
addQuery(query: string, time?: number, connection?: string): this {
61+
this.handler.addQuery(query, time, connection)
62+
return this
63+
}
64+
65+
/**
66+
* Handle an error and return an HTML response
67+
*/
68+
handle(error: Error, status: number = 500): Response {
69+
if (this.isDevelopment) {
70+
return this.handler.handleError(error, status)
71+
}
72+
73+
// Production: show simple error page without details
74+
return new Response(renderProductionErrorPage(status), {
75+
status,
76+
headers: { 'Content-Type': 'text/html; charset=utf-8' },
77+
})
78+
}
79+
80+
/**
81+
* Create a 404 Not Found response
82+
*/
83+
notFound(message?: string): Response {
84+
const error = new HttpError(404, message || 'The requested resource could not be found.')
85+
return this.handle(error, 404)
86+
}
87+
88+
/**
89+
* Create a 500 Internal Server Error response
90+
*/
91+
serverError(error: Error): Response {
92+
return this.handle(error, 500)
93+
}
94+
95+
/**
96+
* Create a 403 Forbidden response
97+
*/
98+
forbidden(message?: string): Response {
99+
const error = new HttpError(403, message || 'You do not have permission to access this resource.')
100+
return this.handle(error, 403)
101+
}
102+
103+
/**
104+
* Create a 401 Unauthorized response
105+
*/
106+
unauthorized(message?: string): Response {
107+
const error = new HttpError(401, message || 'Authentication is required to access this resource.')
108+
return this.handle(error, 401)
109+
}
110+
111+
/**
112+
* Create a 400 Bad Request response
113+
*/
114+
badRequest(message?: string): Response {
115+
const error = new HttpError(400, message || 'The request was malformed or invalid.')
116+
return this.handle(error, 400)
117+
}
118+
119+
/**
120+
* Create a 422 Unprocessable Entity response
121+
*/
122+
validationError(message?: string): Response {
123+
const error = new HttpError(422, message || 'The request was well-formed but could not be processed.')
124+
return this.handle(error, 422)
125+
}
126+
127+
/**
128+
* Create a 429 Too Many Requests response
129+
*/
130+
tooManyRequests(message?: string): Response {
131+
const error = new HttpError(429, message || 'You have exceeded the rate limit.')
132+
return this.handle(error, 429)
133+
}
134+
135+
/**
136+
* Create a 503 Service Unavailable response
137+
*/
138+
serviceUnavailable(message?: string): Response {
139+
const error = new HttpError(503, message || 'The service is temporarily unavailable.')
140+
return this.handle(error, 503)
141+
}
142+
}
143+
144+
/**
145+
* Create a new HTTP error handler
146+
*/
147+
export function createHttpErrorHandler(options?: {
148+
isDevelopment?: boolean
149+
config?: ErrorPageConfig
150+
}): HttpErrorHandler {
151+
return new HttpErrorHandler(options)
152+
}
153+
154+
/**
155+
* Quick helper to render an error page for HTTP errors
156+
*/
157+
export function renderHttpError(
158+
error: Error,
159+
request?: Request,
160+
options?: {
161+
status?: number
162+
isDevelopment?: boolean
163+
config?: ErrorPageConfig
164+
},
165+
): Response {
166+
const handler = createHttpErrorHandler({
167+
isDevelopment: options?.isDevelopment,
168+
config: options?.config,
169+
})
170+
171+
if (request) {
172+
handler.setRequest(request)
173+
}
174+
175+
return handler.handle(error, options?.status)
176+
}
177+
178+
/**
179+
* Express/Hono style error middleware
180+
*/
181+
export function errorMiddleware(options?: {
182+
isDevelopment?: boolean
183+
config?: ErrorPageConfig
184+
}) {
185+
const handler = createHttpErrorHandler(options)
186+
187+
return async (error: Error, request: Request): Promise<Response> => {
188+
handler.setRequest(request)
189+
190+
// Determine status code
191+
let status = 500
192+
if (error instanceof HttpError) {
193+
status = error.status
194+
}
195+
else if ('status' in error && typeof error.status === 'number') {
196+
status = error.status as number
197+
}
198+
else if ('statusCode' in error && typeof error.statusCode === 'number') {
199+
status = error.statusCode as number
200+
}
201+
202+
return handler.handle(error, status)
203+
}
204+
}

storage/framework/core/error-handling/src/index.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ export * from './http'
33
export * from './model'
44
export * from './utils'
55

6+
// Result type exports
67
export type {
78
Err,
89
Ok,
@@ -15,3 +16,28 @@ export {
1516
fromPromise,
1617
ok,
1718
} from 'ts-error-handling'
19+
20+
// Error page exports (Ignition-style)
21+
export type {
22+
CodeSnippet,
23+
EnvironmentContext,
24+
ErrorPageConfig,
25+
ErrorPageData,
26+
HttpError as HttpErrorInfo,
27+
HttpStatusCode,
28+
QueryInfo,
29+
RequestContext,
30+
RoutingContext,
31+
StackFrame,
32+
} from 'ts-error-handling'
33+
34+
export {
35+
createErrorHandler,
36+
ERROR_PAGE_CSS,
37+
ErrorHandler as ErrorPageHandler,
38+
errorResponse,
39+
HTTP_ERRORS,
40+
renderError,
41+
renderErrorPage,
42+
renderProductionErrorPage,
43+
} from 'ts-error-handling'

0 commit comments

Comments
 (0)