New Case Study:See how Anthropic automated 95% of dependency reviews with Socket.Learn More
Socket
Sign inDemoInstall
Socket

bunshine

Package Overview
Dependencies
Maintainers
0
Versions
43
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

bunshine - npm Package Compare versions

Comparing version 2.0.0 to 3.0.0-rc.1

dist/index.d.ts

3

bin/serve.ts

@@ -19,5 +19,2 @@ #!/usr/bin/env bun

index: ['index.html'],
gzip: {
cache: false,
},
})

@@ -24,0 +21,0 @@ );

@@ -15,9 +15,12 @@ export {

type NextFunction,
type SingleErrorHandler,
type SingleHandler,
} from './src/HttpRouter/HttpRouter';
export {
buildFileResponse,
factory,
file,
json,
minGzipSize,
redirect,
sse,
type Factory,

@@ -30,2 +33,36 @@ type FileResponseOptions,

export {
compression,
compressionDefaults,
type CompressionOptions,
} from './src/middleware/compression/compression';
export {
cors,
corsDefaults,
type CorsOptions,
} from './src/middleware/cors/cors';
export { devLogger } from './src/middleware/devLogger/devLogger';
export {
defaultEtagsCalculator,
etags,
type EtagHashCalculator,
type EtagOptions,
} from './src/middleware/etags/etags';
export {
headers,
type HeaderCondition,
type HeaderValue,
type HeaderValues,
} from './src/middleware/headers/headers';
export { performanceHeader } from './src/middleware/performanceHeader/performanceHeader';
export { prodLogger } from './src/middleware/prodLogger/prodLogger';
export {
responseCache,
type ResponseCache,
} from './src/middleware/responseCache/responseCache';
export {
serveFiles,
type ServeFilesOptions,
} from './src/middleware/serveFiles/serveFiles';
export { trailingSlashes } from './src/middleware/trailingSlashes/trailingSlashes';
export {
default as SocketRouter,

@@ -36,3 +73,3 @@ type BunHandlers,

type SocketErrorHandler,
type SocketEventName,
type SocketEventType,
type SocketMessageHandler,

@@ -42,22 +79,2 @@ type SocketPlainHandler,

type WsDataShape,
} from './src/SocketRouter/SocketRouter.ts';
export { cors, type CorsOptions } from './src/middleware/cors/cors';
export { devLogger } from './src/middleware/devLogger/devLogger';
export { performanceHeader } from './src/middleware/performanceHeader/performanceHeader.ts';
export { prodLogger } from './src/middleware/prodLogger/prodLogger';
export { securityHeaders } from './src/middleware/securityHeaders/securityHeaders';
export type {
AllowedApis,
CSPDirectives,
CSPSource,
ReportOptions,
SandboxOptions,
SecurityHeaderOptions,
SecurityHeaderValue,
} from './src/middleware/securityHeaders/securityHeaders.types.ts';
export {
serveFiles,
type GzipOptions,
type StaticOptions,
} from './src/middleware/serveFiles/serveFiles';
export { trailingSlashes } from './src/middleware/trailingSlashes/trailingSlashes';
} from './src/SocketRouter/SocketRouter';
{
"name": "bunshine",
"version": "2.0.0",
"version": "3.0.0-rc.1",
"module": "server/server.ts",
"type": "module",
"main": "index.ts",
"types": "index.d.ts",
"types": "dist/index.d.ts",
"scripts": {

@@ -12,3 +12,4 @@ "test-watch": "bun test --watch",

"lint": "tsc",
"build": "rm index.d.ts; bunx dts-bundle-generator -o index.d.ts index.ts",
"build:esm": "yes | npx esbuild index.ts --bundle --platform=node --format=esm --external:./package.json --outfile=dist/index.mjs",
"build:dts": "yes | bunx dts-bundle-generator -o dist/index.d.ts index.ts",
"example": "bun --watch ./examples/server.ts"

@@ -22,3 +23,3 @@ },

"bin": {
"serve": "./bin/serve.ts"
"bunshine-serve": "./bin/serve.ts"
},

@@ -50,8 +51,6 @@ "keywords": [

"dependencies": {
"lru-cache": "11.0.1"
"lru-cache": "11.0.2"
},
"devDependencies": {
"@types/eventsource": "1.1.15",
"@types/ms": "0.7.34",
"bun-types": "1.1.29",
"@types/bun": "1.1.13",
"eventsource": "2.0.2",

@@ -61,7 +60,7 @@ "globby": "14.0.2",

"prettier-plugin-organize-imports": "4.1.0",
"redos-detector": "5.1.0",
"redos-detector": "5.1.3",
"tinybench": "2.9.0",
"type-fest": "4.26.1",
"typescript": "5.6.2"
"typescript": "5.6.3"
}
}

@@ -5,11 +5,11 @@ # Bunshine

<img alt="Bunshine Logo" src="https://github.com/kensnyder/bunshine/raw/main/assets/bunshine-logo.png?v=2.0.0" 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.1" width="200" height="187" />
[![NPM Link](https://img.shields.io/npm/v/bunshine?v=2.0.0)](https://npmjs.com/package/bunshine)
[![Language](https://badgen.net/static/language/TS?v=2.0.0)](https://github.com/search?q=repo:kensnyder/bunshine++language:TypeScript&type=code)
![Test Coverage: 92%](https://badgen.net/static/test%20coverage/92%25/green?v=2.0.0)
[![Gzipped Size](https://badgen.net/bundlephobia/minzip/bunshine?label=minzipped&v=2.0.0)](https://bundlephobia.com/package/bunshine@2.0.0)
[![Dependency details](https://badgen.net/bundlephobia/dependency-count/bunshine?v=2.0.0)](https://www.npmjs.com/package/bunshine?activeTab=dependencies)
[![Tree shakeable](https://badgen.net/bundlephobia/tree-shaking/bunshine?v=2.0.0)](https://www.npmjs.com/package/bunshine)
[![ISC License](https://badgen.net/github/license/kensnyder/bunshine?v=2.0.0)](https://opensource.org/licenses/ISC)
[![NPM Link](https://img.shields.io/npm/v/bunshine?v=3.0.0-rc.1)](https://npmjs.com/package/bunshine)
[![Language](https://badgen.net/static/language/TS?v=3.0.0-rc.1)](https://github.com/search?q=repo:kensnyder/bunshine++language:TypeScript&type=code)
![Test Coverage: 96%](https://badgen.net/static/test%20coverage/92%25/green?v=3.0.0-rc.1)
[![Gzipped Size](https://badgen.net/bundlephobia/minzip/bunshine?label=minzipped&v=3.0.0-rc.1)](https://bundlephobia.com/package/bunshine@3.0.0-rc.1)
[![Dependency details](https://badgen.net/bundlephobia/dependency-count/bunshine?v=3.0.0-rc.1)](https://www.npmjs.com/package/bunshine?activeTab=dependencies)
[![Tree shakeable](https://badgen.net/bundlephobia/tree-shaking/bunshine?v=3.0.0-rc.1)](https://www.npmjs.com/package/bunshine)
[![ISC License](https://badgen.net/github/license/kensnyder/bunshine?v=3.0.0-rc.1)](https://opensource.org/licenses/ISC)

@@ -34,4 +34,4 @@ ## Installation

7. Support async handlers
8. Provide common middleware out of the box
9. Built-in gzip compression
8. Provide common middleware out of the box (cors, prodLogger, headers, compression, etags)
9. Support traditional routing syntax
10. Make specifically for Bun

@@ -54,6 +54,9 @@ 11. Comprehensive unit tests

- [serveFiles](#servefiles)
- [responseCache](#responseCache)
- [compression](#compression)
- [cors](#cors)
- [devLogger & prodLogger](#devlogger--prodlogger)
- [headers](#headers)
- [performanceHeader](#performanceheader)
- [securityHeaders](#securityheaders)
- [etags](#etags)
11. [TypeScript pro-tips](#typescript-pro-tips)

@@ -63,4 +66,14 @@ 12. [Roadmap](#roadmap)

## Usage
## 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](https://www.npmjs.com/package/@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

@@ -77,3 +90,3 @@

app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });
```

@@ -84,10 +97,11 @@

```ts
import { HttpRouter, redirect } from 'bunshine';
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'));
await authorize(c.request.headers.get('Authorization')); // see implementation below
const data = await c.request.json();
const result = await updateUser(params.id, data);
const result = await updateUser(params.id, data); // made-up function
if (result === 'not found') {

@@ -113,3 +127,3 @@ return c.json({ error: 'User not found' }, { status: 404 });

app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });

@@ -125,2 +139,22 @@ function authorize(authHeader: string) {

You can also make a path-specific error catcher like this:
```ts
import { HttpRouter, redirect, compression } from 'bunshine';
const app = new HttpRouter();
app.get('/api/*', async (c, next) => {
try {
return await next();
} catch (e) {
// do something with error
// maybe return json
}
});
// attach other routes
app.get('/api/v1/posts', handler);
```
### What is `c` here?

@@ -137,4 +171,4 @@

// Properties of the Context object
c.request; // The raw request object
c.url; // The URL object
c.request; // The raw Request object
c.url; // The URL object (get url string with c.url.href, or query with c.url.searchParams)
c.params; // The request params from route placeholders

@@ -145,3 +179,3 @@ c.server; // The Bun server instance (useful for pub-sub)

c.error; // An error object available to handlers registered with app.on500()
c.ip; // The IP address of the client (not necessarily the end user)
c.ip; // The IP address of the client or load balancer (not necessarily the end user)
c.date; // The date of the request

@@ -151,3 +185,2 @@ c.now; // The result of performance.now() at the start of the request

// Convenience methods for creating Response objects with various content types
// Note that responses are automatically gzipped if the client accepts gzip
c.json(data, init);

@@ -179,3 +212,3 @@ c.text(text, init);

app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });
```

@@ -197,2 +230,5 @@

// handler not affected by middleware defined below
app.get('/healthcheck', c => c.text('200 OK'));
// Run before each request

@@ -234,2 +270,8 @@ app.use(c => {

// Middleware before a given handler (as args)
app.get('/users/:id', paramValidationMiddleware, async c => {
const user = await getUser(c.params.id);
return c.json(user);
});
// Middleware before a given handler (as array)

@@ -244,12 +286,6 @@ app.get('/users/:id', [

// Middleware before a given handler (as args)
app.get('/users/:id', paramValidationMiddleware, async c => {
const user = await getUser(c.params.id);
return c.json(user);
});
// handler affected by applicable middleware
// handler affected by middleware defined above
app.get('/', c => c.text('Hello World!'));
app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });
```

@@ -263,6 +299,31 @@

app.get('/users/me', handler1);
app.get('/users/:id', handler2);
app.get('/users/:id', handler2); // runs only if id is not "me" or handler1 doesn't respond
app.get('*', http404Handler);
```
And to illustrate the wrap-like behavior of `await`ing the `next` function:
```ts
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');
});
// logs 1, 2, 3, 4, then 5
// Same goes for a list of handlers:
app.get('/', runs1stAnd5th, runs2ndAnd4th, runs3rd);
```
### What does it mean that "every handler is treated like middleware"?

@@ -372,3 +433,3 @@

// start the server
app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });
```

@@ -428,3 +489,3 @@

// start the server
app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });

@@ -483,10 +544,10 @@ //

const msg = `${sc.data.username} has left the chat`;
ws.publish(`chat-room-${sc.params.room}`, msg);
ws.unsubscribe(`chat-room-${sc.params.room}`);
sc.publish(`chat-room-${sc.params.room}`, msg);
sc.unsubscribe(`chat-room-${sc.params.room}`);
},
});
const server = app.listen({ port: 3100 });
const server = app.listen({ port: 3100, reusePort: true });
// at a later time, you can also publish a message from another source
// at a later time, you can also publish a message from another part of your code
server.publish(channel, message);

@@ -517,3 +578,3 @@ ```

// start the server
app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });

@@ -559,3 +620,3 @@ //

// start the server
app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });

@@ -605,3 +666,3 @@ //

[RegExp Denial of Service vulnerability](https://security.snyk.io/vuln/SNYK-JS-PATHTOREGEXP-7925106),
Bunshine no longer uses
Bunshine v2+ no longer uses
[path-to-regexp docs](https://www.npmjs.com/package/path-to-regexp).

@@ -617,3 +678,2 @@

- Static paths (e.g. `/posts`)
- Custom Regular Expression (e.g. `/^\/author\/([a-z]+)$/i`)

@@ -643,7 +703,7 @@ Support for other behaviors can lead to a Regular Expression Denial of service

For example, the dot in `/assets/*.js` will not match all characters--only dots.™™
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
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).

@@ -662,4 +722,16 @@ For cases where you need to limit by character or specify optional segments,

### Caching
If you want to double check all your routes, you can use code like the following:
```ts
import { HttpRouter } from 'bunshine';
import { isSafe } from 'redos-detector';
const app = new HttpRouter();
app.get('/', home);
// ... all my routes
// detectPotentialDos() calls console.warn with() details of each unsafe pattern
app.matcher.detectPotentialDos(isSafe);
```
### HTTP methods

@@ -690,3 +762,3 @@

app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });
```

@@ -709,3 +781,3 @@

app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });
```

@@ -724,3 +796,3 @@

app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });
```

@@ -743,3 +815,3 @@

app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });
```

@@ -762,3 +834,3 @@

app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });
```

@@ -772,3 +844,2 @@

| 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 |

@@ -783,2 +854,52 @@ | fallthrough | `true` | If false, issue a 404 when a file is not found, otherwise proceed to next handler |

### 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.
```ts
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:
```ts
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:
```ts
type CompressionOptions = {
prefer: 'br' | 'gzip' | 'none'; // default gzip
br: BrotliOptions; // default from node:zlib
gzip: ZlibCompressionOptions; // default from node:zlib
minSize: number; // files smaller than this will not be compressed
maxSize: number; // files larger than this will not be compressed
};
```
### cors

@@ -800,3 +921,3 @@

app.use(cors({ origin: ['https://example.com', /https:\/\/stuff.[a-z]+/i] }));
app.use(cors({ origin: incomingOrigin => incomingOrigin }));
app.use(cors({ origin: incomingOrigin => incomingOrigin })); // This may be preferred to *
app.use(cors({ origin: incomingOrigin => getAllowedOrigins(incomingOrigin) }));

@@ -822,3 +943,3 @@

app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });
```

@@ -833,4 +954,30 @@

_maxAge_: the number of seconds clients should cache the CORS headers
_credentials_: whether to allow credentials (e.g. cookies or auth headers)
_credentials_: whether to allow clients to send credentials (e.g. cookies or auth headers)
### headers
The `headers` middleware adds headers to outgoing responses.
```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)',
});
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

@@ -860,6 +1007,6 @@

"pathname": "/",
"runtime": "Bun v1.1.4",
"poweredBy": "Bunshine v2.0.0",
"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.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.1.0 Safari/537.36",
"pid": 123

@@ -880,6 +1027,6 @@ }

"pathname": "/",
"runtime": "Bun v1.1.4",
"poweredBy": "Bunshine v2.0.0",
"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.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.1.0 Safari/537.36",
"pid": 123,

@@ -890,2 +1037,4 @@ "took": 5

Note that `id` correlates between a request and its response.
To use these loggers, simply attach them as middleware.

@@ -903,3 +1052,3 @@

app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });
```

@@ -921,94 +1070,18 @@

app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });
```
### securityHeaders
### etags
You can add security-related headers to responses with the `securityHeaders`
middleware. For more information about security headers, checkout these
resources:
You can add etag headers and respond to `If-None-Match` headers.
- [securityheaders.com](https://securityheaders.com)
- [MDN Security on the Web](https://developer.mozilla.org/en-US/docs/Web/Security)
- [MDN Content-Security-Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy)
```ts
import { HttpRouter, securityHeaders } from 'bunshine';
import { HttpRouter, etags } from 'bunshine';
const app = new HttpRouter();
app.use(securityHeaders());
// The following are defaults that you can override
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: {
// only include special APIs that you use
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.use(etags());
app.get('/resource1', c => c.text(someBigThing));
app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });
```

@@ -1038,3 +1111,3 @@

app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });
```

@@ -1064,3 +1137,3 @@

const cookies = req.headers.get('cookie');
const user = getUserFromCookies(cookies);
const user: User = getUserFromCookies(cookies);
// here user is typed as User

@@ -1084,5 +1157,136 @@ return { user };

// start the server
app.listen({ port: 3100 });
app.listen({ port: 3100, reusePort: true });
```
### Typing middleware
```ts
function myMiddleware(options: Options): Middleware {
return (c, next) => {
// TypeScript infers c and next because of Middleware
};
}
```
## Examples of common http server tasks
```ts
import { HttpRouter } from 'bunshine';
import { Middleware } from './HttpRouter';
const app = new HttpRouter();
// how to read query params
app.get('/', c => {
c.url.searchParams; // URLSearchParams object
Object.fromEntries(c.url.searchParams); // as plain object (but repeated keys are dropped)
for (const [key, value] of c.url.searchParams) {
} // iterate params
});
// create small functions that always return the same thing
const respondWith404 = c => c.text('Not found', { status: 404 });
// block dotfile access (e.g. .env, .git, .svn, .htaccess)
app.get(/^\./, respondWith404);
// block URLs that end with .env and other dumb endings
app.all(/\.(env|bak|old|tmp|backup|log|ini|conf)$/, respondWith404);
// block WordPress URLs such as /wordpress/wp-includes/wlwmanifest.xml
app.all(/(^wordpress\/|\/wp-includes\/)/, respondWith404);
// block Other language URLs such as /phpinfo.php and /admin.cgi
app.all(/^[^/]+\.(php|cgi)$/, respondWith404);
// block Commonly probed application paths
app.all(/^(phpmyadmin|mysql|cgi-bin|cpanel|plesk)/i, respondWith404);
// middleware to add CSP
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;
});
// Later modify CSP at a certain route
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;
});
// Persist data in c.locals
app.get('/api/*', async (c, next) => {
const authValue = c.request.headers.get('Authorization');
c.locals.auth = {
identity: await getUser(authValue),
permission: await getPermissions(authValue),
};
// return nothing so that subsequent handlers get called
});
// Middleware to cast incoming json payload to zod schema
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);
// Destructure context object
app.get('/api/*', async ({ url, request, json }) => {
// do stuff with url and request
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

@@ -1094,21 +1298,17 @@

- ✅ examples/server.ts
- ✅ middleware > serveFiles
- ✅ middleware > compression
- ✅ middleware > cors
- ✅ middleware > devLogger
- ✅ middleware > etags
- ✅ middleware > headers
- ✅ middleware > performanceHeader
- ✅ middleware > prodLogger
- ✅ middleware > performanceHeader
- ✅ middleware > securityHeaders
- ✅ middleware > responseCache
- ✅ middleware > serveFiles
- ✅ middleware > trailingSlashes
- 🔲 middleware > html rewriter
- 🔲 middleware > hmr
- 🔲 middleware > directoryListing
- 🔲 middleware > rate limiter
- 🔲 document headers middleware
- 🔲 move some middleware to `@bunshine/\*`?
- ✅ gzip compression
- ✅ document the headers middleware
- ✅ options for serveFiles
- 🔲 tests for cors
- ✅ tests for cors
- 🔲 tests for devLogger
- 🔲 tests for prodLogger
- 🔲 tests for gzip
- 🔲 tests for responseFactories

@@ -1121,6 +1321,3 @@ - ✅ tests for serveFiles

- 🔲 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)

@@ -1127,0 +1324,0 @@ ## License

@@ -61,34 +61,34 @@ import type { BunFile, Server } from 'bun';

/** A shorthand for `new Response(text, { headers: { 'Content-type': 'text/plain' } })` */
text(text: string, init: ResponseInit = {}) {
text = (text: string, init: ResponseInit = {}) => {
return textPlain.call(this, text, init);
}
};
/** A shorthand for `new Response(js, { headers: { 'Content-type': 'text/javascript' } })` */
js(js: string, init: ResponseInit = {}) {
js = (js: string, init: ResponseInit = {}) => {
return textJs.call(this, js, init);
}
};
/** A shorthand for `new Response(html, { headers: { 'Content-type': 'text/html' } })` */
html(html: string, init: ResponseInit = {}) {
html = (html: string, init: ResponseInit = {}) => {
return textHtml.call(this, html, init);
}
};
/** A shorthand for `new Response(html, { headers: { 'Content-type': 'text/css' } })` */
css(css: string, init: ResponseInit = {}) {
css = (css: string, init: ResponseInit = {}) => {
return textCss.call(this, css, init);
}
};
/** A shorthand for `new Response(xml, { headers: { 'Content-type': 'text/xml' } })` */
xml(xml: string, init: ResponseInit = {}) {
xml = (xml: string, init: ResponseInit = {}) => {
return textXml.call(this, xml, init);
}
};
/** A shorthand for `new Response(JSON.stringify(data), { headers: { 'Content-type': 'application/json' } })` */
json(data: any, init: ResponseInit = {}) {
json = (data: any, init: ResponseInit = {}) => {
return json.call(this, data, init);
}
};
/** A shorthand for `new Response(null, { headers: { Location: url }, status: 301 })` */
redirect(url: string, status = 302) {
redirect = (url: string, status = 302) => {
return redirect(url, status);
}
};
/** A shorthand for `new Response(bunFile, fileHeaders)` */
async file(
file = async (
filenameOrBunFile: string | BunFile,
fileOptions: FileResponseOptions = {}
) {
) => {
return file(filenameOrBunFile, {

@@ -98,7 +98,7 @@ range: this.request.headers.get('Range') || undefined,

});
}
};
/** A shorthand for `new Response({ headers: { 'Content-type': 'text/event-stream' } })` */
sse(setup: SseSetupFunction, init: ResponseInit = {}) {
sse = (setup: SseSetupFunction, init: ResponseInit = {}) => {
return sse(this.request.signal, setup, init);
}
};
}
import type { ServeOptions, Server } from 'bun';
import os from 'os';
import bunshine from '../../package.json';
import os from 'node:os';
import bunshine from '../../package.json' assert { type: 'json' };
import Context, { type ContextWithError } from '../Context/Context';
import MatcherWithCache from '../MatcherWithCache/MatcherWithCache.ts';
import SocketRouter from '../SocketRouter/SocketRouter.ts';
import MatcherWithCache from '../MatcherWithCache/MatcherWithCache';
import SocketRouter from '../SocketRouter/SocketRouter';
import { fallback404 } from './fallback404';

@@ -8,0 +8,0 @@ import { fallback500 } from './fallback500';

import { BunFile } from 'bun';
import path from 'node:path';
import Context from '../Context/Context.ts';
import getMimeType from '../getMimeType/getMimeType.ts';
import { gzipString } from '../gzip/gzip.ts';
import Context from '../Context/Context';
import getMimeType from '../getMimeType/getMimeType';

@@ -11,7 +10,2 @@ export type Factory = (body: string, init?: ResponseInit) => Response;

// body must be large enough to be worth compressing
// (54 is minimum size of gzip after metadata; 100 is arbitrary choice)
// see benchmarks/gzip.ts for more information
export let minGzipSize = 100;
export function json(this: Context, data: any, init: ResponseInit = {}) {

@@ -21,12 +15,4 @@ let body: string | Uint8Array = JSON.stringify(data);

if (!init.headers.has('Content-Type')) {
init.headers.set('Content-type', `application/json; charset=utf-8`);
init.headers.set('Content-Type', `application/json; charset=utf-8`);
}
if (!init.headers.has('Content-Encoding')) {
// body must be large enough to be worth compressing
if (body.length >= minGzipSize) {
body = gzipString(body);
init.headers.set('Content-Encoding', 'gzip');
init.headers.set('Content-Length', String(body.length));
}
}
return new Response(body, init);

@@ -39,16 +25,4 @@ }

if (!init.headers.has('Content-Type')) {
init.headers.set('Content-type', `${contentType}; charset=utf-8`);
init.headers.set('Content-Type', `${contentType}; charset=utf-8`);
}
if (!init.headers.has('Content-Encoding')) {
if (
// client must expect gzip
this.request.headers.get('Accept-Encoding')?.includes('gzip') &&
// body must be large enough to be worth compressing
body.length >= minGzipSize
) {
// @ts-expect-error
body = gzipString(body);
init.headers.set('Content-Encoding', 'gzip');
}
}
init.headers.set('Content-Length', String(body.length));

@@ -71,3 +45,2 @@ return new Response(body, init);

chunkSize?: number;
gzip?: boolean;
disposition?: 'inline' | 'attachment';

@@ -93,3 +66,2 @@ acceptRanges?: boolean;

method: 'GET',
gzip: fileOptions.gzip,
});

@@ -214,3 +186,2 @@ if (fileOptions.acceptRanges !== false) {

method,
gzip,
}: {

@@ -222,3 +193,2 @@ file: BunFile;

method: string;
gzip?: boolean;
}) {

@@ -225,0 +195,0 @@ let response: Response;

import { LRUCache } from 'lru-cache';
import RouteMatcher from '../RouteMatcher/RouteMatcher.ts';
import RouteMatcher from '../RouteMatcher/RouteMatcher';

@@ -8,3 +8,3 @@ export default class MatcherWithCache<

cache: LRUCache<string, any>;
constructor(size: number = 5000) {
constructor(size: number = 4000) {
super();

@@ -11,0 +11,0 @@ this.cache = new LRUCache<string, any>({ max: size });

@@ -21,3 +21,3 @@ import type Context from '../../Context/Context';

export const CorsDefaults = {
export const corsDefaults = {
origin: '*',

@@ -31,3 +31,3 @@ allowMethods: ['GET', 'HEAD', 'PUT', 'POST', 'PATCH', 'DELETE'],

const opts = {
...CorsDefaults,
...corsDefaults,
...options,

@@ -34,0 +34,0 @@ };

@@ -1,2 +0,2 @@

import type { Middleware } from '../../HttpRouter/HttpRouter.ts';
import type { Middleware } from '../../HttpRouter/HttpRouter';

@@ -3,0 +3,0 @@ export function devLogger(): Middleware {

@@ -1,3 +0,3 @@

import type Context from '../../Context/Context.ts';
import type { Middleware, NextFunction } from '../../HttpRouter/HttpRouter.ts';
import type Context from '../../Context/Context';
import type { Middleware, NextFunction } from '../../HttpRouter/HttpRouter';

@@ -7,3 +7,3 @@ export type HeaderValue =

| ((c: Context, resp: Response) => string | null | Promise<string | null>);
export type HeadersInit = Record<string, HeaderValue>;
export type HeaderValues = Record<string, HeaderValue>;
export type HeaderCondition = (

@@ -15,3 +15,3 @@ c: Context,

export function headers(
headers: HeadersInit,
headers: HeaderValues,
condition?: HeaderCondition

@@ -18,0 +18,0 @@ ): Middleware {

import os from 'os';
// @ts-ignore
import bunshine from '../../../package.json';
import type { Middleware } from '../../HttpRouter/HttpRouter.ts';
import bunshinePkg from '../../../package.json' assert { type: 'json' };
import type { Middleware } from '../../HttpRouter/HttpRouter';

@@ -10,3 +9,3 @@ const machine = os.hostname();

: `Node v${process.versions.node}`;
const poweredBy = `Bunshine v${bunshine.version}`;
const poweredBy = `Bunshine v${bunshinePkg.version}`;

@@ -13,0 +12,0 @@ export function prodLogger(): Middleware {

@@ -1,14 +0,11 @@

import { ZlibCompressionOptions } from 'bun';
import path from 'path';
import type { Middleware } from '../../HttpRouter/HttpRouter.ts';
import { buildFileResponse } from '../../HttpRouter/responseFactories.ts';
import { FileGzipper } from '../../gzip/FileGzipper.ts';
import ms from '../../ms/ms.ts';
import type { Middleware } from '../../HttpRouter/HttpRouter';
import { buildFileResponse } from '../../HttpRouter/responseFactories';
import ms from '../../ms/ms';
// see https://expressjs.com/en/4x/api.html#express.static
// and https://www.npmjs.com/package/send#dotfiles
export type StaticOptions = {
export type ServeFilesOptions = {
acceptRanges?: boolean;
dotfiles?: 'allow' | 'deny' | 'ignore';
etag?: boolean;
extensions?: string[];

@@ -20,34 +17,4 @@ fallthrough?: boolean;

maxAge?: number | string;
gzip?: GzipOptions;
};
export type GzipOptions = {
minFileSize?: number;
maxFileSize?: number;
mimeTypes?: Array<string | RegExp>;
zlibOptions?: ZlibCompressionOptions;
cache:
| false
| {
type: 'file' | 'precompress' | 'memory' | 'never';
maxBytes?: number;
path?: string;
};
};
const defaultGzipOptions: GzipOptions = {
minFileSize: 1024,
maxFileSize: 1024 * 1024 * 25,
mimeTypes: [
/^text\/.*/,
/^application\/json/,
/^image\/svg/,
/^font\/(otf|ttf|eot)/,
],
zlibOptions: {},
cache: {
type: 'never',
},
};
export function serveFiles(

@@ -58,4 +25,2 @@ directory: string,

dotfiles = 'ignore',
// etag is Not yet implemented
etag = true,
extensions = [],

@@ -67,15 +32,7 @@ fallthrough = true,

maxAge = undefined,
gzip = undefined,
}: StaticOptions = {}
}: ServeFilesOptions = {}
): Middleware {
const cacheControlHeader =
maxAge === undefined ? null : getCacheControl(maxAge, immutable);
const gzipper = gzip
? new FileGzipper(directory, { ...defaultGzipOptions, ...gzip })
: undefined;
return async c => {
if (gzipper) {
// wait for setup cache if not done already
await gzipper.setupPromise;
}
const filename = c.params[0] || c.url.pathname;

@@ -124,18 +81,9 @@ if (filename.startsWith('.')) {

const rangeHeader = c.request.headers.get('range');
let response: Response;
if (rangeHeader || !gzipper) {
// get base response
response = await buildFileResponse({
file,
acceptRanges,
chunkSize: 0,
rangeHeader,
method: c.request.method,
gzip: false,
});
} else {
response = await gzipper.fetch(file);
}
// add current date
response.headers.set('Date', new Date().toUTCString());
const response = await buildFileResponse({
file,
acceptRanges,
chunkSize: 0,
rangeHeader,
method: c.request.method,
});
// add last modified

@@ -142,0 +90,0 @@ if (lastModified) {

@@ -63,3 +63,3 @@ type Registration<T> = {

pattern,
regex: /\/(.+)/,
regex: /^\/(.+)$/,
matcher: subject => ({ '0': subject.slice(1) }),

@@ -73,3 +73,3 @@ target,

pattern,
regex: /(.+)/,
regex: /^(.+)$/,
matcher: subject => ({ '0': subject }),

@@ -138,4 +138,4 @@ target,

if (detector(reg.regex, config).safe === false) {
throw new Error(
`Potential ReDoS detected for pattern ${reg.pattern}. Consider using a `
console.warn(
`Bunshine: Potential ReDoS detected for pattern "${reg.pattern}" => ${reg.regex.source}`
);

@@ -142,0 +142,0 @@ }

import { Server, ServerWebSocket, ServerWebSocketSendStatus } from 'bun';
import { WsDataShape } from './SocketRouter.ts';
import { WsDataShape } from './SocketRouter';

@@ -4,0 +4,0 @@ const isBufferSource = (function () {

import type { ServerWebSocket } from 'bun';
import { RequireAtLeastOne } from 'type-fest';
import Context from '../Context/Context.ts';
import Context from '../Context/Context';
import HttpRouter, { NextFunction } from '../HttpRouter/HttpRouter';
import RouteMatcher from '../RouteMatcher/RouteMatcher';
import SocketContext, { SocketMessage } from './SocketContext.ts';
import SocketContext, { SocketMessage } from './SocketContext';
// U = UpgradeShape
// P = ParamsShape
// T = Type i.e. EventName
// T = Type i.e. SocketEventType

@@ -23,3 +23,3 @@ export type WsDataShape<U = any, P = Record<string, any>> = {

export type SocketMessageHandler<U, P, T extends SocketEventName> = (
export type SocketMessageHandler<U, P, T extends SocketEventType> = (
context: SocketContext<U, P>,

@@ -67,3 +67,3 @@ message: SocketMessage<T>

export type SocketEventName =
export type SocketEventType =
| 'open'

@@ -105,3 +105,2 @@ | 'message'

this.routeMatcher.add('ALL', path, handlers);
// console.log('ws handlers registered!', path);
// create a router path that upgrades to a socket

@@ -141,3 +140,3 @@ this.httpRouter.get<P>(path, async (c, next) => {

};
private _createHandler = (eventName: SocketEventName) => {
private _createHandler = (eventName: SocketEventType) => {
return async (ws: ServerWebSocket<WsDataShape>, ...args: any) => {

@@ -144,0 +143,0 @@ const sc = ws.data.sc as SocketContext;

{
"include": ["index.ts", "package.json", "src/**/*", "examples", "benchmarks", "bin/**/*"],
"exclude": [
"**/*.spec.ts"
],
"files": ["index.ts"],
"exclude": ["**/*.spec.ts"],
"compilerOptions": {
"lib": ["ESNext", "dom", "dom.iterable"],
"module": "esnext",
"target": "esnext",
"moduleResolution": "bundler",
"moduleDetection": "force",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"target": "ESNext",
"outDir": "./dist",
"declaration": true,
"removeComments": false,
"noEmit": true,
"pretty": true,
"isolatedModules": true,
"sourceMap": false,
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"allowImportingTsExtensions": true,
"composite": true,
"strict": true,
"noEmit": true,
"downlevelIteration": true,
"skipLibCheck": true,
"noImplicitAny": false,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"types": [
"bun-types"
]
"types": []
}
}

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc