Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PoC] feat: introduce Partial Content Middleware #3516

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions src/middleware/partial-content/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { Hono } from '../../hono'
import { partialContent } from './index'

const app = new Hono()

app.use(partialContent())

const body = 'This is a test mock data for range requests.'

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 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.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 return the last 10 bytes of the mock data (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 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('')
})
})
131 changes: 131 additions & 0 deletions src/middleware/partial-content/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
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 = Number(contentLength)
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', 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
}

Check warning on line 100 in src/middleware/partial-content/index.ts

View check run for this annotation

Codecov / codecov/patch

src/middleware/partial-content/index.ts#L98-L100

Added lines #L98 - L100 were not covered by tests

for (const part of contents) {
const contentRange = formatRangeSize(part.start, part.end, totalSize)
const sliceStart = part.start
const sliceEnd = part.end + 1
const chunk = value.subarray(sliceStart, sliceEnd)

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.close()
},
})

c.res = c.body(responseBody, 206)
}
}
Loading