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 - Integrated support for routing
WebSocket
requests - Integrated support for Server Sent Events
- Support ranged file downloads (e.g. for video streaming)
- Be very lightweight
- Elegantly 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
- SSL
- Serving static files
- Writing middleware
- Throwing responses
- WebSockets
- WebSocket pub-sub
- Server Sent Events
- Route Matching
- Included middleware
- TypeScript pro-tips
- Examples of common http server setup
- Design Decisions
- Roadmap
- Change Log
- ISC 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 discontinued. 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.onNotFound(c => {
return c.text('Page Not found', { status: 404 });
});
app.onError(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;
return new Response(JSON.stringify(payload), {
headers: { 'Content-type': 'application/json' },
});
return c.json(data, init);
return c.text(text, init);
return c.js(jsText, init);
return c.xml(xmlText, init);
return c.html(htmlText, init);
return c.css(cssText, init);
return c.file(pathOrSource, init);
type ResponseInit = {
headers?: Headers | Record<string, string> | Array<[string, string]>;
status?: number;
statusText?: string;
};
return c.redirect(url, status);
});
And c
is destructureable. For example, you can write:
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
app.get('/', ({ url, text }) => {
return text('Hello at ' + url.pathname);
});
app.listen({ port: 3100, reusePort: true });
SSL
Supporting HTTPS is simple on Bun and Bunshine. For details on all supported
options, see the Bun docs on TLS.
You can obtain free SSL certificates from a service such as
Let's Encrypt.
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
const server = app.listen({
port: 443,
reusePort: true,
tls: {
key: Bun.file(`${import.meta.dir}/certs/my.key`),
cert: Bun.file(`${import.meta.dir}/certs/my.crt`),
ca: Bun.file(`${import.meta.dir}/certs/ca.pem`),
},
});
app.get('/', () => new Response('hello from https'));
const resp = await fetch('https://localhost');
Serving static files
Serving static files is easy with the serveFiles
middleware. Note that ranged
requests are supported, so you can use Bunshine for video streaming and 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.
If you want to manually manage serving a file, you can use the following approach.
import { HttpRouter, serveFiles } from 'bunshine';
const app = new HttpRouter();
app.get('/assets/:name.png', c => {
const name = c.params.name;
const filePath = `${import.meta.dir}/assets/${name}.png`;
return c.file(filePath);
});
app.get('/build/:hash.map', c => {
const hash = c.params.hash;
const filePath = `${import.meta.dir}/assets/${name}.mystuff`;
return c.file(Bun.file(filePath), {
headers: { 'Content-type': 'application/json' },
});
});
app.get('/profile/*.jpg', async c => {
const intArray = getBytesFromExternal(c.params[0]);
return c.file(bytes);
});
app.get('/files/*', async c => {
return c.file(path, {
disposition,
headers,
sendLastModified,
acceptRanges,
chunkSize,
});
});
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 (isBot(c.request.headers.get('User-Agent'))) {
return c.text('Bots are forbidden', { 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;
});
const requireAdmin: Middleware = c => {
if (!isAdmin(c.request.headers.get('Authorization'))) {
return c.redirect('/login', { status: 403 });
}
};
app.get('/admin', requireAdmin);
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);
},
]);
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,
});
};
app.get('/api/*', ensureSafeData);
app.get('/api/v1/users/:id', getUser);
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('/', async (c, next) => {
console.log('never');
return c.text('Hello2');
});
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 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]]]);
const adminMiddleware = [getAuthCookie, checkPermissions];
app.get('/admin/posts', adminMiddleware, getPosts);
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 });
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
Setting up websockets is easy by registering handlers for one or more routes
using the app.socket.at()
function.
import { HttpRouter, SocketMessage } 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: c => {
const cookies = c.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, reason) {
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' }));
Are sockets magical?
They're simpler than you think. What Bun does internally:
- Responds to a regular HTTP request with HTTP 101 (Switching Protocols)
- Tells the client to make socket connection on another port
- Keeps connection open and sends/receives messages
- Keeps objects in memory to represent each connected client
- Tracks topic subscription and un-subscription for each client
In Node, the process is complicated because you have to import and orchestrate
http/https, and net. But Bun provides Bun.serve() which handles everything.
Bunshine is a wrapper around Bun.serve that adds routing and convenience
functions.
With Bunshine you don't need to use Socket.IO or any other framework.
Connecting from the client requires no library either. You can simply
create a new WebSocket()
object and use it to listen and send data.
Bun also includes built-in subscription and broadcasting for pub-sub
applications. See below for pub-sub examples using
Bunshine.
Socket Context properties
import { Context, HttpRouter, NextFunction, SocketContext } from 'bunshine';
const app = new HttpRouter();
type ParamsShape = { room: string };
type DataShape = { user: User };
app.socket.at<ParmasShape, DataShape>('/games/rooms/:room', {
upgrade: (c: Context, next: NextFunction) => {
return data;
},
message(sc, message) {
sc.url;
sc.server;
sc.params;
sc.data;
sc.remoteAddress;
sc.readyState;
sc.binaryType;
sc.send(message, compress );
sc.close(status , reason );
sc.terminate();
sc.subscribe(topic);
sc.unsubscribe(topic);
sc.isSubscribed(topic);
sc.cork(topic);
sc.publish(topic, message, compress );
sc.ping(data );
sc.pong(data );
message.raw();
message.text(encoding );
`${message}`;
message.buffer();
message.arrayBuffer();
message.readableStream();
message.json();
message.type;
},
error: (sc: SocketContext, error: Error) => {
},
close(sc: SocketContext, code: number, reason: string) {
},
});
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();
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.text()}`;
sc.publish(`chat-room-${sc.params.room}`, fullMessage);
sc.send(fullMessage);
},
close(sc, code, reason) {
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 in the browser will open a connection to the
server, and if the server closes the connection, a 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 (ReDoS) vulnerability
Bunshine v2+ no longer uses path-to-regexp
because the new, safer version
imposes disruptive limitations such as no support for unnamed wildcards.
What is supported
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 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.
Supported 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 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
. But be sure to check your
RegExp
with a ReDoS such as Devina or
redos-checker on npm.
For example, the dot in /assets/*.js
will not match all characters--only dots.
Examples of unsupported routes
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,
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 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 at runtime, you can install
redos-detector
and use Bunshine's detectPotentialDos
function:
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. Note that ranged requests are
supported, so you can use it to serve video streams and support 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.headGet('/public/*', serveFiles(`${import.meta.dir}/public`));
app.on(['HEAD', 'GET'], '/public/*', 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 whitelist 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 based on the filesystem's last modified date |
† A number in milliseconds or expression such as '30min', '14 days', '1y'.
compression
To add Gzip compression:
import { HttpRouter, compression, serveFiles } from 'bunshine';
const app = new HttpRouter();
app.use(compression());
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;
};
trailingSlashes
Use the trailingSlashes
middleware to make sure all URLs have consistent
slash naming for caching, analytics and SEO purposes.
import { HttpRouter, cors } from 'bunshine';
const app = new HttpRouter();
app.use(trailingSlashes('remove'));
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);
You can also use pass a function as a second parameter to headers
, to only
apply
the given headers under certain conditions.
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
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)
Screenshot:
prodLogger
outputs logs in JSON with the following shape:
Request log:
{
"msg": "--> GET /home",
"type": "request",
"date": "2021-08-01T19:10:50.276Z",
"id": "ea98fe2e-45e0-47d1-9344-2e3af680d6a7",
"host": "example.com",
"method": "GET",
"pathname": "/home",
"runtime": "Bun v1.1.34",
"poweredBy": "Bunshine v3.2.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.0 Safari/537.36",
"pid": 123
}
Response log:
{
"msg": "200 GET /home",
"type": "response",
"date": "2021-08-01T19:10:50.286Z",
"id": "ea98fe2e-45e0-47d1-9344-2e3af680d6a7",
"host": "example.com",
"method": "GET",
"pathname": "/home",
"runtime": "Bun v1.1.34",
"poweredBy": "Bunshine v3.2.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.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.get('/api/*', logger());
api.get('/api/users/:id', getUser);
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 });
Note: Please register etags()
before compression()
.
It's important to generate the ETag after compressing the response. This
ensures that the ETag reflects the exact version of the compressed content sent
to the client. If you generate the ETag before compression, it will correspond
to the uncompressed content, leading to mismatches when clients compare ETags
for cached compressed responses.
Recommended Middleware
Most applications will want a full-featured set of middleware. Below is the
recommended middleware in recommended order.
import { HttpRouter, compression, etags, performanceHeader } from 'bunshine';
const app = new HttpRouter();
app.use(performanceHeader);
app.use(process.env.NODE_ENV === 'development' ? devLogger() : prodLogger());
app.use(trailingSlashes('remove'));
app.use(etags());
app.use(compression());
Note: Please register etags()
before compression()
.
It's important to generate the ETag after compressing the response. This
ensures that the ETag reflects the exact version of the compressed content sent
to the client. If you generate the ETag before compression, it will correspond
to the uncompressed content, leading to mismatches when clients compare ETags
for cached compressed responses.
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 specify URL param types 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, reason) {
},
});
app.listen({ port: 3100, reusePort: true });
Typing middleware
import { type Middleware } from 'bunshine';
function myMiddleware(options: Options): Middleware {
return (c, next) => {
};
}
Examples of common http server setup
import { HttpRouter, type Middleware } from 'bunshine';
const app = new HttpRouter();
app.get('/', c => {
c.url.searchParams;
Object.fromEntries(c.url.searchParams);
for (const [key, value] of c.url.searchParams) {
}
});
app.use(c => {
c.query = Object.fromEntries(c.url.searchParams);
});
app.post('/api/user', async c => {
const data = await c.request.json();
});
app.on(['POST', 'PUT', 'PATCH'], async c => {
if (c.request.headers.get('Content-Type')?.includes('application/json')) {
c.body = await c.request.json();
}
});
const respondWith404 = c => c.text('Not found', { status: 404 });
app.get(/^\./, respondWith404);
app.all(/\.(env|bak|old|tmp|backup|log|ini|conf)$/, 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.all('/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.text(result.error, { status: 400 });
}
c.locals.safePayload = result.data;
};
}
app.post('/api/users', castSchema(userCreateSchema), createUser);
app.get('/api/*', async ({ url, request, json }) => {
return json({ message: `my json response at ${url.pathname}` });
});
app.listen({ port: 0, reusePort: true });
Design 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
- 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
size savings at the cost of 100x 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.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()
middleware
uses performance.now()
when it sets the X-Took
header with the number of
milliseconds 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 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.
Roadmap
- ✅ HttpRouter
- ✅ SocketRouter
- ✅ Context
- ✅ ./examples/kitchen-sink.ts
- 🔲 more examples in ./examples
- ✅ middleware > compression
- ✅ middleware > cors
- ✅ middleware > devLogger
- ✅ middleware > etags
- ✅ middleware > headers
- ✅ middleware > performanceHeader
- ✅ middleware > prodLogger
- ✅ 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
- 🔲 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