diff --git a/extensions/res/download.ts b/extensions/res/download.ts index 2bb59e5..6087960 100644 --- a/extensions/res/download.ts +++ b/extensions/res/download.ts @@ -9,21 +9,31 @@ export type DownloadOptions = headers: Record }> +type Callback = (err?: unknown) => void + export const download = < Req extends Request = Request, Res extends DummyResponse = DummyResponse, >(req: Req, res: Res) => async ( path: string, - filename?: string, - options: DownloadOptions = {}, + filename?: string | Callback, + options?: DownloadOptions | Callback, + cb?: Callback, ): Promise => { - const name: string | null = filename as string - let opts: DownloadOptions = options - + let name: string | null = filename as string + let done = cb + let opts: DownloadOptions = (options || null) as DownloadOptions + if (typeof filename === 'function') { + done = filename + name = null + } else if (typeof options === 'function') { + done = options + } + opts = opts || {} // set Content-Disposition when file is sent const headers: Record = { - 'Content-Disposition': contentDisposition(name || path), + 'Content-Disposition': contentDisposition(basename(name || path)), } // merge user-provided headers @@ -34,13 +44,16 @@ async ( } } } - // merge user-provided options opts = { ...opts, headers } // send file - return await sendFile(req, res)(path, opts) + return await sendFile(req, res)( + path, + opts, + done || (() => undefined), + ) } export const attachment = diff --git a/extensions/res/send/sendFile.ts b/extensions/res/send/sendFile.ts index 3f3bbfe..e6c20fa 100644 --- a/extensions/res/send/sendFile.ts +++ b/extensions/res/send/sendFile.ts @@ -26,15 +26,17 @@ export const sendFile = < Req extends Request = Request, Res extends DummyResponse = DummyResponse, >(req: Req, res: Res) => -async (path: string, { signal, ...opts }: SendFileOptions = {}) => { +async ( + path: string, + { signal, ...opts }: SendFileOptions = {}, + cb?: (err?: unknown) => void, +) => { const { root, headers = {}, encoding = 'utf-8', ...options } = opts - if (!_path.isAbsolute(path) && !root) { throw new TypeError('path must be absolute') } const filePath = root ? _path.join(root, path) : path - const stats = await Deno.stat(filePath) headers['Content-Encoding'] = encoding @@ -48,7 +50,6 @@ async (path: string, { signal, ...opts }: SendFileOptions = {}) => { headers['Content-Security-Policy'] = 'default-src \'none\'' headers['X-Content-Type-Options'] = 'nosniff' - let status = 200 if (req.headers.get('range')) { @@ -74,8 +75,13 @@ async (path: string, { signal, ...opts }: SendFileOptions = {}) => { res._init.status = status - const file = await Deno.readFile(path, { signal }) - + let file + try { + file = await Deno.readFile(filePath, { signal }) + } catch (error) { + cb!(error) + file = null + } await send(req, res)(file) return res diff --git a/router.ts b/router.ts index 23f40cb..814cc40 100644 --- a/router.ts +++ b/router.ts @@ -228,6 +228,17 @@ export class Router< return this } + + all(...args: UseMethodParams): this { + const handlers = args.slice(1).flat() + pushMiddleware(this.middleware)({ + path: args[0] as Handler, + handler: handlers[0] as Handler, + handlers: handlers.slice(1) as Handler[], + type: 'route', + }) + return this + } /** * Return the app's absolute pathname * based on the parent(s) that have diff --git a/tests/core/app.test.ts b/tests/core/app.test.ts index 4137fd6..a75ec93 100644 --- a/tests/core/app.test.ts +++ b/tests/core/app.test.ts @@ -134,19 +134,16 @@ describe('Testing App routing', () => { ) ;(await makeFetch(app.handler)('/abcdef')).expect(404) }) - // it('"*" should catch all undefined routes', async () => { - // const app = new App() - - // const server = app.handler - - // app - // .get('/route', (_req, res) => void res.send('A different route')) - // .all('*', (_req, res) => void res.send('Hello world')) - - // await makeFetch(server)('/route').expect(200, 'A different route') - - // await makeFetch(server)('/test').expect(200, 'Hello world') - // }) + it('"*" should catch all undefined routes', async () => { + const app = new App() + app.get( + '/route', + async (_req, res) => void await res.send('A different route'), + ) + app.all('*', async (_req, res) => void await res.send('Hello world')) + ;(await makeFetch(app.handler)('/route')).expect('A different route') + ;(await makeFetch(app.handler)('/test')).expect('Hello world') + }) it('should throw 404 on no routes', async () => { const app = new App() const fetch = makeFetch(app.handler) @@ -371,166 +368,132 @@ describe('HTTP methods', () => { const res = await fetch('/', { method: 'PATCH' }) res.expect('PATCH') }) - // it('app.head handles head request', async () => { - // const app = new App() - - // app.head('/', (req, res) => void res.end(req.method)) - - // const server = app.handler - // const fetch = makeFetch(server) - - // await fetch('/', { method: 'HEAD' }).expect(200, '') - // }) - // it('app.delete handles delete request', async () => { - // const app = new App() - - // app.delete('/', (req, res) => void res.end(req.method)) - - // const server = app.handler - // const fetch = makeFetch(server) - - // await fetch('/', { method: 'DELETE' }).expect(200, 'DELETE') - // }) - // it('app.checkout handles checkout request', async () => { - // const app = new App() - - // app.checkout('/', (req, res) => void res.end(req.method)) - - // const server = app.handler - // const fetch = makeFetch(server) - - // await fetch('/', { method: 'CHECKOUT' }).expect(200, 'CHECKOUT') - // }) - // it('app.copy handles copy request', async () => { - // const app = new App() - - // app.copy('/', (req, res) => void res.end(req.method)) - - // const server = app.handler - // const fetch = makeFetch(server) - - // await fetch('/', { method: 'COPY' }).expect(200, 'COPY') - // }) - // it('app.lock handles lock request', async () => { - // const app = new App() - - // app.lock('/', (req, res) => void res.end(req.method)) - - // const server = app.handler - // const fetch = makeFetch(server) - - // await fetch('/', { method: 'LOCK' }).expect(200, 'LOCK') - // }) - // it('app.merge handles merge request', async () => { - // const app = new App() - - // app.merge('/', (req, res) => void res.end(req.method)) - - // const server = app.handler - // const fetch = makeFetch(server) - - // await fetch('/', { method: 'MERGE' }).expect(200, 'MERGE') - // }) - // it('app.mkactivity handles mkactivity request', async () => { - // const app = new App() - - // app.mkactivity('/', (req, res) => void res.end(req.method)) - - // const server = app.handler - - // const fetch = makeFetch(server) - - // await fetch('/', { method: 'MKACTIVITY' }).expect(200, 'MKACTIVITY') - // }) - // it('app.mkcol handles mkcol request', async () => { - // const app = new App() - - // app.mkcol('/', (req, res) => void res.end(req.method)) - - // const server = app.handler - // const fetch = makeFetch(server) - - // await fetch('/', { method: 'MKCOL' }).expect(200, 'MKCOL') - // }) - // it('app.move handles move request', async () => { - // const app = new App() - - // app.move('/', (req, res) => void res.end(req.method)) - - // const server = app.handler - // const fetch = makeFetch(server) - - // await fetch('/', { method: 'MOVE' }).expect(200, 'MOVE') - // }) - // it('app.search handles search request', async () => { - // const app = new App() - - // app.search('/', (req, res) => void res.end(req.method)) - - // const server = app.handler - // const fetch = makeFetch(server) - - // await fetch('/', { method: 'SEARCH' }).expect(200, 'SEARCH') - // }) - // it('app.notify handles notify request', async () => { - // const app = new App() + it('app.head handles head request', async () => { + const app = new App() - // app.notify('/', (req, res) => void res.end(req.method)) + app.head('/', (req, res) => void res.end(req.method)) - // const server = app.handler - // const fetch = makeFetch(server) + const fetch = makeFetch(app.handler) - // await fetch('/', { method: 'NOTIFY' }).expect(200, 'NOTIFY') - // }) - // it('app.purge handles purge request', async () => { - // const app = new App() + const res = await fetch('/', { method: 'HEAD' }) + res.expect('HEAD', null) + }) + it('app.delete handles delete request', async () => { + const app = new App() - // app.purge('/', (req, res) => void res.end(req.method)) + app.delete('/', (req, res) => void res.end(req.method)) + ;(await makeFetch(app.handler)('/', { method: 'DELETE' })).expect( + 'DELETE', + ) + }) + it('app.checkout handles checkout request', async () => { + const app = new App() - // const server = app.handler - // const fetch = makeFetch(server) + app.checkout('/', (req, res) => void res.end(req.method)) + ;(await makeFetch(app.handler)('/', { method: 'CHECKOUT' })).expect( + 'CHECKOUT', + ) + }) + it('app.copy handles copy request', async () => { + const app = new App() - // await fetch('/', { method: 'PURGE' }).expect(200, 'PURGE') - // }) - // it('app.report handles report request', async () => { - // const app = new App() + app.copy('/', (req, res) => void res.end(req.method)) + ;(await makeFetch(app.handler)('/', { method: 'COPY' })).expect('COPY') + }) + it('app.lock handles lock request', async () => { + const app = new App() - // app.report('/', (req, res) => void res.end(req.method)) + app.lock('/', (req, res) => void res.end(req.method)) + ;(await makeFetch(app.handler)('/', { method: 'LOCK' })).expect('LOCK') + }) + it('app.merge handles merge request', async () => { + const app = new App() - // const fetch = makeFetch(app.handler) + app.merge('/', (req, res) => void res.end(req.method)) + ;(await makeFetch(app.handler)('/', { method: 'MERGE' })).expect( + 'MERGE', + ) + }) + it('app.mkactivity handles mkactivity request', async () => { + const app = new App() - // await fetch('/', { method: 'REPORT' }).expect(200, 'REPORT') - // }) - // it('app.subscribe handles subscribe request', async () => { - // const app = new App() + app.mkactivity('/', (req, res) => void res.end(req.method)) + ;(await makeFetch(app.handler)('/', { method: 'MKACTIVITY' })).expect( + 'MKACTIVITY', + ) + }) + it('app.mkcol handles mkcol request', async () => { + const app = new App() - // app.subscribe('/', (req, res) => void res.end(req.method)) + app.mkcol('/', (req, res) => void res.end(req.method)) + ;(await makeFetch(app.handler)('/', { method: 'MKCOL' })).expect( + 'MKCOL', + ) + }) + it('app.move handles move request', async () => { + const app = new App() - // const server = app.handler - // const fetch = makeFetch(server) + app.move('/', (req, res) => void res.end(req.method)) + ;(await makeFetch(app.handler)('/', { method: 'MOVE' })).expect('MOVE') + }) + it('app.search handles search request', async () => { + const app = new App() - // await fetch('/', { method: 'SUBSCRIBE' }).expect(200, 'SUBSCRIBE') - // }) - // it('app.unsubscribe handles unsubscribe request', async () => { - // const app = new App() + app.search('/', (req, res) => void res.end(req.method)) + ;(await makeFetch(app.handler)('/', { method: 'SEARCH' })).expect( + 'SEARCH', + ) + }) + it('app.notify handles notify request', async () => { + const app = new App() - // app.unsubscribe('/', (req, res) => void res.end(req.method)) + app.notify('/', (req, res) => void res.end(req.method)) + ;(await makeFetch(app.handler)('/', { method: 'NOTIFY' })).expect( + 'NOTIFY', + ) + }) + it('app.purge handles purge request', async () => { + const app = new App() - // const server = app.handler - // const fetch = makeFetch(server) + app.purge('/', (req, res) => void res.end(req.method)) + ;(await makeFetch(app.handler)('/', { method: 'PURGE' })).expect( + 'PURGE', + ) + }) + it('app.report handles report request', async () => { + const app = new App() - // await fetch('/', { method: 'UNSUBSCRIBE' }).expect(200, 'UNSUBSCRIBE') - // }) - // it('app.trace handles trace request', async () => { - // const app = new App() + app.report('/', (req, res) => void res.end(req.method)) + ;(await makeFetch(app.handler)('/', { method: 'REPORT' })).expect( + 'REPORT', + ) + }) + it('app.subscribe handles subscribe request', async () => { + const app = new App() - // app.trace('/', (req, res) => void res.end(req.method)) + app.subscribe('/', (req, res) => void res.end(req.method)) + ;(await makeFetch(app.handler)('/', { method: 'SUBSCRIBE' })).expect( + 'SUBSCRIBE', + ) + }) + it('app.unsubscribe handles unsubscribe request', async () => { + const app = new App() - // const server = app.handler - // const fetch = makeFetch(server) + app.unsubscribe('/', (req, res) => void res.end(req.method)) + ;(await makeFetch(app.handler)('/', { method: 'UNSUBSCRIBE' })).expect( + 'UNSUBSCRIBE', + ) + }) + it.skip('app.trace handles trace request', async () => { + const app = new App() - // await fetch('/', { method: 'TRACE' }).expect(200, 'TRACE') - // }) + app.trace('/', (req, res) => void res.end(req.method)) + try { + await makeFetch(app.handler)('/', { method: 'TRACE' }) + } catch (e) { + expect((e as Error).message).toBe('Method is forbidden.') + } + }) it('HEAD request works when any of the method handlers are defined', async () => { const app = new App() @@ -546,8 +509,7 @@ describe('HTTP methods', () => { app.get('/', (_, res) => void res.end('It works')) - const server = app.handler - const fetch = makeFetch(server) + const fetch = makeFetch(app.handler) const res = await fetch('/hello', { method: 'HEAD' }) res.expect(404) diff --git a/tests/modules/res.test.ts b/tests/modules/res.test.ts index b6dedac..eacaab3 100644 --- a/tests/modules/res.test.ts +++ b/tests/modules/res.test.ts @@ -18,6 +18,7 @@ import { setVaryHeader, } from '../../extensions/res/mod.ts' import type { DummyResponse } from '../../response.ts' +import { runServer } from '../util.test.ts' const __dirname = path.dirname(import.meta.url) describe('Response extensions', () => { @@ -333,125 +334,122 @@ describe('Response extensions', () => { res.expect('Content-Disposition', 'attachment; filename="favicon.ico"') }) }) - // describe('res.download(filename)', () => { - // it('should set Content-Disposition based on path', async () => { - // const app = async (req: Request) => { - // const res: DummyResponse & { send?: ReturnType } = { - // _init: { - // headers: new Headers(), - // }, - // locals: {}, - // } - // const filePath = path.join(__dirname, '../fixtures', 'favicon.ico') - // res.send = send(req, res) - // await download(req, res)(filePath.slice(5, filePath.length)) - // return new Response(res._body, res._init) - // } - - // const res = await makeFetch(app)('/') - - // res.expect('Content-Disposition', 'attachment; filename="favicon.ico"') - // }) - // it('should set Content-Disposition based on filename', async () => { - // const app = async (req: Request) => { - // const res: DummyResponse & { send?: ReturnType } = { - // _init: { headers: new Headers({}) }, - // locals: {}, - // } - // res.send = send(req, res) - // await download(req, res)( - // path.join(__dirname, '../fixtures', 'favicon.ico'), - // 'favicon.icon', - // ) - // return new Response(res._body, res._init) - // } - // const res = await makeFetch(app)('/') - - // res.expect( - // 'Content-Disposition', - // 'attachment; filename="favicon.icon"', - // ) - // }) - // it('should pass the error to a callback', async () => { - // const app = async (req: Request) => { - // const res: DummyResponse & { send?: ReturnType } = { - // _init: { headers: new Headers({}) }, - // } - // res.send = send(req, res) - // try { - // await download(req, res)( - // path.join(__dirname, '../fixtures'), - // 'some_file.png', - // ) - - // return new Response(res._body, res._init) - // } catch (e) { - // return new Response(e.message, { status: 500 }) - // } - // } - - // const res = await makeFetch(app)('/') - // res.expect( - // 'Content-Disposition', - // 'attachment; filename="some_file.png"', - // ) - // }) - // it('should set "root" from options', async () => { - // const app = runServer((req, res) => { - // download(req, res)('favicon.ico', () => void 0, { - // root: path.join(__dirname, '../fixtures'), - // }).end() - // }) - - // await makeFetch(app)('/').expect( - // 'Content-Disposition', - // 'attachment; filename="favicon.ico"', - // ) - // }) - // it(`'should pass options to sendFile'`, async () => { - // const ac = new AbortController() - // const app = async (req: Request) => { - // const res: DummyResponse & { send?: ReturnType } = { - // _init: { headers: new Headers({}) }, - // locals: {}, - // } - // res.send = send(req, res) - // await download(req, res)( - // path.join(__dirname, '../fixtures', 'favicon.ico'), - // 'favicon.icon', - // { signal: ac.signal }, - // ) - // return new Response(res._body, res._init) - // } - // const fetch = makeFetch(app) - // const res = await fetch('/') - // res.expect('Content-Disposition', 'attachment; filename="favicon.ico"') - // }) - // it('should set headers from options', async () => { - // const app = async (req: Request) => { - // const res: DummyResponse & { send?: ReturnType } = { - // _init: { headers: new Headers({}) }, - // locals: {}, - // } - // res.send = send(req, res) - // await download(req, res)( - // path.join(__dirname, '../fixtures', 'favicon.ico'), - // 'favicon.icon', - // { - // headers: { - // 'X-Custom-Header': 'Value', - // }, - // }, - // ) - // return new Response(res._body, res._init) - // } - // const fetch = makeFetch(app) - // const res = await fetch('/') - // res - // .expect('Content-Disposition', 'attachment; filename="favicon.ico"') - // .expect('X-Custom-Header', 'Value') - // }) - // }) + describe('res.download(filename)', () => { + it('should set Content-Disposition based on path', async () => { + const app = async (req: Request) => { + const res: DummyResponse & { send?: ReturnType } = { + _init: { + headers: new Headers(), + }, + locals: {}, + } + const filePath = path.join(Deno.cwd(), 'tests/fixtures', '/favicon.ico') + res.send = send(req, res) + await download(req, res)(filePath) + return new Response(res._body, res._init) + } + + const res = await makeFetch(app)('/') + + res.expect('Content-Disposition', 'attachment; filename="favicon.ico"') + }) + it('should set Content-Disposition based on filename', async () => { + const app = async (req: Request) => { + const res: DummyResponse & { send?: ReturnType } = { + _init: { headers: new Headers({}) }, + locals: {}, + } + res.send = send(req, res) + await download(req, res)( + path.join(Deno.cwd(), 'tests/fixtures', '/favicon.ico'), + 'favicon.icon', + ) + return new Response(res._body, res._init) + } + const res = await makeFetch(app)('/') + + res.expect( + 'Content-Disposition', + 'attachment; filename="favicon.icon"', + ) + }) + it('should pass the error to a callback', async () => { + const app = async (req: Request) => { + const res: DummyResponse & { send?: ReturnType } = { + _init: { headers: new Headers({}) }, + } as DummyResponse + res.send = send(req, res) + await download(req, res)( + path.join(Deno.cwd(), 'tests/fixtures'), + 'some_file.png', + (err) => { + expect((err as Error).message).toContain('readfile') + }, + ) + return new Response(res._body, res._init) + } + + const res = await makeFetch(app)('/') + res.expectHeader( + 'Content-Disposition', + 'attachment; filename="some_file.png"', + ) + }) + it.skip('should set "root" from options', async () => { + const app = runServer(async (req, res) => { + return (await download(req, res)('favicon.ico', 'favicon.ico', { + root: path.join(Deno.cwd(), 'tests/fixtures'), + })) as unknown as Response + }) + ;(await makeFetch(app)('/')).expect( + 'Content-Disposition', + 'attachment; filename="favicon.ico"', + ) + }) + it('should pass options to sendFile', async () => { + const ac = new AbortController() + const app = async (req: Request) => { + const res: DummyResponse & { send?: ReturnType } = { + _init: { headers: new Headers({}) }, + locals: {}, + } + res.send = send(req, res) + await download(req, res)( + path.join(Deno.cwd(), 'tests/fixtures', 'favicon.ico'), + 'favicon.ico', + { signal: ac.signal }, + ) + return new Response(res._body, res._init) + } + const fetch = makeFetch(app) + const res = await fetch('/') + res.expect('Content-Disposition', 'attachment; filename="favicon.ico"') + }) + it('should set headers from options', async () => { + const app = async (req: Request) => { + const res: DummyResponse & { send?: ReturnType } = { + _init: { headers: new Headers({}) }, + locals: {}, + } + res.send = send(req, res) + await download(req, res)( + path.join(Deno.cwd(), 'tests/fixtures', 'favicon.ico'), + 'favicon.ico', + { + headers: { + 'X-Custom-Header': 'Value', + }, + }, + ) + return new Response(res._body, res._init) + } + const fetch = makeFetch(app) + const res = await fetch('/') + res + .expect('Content-Disposition', 'attachment; filename="favicon.ico"') + .expect('X-Custom-Header', 'Value') + }) + }) describe('res.cookie(name, value, options)', () => { it('serializes the cookie and puts it in a Set-Cookie header', async () => { const app = () => { diff --git a/utils/eTag.ts b/utils/eTag.ts index 02fa584..3f4ce5f 100644 --- a/utils/eTag.ts +++ b/utils/eTag.ts @@ -1,13 +1,13 @@ import { base64 } from '../deps.ts' const encoder = new TextEncoder() -const entityTag = async (entity: string): Promise => { - if (entity.length === 0) { +const entityTag = async (entity: string | Uint8Array): Promise => { + if (entity.length === 0 && typeof entity != 'string') { // fast-path empty return '"0-2jmj7l5rSw0yVb/vlWAYkK/YBwk"' } else { // generate hash - const data = encoder.encode(entity) + const data = encoder.encode(entity as string) const buf = await crypto.subtle.digest('SHA-1', data) const hash = base64.encode(buf).slice(0, 27) const len = data.byteLength @@ -30,8 +30,8 @@ export const eTag = async ( // generate entity tag - const tag = typeof entity === 'string' - ? await entityTag(entity) + const tag = typeof entity === 'string' || ArrayBuffer.isView(entity) + ? await entityTag(entity as string) : statTag(entity) return weak ? 'W/' + tag : tag