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

bunshine

Package Overview
Dependencies
Maintainers
1
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 0.9.3 to 0.10.0

benchmarks/inner-functions.ts

4

examples/server.ts

@@ -6,1 +6,5 @@ import { HttpRouter } from '../index';

app.get('/', c => c.text('Hello World'));
app.get('/bye', c => c.html('<h1>Bye World</h1>'));
app.listen();
app.emitUrl();
export { default as Context } from './src/Context/Context';
export { default as HttpRouter } from './src/HttpRouter/HttpRouter';
export {
default as HttpRouter,
type Handler,
type Middleware,
type NextFunction,
} from './src/HttpRouter/HttpRouter';
export {
file,
html,

@@ -8,4 +14,7 @@ js,

redirect,
sse,
text,
xml,
type FileResponseOptions,
type SseSetupFunction,
} from './src/HttpRouter/responseFactories';

@@ -15,5 +24,8 @@ export { default as SocketRouter } from './src/SocketRouter/SocketRouter.ts';

export { devLogger } from './src/middleware/devLogger/devLogger';
export { performanceLogger } from './src/middleware/performanceLogger/performanceLogger';
export { performanceHeader } from './src/middleware/performanceHeader/performanceHeader.ts';
export { prodLogger } from './src/middleware/prodLogger/prodLogger';
export { securityHeaders } from './src/middleware/securityHeaders/securityHeaders';
export { serveFiles } from './src/middleware/serveFiles/serveFiles';
export {
serveFiles,
type StaticOptions,
} from './src/middleware/serveFiles/serveFiles';

15

package.json
{
"name": "bunshine",
"version": "0.9.3",
"version": "0.10.0",
"module": "server/server.ts",
"type": "module",
"scripts": {
"test-watch": "bun test --watch",
"coverage": "bun test --coverage",
"start": "bun ./examples/server.ts"
"example": "bun --watch ./examples/server.ts"
},
"dependencies": {
"lru-cache": "^10.1.0",
"path-to-regexp": "^6.2.1"

@@ -15,9 +17,10 @@ },

"@types/eventsource": "^1.1.15",
"bun-types": "^1.0.13",
"bun-types": "^1.0.21",
"eventsource": "^2.0.2",
"prettier": "^3.1.0",
"mitata": "^0.1.6",
"prettier": "^3.1.1",
"prettier-plugin-organize-imports": "^3.2.4",
"type-fest": "^4.8.2",
"typescript": "^5.2.2"
"type-fest": "^4.9.0",
"typescript": "^5.3.3"
}
}

@@ -1,5 +0,6 @@

<img alt="Bunshine Logo" src="https://github.com/kensnyder/bunshine/raw/main/assets/bunshine-logo.png?v=0.9.3" width="200" height="187" />
<img alt="Bunshine Logo" src="https://github.com/kensnyder/bunshine/raw/main/assets/bunshine-logo.png?v=0.10.0" width="200" height="187" />
[![NPM Link](https://img.shields.io/npm/v/bunshine?v=0.9.3)](https://npmjs.com/package/bunshine)
[![ISC License](https://img.shields.io/npm/l/bunshine.svg?v=0.9.3)](https://opensource.org/licenses/ISC)
[![NPM Link](https://img.shields.io/npm/v/bunshine?v=0.10.0)](https://npmjs.com/package/bunshine)
[![Dependencies](https://badgen.net/static/dependencies/2/green?v=0.10.0)](https://www.npmjs.com/package/bunshine?activeTab=dependencies)
[![ISC License](https://img.shields.io/npm/l/bunshine.svg?v=0.10.0)](https://opensource.org/licenses/ISC)

@@ -19,3 +20,3 @@ # Bunshine

5. Be very lightweight
6. Treat every handler function like middleware
6. Treat every handler like middleware
7. Support async handlers

@@ -25,16 +26,19 @@ 8. Provide common middleware out of the box

10. Comprehensive unit tests
11. Support for `X-HTTP-Method-Override` header
## Table of Contents
1. Basic example
2. Full example
3. Serving static files
4. Middleware
5. WebSockets
6. WebSocket pub-sub
7. Server Sent Events
8. Routing examples
9. Middleware
10. Roadmap
11. License
1. [Basic example](#basic-example)
2. [Full example](#full-example)
3. [Serving static files](#serving-static-files)
4. [Writing middleware](#writing-middleware)
5. [Throwing responses](#throwing-responses)
6. [WebSockets](#websockets)
7. [WebSocket pub-sub](#websocket-pub-sub)
8. [Server Sent Events](#server-sent-events)
9. [Routing examples](#routing-examples)
10. [Included middleware](#included-middleware)
11. [TypeScript pro-tips](#typescript-pro-tips)
12. [Roadmap](#roadmap)
13. [License](./LICENSE.md)

@@ -79,4 +83,3 @@ ## Usage

// called when no handlers match the requested path
console.log('404');
return c.json({ error: 'Not found' }, { status: 404 });
return c.text('Page Not found', { status: 404 });
});

@@ -86,3 +89,3 @@

// called when a handler throws an error
console.log('500');
console.error('500', c.error);
return c.json({ error: 'Internal server error' }, { status: 500 });

@@ -104,6 +107,25 @@ });

### What does it mean that "every handler is treated like middleware"?
`c` is a `Context` object that contains the request and params.
```ts
import { HttpRouter, type Context, type NextFunction } from 'bunshine';
const app = new HttpRouter();
app.get('/hello', (c: Context, next: NextFunction) => {
c.request; // The raw request object
c.params; // The request params from URL placeholders
c.server; // The Bun server instance (useful for pub-sub)
c.app; // The HttpRouter instance
c.locals; // A place to persist data between handlers for the duration of the request
c.error; // Handlers registered with app.on500() can see this Error object
});
```
## 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.
```ts

@@ -114,3 +136,3 @@ import { HttpRouter, serveFiles } from 'bunshine';

app.use(serveFiles(`${import.meta.dir}/public`));
app.get('/public/*', serveFiles(`${import.meta.dir}/public`));

@@ -120,4 +142,6 @@ app.listen({ port: 3100 });

## Middleware
## Writing middleware
Here are more examples of attaching middleware.
```ts

@@ -187,6 +211,115 @@ import { HttpRouter } from 'bunshine';

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:
```ts
import { HttpRouter, type Context, type NextFunction } from 'bunshine';
const app = new HttpRouter();
// ❌ Incorrect asynchronous handler
app.get('/hello', (c: Context, next: NextFunction) => {
setTimeout(() => {
next(new Response('Hello World!'));
}, 1000);
});
// ✅ Correct asynchronous handler
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:
```ts
import { HttpRouter, type Context, type NextFunction } from 'bunshine';
const app = new HttpRouter();
// ❌ Incorrect use of next
app.get('/hello', (c: Context, next: NextFunction) => {
const resp = next();
// do stuff with response
});
// ✅ Correct use of next
app.get('/hello', async (c: Context, next: NextFunction) => {
const resp = await next();
// do stuff with response
});
```
And finally, it means that `.use()` is just a convenience function for
registering middleware. Consider the following:
```ts
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
// The following 2 are the same
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:
```ts
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
// middleware can be inserted with parameters
app.get('/admin', getAuthMiddleware('admin'), middleware2, handler);
// Bunshine accepts any number of middleware functions in parameters or arrays
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:
```ts
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');
// code here will only run if checkPermission hasn't thrown a Response
});
// start the server
app.listen({ port: 3100 });
```
## WebSockets
Setting up websockets at various paths is easy with the `socket` property.
```ts

@@ -201,3 +334,3 @@ import { HttpRouter } from 'bunshine';

// WebSocket routes
app.socket.at<{ user: string }>('/games/rooms/:room', {
app.socket.at('/games/rooms/:room', {
// Optional. Allows you to specify arbitrary data to attach to ws.data.

@@ -258,2 +391,5 @@ upgrade: ({ request, params, url }) => {

And WebSockets make it super easy to create a pub-sub system with no external
dependencies.
```ts

@@ -287,6 +423,15 @@ import { HttpRouter } from 'bunshine';

});
const server = app.listen({ port: 3100 });
// at a later time, publish a message from another source
server.publish(channel, message);
```
## Server Sent Events
## 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.
```ts

@@ -322,2 +467,68 @@ import { HttpRouter } from 'bunshine';

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 the browser will understand to mean "stop listening". Here is an
example:
```ts
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
app.get('/convert-video/:videoId', c => {
const { videoId } = c.params;
return c.sse(send => {
const onProgress = percent => {
send('progress', percent);
};
const onComplete = () => {
send('progress', 'complete');
};
startVideoConversion(videoId, onProgress, onComplete);
});
});
// start the server
app.listen({ port: 3100 });
//
// Browser side:
//
const conversionProgress = new EventSource('/convert-video/123');
conversionProgress.addEventListener('progress', e => {
if (e.data === 'complete') {
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:
```ts
//
// Server side
//
app.get('/hello', c => {
const { videoId } = c.params;
return c.sse(send => {
send('event1', 'data1');
send('event2', 'data2');
});
});
//
// Browser side:
//
const events = new EventSource('/hello');
events.addEventListener('event1', listener1);
events.addEventListener('event2', listener2);
```
## Routing examples

@@ -344,7 +555,9 @@

## Middleware
## Included middleware
### serveFiles
Serve static files from a directory.
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 this for video streaming or partial downloads.

@@ -356,3 +569,3 @@ ```ts

app.get('/public', serveFiles(`${import.meta.dir}/public`));
app.get('/public/*', serveFiles(`${import.meta.dir}/public`));

@@ -364,10 +577,265 @@ app.listen({ port: 3100 });

### devLogger
To add CORS headers to some/all responses, use the `cors` middleware.
### performanceLogger
```ts
import { HttpRouter, cors } from 'bunshine';
### prodLogger
const app = new HttpRouter();
// most basic cors examples
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 => getOrigin(incomingOrigin) }));
// All options
app.use(
cors({
origin: 'https://example.com',
allowMethods: ['GET', 'POST'],
allowHeaders: ['X-HTTP-Method-Override', 'Authorization'],
maxAge: 86400,
credentials: true,
exposeHeaders: ['X-Response-Id'],
})
);
// and of course, cors can be attached at a specific path
app.all('/api', cors({ origin: '*' }));
app.listen({ port: 3100 });
```
### devLogger & prodLogger
`devLogger` outputs colorful logs in the form
`[timestamp] METHOD PATHNAME STATUS_CODE (RESPONSE_TIME)`.
For example: `[19:10:50.276Z] GET / 200 (5ms)`.
`prodLogger` outputs logs in JSON with the following shape:
Request log:
```json
{
"date": "2021-08-01T19:10:50.276Z",
"method": "GET",
"pathname": "/",
"runtime": "Bun 1.0.16",
"machine": "server1",
"pid": 1,
"id": "ea98fe2e-45e0-47d1-9344-2e3af680d6a7"
}
```
Response log:
```json
{
"date": "2021-08-01T19:10:50.276Z",
"method": "GET",
"pathname": "/",
"status": 200,
"runtime": "Bun 1.0.16",
"machine": "server1",
"pid": 1,
"id": "ea98fe2e-45e0-47d1-9344-2e3af680d6a7",
"took": 5
}
```
To use these loggers, simply attach them as middleware.
```ts
import { HttpRouter, devLogger, prodLogger } from 'bunshine';
const app = new HttpRouter();
const logger = process.env.NODE_ENV === 'development' ? devLogger : prodLogger;
app.use(logger());
app.listen({ port: 3100 });
```
### performanceHeader
You can add an X-Took header with the number of milliseconds it took to respond.
```ts
import { HttpRouter, performanceHeader } from 'bunshine';
const app = new HttpRouter();
// Add X-Took header
app.use(performanceHeader());
// Or use a custom header name
app.use(performanceHeader('X-Time-Milliseconds'));
app.listen({ port: 3100 });
```
### securityHeaders
You can add security-related headers to responses with the `securityHeaders`
middleware. For more information about security headers, checkout these
resources:
- [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';
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.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:
```ts
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
app.post<{ id: string }>('/users/:id', async c => {
// TypeScript now knows that c.params.id is a string
});
app.get<{ 0: string }>('/auth/*', async c => {
// TypeScript now knows that c['0'] is a string
});
app.listen({ port: 3100 });
```
### Typing WebSocket data
```ts
import { HttpRouter } from 'bunshine';
const app = new HttpRouter();
// regular routes
app.get('/', c => c.text('Hello World!'));
type User = {
nickname: string;
email: string;
first: string;
last: string;
};
// WebSocket routes
app.socket.at<{ room: string }, { user: User }>('/games/rooms/:room', {
upgrade: ({ request, params, url }) => {
// Typescript knows that ws.data.params.room is a string
const cookies = req.headers.get('cookie');
const user = getUserFromCookies(cookies);
// here user is typed as User
return { user };
},
open(ws) {
// TypeScript knows that ws.data.params.room is a string
// TypeScript knows that ws.data.user is a User
},
message(ws, message) {
// TypeScript knows that ws.data.params.room is a string
// TypeScript knows that ws.data.user is a User
},
close(ws, code, message) {
// TypeScript knows that ws.data.params.room is a string
// TypeScript knows that ws.data.user is a User
},
});
// start the server
app.listen({ port: 3100 });
```
## Roadmap

@@ -378,2 +846,3 @@

- ✅ Context
- ✅ examples/server.ts
- ✅ middleware > serveFiles

@@ -383,5 +852,12 @@ - ✅ middleware > cors

- ✅ middleware > prodLogger
- 🔲 middleware > performanceLogger
- 🔲 middleware > securityHeaders
- 🔲 examples/server.ts
- ✅ middleware > performanceHeader
- ✅ middleware > securityHeaders
- ✅ middleware > trailingSlashes
- 🔲 middleware > compression
- 🔲 options for serveFiles
- 🔲 tests for cors
- 🔲 tests for devLogger
- 🔲 tests for prodLogger
- 🔲 tests for serveFiles
- 🔲 more examples
- 🔲 GitHub Actions to run tests and coverage

@@ -388,0 +864,0 @@

@@ -16,9 +16,18 @@ import type { BunFile, Server } from 'bun';

export default class Context {
export default class Context<
ParamsShape extends Record<string, string> = Record<string, string>,
> {
/** The raw request object */
request: Request;
/** The Bun server instance */
server: Server;
/** The HttpRouter instance */
app: HttpRouter;
params: Record<string, string> = {};
/** The request params from URL placeholders */
params: ParamsShape = {} as ParamsShape;
/** A place to persist data between handlers for the duration of the request */
locals: Record<string, any> = {};
/** Handlers registered with app.on500() can see this Error object */
error: Error | undefined;
/** A URL object constructed with `new URL(request.url)` */
url: URL;

@@ -31,8 +40,18 @@ constructor(request: Request, server: Server, app: HttpRouter) {

}
getIp = () => {
return this.server.requestIP(this.request);
};
/** A shorthand for `new Response(text, { headers: { 'Content-type': 'text/plain' } })` */
text = text;
/** A shorthand for `new Response(js, { headers: { 'Content-type': 'text/javascript' } })` */
js = js;
/** A shorthand for `new Response(html, { headers: { 'Content-type': 'text/html' } })` */
html = html;
/** A shorthand for `new Response(xml, { headers: { 'Content-type': 'text/xml' } })` */
xml = xml;
/** A shorthand for `new Response(JSON.stringify(data), { headers: { 'Content-type': 'application/json' } })` */
json = json;
/** A shorthand for `new Response(null, { headers: { Location: url }, status: 301 })` */
redirect = redirect;
/** A shorthand for `new Response(fileBody, fileHeaders)` */
file = async (

@@ -52,2 +71,3 @@ filenameOrBunFile: string | BunFile,

};
/** A shorthand for `new Response({ headers: { 'Content-type': 'text/event-stream' } })` */
sse = (setup: SseSetupFunction, init: ResponseInit = {}) => {

@@ -54,0 +74,0 @@ return sse(this.request.signal, setup, init);

import type { Server } from 'bun';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import EventSource from 'eventsource';

@@ -289,4 +289,4 @@ import HttpRouter from './HttpRouter';

app.get('/', () => new Response('Hi'));
server = app.listen({ port: 7772 });
const resp = await fetch('http://localhost:7772/');
server = app.listen({ port: 7773 });
const resp = await fetch('http://localhost:7773/');
expect(resp.status).toBe(200);

@@ -301,4 +301,4 @@ expect(await resp.text()).toBe('Hi');

});
server = app.listen({ port: 7772 });
const resp = await fetch('http://localhost:7772/', {
server = app.listen({ port: 7774 });
const resp = await fetch('http://localhost:7774/', {
method: 'PUT',

@@ -324,4 +324,4 @@ headers: {

});
server = app.listen({ port: 7772 });
const resp = await fetch('http://localhost:7772/hi?name=Bob', {
server = app.listen({ port: 7775 });
const resp = await fetch('http://localhost:7775/hi?name=Bob', {
method: 'HEAD',

@@ -343,6 +343,6 @@ });

});
server = app.listen({ port: 7772 });
server = app.listen({ port: 7776 });
const formData = new URLSearchParams();
formData.append('key', 'secret');
const resp = await fetch('http://localhost:7772/parrot', {
const resp = await fetch('http://localhost:7776/parrot', {
method: 'POST',

@@ -369,6 +369,6 @@ headers: {

});
server = app.listen({ port: 7772 });
server = app.listen({ port: 7777 });
const formData = new FormData();
formData.append('key2', 'secret2');
const resp = await fetch('http://localhost:7772/parrot', {
const resp = await fetch('http://localhost:7777/parrot', {
method: 'POST',

@@ -387,4 +387,4 @@ body: formData,

});
server = app.listen({ port: 7772 });
const resp = await fetch('http://localhost:7772/', {
server = app.listen({ port: 7778 });
const resp = await fetch('http://localhost:7778/', {
method: 'PATCH',

@@ -408,4 +408,4 @@ headers: {

});
server = app.listen({ port: 7772 });
const resp = await fetch('http://localhost:7772/', {
server = app.listen({ port: 7779 });
const resp = await fetch('http://localhost:7779/', {
method: 'TRACE',

@@ -430,4 +430,4 @@ headers: {

});
server = app.listen({ port: 7772 });
const resp = await fetch('http://localhost:7772/users/42', {
server = app.listen({ port: 7780 });
const resp = await fetch('http://localhost:7780/users/42', {
method: 'DELETE',

@@ -447,4 +447,4 @@ });

});
server = app.listen({ port: 7772 });
const resp = await fetch('http://localhost:7772/users/42', {
server = app.listen({ port: 7781 });
const resp = await fetch('http://localhost:7781/users/42', {
method: 'OPTIONS',

@@ -459,5 +459,5 @@ });

});
server = app.listen({ port: 7772 });
server = app.listen({ port: 7782 });
app.locals.foo = 'bar';
const resp = await fetch('http://localhost:7772/home');
const resp = await fetch('http://localhost:7782/home');
expect(resp.status).toBe(200);

@@ -474,4 +474,4 @@ expect(await resp.text()).toBe('bar');

});
server = app.listen({ port: 7772 });
const stream = new EventSource('http://localhost:7772/home');
server = app.listen({ port: 7783 });
const stream = new EventSource('http://localhost:7783/home');
let messages: string[] = [];

@@ -485,6 +485,8 @@ stream.addEventListener('open', () => {

expect(stream).toBeInstanceOf(EventSource);
await new Promise(r => setTimeout(r, 40));
await new Promise(r => setTimeout(r, 100));
expect(messages).toEqual(['open', 'hello', 'hello2']);
stream.close();
});
it('should enable named EventSource', async () => {
// TODO: change EventSource tests to use Promises instead of timeouts
app.get('/home', c => {

@@ -497,4 +499,4 @@ return c.sse((send, close) => {

});
server = app.listen({ port: 7772 });
const stream = new EventSource('http://localhost:7772/home');
server = app.listen({ port: 7784 });
const stream = new EventSource('http://localhost:7784/home');
let messages: Array<{

@@ -514,3 +516,3 @@ name: string;

});
await new Promise(r => setTimeout(r, 40));
await new Promise(r => setTimeout(r, 100));
expect(messages).toEqual([

@@ -521,3 +523,3 @@ {

id: 'id1',
origin: 'http://localhost:7772',
origin: 'http://localhost:7784',
},

@@ -528,3 +530,3 @@ {

id: 'id2',
origin: 'http://localhost:7772',
origin: 'http://localhost:7784',
},

@@ -540,4 +542,4 @@ ]);

});
server = app.listen({ port: 7772 });
const stream = new EventSource('http://localhost:7772/home');
server = app.listen({ port: 7785 });
const stream = new EventSource('http://localhost:7785/home');
let messages: any[] = [];

@@ -552,3 +554,83 @@ stream.addEventListener('myEvent', evt => {

});
it('should JSON encode data if needed', done => {
const readyToSend = new Promise((resolve, reject) => {
app.get('/home', c => {
return c.sse(send => {
resolve(() => {
send('myEvent', { hello: '7786' }, 'id1');
});
});
});
app.onError(c => reject(c.error));
server = app.listen({ port: 7786 });
}) as Promise<() => void>;
const readyToListen = new Promise((resolve, reject) => {
const stream = new EventSource('http://localhost:7786/home');
stream.addEventListener('error', evt => {
reject();
console.log('-------------------------------');
console.log('Stream at 7786 got error event:', evt);
expect(false).toBe(true);
done();
stream.close();
});
stream.addEventListener('myEvent', evt => {
expect(evt.type).toBe('myEvent');
expect(evt.data).toBe('{"hello":"7786"}');
expect(evt.lastEventId).toBe('id1');
expect(evt.origin).toBe('http://localhost:7786');
done();
stream.close();
});
resolve(7786);
}) as Promise<number>;
Promise.all([readyToSend, readyToListen]).then(([doSend]) => doSend());
});
it('should warn when overriding some headers', async () => {
spyOn(console, 'warn').mockImplementation(() => {});
app.get('/home', c => {
return c.sse(send => send('data'), {
headers: {
'Content-Type': 'text/plain',
'Cache-Control': 'foo',
Connection: 'whatever',
},
});
});
app.onError(c => {
console.log('app.onError', c.error);
});
server = app.listen({ port: 7787 });
const stream = new EventSource('http://localhost:7787/home');
stream.addEventListener('myEvent', () => {});
await new Promise(r => setTimeout(r, 100));
stream.close();
expect(console.warn).toHaveBeenCalledTimes(3);
// @ts-expect-error
console.warn.mockRestore();
});
it('should not warn if those headers are correct', async () => {
spyOn(console, 'warn').mockImplementation(() => {});
app.get('/home', c => {
return c.sse(send => send('data'), {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
});
app.onError(c => {
console.log('app.onError', c.error);
});
server = app.listen({ port: 7788 });
const stream = new EventSource('http://localhost:7788/home');
stream.addEventListener('myEvent', () => {});
await new Promise(r => setTimeout(r, 100));
stream.close();
expect(console.warn).toHaveBeenCalledTimes(0);
// @ts-expect-error
console.warn.mockRestore();
});
});
});
import type { ServeOptions, Server } from 'bun';
// @ts-ignore
import bunshine from '../../package.json';
import Context from '../Context/Context';
import MatcherWithCache from '../MatcherWithCache/MatcherWithCache.ts';
import PathMatcher from '../PathMatcher/PathMatcher';

@@ -10,14 +13,20 @@ import SocketRouter from '../SocketRouter/SocketRouter.ts';

export type SingleHandler = (
context: Context,
export type SingleHandler<
ParamsShape extends Record<string, string> = Record<string, string>,
> = (
context: Context<ParamsShape>,
next: NextFunction
) => Response | void | Promise<Response | void>;
export type Middleware = SingleHandler;
export type Middleware<
ParamsShape extends Record<string, string> = Record<string, string>,
> = SingleHandler<ParamsShape>;
export type Handler = SingleHandler | Handler[];
export type Handler<
ParamsShape extends Record<string, string> = Record<string, string>,
> = SingleHandler<ParamsShape> | Handler<ParamsShape>[];
type RouteInfo = {
verb: string;
handler: Handler;
handler: Handler<any>;
};

@@ -52,11 +61,55 @@

export type HttpRouterOptions = {
cacheSize?: number;
};
export type EmitUrlOptions = {
verbose?: boolean;
};
export default class HttpRouter {
version: string = bunshine.version;
locals: Record<string, any> = {};
pathMatcher: PathMatcher<RouteInfo> = new PathMatcher<RouteInfo>();
server: Server | undefined;
pathMatcher: MatcherWithCache<RouteInfo>;
_wsRouter?: SocketRouter;
_onErrors: any[] = [];
_on404s: any[] = [];
constructor(options: HttpRouterOptions = {}) {
this.pathMatcher = new MatcherWithCache<RouteInfo>(
new PathMatcher(),
options.cacheSize || 4000
);
}
respectSigTerm = ({ closeActiveConnections = true } = {}) => {
['SIGTERM', 'SIGINT'].forEach(signal => {
process.once(signal, () => {
console.log(`☀️ Received ${signal}, shutting down.`);
this.server?.stop(closeActiveConnections);
});
});
};
listen = (options: Omit<ServeOptions, 'fetch'> = {}) => {
return Bun.serve(this.getExport(options));
const server = Bun.serve(this.getExport(options));
this.server = server;
return server;
};
emitUrl = (options: EmitUrlOptions = { verbose: false }) => {
if (!this.server) {
throw new Error(
'Cannot emit URL before server has been started. Call .listen() first.'
);
}
const servingAt = String(this.server.url);
if (options.verbose) {
const server = Bun.env.COMPUTERNAME || Bun.env.HOSTNAME;
const mode = Bun.env.NODE_ENV || 'production';
const took = Math.round(performance.now());
console.log(
`☀️ Bunshine v${bunshine.version} on Bun v${Bun.version} running at ${servingAt} on server "${server}" in ${mode} (${took}ms)`
);
} else {
console.log(`☀️ Serving ${servingAt}`);
}
};
getExport = (options: Omit<ServeOptions, 'fetch' | 'websocket'> = {}) => {

@@ -80,6 +133,6 @@ const config = {

}
on = (
on = <ParamsShape extends Record<string, string> = Record<string, string>>(
verbOrVerbs: HttpMethods | HttpMethods[],
path: string | RegExp,
...handlers: Handler[]
...handlers: Handler<ParamsShape>[]
) => {

@@ -95,3 +148,3 @@ if (Array.isArray(verbOrVerbs)) {

verb: verbOrVerbs as string,
handler: handler as SingleHandler,
handler: handler as SingleHandler<ParamsShape>,
});

@@ -101,29 +154,51 @@ }

};
all = (path: string | RegExp, ...handlers: Handler[]) =>
this.on('ALL', path, handlers);
get = (path: string | RegExp, ...handlers: Handler[]) =>
this.on('GET', path, handlers);
put = (path: string | RegExp, ...handlers: Handler[]) =>
this.on('PUT', path, handlers);
head = (path: string | RegExp, ...handlers: Handler[]) =>
this.on('HEAD', path, handlers);
post = (path: string | RegExp, ...handlers: Handler[]) =>
this.on('POST', path, handlers);
patch = (path: string | RegExp, ...handlers: Handler[]) =>
this.on('PATCH', path, handlers);
trace = (path: string | RegExp, ...handlers: Handler[]) =>
this.on('TRACE', path, handlers);
delete = (path: string | RegExp, ...handlers: Handler[]) =>
this.on('DELETE', path, handlers);
options = (path: string | RegExp, ...handlers: Handler[]) =>
this.on('OPTIONS', path, handlers);
use = (...handlers: Handler[]) => {
all = <ParamsShape extends Record<string, string> = Record<string, string>>(
path: string | RegExp,
...handlers: Handler<ParamsShape>[]
) => this.on<ParamsShape>('ALL', path, handlers);
get = <ParamsShape extends Record<string, string> = Record<string, string>>(
path: string | RegExp,
...handlers: Handler<ParamsShape>[]
) => this.on<ParamsShape>('GET', path, handlers);
put = <ParamsShape extends Record<string, string> = Record<string, string>>(
path: string | RegExp,
...handlers: Handler<ParamsShape>[]
) => this.on<ParamsShape>('PUT', path, handlers);
head = <ParamsShape extends Record<string, string> = Record<string, string>>(
path: string | RegExp,
...handlers: Handler<ParamsShape>[]
) => this.on<ParamsShape>('HEAD', path, handlers);
post = <ParamsShape extends Record<string, string> = Record<string, string>>(
path: string | RegExp,
...handlers: Handler<ParamsShape>[]
) => this.on<ParamsShape>('POST', path, handlers);
patch = <ParamsShape extends Record<string, string> = Record<string, string>>(
path: string | RegExp,
...handlers: Handler<ParamsShape>[]
) => this.on<ParamsShape>('PATCH', path, handlers);
trace = <ParamsShape extends Record<string, string> = Record<string, string>>(
path: string | RegExp,
...handlers: Handler<ParamsShape>[]
) => this.on<ParamsShape>('TRACE', path, handlers);
delete = <
ParamsShape extends Record<string, string> = Record<string, string>,
>(
path: string | RegExp,
...handlers: Handler<ParamsShape>[]
) => this.on<ParamsShape>('DELETE', path, handlers);
options = <
ParamsShape extends Record<string, string> = Record<string, string>,
>(
path: string | RegExp,
...handlers: Handler<ParamsShape>[]
) => this.on<ParamsShape>('OPTIONS', path, handlers);
use = (...handlers: Handler<{}>[]) => {
this.all('*', handlers);
return this;
};
onError = (...handlers: Handler[]) => {
onError = (...handlers: Handler<Record<string, string>>[]) => {
this._onErrors.push(...handlers.flat(9));
return this;
};
on404 = (...handlers: Handler[]) => {
on404 = (...handlers: Handler<Record<string, string>>[]) => {
this._on404s.push(...handlers.flat(9));

@@ -141,10 +216,12 @@ return this;

const matched = this.pathMatcher.match(pathname, filter, this._on404s);
let i = 0;
const next: NextFunction = async () => {
const generated = matched.next();
if (generated.done) {
const match = matched[i++];
if (!match) {
return fallback404(context);
}
const match = generated.value;
context.params = match!.params;
const handler = match!.target.handler as SingleHandler;
context.params = match.params;
const handler = match.target.handler as SingleHandler<
Record<string, string>
>;

@@ -170,2 +247,3 @@ try {

if (e instanceof Response) {
// a response has been thrown; respond to client with it
return e;

@@ -172,0 +250,0 @@ }

@@ -58,9 +58,9 @@ import { BunFile } from 'bun';

}
// Bun has a bug when setting content-length and content-range automatically
// so convert file to buffer
let buffer = await file.arrayBuffer();
// the range is less than the entire file
if (end - 1 < totalFileSize) {
file = file.slice(start, end + 1);
buffer = buffer.slice(start, end + 1);
}
// Bun has a bug when setting content-length and content-range automatically
// so convert file to buffer
const buffer = await file.arrayBuffer();
resp = new Response(buffer, { ...responseInit, status: 206 });

@@ -76,6 +76,4 @@ if (!resp.headers.has('Content-Type')) {

}
if (!resp.headers.has('Accept-Ranges')) {
// tell the client that we are capable of handling range requests
resp.headers.set('Accept-Ranges', 'bytes');
}
// tell the client that we are capable of handling range requests
resp.headers.set('Accept-Ranges', 'bytes');
return resp;

@@ -86,3 +84,3 @@ };

eventName: string,
data?: string,
data?: string | object,
id?: string,

@@ -104,8 +102,9 @@ retry?: number

async start(controller: ReadableStreamDefaultController) {
// create encoder to handle utf8
// Step 1: create encoder to handle utf8
const encoder = new TextEncoder();
// define the send and close functions
// Step 2: define the send and close functions
function send(
eventName: string,
data?: string,
data?: string | object,
id?: string,

@@ -118,3 +117,6 @@ retry?: number

} else {
let message = `event: ${eventName}\ndata:${data}`;
if (data && typeof data !== 'string') {
data = JSON.stringify(data);
}
let message = `event: ${eventName}\ndata:${String(data)}`;
if (id) {

@@ -130,2 +132,3 @@ message += `\nid: ${id}`;

if (signal.aborted) {
// client disconnected already
close();

@@ -145,2 +148,3 @@ } else {

}
// setup and listen for abort signal

@@ -152,2 +156,3 @@ const cleanup = setup(send, close);

if (signal.aborted) {
/* c8 ignore next */
close();

@@ -157,10 +162,17 @@ }

});
let headers = new Headers(init.headers);
if (headers.has('Content-Type')) {
if (
headers.has('Content-Type') &&
headers.get('Content-Type') !== 'text/event-stream'
) {
console.warn('Overriding Content-Type header to `text/event-stream`');
}
if (headers.has('Cache-Control')) {
if (
headers.has('Cache-Control') &&
headers.get('Cache-Control') !== 'no-cache'
) {
console.warn('Overriding Cache-Control header to `no-cache`');
}
if (headers.has('Connection')) {
if (headers.has('Connection') && headers.get('Connection') !== 'keep-alive') {
console.warn('Overriding Connection header to `keep-alive`');

@@ -167,0 +179,0 @@ }

@@ -1,6 +0,13 @@

import type {Middleware} from '../../HttpRouter/HttpRouter';
import type Context from '../../Context/Context';
import type { Middleware } from '../../HttpRouter/HttpRouter';
export type CorsOptions = {
origin: string | string[] | ((origin: string) => string | undefined | null);
origin:
| string
| RegExp
| Array<string | RegExp>
| boolean
| ((
incomingOrigin: string
) => string | string[] | boolean | undefined | null);
allowMethods?: string[];

@@ -28,7 +35,38 @@ allowHeaders?: string[];

return () => optsOrigin;
} else if (optsOrigin instanceof RegExp) {
return (incoming: string) =>
optsOrigin.test(incoming) ? incoming : null;
} else if (Array.isArray(optsOrigin)) {
return (incoming: string) => {
for (const origin of optsOrigin) {
if (
(typeof origin === 'string' && origin === incoming) ||
(origin instanceof RegExp && origin.test(incoming))
) {
return incoming;
}
}
return null;
};
} else if (optsOrigin === true) {
return (incoming: string) => incoming;
} else if (optsOrigin === false) {
return () => null;
} else if (typeof optsOrigin === 'function') {
return optsOrigin;
return (incoming: string, c: Context) => {
const origins = optsOrigin(incoming, c);
if (origins === true) {
return incoming;
} else if (origins === false) {
return null;
} else if (Array.isArray(origins)) {
return origins.includes(incoming) ? incoming : null;
} else if (typeof origins === 'string') {
return origins;
} else {
return null;
}
};
} else {
return (origin: string) =>
optsOrigin.includes(origin) ? origin : optsOrigin[0];
throw new Error('Invalid cors origin option');
}

@@ -69,3 +107,6 @@ })(opts.origin);

function addAccessHeaders(c: Context, response: Response) {
const allowOrigin = findAllowOrigin(c.request.headers.get('origin') || '');
const incomingOrigin = c.request.headers.get('origin');
const allowOrigin = incomingOrigin
? findAllowOrigin(incomingOrigin, c)
: null;
if (allowOrigin) {

@@ -72,0 +113,0 @@ response.headers.set('Access-Control-Allow-Origin', allowOrigin);

@@ -12,9 +12,9 @@ import type { Middleware } from '../../HttpRouter/HttpRouter.ts';

const range = c.request.headers.get('Range');
let maybeRange = range ? ` \x1b[37m${range}\x1b[0m` : '';
let maybeRange = range ? ` ${gray(range)}` : '';
// log response status
const ms = (performance.now() - start).toFixed(1);
process.stdout.write(
`\x1b[0m\x1b[37m[${time}]\x1b[0m ${c.request.method} \x1b[92m${pathname}\x1b[0m `
`${gray(`[${time}]`)} ${c.request.method} ${green(pathname)} `
);
console.log(`\x1b[0m\x1b[96m${resp.status}\x1b[0m${maybeRange} (${ms}ms)`);
console.log(`${cyan(String(resp.status))}${maybeRange} (${ms}ms)`);
// return response

@@ -24,1 +24,5 @@ return resp;

}
const gray = (s: string) => `\x1b[90m${s}\x1b[0m`;
const green = (s: string) => `\x1b[92m${s}\x1b[0m`;
const cyan = (s: string) => `\x1b[96m${s}\x1b[0m`;
import os from 'os';
// @ts-ignore
import bunshine from '../../../package.json';
import type { Middleware } from '../../HttpRouter/HttpRouter.ts';
const machine = os.hostname();
const runtime = `Bun ${Bun.version}`;
const runtime = `Bun v${Bun.version}`;
const poweredBy = `Bunshine v${bunshine.version}`;
export function prodLogger(): Middleware {
return async (c, next) => {
const start = performance.now();
const { pathname, host, protocol } = c.url;
const base = `${protocol}//${host}`;
const { pathname, host } = c.url;
const date = new Date().toISOString();
const id = crypto.randomUUID();
// write request
// log request
process.stdout.write(
JSON.stringify({
runtime,
machine,
pid: process.pid,
msg: 'HTTP request',
date,
id,
host,
method: c.request.method,
base,
pathname,
runtime,
poweredBy,
machine,
}) + '\n'
);
// get response
// wait for response
const resp = await next();
// log response status
const took = performance.now() - start;
// log response info
const took = Math.round((performance.now() - start) * 1000) / 1000;
process.stdout.write(
JSON.stringify({
runtime,
machine,
pid: process.pid,
msg: 'HTTP response',
date,
id,
host,
method: c.request.method,
base,
pathname,
status: resp.status,
runtime,
poweredBy,
machine,
took,
status: resp.status,
}) + '\n'

@@ -43,0 +48,0 @@ );

@@ -0,61 +1,162 @@

import bunshine from '../../../package.json';
import Context from '../../Context/Context.ts';
import type { Middleware } from '../../HttpRouter/HttpRouter.ts';
import type { Middleware, NextFunction } from '../../HttpRouter/HttpRouter.ts';
import type {
AllowedApis,
CSPDirectives,
CSPSource,
ReportOptions,
SandboxOptions,
SecurityHeaderOptions,
SecurityHeaderValue,
} from './securityHeaders.types.ts';
export type SecurityHeader =
| string
| null
| false
| undefined
| number
| ((context: Context) => string | null | false | undefined | number);
export type SecurityHeaderOptions = {
server?: SecurityHeader;
xPoweredBy?: SecurityHeader;
strictTransportSecurity?: SecurityHeader;
xXssProtection?: SecurityHeader;
xContentTypeOptions?: SecurityHeader;
xFrameOptions?: SecurityHeader;
referrerPolicy?: SecurityHeader;
contentSecurityPolicy?: SecurityHeader;
accessControlAllowOrigin?: SecurityHeader;
permissionsPolicy?: SecurityHeader;
crossOriginEmbedderPolicy?: SecurityHeader;
crossOriginOpenerPolicy?: SecurityHeader;
crossOriginResourcePolicy?: SecurityHeader;
const defaultValues: SecurityHeaderOptions = {
accessControlAllowOrigin: '*',
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: {},
report: {},
},
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',
strictTransportSecurity: 'max-age=86400; includeSubDomains; preload',
xContentTypeOptions: 'nosniff',
xFrameOptions: 'SAMEORIGIN',
xPoweredBy: false,
xXssProtection: '1; mode=block',
};
const defaultOptions: SecurityHeaderOptions = {
server: false,
xPoweredBy: false,
strictTransportSecurity: '',
xXssProtection: '',
xContentTypeOptions: '',
xFrameOptions: '',
referrerPolicy: '',
contentSecurityPolicy: '',
accessControlAllowOrigin: '',
permissionsPolicy: '',
crossOriginEmbedderPolicy: '',
crossOriginOpenerPolicy: '',
crossOriginResourcePolicy: '',
const permissionsPolicyDefaults: AllowedApis = {
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: [],
};
export function securityHeaders(options: SecurityHeaderOptions): Middleware {
const headers: Array<[string, SecurityHeader]> = Object.entries({
...defaultOptions,
export function securityHeaders(
options: SecurityHeaderOptions = {}
): Middleware {
const headers: Record<string, any> = {
values: [],
functions: [],
};
const resolved = {
...defaultValues,
...options,
}).map(([name, value]) => {
return [dasherize(name), value];
});
return async (context, next) => {
const resp = await next();
for (const [name, value] of headers) {
const finalValue = typeof value === 'function' ? value(context) : value;
if (finalValue) {
resp.headers.set(name, String(finalValue));
} else {
resp.headers.delete(name);
};
for (const [name, value] of Object.entries(resolved)) {
if (typeof value === 'function') {
headers.functions.push([name, value]);
} else {
const resolved = _resolveHeaderValue(name, value);
if (resolved) {
headers.values.push([_dasherize(name), resolved]);
}
}
}
return async (context: Context, next: NextFunction) => {
const resp = await next();
if (!resp.headers.get('content-type')?.includes('text/html')) {
// no need to set security headers for non-html responses
return resp;
}
for (let [dasherizedName, value] of headers.values) {
resp.headers.set(dasherizedName, value);
}
for (let [rawName, value] of headers.functions) {
try {
let resolved = _resolveHeaderValue(rawName, value(context));
// @ts-expect-error
if (resolved && typeof resolved.then === 'function') {
resolved = await resolved;
}
if (typeof resolved === 'string' && resolved !== '') {
resp.headers.set(_dasherize(rawName), resolved);
}
} catch (e) {}
}
return resp;

@@ -65,4 +166,99 @@ };

function dasherize(str: string): string {
function _resolveHeaderValue(
name: string,
value: SecurityHeaderValue | AllowedApis | CSPDirectives
) {
if (value === false || value === null || value === undefined) {
return;
}
if (name === 'xPoweredBy' && value === true) {
return `Bunshine v${bunshine.version}`;
} else if (value === true) {
// @ts-expect-error
value = defaultValues[name];
}
if (name === 'contentSecurityPolicy') {
return _getCspHeader(value as CSPDirectives);
} else if (name === 'permissionsPolicy') {
return _getPpHeader(value as AllowedApis);
}
return value;
}
function _dasherize(str: string): string {
return str.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`);
}
function _getCspHeader(directives: CSPDirectives) {
const items = [];
for (let [key, originalValue] of Object.entries(directives)) {
let value:
| true
| CSPSource[]
| SandboxOptions
| ReportOptions
| string
| undefined = originalValue;
if (key === 'sandbox' && typeof value === 'object') {
value = _getSandboxString(value as SandboxOptions);
} else if (key === 'report' && typeof value === 'object') {
value = _getReportString(value as ReportOptions);
} else if (Array.isArray(value) && value.length > 0) {
items.push(`${_dasherize(key)} ${value.map(_getCspItem).join(' ')}`);
}
if (typeof value === 'string' && value !== '') {
items.push(value);
}
}
return items.join('; ');
}
function _getCspItem(source: CSPSource) {
if (typeof source === 'string') {
return source;
} else if ('uris' in source) {
return source.uris.join(' ');
} else if ('uri' in source) {
return source.uri;
} else if ('nonce' in source) {
return `nonce-${source.nonce}`;
} else if ('nonces' in source) {
return source.nonces.map(n => `nonce-${n}`).join(' ');
} else if ('hash' in source) {
return source.hash;
} else if ('hashes' in source) {
return source.hashes.join(' ');
}
}
function _getPpHeader(apis: AllowedApis) {
const final = { ...permissionsPolicyDefaults, ...apis };
const items = [];
for (const [name, value] of Object.entries(final)) {
items.push(`${_dasherize(name)}=(${value.join(' ')})`);
}
return items.join(', ');
}
function _getSandboxString(options: SandboxOptions) {
const items = [];
for (const [name, value] of Object.entries(options)) {
if (value) {
items.push(_dasherize(name));
}
}
if (items.length === 0) {
return '';
}
items.unshift('sandbox');
return items.join(' ');
}
function _getReportString(reportOption: ReportOptions) {
if (reportOption.uri) {
return `report-uri ${reportOption.uri}`;
}
if (reportOption.to) {
return `report-to ${reportOption.to}`;
}
}
import path from 'path';
import type { Middleware } from '../../HttpRouter/HttpRouter.ts';
export function serveFiles(directory: string): Middleware {
// see https://expressjs.com/en/4x/api.html#express.static
// and https://www.npmjs.com/package/send#dotfiles
export type StaticOptions = {
acceptRanges?: boolean;
cacheControl?: boolean;
dotfiles?: 'allow' | 'deny' | 'ignore';
etag?: boolean;
extensions?: string[];
fallthrough?: boolean;
immutable?: boolean;
index?: boolean | string | string[];
lastModified?: boolean;
maxAge?: number | string;
redirect?: boolean;
setHeaders?: Middleware;
};
export function serveFiles(
directory: string,
{
acceptRanges = true,
cacheControl = true,
dotfiles = 'ignore',
etag = true,
extensions = [],
fallthrough = true,
immutable = false,
index = false,
lastModified = true,
maxAge = undefined,
redirect = true,
setHeaders,
}: StaticOptions = {}
): Middleware {
return c => {
return c.file(path.join(directory, c.url.pathname));
return c.file(path.join(directory, c.params[0] || c.url.pathname));
};
}

@@ -40,3 +40,3 @@ import { match } from 'path-to-regexp';

}
*match(
match(
path: string,

@@ -46,21 +46,19 @@ filter?: (target: Target) => boolean,

) {
const matched = [];
for (const reg of this.registered) {
const result = reg.matcher(path);
if (result && (!filter || filter(reg.target))) {
yield {
matched.push({
target: reg.target,
params: result.params,
};
});
}
}
if (!fallbacks) {
return;
if (fallbacks) {
for (const fb of fallbacks) {
matched.push({ target: { handler: fb }, params: {} });
}
}
for (const fallback of fallbacks) {
yield {
target: { params: {}, handler: fallback },
params: {},
};
}
return matched;
}
}

@@ -12,6 +12,15 @@ import type { Server, ServerWebSocket } from 'bun';

};
export type SocketUpgradeHandler = (
context: Context
) => Record<string, any> | Promise<Record<string, any>>;
export type SocketErrorHandler<WsDataShape> = (
export type FinalWsDataShape<ParamsShape, UpgradeShape> = Merge<
Merge<DefaultDataShape, { params: ParamsShape }>,
UpgradeShape
>;
export type SocketUpgradeHandler<
ParamsShape extends Record<string, string> = Record<string, string>,
UpgradeShape extends Record<string, any> = Record<string, any>,
> = (context: Context<ParamsShape>) => UpgradeShape | Promise<UpgradeShape>;
export type SocketErrorHandler<
WsDataShape extends Record<string, any> = Record<string, any>,
> = (
ws: ServerWebSocket<WsDataShape>,

@@ -21,34 +30,33 @@ eventName: SocketEventName,

) => void;
export type SocketOpenHandler<WsDataShape> = (
ws: ServerWebSocket<WsDataShape>
) => void;
export type SocketMessageHandler<WsDataShape> = (
ws: ServerWebSocket<WsDataShape>,
message: string | Buffer
) => void;
export type SocketCloseHandler<WsDataShape> = (
ws: ServerWebSocket<WsDataShape>,
status: number,
reason: string
) => void;
export type SocketDrainHandler<WsDataShape> = (
ws: ServerWebSocket<WsDataShape>
) => void;
export type SocketPingHandler<WsDataShape> = (
ws: ServerWebSocket<WsDataShape>,
message: Buffer
) => void;
export type SocketPongHandler<WsDataShape> = (
ws: ServerWebSocket<WsDataShape>,
message: Buffer
) => void;
export type Handlers<WsDataShape> = {
upgrade?: SocketUpgradeHandler;
error?: SocketErrorHandler<WsDataShape>;
open?: SocketOpenHandler<WsDataShape>;
message?: SocketMessageHandler<WsDataShape>;
close?: SocketCloseHandler<WsDataShape>;
drain?: SocketDrainHandler<WsDataShape>;
ping?: SocketPingHandler<WsDataShape>;
pong?: SocketPongHandler<WsDataShape>;
export type SocketOpenHandler<
WsDataShape extends Record<string, any> = Record<string, any>,
> = (ws: ServerWebSocket<WsDataShape>) => void;
export type SocketMessageHandler<
WsDataShape extends Record<string, any> = Record<string, any>,
> = (ws: ServerWebSocket<WsDataShape>, message: string | Buffer) => void;
export type SocketCloseHandler<
WsDataShape extends Record<string, any> = Record<string, any>,
> = (ws: ServerWebSocket<WsDataShape>, status: number, reason: string) => void;
export type SocketDrainHandler<
WsDataShape extends Record<string, any> = Record<string, any>,
> = (ws: ServerWebSocket<WsDataShape>) => void;
export type SocketPingHandler<
WsDataShape extends Record<string, any> = Record<string, any>,
> = (ws: ServerWebSocket<WsDataShape>, message: Buffer) => void;
export type SocketPongHandler<
WsDataShape extends Record<string, any> = Record<string, any>,
> = (ws: ServerWebSocket<WsDataShape>, message: Buffer) => void;
export type WsHandlers<
ParamsShape extends Record<string, string> = Record<string, string>,
UpgradeShape extends Record<string, any> = Record<string, any>,
> = {
upgrade?: SocketUpgradeHandler<ParamsShape, UpgradeShape>;
error?: SocketErrorHandler<FinalWsDataShape<ParamsShape, UpgradeShape>>;
open?: SocketOpenHandler<FinalWsDataShape<ParamsShape, UpgradeShape>>;
message?: SocketMessageHandler<FinalWsDataShape<ParamsShape, UpgradeShape>>;
close?: SocketCloseHandler<FinalWsDataShape<ParamsShape, UpgradeShape>>;
drain?: SocketDrainHandler<FinalWsDataShape<ParamsShape, UpgradeShape>>;
ping?: SocketPingHandler<FinalWsDataShape<ParamsShape, UpgradeShape>>;
pong?: SocketPongHandler<FinalWsDataShape<ParamsShape, UpgradeShape>>;
};

@@ -65,26 +73,30 @@ export type SocketEventName =

httpRouter: HttpRouter;
pathMatcher: PathMatcher<Partial<Handlers<any>>>;
handlers: Handlers<any>;
pathMatcher: PathMatcher<WsHandlers>;
handlers: WsHandlers;
constructor(router: HttpRouter) {
this.httpRouter = router;
this.httpRouter._wsRouter = this;
this.pathMatcher = new PathMatcher<Handlers<any>>();
this.pathMatcher = new PathMatcher<WsHandlers>();
this.handlers = {
open: this._createHandler('open') as SocketOpenHandler<any>,
message: this._createHandler('message') as SocketMessageHandler<any>,
close: this._createHandler('close') as SocketCloseHandler<any>,
drain: this._createHandler('drain') as SocketDrainHandler<any>,
ping: this._createHandler('ping') as SocketPingHandler<any>,
pong: this._createHandler('pong') as SocketPongHandler<any>,
open: this._createHandler('open'),
message: this._createHandler('message'),
close: this._createHandler('close'),
drain: this._createHandler('drain'),
ping: this._createHandler('ping'),
pong: this._createHandler('pong'),
};
}
at = <UpgradeDataShape extends Record<string, any>>(
at = <
ParamsShape extends Record<string, string> = Record<string, string>,
UpgradeShape extends Record<string, any> = Record<string, any>,
>(
path: string,
handlers: Handlers<Merge<DefaultDataShape, UpgradeDataShape>>
handlers: WsHandlers<ParamsShape, UpgradeShape>
) => {
// capture the matcher details
// @ts-expect-error
this.pathMatcher.add(path, handlers);
// console.log('ws handlers registered!', path);
// create a router path that upgrades to a socket
this.httpRouter.get(path, (c: Context) => {
this.httpRouter.get<ParamsShape>(path, c => {
const upgradeData = handlers.upgrade?.(c) || {};

@@ -104,3 +116,3 @@ try {

// See https://bun.sh/guides/websocket/upgrade
return new Response(null, { status: 101 });
return undefined;
}

@@ -118,3 +130,3 @@ } catch (e) {

return c.text('Client does not support WebSocket', {
status: 400,
status: 426, // 426 Upgrade Required
});

@@ -125,4 +137,4 @@ });

};
fallbackError = <WsDataShape extends DefaultDataShape>(
ws: ServerWebSocket<WsDataShape>,
_fallbackError = (
ws: ServerWebSocket<Record<string, any>>,
eventName: string,

@@ -137,3 +149,3 @@ error: Error

_createHandler = (eventName: SocketEventName) => {
return async (ws: ServerWebSocket<any>, ...args: any) => {
return async (ws: ServerWebSocket<Record<string, any>>, ...args: any) => {
const pathname = ws.data.url.pathname;

@@ -154,6 +166,6 @@ const matched = this.pathMatcher.match(pathname);

const error = e as Error;
this.fallbackError(ws, eventName, error);
this._fallbackError(ws, eventName, error);
}
} else {
this.fallbackError(ws, eventName, error);
this._fallbackError(ws, eventName, error);
}

@@ -160,0 +172,0 @@ }

{
"compilerOptions": {
"include": ["*"],
"lib": ["ESNext"],

@@ -4,0 +5,0 @@ "module": "esnext",

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