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