Skip to content

Commit 1c80c4e

Browse files
committed
feat(deserve): optimize framework for streaming and error handling 🐛
- Add generic streaming support - Fix circular dependencies and internal imports - Fix static file resolution for mount points - Implement content negotiation for error responses - Improve memory efficiency using file streaming - Upgrade middleware matching with recursion support
1 parent 0ff9118 commit 1c80c4e

4 files changed

Lines changed: 109 additions & 35 deletions

File tree

src/Context.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ErrorHandler } from '@app/index.ts'
1+
import type { ErrorHandler } from '@app/Types.ts'
22

33
/**
44
* Request context class.
@@ -228,6 +228,7 @@ export class Context {
228228
html: (html: string, options?: ResponseInit) => Response
229229
json: (data: unknown, options?: ResponseInit) => Response
230230
redirect: (url: string, status?: number) => Response
231+
stream: (stream: ReadableStream, options?: ResponseInit, contentType?: string) => Response
231232
text: (text: string, options?: ResponseInit) => Response
232233
} {
233234
return {
@@ -264,16 +265,18 @@ export class Context {
264265
options?: ResponseInit
265266
): Promise<Response> => {
266267
try {
267-
const fileData = await Deno.readFile(filePath)
268+
const file = await Deno.open(filePath, { read: true })
269+
const fileInfo = await file.stat()
268270
const fileName = filename || filePath.split('/').pop() || 'download'
269-
return new Response(fileData, {
271+
return new Response(file.readable, {
270272
headers: {
271273
'Content-Type': 'application/octet-stream',
272274
'Content-Disposition': `attachment; filename="${fileName}"`,
273-
'Content-Length': fileData.length.toString(),
275+
'Content-Length': fileInfo.size.toString(),
274276
...this.responseHeaders,
275277
...(options?.headers || {})
276-
}
278+
},
279+
...options
277280
})
278281
} catch (error) {
279282
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
@@ -303,6 +306,20 @@ export class Context {
303306
redirect: (url: string, status = 302): Response => {
304307
return Response.redirect(url, status)
305308
},
309+
stream: (
310+
stream: ReadableStream,
311+
options?: ResponseInit,
312+
contentType = 'application/octet-stream'
313+
): Response => {
314+
return new Response(stream, {
315+
headers: {
316+
'Content-Type': contentType,
317+
...this.responseHeaders,
318+
...(options?.headers || {})
319+
},
320+
...options
321+
})
322+
},
306323
text: (text: string, options?: ResponseInit): Response => {
307324
return new Response(text, {
308325
headers: {
@@ -369,7 +386,7 @@ export class Context {
369386
const result: Record<string, string> = {}
370387
const cookieHeader = this.req.headers.get('cookie')
371388
if (cookieHeader) {
372-
cookieHeader.split(';').forEach((cookie) => {
389+
cookieHeader.split(';').forEach(cookie => {
373390
const [key, ...valueParts] = cookie.trim().split('=')
374391
if (key) {
375392
result[key] = valueParts.join('=')

src/Handler.ts

Lines changed: 83 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1-
import {
2-
Context,
3-
type ErrorMiddleware,
4-
type Middleware,
5-
type MiddlewareEntry,
6-
type RouteHandler,
7-
type RouteMetadata,
8-
type ServeOptions,
9-
type StaticFileHandler
10-
} from '@app/index.ts'
1+
import type {
2+
ErrorMiddleware,
3+
Middleware,
4+
MiddlewareEntry,
5+
RouteHandler,
6+
RouteMetadata,
7+
ServeOptions,
8+
StaticFileHandler
9+
} from '@app/Types.ts'
1110
import { pathToFileURL } from 'node:url'
1211
import { FastRouter } from '@neabyte/fast-router'
1312
import { allowedExtensions, contentTypes, httpMethods } from '@app/Constant.ts'
13+
import { Context } from '@app/Context.ts'
1414

1515
/**
1616
* Request handler class.
@@ -43,8 +43,9 @@ export class Handler {
4343
const metadata: RouteMetadata = {
4444
handler: {
4545
staticRoute: true,
46+
urlPath,
4647
execute: async (ctx: Context) => {
47-
return await this.serveStaticFile(ctx, options)
48+
return await this.serveStaticFile(ctx, options, urlPath)
4849
}
4950
},
5051
pattern: routePattern
@@ -82,7 +83,8 @@ export class Handler {
8283
'staticRoute' in handler &&
8384
handler.staticRoute
8485
) {
85-
return await (handler as StaticFileHandler).execute(ctx)
86+
const staticHandler = handler as StaticFileHandler
87+
return await staticHandler.execute(ctx)
8688
}
8789
try {
8890
return await (handler as RouteHandler)(ctx)
@@ -120,11 +122,11 @@ export class Handler {
120122
}
121123

122124
/**
123-
* Handles responses with optional custom error middleware.
124-
* @param ctx - The context object
125+
* Handles responses for routes and errors.
126+
* @param ctx - Context object
125127
* @param statusCode - HTTP status code
126128
* @param error - Error object
127-
* @returns Response
129+
* @returns Response object
128130
*/
129131
handleResponse(ctx: Context, statusCode: number, error: Error): Response {
130132
if (this.errorMiddleware) {
@@ -138,7 +140,38 @@ export class Handler {
138140
return customResponse
139141
}
140142
}
141-
return ctx.send.custom(null, { status: statusCode, headers: ctx.responseHeadersMap })
143+
const isJson = ctx.request.headers.get('accept')?.includes('application/json')
144+
if (isJson) {
145+
return ctx.send.json(
146+
{
147+
error: error.message,
148+
path: ctx.pathname,
149+
statusCode
150+
},
151+
{ status: statusCode }
152+
)
153+
}
154+
const html = `
155+
<!DOCTYPE html>
156+
<html>
157+
<head>
158+
<title>${statusCode} - ${error.message}</title>
159+
<style>
160+
body { font-family: sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #fafafa; color: #333; }
161+
.card { background: white; padding: 2rem; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.1); text-align: center; max-width: 400px; }
162+
h1 { font-size: 3rem; margin: 0; color: #ff6b6b; }
163+
p { color: #666; margin: 1rem 0; }
164+
</style>
165+
</head>
166+
<body>
167+
<div class="card">
168+
<h1>${statusCode}</h1>
169+
<p>${error.message}</p>
170+
</div>
171+
</body>
172+
</html>
173+
`
174+
return ctx.send.html(html, { status: statusCode })
142175
}
143176

144177
/**
@@ -163,7 +196,7 @@ export class Handler {
163196
const routePattern = this.createRoutePattern(routePath)
164197
if (routePattern) {
165198
this.validateRouteModule(fileModule, routePath)
166-
Object.keys(fileModule).forEach((method) => {
199+
Object.keys(fileModule).forEach(method => {
167200
const handler = fileModule[method] as RouteHandler
168201
const metadata: RouteMetadata = {
169202
handler,
@@ -198,7 +231,7 @@ export class Handler {
198231
* @throws {Error} When module is invalid
199232
*/
200233
validateRouteModule(module: Record<string, unknown>, routePath: string): void {
201-
const exportedMethods = Object.keys(module).filter((key) => httpMethods.includes(key))
234+
const exportedMethods = Object.keys(module).filter(key => httpMethods.includes(key))
202235
if (exportedMethods.length === 0) {
203236
throw new Error(
204237
`Route ${routePath}: Must export at least one HTTP method (${httpMethods.join(', ')})`
@@ -220,9 +253,14 @@ export class Handler {
220253
* @returns Response if middleware returned one, undefined otherwise
221254
*/
222255
private async executeMiddlewares(ctx: Context, pathname: string): Promise<Response | undefined> {
223-
const applicableMiddlewares = this.entryMiddleware.filter(
224-
(mw) => mw.path === '' || pathname.startsWith(mw.path) || mw.path === '*'
225-
)
256+
const applicableMiddlewares = this.entryMiddleware.filter(mw => {
257+
if (mw.path === '' || mw.path === '*') return true
258+
if (mw.path.endsWith('/**')) {
259+
const base = mw.path.slice(0, -3)
260+
return pathname.startsWith(base)
261+
}
262+
return pathname === mw.path
263+
})
226264
let index = 0
227265
const next = async (): Promise<Response> => {
228266
if (index >= applicableMiddlewares.length) {
@@ -246,43 +284,60 @@ export class Handler {
246284
* Serves static files from the filesystem.
247285
* @param ctx - Context object
248286
* @param options - Static file serving options
287+
* @param urlPath - URL mount point
249288
* @returns Response with file or 404
250289
*/
251-
private async serveStaticFile(ctx: Context, options: ServeOptions): Promise<Response> {
290+
private async serveStaticFile(
291+
ctx: Context,
292+
options: ServeOptions,
293+
urlPath: string
294+
): Promise<Response> {
252295
try {
253-
const filePath = ctx.pathname === '/' ? 'index.html' : ctx.pathname.slice(1)
296+
let filePath = ctx.pathname
297+
if (urlPath !== '/') {
298+
filePath = ctx.pathname.slice(urlPath.length)
299+
}
300+
if (filePath === '/' || filePath === '') {
301+
filePath = 'index.html'
302+
} else if (filePath.startsWith('/')) {
303+
filePath = filePath.slice(1)
304+
}
254305
const basePath = options.path.startsWith('/') ? options.path : `${Deno.cwd()}/${options.path}`
255306
const fullPath = new URL(filePath, `file://${basePath.replace(/^\.\//, '')}/`).pathname
256307
const fileInfo = await Deno.stat(fullPath).catch(() => null)
257308
if (!fileInfo || !fileInfo.isFile) {
258309
return ctx.handleError(404, new Error('File not found'))
259310
}
260-
const fileData = await Deno.readFile(fullPath)
261311
const extension = filePath.split('.').pop()?.toLowerCase() ?? ''
262312
const contentType = contentTypes[extension] ?? 'application/octet-stream'
313+
const file = await Deno.open(fullPath, { read: true })
263314
let etag: string | null = null
264315
if (options.etag) {
265-
const hashBuffer = await crypto.subtle.digest('SHA-256', fileData)
316+
const hashBuffer = await crypto.subtle.digest(
317+
'SHA-256',
318+
new TextEncoder().encode(`${fileInfo.size}-${fileInfo.mtime?.getTime()}`)
319+
)
266320
const hashArray = Array.from(new Uint8Array(hashBuffer))
267-
const hashHex = hashArray.map((b) => b.toString(16).padStart(2, '0')).join('')
321+
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('')
268322
etag = `"${hashHex}"`
269323
}
270324
if (etag && ctx.request.headers.get('If-None-Match') === etag) {
325+
file.close()
271326
ctx.setHeader('ETag', etag)
272327
if (options.cacheControl !== undefined) {
273328
ctx.setHeader('Cache-Control', `public, max-age=${options.cacheControl}`)
274329
}
275330
return ctx.handleError(304, new Error('Not Modified'))
276331
}
277332
ctx.setHeader('Content-Type', contentType)
278-
ctx.setHeader('Content-Length', fileData.length.toString())
333+
ctx.setHeader('Content-Length', fileInfo.size.toString())
279334
if (etag) {
280335
ctx.setHeader('ETag', etag)
281336
}
282337
if (options.cacheControl !== undefined) {
283338
ctx.setHeader('Cache-Control', `public, max-age=${options.cacheControl}`)
284339
}
285-
return ctx.send.custom(fileData)
340+
return ctx.send.custom(file.readable)
286341
} catch (error) {
287342
return ctx.handleError(500, error as Error)
288343
}

src/Router.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { ErrorMiddleware, Middleware, RouterOptions, ServeOptions } from '@app/index.ts'
1+
import type { ErrorMiddleware, Middleware, RouterOptions, ServeOptions } from '@app/Types.ts'
22
import { Handler } from '@app/Handler.ts'
33

44
/**

src/Types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ export interface ServeOptions {
8989
export type StaticFileHandler = {
9090
/** Indicates this is a static route */
9191
staticRoute: true
92+
/** URL path mount point */
93+
urlPath: string
9294
/** Executes static file serving */
9395
execute: (ctx: Context) => Promise<Response>
9496
}

0 commit comments

Comments
 (0)