diff --git a/README.md b/README.md index 88a4146..46beabd 100644 --- a/README.md +++ b/README.md @@ -285,6 +285,47 @@ app.use( ) ``` +## Send File Helper + +While `serveStatic` serves files based on the request path, the `sendFile` helper serves the file at the path you specify. It is useful when you want to determine the file to serve dynamically, like `res.sendFile()` of Express. It sets the same headers (e.g. `Content-Type`, `Content-Length`, `Last-Modified`) and supports the same features (range requests, HEAD/OPTIONS requests, precompressed files) as `serveStatic`. + +```ts +import { Hono } from 'hono' +import { sendFile } from '@hono/node-server/send-file' + +const app = new Hono() + +app.get('/download/:id', (c) => { + // Serve the file at a dynamically determined path + const filePath = lookupFilePathById(c.req.param('id')) + return sendFile(c, filePath) +}) +``` + +If the file is not found, `sendFile` returns the Not Found Response of the Context by default. You can customize it with the `onNotFound` option, which can return a `Response`. + +```ts +app.get('/download/:id', (c) => { + return sendFile(c, lookupFilePathById(c.req.param('id')), { + onNotFound: (path, c) => { + return c.text(`No file found at ${path}`, 404) + }, + }) +}) +``` + +### Options + +`sendFile` accepts the following options. `root`, `index`, `precompressed` and `onFound` work in the same way as the [options of `serveStatic`](#options-1). The given `path` is resolved relative to `root` when it is set. + +| Option | Description | +| ------ | ----------- | +| `root` | Root path to resolve the given `path` against. | +| `index` | Index file name to serve when the `path` points to a directory. Default is `index.html`. | +| `precompressed` | Serve precompressed files with `.br`/`.zst`/`.gz` extensions based on the `Accept-Encoding` header. | +| `onFound` | Callback called with the resolved file path and the Context when the file is found. | +| `onNotFound` | Callback called when the file is not found. Unlike `serveStatic`, it can return a `Response`, which is then used as the response. | + ## ConnInfo Helper You can use the [ConnInfo Helper](https://hono.dev/docs/helpers/conninfo) by importing `getConnInfo` from `@hono/node-server/conninfo`. diff --git a/package.json b/package.json index 40fd9dc..7d6cb79 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,16 @@ "default": "./dist/serve-static.cjs" } }, + "./send-file": { + "import": { + "types": "./dist/send-file.d.mts", + "default": "./dist/send-file.mjs" + }, + "require": { + "types": "./dist/send-file.d.cts", + "default": "./dist/send-file.cjs" + } + }, "./utils/*": { "import": { "types": "./dist/utils/*.d.mts", @@ -68,6 +78,9 @@ "serve-static": [ "./dist/serve-static.d.mts" ], + "send-file": [ + "./dist/send-file.d.mts" + ], "utils/*": [ "./dist/utils/*.d.mts" ], diff --git a/src/send-file.ts b/src/send-file.ts new file mode 100644 index 0000000..f107401 --- /dev/null +++ b/src/send-file.ts @@ -0,0 +1,2 @@ +export { sendFile } from './serve-static' +export type { SendFileOptions } from './serve-static' diff --git a/src/serve-static.ts b/src/serve-static.ts index 6cf59ee..f503bfd 100644 --- a/src/serve-static.ts +++ b/src/serve-static.ts @@ -18,6 +18,18 @@ export type ServeStaticOptions = { onNotFound?: (path: string, c: Context) => void | Promise } +export type SendFileOptions = Pick< + ServeStaticOptions, + 'root' | 'index' | 'precompressed' | 'onFound' +> & { + /** + * Called when the file is not found. Unlike the `onNotFound` option of `serveStatic`, + * it can return a `Response`, which is then used as the response. + * If it returns nothing, the Not Found Response of the Context (`c.notFound()`) is used. + */ + onNotFound?: (path: string, c: Context) => Response | void | Promise +} + const COMPRESSIBLE_CONTENT_TYPE_REGEX = /^\s*(?:text\/[^;\s]+|application\/(?:javascript|json|xml|xml-dtd|ecmascript|dart|postscript|rtf|tar|toml|vnd\.dart|vnd\.ms-fontobject|vnd\.ms-opentype|wasm|x-httpd-php|x-javascript|x-ns-proxy-autoconfig|x-sh|x-tar|x-virtualbox-hdd|x-virtualbox-ova|x-virtualbox-ovf|x-virtualbox-vbox|x-virtualbox-vdi|x-virtualbox-vhd|x-virtualbox-vmdk|x-www-form-urlencoded)|font\/(?:otf|ttf)|image\/(?:bmp|vnd\.adobe\.photoshop|vnd\.microsoft\.icon|vnd\.ms-dds|x-icon|x-ms-bmp)|message\/rfc822|model\/gltf-binary|x-shader\/x-fragment|x-shader\/x-vertex|[^;\s]+?\+(?:json|text|xml|yaml))(?:[;\s]|$)/i const ENCODINGS = { @@ -103,6 +115,118 @@ const tryDecode = (str: string, decoder: Decoder): string => { const tryDecodeURI = (str: string) => tryDecode(str, decodeURI) +type FoundFile = { path: string; stats: Stats | undefined } + +const findFile = (path: string, index?: string): FoundFile => { + const stats = getStats(path) + + if (!stats?.isDirectory()) { + return { path, stats } + } + + const indexPath = join(path, index ?? 'index.html') + return { path: indexPath, stats: getStats(indexPath) } +} + +const findPrecompressedFile = ( + path: string, + mimeType: string | undefined, + acceptEncodingHeader: string | undefined +): { encoding: string; path: string; stats: Stats } | undefined => { + const compressible = + !mimeType || + mimeType === 'application/octet-stream' || + COMPRESSIBLE_CONTENT_TYPE_REGEX.test(mimeType) + if (!compressible) { + return undefined + } + + const acceptedEncodings = new Set( + acceptEncodingHeader?.split(',').map((encoding) => encoding.trim()) + ) + + for (const encoding of ENCODINGS_ORDERED_KEYS) { + if (!acceptedEncodings.has(encoding)) { + continue + } + const precompressedPath = path + ENCODINGS[encoding] + const stats = getStats(precompressedPath) + if (stats) { + return { encoding, path: precompressedPath, stats } + } + } +} + +const createRangeResponse = ( + c: Context, + path: string, + range: string, + size: number +): Response => { + c.header('Accept-Ranges', 'bytes') + + // A malformed Range header serves the whole file, as `serveStatic` has always done. + const rangeSpec: ByteRangeSpec = parseByteRange(range) ?? { + type: 'open-ended', + start: 0, + } + const resolvedRange = resolveByteRange(rangeSpec, size) + + if (!resolvedRange) { + c.header('Content-Range', `bytes */${size}`) + return c.body(null, 416) + } + + const { start, end } = resolvedRange + const chunkSize = end - start + 1 + c.header('Content-Length', chunkSize.toString()) + c.header('Content-Range', `bytes ${start}-${end}/${size}`) + return c.body(createStreamBody(createReadStream(path, { start, end })), 206) +} + +const createFileResponse = (c: Context, path: string, size: number): Response => { + if (c.req.method === 'HEAD' || c.req.method === 'OPTIONS') { + c.header('Content-Length', size.toString()) + c.status(200) + return c.body(null) + } + + const range = c.req.header('range') + if (!range) { + c.header('Content-Length', size.toString()) + return c.body(createStreamBody(createReadStream(path)), 200) + } + + return createRangeResponse(c, path, range, size) +} + +type ServeFileOptions = Pick, 'precompressed' | 'onFound'> + +const serveFile = async ( + c: Context, + path: string, + stats: Stats, + options: ServeFileOptions +): Promise => { + const mimeType = getMimeType(path) + c.header('Content-Type', mimeType || 'application/octet-stream') + + if (options.precompressed) { + const precompressed = findPrecompressedFile(path, mimeType, c.req.header('Accept-Encoding')) + if (precompressed) { + c.header('Content-Encoding', precompressed.encoding) + c.header('Vary', 'Accept-Encoding', { append: true }) + path = precompressed.path + stats = precompressed.stats + } + } + + c.header('Last-Modified', stats.mtime.toUTCString()) + const result = createFileResponse(c, path, stats.size) + await options.onFound?.(path, c) + return result +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any export const serveStatic = ( options: ServeStaticOptions = { root: '' } @@ -136,94 +260,58 @@ export const serveStatic = ( } } - let path = join( + const requestPath = join( root, !optionPath && options.rewriteRequestPath ? options.rewriteRequestPath(filename, c) : filename ) - let stats = getStats(path) - - if (stats && stats.isDirectory()) { - const indexFile = options.index ?? 'index.html' - path = join(path, indexFile) - stats = getStats(path) - } + const found = findFile(requestPath, options.index) - if (!stats) { - await options.onNotFound?.(path, c) + if (!found.stats) { + await options.onNotFound?.(found.path, c) return next() } - const mimeType = getMimeType(path) - c.header('Content-Type', mimeType || 'application/octet-stream') - - if ( - options.precompressed && - (!mimeType || - mimeType === 'application/octet-stream' || - COMPRESSIBLE_CONTENT_TYPE_REGEX.test(mimeType)) - ) { - const acceptEncodingSet = new Set( - c.req - .header('Accept-Encoding') - ?.split(',') - .map((encoding) => encoding.trim()) - ) - - for (const encoding of ENCODINGS_ORDERED_KEYS) { - if (!acceptEncodingSet.has(encoding)) { - continue - } - const precompressedStats = getStats(path + ENCODINGS[encoding]) - if (precompressedStats) { - c.header('Content-Encoding', encoding) - c.header('Vary', 'Accept-Encoding', { append: true }) - stats = precompressedStats - path = path + ENCODINGS[encoding] - break - } - } - } - - let result - const size = stats.size - const range = c.req.header('range') || '' - c.header('Last-Modified', stats.mtime.toUTCString()) - - if (c.req.method == 'HEAD' || c.req.method == 'OPTIONS') { - c.header('Content-Length', size.toString()) - c.status(200) - result = c.body(null) - } else if (!range) { - c.header('Content-Length', size.toString()) - result = c.body(createStreamBody(createReadStream(path)), 200) - } else { - c.header('Accept-Ranges', 'bytes') - - // Preserve the existing behavior of serving the whole representation for - // a malformed range. - const rangeSpec: ByteRangeSpec = parseByteRange(range) ?? { - type: 'open-ended', - start: 0, - } - const resolvedRange = resolveByteRange(rangeSpec, size) - - if (!resolvedRange) { - c.header('Content-Range', `bytes */${size}`) - result = c.body(null, 416) - } else { - const { start, end } = resolvedRange - const chunkSize = end - start + 1 - const stream = createReadStream(path, { start, end }) + return serveFile(c, found.path, found.stats, options) + } +} - c.header('Content-Length', chunkSize.toString()) - c.header('Content-Range', `bytes ${start}-${end}/${size}`) +/** + * Send a file as the response, like `res.sendFile()` of Express. + * + * While `serveStatic` serves files based on the request path, `sendFile` serves + * the file at the given path, so it is useful when you want to determine the + * file to serve dynamically. It sets the same headers (e.g. `Content-Type`, + * `Content-Length`, `Last-Modified`) and supports the same features (range + * requests, HEAD/OPTIONS requests, precompressed files) as `serveStatic`. + * + * When the file is not found, `sendFile` returns the Not Found Response of the + * Context instead of calling the next handler. Customize this with the + * `onNotFound` option, which may return a `Response` to use instead. + * + * @example + * ```ts + * app.get('/download/:id', (c) => sendFile(c, lookupFilePathById(c.req.param('id')))) + * ``` + * + * @see {@link https://github.com/honojs/node-server/issues/205} + */ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export const sendFile = async ( + c: Context, + path: string, + options: SendFileOptions = {} +): Promise => { + if (c.finalized) { + return c.res + } - result = c.body(createStreamBody(stream), 206) - } - } + const found = findFile(join(options.root || '', path), options.index) - await options.onFound?.(path, c) - return result + if (!found.stats) { + const response = await options.onNotFound?.(found.path, c) + return response ?? (await c.notFound()) } + + return serveFile(c, found.path, found.stats, options) } diff --git a/test/send-file.test.ts b/test/send-file.test.ts new file mode 100644 index 0000000..8fd05e9 --- /dev/null +++ b/test/send-file.test.ts @@ -0,0 +1,308 @@ +import { Hono } from 'hono' +import { statSync } from 'node:fs' +import path from 'node:path' +import { sendFile } from './../src/send-file' +import { createAdaptorServer } from './../src/server' +import { requestServer } from './helpers/request' + +describe('Send File Helper', () => { + const server = createAdaptorServer( + new Hono() + // Registered with `.all()` (like `app.use(...)` in the serveStatic tests) + // so HEAD/OPTIONS requests also reach `sendFile`. + .all('/dynamic/*', (c) => { + const requested = c.req.path.replace('/dynamic/', '') + const filePath = + requested === 'root' + ? './test/assets/static/index.html' + : `./test/assets/static/${requested}` + return sendFile(c, filePath) + }) + .get('/with-root/:name', (c) => { + return sendFile(c, c.req.param('name'), { root: './test/assets/static' }) + }) + .get('/with-on-found/:name', (c) => { + return sendFile(c, `./test/assets/static/${c.req.param('name')}`, { + onFound: (path, c) => { + c.header('X-Custom', `Found the file at ${path}`) + }, + }) + }) + .get('/with-on-not-found', (c) => { + return sendFile(c, './test/assets/static/does-not-exist.html', { + onNotFound: (path, c) => { + return c.text(`${path} is not found`, 404) + }, + }) + }) + .get('/directory', (c) => { + return sendFile(c, './test/assets/static') + }) + .get('/directory-with-index', (c) => { + return sendFile(c, './test/assets/static', { index: 'plain.txt' }) + }) + .get('/precompressed', (c) => { + return sendFile(c, './test/assets/static-with-precompressed/hello.txt', { + precompressed: true, + }) + }) + ) + + it('Should return the file with correct headers and data', async () => { + const res = await requestServer(server, { method: 'GET', path: '/dynamic/plain.txt' }) + const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8') + expect(res.headers.get('content-length')).toBe('17') + expect(res.headers.get('last-modified')).toBe(stats.mtime.toUTCString()) + expect(await res.text()).toBe('This is plain.txt') + }) + + it('Should return the HTML file at the given path', async () => { + const res = await requestServer(server, { method: 'GET', path: '/dynamic/root' }) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8') + expect(await res.text()).toBe('

Hello Hono

') + }) + + it('Should return correct headers and data for json files', async () => { + const res = await requestServer(server, { method: 'GET', path: '/dynamic/data.json' }) + expect(res.status).toBe(200) + expect(await res.json()).toEqual({ + id: 1, + name: 'Foo Bar', + flag: true, + }) + expect(res.headers.get('content-type')).toBe('application/json') + }) + + it('Should resolve the path with the root option', async () => { + const res = await requestServer(server, { method: 'GET', path: '/with-root/plain.txt' }) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8') + expect(await res.text()).toBe('This is plain.txt') + }) + + it('Should return the Not Found Response of the Context for non-existent files', async () => { + const res = await requestServer(server, { method: 'GET', path: '/dynamic/does-not-exist.txt' }) + expect(res.status).toBe(404) + expect(res.headers.get('content-type')).toBe('text/plain; charset=UTF-8') + expect(await res.text()).toBe('404 Not Found') + }) + + it('Should use the custom notFound handler of the app', async () => { + const app = new Hono() + app.get('/file', (c) => sendFile(c, './test/assets/static/does-not-exist.txt')) + app.notFound((c) => c.text('Custom Not Found', 404)) + const customServer = createAdaptorServer(app) + + const res = await requestServer(customServer, { method: 'GET', path: '/file' }) + expect(res.status).toBe(404) + expect(await res.text()).toBe('Custom Not Found') + + await new Promise((resolve) => customServer.close(() => resolve())) + }) + + it('Should return the Response from onNotFound if provided', async () => { + const res = await requestServer(server, { method: 'GET', path: '/with-on-not-found' }) + expect(res.status).toBe(404) + expect(await res.text()).toMatch(/does-not-exist\.html is not found/) + }) + + it('Should call onFound with the resolved path', async () => { + const res = await requestServer(server, { method: 'GET', path: '/with-on-found/plain.txt' }) + expect(res.status).toBe(200) + expect(res.headers.get('x-custom')).toMatch( + /Found the file at test[\/\\]assets[\/\\]static[\/\\]plain\.txt$/ + ) + expect(await res.text()).toBe('This is plain.txt') + }) + + it('Should return 200 response to HEAD request', async () => { + const res = await requestServer(server, { method: 'HEAD', path: '/dynamic/plain.txt' }) + const stats = statSync(path.join(__dirname, 'assets', 'static', 'plain.txt')) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8') + expect(res.headers.get('content-length')).toBe('17') + expect(res.headers.get('last-modified')).toBe(stats.mtime.toUTCString()) + expect(res.body).toBeNull() + }) + + it('Should return 200 response to OPTIONS request', async () => { + // The `requestServer` helper cannot read a bodiless response, so check the + // `Response` directly. + const app = new Hono().options('/file', (c) => sendFile(c, './test/assets/static/plain.txt')) + const res = await app.request('/file', { method: 'OPTIONS' }) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8') + expect(res.headers.get('content-length')).toBe('17') + expect(res.body).toBeNull() + }) + + it('Should serve the index file specified with the index option for a directory', async () => { + const res = await requestServer(server, { method: 'GET', path: '/directory-with-index' }) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/plain; charset=utf-8') + expect(await res.text()).toBe('This is plain.txt') + }) + + it('Should return 404 for a directory without an index file', async () => { + const res = await requestServer(server, { method: 'GET', path: '/dynamic/admin' }) + expect(res.status).toBe(404) + }) + + it('Should return index.html for a directory by default', async () => { + const res = await requestServer(server, { method: 'GET', path: '/directory' }) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('text/html; charset=utf-8') + expect(await res.text()).toBe('

Hello Hono

') + }) + + it('Should serve precompressed files based on Accept-Encoding', async () => { + const res = await requestServer(server, { + method: 'GET', + path: '/precompressed', + headers: { 'accept-encoding': 'br' }, + }) + expect(res.status).toBe(200) + expect(res.headers.get('content-encoding')).toBe('br') + expect(res.headers.get('vary')).toBe('Accept-Encoding') + expect(await res.text()).toBe('Hello br Compressed') + }) + + describe('Range requests', () => { + it('Should return correct headers and data with range headers', async () => { + const res = await requestServer(server, { + method: 'GET', + path: '/dynamic/plain.txt', + headers: { range: '0-9' }, + }) + expect(res.status).toBe(206) + expect(res.headers.get('content-length')).toBe('10') + expect(res.headers.get('content-range')).toBe('bytes 0-9/17') + expect(await res.text()).toBe('This is pl') + }) + + it('Should return the remaining bytes with a range starting in the middle', async () => { + const res = await requestServer(server, { + method: 'GET', + path: '/dynamic/plain.txt', + headers: { range: '10-16' }, + }) + expect(res.status).toBe(206) + expect(res.headers.get('content-range')).toBe('bytes 10-16/17') + expect(await res.text()).toBe('ain.txt') + }) + + it('Should handle a client range exceeding the data size', async () => { + const res = await requestServer(server, { + method: 'GET', + path: '/dynamic/plain.txt', + headers: { range: '0-20' }, + }) + expect(res.status).toBe(206) + expect(res.headers.get('content-range')).toBe('bytes 0-16/17') + expect(await res.text()).toBe('This is plain.txt') + }) + + it('Should handle an invalid range header gracefully', async () => { + const res = await requestServer(server, { + method: 'GET', + path: '/dynamic/plain.txt', + headers: { range: 'hello' }, + }) + expect(res.status).toBe(206) + expect(res.headers.get('content-range')).toBe('bytes 0-16/17') + expect(await res.text()).toBe('This is plain.txt') + }) + + it('Should return the last N bytes for a suffix range', async () => { + const res = await requestServer(server, { + method: 'GET', + path: '/dynamic/plain.txt', + headers: { range: 'bytes=-5' }, + }) + expect(res.status).toBe(206) + expect(res.headers.get('content-range')).toBe('bytes 12-16/17') + expect(await res.text()).toBe('n.txt') + }) + + it('Should return the whole file for a suffix range exceeding the file size', async () => { + const res = await requestServer(server, { + method: 'GET', + path: '/dynamic/plain.txt', + headers: { range: 'bytes=-100' }, + }) + expect(res.status).toBe(206) + expect(res.headers.get('content-range')).toBe('bytes 0-16/17') + expect(await res.text()).toBe('This is plain.txt') + }) + + it('Should return exactly 1 byte for range bytes=0-0', async () => { + const res = await requestServer(server, { + method: 'GET', + path: '/dynamic/plain.txt', + headers: { range: 'bytes=0-0' }, + }) + expect(res.status).toBe(206) + expect(res.headers.get('content-range')).toBe('bytes 0-0/17') + expect(await res.text()).toBe('T') + }) + + it('Should return 416 when the range start is beyond the end of the file', async () => { + const res = await requestServer(server, { + method: 'GET', + path: '/dynamic/plain.txt', + headers: { range: 'bytes=100-200' }, + }) + expect(res.status).toBe(416) + expect(res.headers.get('content-range')).toBe('bytes */17') + }) + }) + + describe('Already finalized Context', () => { + it('Should return the already set Response', async () => { + const app = new Hono() + app.get('/file', async (c, next) => { + await next() + return sendFile(c, './test/assets/static/plain.txt') + }) + app.get('/file', (c) => c.text('Already set')) + const finalizedServer = createAdaptorServer(app) + + // The first handler is only finalized after `next()` when it awaits the + // response of the second, so `sendFile` sees a finalized Context. + const res = await requestServer(finalizedServer, { method: 'GET', path: '/file' }) + expect(res.status).toBe(200) + expect(await res.text()).toBe('Already set') + + await new Promise((resolve) => finalizedServer.close(() => resolve())) + }) + }) + + describe('Type compatibility', () => { + it('Should be returnable from handlers without PR #4012 style core type changes', async () => { + // `sendFile` always returns a `Response`, so it satisfies the current + // `HandlerResponse` type without requiring handlers to be allowed to + // return middleware functions (honojs/hono#4012). + const app = new Hono<{ Variables: { greet: string } }>() + .use('/typed/*', async (c, next) => { + c.set('greet', 'Hello') + await next() + }) + .get('/typed/:name', (c) => { + const name = c.req.param('name') + c.header('X-Greet', c.get('greet')) + return sendFile(c, `./test/assets/static/${name}.txt`) + }) + + const typedServer = createAdaptorServer(app) + const res = await requestServer(typedServer, { method: 'GET', path: '/typed/plain' }) + expect(res.status).toBe(200) + expect(res.headers.get('x-greet')).toBe('Hello') + expect(await res.text()).toBe('This is plain.txt') + + await new Promise((resolve) => typedServer.close(() => resolve())) + }) + }) +}) diff --git a/tsdown.config.ts b/tsdown.config.ts index 4f771fb..c36b04e 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,7 +1,14 @@ import { defineConfig } from 'tsdown' export default defineConfig({ - entry: ['./src/index.ts', './src/serve-static.ts', './src/conninfo.ts', './src/early-hints.ts', './src/utils/*.ts'], + entry: [ + './src/index.ts', + './src/serve-static.ts', + './src/send-file.ts', + './src/conninfo.ts', + './src/early-hints.ts', + './src/utils/*.ts', + ], format: ['esm', 'cjs'], dts: true, sourcemap: false,