diff --git a/src/adapter/bun/handler.ts b/src/adapter/bun/handler.ts index a61ec695..d1f56579 100644 --- a/src/adapter/bun/handler.ts +++ b/src/adapter/bun/handler.ts @@ -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 @@ -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 @@ -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 @@ -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( diff --git a/src/adapter/utils.ts b/src/adapter/utils.ts index 007f0578..19783fe7 100644 --- a/src/adapter/utils.ts +++ b/src/adapter/utils.ts @@ -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 || diff --git a/src/adapter/web-standard/handler.ts b/src/adapter/web-standard/handler.ts index 9dc90ed3..2e057cb1 100644 --- a/src/adapter/web-standard/handler.ts +++ b/src/adapter/web-standard/handler.ts @@ -19,7 +19,8 @@ const handleElysiaFile = ( file: ElysiaFile, set: Context['set'] = { headers: {} - } + }, + request?: Request ) => { const path = file.path const contentType = @@ -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 = ( @@ -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 @@ -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 @@ -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 @@ -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( diff --git a/src/compose.ts b/src/compose.ts index bf393768..440da3a9 100644 --- a/src/compose.ts +++ b/src/compose.ts @@ -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) diff --git a/test/response/range.test.ts b/test/response/range.test.ts new file mode 100644 index 00000000..4a7b63dc --- /dev/null +++ b/test/response/range.test.ts @@ -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') + }) +})