From 4d8c6e6fcdebeae237ee0884c3b47ce50d807969 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Sat, 15 Jul 2023 23:34:19 +0900 Subject: [PATCH] feat(serve-static): support range requests (#63) * 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> --- src/serve-static.ts | 77 ++++++++++++++++++++++++++++----------- test/serve-static.test.ts | 36 ++++++++++++++++++ 2 files changed, 91 insertions(+), 22 deletions(-) diff --git a/src/serve-static.ts b/src/serve-static.ts index 6e5bc8e..45e0b9f 100644 --- a/src/serve-static.ts +++ b/src/serve-static.ts @@ -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 = { /** @@ -14,36 +14,69 @@ export type ServeStaticOptions = { rewriteRequestPath?: (path: string) => string } -export const serveStatic = (options: ServeStaticOptions = { root: '' }) => { - return async (c: Context, next: Next): Promise => { +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) } } diff --git a/test/serve-static.test.ts b/test/serve-static.test.ts index 5df2bda..7656dc8 100644 --- a/test/serve-static.test.ts +++ b/test/serve-static.test.ts @@ -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') + }) })