diff --git a/runtime-tests/bun/index.test.tsx b/runtime-tests/bun/index.test.tsx index 6dda78aba..8532f4b8f 100644 --- a/runtime-tests/bun/index.test.tsx +++ b/runtime-tests/bun/index.test.tsx @@ -167,6 +167,67 @@ describe('Serve Static Middleware', () => { expect(await res.text()).toBe('Bun!') expect(onNotFound).not.toHaveBeenCalled() }) + + describe('Range Request', () => { + it('Should support a single range request', async () => { + const res = await app.request( + new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=0-4' } }) + ) + expect(await res.text()).toBe('\u0000\u0000\u0001\u0000\u0003') + expect(res.status).toBe(206) + expect(res.headers.get('Content-Type')).toBe('image/x-icon') + expect(res.headers.get('Content-Length')).toBe('5') + expect(res.headers.get('Content-Range')).toBe('bytes 0-4/15406') + }) + + it('Should support a single range where its end is larger than the actual size', async () => { + const res = await app.request( + new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=15400-20000' } }) + ) + expect(await res.text()).toBe('\u0000\u0000\u0000\u0000\u0000\u0000') + expect(res.status).toBe(206) + expect(res.headers.get('Content-Type')).toBe('image/x-icon') + expect(res.headers.get('Content-Length')).toBe('6') + expect(res.headers.get('Content-Range')).toBe('bytes 15400-15405/15406') + }) + + it('Should support omitted end', async () => { + const res = await app.request( + new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=15400-' } }) + ) + expect(await res.text()).toBe('\u0000\u0000\u0000\u0000\u0000\u0000') + expect(res.status).toBe(206) + expect(res.headers.get('Content-Type')).toBe('image/x-icon') + expect(res.headers.get('Content-Length')).toBe('6') + expect(res.headers.get('Content-Range')).toBe('bytes 15400-15405/15406') + }) + + it('Should support the last N bytes request', async () => { + const res = await app.request( + new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=-10' } }) + ) + expect(await res.text()).toBe('\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000') + expect(res.status).toBe(206) + expect(res.headers.get('Content-Type')).toBe('image/x-icon') + expect(res.headers.get('Content-Length')).toBe('10') + expect(res.headers.get('Content-Range')).toBe('bytes 15396-15405/15406') + }) + + it('Should support multiple ranges', async () => { + const res = await app.request( + new Request('http://localhost/favicon.ico', { + headers: { Range: 'bytes=151-200,351-400,15401-15500' }, + }) + ) + await res.arrayBuffer() + expect(res.status).toBe(206) + expect(res.headers.get('Content-Type')).toBe( + 'multipart/byteranges; boundary=PARTIAL_CONTENT_BOUNDARY' + ) + expect(res.headers.get('Content-Length')).toBe('105') + expect(res.headers.get('Content-Range')).toBeNull() + }) + }) }) // Bun support WebCrypto since v0.2.2 diff --git a/runtime-tests/deno/middleware.test.tsx b/runtime-tests/deno/middleware.test.tsx index b3fc3fed5..12c9b2934 100644 --- a/runtime-tests/deno/middleware.test.tsx +++ b/runtime-tests/deno/middleware.test.tsx @@ -147,6 +147,61 @@ Deno.test('Serve Static middleware', async () => { res = await app.request('http://localhost/static-absolute-root/plain.txt') assertEquals(res.status, 200) assertEquals(await res.text(), 'Deno!') + + // Should support a single range request + res = await app.request( + new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=0-4' } }) + ) + assertEquals(await res.text(), '\u0000\u0000\u0001\u0000\u0003') + assertEquals(res.status, 206) + assertEquals(res.headers.get('Content-Type'), 'image/x-icon') + assertEquals(res.headers.get('Content-Length'), '5') + assertEquals(res.headers.get('Content-Range'), 'bytes 0-4/15406') + + // Should support a single range where its end is larger than the actual size + res = await app.request( + new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=15400-20000' } }) + ) + assertEquals(await res.text(), '\u0000\u0000\u0000\u0000\u0000\u0000') + assertEquals(res.status, 206) + assertEquals(res.headers.get('Content-Type'), 'image/x-icon') + assertEquals(res.headers.get('Content-Length'), '6') + assertEquals(res.headers.get('Content-Range'), 'bytes 15400-15405/15406') + + // Should support omitted end + res = await app.request( + new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=15400-' } }) + ) + assertEquals(await res.text(), '\u0000\u0000\u0000\u0000\u0000\u0000') + assertEquals(res.status, 206) + assertEquals(res.headers.get('Content-Type'), 'image/x-icon') + assertEquals(res.headers.get('Content-Length'), '6') + assertEquals(res.headers.get('Content-Range'), 'bytes 15400-15405/15406') + + // Should support the last N bytes request + res = await app.request( + new Request('http://localhost/favicon.ico', { headers: { Range: 'bytes=-10' } }) + ) + assertEquals(await res.text(), '\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000\u0000') + assertEquals(res.status, 206) + assertEquals(res.headers.get('Content-Type'), 'image/x-icon') + assertEquals(res.headers.get('Content-Length'), '10') + assertEquals(res.headers.get('Content-Range'), 'bytes 15396-15405/15406') + + // Should support multiple ranges + res = await app.request( + new Request('http://localhost/favicon.ico', { + headers: { Range: 'bytes=151-200,351-400,15401-15500' }, + }) + ) + await res.arrayBuffer() + assertEquals(res.status, 206) + assertEquals( + res.headers.get('Content-Type'), + 'multipart/byteranges; boundary=PARTIAL_CONTENT_BOUNDARY' + ) + assertEquals(res.headers.get('Content-Length'), '105') + assertEquals(res.headers.get('Content-Range'), null) }) Deno.test('JWT Authentication middleware', async () => { diff --git a/src/adapter/bun/serve-static.ts b/src/adapter/bun/serve-static.ts index c5be32e6c..b48c23624 100644 --- a/src/adapter/bun/serve-static.ts +++ b/src/adapter/bun/serve-static.ts @@ -1,5 +1,5 @@ /* eslint-disable @typescript-eslint/ban-ts-comment */ -import { stat } from 'node:fs/promises' +import { open, stat } from 'node:fs/promises' import { serveStatic as baseServeStatic } from '../../middleware/serve-static' import type { ServeStaticOptions } from '../../middleware/serve-static' import type { Env, MiddlewareHandler } from '../../types' @@ -30,6 +30,38 @@ export const serveStatic = ( getContent, pathResolve, isDir, + partialContentSupport: async (path: string) => { + path = path.startsWith('/') ? path : `./${path}` + const handle = await open(path) + const size = (await handle.stat()).size + return { + size, + getPartialContent: function getPartialContent(start: number, end: number) { + const readStream = handle.createReadStream({ start, end }) + const data = new ReadableStream({ + start(controller) { + readStream.on('data', (chunk) => { + controller.enqueue(chunk) + }) + readStream.on('end', () => { + controller.close() + }) + readStream.on('error', (e) => { + controller.error(e) + }) + }, + }) + return { + start, + end, + data, + } + }, + close: () => { + handle.close() + }, + } + }, })(c, next) } } diff --git a/src/adapter/cloudflare-workers/serve-static.ts b/src/adapter/cloudflare-workers/serve-static.ts index b2f4954e7..92eea12a1 100644 --- a/src/adapter/cloudflare-workers/serve-static.ts +++ b/src/adapter/cloudflare-workers/serve-static.ts @@ -31,6 +31,7 @@ export const serveStatic = ( : undefined, }) } + // partialContentSupport is not implemented since this middleware is deprecated return baseServeStatic({ ...options, getContent, diff --git a/src/adapter/deno/deno.d.ts b/src/adapter/deno/deno.d.ts index bebe8e966..4ac9140e0 100644 --- a/src/adapter/deno/deno.d.ts +++ b/src/adapter/deno/deno.d.ts @@ -8,6 +8,11 @@ declare namespace Deno { */ export function mkdir(path: string, options?: { recursive?: boolean }): Promise + export function lstatSync(path: string): { + isDirectory: boolean + size: number + } + /** * Write a new file, with the specified path and data. * @@ -25,4 +30,19 @@ declare namespace Deno { response: Response socket: WebSocket } + + export function open(path: string): Promise + + export enum SeekMode { + Start = 0, + } + + export function seekSync(rid: number, offset: number, whence: SeekMode): number + export function readSync(rid: number, buffer: Uint8Array): number + + export type FsFile = { + rid: number + readable: ReadableStream + close(): void + } } diff --git a/src/adapter/deno/serve-static.ts b/src/adapter/deno/serve-static.ts index 33786b203..b24b5fc2d 100644 --- a/src/adapter/deno/serve-static.ts +++ b/src/adapter/deno/serve-static.ts @@ -4,7 +4,7 @@ import type { Env, MiddlewareHandler } from '../../types' // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore -const { open, lstatSync } = Deno +const { open, lstatSync, seekSync, readSync, SeekMode } = Deno export const serveStatic = ( options: ServeStaticOptions @@ -13,10 +13,10 @@ export const serveStatic = ( const getContent = async (path: string) => { try { const file = await open(path) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return file ? (file.readable as any) : null + return file?.readable ?? null } catch (e) { console.warn(`${e}`) + return null } } const pathResolve = (path: string) => { @@ -35,6 +35,27 @@ export const serveStatic = ( getContent, pathResolve, isDir, + partialContentSupport: async (path: string) => { + path = path.startsWith('/') ? path : `./${path}` + const handle = await open(path) + const size = lstatSync(path).size + return { + size, + getPartialContent: function getPartialContent(start: number, end: number) { + seekSync(handle.rid, start, SeekMode.Start) + const data = new Uint8Array(end - start + 1) + readSync(handle.rid, data) + return { + start, + end, + data, + } + }, + close: () => { + handle.close() + }, + } + }, })(c, next) } } diff --git a/src/middleware/serve-static/index.test.ts b/src/middleware/serve-static/index.test.ts index 968140467..e2fe2b5c3 100644 --- a/src/middleware/serve-static/index.test.ts +++ b/src/middleware/serve-static/index.test.ts @@ -284,4 +284,262 @@ describe('Serve Static Middleware', () => { expect(res.status).toBe(404) }) }) + + describe('206 Partial Content support', async () => { + const getContent = vi.fn() + const onFound = vi.fn() + const onNotFound = vi.fn() + const close = vi.fn() + const partialContentSupport = vi.fn(async (path) => { + return { + size: 1000, + getPartialContent: (start: number, end: number) => ({ + start, + end, + data: `Hello in ${path}`, + }), + close, + } + }) + beforeEach(() => { + getContent.mockClear() + onFound.mockClear() + onNotFound.mockClear() + partialContentSupport.mockClear() + close.mockClear() + }) + + it('fallbacks to getContent if Range header can not be decoded', async () => { + const partialContentSupport = vi.fn() + const app = new Hono().use( + '*', + baseServeStatic({ + getContent, + partialContentSupport, + onNotFound, + onFound, + }) + ) + + const res = await app.request('/static/hello.jpg', { + headers: { + Range: 'bytes=INVALID', + }, + }) + + expect(getContent).toBeCalledTimes(1) + expect(partialContentSupport).not.toBeCalled() + expect(res.status).toBe(404) + // Other parts of the response is not interesting here + }) + + it('supports bytes=N-M to return a single requested range', async () => { + const app = new Hono().use( + '*', + baseServeStatic({ + getContent, + partialContentSupport, + onNotFound, + onFound, + }) + ) + + const res = await app.request('/static/hello.jpg', { + headers: { + Range: 'bytes=31-130', + }, + }) + + expect(getContent).not.toBeCalled() + expect(partialContentSupport).toBeCalledTimes(1) + expect(close).toBeCalledTimes(1) + expect(onFound).toBeCalledTimes(1) + expect(onNotFound).not.toBeCalled() + expect(res.status).toBe(206) + expect(res.headers.get('Content-Encoding')).toBeNull() + expect(res.headers.get('Vary')).toBeNull() + expect(res.headers.get('Content-Type')).toMatch(/^image\/jpeg$/) + expect(res.headers.get('Accept-Ranges')).toMatch(/^bytes$/) + expect(res.headers.get('Content-Range')).toMatch(/^bytes 31-130\/1000$/) + expect(res.headers.get('Content-Length')).toMatch(/^100$/) + expect(await res.text()).toBe('Hello in static/hello.jpg') + }) + + it('supports bytes=N- to return the remaining bytes after the first (N-1) bytes', async () => { + const app = new Hono().use( + '*', + baseServeStatic({ + getContent, + partialContentSupport, + onNotFound, + onFound, + }) + ) + + const res = await app.request('/static/hello.jpg', { + headers: { + Range: 'bytes=31-', + }, + }) + + expect(getContent).not.toBeCalled() + expect(partialContentSupport).toBeCalledTimes(1) + expect(close).toBeCalledTimes(1) + expect(onFound).toBeCalledTimes(1) + expect(onNotFound).not.toBeCalled() + expect(res.status).toBe(206) + expect(res.headers.get('Content-Encoding')).toBeNull() + expect(res.headers.get('Vary')).toBeNull() + expect(res.headers.get('Content-Type')).toMatch(/^image\/jpeg$/) + expect(res.headers.get('Accept-Ranges')).toMatch(/^bytes$/) + expect(res.headers.get('Content-Range')).toMatch(/^bytes 31-999\/1000$/) + expect(res.headers.get('Content-Length')).toMatch(/^969$/) + expect(await res.text()).toBe('Hello in static/hello.jpg') + }) + + it('supports bytes=-N to return the last N bytes', async () => { + const app = new Hono().use( + '*', + baseServeStatic({ + getContent, + partialContentSupport, + onNotFound, + onFound, + }) + ) + + const res = await app.request('/static/hello.jpg', { + headers: { + Range: 'bytes=-100', + }, + }) + + expect(getContent).not.toBeCalled() + expect(partialContentSupport).toBeCalledTimes(1) + expect(close).toBeCalledTimes(1) + expect(onFound).toBeCalledTimes(1) + expect(onNotFound).not.toBeCalled() + expect(res.status).toBe(206) + expect(res.headers.get('Content-Encoding')).toBeNull() + expect(res.headers.get('Vary')).toBeNull() + expect(res.headers.get('Content-Type')).toMatch(/^image\/jpeg$/) + expect(res.headers.get('Accept-Ranges')).toMatch(/^bytes$/) + expect(res.headers.get('Content-Range')).toMatch(/^bytes 900-999\/1000$/) + expect(res.headers.get('Content-Length')).toMatch(/^100$/) + expect(await res.text()).toBe('Hello in static/hello.jpg') + }) + + // TODO: 416 Range not satisfiable + // TODO: compression + // TODO: Date, Cache-Control, ETag, Expires, Content-Location, and Vary. + // TODO: If-Range + + it('supports multiple ranges byte=N1-N2,N3-N4,...', async () => { + const partialContentSupport = vi.fn(async (path: string) => { + return { + getPartialContent: (start: number, end: number) => { + const data = + start === 101 + ? `Hello in ${path}` + : start === 301 + ? new Blob([`Hello in ${path}`]).stream() + : start === 501 + ? new TextEncoder().encode(`Hello in ${path}`) + : null + return { start, end, data: data! } + }, + size: 1000, + close, + } + }) + const app = new Hono().use( + '*', + baseServeStatic({ + getContent, + partialContentSupport, + onNotFound, + onFound, + }) + ) + + const res = await app.request('/static/hello.jpg', { + headers: { + Range: 'bytes=101-200, 301-400, 501-600', + }, + }) + + expect(getContent).not.toBeCalled() + expect(partialContentSupport).toBeCalledTimes(1) + expect(close).toBeCalledTimes(1) + expect(onFound).toBeCalledTimes(1) + expect(onNotFound).not.toBeCalled() + expect(res.status).toBe(206) + expect(res.headers.get('Content-Encoding')).toBeNull() + expect(res.headers.get('Vary')).toBeNull() + expect(res.headers.get('Content-Type')).toMatch( + /^multipart\/byteranges; boundary=PARTIAL_CONTENT_BOUNDARY$/ + ) + expect(res.headers.get('Accept-Ranges')).toMatch(/^bytes$/) + expect(res.headers.get('Content-Range')).toBeNull() + expect(res.headers.get('Content-Length')).toMatch(/^300$/) + expect(await res.text()).toBe( + [ + '--PARTIAL_CONTENT_BOUNDARY', + 'Content-Type: image/jpeg', + 'Content-Range: bytes 101-200/1000', + '', + 'Hello in static/hello.jpg', + '--PARTIAL_CONTENT_BOUNDARY', + 'Content-Type: image/jpeg', + 'Content-Range: bytes 301-400/1000', + '', + 'Hello in static/hello.jpg', + '--PARTIAL_CONTENT_BOUNDARY', + 'Content-Type: image/jpeg', + 'Content-Range: bytes 501-600/1000', + '', + 'Hello in static/hello.jpg', + '--PARTIAL_CONTENT_BOUNDARY--', + '', + ].join('\r\n') + ) + }) + + it('handles no content', async () => { + const partialContentSupport = vi.fn(async () => { + return { + getPartialContent: vi.fn(), + size: undefined, + close, + } + }) + const app = new Hono().use( + '*', + baseServeStatic({ + getContent, + partialContentSupport, + onNotFound, + onFound, + }) + ) + + const res = await app.request('/static/hello.jpg', { + headers: { + Range: 'bytes=-100', + }, + }) + + expect(getContent).not.toBeCalled() + expect(partialContentSupport).toBeCalledTimes(1) + expect(close).not.toBeCalled() + expect(onFound).not.toBeCalled() + expect(onNotFound).toBeCalledTimes(1) + expect(res.status).toBe(404) + expect(res.headers.get('Content-Type')).toMatch(/^text\/plain/) + expect(res.headers.get('Accept-Ranges')).toBeNull() + expect(res.headers.get('Content-Range')).toBeNull() + expect(res.headers.get('Content-Length')).toBeNull() + expect(await res.text()).toBe('404 Not Found') + }) + }) }) diff --git a/src/middleware/serve-static/index.ts b/src/middleware/serve-static/index.ts index 0f1837f22..473ef3ce7 100644 --- a/src/middleware/serve-static/index.ts +++ b/src/middleware/serve-static/index.ts @@ -7,7 +7,7 @@ import type { Context, Data } from '../../context' import type { Env, MiddlewareHandler } from '../../types' import { COMPRESSIBLE_CONTENT_TYPE_REGEX } from '../../utils/compress' import { getFilePath, getFilePathWithoutDefaultDocument } from '../../utils/filepath' -import { getMimeType } from '../../utils/mime' +import { getMimeType, mimes } from '../../utils/mime' export type ServeStaticOptions = { root?: string @@ -29,12 +29,66 @@ const ENCODINGS_ORDERED_KEYS = Object.keys(ENCODINGS) as (keyof typeof ENCODINGS const DEFAULT_DOCUMENT = 'index.html' const defaultPathResolve = (path: string) => path +const PARTIAL_CONTENT_BOUNDARY = 'PARTIAL_CONTENT_BOUNDARY' + +export type PartialContent = { start: number; end: number; data: Data } + +const formatRangeSize = (start: number, end: number, totalSize: number | undefined): string => { + end = Math.min(end, totalSize ? totalSize - 1 : end) + return `bytes ${start}-${end}/${totalSize ?? '*'}` +} + +export type RangeRequest = + | { type: 'range'; start: number; end: number | undefined } + | { type: 'last'; last: number } + | { type: 'ranges'; ranges: Array<{ start: number; end: number }> } + +// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Range +const decodeRangeRequestHeader = (raw: string | undefined): RangeRequest | undefined => { + const bytes = raw?.match(/^bytes=(.+)$/) + if (bytes) { + const bytesContent = bytes[1].trim() + const last = bytesContent.match(/^-(\d+)$/) + if (last) { + return { type: 'last', last: parseInt(last[1]) } + } + + const single = bytesContent.match(/^(\d+)-(\d+)?$/) + if (single) { + return { + type: 'range', + start: parseInt(single[1]), + end: single[2] ? parseInt(single[2]) : undefined, + } + } + + const multiple = bytesContent.match(/^(\d+-\d+(?:,\s*\d+-\d+)+)$/) + if (multiple) { + const ranges = multiple[1].split(',').map((range) => { + const [start, end] = range.split('-').map((n) => parseInt(n.trim())) + return { start, end } + }) + return { type: 'ranges', ranges } + } + } + + // RFC 9110 https://www.rfc-editor.org/rfc/rfc9110#field.range + // - An origin server MUST ignore a Range header field that contains a range unit it does not understand. + // - A server that supports range requests MAY ignore or reject a Range header field that contains an invalid ranges-specifier. + return undefined +} + /** * This middleware is not directly used by the user. Create a wrapper specifying `getContent()` by the environment such as Deno or Bun. */ export const serveStatic = ( options: ServeStaticOptions & { getContent: (path: string, c: Context) => Promise + partialContentSupport?: (path: string) => Promise<{ + size: number | undefined + getPartialContent: (start: number, end: number) => PartialContent + close: () => void + }> pathResolve?: (path: string) => string isDir?: (path: string) => boolean | undefined | Promise } @@ -86,6 +140,87 @@ export const serveStatic = ( path = '/' + path } + const rangeRequest = decodeRangeRequestHeader(c.req.header('Range')) + if (rangeRequest) { + if (!options.partialContentSupport) { + // Fallbacks to getContent since getPartialContents is not provided + } else { + const { size, getPartialContent, close } = await options.partialContentSupport(path) + if (size === undefined) { + await options.onNotFound?.(path, c) + await next() + return + } + const offsetSize = size - 1 + let contents: Array + switch (rangeRequest.type) { + case 'last': + contents = [getPartialContent(size - rangeRequest.last, offsetSize)] + break + case 'range': + contents = [ + getPartialContent( + rangeRequest.start, + Math.min(rangeRequest.end ?? offsetSize, offsetSize) + ), + ] + break + case 'ranges': + contents = rangeRequest.ranges.map((range) => + getPartialContent(range.start, Math.min(range.end, offsetSize)) + ) + break + } + close() + + const contentLength = contents.reduce((acc, { start, end }) => acc + (end - start + 1), 0) + c.header('Content-Length', String(contentLength)) + c.header('Accept-Ranges', 'bytes') + + const mimeType = getMimeType(path, options.mimes ?? mimes) ?? 'application/octet-stream' + let responseBody: Data + + if (contents.length === 1) { + c.header('Content-Type', mimeType) + const part = contents[0] + const contentRange = formatRangeSize(part.start, part.end, size) + c.header('Content-Range', contentRange) + responseBody = part.data + } else { + c.header('Content-Type', `multipart/byteranges; boundary=${PARTIAL_CONTENT_BOUNDARY}`) + responseBody = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + while (contents.length) { + const part = contents.shift()! + const contentRange = formatRangeSize(part.start, part.end, size) + controller.enqueue( + encoder.encode( + `--${PARTIAL_CONTENT_BOUNDARY}\r\nContent-Type: ${mimeType}\r\nContent-Range: ${contentRange}\r\n\r\n` + ) + ) + if (typeof part.data === 'string') { + controller.enqueue(encoder.encode(part.data)) + } else if (part.data instanceof ReadableStream) { + const reader = part.data.getReader() + const readResult = await reader.read() + controller.enqueue(readResult.value) + } else { + controller.enqueue(part.data) + } + controller.enqueue(encoder.encode('\r\n')) + } + controller.enqueue(encoder.encode(`--${PARTIAL_CONTENT_BOUNDARY}--\r\n`)) + controller.close() + }, + }) + } + + await options.onFound?.(path, c) + return c.body(responseBody, 206) + } + } + const getContent = options.getContent const pathResolve = options.pathResolve ?? defaultPathResolve path = pathResolve(path)