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

Installation
bun add bunshine
Or to run Bunshine on Node,
install Nodeshine.
Motivation
- Use bare
Request
and Response
objects - Support for routing
WebSocket
requests - Support for Server Sent Events
- Support ranged file downloads (e.g. for video streaming)
- Be very lightweight
- Treat every handler like middleware
- Support async handlers
- Provide common middleware out of the box (cors, prodLogger, headers, compression, etags)
- Support traditional routing syntax
- Make specifically for Bun
- Comprehensive unit tests
- Support for
X-HTTP-Method-Override
header
Table of Contents
- Basic example
- Full example
- Serving static files
- Writing middleware
- Throwing responses
- WebSockets
- WebSocket pub-sub
- Server Sent Events
- Route Matching
- Included middleware
- TypeScript pro-tips
- Roadmap
- License
Upgrading from 1.x to 2.x
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
@side/fortifyjs instead. - The
serveFiles
middleware no longer accepts options for etags
or gzip
.
Instead, compose the etags
and compression
middlewares:
app.headGet('/files/*', etags(), compression(), serveFiles(...))
Basic example
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
app.get('/', c => {
return new Response('Hello at ' + c.url.pathname);
});
app.listen({ port: 3100, reusePort: true });
Full example
import { HttpRouter, redirect, compression } from 'bunshine';
const app = new HttpRouter();
app.use(compresion());
app.patch('/users/:id', async c => {
await authorize(c.request.headers.get('Authorization'));
const data = await c.request.json();
const result = await updateUser(params.id, data);
if (result === 'not found') {
return c.json({ error: 'User not found' }, { status: 404 });
} else if (result === 'error') {
return c.json({ error: 'Error updating user' }, { status: 500 });
} else {
return c.json({ error: false });
}
});
app.on404(c => {
return c.text('Page Not found', { status: 404 });
});
app.on500(c => {
console.error('500', c.error);
return c.json({ error: 'Internal server error' }, { status: 500 });
});
app.listen({ port: 3100, reusePort: true });
function authorize(authHeader: string) {
if (!authHeader) {
throw redirect('/login');
} else if (!jwtVerify(authHeader)) {
throw redirect('/not-allowed');
}
}
You can also make a path-specific error catcher like this:
import { HttpRouter, redirect, compression } from 'bunshine';
const app = new HttpRouter();
app.get('/api/*', async (c, next) => {
try {
return await next();
} catch (e) {
}
});
app.get('/api/v1/posts', handler);
What is c
here?
c
is a Context
object that contains the request and params.
import { HttpRouter, type Context, type NextFunction } from 'bunshine';
const app = new HttpRouter();
app.get('/hello', (c: Context, next: NextFunction) => {
c.request;
c.url;
c.params;
c.server;
c.app;
c.locals;
c.error;
c.ip;
c.date;
c.now;
c.json(data, init);
c.text(text, init);
c.js(jsText, init);
c.xml(xmlText, init);
c.html(htmlText, init);
c.css(cssText, init);
c.file(path, init);
c.redirect(url, status);
});
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
downloads.
import { HttpRouter, serveFiles } from 'bunshine';
const app = new HttpRouter();
app.get('/public/*', serveFiles(`${import.meta.dir}/public`));
app.listen({ port: 3100, reusePort: true });
See the serveFiles section for more info.
Also note you can serve files with bunshine anywhere with bunx bunshine serve
.
It currently uses the default serveFiles()
options.
Writing middleware
Here are more examples of attaching middleware.
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
app.get('/healthcheck', c => c.text('200 OK'));
app.use(c => {
if (!isAllowed(c.request.headers.get('Authorization'))) {
return c.redirect('/login', { status: 403 });
}
});
app.use(async (c, next) => {
const resp = await next();
if (resp.status === 403) {
logThatUserWasForbidden(c.request.url);
}
return resp;
});
app.use(async (c, next) => {
logRequest(c.request);
const resp = await next();
logResponse(resp);
return resp;
});
app.get('/admin', c => {
if (!isAdmin(c.request.headers.get('Authorization'))) {
return c.redirect('/login', { status: 403 });
}
});
app.get('/users/:id', paramValidationMiddleware, async c => {
const user = await getUser(c.params.id);
return c.json(user);
});
app.get('/users/:id', [
paramValidationMiddleware({ id: zod.number() }),
async c => {
const user = await getUser(c.params.id);
return c.json(user);
},
]);
app.get('/', c => c.text('Hello World!'));
app.listen({ port: 3100, reusePort: true });
Note that because every handler is treated like middleware,
you must register handlers in order of desired specificity. For example:
app.get('/users/me', handler1);
app.get('/users/:id', handler2);
app.get('*', http404Handler);
And to illustrate the wrap-like behavior of await
ing the next
function:
app.get('/', async (c, next) => {
console.log(1);
const resp = await next();
console.log(5);
return resp;
});
app.get('/', async (c, next) => {
console.log(2);
const resp = await next();
console.log(4);
return resp;
});
app.get('/', async (c, next) => {
console.log(3);
return c.text('Hello');
});
app.get('/', runs1stAnd5th, runs2ndAnd4th, runs3rd);
What does it mean that "every handler is treated like middleware"?
If a handler does not return a Response
object or return a promise that does
not resolve to a Response
object, then the next matching handler will be
called. Consider the following:
import { HttpRouter, type Context, type NextFunction } from 'bunshine';
const app = new HttpRouter();
app.get('/hello', (c: Context, next: NextFunction) => {
setTimeout(() => {
next(new Response('Hello World!'));
}, 1000);
});
app.get('/hello', async (c: Context) => {
return new Promise(resolve => {
setTimeout(() => {
resolve(new Response('Hello World!'));
}, 1000);
});
});
It also means that the next()
function is async. Consider the following:
import { HttpRouter, type Context, type NextFunction } from 'bunshine';
const app = new HttpRouter();
app.get('/hello', (c: Context, next: NextFunction) => {
const resp = next();
});
app.get('/hello', async (c: Context, next: NextFunction) => {
const resp = await next();
});
And finally, it means that .use()
is just a convenience function for
registering middleware. Consider the following:
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
app.use(middlewareHandler);
app.all('*', middlewareHandler);
This all-handlers-are-middleware behavior complements the way that handlers
and middleware can be registered. Consider the following:
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
app.get('/admin', getAuthMiddleware('admin'), middleware2, handler);
app.get('/posts', middleware1, middleware2, handler);
app.get('/users', [middleware1, middleware2, handler]);
app.get('/visitors', [[middleware1, [middleware2, handler]]]);
Throwing responses
You can throw a Response
object from anywhere in your code to send a response.
Here is an example:
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
async function checkPermission(request: Request, action: string) {
const authHeader = request.headers.get('Authorization');
if (!(await hasPermission(authHeader, action))) {
throw c.redirect('/home');
} else if (hasTooManyRequests(authHeader)) {
throw c.json({ error: 'Too many requests' }, { status: 429 });
}
}
app.post('/posts', async c => {
await checkPermissions(c.request, 'create-post');
});
app.listen({ port: 3100, reusePort: true });
WebSockets
Setting up websockets at various paths is easy with the socket
property.
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
app.get('/', c => c.text('Hello World!'));
type ParamsShape = { room: string };
type DataShape = { user: User };
app.socket.at<ParmasShape, DataShape>('/games/rooms/:room', {
upgrade: sc => {
const cookies = sc.request.headers.get('cookie');
const user = getUserFromCookies(cookies);
return { user };
},
error: (sc, error) => {
console.log('WebSocket error', error.message);
},
open(sc) {
const room = sc.params.room;
const user = sc.data.user;
markUserEntrance(room, user);
ws.send(getGameState(room));
},
message(sc, message) {
const room = sc.params.room;
const user = sc.data.user;
const result = saveMove(room, user, message.json());
ws.send(result);
},
close(sc, code, message) {
const room = sc.params.room;
const user = sc.data.user;
markUserExit(room, user);
},
});
app.listen({ port: 3100, reusePort: true });
const gameRoom = new WebSocket('ws://localhost:3100/games/rooms/1?user=42');
gameRoom.onmessage = e => {
const data = JSON.parse(e.data);
if (data.type === 'GameState') {
setGameState(data);
} else if (data.type === 'GameMove') {
playMove(data);
}
};
gameRoom.onerror = handleGameError;
gameRoom.send(JSON.stringify({ type: 'GameMove', move: 'rock' }));
WebSocket pub-sub
And WebSockets make it super easy to create a pub-sub system with no external
dependencies.
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
app.get('/', c => c.text('Hello World!'));
type ParamsShape = { room: string };
type DataShape = { username: string };
app.socket.at<ParamsShape, DataShape>('/chat/:room', {
upgrade: c => {
const cookies = c.request.headers.get('cookie');
const username = getUsernameFromCookies(cookies);
return { username };
},
open(sc) {
const msg = `${sc.data.username} has entered the chat`;
sc.subscribe(`chat-room-${sc.params.room}`);
sc.publish(`chat-room-${sc.params.room}`, msg);
},
message(sc, message) {
const fullMessage = `${sc.data.username}: ${message}`;
sc.publish(`chat-room-${sc.params.room}`, fullMessage);
sc.send(fullMessage);
},
close(sc, code, message) {
const msg = `${sc.data.username} has left the chat`;
sc.publish(`chat-room-${sc.params.room}`, msg);
sc.unsubscribe(`chat-room-${sc.params.room}`);
},
});
const server = app.listen({ port: 3100, reusePort: true });
server.publish(channel, message);
Server-Sent Events
Server-Sent Events (SSE) are similar to WebSockets, but one way. The server can
send messages, but the client cannot. This is useful for streaming data to the
browser.
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
app.get<{ symbol: string }>('/stock/:symbol', c => {
const symbol = c.params.symbol;
return c.sse(send => {
setInterval(async () => {
const data = await getPriceData(symbol);
send('price', { gain: data.gain, price: data.price });
}, 6000);
});
});
app.listen({ port: 3100, reusePort: true });
const livePrice = new EventSource('http://localhost:3100/stock/GOOG');
livePrice.addEventListener('price', e => {
const { gain, price } = JSON.parse(e.data);
document.querySelector('#stock-GOOG-gain').innerText = gain;
document.querySelector('#stock-GOOG-price').innerText = price;
});
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, the browser will automatically reconnect.
So if you want to tell the browser you are done sending events, send a
message that your client-side code will understand to mean "stop listening".
Here is an example:
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
app.get<{ videoId: string }>('/convert-video/:videoId', c => {
const { videoId } = c.params;
return c.sse(send => {
const onProgress = percent => {
send('progress', { percent });
};
const onComplete = () => {
send('progress', { percent: 100 });
};
startVideoConversion(videoId, onProgress, onComplete);
});
});
app.listen({ port: 3100, reusePort: true });
const conversionProgress = new EventSource('/convert-video/123');
conversionProgress.addEventListener('progress', e => {
const data = JSON.parse(e.data);
if (data.percent === 100) {
conversionProgress.close();
} else {
document.querySelector('#progress').innerText = e.data;
}
});
You may have noticed that you can attach multiple listeners to an EventSource
object to react to multiple event types. Here is a minimal example:
app.get('/hello', c => {
const { videoId } = c.params;
return c.sse(send => {
send('event1', 'data1');
send('event2', 'data2');
});
});
const events = new EventSource('/hello');
events.addEventListener('event1', listener1);
events.addEventListener('event2', listener2);
Route Matching
Bunshine v1 used the path-to-regexp
package for processing path routes.
Due to a discovered
RegExp Denial of Service vulnerability,
Bunshine v2+ no longer uses
path-to-regexp docs.
Support
Bunshine supports the following route matching features:
- Named placeholders using colons (e.g.
/posts/:id
) - End wildcards using stars (e.g.
/assets/*
) - 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.
Path examples
Path | URL | params |
---|
/path | /path | {} |
/users/:id | /users/123 | { id: '123' } |
/users/:id/groups | /users/123/groups | { id: '123' } |
/u/:id/groups/:gid | /u/1/groups/a | { id: '1', gid: 'a' } |
/star/* | /star/man | { 0: 'man' } |
/star/* | /star/man/can | { 0: 'man/can' } |
/star/*/can | /star/man/can | { 0: 'man' } |
/star/*/can/* | /star/man/can/go | { 0: 'man', 1: 'go' } |
Special Characters
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
.
For example, the dot in /assets/*.js
will not match all characters--only dots.
Not supported
Support for regex-like syntax has been dropped in v2 due to the aforementioned
RegExp Denial of Service vulnerability.
For cases where you need to limit by character or specify optional segments,
you'll need to pass in a RegExp
. Be sure to check your RegExp
with a ReDoS
checker such as Devina or
redos-checker on npm.
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 | ^\/([^/]*)\/(.*)$ |
If you want to double check all your routes, you can use code like the following:
import { HttpRouter } from 'bunshine';
import { isSafe } from 'redos-detector';
const app = new HttpRouter();
app.get('/', home);
app.matcher.detectPotentialDos(isSafe);
HTTP methods
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
app.head('/posts/:id', doesPostExist);
app.get('/posts/:id', getPost);
app.post('/posts/:id', addPost);
app.patch('/posts/:id', editPost);
app.put('/posts/:id', upsertPost);
app.trace('/posts/:id', tracePost);
app.delete('/posts/:id', deletePost);
app.options('/posts/:id', getPostCors);
app.headGet('/files/*', serveFiles(`${import.meta.dir}/files`));
app.on(['POST', 'PATCH'], '/posts/:id', addEditPost);
app.get(/^\/author\/([a-z]+)$/i, getPost);
app.listen({ port: 3100, reusePort: true });
Included middleware
serveFiles
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.
import { HttpRouter, serveFiles } from 'bunshine';
const app = new HttpRouter();
app.get('/public/*', serveFiles(`${import.meta.dir}/public`));
app.listen({ port: 3100, reusePort: true });
How to respond to both GET and HEAD requests:
import { HttpRouter, serveFiles } from 'bunshine';
const app = new HttpRouter();
app.on(['HEAD', 'GET'], '/public/*', serveFiles(`${import.meta.dir}/public`));
app.headGet('/public/*', serveFiles(`${import.meta.dir}/public`));
app.listen({ port: 3100, reusePort: true });
How to alter the response provided by another handler:
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:
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, reusePort: true });
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 |
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 |
maxAge | undefined | If given, add a Cache-Control header with max-age† |
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 |
† A number in milliseconds or expression such as '30min', '14 days', '1y'.
responseCache
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
builds, where your assets aren't changing. In the example below, lru-cache
is
used to store assets in memory. Any cache that implements has(url: string)
,
get(url: string)
and set(url: string, resp: Response)
methods can be used.
Your cache can also serialize responses to save them to an external system.
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.
import { LRUCache } from 'lru-cache';
import { HttpRouter, responseCache, serveFiles } from 'bunshine';
const app = new HttpRouter();
app.headGet(
'/public/*',
responseCache(new LRUCache({ max: 100 })),
serveFiles(`${import.meta.dir}/build/public`)
);
compression
To add Gzip compression:
import { HttpRouter, compression, serveFiles } from 'bunshine';
const app = new HttpRouter();
app.get('/public/*', compression(), serveFiles(`${import.meta.dir}/public`));
app.listen({ port: 3100, reusePort: true });
The compression middleware takes an object with options:
type CompressionOptions = {
prefer: 'br' | 'gzip' | 'none';
br: BrotliOptions;
gzip: ZlibCompressionOptions;
minSize: number;
maxSize: number;
};
cors
To add CORS headers to some/all responses, use the cors
middleware.
import { HttpRouter, cors } from 'bunshine';
const app = new HttpRouter();
app.use(cors({ origin: '*' }));
app.use(cors({ origin: true }));
app.use(cors({ origin: 'https://example.com' }));
app.use(cors({ origin: /^https:\/\// }));
app.use(cors({ origin: ['https://example.com', 'https://stuff.com'] }));
app.use(cors({ origin: ['https://example.com', /https:\/\/stuff.[a-z]+/i] }));
app.use(cors({ origin: incomingOrigin => incomingOrigin }));
app.use(cors({ origin: incomingOrigin => getAllowedOrigins(incomingOrigin) }));
app.use(
cors({
origin: 'https://example.com',
allowMethods: ['GET', 'POST'],
allowHeaders: ['X-HTTP-Method-Override', 'Authorization'],
exposeHeaders: ['X-Response-Id'],
maxAge: 86400,
credentials: true,
})
);
app.all('/api', cors({ origin: '*' }));
app.get('/api/hello', c => c.json({ hello: 'world' }));
app.listen({ port: 3100, reusePort: true });
Options details:
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
allowHeaders: an array of HTTP headers to allow clients to send
exposeHeaders: an array of HTTP headers to expose to clients
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)
The headers
middleware adds headers to outgoing responses.
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)',
});
app.get('/login', htmlSecurityHeaders, loginHandler);
app.get('/cms/*', htmlSecurityHeaders);
const neverCache = headers({
'Cache-control': 'no-store, must-revalidate',
Expires: '0',
});
app.get('/api/*', neverCache);
devLogger & prodLogger
devLogger
outputs colorful logs in the form below.
[timestamp] METHOD PATHNAME STATUS_CODE (RESPONSE_TIME)
example:
[19:10:50.276Z] GET /api/users/me 200 (5ms)
prodLogger
outputs logs in JSON with the following shape:
Request log:
{
"msg": "--> GET /",
"type": "request",
"date": "2021-08-01T19:10:50.276Z",
"id": "ea98fe2e-45e0-47d1-9344-2e3af680d6a7",
"host": "example.com",
"method": "GET",
"pathname": "/",
"runtime": "Bun v1.1.33",
"poweredBy": "Bunshine v3.0.0-rc.1",
"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",
"pid": 123
}
Response log:
{
"msg": "200 GET /",
"type": "response",
"date": "2021-08-01T19:10:50.286Z",
"id": "ea98fe2e-45e0-47d1-9344-2e3af680d6a7",
"host": "example.com",
"method": "GET",
"pathname": "/",
"runtime": "Bun v1.1.3",
"poweredBy": "Bunshine v3.0.0-rc.1",
"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",
"pid": 123,
"took": 5
}
Note that id
correlates between a request and its response.
To use these loggers, simply attach them as middleware.
import { HttpRouter, devLogger, prodLogger } from 'bunshine';
const app = new HttpRouter();
const logger = process.env.NODE_ENV === 'development' ? devLogger : prodLogger;
app.use(logger());
app.use('/api/*', logger());
app.listen({ port: 3100, reusePort: true });
You can add an X-Took header with the number of milliseconds it took to respond.
import { HttpRouter, performanceHeader } from 'bunshine';
const app = new HttpRouter();
app.use(performanceHeader());
app.use(performanceHeader('X-Time-Milliseconds'));
app.listen({ port: 3100, reusePort: true });
etags
You can add etag headers and respond to If-None-Match
headers.
import { HttpRouter, etags } from 'bunshine';
const app = new HttpRouter();
app.use(etags());
app.get('/resource1', c => c.text(someBigThing));
app.listen({ port: 3100, reusePort: true });
TypeScript pro-tips
Bun embraces TypeScript and so does Bunshine. Here are some tips for getting
the most out of TypeScript.
Typing URL params
You can type URL params by passing a type to any of the route methods:
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
app.post<{ id: string }>('/users/:id', async c => {
});
app.get<{ 0: string }>('/auth/*', async c => {
});
app.listen({ port: 3100, reusePort: true });
Typing WebSocket data
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
app.get('/', c => c.text('Hello World!'));
type User = {
nickname: string;
email: string;
first: string;
last: string;
};
app.socket.at<{ room: string }, { user: User }>('/games/rooms/:room', {
upgrade: ({ request, params, url }) => {
const cookies = req.headers.get('cookie');
const user: User = getUserFromCookies(cookies);
return { user };
},
open(ws) {
},
message(ws, message) {
},
close(ws, code, message) {
},
});
app.listen({ port: 3100, reusePort: true });
Typing middleware
function myMiddleware(options: Options): Middleware {
return (c, next) => {
};
}
Examples of common http server tasks
import { HttpRouter } from 'bunshine';
import { Middleware } from './HttpRouter';
const app = new HttpRouter();
app.get('/', c => {
c.url.searchParams;
Object.fromEntries(c.url.searchParams);
for (const [key, value] of c.url.searchParams) {
}
});
const respondWith404 = c => c.text('Not found', { status: 404 });
app.get(/^\./, respondWith404);
app.all(/\.(env|bak|old|tmp|backup|log|ini|conf)$/, respondWith404);
app.all(/(^wordpress\/|\/wp-includes\/)/, respondWith404);
app.all(/^[^/]+\.(php|cgi)$/, respondWith404);
app.all(/^(phpmyadmin|mysql|cgi-bin|cpanel|plesk)/i, respondWith404);
app.use(async (c, next) => {
const resp = await next();
if (
resp.headers.get('content-type')?.includes('text/html') &&
!resp.headers.has('Content-Security-Headers')
) {
resp.headers.set(
'Content-Security-Headers',
"frame-src 'self'; frame-ancestors 'self'; worker-src 'self'; connect-src 'self'; default-src 'self'; font-src *; img-src *; manifest-src 'self'; media-src 'self' data:; object-src 'self' data:; prefetch-src 'self'; script-src 'self'; script-src-elem 'self' 'unsafe-inline'; script-src-attr 'none'; style-src-attr 'self' 'unsafe-inline'; base-uri 'self'; form-action 'self'"
);
}
return resp;
});
app.headGet('/embeds/*', async (c, next) => {
const resp = await next();
const csp = response.headers.get('Content-Security-Headers');
if (csp) {
resp.headers.set(
'Content-Security-Headers',
csp.replace(/frame-ancestors .+?;/, 'frame-ancestors *;')
);
}
return resp;
});
app.get('/api/*', async (c, next) => {
const authValue = c.request.headers.get('Authorization');
c.locals.auth = {
identity: await getUser(authValue),
permission: await getPermissions(authValue),
};
});
function castSchema(zodSchema: ZodObject): Middleware {
return async c => {
const result = zodSchema.safeParse(await c.json());
if (result.error) {
return c.json(result.error, { status: 400 });
}
c.locals.safePayload = result.data;
};
}
app.post('/api/users', castSchema(userCreateSchema), createUser);
app.get('/api/*', async ({ url, request, json }) => {
return text('my json response');
});
Decisions
The following decisions are based on scripts in /benchmarks:
- 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
middleware. Deflate provides no advantage, and Brotli provides 2-8% additional
size savings at the cost of 7-10x as much CPU time as gzip. Brotli takes on
the order of 100ms to compress 100kb of html, compared to sub-milliseconds
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.
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.
Some additional design decisions:
- I decided to use LRUCache and a custom router. I looked into trie routers and
compile RegExp routers, but they didn't easily support the concept of matching
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.
Roadmap
- ✅ HttpRouter
- ✅ SocketRouter
- ✅ Context
- ✅ examples/server.ts
- ✅ middleware > compression
- ✅ middleware > cors
- ✅ middleware > devLogger
- ✅ middleware > etags
- ✅ middleware > headers
- ✅ middleware > performanceHeader
- ✅ middleware > prodLogger
- ✅ middleware > responseCache
- ✅ middleware > serveFiles
- ✅ middleware > trailingSlashes
- ✅ document the headers middleware
- ✅ options for serveFiles
- ✅ tests for cors
- 🔲 tests for devLogger
- 🔲 tests for prodLogger
- 🔲 tests for responseFactories
- ✅ tests for serveFiles
- 🔲 100% test coverage
- 🔲 support and document flags to bin/serve.ts with commander
- 🔲 more files in examples folder
- 🔲 example of mini app that uses bin/serve.ts (maybe our own docs?)
- 🔲 GitHub Actions to run tests and coverage
- ✅ Replace "ms" with a small and simple implementation
License
ISC License