Comparing version 0.9.3 to 0.10.0
@@ -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(); |
18
index.ts
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'; |
{ | ||
"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" | ||
} | ||
} |
540
README.md
@@ -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" /> | ||
[](https://npmjs.com/package/bunshine) | ||
[](https://opensource.org/licenses/ISC) | ||
[](https://npmjs.com/package/bunshine) | ||
[](https://www.npmjs.com/package/bunshine?activeTab=dependencies) | ||
[](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
187747
36
3136
851
2
8
+ Addedlru-cache@^10.1.0
+ Addedlru-cache@10.4.3(transitive)