Skip to content

Commit e130320

Browse files
authored
Merge pull request #1802 from MarcelOlsen/fix/range-header-ignored
Handle Range header for File/Blob responses
2 parents 1546425 + a27cc97 commit e130320

File tree

5 files changed

+178
-33
lines changed

5 files changed

+178
-33
lines changed

src/adapter/bun/handler.ts

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,13 @@ export const mapResponse = (
3838
return Response.json(response, set as any)
3939

4040
case 'ElysiaFile':
41-
return handleFile((response as ElysiaFile).value as File, set)
41+
return handleFile((response as ElysiaFile).value as File, set, request)
4242

4343
case 'File':
44-
return handleFile(response as File, set)
44+
return handleFile(response as File, set, request)
4545

4646
case 'Blob':
47-
return handleFile(response as Blob, set)
47+
return handleFile(response as Blob, set, request)
4848

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

177177
case 'ElysiaFile':
178-
return handleFile((response as ElysiaFile).value as File, set)
178+
return handleFile((response as ElysiaFile).value as File, set, request)
179179

180180
case 'File':
181-
return handleFile(response as File, set)
181+
return handleFile(response as File, set, request)
182182

183183
case 'Blob':
184-
return handleFile(response as File | Blob, set)
184+
return handleFile(response as File | Blob, set, request)
185185

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

291291
case 'ElysiaFile':
292-
return handleFile((response as ElysiaFile).value as File, set)
292+
return handleFile((response as ElysiaFile).value as File, set, request)
293293

294294
case 'File':
295-
return handleFile(response as File, set)
295+
return handleFile(response as File, set, request)
296296

297297
case 'Blob':
298-
return handleFile(response as File | Blob, set)
298+
return handleFile(response as File | Blob, set, request)
299299

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

407407
case 'ElysiaFile':
408-
return handleFile((response as ElysiaFile).value as File)
408+
return handleFile((response as ElysiaFile).value as File, undefined, request)
409409

410410
case 'File':
411-
return handleFile(response as File)
411+
return handleFile(response as File, undefined, request)
412412

413413
case 'Blob':
414-
return handleFile(response as File | Blob)
414+
return handleFile(response as File | Blob, undefined, request)
415415

416416
case 'ElysiaCustomStatusResponse':
417417
return mapResponse(

src/adapter/utils.ts

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,75 @@ import { isBun } from '../universal/utils'
77

88
export const handleFile = (
99
response: File | Blob,
10-
set?: Context['set']
10+
set?: Context['set'],
11+
request?: Request
1112
): Response => {
1213
if (!isBun && response instanceof Promise)
13-
return response.then((res) => handleFile(res, set)) as any
14+
return response.then((res) => handleFile(res, set, request)) as any
1415

1516
const size = response.size
17+
18+
const rangeHeader = request?.headers.get('range')
19+
if (rangeHeader) {
20+
const match = /bytes=(\d*)-(\d*)/.exec(rangeHeader)
21+
if (match) {
22+
if (!match[1] && !match[2])
23+
return new Response(null, {
24+
status: 416,
25+
headers: mergeHeaders(
26+
new Headers({ 'content-range': `bytes */${size}` }),
27+
set?.headers ?? {}
28+
)
29+
})
30+
31+
let start: number
32+
let end: number
33+
34+
if (!match[1] && match[2]) {
35+
const suffix = parseInt(match[2])
36+
start = Math.max(0, size - suffix)
37+
end = size - 1
38+
} else {
39+
start = match[1] ? parseInt(match[1]) : 0
40+
end = match[2]
41+
? Math.min(parseInt(match[2]), size - 1)
42+
: size - 1
43+
}
44+
45+
if (start >= size || start > end) {
46+
return new Response(null, {
47+
status: 416,
48+
headers: mergeHeaders(
49+
new Headers({ 'content-range': `bytes */${size}` }),
50+
set?.headers ?? {}
51+
)
52+
})
53+
}
54+
55+
const contentLength = end - start + 1
56+
const rangeHeaders = new Headers({
57+
'accept-ranges': 'bytes',
58+
'content-range': `bytes ${start}-${end}/${size}`,
59+
'content-length': String(contentLength)
60+
})
61+
62+
// Blob.slice() exists at runtime but is absent from the ESNext lib typings
63+
// (no DOM lib). Cast through unknown to the minimal interface we need.
64+
// Pass response.type as third arg so the sliced blob preserves MIME type.
65+
return new Response(
66+
(
67+
response as unknown as {
68+
slice(start: number, end: number, contentType?: string): Blob
69+
}
70+
).slice(start, end + 1, response.type),
71+
{
72+
status: 206,
73+
headers: mergeHeaders(rangeHeaders, set?.headers ?? {})
74+
}
75+
)
76+
}
77+
}
78+
1679
const immutable =
1780
set &&
1881
(set.status === 206 ||

src/adapter/web-standard/handler.ts

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ const handleElysiaFile = (
1919
file: ElysiaFile,
2020
set: Context['set'] = {
2121
headers: {}
22-
}
22+
},
23+
request?: Request
2324
) => {
2425
const path = file.path
2526
const contentType =
@@ -42,10 +43,10 @@ const handleElysiaFile = (
4243
set.headers['content-length'] = size
4344
}
4445

45-
return handleFile(file.value as any, set)
46+
return handleFile(file.value as any, set, request)
4647
}) as any
4748

48-
return handleFile(file.value as any, set)
49+
return handleFile(file.value as any, set, request)
4950
}
5051

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

7374
case 'ElysiaFile':
74-
return handleElysiaFile(response as ElysiaFile, set)
75+
return handleElysiaFile(response as ElysiaFile, set, request)
7576

7677
case 'File':
77-
return handleFile(response as File, set)
78+
return handleFile(response as File, set, request)
7879

7980
case 'Blob':
80-
return handleFile(response as Blob, set)
81+
return handleFile(response as Blob, set, request)
8182

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

227228
case 'ElysiaFile':
228-
return handleElysiaFile(response as ElysiaFile, set)
229+
return handleElysiaFile(response as ElysiaFile, set, request)
229230

230231
case 'File':
231-
return handleFile(response as File, set)
232+
return handleFile(response as File, set, request)
232233

233234
case 'Blob':
234-
return handleFile(response as File | Blob, set)
235+
return handleFile(response as File | Blob, set, request)
235236

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

358359
case 'ElysiaFile':
359-
return handleElysiaFile(response as ElysiaFile, set)
360+
return handleElysiaFile(response as ElysiaFile, set, request)
360361

361362
case 'File':
362-
return handleFile(response as File, set)
363+
return handleFile(response as File, set, request)
363364

364365
case 'Blob':
365-
return handleFile(response as File | Blob, set)
366+
return handleFile(response as File | Blob, set, request)
366367

367368
case 'ElysiaCustomStatusResponse':
368369
set.status = (response as ElysiaCustomStatusResponse<200>).code
@@ -495,13 +496,13 @@ export const mapCompactResponse = (
495496
})
496497

497498
case 'ElysiaFile':
498-
return handleElysiaFile(response as ElysiaFile)
499+
return handleElysiaFile(response as ElysiaFile, undefined, request)
499500

500501
case 'File':
501-
return handleFile(response as File)
502+
return handleFile(response as File, undefined, request)
502503

503504
case 'Blob':
504-
return handleFile(response as File | Blob)
505+
return handleFile(response as File | Blob, undefined, request)
505506

506507
case 'ElysiaCustomStatusResponse':
507508
return mapResponse(

src/compose.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -894,10 +894,9 @@ export const composeHandler = ({
894894
return `const _res=${response}` + after + `return _res`
895895
}
896896

897-
const mapResponseContext =
898-
maybeStream && adapter.mapResponseContext
899-
? `,${adapter.mapResponseContext}`
900-
: ''
897+
const mapResponseContext = adapter.mapResponseContext
898+
? `,${adapter.mapResponseContext}`
899+
: ''
901900

902901
if (hasTrace || inference.route) fnLiteral += `c.route=\`${path}\`\n`
903902
if (hasTrace || hooks.afterResponse?.length)

test/response/range.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { describe, expect, it } from 'bun:test'
2+
3+
import { Elysia } from '../../src'
4+
import { req } from '../utils'
5+
6+
// Regression test for https://github.com/elysiajs/elysia/issues/1790
7+
// Range header was ignored; always returned bytes 0-N/N instead of the requested slice.
8+
describe('Range header', () => {
9+
const content = '12345'
10+
const app = new Elysia().get('/file', () => new Blob([content]))
11+
12+
it('returns full file without Range header', async () => {
13+
const res = await app.handle(req('/file'))
14+
expect(res.status).toBe(200)
15+
expect(await res.text()).toBe(content)
16+
})
17+
18+
it('handles bytes=start- (open-ended range)', async () => {
19+
const res = await app.handle(
20+
req('/file', { headers: { range: 'bytes=3-' } })
21+
)
22+
expect(res.status).toBe(206)
23+
expect(res.headers.get('content-range')).toBe('bytes 3-4/5')
24+
expect(res.headers.get('content-length')).toBe('2')
25+
expect(await res.text()).toBe('45')
26+
})
27+
28+
it('handles bytes=start-end (bounded range)', async () => {
29+
const res = await app.handle(
30+
req('/file', { headers: { range: 'bytes=1-3' } })
31+
)
32+
expect(res.status).toBe(206)
33+
expect(res.headers.get('content-range')).toBe('bytes 1-3/5')
34+
expect(res.headers.get('content-length')).toBe('3')
35+
expect(await res.text()).toBe('234')
36+
})
37+
38+
it('handles bytes=-suffix (last N bytes)', async () => {
39+
const res = await app.handle(
40+
req('/file', { headers: { range: 'bytes=-2' } })
41+
)
42+
expect(res.status).toBe(206)
43+
expect(res.headers.get('content-range')).toBe('bytes 3-4/5')
44+
expect(res.headers.get('content-length')).toBe('2')
45+
expect(await res.text()).toBe('45')
46+
})
47+
48+
it('clamps end beyond file size to last byte', async () => {
49+
const res = await app.handle(
50+
req('/file', { headers: { range: 'bytes=2-999' } })
51+
)
52+
expect(res.status).toBe(206)
53+
expect(res.headers.get('content-range')).toBe('bytes 2-4/5')
54+
expect(await res.text()).toBe('345')
55+
})
56+
57+
it('returns 416 when start is out of range', async () => {
58+
const res = await app.handle(
59+
req('/file', { headers: { range: 'bytes=99-' } })
60+
)
61+
expect(res.status).toBe(416)
62+
expect(res.headers.get('content-range')).toBe('bytes */5')
63+
})
64+
65+
it('returns 416 for invalid "bytes=-" (both positions empty)', async () => {
66+
const res = await app.handle(
67+
req('/file', { headers: { range: 'bytes=-' } })
68+
)
69+
expect(res.status).toBe(416)
70+
expect(res.headers.get('content-range')).toBe('bytes */5')
71+
})
72+
73+
it('ignores subsequent ranges in multi-range requests, uses first range only', async () => {
74+
// Multi-range (e.g. bytes=0-1,3-4) is not supported; only the first range is applied.
75+
const res = await app.handle(
76+
req('/file', { headers: { range: 'bytes=0-1,3-4' } })
77+
)
78+
expect(res.status).toBe(206)
79+
expect(res.headers.get('content-range')).toBe('bytes 0-1/5')
80+
expect(await res.text()).toBe('12')
81+
})
82+
})

0 commit comments

Comments
 (0)