Comparing version 0.10.0 to 0.11.0
@@ -1,2 +0,2 @@ | ||
ISC License (ISC) Copyright 2023 Ken Snyder | ||
ISC License (ISC) Copyright 2023-2024 Ken Snyder | ||
@@ -3,0 +3,0 @@ Permission to use, copy, modify, and/or distribute this software for any purpose |
{ | ||
"name": "bunshine", | ||
"version": "0.10.0", | ||
"version": "0.11.0", | ||
"module": "server/server.ts", | ||
@@ -13,2 +13,3 @@ "type": "module", | ||
"lru-cache": "^10.1.0", | ||
"ms": "^2.1.3", | ||
"path-to-regexp": "^6.2.1" | ||
@@ -18,2 +19,3 @@ }, | ||
"@types/eventsource": "^1.1.15", | ||
"@types/ms": "^0.7.34", | ||
"bun-types": "^1.0.21", | ||
@@ -20,0 +22,0 @@ "eventsource": "^2.0.2", |
@@ -1,6 +0,7 @@ | ||
<img alt="Bunshine Logo" src="https://github.com/kensnyder/bunshine/raw/main/assets/bunshine-logo.png?v=0.10.0" width="200" height="187" /> | ||
<img alt="Bunshine Logo" src="https://github.com/kensnyder/bunshine/raw/main/assets/bunshine-logo.png?v=0.11.0" width="200" height="187" /> | ||
[](https://npmjs.com/package/bunshine) | ||
[](https://www.npmjs.com/package/bunshine?activeTab=dependencies) | ||
[](https://opensource.org/licenses/ISC) | ||
[](https://npmjs.com/package/bunshine) | ||
[](https://www.npmjs.com/package/bunshine?activeTab=dependencies) | ||
 | ||
[](https://opensource.org/licenses/ISC) | ||
@@ -136,2 +137,4 @@ # Bunshine | ||
See the [serveFiles](#serveFiles) section for more info. | ||
## Writing middleware | ||
@@ -562,2 +565,66 @@ | ||
How to respond to both GET and HEAD requests: | ||
```ts | ||
import { HttpRouter, serveFiles } from 'bunshine'; | ||
const app = new HttpRouter(); | ||
app.on(['HEAD', 'GET'], '/public/*', serveFiles(`${import.meta.dir}/public`)); | ||
app.listen({ port: 3100 }); | ||
``` | ||
How to alter the response: | ||
```ts | ||
import { HttpRouter, serveFiles } from 'bunshine'; | ||
const app = new HttpRouter(); | ||
const addFooHeader = async (_, next) => { | ||
const response = await next(); | ||
response.headers.set('x-foo', 'bar'); | ||
return response; | ||
}; | ||
app.get('/public/*', addFooHeader, serveFiles(`${import.meta.dir}/public`)); | ||
app.listen({ port: 3100 }); | ||
``` | ||
serveFiles accepts an optional second parameter for options: | ||
```ts | ||
import { HttpRouter, serveFiles } from 'bunshine'; | ||
const app = new HttpRouter(); | ||
app.get( | ||
'/public/*', | ||
serveFiles(`${import.meta.dir}/public`, { | ||
extensions: ['html', 'css', 'js', 'png', 'jpg', 'gif', 'svg', 'ico'], | ||
index: true, | ||
}) | ||
); | ||
app.listen({ port: 3100 }); | ||
``` | ||
All options for serveFiles: | ||
| Option | Default | Description | | ||
| ------------ | ----------- | ----------------------------------------------------------------------------------------- | | ||
| acceptRanges | `true` | If true, accept ranged byte requests | | ||
| dotfiles | `"ignore"` | How to handle dotfiles; allow=>serve normally, deny=>return 403, ignore=>run next handler | | ||
| etag | N/A | Not yet implemented | | ||
| extensions | `[]` | If given, a list of file extensions to allow | | ||
| fallthrough | `true` | If false, issue a 404 when a file is not found, otherwise proceed to next handler | | ||
| immutable | `false` | If true, add immutable directive to Cache-Control header; must also specify maxAge | | ||
| index | `[]` | If given, a list of filenames (e.g. index.html) to look for when path is a folder | | ||
| lastModified | `true` | If true, set the Last-Modified header | | ||
| maxAge | `undefined` | If given, add a Cache-Control header with max-age† | | ||
† _A number in milliseconds or [ms](https://www.npmjs.com/package/ms) compatible expression such as '30m' or '1y'._ | ||
### cors | ||
@@ -564,0 +631,0 @@ |
@@ -51,3 +51,3 @@ import type { Server } from 'bun'; | ||
const text = await file.text(); | ||
expect(resp.status).toBe(206); | ||
expect(resp.status).toBe(200); | ||
expect(text).toBe('<h1>'); | ||
@@ -86,3 +86,3 @@ }); | ||
expect(resp).toBeInstanceOf(Response); | ||
expect(resp.status).toBe(206); | ||
expect(resp.status).toBe(200); | ||
expect(await resp.text()).toBe('<h1>'); | ||
@@ -89,0 +89,0 @@ }); |
@@ -32,4 +32,3 @@ import { BunFile } from 'bun'; | ||
filenameOrBunFile: string | BunFile, | ||
fileOptions: FileResponseOptions = {}, | ||
responseInit: ResponseInit = {} | ||
fileOptions: FileResponseOptions = {} | ||
) => { | ||
@@ -44,34 +43,9 @@ let file = | ||
} | ||
let resp: Response; | ||
const rangeMatch = fileOptions.range?.match(/^bytes=(\d*)-(\d*)$/); | ||
if (rangeMatch) { | ||
const start = parseInt(rangeMatch[1]) || 0; | ||
let end = parseInt(rangeMatch[2]); | ||
if (isNaN(end)) { | ||
// Initial request: some browsers use "Range: bytes=0-" | ||
end = Math.min( | ||
start + (fileOptions.chunkSize || 3 * 1024 ** 2), | ||
totalFileSize - 1 | ||
); | ||
} | ||
if (end > totalFileSize - 1) { | ||
return new Response('416 Range not satisfiable', { status: 416 }); | ||
} | ||
// Bun has a bug when setting content-length and content-range automatically | ||
// so convert file to buffer | ||
let buffer = await file.arrayBuffer(); | ||
// the range is less than the entire file | ||
if (end - 1 < totalFileSize) { | ||
buffer = buffer.slice(start, end + 1); | ||
} | ||
resp = new Response(buffer, { ...responseInit, status: 206 }); | ||
if (!resp.headers.has('Content-Type')) { | ||
resp.headers.set('Content-Type', 'application/octet-stream'); | ||
} | ||
resp.headers.set('Content-Length', String(buffer.byteLength)); | ||
resp.headers.set('Content-Range', `bytes ${start}-${end}/${totalFileSize}`); | ||
} else { | ||
// Bun will automatically set content-type and length | ||
resp = new Response(file); | ||
} | ||
const resp = await buildFileResponse({ | ||
file, | ||
acceptRanges: true, | ||
chunkSize: fileOptions.chunkSize, | ||
rangeHeader: fileOptions.range, | ||
method: 'GET', | ||
}); | ||
// tell the client that we are capable of handling range requests | ||
@@ -176,2 +150,3 @@ resp.headers.set('Accept-Ranges', 'bytes'); | ||
headers.set('Connection', 'keep-alive'); | ||
// @ts-ignore | ||
return new Response(stream, { ...init, headers }); | ||
@@ -184,2 +159,3 @@ }; | ||
...init, | ||
// @ts-ignore | ||
headers: { | ||
@@ -192,1 +168,55 @@ ...(init.headers || {}), | ||
} | ||
export async function buildFileResponse({ | ||
file, | ||
acceptRanges, | ||
chunkSize, | ||
rangeHeader, | ||
method, | ||
}: { | ||
file: BunFile; | ||
acceptRanges: boolean; | ||
chunkSize?: number; | ||
rangeHeader?: string | null; | ||
method: string; | ||
}) { | ||
let response: Response; | ||
const rangeMatch = String(rangeHeader).match(/^bytes=(\d*)-(\d*)$/); | ||
if (acceptRanges && rangeMatch) { | ||
const totalFileSize = file.size; | ||
const start = parseInt(rangeMatch[1]) || 0; | ||
let end = parseInt(rangeMatch[2]); | ||
if (isNaN(end)) { | ||
// Initial request: some browsers use "Range: bytes=0-" | ||
end = Math.min(start + (chunkSize || 3 * 1024 ** 2), totalFileSize - 1); | ||
} | ||
if (end > totalFileSize - 1) { | ||
return new Response('416 Range not satisfiable', { status: 416 }); | ||
} | ||
// Bun has a bug when setting content-length and content-range automatically | ||
// so convert file to buffer | ||
let buffer = await file.arrayBuffer(); | ||
let status = 206; | ||
// the range is less than the entire file | ||
if (end - 1 < totalFileSize) { | ||
buffer = buffer.slice(start, end + 1); | ||
status = 200; | ||
} | ||
response = new Response(buffer, { status }); | ||
if (!response.headers.has('Content-Type')) { | ||
response.headers.set('Content-Type', 'application/octet-stream'); | ||
} | ||
response.headers.set('Content-Length', String(buffer.byteLength)); | ||
response.headers.set( | ||
'Content-Range', | ||
`bytes ${start}-${end}/${totalFileSize}` | ||
); | ||
} else { | ||
// Bun will automatically set content-type and length | ||
response = new Response(file, { status: method === 'HEAD' ? 204 : 200 }); | ||
} | ||
if (acceptRanges) { | ||
response.headers.set('Accept-Ranges', 'bytes'); | ||
} | ||
return response; | ||
} |
@@ -0,3 +1,5 @@ | ||
import ms from 'ms'; | ||
import path from 'path'; | ||
import type { Middleware } from '../../HttpRouter/HttpRouter.ts'; | ||
import { buildFileResponse } from '../../HttpRouter/responseFactories.ts'; | ||
@@ -8,3 +10,2 @@ // see https://expressjs.com/en/4x/api.html#express.static | ||
acceptRanges?: boolean; | ||
cacheControl?: boolean; | ||
dotfiles?: 'allow' | 'deny' | 'ignore'; | ||
@@ -15,7 +16,5 @@ etag?: boolean; | ||
immutable?: boolean; | ||
index?: boolean | string | string[]; | ||
index?: string[]; | ||
lastModified?: boolean; | ||
maxAge?: number | string; | ||
redirect?: boolean; | ||
setHeaders?: Middleware; | ||
}; | ||
@@ -27,18 +26,99 @@ | ||
acceptRanges = true, | ||
cacheControl = true, | ||
dotfiles = 'ignore', | ||
etag = true, | ||
etag = true, // Not yet implemented | ||
extensions = [], | ||
fallthrough = true, | ||
immutable = false, | ||
index = false, | ||
index = [], | ||
lastModified = true, | ||
maxAge = undefined, | ||
redirect = true, | ||
setHeaders, | ||
}: StaticOptions = {} | ||
): Middleware { | ||
return c => { | ||
return c.file(path.join(directory, c.params[0] || c.url.pathname)); | ||
const cacheControlHeader = | ||
maxAge === undefined ? null : getCacheControl(maxAge, immutable); | ||
return async c => { | ||
const filename = c.params[0] || c.url.pathname; | ||
if (filename.startsWith('.')) { | ||
if (dotfiles === 'ignore') { | ||
// fall through to next handler | ||
return; | ||
} | ||
if (dotfiles === 'deny') { | ||
return new Response('403 Forbidden', { status: 403 }); | ||
} | ||
} | ||
if (extensions.length > 0) { | ||
const ext = path.extname(filename).slice(1); | ||
if (!extensions.includes(ext)) { | ||
// fall through to next handler | ||
return; | ||
} | ||
} | ||
// get file path | ||
const filePath = path.join(directory, filename); | ||
// init file | ||
let file = Bun.file(filePath); | ||
// handle existence | ||
let exists = await file.exists(); | ||
// console.log('----------=========------- exists?', { exists, filePath }); | ||
// handle index files | ||
if (!exists && index.length > 0) { | ||
// try to find index file such as index.html or index.js | ||
for (const indexFile of index) { | ||
const indexFilePath = path.join(filePath, indexFile); | ||
file = Bun.file(indexFilePath); | ||
exists = await file.exists(); | ||
if (exists) { | ||
break; | ||
} | ||
} | ||
} | ||
if (!exists) { | ||
if (fallthrough) { | ||
return; | ||
} | ||
return new Response('404 Not Found', { status: 404 }); | ||
} | ||
// get base response | ||
const response = await buildFileResponse({ | ||
file, | ||
acceptRanges, | ||
chunkSize: 0, | ||
rangeHeader: c.request.headers.get('range'), | ||
method: c.request.method, | ||
}); | ||
// add current date | ||
response.headers.set('Date', new Date().toUTCString()); | ||
// add last modified | ||
if (lastModified) { | ||
response.headers.set( | ||
'Last-Modified', | ||
new Date(file.lastModified).toUTCString() | ||
); | ||
} | ||
// add Cache-Control header | ||
if (cacheControlHeader) { | ||
response.headers.set('Cache-Control', cacheControlHeader); | ||
} | ||
return response; | ||
}; | ||
} | ||
function getCacheControl(maxAge: string | number, immutable: boolean) { | ||
let cacheControl = 'public, '; | ||
if (typeof maxAge === 'string') { | ||
const milliseconds = ms(maxAge); | ||
if (milliseconds === undefined) { | ||
throw new Error(`Invalid maxAge: ${maxAge}`); | ||
} | ||
maxAge = milliseconds; | ||
} | ||
if (typeof maxAge === 'number' && maxAge >= 0) { | ||
const seconds = Math.floor(maxAge / 1000); | ||
cacheControl += `max-age=${seconds}`; | ||
} | ||
if (immutable) { | ||
cacheControl += ', immutable'; | ||
} | ||
return cacheControl; | ||
} |
Sorry, the diff of this file is not supported yet
203680
43
3543
918
3
9