Comparing version 3.0.0-rc.1 to 3.0.0-rc.2
@@ -104,6 +104,4 @@ // Generated by dts-bundle-generator v9.5.1 | ||
export type SingleHandler<ParamsShape extends Record<string, string> = Record<string, string>> = (context: Context<ParamsShape>, next: NextFunction) => Response | void | Promise<Response | void>; | ||
export type SingleErrorHandler<ParamsShape extends Record<string, string> = Record<string, string>> = (context: ContextWithError<ParamsShape>, next: NextFunction) => Response | void | Promise<Response | void>; | ||
export type Middleware<ParamsShape extends Record<string, string> = Record<string, string>> = SingleHandler<ParamsShape>; | ||
export type Handler<ParamsShape extends Record<string, string> = Record<string, string>> = SingleHandler<ParamsShape> | Handler<ParamsShape>[]; | ||
export type ErrorHandler<ParamsShape extends Record<string, string> = Record<string, string>> = SingleErrorHandler<ParamsShape> | ErrorHandler<ParamsShape>[]; | ||
export type ListenOptions = Omit<ServeOptions, "fetch" | "websocket"> | number; | ||
@@ -125,4 +123,6 @@ export type HttpMethods = "ALL" | "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" | "TRACE"; | ||
_wsRouter?: SocketRouter; | ||
private _onErrors; | ||
private _on404s; | ||
onNotFound: typeof HttpRouter.on404; | ||
onError: typeof HttpRouter.on500; | ||
private _on404Handlers; | ||
private _on500Handlers; | ||
constructor(options?: HttpRouterOptions); | ||
@@ -145,4 +145,4 @@ listen(portOrOptions?: ListenOptions): Server; | ||
use(...handlers: Handler<{}>[]): this; | ||
onError(...handlers: ErrorHandler<Record<string, string>>[]): this; | ||
on404(...handlers: Handler<Record<string, string>>[]): this; | ||
on404(...handlers: SingleHandler<Record<string, string>>[]): this; | ||
on500(...handlers: SingleHandler<Record<string, string>>[]): this; | ||
fetch: (request: Request, server: Server) => Promise<Response>; | ||
@@ -172,5 +172,2 @@ } | ||
}): Promise<Response>; | ||
export type ContextWithError<ParamsShape extends Record<string, string> = Record<string, string>> = Context<ParamsShape> & { | ||
error: Error; | ||
}; | ||
export declare class Context<ParamsShape extends Record<string, string> = Record<string, string>> { | ||
@@ -223,2 +220,8 @@ /** The raw request object */ | ||
} | ||
export type ApplyHandlerIfArgs = { | ||
requestCondition?: (c: Context) => Promise<boolean> | boolean; | ||
responseCondition?: (c: Context, resp: Response) => Promise<boolean> | boolean; | ||
handler: SingleHandler; | ||
}; | ||
export declare function applyHandlerIf(conditions: ApplyHandlerIfArgs): Middleware; | ||
export type CompressionOptions = { | ||
@@ -225,0 +228,0 @@ prefer: "br" | "gzip" | "none"; |
11
index.ts
@@ -0,9 +1,5 @@ | ||
export { default as Context } from './src/Context/Context'; | ||
export { | ||
default as Context, | ||
type ContextWithError, | ||
} from './src/Context/Context'; | ||
export { | ||
default as HttpRouter, | ||
type EmitUrlOptions, | ||
type ErrorHandler, | ||
type Handler, | ||
@@ -15,3 +11,2 @@ type HttpMethods, | ||
type NextFunction, | ||
type SingleErrorHandler, | ||
type SingleHandler, | ||
@@ -33,2 +28,6 @@ } from './src/HttpRouter/HttpRouter'; | ||
export { | ||
applyHandlerIf, | ||
type ApplyHandlerIfArgs, | ||
} from './src/middleware/applyHandlerIf/applyHandlerIf'; | ||
export { | ||
compression, | ||
@@ -35,0 +34,0 @@ compressionDefaults, |
{ | ||
"name": "bunshine", | ||
"version": "3.0.0-rc.1", | ||
"version": "3.0.0-rc.2", | ||
"module": "server/server.ts", | ||
@@ -5,0 +5,0 @@ "type": "module", |
326
README.md
@@ -5,11 +5,10 @@ # Bunshine | ||
<img alt="Bunshine Logo" src="https://github.com/kensnyder/bunshine/raw/main/assets/bunshine-logo.png?v=3.0.0-rc.1" width="200" height="187" /> | ||
<img alt="Bunshine Logo" src="https://github.com/kensnyder/bunshine/raw/main/assets/bunshine-logo.png?v=3.0.0-rc.2" width="200" height="187" /> | ||
[](https://npmjs.com/package/bunshine) | ||
[](https://github.com/search?q=repo:kensnyder/bunshine++language:TypeScript&type=code) | ||
 | ||
[](https://bundlephobia.com/package/bunshine@3.0.0-rc.1) | ||
[](https://www.npmjs.com/package/bunshine?activeTab=dependencies) | ||
[](https://www.npmjs.com/package/bunshine) | ||
[](https://opensource.org/licenses/ISC) | ||
[](https://npmjs.com/package/bunshine) | ||
[](https://github.com/search?q=repo:kensnyder/bunshine++language:TypeScript&type=code) | ||
 | ||
[](https://www.npmjs.com/package/bunshine?activeTab=dependencies) | ||
 | ||
[](https://opensource.org/licenses/ISC) | ||
@@ -28,12 +27,13 @@ ## Installation | ||
1. Use bare `Request` and `Response` objects | ||
2. Support for routing `WebSocket` requests | ||
3. Support for Server Sent Events | ||
4. Support ranged file downloads (e.g. for video streaming) | ||
5. Be very lightweight | ||
6. Treat every handler like middleware | ||
7. Support async handlers | ||
8. Provide common middleware out of the box (cors, prodLogger, headers, compression, etags) | ||
9. Support traditional routing syntax | ||
10. Make specifically for Bun | ||
11. Comprehensive unit tests | ||
2. Integrated support for routing `WebSocket` requests | ||
3. Integrated support for _Server Sent Events_ | ||
4. Support _ranged file downloads_ (e.g. for video streaming) | ||
5. Be very _lightweight_ | ||
6. _Elegantly_ treat every handler like middleware | ||
7. Support _async handlers_ | ||
8. Provide _common middleware_ out of the box (cors, prodLogger, headers, | ||
compression, etags) | ||
9. Support _traditional routing_ syntax | ||
10. Make specifically for _Bun_ | ||
11. Comprehensive _unit tests_ | ||
12. Support for `X-HTTP-Method-Override` header | ||
@@ -61,13 +61,16 @@ | ||
- [etags](#etags) | ||
- [Recommended Middleware](#recommended-middleware) | ||
11. [TypeScript pro-tips](#typescript-pro-tips) | ||
12. [Roadmap](#roadmap) | ||
13. [License](./LICENSE.md) | ||
12. [Design Decisions](#design-decisions) | ||
13. [Roadmap](#roadmap) | ||
14. [ISC License](./LICENSE.md) | ||
## Upgrading from 1.x to 2.x | ||
RegExp symbols are not allowed in route definitions to avoid ReDoS vulnerabilities. | ||
`RegExp` symbols are not allowed in route definitions to avoid ReDoS | ||
vulnerabilities. | ||
## Upgrading from 2.x to 3.x | ||
- The `securityHeaders` middleware has been dropped. Use a library such as | ||
- The `securityHeaders` middleware has been discontinued. Use a library such as | ||
[@side/fortifyjs](https://www.npmjs.com/package/@side/fortifyjs) instead. | ||
@@ -113,3 +116,4 @@ - The `serveFiles` middleware no longer accepts options for `etags` or `gzip`. | ||
app.on404(c => { | ||
app.onNotFound(c => { | ||
// alias: on404 | ||
// called when no handlers match the requested path | ||
@@ -119,3 +123,4 @@ return c.text('Page Not found', { status: 404 }); | ||
app.on500(c => { | ||
app.onError(c => { | ||
// alias: on500 | ||
// called when a handler throws an error | ||
@@ -174,3 +179,3 @@ console.error('500', c.error); | ||
c.locals; // A place to persist data between handlers for the duration of the request | ||
c.error; // An error object available to handlers registered with app.on500() | ||
c.error; // An error object available to handlers registered with app.onError() | ||
c.ip; // The IP address of the client or load balancer (not necessarily the end user) | ||
@@ -212,3 +217,3 @@ c.date; // The date of the request | ||
Also note you can serve files with bunshine anywhere with `bunx bunshine serve`. | ||
Also note you can serve files with Bunshine anywhere with `bunx bunshine-serve`. | ||
It currently uses the default `serveFiles()` options. | ||
@@ -230,5 +235,4 @@ | ||
app.use(c => { | ||
if (!isAllowed(c.request.headers.get('Authorization'))) { | ||
// redirect instead of running other middleware or handlers | ||
return c.redirect('/login', { status: 403 }); | ||
if (isBot(c.request.headers.get('User-Agent'))) { | ||
return c.text('Bots are forbidden', { status: 403 }); | ||
} | ||
@@ -238,3 +242,3 @@ // continue to other handlers | ||
// Run after each request | ||
// Run code after each request | ||
app.use(async (c, next) => { | ||
@@ -251,3 +255,3 @@ // wait for response from other handlers | ||
// Run before AND after each request | ||
// Run code before AND after each request | ||
app.use(async (c, next) => { | ||
@@ -261,7 +265,8 @@ logRequest(c.request); | ||
// Middleware at a certain path | ||
app.get('/admin', c => { | ||
const requireAdmin: Middleware = c => { | ||
if (!isAdmin(c.request.headers.get('Authorization'))) { | ||
return c.redirect('/login', { status: 403 }); | ||
} | ||
}); | ||
}; | ||
app.get('/admin', requireAdmin); | ||
@@ -286,2 +291,18 @@ // Middleware before a given handler (as args) | ||
// define a handler function to be used in multiple places | ||
const ensureSafeData = async (_, next) => { | ||
const unsafeResponse = await next(); | ||
const text = await unsafeResponse.text(); | ||
const scrubbed = scrubSensitiveData(text); | ||
return new Response(scrubbed, { | ||
headers: unsafeResponse.headers, | ||
status: unsafeResponse.status, | ||
statusText: unsafeResponse.statusText, | ||
}); | ||
}; | ||
// all routes that start with /api will get ensureSafeData applied | ||
app.get('/api/*', ensureSafeData); | ||
app.get('/api/v1/users/:id', getUser); | ||
app.listen({ port: 3100, reusePort: true }); | ||
@@ -296,3 +317,3 @@ ``` | ||
app.get('/users/me', handler1); | ||
app.get('/users/:id', handler2); // runs only if id is not "me" or handler1 doesn't respond | ||
app.get('/users/:id', handler2); // runs only if handler1 doesn't respond | ||
app.get('*', http404Handler); | ||
@@ -320,2 +341,6 @@ ``` | ||
}); | ||
app.get('/', async (c, next) => { | ||
console.log('never'); | ||
return c.text('Hello2'); | ||
}); | ||
// logs 1, 2, 3, 4, then 5 | ||
@@ -362,8 +387,9 @@ | ||
// ❌ Incorrect use of next | ||
// ❌ Incorrect use of next() | ||
app.get('/hello', (c: Context, next: NextFunction) => { | ||
const resp = next(); | ||
// oops! resp is a Promise | ||
}); | ||
// ✅ Correct use of next | ||
// ✅ Correct use of next() | ||
app.get('/hello', async (c: Context, next: NextFunction) => { | ||
@@ -376,4 +402,4 @@ // wait for other handlers to return a response | ||
And finally, it means that `.use()` is just a convenience function for | ||
registering middleware. Consider the following: | ||
And it means that `.use()` is just a convenience function for registering | ||
middleware. Consider the following: | ||
@@ -398,3 +424,3 @@ ```ts | ||
// middleware can be inserted with parameters | ||
// middleware can be inserted as parameters to app.get() | ||
app.get('/admin', getAuthMiddleware('admin'), middleware2, handler); | ||
@@ -437,2 +463,6 @@ | ||
Throwing a `Response` effectively skips the currently running handler/middleware | ||
and passes control to the next handler. That way thrown responses will be passed | ||
to subsequent middleware such as loggers. | ||
## WebSockets | ||
@@ -593,3 +623,3 @@ | ||
Creating an `EventSource` object will open a connection to the server, and if | ||
the server closes the connection, the browser will automatically reconnect. | ||
the server closes the connection, a browser will automatically reconnect. | ||
@@ -612,2 +642,3 @@ So if you want to tell the browser you are done sending events, send a | ||
const onComplete = () => { | ||
// Browser code will close connection when percent is 100 | ||
send('progress', { percent: 100 }); | ||
@@ -664,7 +695,7 @@ }; | ||
Due to a discovered | ||
[RegExp Denial of Service vulnerability](https://security.snyk.io/vuln/SNYK-JS-PATHTOREGEXP-7925106), | ||
Bunshine v2+ no longer uses | ||
[path-to-regexp docs](https://www.npmjs.com/package/path-to-regexp). | ||
[RegExp Denial of Service (ReDoS) vulnerability](https://security.snyk.io/vuln/SNYK-JS-PATHTOREGEXP-7925106) | ||
Bunshine v2+ no longer uses `path-to-regexp` because the new, safer version | ||
imposes disruptive limitations such as no support for unnamed wildcards. | ||
### Support | ||
### What is supported | ||
@@ -675,10 +706,10 @@ Bunshine supports the following route matching features: | ||
- End wildcards using stars (e.g. `/assets/*`) | ||
- Middle non-slash wildcards using stars (e.g. `/assets/*/*.css`) | ||
- Middle (non-slash) wildcards using stars (e.g. `/assets/*/*.css`) | ||
- Static paths (e.g. `/posts`) | ||
Support for other behaviors can lead to a Regular Expression Denial of service | ||
vulnerability where an attacker can request long URLs and tie up your server | ||
CPU with backtracking regular expression searches. | ||
Support for RegExp symbols such as `\d+` can lead to a Regular Expression Denial | ||
of Service (ReDoS) vulnerability where an attacker can request long URLs and | ||
tie up your server CPU with backtracking regular-expression searches. | ||
### Path examples | ||
### Supported path examples | ||
@@ -696,14 +727,16 @@ | Path | URL | params | | ||
### Special Characters | ||
### Special characters are not supported | ||
Note that all regular-expression special characters including | ||
`\ ^ $ * + ? . ( ) | { } [ ]` will be escaped. If you need any of these | ||
behaviors, you'll need to pass in a `RegExp`. | ||
behaviors, you'll need to pass in a `RegExp`. But be sure to check your | ||
`RegExp` with a ReDoS such as [Devina](https://devina.io/redos-checker) or | ||
[redos-checker on npm](https://www.npmjs.com/package/redos-detector). | ||
For example, the dot in `/assets/*.js` will not match all characters--only dots. | ||
### Not supported | ||
### Examples of unsupported routes | ||
Support for regex-like syntax has been dropped in v2 due to the aforementioned | ||
[RegExp Denial of Service vulnerability](https://security.snyk.io/vuln/SNYK-JS-PATHTOREGEXP-7925106). | ||
Support for regex-like syntax has been dropped in Bunshine v2 due to the | ||
aforementioned RegExp Denial of Service (ReDoS) vulnerability. | ||
For cases where you need to limit by character or specify optional segments, | ||
@@ -714,10 +747,11 @@ you'll need to pass in a `RegExp`. Be sure to check your `RegExp` with a ReDoS | ||
| Example | Explaination | Equivalent RegExp | | ||
| ------------------- | ----------------------------------------- | ------------------------ | | ||
| `/users/([a-z-]+)/` | Character classes are not supported | `^\/users\/([a-z-]+)$` | | ||
| `/users/(\\d+)` | Character class escapes are not supported | `^/\/users\/(\d+)$` | | ||
| `/(users\|u)/:id` | Pipes are not supported | `^\/(users\|u)/([^/]+)$` | | ||
| `/:a/:b?` | Optional params are not supported | `^\/([^/]*)\/(.*)$` | | ||
| Example | Explaination | Equivalent Safe RegExp | | ||
| ---------------------- | ----------------------------------------- | ------------------------ | | ||
| `/users/([a-z-]+)/` ❌ | Character classes are not supported | `^\/users\/([a-z-]+)$` | | ||
| `/users/(\\d+)` ❌ | Character class escapes are not supported | `^/\/users\/(\d+)$` | | ||
| `/(users\|u)/:id` ❌ | Pipes are not supported | `^\/(users\|u)/([^/]+)$` | | ||
| `/:a/:b?` ❌ | Optional params are not supported | `^\/([^/]*)\/(.*)$` | | ||
If you want to double check all your routes, you can use code like the following: | ||
If you want to double-check all your routes, you can install `redos-detector` | ||
and use Bunshine's `detectPotentialDos` function: | ||
@@ -768,5 +802,5 @@ ```ts | ||
Serve static files from a directory. As shown above, serving static files is | ||
easy with the `serveFiles` middleware. Note that ranged requests are | ||
supported, so you can use it for video streaming or partial downloads. | ||
Serve static files from a directory. Note that ranged requests are | ||
supported, so you can use it to serve video streams and support partial | ||
downloads. | ||
@@ -790,5 +824,5 @@ ```ts | ||
app.headGet('/public/*', serveFiles(`${import.meta.dir}/public`)); | ||
// or | ||
app.on(['HEAD', 'GET'], '/public/*', serveFiles(`${import.meta.dir}/public`)); | ||
// or | ||
app.headGet('/public/*', serveFiles(`${import.meta.dir}/public`)); | ||
@@ -798,20 +832,2 @@ app.listen({ port: 3100, reusePort: true }); | ||
How to alter the response provided by another handler: | ||
```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, reusePort: true }); | ||
``` | ||
serveFiles accepts an optional second parameter for options: | ||
@@ -841,3 +857,3 @@ | ||
| dotfiles | `"ignore"` | How to handle dotfiles; allow=>serve normally, deny=>return 403, ignore=>run next handler | | ||
| extensions | `[]` | If given, a list of file extensions to allow | | ||
| extensions | `[]` | If given, a whitelist of file extensions to allow | | ||
| fallthrough | `true` | If false, issue a 404 when a file is not found, otherwise proceed to next handler | | ||
@@ -847,3 +863,3 @@ | maxAge | `undefined` | If given, add a Cache-Control header with max-age† | | ||
| 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 | | ||
| lastModified | `true` | If true, set the Last-Modified header based on the filesystem's last modified date | | ||
@@ -855,3 +871,3 @@ † _A number in milliseconds or expression such as '30min', '14 days', '1y'._ | ||
Simple caching can be accomplished with the `responseCache()` middleware. It | ||
saves responses to a cache you supply, based on URL. This can be useful for | ||
saves responses to a cache you supply, with URLs as keys. This can be useful for | ||
builds, where your assets aren't changing. In the example below, `lru-cache` is | ||
@@ -862,4 +878,4 @@ used to store assets in memory. Any cache that implements `has(url: string)`, | ||
Keep in mind that your `set()` function will receive a `Response` object and | ||
your `get()` function should be an object with a `clone()` method that returns | ||
a `Response` object. | ||
your `get()` function should return an object with a `clone()` method that | ||
returns a `Response` object. | ||
@@ -878,2 +894,6 @@ ```ts | ||
Note that caching in memory can potentially consume a lot of memory. Using a | ||
service such as CloudFlare removes the need for caching assets and takes a load | ||
off your application. | ||
### compression | ||
@@ -888,2 +908,6 @@ | ||
// compress all payloads | ||
app.use(compression()); | ||
// compress only certain payloads | ||
app.get('/public/*', compression(), serveFiles(`${import.meta.dir}/public`)); | ||
@@ -948,3 +972,4 @@ | ||
_origin_: A string, regex, array of strings/regexes, or a function that returns the desired origin header | ||
_origin_: A string, regex, array of strings/regexes, or a function that returns | ||
the desired origin header | ||
_allowMethods_: an array of HTTP verbs to allow clients to make | ||
@@ -954,3 +979,4 @@ _allowHeaders_: an array of HTTP headers to allow clients to send | ||
_maxAge_: the number of seconds clients should cache the CORS headers | ||
_credentials_: whether to allow clients to send credentials (e.g. cookies or auth headers) | ||
_credentials_: whether to allow clients to send credentials (e.g. cookies or | ||
auth headers) | ||
@@ -983,2 +1009,24 @@ ### headers | ||
You can also use pass a function as a second parameter to `headers`, to only | ||
apply | ||
the given headers under certain conditions. | ||
```ts | ||
import { HttpRouter, headers } from '../index'; | ||
const app = new HttpRouter(); | ||
const htmlSecurityHeaders = headers( | ||
{ | ||
'Content-Security-Policy': `default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;`, | ||
'Referrer-Policy': 'strict-origin', | ||
'Permissions-Policy': | ||
'accelerometer=(), ambient-light-sensor=(), autoplay=(*), battery=(), camera=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), local-fonts=(), magnetometer=(), midi=(), payment=(), picture-in-picture=(), publickey-credentials-create=(), publickey-credentials-get=(), screen-wake-lock=(), serial=(), usb=(), web-share=(self)', | ||
}, | ||
(c, response) => response.headers.get('content-type')?.includes('text/html') | ||
); | ||
app.use(htmlSecurityHeaders); | ||
``` | ||
### devLogger & prodLogger | ||
@@ -1009,5 +1057,5 @@ | ||
"runtime": "Bun v1.1.33", | ||
"poweredBy": "Bunshine v3.0.0-rc.1", | ||
"poweredBy": "Bunshine v3.0.0-rc.2", | ||
"machine": "server1", | ||
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0-rc.1.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-rc.2.0 Safari/537.36", | ||
"pid": 123 | ||
@@ -1029,5 +1077,5 @@ } | ||
"runtime": "Bun v1.1.3", | ||
"poweredBy": "Bunshine v3.0.0-rc.1", | ||
"poweredBy": "Bunshine v3.0.0-rc.2", | ||
"machine": "server1", | ||
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0-rc.1.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-rc.2.0 Safari/537.36", | ||
"pid": 123, | ||
@@ -1048,5 +1096,7 @@ "took": 5 | ||
const logger = process.env.NODE_ENV === 'development' ? devLogger : prodLogger; | ||
// attach very first to log all requests | ||
app.use(logger()); | ||
// or at a specific path | ||
app.use('/api/*', logger()); | ||
// OR attach only to a specific path | ||
app.get('/api/*', logger()); | ||
api.get('/api/users/:id', getUser); | ||
@@ -1088,2 +1138,22 @@ app.listen({ port: 3100, reusePort: true }); | ||
### Recommended Middleware | ||
Most applications will want a full-featured set of middleware. Below is the | ||
recommended middleware in order. | ||
```ts | ||
import { HttpRouter, compression, etags, performanceHeader } from 'bunshine'; | ||
const app = new HttpRouter(); | ||
// add total execution time in milliseconds | ||
app.use(performanceHeader); | ||
// log all requests | ||
app.use(process.env.NODE_ENV === 'development' ? devLogger() : prodLogger()); | ||
// use etag headers | ||
app.use(etags()); | ||
// compress all payloads | ||
app.use(compression()); | ||
``` | ||
## TypeScript pro-tips | ||
@@ -1096,3 +1166,3 @@ | ||
You can type URL params by passing a type to any of the route methods: | ||
You can specify URL param types by passing a type to any of the route methods: | ||
@@ -1162,5 +1232,7 @@ ```ts | ||
```ts | ||
import { HttpRouter, type Middleware } from 'bunshine'; | ||
function myMiddleware(options: Options): Middleware { | ||
return (c, next) => { | ||
// TypeScript infers c and next because of Middleware | ||
// TypeScript infers c and next because of Middleware type | ||
}; | ||
@@ -1173,4 +1245,3 @@ } | ||
```ts | ||
import { HttpRouter } from 'bunshine'; | ||
import { Middleware } from './HttpRouter'; | ||
import { HttpRouter, type Middleware } from 'bunshine'; | ||
@@ -1230,2 +1301,3 @@ const app = new HttpRouter(); | ||
const authValue = c.request.headers.get('Authorization'); | ||
// subsequent handler will have access to this auth information | ||
c.locals.auth = { | ||
@@ -1248,2 +1320,3 @@ identity: await getUser(authValue), | ||
} | ||
app.post('/api/users', castSchema(userCreateSchema), createUser); | ||
@@ -1254,16 +1327,16 @@ | ||
// do stuff with url and request | ||
return text('my json response'); | ||
return json({ message: 'my json response' }); | ||
}); | ||
``` | ||
## Decisions | ||
## Design Decisions | ||
The following decisions are based on scripts in /benchmarks: | ||
- bound-functions.ts - The Context object created for each request has its | ||
- `bound-functions.ts` - The Context object created for each request has its | ||
methods automatically bound to the instance. It is convenient for developers | ||
and adds only a tiny overhead. | ||
- inner-functions.ts - The Context is a class, not a set of functions in an | ||
enclosure which saves about 3% of time. | ||
- compression.ts - gzip is the default preferred format for the compression | ||
- `inner-functions.ts` - Context is a class, not a set of functions in a | ||
closure, which saves about 3% of time. | ||
- `compression.ts` - gzip is the default preferred format for the compression | ||
middleware. Deflate provides no advantage, and Brotli provides 2-8% additional | ||
@@ -1273,15 +1346,15 @@ size savings at the cost of 7-10x as much CPU time as gzip. Brotli takes on | ||
for gzip. | ||
- etags - etag calculation is very fast. On the order of tens of microseconds | ||
for 100kb of html. | ||
- lru-matcher.ts - The default LRU cache size used for the router is 4000. | ||
- `etags.ts` - etag calculation is very fast. On the order of tens of | ||
microseconds for 100kb of html. | ||
- `lru-matcher.ts` - The default LRU cache size used for the router is 4000. | ||
Cache sizes of 4000+ are all about 1.4x faster than no cache. | ||
- response-reencoding.ts - Both the etags middleware and compression middleware | ||
convert the response body to an ArrayBuffer, process it, then create a new | ||
Response object. The decode/reencode process takes only 10s of microseconds. | ||
- TextEncoder-reuse.ts - The Context object's response factories (c.json(), | ||
c.html(), etc.) reuse a single TextEncoder object. That gains about 18% which | ||
turns out to be only on the order of 10s of nanoseconds. | ||
- timer-resolution.ts - performance.now() is faster than Date.now() even though | ||
it provides additional precision. The performanceHeader uses performance.now() | ||
when it sets the X-Took header, which is rounded to 3 decimal places. | ||
- `response-reencoding.ts` - Both the etags middleware and compression | ||
middleware convert the response body to an ArrayBuffer, process it, then create a new Response object. The decode/reencode process takes only 10s of microseconds. | ||
- `TextEncoder-reuse.ts` - The Context object's response factories (c.json(), | ||
c.html(), etc.) reuse a single `TextEncoder` object. That gains about 18% | ||
which turns out to be only on the order of 10s of nanoseconds. | ||
- `timer-resolution.ts` - `performance.now()` is faster than `Date.now()` even | ||
though it provides additional precision. The `performanceHeader()` middleware | ||
uses `performance.now()` when it sets the `X-Took` header with the number of | ||
milliseconds rounded to 3 decimal places. | ||
@@ -1293,4 +1366,15 @@ Some additional design decisions: | ||
multiple handlers and running each one in order of registration. Bunshine v1 | ||
did use `path-to-regexp`, but that recently stopped supporting `*` in route | ||
registration. | ||
did use `path-to-regexp`, but that recently stopped supporting many common | ||
route definitions. | ||
- Handlers must use `Request` and `Response` objects. We all work with these | ||
modern APIs every time we use `fetch`. Cloudflare Workers, Deno, Bun, and | ||
other runtimes decided to use bare `Request` and `Response` objects for their | ||
standard libraries. Someday Node may add support too. Frameworks like Express | ||
and Hono are nice, but take some learning to understand their proprietary APIs | ||
for creating responses, sending responses, and setting headers. | ||
- Handlers receive a raw `URL` object. `URL` objects are a fast and standardized | ||
way to parse incoming URLs. The router only needs to know the path, and so | ||
does no other processing for query parameters, ports and so on. There are many | ||
approaches to parse query parameters; Bunshine is agnostic. Use | ||
`c.url.searchParams` however you like. | ||
@@ -1297,0 +1381,0 @@ ## Roadmap |
@@ -19,6 +19,2 @@ import type { BunFile, Server } from 'bun'; | ||
export type ContextWithError< | ||
ParamsShape extends Record<string, string> = Record<string, string>, | ||
> = Context<ParamsShape> & { error: Error }; | ||
export default class Context< | ||
@@ -25,0 +21,0 @@ ParamsShape extends Record<string, string> = Record<string, string>, |
@@ -1,4 +0,4 @@ | ||
import type { ContextWithError } from '../Context/Context'; | ||
import type Context from '../Context/Context'; | ||
export const fallback500 = (context: ContextWithError) => { | ||
export const fallback500 = (context: Context) => { | ||
const error = context.error; | ||
@@ -8,4 +8,4 @@ const headers = new Headers(); | ||
if (Bun.env.NODE_ENV === 'development') { | ||
const message = error.message || String(error); | ||
const stack = error.stack || 'N/A'; | ||
const message = error ? error.message || String(error) : 'Unknown Error'; | ||
const stack = error?.stack || 'N/A'; | ||
headers.append('Reason', 'Error was not handled'); | ||
@@ -12,0 +12,0 @@ headers.append('Error-Text', JSON.stringify(message)); |
import type { ServeOptions, Server } from 'bun'; | ||
import os from 'node:os'; | ||
import bunshine from '../../package.json' assert { type: 'json' }; | ||
import Context, { type ContextWithError } from '../Context/Context'; | ||
import bunshinePkg from '../../package.json' assert { type: 'json' }; | ||
import Context from '../Context/Context'; | ||
import MatcherWithCache from '../MatcherWithCache/MatcherWithCache'; | ||
@@ -19,9 +19,2 @@ import SocketRouter from '../SocketRouter/SocketRouter'; | ||
export type SingleErrorHandler< | ||
ParamsShape extends Record<string, string> = Record<string, string>, | ||
> = ( | ||
context: ContextWithError<ParamsShape>, | ||
next: NextFunction | ||
) => Response | void | Promise<Response | void>; | ||
export type Middleware< | ||
@@ -35,6 +28,2 @@ ParamsShape extends Record<string, string> = Record<string, string>, | ||
export type ErrorHandler< | ||
ParamsShape extends Record<string, string> = Record<string, string>, | ||
> = SingleErrorHandler<ParamsShape> | ErrorHandler<ParamsShape>[]; | ||
export type ListenOptions = Omit<ServeOptions, 'fetch' | 'websocket'> | number; | ||
@@ -64,3 +53,3 @@ | ||
export default class HttpRouter { | ||
version: string = bunshine.version; | ||
version: string = bunshinePkg.version; | ||
locals: Record<string, any> = {}; | ||
@@ -70,4 +59,6 @@ server: Server | undefined; | ||
_wsRouter?: SocketRouter; | ||
private _onErrors: any[] = []; | ||
private _on404s: any[] = []; | ||
onNotFound: typeof this.on404; | ||
onError: typeof this.on500; | ||
private _on404Handlers: SingleHandler[] = []; | ||
private _on500Handlers: SingleHandler[] = []; | ||
constructor(options: HttpRouterOptions = {}) { | ||
@@ -77,2 +68,4 @@ this.routeMatcher = new MatcherWithCache<SingleHandler>( | ||
); | ||
this.onNotFound = this.on404; | ||
this.onError = this.on500; | ||
} | ||
@@ -106,3 +99,3 @@ listen(portOrOptions: ListenOptions = {}) { | ||
: `Node v${process.versions.node}`; | ||
message = `☀️ Bunshine v${bunshine.version} on ${runtime} serving at ${servingAt} on "${server}" in ${mode} (${took}ms)`; | ||
message = `☀️ Bunshine v${bunshinePkg.version} on ${runtime} serving at ${servingAt} on "${server}" in ${mode} (${took}ms)`; | ||
} else { | ||
@@ -213,8 +206,8 @@ message = `☀️ Serving ${servingAt}`; | ||
} | ||
onError(...handlers: ErrorHandler<Record<string, string>>[]) { | ||
this._onErrors.push(...handlers.flat(9)); | ||
on404(...handlers: SingleHandler<Record<string, string>>[]) { | ||
this._on404Handlers.push(...handlers.flat(9)); | ||
return this; | ||
} | ||
on404(...handlers: Handler<Record<string, string>>[]) { | ||
this._on404s.push(...handlers.flat(9)); | ||
on500(...handlers: SingleHandler<Record<string, string>>[]) { | ||
this._on500Handlers.push(...handlers.flat(9)); | ||
return this; | ||
@@ -228,3 +221,7 @@ } | ||
).toUpperCase(); | ||
const matched = this.routeMatcher.match(method, pathname, this._on404s); | ||
const matched = this.routeMatcher.match( | ||
method, | ||
pathname, | ||
this._on404Handlers | ||
); | ||
let i = 0; | ||
@@ -252,4 +249,3 @@ const next: NextFunction = async () => { | ||
} catch (e) { | ||
// @ts-expect-error | ||
return errorHandler(e); | ||
return errorHandler(e as Error); | ||
} | ||
@@ -265,5 +261,5 @@ }; | ||
const nextError: NextFunction = async () => { | ||
const handler = this._onErrors[idx++]; | ||
const handler = this._on500Handlers[idx++]; | ||
if (!handler) { | ||
return fallback500(context as ContextWithError); | ||
return fallback500(context); | ||
} | ||
@@ -270,0 +266,0 @@ try { |
@@ -21,5 +21,7 @@ import type Context from '../../Context/Context'; | ||
type OriginResolver = (incoming: string, c: Context) => string | null; | ||
export const corsDefaults = { | ||
origin: '*', | ||
allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'PATCH', 'DELETE'], | ||
allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], | ||
allowHeaders: [], | ||
@@ -34,48 +36,65 @@ exposeHeaders: [], | ||
}; | ||
const findAllowOrigin = (optsOrigin => { | ||
if (typeof optsOrigin === 'string') { | ||
return () => optsOrigin; | ||
} else if (optsOrigin instanceof RegExp) { | ||
return (incoming: string) => | ||
optsOrigin.test(incoming) ? incoming : null; | ||
} else if (Array.isArray(optsOrigin)) { | ||
return (incoming: string) => { | ||
for (const origin of optsOrigin) { | ||
if ( | ||
(typeof origin === 'string' && origin === incoming) || | ||
(origin instanceof RegExp && origin.test(incoming)) | ||
) { | ||
return incoming; | ||
} | ||
const originResolver = getOriginResolver(opts.origin); | ||
const optionsRequestHandler = getOptionsRequestHandler(opts, originResolver); | ||
const maybeAddAccessHeaders = getAccessHeaderHandler(opts, originResolver); | ||
return async (c, next) => { | ||
if (c.request.method === 'OPTIONS') { | ||
return optionsRequestHandler(c); | ||
} | ||
const response = await next(); | ||
maybeAddAccessHeaders(c, response); | ||
return response; | ||
}; | ||
} | ||
function getOriginResolver(optsOrigin: CorsOptions['origin']): OriginResolver { | ||
if (typeof optsOrigin === 'string') { | ||
return () => optsOrigin; | ||
} else if (optsOrigin instanceof RegExp) { | ||
return (incoming: string) => (optsOrigin.test(incoming) ? incoming : null); | ||
} else if (Array.isArray(optsOrigin)) { | ||
return (incoming: string) => { | ||
for (const origin of optsOrigin) { | ||
if ( | ||
(typeof origin === 'string' && origin === incoming) || | ||
(origin instanceof RegExp && origin.test(incoming)) | ||
) { | ||
return incoming; | ||
} | ||
} | ||
return null; | ||
}; | ||
} else if (optsOrigin === true) { | ||
return (incoming: string) => incoming; | ||
} else if (optsOrigin === false) { | ||
return () => null; | ||
} else if (typeof optsOrigin === 'function') { | ||
return (incoming: string, c: Context) => { | ||
const origins = optsOrigin(incoming, c); | ||
if (origins === true) { | ||
return incoming; | ||
} else if (origins === false) { | ||
return null; | ||
}; | ||
} else if (optsOrigin === true) { | ||
return (incoming: string) => incoming; | ||
} else if (optsOrigin === false) { | ||
return () => null; | ||
} else if (typeof optsOrigin === 'function') { | ||
return (incoming: string, c: Context) => { | ||
const origins = optsOrigin(incoming, c); | ||
if (origins === true) { | ||
return incoming; | ||
} else if (origins === false) { | ||
return null; | ||
} else if (Array.isArray(origins)) { | ||
return origins.includes(incoming) ? incoming : null; | ||
} else if (typeof origins === 'string') { | ||
return origins; | ||
} else { | ||
return null; | ||
} | ||
}; | ||
} else { | ||
throw new Error('Invalid cors origin option'); | ||
} | ||
})(opts.origin); | ||
function handleOptionsRequest(c: Context) { | ||
} else if (Array.isArray(origins)) { | ||
return origins.includes(incoming) ? incoming : null; | ||
} else if (typeof origins === 'string') { | ||
return origins; | ||
} else { | ||
return null; | ||
} | ||
}; | ||
} else { | ||
throw new Error('Invalid cors origin option'); | ||
} | ||
} | ||
function getOptionsRequestHandler( | ||
opts: CorsOptions, | ||
originResolver: OriginResolver | ||
) { | ||
return function handleOptionsRequest(c: Context) { | ||
const respHeaders = new Headers(); | ||
const incomingOrigin = c.request.headers.get('origin'); | ||
const allowOrigin = incomingOrigin | ||
? findAllowOrigin(incomingOrigin, c) | ||
? originResolver(incomingOrigin, c) | ||
: null; | ||
@@ -85,3 +104,3 @@ if (allowOrigin) { | ||
} else { | ||
// TODO: find out if we should send a 4xx instead? | ||
return new Response(null, { status: 403 }); | ||
} | ||
@@ -110,14 +129,17 @@ if (opts.maxAge != null) { | ||
} | ||
// TODO: is this deletion necessary? | ||
respHeaders.delete('Content-Length'); | ||
respHeaders.delete('Content-Type'); | ||
return new Response(null, { | ||
headers: respHeaders, | ||
status: 204, | ||
status: 200, | ||
}); | ||
} | ||
function maybeAddAccessHeaders(c: Context, response: Response) { | ||
}; | ||
} | ||
function getAccessHeaderHandler( | ||
opts: CorsOptions, | ||
originResolver: OriginResolver | ||
) { | ||
return function maybeAddAccessHeaders(c: Context, response: Response) { | ||
const incomingOrigin = c.request.headers.get('origin'); | ||
const allowOrigin = incomingOrigin | ||
? findAllowOrigin(incomingOrigin, c) | ||
? originResolver(incomingOrigin, c) | ||
: null; | ||
@@ -139,11 +161,3 @@ if (allowOrigin) { | ||
} | ||
} | ||
return async (c, next) => { | ||
if (c.request.method === 'OPTIONS') { | ||
return handleOptionsRequest(c); | ||
} | ||
const response = await next(); | ||
maybeAddAccessHeaders(c, response); | ||
return response; | ||
}; | ||
} |
135888
34
2273
1366