0
0
mirror of https://github.com/honojs/hono.git synced 2024-11-24 02:07:30 +01:00

feat: introduce Partial Content Middleware

This commit is contained in:
Yusuke Wada 2024-10-14 19:01:05 +09:00
parent 9986b47253
commit 8c79f9e5d9
2 changed files with 176 additions and 0 deletions

View File

@ -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')
})
})

View File

@ -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)
}
}