diff --git a/src/middleware/partial-content/index.test.ts b/src/middleware/partial-content/index.test.ts new file mode 100644 index 000000000..bb73aa1f5 --- /dev/null +++ b/src/middleware/partial-content/index.test.ts @@ -0,0 +1,174 @@ +import { Hono } from '../../hono' +import { partialContent } from './index' + +const app = new Hono() + +app.use(partialContent()) + +const body = 'This is a test mock data for range requests.' + +app.get('/hello.jpg', (c) => { + return c.body(body, { + headers: { + 'Content-Length': body.length.toString(), + 'Content-Type': 'image/jpeg', // fake content type + }, + }) +}) + +describe('Partial Content Middleware', () => { + it('Should return the first 5 bytes of the mock data (bytes=0-4)', async () => { + const res = await app.request('/hello.jpg', { + headers: { + Range: 'bytes=0-4', + }, + }) + + expect(res.headers.get('Content-Type')).toBe('image/jpeg') + expect(res.headers.get('Content-Range')).toBe('bytes 0-4/44') + expect(await res.text()).toBe('This ') + }) + + it('Should return the bytes from 6 to 10 (bytes=6-10)', async () => { + const res = await app.request('/hello.jpg', { + headers: { + Range: 'bytes=6-10', + }, + }) + + expect(res.headers.get('Content-Type')).toBe('image/jpeg') + expect(res.headers.get('Content-Range')).toBe('bytes 6-10/44') + expect(await res.text()).toBe('s a t') + }) + + it('Should return multiple ranges of the mock data (bytes=0-4, 6-10)', async () => { + const res = await app.request('/hello.jpg', { + headers: { + Range: 'bytes=0-4, 6-10', + }, + }) + + expect(res.headers.get('Content-Type')).toBe( + 'multipart/byteranges; boundary=PARTIAL_CONTENT_BOUNDARY' + ) + + const expectedResponse = [ + '--PARTIAL_CONTENT_BOUNDARY', + 'Content-Type: image/jpeg', + 'Content-Range: bytes 0-4/44', + '', + 'This ', + '--PARTIAL_CONTENT_BOUNDARY', + 'Content-Type: image/jpeg', + 'Content-Range: bytes 6-10/44', + '', + 's a t', + '--PARTIAL_CONTENT_BOUNDARY--', + '', + ].join('\r\n') + expect(await res.text()).toBe(expectedResponse) + }) + + it('Should return the last 10 bytes of the mock data (bytes=-10)', async () => { + const res = await app.request('/hello.jpg', { + headers: { + Range: 'bytes=-10', + }, + }) + + expect(res.headers.get('Content-Type')).toBe('image/jpeg') + expect(res.headers.get('Content-Range')).toBe('bytes 34-43/44') + expect(await res.text()).toBe(' requests.') + }) + + it('Should return the remaining bytes starting from byte 10 (bytes=10-)', async () => { + const res = await app.request('/hello.jpg', { + headers: { + Range: 'bytes=10-', + }, + }) + + expect(res.headers.get('Content-Type')).toBe('image/jpeg') + expect(res.headers.get('Content-Range')).toBe('bytes 10-43/44') + expect(await res.text()).toBe('test mock data for range requests.') + }) + + it('Should return 416 Range Not Satisfiable for excessive number of ranges (11 or more)', async () => { + const res = await app.request('/hello.jpg', { + headers: { + Range: 'bytes=0-0,1-1,2-2,3-3,4-4,5-5,6-6,7-7,8-8,9-9,10-10', + }, + }) + + expect(res.status).toBe(416) + expect(res.headers.get('Content-Range')).toBeNull() + expect(res.headers.get('Content-Length')).toBe('44') + }) + + it('Should return 404 if content is not found', async () => { + const app = new Hono() + app.use(partialContent()) + + app.get('/notfound.jpg', (c) => { + return c.notFound() + }) + + const res = await app.request('/notfound.jpg', { + headers: { + Range: 'bytes=0-10', + }, + }) + + expect(res.status).toBe(404) + }) + + it('Should return full content if Range header is not provided', async () => { + const res = await app.request('/hello.jpg') + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/jpeg') + expect(await res.text()).toBe(body) // Full body should be returned + }) + + it('Should return full content if Content-Length is missing', async () => { + const appWithoutContentLength = new Hono() + appWithoutContentLength.use(partialContent()) + + appWithoutContentLength.get('/hello.jpg', (c) => { + return c.body(body, { + headers: { + 'Content-Type': 'image/jpeg', + }, + }) + }) + + const res = await appWithoutContentLength.request('/hello.jpg', { + headers: { + Range: 'bytes=0-4', + }, + }) + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/jpeg') + expect(await res.text()).toBe(body) + }) + + it('Should return 204 No Content if body is missing', async () => { + const appWithoutBody = new Hono() + appWithoutBody.use(partialContent()) + + appWithoutBody.get('/empty.jpg', (c) => { + return c.body(null, 204) + }) + + const res = await appWithoutBody.request('/empty.jpg', { + headers: { + Range: 'bytes=0-4', + }, + }) + + expect(res.status).toBe(204) + expect(res.headers.get('Content-Type')).toBeNull() + expect(await res.text()).toBe('') + }) +}) diff --git a/src/middleware/partial-content/index.ts b/src/middleware/partial-content/index.ts new file mode 100644 index 000000000..294564ce4 --- /dev/null +++ b/src/middleware/partial-content/index.ts @@ -0,0 +1,131 @@ +import type { MiddlewareHandler } from '../../types' + +type Data = string | ArrayBuffer | ReadableStream + +const PARTIAL_CONTENT_BOUNDARY = 'PARTIAL_CONTENT_BOUNDARY' + +export type PartialContent = { start: number; end: number; data: Data } + +const formatRangeSize = (start: number, end: number, size: number | undefined): string => { + return `bytes ${start}-${end}/${size ?? '*'}` +} + +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 } + } + } + + return undefined +} + +export const partialContent = (): MiddlewareHandler => + async function partialContent(c, next) { + await next() + + const rangeRequest = decodeRangeRequestHeader(c.req.header('Range')) + const contentLength = c.res.headers.get('Content-Length') + + if (rangeRequest && contentLength && c.res.body) { + const totalSize = Number(contentLength) + const offsetSize = totalSize - 1 + const bodyStream = c.res.body.getReader() + + const contents = + rangeRequest.type === 'ranges' + ? rangeRequest.ranges + : [ + { + start: + rangeRequest.type === 'last' ? totalSize - rangeRequest.last : rangeRequest.start, + end: + rangeRequest.type === 'range' + ? Math.min(rangeRequest.end ?? offsetSize, offsetSize) + : offsetSize, + }, + ] + + if (contents.length > 10) { + c.header('Content-Length', totalSize.toString()) + c.res = c.body(null, 416) + return + } + + const contentType = c.res.headers.get('Content-Type') + + if (contents.length === 1) { + const part = contents[0] + const contentRange = formatRangeSize(part.start, part.end, totalSize) + c.header('Content-Range', contentRange) + } else { + c.header('Content-Type', `multipart/byteranges; boundary=${PARTIAL_CONTENT_BOUNDARY}`) + } + + const responseBody = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder() + const { done, value } = await bodyStream.read() + + if (done || !value) { + controller.close() + return + } + + for (const part of contents) { + const contentRange = formatRangeSize(part.start, part.end, totalSize) + const sliceStart = part.start + const sliceEnd = part.end + 1 + const chunk = value.subarray(sliceStart, sliceEnd) + + if (contents.length === 1) { + controller.enqueue(chunk) + } else { + controller.enqueue( + encoder.encode( + `--${PARTIAL_CONTENT_BOUNDARY}\r\nContent-Type: ${contentType}\r\nContent-Range: ${contentRange}\r\n\r\n` + ) + ) + controller.enqueue(chunk) + controller.enqueue(encoder.encode('\r\n')) + } + } + + if (contents.length !== 1) { + controller.enqueue(encoder.encode(`--${PARTIAL_CONTENT_BOUNDARY}--\r\n`)) + } + + controller.close() + }, + }) + + c.res = c.body(responseBody, 206) + } + }