Skip to content

Commit ab504d3

Browse files
committed
range 206 fix for safari
1 parent cf0620d commit ab504d3

File tree

9 files changed

+129
-42
lines changed

9 files changed

+129
-42
lines changed

packages/bunshine/CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# Changelog
22

3+
## v3.1.2 - Nov 25, 2024
4+
5+
- Change default range chunk size 3MB => 1MB
6+
- Support passing headers to c.file()
7+
- Return 206 in ranged downloads even if whole file is requested (Safari bug)
8+
39
## v3.1.1 - Nov 23, 2024
410

511
- Fix Content-Range header when file size is 0

packages/bunshine/README.md

Lines changed: 93 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,14 @@
22

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

5-
<img alt="Bunshine Logo" src="https://github.com/kensnyder/bunshine/raw/main/packages/bunshine/assets/bunshine-logo.png?v=3.1.1" width="200" height="187" />
5+
<img alt="Bunshine Logo" src="https://github.com/kensnyder/bunshine/raw/main/packages/bunshine/assets/bunshine-logo.png?v=3.1.2" width="200" height="187" />
66

7-
[![NPM Link](https://img.shields.io/npm/v/bunshine?v=3.1.1)](https://npmjs.com/package/bunshine)
8-
[![Language: TypeScript](https://badgen.net/static/language/TS?v=3.1.1)](https://github.com/search?q=repo:kensnyder/bunshine++language:TypeScript&type=code)
9-
[![Code Coverage](https://codecov.io/gh/kensnyder/bunshine/graph/badge.svg?token=4LLWB8NBNT&v=3.1.1)](https://codecov.io/gh/kensnyder/bunshine)
10-
[![Dependencies: 1](https://badgen.net/static/dependencies/1/green?v=3.1.1)](https://www.npmjs.com/package/bunshine?activeTab=dependencies)
11-
![Tree shakeable](https://badgen.net/static/tree%20shakeable/yes/green?v=3.1.1)
12-
[![ISC License](https://badgen.net/github/license/kensnyder/bunshine?v=3.1.1)](https://opensource.org/licenses/ISC)
7+
[![NPM Link](https://img.shields.io/npm/v/bunshine?v=3.1.2)](https://npmjs.com/package/bunshine)
8+
[![Language: TypeScript](https://badgen.net/static/language/TS?v=3.1.2)](https://github.com/search?q=repo:kensnyder/bunshine++language:TypeScript&type=code)
9+
[![Code Coverage](https://codecov.io/gh/kensnyder/bunshine/graph/badge.svg?token=4LLWB8NBNT&v=3.1.2)](https://codecov.io/gh/kensnyder/bunshine)
10+
[![Dependencies: 1](https://badgen.net/static/dependencies/1/green?v=3.1.2)](https://www.npmjs.com/package/bunshine?activeTab=dependencies)
11+
![Tree shakeable](https://badgen.net/static/tree%20shakeable/yes/green?v=3.1.2)
12+
[![ISC License](https://badgen.net/github/license/kensnyder/bunshine?v=3.1.2)](https://opensource.org/licenses/ISC)
1313

1414
## Installation
1515

@@ -61,7 +61,8 @@ _Or to run Bunshine on Node,
6161
12. [Examples of common http server setup](#examples-of-common-http-server-setup)
6262
13. [Design Decisions](#design-decisions)
6363
14. [Roadmap](#roadmap)
64-
15. [ISC License](./LICENSE.md)
64+
15. [Change Log](./CHANGELOG.md)
65+
16. [ISC License](./LICENSE.md)
6566

6667
## Upgrading from 1.x to 2.x
6768

@@ -238,6 +239,56 @@ See the [serveFiles](#serveFiles) section for more info.
238239
Also note you can serve files with Bunshine anywhere with `bunx bunshine-serve`.
239240
It currently uses the default `serveFiles()` options.
240241

242+
If you want to manually manage serving a file, you can use the following approach.
243+
244+
```ts
245+
import { HttpRouter, serveFiles } from 'bunshine';
246+
247+
const app = new HttpRouter();
248+
249+
app.get('/assets/:name.png', c => {
250+
const name = c.params.name;
251+
const filePath = `${import.meta.dir}/assets/${name}.png`;
252+
// you can pass a string path
253+
return c.file(filePath);
254+
// Bun will set Content-Type based on the string file extension
255+
});
256+
257+
app.get('/build/:hash.map', c => {
258+
const hash = c.params.hash;
259+
const filePath = `${import.meta.dir}/assets/${name}.png`;
260+
// you can pass a BunFile
261+
return c.file(
262+
Bun.file(filePath, {
263+
// Bun will automatically set Content-Type based on the file's extension
264+
// but you can set it or override it, for instance if Bun doesn't know it's type
265+
headers: { 'Content-type': 'application/json' },
266+
})
267+
);
268+
});
269+
270+
app.get('/profile/*.jpg', async c => {
271+
// you can pass a Buffer, Readable, or TypedArray
272+
const intArray = getBytesFromExternal(c.params[0]);
273+
const resp = c.file(bytes);
274+
// You can use something like file-type on npm
275+
// To get a mime type based on binary data
276+
const { mime } = await fileTypeFromBuffer(intArray);
277+
resp.headers.set('Content-type', mime);
278+
return resp;
279+
});
280+
281+
app.get('/files/*', async c => {
282+
// c.file() accepts 4 options:
283+
return c.file(path, {
284+
disposition, // Use a Content-Disposition header with "inline" or "attachment"
285+
headers, // additional headers to add
286+
acceptRanges, // unless false, will support partial (ranged) downloads
287+
chunkSize, // Size for ranged downloads when client doesn't specify chunk size. Defaults to 3MB
288+
});
289+
});
290+
```
291+
241292
## Writing middleware
242293

243294
Here are more examples of attaching middleware.
@@ -601,11 +652,11 @@ app.socket.at<ParmasShape, DataShape>('/games/rooms/:room', {
601652
sc.readyState; // 0=connecting, 1=connected, 2=closing, 3=close
602653
sc.binaryType; // nodebuffer, arraybuffer, uint8array
603654
sc.send(message, compress /*optional*/); // compress is optional
604-
// message can be string, data to be JSON.stringified, or binary data such as Buffer or Uint8Array.
605-
// compress can be true to compress message
655+
// message can be string, data to be JSON.stringified, or binary data such as Buffer or Uint8Array.
656+
// compress can be true to compress message
606657
sc.close(status /*optional*/, reason /*optional*/); // status and reason are optional
607-
// status can be a valid WebSocket status number (in the 1000s)
608-
// reason can be text to tell client why you are closing
658+
// status can be a valid WebSocket status number (in the 1000s)
659+
// reason can be text to tell client why you are closing
609660
sc.terminate(); // terminates socket without telling client why
610661
sc.subscribe(topic); // The name of a topic to subscribe this client
611662
sc.unsubscribe(topic); // Name of topic to unsubscribe
@@ -620,8 +671,9 @@ app.socket.at<ParmasShape, DataShape>('/games/rooms/:room', {
620671
`${message}`; // will do the same as .text()
621672
message.buffer(); // get data as Buffer
622673
message.arrayBuffer(); // get data as array buffer
623-
message.readableString(); // get data as a ReadableString object
674+
message.readableStream(); // get data as a ReadableStream object
624675
message.json(); // parse data with JSON.parse()
676+
message.type; // message, ping, or pong
625677
},
626678
// called when a handler throws any error
627679
error: (sc: SocketContext, error: Error) => {
@@ -1129,7 +1181,7 @@ example:
11291181

11301182
Screenshot:
11311183

1132-
<img alt="devLogger" src="https://github.com/kensnyder/bunshine/raw/main/assets/devLogger-screenshot.png?v=3.1.1" width="524" height="78" />
1184+
<img alt="devLogger" src="https://github.com/kensnyder/bunshine/raw/main/assets/devLogger-screenshot.png?v=3.1.2" width="524" height="78" />
11331185

11341186
`prodLogger` outputs logs in JSON with the following shape:
11351187

@@ -1145,7 +1197,7 @@ Request log:
11451197
"method": "GET",
11461198
"pathname": "/home",
11471199
"runtime": "Bun v1.1.34",
1148-
"poweredBy": "Bunshine v3.1.1",
1200+
"poweredBy": "Bunshine v3.1.2",
11491201
"machine": "server1",
11501202
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
11511203
"pid": 123
@@ -1164,7 +1216,7 @@ Response log:
11641216
"method": "GET",
11651217
"pathname": "/home",
11661218
"runtime": "Bun v1.1.34",
1167-
"poweredBy": "Bunshine v3.1.1",
1219+
"poweredBy": "Bunshine v3.1.2",
11681220
"machine": "server1",
11691221
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
11701222
"pid": 123,
@@ -1354,7 +1406,25 @@ app.get('/', c => {
13541406
c.url.searchParams; // URLSearchParams object
13551407
Object.fromEntries(c.url.searchParams); // as plain object (but repeated keys are dropped)
13561408
for (const [key, value] of c.url.searchParams) {
1357-
} // iterate params
1409+
// iterate params
1410+
}
1411+
});
1412+
1413+
// Or set c.query via middleware
1414+
app.use(c => {
1415+
c.query = Object.fromEntries(c.url.searchParams);
1416+
});
1417+
1418+
// how to read json payload
1419+
app.post('/api/user', async c => {
1420+
const data = await c.request.json();
1421+
});
1422+
1423+
// Or set c.body via middleware
1424+
app.on(['POST', 'PUT', 'PATCH'], async c => {
1425+
if (c.request.headers.get('Content-Type')?.includes('application/json')) {
1426+
c.body = await c.request.json();
1427+
}
13581428
});
13591429

13601430
// create small functions that always return the same thing
@@ -1363,12 +1433,6 @@ const respondWith404 = c => c.text('Not found', { status: 404 });
13631433
app.get(/^\./, respondWith404);
13641434
// block URLs that end with .env and other dumb endings
13651435
app.all(/\.(env|bak|old|tmp|backup|log|ini|conf)$/, respondWith404);
1366-
// block WordPress URLs such as /wordpress/wp-includes/wlwmanifest.xml
1367-
app.all(/(^wordpress\/|\/wp-includes\/)/, respondWith404);
1368-
// block Other language URLs such as /phpinfo.php and /admin.cgi
1369-
app.all(/^[^/]+\.(php|cgi)$/, respondWith404);
1370-
// block Commonly probed application paths
1371-
app.all(/^(phpmyadmin|mysql|cgi-bin|cpanel|plesk)/i, respondWith404);
13721436

13731437
// middleware to add CSP
13741438
app.use(async (c, next) => {
@@ -1398,7 +1462,7 @@ app.headGet('/embeds/*', async (c, next) => {
13981462
});
13991463

14001464
// Persist data in c.locals
1401-
app.get('/api/*', async (c, next) => {
1465+
app.all('/api/*', async (c, next) => {
14021466
const authValue = c.request.headers.get('Authorization');
14031467
// subsequent handler will have access to this auth information
14041468
c.locals.auth = {
@@ -1413,7 +1477,7 @@ function castSchema(zodSchema: ZodObject): Middleware {
14131477
return async c => {
14141478
const result = zodSchema.safeParse(await c.json());
14151479
if (result.error) {
1416-
return c.json(result.error, { status: 400 });
1480+
return c.text(result.error, { status: 400 });
14171481
}
14181482
c.locals.safePayload = result.data;
14191483
};
@@ -1424,8 +1488,11 @@ app.post('/api/users', castSchema(userCreateSchema), createUser);
14241488
// Destructure context object
14251489
app.get('/api/*', async ({ url, request, json }) => {
14261490
// do stuff with url and request
1427-
return json({ message: 'my json response' });
1491+
return json({ message: `my json response at ${url.pathname}` });
14281492
});
1493+
1494+
// listen on random port
1495+
app.listen({ port: 0, reusePort: true });
14291496
```
14301497

14311498
## Design Decisions

packages/bunshine/bin/serve.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ app.headGet(
1818
index: ['index.html'],
1919
})
2020
);
21-
app.listen();
21+
app.listen({ port: 0, reusePort: true });
2222

2323
console.log(`☀️ Bunshine serving static files at ${app.server!.url}`);
2424

packages/bunshine/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "bunshine",
3-
"version": "3.1.1",
3+
"version": "3.1.2",
44
"module": "server/server.ts",
55
"type": "module",
66
"main": "index.ts",

packages/bunshine/src/HttpRouter/HttpRouter.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -194,17 +194,17 @@ export default class HttpRouter {
194194
) {
195195
return this.on<ParamsShape>(['HEAD', 'GET'], path, handlers);
196196
}
197-
use(...handlers: Handler<{}>[]) {
197+
use = (...handlers: Handler<{}>[]) => {
198198
return this.all('*', handlers);
199-
}
200-
on404(...handlers: SingleHandler<Record<string, string>>[]) {
199+
};
200+
on404 = (...handlers: SingleHandler<Record<string, string>>[]) => {
201201
this._on404Handlers.push(...handlers.flat(9));
202202
return this;
203-
}
204-
on500(...handlers: SingleHandler<Record<string, string>>[]) {
203+
};
204+
on500 = (...handlers: SingleHandler<Record<string, string>>[]) => {
205205
this._on500Handlers.push(...handlers.flat(9));
206206
return this;
207-
}
207+
};
208208
fetch = async (request: Request, server: Server) => {
209209
const context = new Context(request, server, this);
210210
const pathname = context.url.pathname;

packages/bunshine/src/parseRangeHeader/parseRangeHeader.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,15 @@ describe('parseRangeHeader', () => {
5757
status: 416,
5858
});
5959
});
60-
it('should return 200 on full byte range', () => {
60+
it('should return 206 on full byte range', () => {
6161
const result = parseRangeHeader({
6262
rangeHeader: 'bytes=0-999',
6363
totalFileSize: 1000,
6464
});
6565
expect(result).toEqual({
66-
slice: null,
66+
slice: { start: 0, end: 999 },
6767
contentLength: 1000,
68-
status: 200,
68+
status: 206,
6969
});
7070
});
7171
it('should return 206 on first bytes', () => {

packages/bunshine/src/parseRangeHeader/parseRangeHeader.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export type RangeInformation = {
77
export default function parseRangeHeader({
88
rangeHeader,
99
totalFileSize,
10-
defaultChunkSize = 3 * 1024 ** 2, // 3MB chunk if byte range is open ended
10+
defaultChunkSize = 1024 ** 2, // 1MB chunk if byte range is open ended
1111
}: RangeInformation) {
1212
if (!rangeHeader) {
1313
// range header missing or empty
@@ -42,7 +42,8 @@ export default function parseRangeHeader({
4242
return { slice: null, contentLength: null, status: 416 };
4343
}
4444
if (start === 0 && end === totalFileSize - 1) {
45-
return { slice: null, contentLength: totalFileSize, status: 200 };
45+
// safari expects a 206 even if the range is the full file
46+
// return { slice: null, contentLength: totalFileSize, status: 200 };
4647
}
4748
return { slice: { start, end }, contentLength: end - start + 1, status: 206 };
4849
}

packages/bunshine/src/responseFactories/file/file.spec.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,11 +92,14 @@ describe('c.file()', () => {
9292
});
9393
const range1Bytes = await rangeResponse1.blob();
9494

95-
expect(rangeResponse1.status).toBe(200);
95+
expect(rangeResponse1.status).toBe(206);
9696
expect(rangeResponse1.headers.get('accept-ranges')).toBe('bytes');
9797
expect(rangeResponse1.headers.get('content-type')).toBe('image/jpeg');
9898
expect(range1Bytes.size).toBe(fileSize);
9999
expect(rangeResponse1.headers.get('content-length')).toBe(String(fileSize));
100+
expect(rangeResponse1.headers.get('content-range')).toBe(
101+
`bytes 0-${fileSize - 1}/${fileSize}`
102+
);
100103
expect(range1Bytes).toEqual(fullFileBytes);
101104

102105
// Step 4: Fetch range "bytes=0-999" and validate

packages/bunshine/src/responseFactories/file/file.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export type FileResponseOptions = {
88
chunkSize?: number;
99
disposition?: 'inline' | 'attachment';
1010
acceptRanges?: boolean;
11+
headers?: HeadersInit;
1112
};
1213

1314
export default async function file(
@@ -31,15 +32,23 @@ export default async function file(
3132
});
3233
// add last modified
3334
resp.headers.set('Last-Modified', new Date(file.lastModified).toUTCString());
34-
if (fileOptions.disposition === 'attachment') {
35-
const filename = path.basename(file.name!);
35+
// optionally add disposition
36+
if (fileOptions.disposition === 'attachment' && file.name) {
37+
const filename = path.basename(file.name);
3638
resp.headers.set(
3739
'Content-Disposition',
3840
`${fileOptions.disposition}; filename="${filename}"`
3941
);
4042
} else if (fileOptions.disposition === 'inline') {
4143
resp.headers.set('Content-Disposition', 'inline');
4244
}
45+
// optionally add headers
46+
if (fileOptions.headers) {
47+
const headers = new Headers(fileOptions.headers);
48+
for (const [name, value] of Object.entries(headers)) {
49+
resp.headers.set(name, value);
50+
}
51+
}
4352
return resp;
4453
}
4554

@@ -80,6 +89,7 @@ async function buildFileResponse({
8089
headers: {
8190
'Content-Type': getMimeType(file),
8291
'Content-Length': String(file.size),
92+
// Currently Bun overrides the Content-Length header to be 0
8393
'X-Content-Length': String(file.size),
8494
...(acceptRanges ? { 'Accept-Ranges': 'bytes' } : {}),
8595
},

0 commit comments

Comments
 (0)