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:
parent
9986b47253
commit
8c79f9e5d9
59
src/middleware/partial-content/index.test.ts
Normal file
59
src/middleware/partial-content/index.test.ts
Normal 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')
|
||||
})
|
||||
})
|
117
src/middleware/partial-content/index.ts
Normal file
117
src/middleware/partial-content/index.ts
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user