Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
13 changes: 13 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -68,6 +78,9 @@
"serve-static": [
"./dist/serve-static.d.mts"
],
"send-file": [
"./dist/send-file.d.mts"
],
"utils/*": [
"./dist/utils/*.d.mts"
],
Expand Down
2 changes: 2 additions & 0 deletions src/send-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { sendFile } from './serve-static'
export type { SendFileOptions } from './serve-static'
244 changes: 166 additions & 78 deletions src/serve-static.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,18 @@ export type ServeStaticOptions<E extends Env = Env> = {
onNotFound?: (path: string, c: Context<E>) => void | Promise<void>
}

export type SendFileOptions<E extends Env = Env> = Pick<
ServeStaticOptions<E>,
'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<E>) => Response | void | Promise<Response | void>
}

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 = {
Expand Down Expand Up @@ -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 = <E extends Env>(
c: Context<E>,
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 = <E extends Env>(c: Context<E>, 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<E extends Env> = Pick<ServeStaticOptions<E>, 'precompressed' | 'onFound'>

const serveFile = async <E extends Env>(
c: Context<E>,
path: string,
stats: Stats,
options: ServeFileOptions<E>
): Promise<Response> => {
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 = <E extends Env = any>(
options: ServeStaticOptions<E> = { root: '' }
Expand Down Expand Up @@ -136,94 +260,58 @@ export const serveStatic = <E extends Env = any>(
}
}

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 <E extends Env = any>(
c: Context<E>,
path: string,
options: SendFileOptions<E> = {}
): Promise<Response> => {
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)
}
Loading