UNPKG

bunshine

Version:

A Bun HTTP & WebSocket server that is a little ray of sunshine.

437 lines (423 loc) 17.6 kB
import type { Server } from 'bun'; import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import HttpRouter from '../../HttpRouter/HttpRouter'; describe('c.file()', () => { let app: HttpRouter; let server: Server; beforeEach(() => { app = new HttpRouter(); server = app.listen({ port: 0 }); }); afterEach(() => { server.stop(true); }); it('should handle paths with disposition="attachment"', async () => { app.get('/home.html', c => c.file(`${import.meta.dir}/../../../testFixtures/home.html`, { disposition: 'attachment', }) ); const resp = await fetch(`${server.url}home.html`); expect(resp.status).toBe(200); expect(resp.headers.get('Content-Disposition')).toBe( 'attachment; filename="home.html"' ); const file = await resp.blob(); const text = await file.text(); expect(text).toBe('<h1>Welcome home</h1>\n'); }); it('should allow multiple headers with same name', async () => { app.get('/home.html', c => { return c.file(`${import.meta.dir}/../../../testFixtures/home.html`, { headers: new Headers([ ['Hello', 'bun'], ['Hello', 'world'], ]), }); }); const resp = await fetch(`${server.url}home.html`); expect(resp.status).toBe(200); expect(resp.headers.get('Hello')).toBe('bun, world'); }); it('should handle BunFile with disposition="attachment"', async () => { app.get('/home.html', c => { const file = Bun.file( `${import.meta.dir}/../../../testFixtures/home.html` ); return c.file(file, { disposition: 'attachment' }); }); const resp = await fetch(`${server.url}home.html`); expect(resp.status).toBe(200); expect(resp.headers.get('Content-Disposition')).toBe( 'attachment; filename="home.html"' ); const file = await resp.blob(); const text = await file.text(); expect(text).toBe('<h1>Welcome home</h1>\n'); }); it('should allow headGet', async () => { app.headGet('/', c => { if (c.request.method === 'GET') { return c.file(`${import.meta.dirname}/../../../testFixtures/home.html`); } else { return new Response('', { headers: { 'Content-type': 'text/html', 'Content-length': '22', }, }); } }); const getResp = await app.fetch(new Request('http://localhost/'), server); expect(getResp.status).toBe(200); expect(getResp.headers.get('content-type')).toInclude('text/html'); expect(await getResp.text()).toInclude('Welcome home'); const headResp = await app.fetch( new Request('http://localhost/', { method: 'HEAD' }), server ); expect(headResp.status).toBe(200); expect(await headResp.text()).toBe(''); }); it('should return correct statuses, headers, and bytes for range requests', async () => { app.headGet('/bun-logo.jpg', c => { return c.file( `${import.meta.dirname}/../../../testFixtures/bun-logo.jpg` ); }); const url = `${server.url}bun-logo.jpg?foo=bar`; // Step 1: Fetch entire file const fullResponse = await fetch(url); const fullFileBytes = await fullResponse.blob(); const fileSize = Number(fullResponse.headers.get('content-length')); expect(fullResponse.status).toBe(200); expect(fullFileBytes.size).toBe(fileSize); expect(fullResponse.headers.get('accept-ranges')).toBe('bytes'); expect(fullResponse.headers.get('content-type')).toBe('image/jpeg'); // Step 2: Fetch HEAD and validate const headResponse = await fetch(url, { method: 'HEAD' }); expect(headResponse.status).toBe(200); expect(headResponse.headers.get('accept-ranges')).toBe('bytes'); expect(headResponse.headers.get('content-type')).toBe('image/jpeg'); expect(headResponse.headers.get('content-length')).toBe( // Before Bun 1.1.43 Bun always sets Content-Length to 0 for HEAD responses // https://github.com/oven-sh/bun/issues/15355 String(fullFileBytes.size) ); // So for old versions, we set an X-Content-Length header to the actual file size expect(headResponse.headers.get('x-content-length')).toBe( String(fullFileBytes.size) ); // Step 3: Fetch range "bytes=0-" and validate const rangeResponse1 = await fetch(url, { headers: { Range: 'bytes=0-' }, }); const range1Bytes = await rangeResponse1.blob(); expect(rangeResponse1.status).toBe(200); expect(rangeResponse1.headers.get('accept-ranges')).toBe('bytes'); expect(rangeResponse1.headers.get('content-type')).toBe('image/jpeg'); expect(range1Bytes.size).toBe(fileSize); expect(rangeResponse1.headers.get('content-length')).toBe(String(fileSize)); expect(range1Bytes).toEqual(fullFileBytes); // Step 4: Fetch range "bytes=0-999" and validate const rangeResponse2 = await fetch(url, { headers: { Range: 'bytes=0-999' }, }); const range2Bytes = await rangeResponse2.blob(); expect(rangeResponse2.status).toBe(206); expect(rangeResponse2.headers.get('accept-ranges')).toBe('bytes'); expect(rangeResponse2.headers.get('content-length')).toBe('1000'); expect(range2Bytes.size).toBe(1000); expect(rangeResponse2.headers.get('content-range')).toBe( `bytes 0-999/${fileSize}` ); expect(range2Bytes).toEqual(fullFileBytes.slice(0, 1000)); expect(rangeResponse2.headers.get('content-type')).toBe('image/jpeg'); // Step 5: Fetch range "bytes=1000-1999" and validate const rangeResponse3 = await fetch(url, { headers: { Range: 'bytes=1000-1999' }, }); const range3Bytes = await rangeResponse3.blob(); expect(rangeResponse3.status).toBe(206); expect(rangeResponse3.headers.get('accept-ranges')).toBe('bytes'); expect(rangeResponse3.headers.get('content-length')).toBe('1000'); expect(range3Bytes.size).toBe(1000); expect(rangeResponse3.headers.get('content-range')).toBe( `bytes 1000-1999/${fileSize}` ); expect(range3Bytes).toEqual(fullFileBytes.slice(1000, 2000)); expect(rangeResponse3.headers.get('content-type')).toBe('image/jpeg'); // Step 5: Fetch range "bytes=-1000" and validate const rangeResponse4 = await fetch(url, { headers: { Range: 'bytes=-1000' }, }); const range4Bytes = await rangeResponse4.blob(); expect(rangeResponse4.status).toBe(206); expect(rangeResponse4.headers.get('accept-ranges')).toBe('bytes'); expect(rangeResponse4.headers.get('content-length')).toBe('1000'); expect(range4Bytes.size).toBe(1000); expect(rangeResponse4.headers.get('content-range')).toBe( `bytes ${fileSize - 1000}-${fileSize - 1}/${fileSize}` ); expect(range4Bytes).toEqual(fullFileBytes.slice(-1000)); expect(rangeResponse4.headers.get('content-type')).toBe('image/jpeg'); // Step 7: Request invalid range const rangeResponse5 = await fetch(url, { headers: { Range: 'bytes=9999999-' }, }); const content = await rangeResponse5.text(); expect(rangeResponse5.status).toBe(416); expect(rangeResponse5.statusText).toBe('Range Not Satisfiable'); expect(rangeResponse5.headers.get('content-range')).toBe( `bytes */${fileSize}` ); expect(content).toContain('Requested range is not satisfiable'); expect(content).toContain(`${fileSize}`); }); // spot test some files from the file-type package test fixtures const fileTypes = { 'fixture.jpg.data': 'image/jpeg', 'fixture.mov.data': 'video/quicktime', 'fixture.ogg.data': 'audio/ogg', 'fixture.pdf.data': 'application/pdf', 'fixture.png.data': 'image/png', 'fixture-office365.pptx.data': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', 'fixture-office365.docx.data': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'fixture-office365.xlsx.data': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', 'fixture.woff2.data': 'font/woff2', 'fixture-bali.tif': 'image/tiff', 'fixture-ffe3.mp3.data': 'audio/mpeg', 'fixture-mp4v2.mp4.data': 'video/mp4', 'fixture.m4v.data': 'video/x-m4v', 'fixture.ico.data': 'image/x-icon', 'fixture-null.webm.data': 'video/webm', }; for (const [name, mime] of Object.entries(fileTypes)) { it(`should detect mime from bytes (${name})`, async () => { app.get(`/${name}`, c => c.file(`${import.meta.dir}/../../../testFixtures/file-type/${name}`) ); const resp = await fetch(`${server.url}${name}`); expect(resp.status).toBe(200); expect(resp.headers.get('Content-Type')).toInclude(mime); }); it(`should detect mime from starting bytes (${name})`, async () => { app.get(`/${name}`, c => c.file(`${import.meta.dir}/../../../testFixtures/file-type/${name}`) ); const resp = await fetch(`${server.url}${name}`, { headers: { Range: 'bytes=0-5000' }, }); expect(resp.status).toBeOneOf([206, 416, 200]); expect(resp.headers.get('Content-Type')).toInclude(mime); }); it(`should detect mime from middle bytes (${name})`, async () => { app.get(`/${name}`, c => c.file(`${import.meta.dir}/../../../testFixtures/file-type/${name}`) ); const resp = await fetch(`${server.url}${name}`, { headers: { Range: 'bytes=999-1000' }, }); expect(resp.status).toBeOneOf([206, 416, 200]); expect(resp.headers.get('Content-Type')).toInclude(mime); }); it(`should detect mime from partial bytes Blob (${name})`, async () => { app.get(`/${name}`, async c => { const file = Bun.file( `${import.meta.dir}/../../../testFixtures/file-type/${name}` ); const buffer = await file.bytes(); // set type to image/png and expect it to be overridden const blob = new Blob([buffer], { type: 'image/png' }); return c.file(blob); }); const resp = await fetch(`${server.url}${name}`, { headers: { Range: 'bytes=0-9' }, }); expect(resp.status).toBe(206); expect(resp.headers.get('Content-Type')).toInclude(mime); expect(resp.headers.get('Content-Length')).toBe('10'); }); it(`should detect mime from bytes 0-1 request (${name})`, async () => { app.get(`/${name}`, c => c.file(`${import.meta.dir}/../../../testFixtures/file-type/${name}`) ); const resp = await fetch(`${server.url}${name}`, { headers: { Range: 'bytes=0-1' }, }); expect(resp.status).toBe(206); expect(resp.headers.get('Content-Type')).toInclude(mime); }); it(`should detect mime from HEAD request (${name})`, async () => { app.headGet(`/${name}`, c => c.file(`${import.meta.dir}/../../../testFixtures/file-type/${name}`) ); const resp = await fetch(`${server.url}${name}`, { method: 'HEAD' }); expect(resp.status).toBe(200); expect(resp.headers.get('Content-Type')).toInclude(mime); }); } it('should detect mime from ArrayBuffer', async () => { app.get(`/video.mp4`, async c => { const file = Bun.file( `${import.meta.dir}/../../../testFixtures/file-type/fixture-mp4v2.mp4.data` ); const buffer = await file.arrayBuffer(); return c.file(buffer); }); const resp = await fetch(`${server.url}video.mp4`); expect(resp.status).toBe(200); expect(resp.headers.get('Content-Type')).toInclude('video/mp4'); }); it('should detect mime from Bunfile', async () => { app.get(`/my.pdf`, async c => { const file = Bun.file( `${import.meta.dir}/../../../testFixtures/file-type/fixture.pdf.data` ); return c.file(file); }); const resp = await fetch(`${server.url}my.pdf`); expect(resp.status).toBe(200); expect(resp.headers.get('Content-Type')).toInclude('application/pdf'); }); it('should detect mime from Uint8Array', async () => { app.get(`/my.jpg`, async c => { const file = Bun.file( `${import.meta.dir}/../../../testFixtures/file-type/fixture.jpg.data` ); const buffer = await file.bytes(); return c.file(buffer); }); const resp = await fetch(`${server.url}my.jpg`); expect(resp.status).toBe(200); expect(resp.headers.get('Content-Type')).toInclude('image/jpeg'); }); it('should detect mime from plain Blob - GET', async () => { app.get(`/my.png`, async c => { const file = Bun.file( `${import.meta.dir}/../../../testFixtures/file-type/fixture.png.data` ); const buffer = await file.bytes(); const blob = new Blob([buffer], { type: 'image/png' }); return c.file(blob); }); const resp = await fetch(`${server.url}my.png`); expect(resp.status).toBe(200); expect(resp.headers.get('Content-Type')).toInclude('image/png'); }); it('should detect mime from plain Blob - HEAD', async () => { app.headGet(`/favicon.ico`, async c => { const file = Bun.file( `${import.meta.dir}/../../../testFixtures/file-type/fixture.ico.data` ); const buffer = await file.bytes(); const blob = new Blob([buffer], { type: 'image/x-icon' }); return c.file(blob); }); const resp = await fetch(`${server.url}favicon.ico`, { method: 'HEAD' }); expect(resp.status).toBe(200); expect(resp.headers.get('Content-Type')).toInclude('image/x-icon'); }); it('should return 404 on bad input', async () => { app.headGet(`/video.mov`, async c => { // @ts-expect-error return c.file(['foo']); }); const resp = await fetch(`${server.url}video.mov`); expect(resp.status).toBe(404); expect(await resp.text()).toContain('File not found'); }); it('should allow disposition of attachment for Uint8Array', async () => { app.get(`/font.woff2`, async c => { const file = Bun.file( `${import.meta.dir}/../../../testFixtures/file-type/fixture.woff2.data` ); const buffer = await file.bytes(); return c.file(buffer, { disposition: 'attachment' }); }); const resp = await fetch(`${server.url}font.woff2`); expect(resp.status).toBe(200); expect(resp.headers.get('Content-Type')).toInclude('font/woff2'); expect(resp.headers.get('Content-Disposition')).toBe('attachment'); }); it('should allow disposition of attachment for Bunfile', async () => { app.get(`/video.webm`, async c => { const file = Bun.file( `${import.meta.dir}/../../../testFixtures/file-type/fixture-null.webm.data` ); return c.file(file, { disposition: 'attachment' }); }); const resp = await fetch(`${server.url}video.webm`); expect(resp.status).toBe(200); expect(resp.headers.get('Content-Type')).toInclude('video/webm'); expect(resp.headers.get('Content-Disposition')).toBe( 'attachment; filename="fixture-null.webm.data"' ); }); it('should allow disposition of form-data for Uint8Array', async () => { app.get(`/my.tiff`, async c => { const file = Bun.file( `${import.meta.dir}/../../../testFixtures/file-type/fixture-bali.tif` ); const buffer = await file.bytes(); return c.file(buffer, { disposition: 'form-data' }); }); const resp = await fetch(`${server.url}my.tiff`); expect(resp.status).toBe(200); expect(resp.headers.get('Content-Type')).toInclude('image/tiff'); expect(resp.headers.get('Content-Disposition')).toBe('form-data'); }); it('should allow overriding content-type', async () => { app.get(`/podcast.ogg`, async c => { const file = Bun.file( `${import.meta.dir}/../../../testFixtures/file-type/fixture.ogg.data` ); const buffer = await file.bytes(); return c.file(buffer, { headers: { 'Content-Type': 'audio/ogg' } }); }); const resp = await fetch(`${server.url}podcast.ogg`); expect(resp.status).toBe(200); expect(resp.headers.get('Content-Type')).toInclude('audio/ogg'); }); it('should support if-modified-since past', async () => { app.get(`/podcast.ogg`, async c => { const path = `${import.meta.dir}/../../../testFixtures/file-type/fixture.ogg.data`; return c.file(path); }); const resp = await fetch(`${server.url}podcast.ogg`, { headers: { 'If-Modified-Since': 'Thu, 01 Jan 1970 00:00:00 GMT' }, }); expect(resp.status).toBe(200); expect(resp.headers.get('Content-Type')).toInclude('audio/ogg'); expect(parseInt(resp.headers.get('Content-Length') || '')).toBeGreaterThan( 0 ); }); it('should support if-modified-since future', async () => { app.get(`/podcast.ogg`, async c => { const path = `${import.meta.dir}/../../../testFixtures/file-type/fixture.ogg.data`; return c.file(path); }); const resp = await fetch(`${server.url}podcast.ogg`, { headers: { 'If-Modified-Since': 'Thu, 01 Jan 3000 00:00:00 GMT' }, }); expect(resp.status).toBe(304); expect(resp.headers.get('Content-Length')).toInclude('0'); }); it('should ignore invalid if-modified-since headers', async () => { app.get(`/podcast.ogg`, async c => { const path = `${import.meta.dir}/../../../testFixtures/file-type/fixture.ogg.data`; return c.file(path); }); const resp = await fetch(`${server.url}podcast.ogg`, { headers: { 'If-Modified-Since': 'oops' }, }); expect(resp.status).toBe(200); expect(parseInt(resp.headers.get('Content-Length') || '')).toBeGreaterThan( 0 ); }); });