diff --git a/src/middleware/partial-content/index.test.ts b/src/middleware/partial-content/index.test.ts index 41e2e28c..bb73aa1f 100644 --- a/src/middleware/partial-content/index.test.ts +++ b/src/middleware/partial-content/index.test.ts @@ -5,7 +5,7 @@ const app = new Hono() app.use(partialContent()) -const body = 'Hello World with Partial Content Middleware!' +const body = 'This is a test mock data for range requests.' app.get('/hello.jpg', (c) => { return c.body(body, { @@ -17,43 +17,158 @@ app.get('/hello.jpg', (c) => { }) describe('Partial Content Middleware', () => { - it('Should return 206 response with correct contents', async () => { + 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.status).toBe(206) - expect(res.headers.get('Content-Type')).toMatch( - /^multipart\/byteranges; boundary=PARTIAL_CONTENT_BOUNDARY$/ - ) - expect(res.headers.get('Content-Range')).toBeNull() - expect(res.headers.get('Content-Length')).toBe('44') - expect(await res.text()).toBe( - [ - '--PARTIAL_CONTENT_BOUNDARY', - 'Content-Type: image/jpeg', - 'Content-Range: bytes 0-4/44', - '', - 'Hello', - '--PARTIAL_CONTENT_BOUNDARY', - 'Content-Type: image/jpeg', - 'Content-Range: bytes 6-10/44', - '', - 'World', - '--PARTIAL_CONTENT_BOUNDARY--', - '', - ].join('\r\n') + + 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 not return 206 response with invalid Range header', async () => { + it('Should return the last 10 bytes of the mock data (bytes=-10)', async () => { const res = await app.request('/hello.jpg', { headers: { - Range: 'bytes=INVALID', + 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 index 633b8dd2..294564ce 100644 --- a/src/middleware/partial-content/index.ts +++ b/src/middleware/partial-content/index.ts @@ -55,7 +55,7 @@ export const partialContent = (): MiddlewareHandler => const contentLength = c.res.headers.get('Content-Length') if (rangeRequest && contentLength && c.res.body) { - const totalSize = parseInt(contentLength, 10) + const totalSize = Number(contentLength) const offsetSize = totalSize - 1 const bodyStream = c.res.body.getReader() @@ -74,17 +74,26 @@ export const partialContent = (): MiddlewareHandler => ] if (contents.length > 10) { - c.header('Content-Length', undefined) - c.header('Content-Range', `bytes bytes */${totalSize}`) + 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 @@ -92,26 +101,31 @@ export const partialContent = (): MiddlewareHandler => for (const part of contents) { const contentRange = formatRangeSize(part.start, part.end, totalSize) - controller.enqueue( - encoder.encode( - `--${PARTIAL_CONTENT_BOUNDARY}\r\nContent-Type: ${contentType}\r\nContent-Range: ${contentRange}\r\n\r\n` - ) - ) - const sliceStart = part.start const sliceEnd = part.end + 1 - const chunk = value.subarray(sliceStart, sliceEnd) - controller.enqueue(chunk) - controller.enqueue(encoder.encode('\r\n')) + + 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.enqueue(encoder.encode(`--${PARTIAL_CONTENT_BOUNDARY}--\r\n`)) controller.close() }, }) - c.header('Content-Type', `multipart/byteranges; boundary=${PARTIAL_CONTENT_BOUNDARY}`) c.res = c.body(responseBody, 206) } }