diff --git a/src/index.ts b/src/index.ts index 2ec99c2..8300eaa 100644 --- a/src/index.ts +++ b/src/index.ts @@ -52,6 +52,26 @@ const listFiles = async (dir: string): Promise => { return all.flat() } +const createRangeResponse = async ( + file: Blob, + range: string, + headers: Record +) => { + // from https://bun.sh/docs/api/http#streaming-files + // parse `Range` header + const [start = 0, end = Infinity] = range + .split('=') // ["Range: bytes", "0-100"] + .at(-1)! // "0-100" + .split('-') // ["0", "100"] + .map(Number) // [0, 100] + + headers['Content-Range'] = `bytes ${start}-${end - 1}/${file.size}` + return new Response(file.slice(start, end), { + headers, + status: 206 + }) +} + export const staticPlugin = async ( { assets = 'public', @@ -236,12 +256,22 @@ export const staticPlugin = async ( ? prefix + fileName.split(sep).join(URL_PATH_SEP) : join(prefix, fileName) + let responseSingleton: Response + app.get( pathName, noCache - ? new Response(file, { - headers - }) + ? ({ headers: reqHeaders }) => { + return !reqHeaders.range + ? (responseSingleton ??= new Response(file, { + headers + })) + : createRangeResponse( + file, + reqHeaders['range'], + headers + ) + } : async ({ headers: reqHeaders }) => { if (await isCached(reqHeaders, etag, filePath)) { return new Response(null, { @@ -255,9 +285,15 @@ export const staticPlugin = async ( if (maxAge !== null) headers['Cache-Control'] += `, max-age=${maxAge}` - return new Response(file, { - headers - }) + return !reqHeaders.range + ? new Response(file, { + headers + }) + : createRangeResponse( + file, + reqHeaders['range'], + headers + ) } ) @@ -279,9 +315,8 @@ export const staticPlugin = async ( headers['Etag'] = etag headers['Cache-Control'] = directive if (maxAge !== null) - headers[ - 'Cache-Control' - ] += `, max-age=${maxAge}` + headers['Cache-Control'] += + `, max-age=${maxAge}` return new Response(file, { headers @@ -358,9 +393,15 @@ export const staticPlugin = async ( } if (noCache) - return new Response(file, { - headers - }) + return !reqHeaders.range + ? new Response(file, { + headers + }) + : createRangeResponse( + file, + reqHeaders['range'], + headers + ) const etag = await generateETag(file) if (await isCached(reqHeaders, etag, path)) @@ -374,9 +415,15 @@ export const staticPlugin = async ( if (maxAge !== null) headers['Cache-Control'] += `, max-age=${maxAge}` - return new Response(file, { - headers - }) + return !reqHeaders.range + ? new Response(file, { + headers + }) + : createRangeResponse( + file, + reqHeaders['range'], + headers + ) } catch (error) { throw new NotFoundError() } diff --git a/test/index.test.ts b/test/index.test.ts index 388d44b..92bf533 100644 --- a/test/index.test.ts +++ b/test/index.test.ts @@ -437,4 +437,24 @@ describe('Static Plugin', () => { res = await app.handle(req('/public/html')) expect(res.status).toBe(404) }) + + it.each([ + [{}], + [{ alwaysStatic: true }], + [{ noCache: true }], + [{ alwaysStatic: true, noCache: true }] + ])('should work with range requests', async (options) => { + const app = new Elysia().use(staticPlugin(options)) + await app.modules + + const request = req('/public/takodachi.png') + request.headers.append('Range', 'bytes=100-200') + + const res = await app.handle(request) + expect(res.status).toBe(206) + expect(res.headers.get('Content-Range')).toBe('bytes 100-199/71118') + + const body = await res.blob() + expect(body.size).toBe(100) + }) })