Comparing version 3.1.2 to 3.2.0
# Changelog | ||
## v3.2.0 - Nov xx, 2024 | ||
- Auto-detect file mime type with c.file() | ||
- Properly match fixed paths | ||
- Fix TypeScript types for HTTPS configuration | ||
- Support If-Modified-Since header | ||
- Tweaks to range header handling | ||
## v3.1.2 - Nov 25, 2024 | ||
@@ -7,3 +15,2 @@ | ||
- Support passing headers to c.file() | ||
- Return 206 in ranged downloads even if whole file is requested (Safari bug) | ||
@@ -10,0 +17,0 @@ ## v3.1.1 - Nov 23, 2024 |
{ | ||
"name": "bunshine", | ||
"version": "3.1.2", | ||
"module": "server/server.ts", | ||
"version": "3.2.0", | ||
"module": "index.ts", | ||
"type": "module", | ||
@@ -48,14 +48,15 @@ "main": "index.ts", | ||
"dependencies": { | ||
"file-type": "19.6.0", | ||
"lru-cache": "11.0.2" | ||
}, | ||
"devDependencies": { | ||
"@types/bun": "1.1.13", | ||
"@types/bun": "1.1.14", | ||
"eventsource": "2.0.2", | ||
"prettier": "3.3.3", | ||
"prettier": "3.4.1", | ||
"prettier-plugin-organize-imports": "4.1.0", | ||
"redos-detector": "5.1.3", | ||
"tinybench": "3.0.6", | ||
"type-fest": "4.27.0", | ||
"typescript": "5.6.3" | ||
"type-fest": "4.29.0", | ||
"typescript": "5.7.2" | ||
} | ||
} |
129
README.md
@@ -5,10 +5,9 @@ # Bunshine | ||
<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" /> | ||
<img alt="Bunshine Logo" src="https://github.com/kensnyder/bunshine/raw/main/packages/bunshine/assets/bunshine-logo.png?v=3.2.0" 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://opensource.org/licenses/ISC) | ||
@@ -44,10 +43,11 @@ ## Installation | ||
2. [Full example](#full-example) | ||
3. [Serving static files](#serving-static-files) | ||
4. [Writing middleware](#writing-middleware) | ||
5. [Throwing responses](#throwing-responses) | ||
6. [WebSockets](#websockets) | ||
7. [WebSocket pub-sub](#websocket-pub-sub) | ||
8. [Server Sent Events](#server-sent-events) | ||
9. [Route Matching](#route-matching) | ||
10. [Included middleware](#included-middleware) | ||
3. [SSL](#ssl) | ||
4. [Serving static files](#serving-static-files) | ||
5. [Writing middleware](#writing-middleware) | ||
6. [Throwing responses](#throwing-responses) | ||
7. [WebSockets](#websockets) | ||
8. [WebSocket pub-sub](#websocket-pub-sub) | ||
9. [Server Sent Events](#server-sent-events) | ||
10. [Route Matching](#route-matching) | ||
11. [Included middleware](#included-middleware) | ||
- [serveFiles](#servefiles) | ||
@@ -62,8 +62,8 @@ - [compression](#compression) | ||
- [Recommended Middleware](#recommended-middleware) | ||
11. [TypeScript pro-tips](#typescript-pro-tips) | ||
12. [Examples of common http server setup](#examples-of-common-http-server-setup) | ||
13. [Design Decisions](#design-decisions) | ||
14. [Roadmap](#roadmap) | ||
15. [Change Log](./CHANGELOG.md) | ||
16. [ISC License](./LICENSE.md) | ||
12. [TypeScript pro-tips](#typescript-pro-tips) | ||
13. [Examples of common http server setup](#examples-of-common-http-server-setup) | ||
14. [Design Decisions](#design-decisions) | ||
15. [Roadmap](#roadmap) | ||
16. [Change Log](./CHANGELOG.md) | ||
17. [ISC License](./LICENSE.md) | ||
@@ -224,6 +224,32 @@ ## Upgrading from 1.x to 2.x | ||
## SSL | ||
Supporting HTTPS is simple on Bun and Bunshine. For details on all supported | ||
options, see the [Bun docs on TLS](https://bun.sh/docs/api/http#tls). | ||
You can obtain free SSL certificates from a service such as | ||
[Let's Encrypt](https://letsencrypt.org/getting-started/). | ||
```ts | ||
import { HttpRouter } from 'bunshine'; | ||
const app = new HttpRouter(); | ||
const server = app.listen({ | ||
port: 443, // works on any port | ||
reusePort: true, | ||
tls: { | ||
key: Bun.file(`${import.meta.dir}/certs/my.key`), | ||
cert: Bun.file(`${import.meta.dir}/certs/my.crt`), | ||
ca: Bun.file(`${import.meta.dir}/certs/ca.pem`), // optional | ||
}, | ||
}); | ||
app.get('/', () => new Response('hello from https')); | ||
const resp = await fetch('https://localhost'); | ||
``` | ||
## Serving static files | ||
Serving static files is easy with the `serveFiles` middleware. Note that ranged | ||
requests are supported, so you can use this for video streaming or partial | ||
requests are supported, so you can use Bunshine for video streaming and partial | ||
downloads. | ||
@@ -267,4 +293,4 @@ | ||
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 | ||
// Bunshine will automatically set Content-Type based on the file bytes | ||
// but you can set it or override it for non-standard mime types | ||
headers: { 'Content-type': 'application/json' }, | ||
@@ -276,10 +302,5 @@ }) | ||
app.get('/profile/*.jpg', async c => { | ||
// you can pass a Buffer, Readable, or TypedArray | ||
// you can pass a Buffer or Uint8Array | ||
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; | ||
return c.file(bytes); | ||
}); | ||
@@ -293,3 +314,4 @@ | ||
acceptRanges, // unless false, will support partial (ranged) downloads | ||
chunkSize, // Size for ranged downloads when client doesn't specify chunk size. Defaults to 3MB | ||
sendLastModified, // unless false, will report file modification date (For paths or BunFile objects) | ||
chunkSize, // Size for ranged downloads when client doesn't specify chunk size. Defaults to 1MB | ||
}); | ||
@@ -327,3 +349,3 @@ }); | ||
} | ||
// return the response from the other handlers | ||
// pass the response to other handlers | ||
return resp; | ||
@@ -363,5 +385,2 @@ }); | ||
// handler affected by middleware defined above | ||
app.get('/', c => c.text('Hello World!')); | ||
// define a handler function to be used in multiple places | ||
@@ -501,3 +520,3 @@ const ensureSafeData = async (_, next) => { | ||
app.get('/posts', middleware1, middleware2, handler); | ||
app.get('/users', [middleware1, middleware2, handler]); | ||
app.get('/users', [middleware1, middleware2], handler); | ||
app.get('/visitors', [[middleware1, [middleware2, handler]]]); | ||
@@ -545,3 +564,4 @@ | ||
Setting up websockets at various paths is easy with the `socket` property. | ||
Setting up websockets is easy by registering handlers for one or more routes | ||
using the `app.socket.at()` function. | ||
@@ -628,12 +648,14 @@ ```ts | ||
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. | ||
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. | ||
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. | ||
Bun also includes built-in subscription and broadcasting for pub-sub | ||
applications. See [below](#websocket-pub-sub) for pub-sub examples using | ||
Bunshine. | ||
@@ -803,4 +825,5 @@ ### Socket Context properties | ||
Note that with SSE, the client must ultimately decide when to stop listening. | ||
Creating an `EventSource` object will open a connection to the server, and if | ||
the server closes the connection, a browser will automatically reconnect. | ||
Creating an `EventSource` object in the browser will open a connection to the | ||
server, and if the server closes the connection, a browser will automatically | ||
reconnect. | ||
@@ -1197,3 +1220,3 @@ So if you want to tell the browser you are done sending events, send a | ||
<img alt="devLogger" src="https://github.com/kensnyder/bunshine/raw/main/assets/devLogger-screenshot.png?v=3.1.2" width="524" height="78" /> | ||
<img alt="devLogger" src="https://github.com/kensnyder/bunshine/raw/main/assets/devLogger-screenshot.png?v=3.2.0" width="524" height="78" /> | ||
@@ -1214,3 +1237,3 @@ `prodLogger` outputs logs in JSON with the following shape: | ||
"runtime": "Bun v1.1.34", | ||
"poweredBy": "Bunshine v3.1.2", | ||
"poweredBy": "Bunshine v3.2.0", | ||
"machine": "server1", | ||
@@ -1234,3 +1257,3 @@ "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", | ||
"runtime": "Bun v1.1.34", | ||
"poweredBy": "Bunshine v3.1.2", | ||
"poweredBy": "Bunshine v3.2.0", | ||
"machine": "server1", | ||
@@ -1404,3 +1427,3 @@ "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", | ||
```ts | ||
import { HttpRouter, type Middleware } from 'bunshine'; | ||
import { type Middleware } from 'bunshine'; | ||
@@ -1569,4 +1592,4 @@ function myMiddleware(options: Options): Middleware { | ||
- ✅ Context | ||
- ✅ examples/kitchen-sink.ts | ||
- 🔲 more examples | ||
- ✅ ./examples/kitchen-sink.ts | ||
- 🔲 more examples in ./examples | ||
- ✅ middleware > compression | ||
@@ -1591,3 +1614,3 @@ - ✅ middleware > cors | ||
- 🔲 example of mini app that uses bin/serve.ts (maybe our own docs?) | ||
- 🔲 GitHub Actions to run tests and coverage | ||
- ✅ GitHub Actions to run tests and coverage | ||
- ✅ Replace "ms" with a small and simple implementation | ||
@@ -1594,0 +1617,0 @@ |
@@ -1,5 +0,6 @@ | ||
import type { BunFile, Server } from 'bun'; | ||
import type { Server } from 'bun'; | ||
import type HttpRouter from '../HttpRouter/HttpRouter'; | ||
import factory from '../responseFactories/factory/factory'; | ||
import file, { type FileResponseOptions } from '../responseFactories/file/file'; | ||
import { FileLike } from '../responseFactories/file/file-io'; | ||
import json from '../responseFactories/json/json'; | ||
@@ -82,6 +83,6 @@ import redirect from '../responseFactories/redirect/redirect'; | ||
file = async ( | ||
filenameOrBunFile: string | BunFile, | ||
pathOrData: FileLike, | ||
fileOptions: FileResponseOptions = {} | ||
) => { | ||
return file.call(this, filenameOrBunFile, { | ||
return file.call(this, pathOrData, { | ||
range: this.request.headers.get('Range') || undefined, | ||
@@ -88,0 +89,0 @@ ...fileOptions, |
@@ -1,2 +0,2 @@ | ||
import type { ServeOptions, Server } from 'bun'; | ||
import { Server, TLSServeOptions } from 'bun'; | ||
import os from 'node:os'; | ||
@@ -27,3 +27,5 @@ import bunshinePkg from '../../package.json' assert { type: 'json' }; | ||
export type ListenOptions = Omit<ServeOptions, 'fetch' | 'websocket'> | number; | ||
export type ListenOptions = | ||
| Omit<TLSServeOptions, 'fetch' | 'websocket'> | ||
| number; | ||
@@ -104,3 +106,3 @@ export type HttpMethods = | ||
} | ||
getExport(options: Omit<ServeOptions, 'fetch' | 'websocket'> = {}) { | ||
getExport(options: Omit<TLSServeOptions, 'fetch' | 'websocket'> = {}) { | ||
const config = { | ||
@@ -110,3 +112,3 @@ port: 0, | ||
fetch: this.fetch, | ||
} as ServeOptions; | ||
} as TLSServeOptions; | ||
if (this._wsRouter) { | ||
@@ -113,0 +115,0 @@ // @ts-expect-error |
@@ -45,6 +45,5 @@ export type RangeInformation = { | ||
if (start === 0 && end === totalFileSize - 1) { | ||
// safari expects a 206 even if the range is the full file | ||
// return { slice: null, contentLength: totalFileSize, status: 200 }; | ||
return { slice: null, contentLength: totalFileSize, status: 200 }; | ||
} | ||
return { slice: { start, end }, contentLength: end - start + 1, status: 206 }; | ||
} |
@@ -1,50 +0,60 @@ | ||
import { BunFile } from 'bun'; | ||
import path from 'node:path'; | ||
import Context from '../../Context/Context'; | ||
import getMimeType from '../../getMimeType/getMimeType'; | ||
import parseRangeHeader from '../../parseRangeHeader/parseRangeHeader'; | ||
import { | ||
FileLike, | ||
getFileBaseName, | ||
getFileChunk, | ||
getFileFull, | ||
getFileMime, | ||
getFileStats, | ||
isFileLike, | ||
} from './file-io'; | ||
import isModifiedSince from './isModifiedSince'; | ||
export type FileResponseOptions = { | ||
chunkSize?: number; | ||
disposition?: 'inline' | 'attachment'; | ||
disposition?: 'inline' | 'attachment' | 'form-data'; | ||
acceptRanges?: boolean; | ||
sendLastModified?: boolean; | ||
headers?: HeadersInit; | ||
}; | ||
const headersWeAdd = [ | ||
'content-type', | ||
'content-length', | ||
'x-content-length', | ||
'content-range', | ||
'accept-ranges', | ||
'last-modified', | ||
'content-disposition', | ||
]; | ||
export default async function file( | ||
this: Context, | ||
filenameOrBunFile: string | BunFile, | ||
fileLike: FileLike, | ||
fileOptions: FileResponseOptions = {} | ||
) { | ||
let file = | ||
typeof filenameOrBunFile === 'string' | ||
? Bun.file(filenameOrBunFile) | ||
: filenameOrBunFile; | ||
if (!(await file.exists())) { | ||
return new Response('File not found', { status: 404 }); | ||
const resp = await getFileResponse(this.request, fileLike, fileOptions); | ||
if (fileOptions.disposition && /^attachment$/.test(fileOptions.disposition)) { | ||
const filename = getFileBaseName(fileLike); | ||
let disposition = 'attachment'; | ||
if (filename) { | ||
disposition += `; filename="${filename}"`; | ||
} | ||
resp.headers.set('Content-Disposition', disposition); | ||
} else if ( | ||
fileOptions.disposition && | ||
/^inline|form-data$/.test(fileOptions.disposition) | ||
) { | ||
resp.headers.set('Content-Disposition', fileOptions.disposition); | ||
} | ||
const resp = await buildFileResponse({ | ||
file, | ||
acceptRanges: fileOptions.acceptRanges !== false, | ||
chunkSize: fileOptions.chunkSize, | ||
rangeHeader: this.request.headers.get('Range'), | ||
method: this.request.method, | ||
}); | ||
// add last modified | ||
resp.headers.set('Last-Modified', new Date(file.lastModified).toUTCString()); | ||
// optionally add disposition | ||
if (fileOptions.disposition === 'attachment' && file.name) { | ||
const filename = path.basename(file.name); | ||
resp.headers.set( | ||
'Content-Disposition', | ||
`${fileOptions.disposition}; filename="${filename}"` | ||
); | ||
} else if (fileOptions.disposition === 'inline') { | ||
resp.headers.set('Content-Disposition', 'inline'); | ||
} | ||
// 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); | ||
for (const [name, value] of headers.entries()) { | ||
if (headersWeAdd.includes(name.toLowerCase())) { | ||
resp.headers.set(name, value); | ||
} else { | ||
resp.headers.append(name, value); | ||
} | ||
} | ||
@@ -55,61 +65,90 @@ } | ||
async function buildFileResponse({ | ||
file, | ||
acceptRanges, | ||
chunkSize, | ||
rangeHeader, | ||
method, | ||
}: { | ||
file: BunFile; | ||
acceptRanges: boolean; | ||
chunkSize?: number; | ||
rangeHeader?: string | null; | ||
method: string; | ||
}) { | ||
const { slice, status } = parseRangeHeader({ | ||
rangeHeader: acceptRanges ? rangeHeader : null, | ||
totalFileSize: file.size, | ||
defaultChunkSize: chunkSize, | ||
}); | ||
if (status === 416) { | ||
return new Response( | ||
method === 'HEAD' | ||
? '' | ||
: `Requested range is not satisfiable. Total size is ${file.size} bytes.`, | ||
{ | ||
status: 416, | ||
headers: { | ||
'Content-Range': `bytes */${file.size}`, | ||
}, | ||
} | ||
); | ||
async function getFileResponse( | ||
request: Request, | ||
file: FileLike, | ||
fileOptions: FileResponseOptions | ||
) { | ||
if (!file || !isFileLike(file)) { | ||
return new Response('File not found', { status: 404 }); | ||
} | ||
if (method === 'HEAD') { | ||
const { size, lastModified, doesExist } = await getFileStats(file); | ||
if (!doesExist) { | ||
return new Response('File not found', { status: 404 }); | ||
} | ||
if (lastModified instanceof Date && !isModifiedSince(request, lastModified)) { | ||
return new Response(null, { status: 304 }); | ||
} | ||
const supportRangedRequest = fileOptions.acceptRanges !== false; | ||
const maybeModifiedHeader: ResponseInit['headers'] = | ||
lastModified instanceof Date && fileOptions.sendLastModified !== false | ||
? { 'Last-Modified': lastModified.toUTCString() } | ||
: {}; | ||
const maybeAcceptRangesHeader: ResponseInit['headers'] = supportRangedRequest | ||
? { 'Accept-Ranges': 'bytes' } | ||
: {}; | ||
if (request.method === 'HEAD') { | ||
const mime = await getFileMime(file); | ||
return new Response(null, { | ||
status, | ||
status: 200, | ||
headers: { | ||
'Content-Type': getMimeType(file), | ||
'Content-Length': String(file.size), | ||
'Content-Type': mime, | ||
'Content-Length': String(size), | ||
...maybeModifiedHeader, | ||
...maybeAcceptRangesHeader, | ||
// Currently Bun overrides the Content-Length header to be 0 | ||
'X-Content-Length': String(file.size), | ||
...(acceptRanges ? { 'Accept-Ranges': 'bytes' } : {}), | ||
// see https://github.com/oven-sh/bun/issues/15355 | ||
'X-Content-Length': String(size), | ||
}, | ||
}); | ||
} | ||
let buffer = await file.arrayBuffer(); | ||
if (slice) { | ||
buffer = buffer.slice(slice.start, slice.end + 1); | ||
const rangeHeader = request.headers.get('Range'); | ||
if (supportRangedRequest && rangeHeader) { | ||
const { slice, status } = parseRangeHeader({ | ||
rangeHeader: rangeHeader, | ||
totalFileSize: size || 0, | ||
defaultChunkSize: fileOptions.chunkSize, | ||
}); | ||
if (status === 416) { | ||
return new Response( | ||
`Requested range is not satisfiable. Total size is ${size} bytes.`, | ||
{ | ||
status: 416, | ||
headers: { | ||
'Content-Type': await getFileMime(file), | ||
'Content-Range': `bytes */${size}`, | ||
...maybeModifiedHeader, | ||
'Accept-Ranges': 'bytes', | ||
// Content-length set automatically based on the string length of error message | ||
}, | ||
} | ||
); | ||
} | ||
if (slice) { | ||
const buffer = await getFileChunk( | ||
file, | ||
slice.start, | ||
slice.end - slice.start + 1 | ||
); | ||
return new Response(buffer, { | ||
status: 206, | ||
headers: { | ||
'Content-Type': await getFileMime(file), | ||
// Content-length will get sent automatically | ||
...maybeModifiedHeader, | ||
'Content-Range': `bytes ${slice.start}-${slice.end}/${size}`, | ||
'Accept-Ranges': 'bytes', | ||
}, | ||
}); | ||
} | ||
} | ||
const buffer = await getFileFull(file); | ||
return new Response(buffer, { | ||
status, | ||
status: 200, | ||
headers: { | ||
'Content-Type': getMimeType(file), | ||
'Content-Length': String(buffer.byteLength), | ||
'X-Content-Length': String(buffer.byteLength), | ||
...(slice | ||
? { 'Content-Range': `bytes ${slice.start}-${slice.end}/${file.size}` } | ||
: {}), | ||
...(acceptRanges ? { 'Accept-Ranges': 'bytes' } : {}), | ||
'Content-Type': await getFileMime(file, buffer), | ||
// Content-length will get sent automatically | ||
...maybeModifiedHeader, | ||
...maybeAcceptRangesHeader, | ||
}, | ||
}); | ||
} |
@@ -88,3 +88,3 @@ type Registration<T> = { | ||
pattern, | ||
regex: new RegExp(`^${pattern}$`), | ||
regex: new RegExp(`^${regexEsc(pattern)}$`), | ||
matcher: subject => (subject === pattern ? {} : null), | ||
@@ -91,0 +91,0 @@ target, |
Sorry, the diff of this file is not supported yet
Sorry, the diff of this file is not supported yet
2203342
183
2504
1602
2
+ Addedfile-type@19.6.0
+ Added@sec-ant/readable-stream@0.4.1(transitive)
+ Added@tokenizer/token@0.3.0(transitive)
+ Addedfile-type@19.6.0(transitive)
+ Addedget-stream@9.0.1(transitive)
+ Addedieee754@1.2.1(transitive)
+ Addedis-stream@4.0.1(transitive)
+ Addedpeek-readable@5.4.2(transitive)
+ Addedstrtok3@9.1.1(transitive)
+ Addedtoken-types@6.0.0(transitive)
+ Addeduint8array-extras@1.4.0(transitive)