Comparing version 3.1.0 to 3.1.2
@@ -21,3 +21,3 @@ #!/usr/bin/env bun | ||
); | ||
app.listen(); | ||
app.listen({ port: 0, reusePort: true }); | ||
@@ -24,0 +24,0 @@ console.log(`☀️ Bunshine serving static files at ${app.server!.url}`); |
# Changelog | ||
## v3.1.0 | ||
## v3.1.2 - Nov 25, 2024 | ||
- Change default range chunk size 3MB => 1MB | ||
- Support passing headers to c.file() | ||
- Return 206 in ranged downloads even if whole file is requested (Safari bug) | ||
## v3.1.1 - Nov 23, 2024 | ||
- Fix Content-Range header when file size is 0 | ||
## v3.1.0 - Nov 23, 2024 | ||
- Remove useless exports of response factories | ||
@@ -6,0 +16,0 @@ - More unit tests |
{ | ||
"name": "bunshine", | ||
"version": "3.1.0", | ||
"version": "3.1.2", | ||
"module": "server/server.ts", | ||
@@ -5,0 +5,0 @@ "type": "module", |
262
README.md
@@ -5,10 +5,10 @@ # Bunshine | ||
<img alt="Bunshine Logo" src="https://github.com/kensnyder/bunshine/raw/main/packages/bunshine/assets/bunshine-logo.png?v=3.1.0" width="200" height="187" /> | ||
<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" /> | ||
[](https://npmjs.com/package/bunshine) | ||
[](https://github.com/search?q=repo:kensnyder/bunshine++language:TypeScript&type=code) | ||
[](https://codecov.io/gh/kensnyder/bunshine) | ||
[](https://www.npmjs.com/package/bunshine?activeTab=dependencies) | ||
 | ||
[](https://opensource.org/licenses/ISC) | ||
[](https://npmjs.com/package/bunshine) | ||
[](https://github.com/search?q=repo:kensnyder/bunshine++language:TypeScript&type=code) | ||
[](https://codecov.io/gh/kensnyder/bunshine) | ||
[](https://www.npmjs.com/package/bunshine?activeTab=dependencies) | ||
 | ||
[](https://opensource.org/licenses/ISC) | ||
@@ -65,3 +65,4 @@ ## Installation | ||
14. [Roadmap](#roadmap) | ||
15. [ISC License](./LICENSE.md) | ||
15. [Change Log](./CHANGELOG.md) | ||
16. [ISC License](./LICENSE.md) | ||
@@ -188,16 +189,16 @@ ## Upgrading from 1.x to 2.x | ||
// Or create Response objects with convenience functions: | ||
return c.json(data, init); | ||
return c.text(text, init); | ||
return c.js(jsText, init); | ||
return c.xml(xmlText, init); | ||
return c.html(htmlText, init); | ||
return c.css(cssText, init); | ||
return c.file(pathOrSource, init); | ||
return c.json(data, init); // data to pass to JSON.stringify | ||
return c.text(text, init); // plain text | ||
return c.js(jsText, init); // plain-text js | ||
return c.xml(xmlText, init); // plain-text xml | ||
return c.html(htmlText, init); // plain-text html | ||
return c.css(cssText, init); // plain-text css | ||
return c.file(pathOrSource, init); // file path, BunFile or binary source | ||
// above init is ResponseInit: | ||
{ | ||
headers: Headers | Record<string, string> | Array<[string, string]>; | ||
status: number; | ||
statusText: string; | ||
} | ||
// above init is the Web Standards ResponseInit: | ||
type ResponseInit = { | ||
headers?: Headers | Record<string, string> | Array<[string, string]>; | ||
status?: number; | ||
statusText?: string; | ||
}; | ||
@@ -244,2 +245,52 @@ // Create a redirect Response: | ||
If you want to manually manage serving a file, you can use the following approach. | ||
```ts | ||
import { HttpRouter, serveFiles } from 'bunshine'; | ||
const app = new HttpRouter(); | ||
app.get('/assets/:name.png', c => { | ||
const name = c.params.name; | ||
const filePath = `${import.meta.dir}/assets/${name}.png`; | ||
// you can pass a string path | ||
return c.file(filePath); | ||
// Bun will set Content-Type based on the string file extension | ||
}); | ||
app.get('/build/:hash.map', c => { | ||
const hash = c.params.hash; | ||
const filePath = `${import.meta.dir}/assets/${name}.png`; | ||
// you can pass a BunFile | ||
return c.file( | ||
Bun.file(filePath, { | ||
// Bun will automatically set Content-Type based on the file's extension | ||
// but you can set it or override it, for instance if Bun doesn't know it's type | ||
headers: { 'Content-type': 'application/json' }, | ||
}) | ||
); | ||
}); | ||
app.get('/profile/*.jpg', async c => { | ||
// you can pass a Buffer, Readable, or TypedArray | ||
const intArray = getBytesFromExternal(c.params[0]); | ||
const resp = c.file(bytes); | ||
// You can use something like file-type on npm | ||
// To get a mime type based on binary data | ||
const { mime } = await fileTypeFromBuffer(intArray); | ||
resp.headers.set('Content-type', mime); | ||
return resp; | ||
}); | ||
app.get('/files/*', async c => { | ||
// c.file() accepts 4 options: | ||
return c.file(path, { | ||
disposition, // Use a Content-Disposition header with "inline" or "attachment" | ||
headers, // additional headers to add | ||
acceptRanges, // unless false, will support partial (ranged) downloads | ||
chunkSize, // Size for ranged downloads when client doesn't specify chunk size. Defaults to 3MB | ||
}); | ||
}); | ||
``` | ||
## Writing middleware | ||
@@ -491,10 +542,10 @@ | ||
```ts | ||
import { HttpRouter } from 'bunshine'; | ||
import { HttpRouter, SocketMessage } from 'bunshine'; | ||
const app = new HttpRouter(); | ||
// regular routes | ||
// register regular routes on app.get()/post()/etc | ||
app.get('/', c => c.text('Hello World!')); | ||
// WebSocket routes | ||
// Register WebSocket routes with app.socket.at() | ||
type ParamsShape = { room: string }; | ||
@@ -504,5 +555,5 @@ type DataShape = { user: User }; | ||
// Optional. Allows you to specify arbitrary data to attach to ws.data. | ||
upgrade: sc => { | ||
upgrade: c => { | ||
// upgrade is called on first connection, before HTTP 101 is issued | ||
const cookies = sc.request.headers.get('cookie'); | ||
const cookies = c.request.headers.get('cookie'); | ||
const user = getUserFromCookies(cookies); | ||
@@ -534,3 +585,3 @@ return { user }; | ||
// List of codes and messages: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code | ||
close(sc, code, message) { | ||
close(sc, code, reason) { | ||
const room = sc.params.room; | ||
@@ -563,2 +614,105 @@ const user = sc.data.user; | ||
### Are sockets magical? | ||
They're simpler than you think. What Bun does internally: | ||
- Responds to a regular HTTP request with HTTP 101 (Switching Protocols) | ||
- Tells the client to make socket connection on another port | ||
- Keeps connection open and sends/receives messages | ||
- Keeps objects in memory to represent each connected client | ||
- Tracks topic subscription and un-subscription for each client | ||
In Node, the process is complicated because you have to import and orchestrate http/https, and net. | ||
But Bun provides Bun.serve() which handles everything. Bunshine is a wrapper | ||
around Bun.serve that adds routing and convenience functions. | ||
With Bunshine you don't need to use Socket.IO or any other framework. | ||
Connecting from the client requires no library either. You can simply | ||
create a new WebSocket() object and use it to listen and send data. | ||
For pub-sub, Bun internally handles subscriptions and broadcasts. See below for | ||
pub-sub examples using Bunshine. | ||
### Socket Context properties | ||
```ts | ||
import { Context, HttpRouter, NextFunction, SocketContext } from 'bunshine'; | ||
const app = new HttpRouter(); | ||
// WebSocket routes | ||
type ParamsShape = { room: string }; | ||
type DataShape = { user: User }; | ||
app.socket.at<ParmasShape, DataShape>('/games/rooms/:room', { | ||
// Optional. Allows you to specify arbitrary data to attach to ws.data. | ||
upgrade: (c: Context, next: NextFunction) => { | ||
// Functions are type annotated for illustrations, but is not neccessary | ||
// c and next here are the same as regular http endpoings | ||
return data; // data returned becomes available on sc.data | ||
}, | ||
// Optional. Called when the client sends a message | ||
message(sc, message) { | ||
sc.url; // the URL object for the request | ||
sc.server; // Same as app.server | ||
sc.params; // Route params for the request (in this case { room: string; }) | ||
sc.data; // Any data you return from the upgrade handler | ||
sc.remoteAddress; // the client or load balancer IP Address | ||
sc.readyState; // 0=connecting, 1=connected, 2=closing, 3=close | ||
sc.binaryType; // nodebuffer, arraybuffer, uint8array | ||
sc.send(message, compress /*optional*/); // compress is optional | ||
// message can be string, data to be JSON.stringified, or binary data such as Buffer or Uint8Array. | ||
// compress can be true to compress message | ||
sc.close(status /*optional*/, reason /*optional*/); // status and reason are optional | ||
// status can be a valid WebSocket status number (in the 1000s) | ||
// reason can be text to tell client why you are closing | ||
sc.terminate(); // terminates socket without telling client why | ||
sc.subscribe(topic); // The name of a topic to subscribe this client | ||
sc.unsubscribe(topic); // Name of topic to unsubscribe | ||
sc.isSubscribed(topic); // True if subscribed to that topic name | ||
sc.cork(topic); // Normally there is no reason to use this function | ||
sc.publish(topic, message, compress /*optional*/); // Publish message to all subscribed clients | ||
sc.ping(data /*optional*/); // Tell client to stay connected | ||
sc.pong(data /*optional*/); // Way to respond to client request to stay connected | ||
message.raw(); // get the raw string or Buffer of the message | ||
message.text(encoding /*optional*/); // get message as string | ||
`${message}`; // will do the same as .text() | ||
message.buffer(); // get data as Buffer | ||
message.arrayBuffer(); // get data as array buffer | ||
message.readableStream(); // get data as a ReadableStream object | ||
message.json(); // parse data with JSON.parse() | ||
message.type; // message, ping, or pong | ||
}, | ||
// called when a handler throws any error | ||
error: (sc: SocketContext, error: Error) => { | ||
// sc is the same as above | ||
}, | ||
// Optional. Called when the client disconnects | ||
// List of codes and messages: https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code | ||
close(sc: SocketContext, code: number, reason: string) { | ||
// sc is the same as above | ||
}, | ||
}); | ||
// start the server | ||
app.listen({ port: 3100, reusePort: true }); | ||
// | ||
// Browser side: | ||
// | ||
const gameRoom = new WebSocket('ws://localhost:3100/games/rooms/1?user=42'); | ||
gameRoom.onmessage = e => { | ||
// receiving messages | ||
const data = JSON.parse(e.data); | ||
if (data.type === 'GameState') { | ||
setGameState(data); | ||
} else if (data.type === 'GameMove') { | ||
playMove(data); | ||
} | ||
}; | ||
gameRoom.onerror = handleGameError; | ||
// send message to server | ||
gameRoom.send(JSON.stringify({ type: 'GameMove', move: 'rock' })); | ||
``` | ||
## WebSocket pub-sub | ||
@@ -574,4 +728,2 @@ | ||
app.get('/', c => c.text('Hello World!')); | ||
type ParamsShape = { room: string }; | ||
@@ -593,7 +745,8 @@ type DataShape = { username: string }; | ||
// to each connection's message handler | ||
const fullMessage = `${sc.data.username}: ${message}`; | ||
// so you need to call sc.publish() and sc.send() | ||
const fullMessage = `${sc.data.username}: ${message.text()}`; | ||
sc.publish(`chat-room-${sc.params.room}`, fullMessage); | ||
sc.send(fullMessage); | ||
}, | ||
close(sc, code, message) { | ||
close(sc, code, reason) { | ||
const msg = `${sc.data.username} has left the chat`; | ||
@@ -1041,3 +1194,3 @@ sc.publish(`chat-room-${sc.params.room}`, msg); | ||
<img alt="devLogger" src="https://github.com/kensnyder/bunshine/raw/main/assets/devLogger-screenshot.png?v=3.1.0" width="524" height="78" /> | ||
<img alt="devLogger" src="https://github.com/kensnyder/bunshine/raw/main/assets/devLogger-screenshot.png?v=3.1.2" width="524" height="78" /> | ||
@@ -1058,5 +1211,5 @@ `prodLogger` outputs logs in JSON with the following shape: | ||
"runtime": "Bun v1.1.34", | ||
"poweredBy": "Bunshine v3.1.0", | ||
"poweredBy": "Bunshine v3.1.2", | ||
"machine": "server1", | ||
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.1.0.0 Safari/537.36", | ||
"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", | ||
"pid": 123 | ||
@@ -1078,5 +1231,5 @@ } | ||
"runtime": "Bun v1.1.34", | ||
"poweredBy": "Bunshine v3.1.0", | ||
"poweredBy": "Bunshine v3.1.2", | ||
"machine": "server1", | ||
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.1.0.0 Safari/537.36", | ||
"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", | ||
"pid": 123, | ||
@@ -1235,3 +1388,3 @@ "took": 5 | ||
}, | ||
close(ws, code, message) { | ||
close(ws, code, reason) { | ||
// TypeScript knows that ws.data.params.room is a string | ||
@@ -1270,5 +1423,23 @@ // TypeScript knows that ws.data.user is a User | ||
for (const [key, value] of c.url.searchParams) { | ||
} // iterate params | ||
// iterate params | ||
} | ||
}); | ||
// Or set c.query via middleware | ||
app.use(c => { | ||
c.query = Object.fromEntries(c.url.searchParams); | ||
}); | ||
// how to read json payload | ||
app.post('/api/user', async c => { | ||
const data = await c.request.json(); | ||
}); | ||
// Or set c.body via middleware | ||
app.on(['POST', 'PUT', 'PATCH'], async c => { | ||
if (c.request.headers.get('Content-Type')?.includes('application/json')) { | ||
c.body = await c.request.json(); | ||
} | ||
}); | ||
// create small functions that always return the same thing | ||
@@ -1280,8 +1451,2 @@ const respondWith404 = c => c.text('Not found', { status: 404 }); | ||
app.all(/\.(env|bak|old|tmp|backup|log|ini|conf)$/, respondWith404); | ||
// block WordPress URLs such as /wordpress/wp-includes/wlwmanifest.xml | ||
app.all(/(^wordpress\/|\/wp-includes\/)/, respondWith404); | ||
// block Other language URLs such as /phpinfo.php and /admin.cgi | ||
app.all(/^[^/]+\.(php|cgi)$/, respondWith404); | ||
// block Commonly probed application paths | ||
app.all(/^(phpmyadmin|mysql|cgi-bin|cpanel|plesk)/i, respondWith404); | ||
@@ -1316,3 +1481,3 @@ // middleware to add CSP | ||
// Persist data in c.locals | ||
app.get('/api/*', async (c, next) => { | ||
app.all('/api/*', async (c, next) => { | ||
const authValue = c.request.headers.get('Authorization'); | ||
@@ -1332,3 +1497,3 @@ // subsequent handler will have access to this auth information | ||
if (result.error) { | ||
return c.json(result.error, { status: 400 }); | ||
return c.text(result.error, { status: 400 }); | ||
} | ||
@@ -1344,4 +1509,7 @@ c.locals.safePayload = result.data; | ||
// do stuff with url and request | ||
return json({ message: 'my json response' }); | ||
return json({ message: `my json response at ${url.pathname}` }); | ||
}); | ||
// listen on random port | ||
app.listen({ port: 0, reusePort: true }); | ||
``` | ||
@@ -1348,0 +1516,0 @@ |
@@ -197,13 +197,13 @@ import type { ServeOptions, Server } from 'bun'; | ||
} | ||
use(...handlers: Handler<{}>[]) { | ||
use = (...handlers: Handler<{}>[]) => { | ||
return this.all('*', handlers); | ||
} | ||
on404(...handlers: SingleHandler<Record<string, string>>[]) { | ||
}; | ||
on404 = (...handlers: SingleHandler<Record<string, string>>[]) => { | ||
this._on404Handlers.push(...handlers.flat(9)); | ||
return this; | ||
} | ||
on500(...handlers: SingleHandler<Record<string, string>>[]) { | ||
}; | ||
on500 = (...handlers: SingleHandler<Record<string, string>>[]) => { | ||
this._on500Handlers.push(...handlers.flat(9)); | ||
return this; | ||
} | ||
}; | ||
fetch = async (request: Request, server: Server) => { | ||
@@ -210,0 +210,0 @@ const context = new Context(request, server, this); |
@@ -10,3 +10,3 @@ export type RangeInformation = { | ||
totalFileSize, | ||
defaultChunkSize = 3 * 1024 ** 2, // 3MB chunk if byte range is open ended | ||
defaultChunkSize = 1024 ** 2, // 1MB chunk if byte range is open ended | ||
}: RangeInformation) { | ||
@@ -17,2 +17,6 @@ if (!rangeHeader) { | ||
} | ||
if (totalFileSize === 0) { | ||
// server should respond with "content-range: bytes */0" | ||
return { slice: null, contentLength: 0, status: 416 }; | ||
} | ||
// only support single ranges | ||
@@ -43,5 +47,6 @@ const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/i); | ||
if (start === 0 && end === totalFileSize - 1) { | ||
return { slice: null, contentLength: totalFileSize, status: 200 }; | ||
// safari expects a 206 even if the range is the full file | ||
// return { slice: null, contentLength: totalFileSize, status: 200 }; | ||
} | ||
return { slice: { start, end }, contentLength: end - start + 1, status: 206 }; | ||
} |
@@ -11,2 +11,3 @@ import { BunFile } from 'bun'; | ||
acceptRanges?: boolean; | ||
headers?: HeadersInit; | ||
}; | ||
@@ -35,4 +36,5 @@ | ||
resp.headers.set('Last-Modified', new Date(file.lastModified).toUTCString()); | ||
if (fileOptions.disposition === 'attachment') { | ||
const filename = path.basename(file.name!); | ||
// optionally add disposition | ||
if (fileOptions.disposition === 'attachment' && file.name) { | ||
const filename = path.basename(file.name); | ||
resp.headers.set( | ||
@@ -45,2 +47,9 @@ 'Content-Disposition', | ||
} | ||
// optionally add headers | ||
if (fileOptions.headers) { | ||
const headers = new Headers(fileOptions.headers); | ||
for (const [name, value] of Object.entries(headers)) { | ||
resp.headers.set(name, value); | ||
} | ||
} | ||
return resp; | ||
@@ -86,2 +95,3 @@ } | ||
'Content-Length': String(file.size), | ||
// Currently Bun overrides the Content-Length header to be 0 | ||
'X-Content-Length': String(file.size), | ||
@@ -88,0 +98,0 @@ ...(acceptRanges ? { 'Accept-Ranges': 'bytes' } : {}), |
@@ -150,3 +150,3 @@ import { Server, ServerWebSocket, ServerWebSocketSendStatus } from 'bun'; | ||
readableStream(chunkSize: number = 1024) { | ||
// @ts-expect-error | ||
// @ts-expect-error - type is incorrect | ||
return new Blob([this.buffer()]).stream(chunkSize); | ||
@@ -153,0 +153,0 @@ } |
827040
2301
1579