From 8c79f9e5d9e7d20b95b4216d26e3d2f5100a6ea7 Mon Sep 17 00:00:00 2001 From: Yusuke Wada Date: Mon, 14 Oct 2024 19:01:05 +0900 Subject: [PATCH] feat: introduce Partial Content Middleware --- src/middleware/partial-content/index.test.ts | 59 ++++++++++ src/middleware/partial-content/index.ts | 117 +++++++++++++++++++ 2 files changed, 176 insertions(+) create mode 100644 src/middleware/partial-content/index.test.ts create mode 100644 src/middleware/partial-content/index.ts diff --git a/src/middleware/partial-content/index.test.ts b/src/middleware/partial-content/index.test.ts new file mode 100644 index 00000000..41e2e28c --- /dev/null +++ b/src/middleware/partial-content/index.test.ts @@ -0,0 +1,59 @@ +import { Hono } from '../../hono' +import { partialContent } from './index' + +const app = new Hono() + +app.use(partialContent()) + +const body = 'Hello World with Partial Content Middleware!' + +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 206 response with correct contents', 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') + ) + }) + + it('Should not return 206 response with invalid Range header', async () => { + const res = await app.request('/hello.jpg', { + headers: { + Range: 'bytes=INVALID', + }, + }) + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/jpeg') + }) +}) diff --git a/src/middleware/partial-content/index.ts b/src/middleware/partial-content/index.ts new file mode 100644 index 00000000..633b8dd2 --- /dev/null +++ b/src/middleware/partial-content/index.ts @@ -0,0 +1,117 @@ +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 = parseInt(contentLength, 10) + 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', undefined) + c.header('Content-Range', `bytes bytes */${totalSize}`) + c.res = c.body(null, 416) + } + + const contentType = c.res.headers.get('Content-Type') + + 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) + 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')) + } + + 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) + } + }