Skip to content
Merged
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
24 changes: 12 additions & 12 deletions src/adapter/bun/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,13 +38,13 @@ export const mapResponse = (
return Response.json(response, set as any)

case 'ElysiaFile':
return handleFile((response as ElysiaFile).value as File, set)
return handleFile((response as ElysiaFile).value as File, set, request)

case 'File':
return handleFile(response as File, set)
return handleFile(response as File, set, request)

case 'Blob':
return handleFile(response as Blob, set)
return handleFile(response as Blob, set, request)

case 'ElysiaCustomStatusResponse':
set.status = (response as ElysiaCustomStatusResponse<200>).code
Expand Down Expand Up @@ -175,13 +175,13 @@ export const mapEarlyResponse = (
return Response.json(response, set as any)

case 'ElysiaFile':
return handleFile((response as ElysiaFile).value as File, set)
return handleFile((response as ElysiaFile).value as File, set, request)

case 'File':
return handleFile(response as File, set)
return handleFile(response as File, set, request)

case 'Blob':
return handleFile(response as File | Blob, set)
return handleFile(response as File | Blob, set, request)

case 'ElysiaCustomStatusResponse':
set.status = (response as ElysiaCustomStatusResponse<200>).code
Expand Down Expand Up @@ -289,13 +289,13 @@ export const mapEarlyResponse = (
return Response.json(response, set as any)

case 'ElysiaFile':
return handleFile((response as ElysiaFile).value as File, set)
return handleFile((response as ElysiaFile).value as File, set, request)

case 'File':
return handleFile(response as File, set)
return handleFile(response as File, set, request)

case 'Blob':
return handleFile(response as File | Blob, set)
return handleFile(response as File | Blob, set, request)

case 'ElysiaCustomStatusResponse':
set.status = (response as ElysiaCustomStatusResponse<200>).code
Expand Down Expand Up @@ -405,13 +405,13 @@ export const mapCompactResponse = (
return Response.json(response)

case 'ElysiaFile':
return handleFile((response as ElysiaFile).value as File)
return handleFile((response as ElysiaFile).value as File, undefined, request)

case 'File':
return handleFile(response as File)
return handleFile(response as File, undefined, request)

case 'Blob':
return handleFile(response as File | Blob)
return handleFile(response as File | Blob, undefined, request)

case 'ElysiaCustomStatusResponse':
return mapResponse(
Expand Down
67 changes: 65 additions & 2 deletions src/adapter/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,75 @@ import { isBun } from '../universal/utils'

export const handleFile = (
response: File | Blob,
set?: Context['set']
set?: Context['set'],
request?: Request
): Response => {
if (!isBun && response instanceof Promise)
return response.then((res) => handleFile(res, set)) as any
return response.then((res) => handleFile(res, set, request)) as any

const size = response.size

const rangeHeader = request?.headers.get('range')
if (rangeHeader) {
const match = /bytes=(\d*)-(\d*)/.exec(rangeHeader)
if (match) {
if (!match[1] && !match[2])
return new Response(null, {
status: 416,
headers: mergeHeaders(
new Headers({ 'content-range': `bytes */${size}` }),
set?.headers ?? {}
)
})

let start: number
let end: number

if (!match[1] && match[2]) {
const suffix = parseInt(match[2])
start = Math.max(0, size - suffix)
end = size - 1
} else {
start = match[1] ? parseInt(match[1]) : 0
end = match[2]
? Math.min(parseInt(match[2]), size - 1)
: size - 1
}

if (start >= size || start > end) {
return new Response(null, {
status: 416,
headers: mergeHeaders(
new Headers({ 'content-range': `bytes */${size}` }),
set?.headers ?? {}
)
})
}

const contentLength = end - start + 1
const rangeHeaders = new Headers({
'accept-ranges': 'bytes',
'content-range': `bytes ${start}-${end}/${size}`,
'content-length': String(contentLength)
})

// Blob.slice() exists at runtime but is absent from the ESNext lib typings
// (no DOM lib). Cast through unknown to the minimal interface we need.
// Pass response.type as third arg so the sliced blob preserves MIME type.
return new Response(
(
response as unknown as {
slice(start: number, end: number, contentType?: string): Blob
}
).slice(start, end + 1, response.type),
{
status: 206,
headers: mergeHeaders(rangeHeaders, set?.headers ?? {})
}
)
}
}

const immutable =
set &&
(set.status === 206 ||
Expand Down
31 changes: 16 additions & 15 deletions src/adapter/web-standard/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ const handleElysiaFile = (
file: ElysiaFile,
set: Context['set'] = {
headers: {}
}
},
request?: Request
) => {
const path = file.path
const contentType =
Expand All @@ -42,10 +43,10 @@ const handleElysiaFile = (
set.headers['content-length'] = size
}

return handleFile(file.value as any, set)
return handleFile(file.value as any, set, request)
}) as any

return handleFile(file.value as any, set)
return handleFile(file.value as any, set, request)
}

export const mapResponse = (
Expand All @@ -71,13 +72,13 @@ export const mapResponse = (
return new Response(JSON.stringify(response), set as any)

case 'ElysiaFile':
return handleElysiaFile(response as ElysiaFile, set)
return handleElysiaFile(response as ElysiaFile, set, request)

case 'File':
return handleFile(response as File, set)
return handleFile(response as File, set, request)

case 'Blob':
return handleFile(response as Blob, set)
return handleFile(response as Blob, set, request)

case 'ElysiaCustomStatusResponse':
set.status = (response as ElysiaCustomStatusResponse<200>).code
Expand Down Expand Up @@ -225,13 +226,13 @@ export const mapEarlyResponse = (
return new Response(JSON.stringify(response), set as any)

case 'ElysiaFile':
return handleElysiaFile(response as ElysiaFile, set)
return handleElysiaFile(response as ElysiaFile, set, request)

case 'File':
return handleFile(response as File, set)
return handleFile(response as File, set, request)

case 'Blob':
return handleFile(response as File | Blob, set)
return handleFile(response as File | Blob, set, request)

case 'ElysiaCustomStatusResponse':
set.status = (response as ElysiaCustomStatusResponse<200>).code
Expand Down Expand Up @@ -356,13 +357,13 @@ export const mapEarlyResponse = (
return new Response(JSON.stringify(response), set as any)

case 'ElysiaFile':
return handleElysiaFile(response as ElysiaFile, set)
return handleElysiaFile(response as ElysiaFile, set, request)

case 'File':
return handleFile(response as File, set)
return handleFile(response as File, set, request)

case 'Blob':
return handleFile(response as File | Blob, set)
return handleFile(response as File | Blob, set, request)

case 'ElysiaCustomStatusResponse':
set.status = (response as ElysiaCustomStatusResponse<200>).code
Expand Down Expand Up @@ -495,13 +496,13 @@ export const mapCompactResponse = (
})

case 'ElysiaFile':
return handleElysiaFile(response as ElysiaFile)
return handleElysiaFile(response as ElysiaFile, undefined, request)

case 'File':
return handleFile(response as File)
return handleFile(response as File, undefined, request)

case 'Blob':
return handleFile(response as File | Blob)
return handleFile(response as File | Blob, undefined, request)

case 'ElysiaCustomStatusResponse':
return mapResponse(
Expand Down
7 changes: 3 additions & 4 deletions src/compose.ts
Original file line number Diff line number Diff line change
Expand Up @@ -894,10 +894,9 @@ export const composeHandler = ({
return `const _res=${response}` + after + `return _res`
}

const mapResponseContext =
maybeStream && adapter.mapResponseContext
? `,${adapter.mapResponseContext}`
: ''
const mapResponseContext = adapter.mapResponseContext
? `,${adapter.mapResponseContext}`
: ''

if (hasTrace || inference.route) fnLiteral += `c.route=\`${path}\`\n`
if (hasTrace || hooks.afterResponse?.length)
Expand Down
82 changes: 82 additions & 0 deletions test/response/range.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { describe, expect, it } from 'bun:test'

import { Elysia } from '../../src'
import { req } from '../utils'

// Regression test for https://github.com/elysiajs/elysia/issues/1790
// Range header was ignored; always returned bytes 0-N/N instead of the requested slice.
describe('Range header', () => {
const content = '12345'
const app = new Elysia().get('/file', () => new Blob([content]))

it('returns full file without Range header', async () => {
const res = await app.handle(req('/file'))
expect(res.status).toBe(200)
expect(await res.text()).toBe(content)
})

it('handles bytes=start- (open-ended range)', async () => {
const res = await app.handle(
req('/file', { headers: { range: 'bytes=3-' } })
)
expect(res.status).toBe(206)
expect(res.headers.get('content-range')).toBe('bytes 3-4/5')
expect(res.headers.get('content-length')).toBe('2')
expect(await res.text()).toBe('45')
})

it('handles bytes=start-end (bounded range)', async () => {
const res = await app.handle(
req('/file', { headers: { range: 'bytes=1-3' } })
)
expect(res.status).toBe(206)
expect(res.headers.get('content-range')).toBe('bytes 1-3/5')
expect(res.headers.get('content-length')).toBe('3')
expect(await res.text()).toBe('234')
})

it('handles bytes=-suffix (last N bytes)', async () => {
const res = await app.handle(
req('/file', { headers: { range: 'bytes=-2' } })
)
expect(res.status).toBe(206)
expect(res.headers.get('content-range')).toBe('bytes 3-4/5')
expect(res.headers.get('content-length')).toBe('2')
expect(await res.text()).toBe('45')
})

it('clamps end beyond file size to last byte', async () => {
const res = await app.handle(
req('/file', { headers: { range: 'bytes=2-999' } })
)
expect(res.status).toBe(206)
expect(res.headers.get('content-range')).toBe('bytes 2-4/5')
expect(await res.text()).toBe('345')
})

it('returns 416 when start is out of range', async () => {
const res = await app.handle(
req('/file', { headers: { range: 'bytes=99-' } })
)
expect(res.status).toBe(416)
expect(res.headers.get('content-range')).toBe('bytes */5')
})

it('returns 416 for invalid "bytes=-" (both positions empty)', async () => {
const res = await app.handle(
req('/file', { headers: { range: 'bytes=-' } })
)
expect(res.status).toBe(416)
expect(res.headers.get('content-range')).toBe('bytes */5')
})

it('ignores subsequent ranges in multi-range requests, uses first range only', async () => {
// Multi-range (e.g. bytes=0-1,3-4) is not supported; only the first range is applied.
const res = await app.handle(
req('/file', { headers: { range: 'bytes=0-1,3-4' } })
)
expect(res.status).toBe(206)
expect(res.headers.get('content-range')).toBe('bytes 0-1/5')
expect(await res.text()).toBe('12')
})
})
Loading