Skip to content

Commit

Permalink
feat(serve-static): support range requests (#63)
Browse files Browse the repository at this point in the history
* Update serve-static.ts

Many fixes

* Pathnames

I just spot an other issue, if a path has spaces or some other special letters in them, most browsers will encode it as a URI component, so we have to decode it.

* Status code

The status code should only be 206 when range headers are preasent, else, it should be 200

* Prettier & Type fix

* Tests

* Update serve-static.ts

Connect Handler type for the middleware function

* merge main

* support if client range exceeds the data size

* merge

* use `Readable.toWeb()`

---------

Co-authored-by: Benjamin <45743294+Hoodgail@users.noreply.github.com>
  • Loading branch information
yusukebe and Hoodgail committed Jul 15, 2023
1 parent e5fe21f commit 4d8c6e6
Show file tree
Hide file tree
Showing 2 changed files with 91 additions and 22 deletions.
77 changes: 55 additions & 22 deletions src/serve-static.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import type { Next } from 'hono'
import { Context } from 'hono'
import { existsSync, readFileSync } from 'fs'
import type { MiddlewareHandler } from 'hono'
import { ReadStream, createReadStream, existsSync, lstatSync } from 'fs'
import { getFilePath } from 'hono/utils/filepath'
import { getMimeType } from 'hono/utils/mime'
import { Readable } from 'stream'

export type ServeStaticOptions = {
/**
Expand All @@ -14,36 +14,69 @@ export type ServeStaticOptions = {
rewriteRequestPath?: (path: string) => string
}

export const serveStatic = (options: ServeStaticOptions = { root: '' }) => {
return async (c: Context, next: Next): Promise<Response | undefined> => {
export const serveStatic = (options: ServeStaticOptions = { root: '' }): MiddlewareHandler => {
return async (c, next) => {
// Do nothing if Response is already set
if (c.finalized) {
await next()
}
if (c.finalized) return next()

const url = new URL(c.req.url)

const filename = options.path ?? decodeURI(url.pathname)
const filename = options.path ?? decodeURIComponent(url.pathname)
let path = getFilePath({
filename: options.rewriteRequestPath ? options.rewriteRequestPath(filename) : filename,
root: options.root,
defaultDocument: options.index ?? 'index.html',
})

path = `./${path}`

if (existsSync(path)) {
const content = readFileSync(path)
if (content) {
const mimeType = getMimeType(path)
if (mimeType) {
c.header('Content-Type', mimeType)
}
// Return Response object
return c.body(content)
}
if (!existsSync(path)) {
return next()
}

const mimeType = getMimeType(path)
if (mimeType) {
c.header('Content-Type', mimeType)
}

console.warn(`Static file: ${path} is not found`)
await next()
return
const stat = lstatSync(path)
const size = stat.size

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())
// Ignore the type mismatch. `c.body` can accept ReadableStream.
// @ts-ignore
return c.body(ReadStream.toWeb(createReadStream(path)), 200)
}

c.header('Accept-Ranges', 'bytes')
c.header('Date', stat.birthtime.toUTCString())

let start = 0
let end = stat.size - 1

const parts = range.replace(/bytes=/, '').split('-')
start = parseInt(parts[0], 10)
end = parts[1] ? parseInt(parts[1], 10) : end
if (size < end - start + 1) {
end = size - 1
}

const chunksize = end - start + 1
const stream = createReadStream(path, { start, end })

c.header('Content-Length', chunksize.toString())
c.header('Content-Range', `bytes ${start}-${end}/${stat.size}`)

// @ts-ignore
return c.body(Readable.toWeb(stream), 206)
}
}
36 changes: 36 additions & 0 deletions test/serve-static.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,40 @@ describe('Serve Static Middleware', () => {
const res = await request(server).get('/dot-static/does-no-exists.txt')
expect(res.status).toBe(404)
})

it('Should return 200 response to HEAD request', async () => {
const res = await request(server).head('/static/plain.txt')
expect(res.status).toBe(200)
expect(res.headers['content-type']).toBe('text/plain; charset=utf-8')
expect(res.headers['content-length']).toBe('17')
expect(res.text).toBe(undefined)
})

it('Should return correct headers and data with range headers', async () => {
let res = await request(server).get('/static/plain.txt').set('range', '0-9')
expect(res.status).toBe(206)
expect(res.headers['content-type']).toBe('text/plain; charset=utf-8')
expect(res.headers['content-length']).toBe('10')
expect(res.headers['content-range']).toBe('bytes 0-9/17')
expect(res.text.length).toBe(10)
expect(res.text).toBe('This is pl')

res = await request(server).get('/static/plain.txt').set('range', '10-16')
expect(res.status).toBe(206)
expect(res.headers['content-type']).toBe('text/plain; charset=utf-8')
expect(res.headers['content-length']).toBe('7')
expect(res.headers['content-range']).toBe('bytes 10-16/17')
expect(res.text.length).toBe(7)
expect(res.text).toBe('ain.txt')
})

it('Should return correct headers and data if client range exceeds the data size', async () => {
const res = await request(server).get('/static/plain.txt').set('range', '0-20')
expect(res.status).toBe(206)
expect(res.headers['content-type']).toBe('text/plain; charset=utf-8')
expect(res.headers['content-length']).toBe('17')
expect(res.headers['content-range']).toBe('bytes 0-16/17')
expect(res.text.length).toBe(17)
expect(res.text).toBe('This is plain.txt')
})
})

0 comments on commit 4d8c6e6

Please sign in to comment.