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
- Built-in gzip compression
- 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
Usage
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 });
Full example
import { HttpRouter, redirect } from 'bunshine';
const app = new HttpRouter();
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 });
function authorize(authHeader: string) {
if (!authHeader) {
throw redirect('/login');
} else if (!jwtVerify(authHeader)) {
throw redirect('/not-allowed');
}
}
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 });
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.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({ id: zod.number() }),
async c => {
const user = await getUser(c.params.id);
return c.json(user);
},
]);
app.get('/users/:id', paramValidationMiddleware, async c => {
const user = await getUser(c.params.id);
return c.json(user);
});
app.get('/', c => c.text('Hello World!'));
app.listen({ port: 3100 });
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);
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 });
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 });
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`;
ws.publish(`chat-room-${sc.params.room}`, msg);
ws.unsubscribe(`chat-room-${sc.params.room}`);
},
});
const server = app.listen({ port: 3100 });
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 });
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 });
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 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
) - Custom Regular Expression (e.g.
/^\/author\/([a-z]+)$/i
)
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 a
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 | ^\/([^/]*)\/(.*)$ |
Caching
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 });
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 });
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 });
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 });
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 });
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 |
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'.
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 });
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 credentials (e.g. cookies or auth headers)
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.4",
"poweredBy": "Bunshine v2.0.0",
"machine": "server1",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
"pid": 123
}
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.4",
"poweredBy": "Bunshine v2.0.0",
"machine": "server1",
"userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
"pid": 123,
"took": 5
}
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 });
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 });
You can add security-related headers to responses with the securityHeaders
middleware. For more information about security headers, checkout these
resources:
import { HttpRouter, securityHeaders } from 'bunshine';
const app = new HttpRouter();
app.use(securityHeaders());
app.use(
securityHeaders({
contentSecurityPolicy: {
frameSrc: ["'self'"],
workerSrc: ["'self'"],
connectSrc: ["'self'"],
defaultSrc: ["'self'"],
fontSrc: ['*'],
imgSrc: ['*'],
manifestSrc: ["'self'"],
mediaSrc: ["'self' data:"],
objectSrc: ["'self' data:"],
prefetchSrc: ["'self'"],
scriptSrc: ["'self'"],
scriptSrcElem: ["'self' 'unsafe-inline'"],
scriptSrcAttr: ["'none'"],
styleSrcAttr: ["'self' 'unsafe-inline'"],
baseUri: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'self'"],
sandbox: {},
},
crossOriginEmbedderPolicy: 'unsafe-none',
crossOriginOpenerPolicy: 'same-origin',
crossOriginResourcePolicy: 'same-origin',
permissionsPolicy: {
accelerometer: [],
ambientLightSensor: [],
autoplay: ['self'],
battery: [],
camera: [],
displayCapture: [],
documentDomain: [],
encryptedMedia: [],
executionWhileNotRendered: [],
executionWhileOutOfViewport: [],
fullscreen: [],
gamepad: [],
geolocation: [],
gyroscope: [],
hid: [],
identityCredentialsGet: [],
idleDetection: [],
localFonts: [],
magnetometer: [],
midi: [],
otpCredentials: [],
payment: [],
pictureInPicture: [],
publickeyCredentialsCreate: [],
publickeyCredentialsGet: [],
screenWakeLock: [],
serial: [],
speakerSelection: [],
storageAccess: [],
usb: [],
webShare: ['self'],
windowManagement: [],
xrSpacialTracking: [],
},
referrerPolicy: 'strict-origin',
server: false,
strictTransportSecurity: 'max-age=86400; includeSubDomains; preload',
xContentTypeOptions: 'nosniff',
xFrameOptions: 'SAMEORIGIN',
xPoweredBy: false,
xXssProtection: '1; mode=block',
})
);
app.listen({ port: 3100 });
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 });
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 = getUserFromCookies(cookies);
return { user };
},
open(ws) {
},
message(ws, message) {
},
close(ws, code, message) {
},
});
app.listen({ port: 3100 });
Roadmap
- ✅ HttpRouter
- ✅ SocketRouter
- ✅ Context
- ✅ examples/server.ts
- ✅ middleware > serveFiles
- ✅ middleware > cors
- ✅ middleware > devLogger
- ✅ middleware > prodLogger
- ✅ middleware > performanceHeader
- ✅ middleware > securityHeaders
- ✅ middleware > trailingSlashes
- 🔲 middleware > html rewriter
- 🔲 middleware > hmr
- 🔲 middleware > directoryListing
- 🔲 middleware > rate limiter
- 🔲 document headers middleware
- 🔲 move some middleware to
@bunshine/\*
? - ✅ gzip compression
- ✅ options for serveFiles
- 🔲 tests for cors
- 🔲 tests for devLogger
- 🔲 tests for prodLogger
- 🔲 tests for gzip
- 🔲 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
- 🔲 Support server clusters
- ✅ Replace "ms" with a small and simple implementation
- ✅ Export functions to gzip strings and files
- ✅ Gzip performance testing (to get min/max defaults)
License
ISC License