Comparing version 0.11.2 to 0.12.0
@@ -1,3 +0,9 @@ | ||
import { bench, group, run } from 'mitata'; | ||
import { runBenchmarks } from './runBenchmarks.ts'; | ||
/* | ||
Conclusion: | ||
classes are | ||
1.03x faster than inner functions | ||
*/ | ||
class TheClass { | ||
@@ -18,23 +24,23 @@ max: number; | ||
// These 2 approaches perform within 1% to 3% of each other | ||
group('2 functions', () => { | ||
bench('inner functions', () => { | ||
const spec = { max: 10000 }; | ||
function addUp() { | ||
let sum = 0; | ||
for (let i = 0; i < spec.max; i++) { | ||
sum += i; | ||
await runBenchmarks( | ||
{ | ||
'inner functions': () => { | ||
const spec = { max: 10000 }; | ||
function addUp() { | ||
let sum = 0; | ||
for (let i = 0; i < spec.max; i++) { | ||
sum += i; | ||
} | ||
return sum; | ||
} | ||
return sum; | ||
} | ||
function noop() {} | ||
addUp(); | ||
}); | ||
bench('class', () => { | ||
const spec = { max: 10000 }; | ||
const theClass = new TheClass(spec); | ||
theClass.addUp(); | ||
}); | ||
}); | ||
await run(); | ||
function noop() {} | ||
addUp(); | ||
}, | ||
classes: () => { | ||
const spec = { max: 10000 }; | ||
const theClass = new TheClass(spec); | ||
theClass.addUp(); | ||
}, | ||
}, | ||
{ time: 15000 } | ||
); |
@@ -1,25 +0,31 @@ | ||
import { bench, group, run } from 'mitata'; | ||
import MatcherWithCache from '../src/MatcherWithCache/MatcherWithCache.ts'; | ||
import PathMatcher from '../src/PathMatcher/PathMatcher.ts'; | ||
import { runBenchmarks } from './runBenchmarks.ts'; | ||
group('lru cache speed', () => { | ||
function withCacheSize(size: number) { | ||
const matcher = | ||
size === 0 | ||
? new PathMatcher() | ||
: new MatcherWithCache(new PathMatcher(), size); | ||
return setup(matcher); | ||
} | ||
// cache size of 5000 is best, averaging 40x faster than no cache | ||
bench('no cache', withCacheSize(0)); | ||
bench('cache size 500', withCacheSize(500)); | ||
bench('cache size 2500', withCacheSize(2500)); | ||
bench('cache size 3000', withCacheSize(3000)); | ||
bench('cache size 4000', withCacheSize(4000)); | ||
bench('cache size 5000', withCacheSize(5000)); | ||
bench('cache size 10000', withCacheSize(8000)); | ||
}); | ||
/* | ||
Conclusion: | ||
Cache sizes of 4000+ are all about 41x faster than no cache | ||
*/ | ||
await run(); | ||
function withCacheSize(size: number) { | ||
const matcher = | ||
size === 0 | ||
? new PathMatcher() | ||
: new MatcherWithCache(new PathMatcher(), size); | ||
return setup(matcher); | ||
} | ||
await runBenchmarks( | ||
{ | ||
'no cache': withCacheSize(0), | ||
'cache size 500': withCacheSize(500), | ||
'cache size 2500': withCacheSize(2500), | ||
'cache size 3000': withCacheSize(3000), | ||
'cache size 4000': withCacheSize(4000), | ||
'cache size 5000': withCacheSize(5000), | ||
'cache size 10000': withCacheSize(10000), | ||
}, | ||
{ time: 5000 } | ||
); | ||
function setup(matcher: any) { | ||
@@ -26,0 +32,0 @@ const urls: string[] = []; |
import { LRUCache } from 'lru-cache'; | ||
import { bench, group, run } from 'mitata'; | ||
import { match } from 'path-to-regexp'; | ||
import { runBenchmarks } from './runBenchmarks.ts'; | ||
/* | ||
Conclusion: | ||
Cache sizes of 5000+ are all about 40x faster than no cache | ||
*/ | ||
const { findAll, getLruFinder, urls } = setup(); | ||
group('lru cache speed', () => { | ||
function finder(find: (url: string) => void) { | ||
return function () { | ||
for (const url of urls) { | ||
find(url); | ||
} | ||
}; | ||
} | ||
// cache size of 5000 is best, averaging 40x faster than no cache | ||
bench('no cache', finder(findAll)); | ||
bench('cache size 500', finder(getLruFinder(500))); | ||
bench('cache size 5000', finder(getLruFinder(5000))); | ||
bench('cache size 50000', finder(getLruFinder(50000))); | ||
bench('cache size 500000', finder(getLruFinder(500000))); | ||
}); | ||
function finder(find: (url: string) => void) { | ||
return function () { | ||
for (const url of urls) { | ||
find(url); | ||
} | ||
}; | ||
} | ||
// group('trie speed', () => { }); | ||
await runBenchmarks( | ||
{ | ||
'no cache': finder(findAll), | ||
'cache size 500': finder(getLruFinder(500)), | ||
'cache size 5000': finder(getLruFinder(5000)), | ||
'cache size 50000': finder(getLruFinder(50000)), | ||
'cache size 500000': finder(getLruFinder(50000)), | ||
}, | ||
{ time: 5000 } | ||
); | ||
await run(); | ||
function setup() { | ||
@@ -41,3 +45,3 @@ const registry: Registration[] = []; | ||
function findAll(urlPath: string) { | ||
const found = []; | ||
const found: Registration[] = []; | ||
for (const reg of registry) { | ||
@@ -57,3 +61,3 @@ if (reg.matcher(urlPath)) { | ||
} | ||
const found = []; | ||
const found: Registration[] = []; | ||
for (const reg of registry) { | ||
@@ -60,0 +64,0 @@ if (reg.matcher(urlPath)) { |
@@ -9,10 +9,3 @@ export { default as Context } from './src/Context/Context'; | ||
export { | ||
file, | ||
html, | ||
js, | ||
json, | ||
redirect, | ||
sse, | ||
text, | ||
xml, | ||
type FileResponseOptions, | ||
@@ -19,0 +12,0 @@ type SseSetupFunction, |
{ | ||
"name": "bunshine", | ||
"version": "0.11.2", | ||
"version": "0.12.0", | ||
"module": "server/server.ts", | ||
@@ -11,2 +11,33 @@ "type": "module", | ||
}, | ||
"repository": { | ||
"type": "git", | ||
"url": "git+https://github.com/kensnyder/bunshine.git" | ||
}, | ||
"bin": { | ||
"serve": "bun ./bin/serve.ts" | ||
}, | ||
"keywords": [ | ||
"Bun HTTP Server", | ||
"Bun Socket Server", | ||
"Bun Server", | ||
"Bun Framework", | ||
"Bun Web Framework", | ||
"Bun Server Sent Events", | ||
"Bun Ranged Files", | ||
"Bun File Server", | ||
"HTTP Server", | ||
"Socket Server", | ||
"Server", | ||
"Server Sent Events", | ||
"Ranged Files", | ||
"File Server", | ||
"GZIP Responses", | ||
"Bun" | ||
], | ||
"author": "kendsnyder@gmail.com", | ||
"license": "ISC", | ||
"bugs": { | ||
"url": "https://github.com/kensnyder/bunshine/issues" | ||
}, | ||
"homepage": "https://github.com/kensnyder/bunshine#readme", | ||
"dependencies": { | ||
@@ -20,7 +51,8 @@ "lru-cache": "^10.1.0", | ||
"@types/ms": "^0.7.34", | ||
"bun-types": "^1.0.22", | ||
"bun-types": "^1.0.23", | ||
"eventsource": "^2.0.2", | ||
"mitata": "^0.1.6", | ||
"prettier": "^3.1.1", | ||
"globby": "^14.0.0", | ||
"prettier": "^3.2.4", | ||
"prettier-plugin-organize-imports": "^3.2.4", | ||
"tinybench": "^2.6.0", | ||
"type-fest": "^4.9.0", | ||
@@ -27,0 +59,0 @@ "typescript": "^5.3.3" |
108
README.md
@@ -1,7 +0,7 @@ | ||
<img alt="Bunshine Logo" src="https://github.com/kensnyder/bunshine/raw/main/assets/bunshine-logo.png?v=0.11.2" width="200" height="187" /> | ||
<img alt="Bunshine Logo" src="https://github.com/kensnyder/bunshine/raw/main/assets/bunshine-logo.png?v=0.12.0" width="200" height="187" /> | ||
[](https://npmjs.com/package/bunshine) | ||
[](https://www.npmjs.com/package/bunshine?activeTab=dependencies) | ||
 | ||
[](https://opensource.org/licenses/ISC) | ||
[](https://npmjs.com/package/bunshine) | ||
[](https://www.npmjs.com/package/bunshine?activeTab=dependencies) | ||
 | ||
[](https://opensource.org/licenses/ISC) | ||
@@ -12,3 +12,5 @@ # Bunshine | ||
`bun install bunshine` | ||
```shell | ||
bun add bunshine | ||
``` | ||
@@ -25,5 +27,6 @@ ## Motivation | ||
8. Provide common middleware out of the box | ||
9. Make specifically for Bun | ||
10. Comprehensive unit tests | ||
11. Support for `X-HTTP-Method-Override` header | ||
9. Built-in gzip compression | ||
10. Make specifically for Bun | ||
11. Comprehensive unit tests | ||
12. Support for `X-HTTP-Method-Override` header | ||
@@ -65,16 +68,16 @@ ## Table of Contents | ||
```ts | ||
import { HttpRouter, json, redirect } from 'bunshine'; | ||
import { HttpRouter, redirect } from 'bunshine'; | ||
const app = new HttpRouter(); | ||
app.patch('/users/:id', async ({ request, params, url }) => { | ||
await authorize(request.headers.get('Authorization')); | ||
const data = await request.json(); | ||
app.patch('/users/:id', async c => { | ||
await authorize(c.request.headers.get('Authorization')); | ||
const data = await c.request.json(); | ||
const result = await updateUser(params.id, data); | ||
if (result === 'not found') { | ||
return json({ error: 'User not found' }, { status: 404 }); | ||
return c.json({ error: 'User not found' }, { status: 404 }); | ||
} else if (result === 'error') { | ||
return json({ error: 'Error updating user' }, { status: 500 }); | ||
return c.json({ error: 'Error updating user' }, { status: 500 }); | ||
} else { | ||
return json({ error: false }); | ||
return c.json({ error: false }); | ||
} | ||
@@ -115,4 +118,6 @@ }); | ||
app.get('/hello', (c: Context, next: NextFunction) => { | ||
// Properties of the Context object | ||
c.request; // The raw request object | ||
c.params; // The request params from URL placeholders | ||
c.url; // The URL object | ||
c.params; // The request params from route placeholders | ||
c.server; // The Bun server instance (useful for pub-sub) | ||
@@ -122,2 +127,16 @@ c.app; // The HttpRouter instance | ||
c.error; // Handlers registered with app.on500() can see this Error object | ||
c.ip; // The IP address of the client (not necessarily the end user) | ||
c.date; // The date of the request | ||
c.now; // The result of performance.now() at the start of the request | ||
// Convenience methods for creating Response objects with various content types | ||
// Note that responses are automatically gzipped if the client accepts gzip | ||
c.json(data, init); | ||
c.text(text, init); | ||
c.js(jsText, init); | ||
c.xml(xmlText, init); | ||
c.html(htmlText, init); | ||
c.css(cssText, init); | ||
c.file(path, init); | ||
// Create a redirect Response | ||
c.redirect(url, status); | ||
}); | ||
@@ -159,2 +178,3 @@ ``` | ||
} | ||
// continue to other handlers | ||
}); | ||
@@ -164,6 +184,9 @@ | ||
app.use(async (c, next) => { | ||
// wait for response from other handlers | ||
const resp = await next(); | ||
// peek at status and log if 403 | ||
if (resp.status === 403) { | ||
logThatUserWasForbidden(c.request.url); | ||
} | ||
// return the response from the other handlers | ||
return resp; | ||
@@ -254,2 +277,3 @@ }); | ||
app.get('/hello', (c: Context, next: NextFunction) => { | ||
// wait for other handlers to return a response | ||
const resp = next(); | ||
@@ -261,2 +285,3 @@ // do stuff with response | ||
app.get('/hello', async (c: Context, next: NextFunction) => { | ||
// wait for other handlers to return a response | ||
const resp = await next(); | ||
@@ -292,2 +317,3 @@ // do stuff with response | ||
// Bunshine accepts any number of middleware functions in parameters or arrays | ||
// so the following are equivalent | ||
app.get('/posts', middleware1, middleware2, handler); | ||
@@ -389,3 +415,3 @@ app.get('/users', [middleware1, middleware2, handler]); | ||
gameRoom.onerror = handleGameError; | ||
// sending messages | ||
// send message to server | ||
gameRoom.send(JSON.stringify({ type: 'GameMove', move: 'rock' })); | ||
@@ -541,2 +567,4 @@ ``` | ||
### Path examples | ||
| Path | URL | params | | ||
@@ -558,2 +586,27 @@ | ---------------------- | --------------------- | ------------------------ | | ||
### HTTP methods | ||
```ts | ||
import { HttpRouter } from 'bunshine'; | ||
const app = new HttpRouter(); | ||
app.head('/posts/:id', doesPostExist); | ||
app.get('/posts/:id', getPost); | ||
app.post('/posts/:id', addPost); | ||
app.patch('/posts/:id', editPost); | ||
app.put('/posts/:id', upsertPost); | ||
app.trace('/posts/:id', tracePost); | ||
app.delete('/posts/:id', deletePost); | ||
app.options('/posts/:id', getPostCors); | ||
// special case for specifying both head and get | ||
app.headGet('/files/*', serveFiles(`${import.meta.dir}/files`)); | ||
// any list of multiple verbs (must be uppercase) | ||
app.on(['POST', 'PATCH'], '/posts/:id', addEditPost); | ||
app.listen({ port: 3100 }); | ||
``` | ||
## Included middleware | ||
@@ -565,3 +618,3 @@ | ||
easy with the `serveFiles` middleware. Note that ranged requests are | ||
supported, so you can use this for video streaming or partial downloads. | ||
supported, so you can use it for video streaming or partial downloads. | ||
@@ -920,10 +973,19 @@ ```ts | ||
- ✅ middleware > trailingSlashes | ||
- 🔲 middleware > compression | ||
- 🔲 options for serveFiles | ||
- 🔲 middleware > directoryListing | ||
- 🔲 middleware > rate limiter | ||
- ✅ gzip compression | ||
- ✅ options for serveFiles | ||
- 🔲 tests for cors | ||
- 🔲 tests for devLogger | ||
- 🔲 tests for prodLogger | ||
- 🔲 tests for serveFiles | ||
- 🔲 more examples | ||
- ✅ tests for serveFiles | ||
- 🔲 add flags to bin/serve.ts with commander | ||
- 🔲 document flags for `bunx bunshine serve` | ||
- 🔲 more files in examples folder | ||
- 🔲 example of mini app that uses bin/serve.ts (maybe our own docs?) | ||
- 🔲 GitHub Actions to run tests and coverage | ||
- 🔲 Fix TypeScript warnings | ||
- 🔲 Support server clusters | ||
- ✅ Export functions to gzip strings and files | ||
- ✅ Gzip performance testing (to get min/max defaults) | ||
@@ -930,0 +992,0 @@ ## License |
import type { Server } from 'bun'; | ||
import { describe, expect, it } from 'bun:test'; | ||
import { beforeEach, describe, expect, it } from 'bun:test'; | ||
import HttpRouter from '../HttpRouter/HttpRouter'; | ||
import { | ||
file, | ||
html, | ||
js, | ||
json, | ||
redirect, | ||
text, | ||
xml, | ||
} from '../HttpRouter/responseFactories.ts'; | ||
import Context from './Context'; | ||
@@ -90,54 +81,67 @@ | ||
}); | ||
it('should return 404 on file not found', async () => { | ||
const resp = await file(`${import.meta.dir}/invalidfile`); | ||
expect(resp.status).toBe(404); | ||
describe('server', () => { | ||
let c: Context; | ||
beforeEach(() => { | ||
const request = new Request('http://localhost/thing'); | ||
const app = new HttpRouter(); | ||
c = new Context(request, server, app); | ||
}); | ||
it('should return 404 on file not found', async () => { | ||
const resp = await c.file(`${import.meta.dir}/invalidfile`); | ||
expect(resp.status).toBe(404); | ||
}); | ||
it('should include text()', async () => { | ||
const resp = c.text('Hi'); | ||
expect(await resp.text()).toBe('Hi'); | ||
expect(resp.headers.get('Content-type')).toStartWith('text/plain'); | ||
}); | ||
it('should include js()', async () => { | ||
const resp = c.js('alert(42)'); | ||
expect(await resp.text()).toBe('alert(42)'); | ||
expect(resp.headers.get('Content-type')).toStartWith('text/javascript'); | ||
}); | ||
it('should include html()', async () => { | ||
const resp = c.html('<h1>Hi</h1>'); | ||
expect(await resp.text()).toBe('<h1>Hi</h1>'); | ||
expect(resp.headers.get('Content-type')).toStartWith('text/html'); | ||
}); | ||
it('should include css()', async () => { | ||
const resp = c.css('* { min-width: 0 }'); | ||
expect(await resp.text()).toBe('* { min-width: 0 }'); | ||
expect(resp.headers.get('Content-type')).toStartWith('text/css'); | ||
}); | ||
it('should include xml()', async () => { | ||
const resp = c.xml('<greeting>Hi</greeting>'); | ||
expect(await resp.text()).toBe('<greeting>Hi</greeting>'); | ||
expect(resp.headers.get('Content-type')).toStartWith('text/xml'); | ||
}); | ||
it('should include json(data)', async () => { | ||
const resp = c.json({ hello: 'world' }); | ||
expect(await resp.json()).toEqual({ hello: 'world' }); | ||
expect(resp.headers.get('Content-type')).toStartWith('application/json'); | ||
}); | ||
it('should include json(data, init)', async () => { | ||
const resp = c.json( | ||
{ hello: 'world' }, | ||
{ | ||
headers: { | ||
'X-Hello': 'World', | ||
}, | ||
} | ||
); | ||
expect(await resp.json()).toEqual({ hello: 'world' }); | ||
expect(resp.headers.get('Content-type')).toStartWith('application/json'); | ||
expect(resp.headers.get('X-Hello')).toBe('World'); | ||
}); | ||
// it('should include redirect(url)', () => { | ||
// const resp = redirect('/home'); | ||
// expect(resp.headers.get('Location')).toBe('/home'); | ||
// expect(resp.status).toBe(302); | ||
// }); | ||
// it('should include redirect(url, status)', () => { | ||
// const resp = redirect('/home', 301); | ||
// expect(resp.headers.get('Location')).toBe('/home'); | ||
// expect(resp.status).toBe(301); | ||
// }); | ||
}); | ||
it('should include text()', async () => { | ||
const resp = text('Hi'); | ||
expect(await resp.text()).toBe('Hi'); | ||
expect(resp.headers.get('Content-type')).toStartWith('text/plain'); | ||
}); | ||
it('should include js()', async () => { | ||
const resp = js('alert(42)'); | ||
expect(await resp.text()).toBe('alert(42)'); | ||
expect(resp.headers.get('Content-type')).toStartWith('text/javascript'); | ||
}); | ||
it('should include html()', async () => { | ||
const resp = html('<h1>Hi</h1>'); | ||
expect(await resp.text()).toBe('<h1>Hi</h1>'); | ||
expect(resp.headers.get('Content-type')).toStartWith('text/html'); | ||
}); | ||
it('should include xml()', async () => { | ||
const resp = xml('<greeting>Hi</greeting>'); | ||
expect(await resp.text()).toBe('<greeting>Hi</greeting>'); | ||
expect(resp.headers.get('Content-type')).toStartWith('text/xml'); | ||
}); | ||
it('should include json(data)', async () => { | ||
const resp = json({ hello: 'world' }); | ||
expect(await resp.json()).toEqual({ hello: 'world' }); | ||
expect(resp.headers.get('Content-type')).toStartWith('application/json'); | ||
}); | ||
it('should include json(data, init)', async () => { | ||
const resp = json( | ||
{ hello: 'world' }, | ||
{ | ||
headers: { | ||
'X-Hello': 'World', | ||
}, | ||
} | ||
); | ||
expect(await resp.json()).toEqual({ hello: 'world' }); | ||
expect(resp.headers.get('Content-type')).toStartWith('application/json'); | ||
expect(resp.headers.get('X-Hello')).toBe('World'); | ||
}); | ||
it('should include redirect(url)', () => { | ||
const resp = redirect('/home'); | ||
expect(resp.headers.get('Location')).toBe('/home'); | ||
expect(resp.status).toBe(302); | ||
}); | ||
it('should include redirect(url, status)', () => { | ||
const resp = redirect('/home', 301); | ||
expect(resp.headers.get('Location')).toBe('/home'); | ||
expect(resp.status).toBe(301); | ||
}); | ||
}); |
import type { BunFile, Server } from 'bun'; | ||
import type HttpRouter from '../HttpRouter/HttpRouter'; | ||
import { | ||
factory, | ||
file, | ||
html, | ||
js, | ||
json, | ||
redirect, | ||
sse, | ||
text, | ||
xml, | ||
type FileResponseOptions, | ||
@@ -16,2 +13,8 @@ type SseSetupFunction, | ||
const textPlain = factory('text/plain'); | ||
const textJs = factory('text/javascript'); | ||
const textHtml = factory('text/html'); | ||
const textXml = factory('text/xml'); | ||
const textCss = factory('text/css'); | ||
export default class Context< | ||
@@ -22,2 +25,4 @@ ParamsShape extends Record<string, string> = Record<string, string>, | ||
request: Request; | ||
/** Alias for `request` */ | ||
req: Request; | ||
/** The Bun server instance */ | ||
@@ -42,2 +47,3 @@ server: Server; | ||
this.request = request; | ||
this.req = request; | ||
this.server = server; | ||
@@ -54,18 +60,34 @@ this.app = app; | ||
/** A shorthand for `new Response(text, { headers: { 'Content-type': 'text/plain' } })` */ | ||
text = text; | ||
text(text: string, init: ResponseInit = {}) { | ||
return textPlain.call(this, text, init); | ||
} | ||
/** A shorthand for `new Response(js, { headers: { 'Content-type': 'text/javascript' } })` */ | ||
js = js; | ||
js(js: string, init: ResponseInit = {}) { | ||
return textJs.call(this, js, init); | ||
} | ||
/** A shorthand for `new Response(html, { headers: { 'Content-type': 'text/html' } })` */ | ||
html = html; | ||
html(html: string, init: ResponseInit = {}) { | ||
return textHtml.call(this, html, init); | ||
} | ||
/** A shorthand for `new Response(html, { headers: { 'Content-type': 'text/css' } })` */ | ||
css(css: string, init: ResponseInit = {}) { | ||
return textCss.call(this, css, init); | ||
} | ||
/** A shorthand for `new Response(xml, { headers: { 'Content-type': 'text/xml' } })` */ | ||
xml = xml; | ||
xml(xml: string, init: ResponseInit = {}) { | ||
return textXml.call(this, xml, init); | ||
} | ||
/** A shorthand for `new Response(JSON.stringify(data), { headers: { 'Content-type': 'application/json' } })` */ | ||
json = json; | ||
json(data: any, init: ResponseInit = {}) { | ||
return json.call(this, data, init); | ||
} | ||
/** A shorthand for `new Response(null, { headers: { Location: url }, status: 301 })` */ | ||
redirect = redirect; | ||
redirect(url: string, status = 302) { | ||
return redirect(url, status); | ||
} | ||
/** A shorthand for `new Response(fileBody, fileHeaders)` */ | ||
file = async ( | ||
async file( | ||
filenameOrBunFile: string | BunFile, | ||
fileOptions: FileResponseOptions = {} | ||
) => { | ||
) { | ||
return file(filenameOrBunFile, { | ||
@@ -75,7 +97,7 @@ range: this.request.headers.get('Range') || undefined, | ||
}); | ||
}; | ||
} | ||
/** A shorthand for `new Response({ headers: { 'Content-type': 'text/event-stream' } })` */ | ||
sse = (setup: SseSetupFunction, init: ResponseInit = {}) => { | ||
sse(setup: SseSetupFunction, init: ResponseInit = {}) { | ||
return sse(this.request.signal, setup, init); | ||
}; | ||
} | ||
} |
@@ -126,4 +126,4 @@ import type { Server } from 'bun'; | ||
it('should extract params', async () => { | ||
app.get('/users/:id', ({ params }) => { | ||
throw new Response(params.id, { | ||
app.get('/users/:id', c => { | ||
throw new Response(c.params.id, { | ||
status: 200, | ||
@@ -143,4 +143,4 @@ headers: { | ||
it('should give params for * routes', async () => { | ||
app.get('/abc/*', ({ params }) => { | ||
throw new Response(params[0], { | ||
app.get('/abc/*', c => { | ||
throw new Response(c.params[0], { | ||
status: 200, | ||
@@ -160,4 +160,4 @@ headers: { | ||
it('should allow registering multiple methods', async () => { | ||
app.on(['POST', 'PUT'], '/user', ({ request, text }) => { | ||
return text('Method was ' + request.method); | ||
app.on(['POST', 'PUT'], '/user', c => { | ||
return new Response('Method was ' + c.request.method); | ||
}); | ||
@@ -178,7 +178,14 @@ const resp = await app.fetch( | ||
it('should allow RegExp paths', async () => { | ||
app.get(/^\/user\/(.+)\/(.+)/, ({ params, url, json }) => { | ||
return json({ | ||
pathname: url.pathname, | ||
params, | ||
}); | ||
app.get(/^\/user\/(.+)\/(.+)/, c => { | ||
return new Response( | ||
JSON.stringify({ | ||
pathname: c.url.pathname, | ||
params: c.params, | ||
}), | ||
{ | ||
headers: { | ||
'Content-type': 'application/json', | ||
}, | ||
} | ||
); | ||
}); | ||
@@ -296,2 +303,11 @@ const resp = await app.fetch( | ||
}); | ||
it('should emit url', async () => { | ||
app.all('/', () => new Response('Hi')); | ||
server = app.listen({ port: 7772 }); | ||
let output: string; | ||
const to = (message: string) => (output = message); | ||
app.emitUrl({ to }); | ||
// @ts-expect-error | ||
expect(output).toContain(String(server.url)); | ||
}); | ||
it('should handle all', async () => { | ||
@@ -312,6 +328,4 @@ app.all('/', () => new Response('Hi')); | ||
it('should handle PUT', async () => { | ||
let body: { name: string } = { name: '' }; | ||
app.put('/', async ({ request }) => { | ||
body = await request.json(); | ||
return new Response('Hi'); | ||
app.put('/', async ({ request, json }) => { | ||
return json(await request.json()); | ||
}); | ||
@@ -326,2 +340,3 @@ server = app.listen({ port: 7774 }); | ||
}); | ||
const body = await resp.json(); | ||
expect(resp.status).toBe(200); | ||
@@ -370,3 +385,2 @@ expect(body).toEqual({ name: 'Alice' }); | ||
expect(resp.status).toBe(200); | ||
// @ts-expect-error | ||
expect(await resp.json()).toEqual({ key: 'secret' }); | ||
@@ -393,10 +407,7 @@ }); | ||
expect(resp.status).toBe(200); | ||
// @ts-expect-error | ||
expect(await resp.json()).toEqual({ key2: 'secret2' }); | ||
}); | ||
it('should handle PATCH', async () => { | ||
let body: { name: string } = { name: '' }; | ||
app.patch('/', async ({ request }) => { | ||
body = await request.json(); | ||
return new Response('Hi'); | ||
app.patch('/', async ({ request, json }) => { | ||
return json(await request.json()); | ||
}); | ||
@@ -411,2 +422,3 @@ server = app.listen({ port: 7778 }); | ||
}); | ||
const body = await resp.json(); | ||
expect(resp.status).toBe(200); | ||
@@ -413,0 +425,0 @@ expect(body).toEqual({ name: 'Charlie' }); |
import type { ServeOptions, Server } from 'bun'; | ||
// @ts-ignore | ||
import os from 'os'; | ||
import bunshine from '../../package.json'; | ||
@@ -66,2 +67,3 @@ import Context from '../Context/Context'; | ||
verbose?: boolean; | ||
to?: (message: string) => void; | ||
}; | ||
@@ -83,3 +85,3 @@ | ||
} | ||
respectSigTerm = ({ closeActiveConnections = true } = {}) => { | ||
respectSigTerm({ closeActiveConnections = true } = {}) { | ||
['SIGTERM', 'SIGINT'].forEach(signal => { | ||
@@ -91,9 +93,10 @@ process.once(signal, () => { | ||
}); | ||
}; | ||
listen = (options: Omit<ServeOptions, 'fetch'> = {}) => { | ||
return this; | ||
} | ||
listen(options: Omit<ServeOptions, 'fetch'> = {}) { | ||
const server = Bun.serve(this.getExport(options)); | ||
this.server = server; | ||
return server; | ||
}; | ||
emitUrl = (options: EmitUrlOptions = { verbose: false }) => { | ||
} | ||
emitUrl({ verbose = false, to = console.log }: EmitUrlOptions = {}) { | ||
if (!this.server) { | ||
@@ -105,14 +108,17 @@ throw new Error( | ||
const servingAt = String(this.server.url); | ||
if (options.verbose) { | ||
const server = Bun.env.COMPUTERNAME || Bun.env.HOSTNAME; | ||
if (verbose) { | ||
const server = Bun.env.COMPUTERNAME || os.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)` | ||
const runtime = process.versions.bun | ||
? `Bun v${process.versions.bun}` | ||
: `Node v${process.versions.node}`; | ||
to( | ||
`☀️ Bunshine v${bunshine.version} on ${runtime} serving at ${servingAt} on "${server}" in ${mode} (${took}ms)` | ||
); | ||
} else { | ||
console.log(`☀️ Serving ${servingAt}`); | ||
to(`☀️ Serving ${servingAt}`); | ||
} | ||
}; | ||
getExport = (options: Omit<ServeOptions, 'fetch' | 'websocket'> = {}) => { | ||
} | ||
getExport(options: Omit<ServeOptions, 'fetch' | 'websocket'> = {}) { | ||
const config = { | ||
@@ -128,3 +134,3 @@ port: 0, | ||
return config; | ||
}; | ||
} | ||
get socket() { | ||
@@ -136,7 +142,7 @@ if (!this._wsRouter) { | ||
} | ||
on = <ParamsShape extends Record<string, string> = Record<string, string>>( | ||
on<ParamsShape extends Record<string, string> = Record<string, string>>( | ||
verbOrVerbs: HttpMethods | HttpMethods[], | ||
path: string | RegExp, | ||
...handlers: Handler<ParamsShape>[] | ||
) => { | ||
) { | ||
if (Array.isArray(verbOrVerbs)) { | ||
@@ -155,55 +161,74 @@ for (const verb of verbOrVerbs) { | ||
return this; | ||
}; | ||
all = <ParamsShape extends Record<string, string> = Record<string, string>>( | ||
} | ||
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>>( | ||
) { | ||
return 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>>( | ||
) { | ||
return 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>>( | ||
) { | ||
return 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>>( | ||
) { | ||
return 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>>( | ||
) { | ||
return 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>>( | ||
) { | ||
return 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>, | ||
>( | ||
) { | ||
return 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>, | ||
>( | ||
) { | ||
return 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<Record<string, string>>[]) => { | ||
) { | ||
return this.on<ParamsShape>('OPTIONS', path, handlers); | ||
} | ||
headGet<ParamsShape extends Record<string, string> = Record<string, string>>( | ||
path: string | RegExp, | ||
...handlers: Handler<ParamsShape>[] | ||
) { | ||
return this.on<ParamsShape>(['HEAD', 'GET'], path, handlers); | ||
} | ||
use(...handlers: Handler<{}>[]) { | ||
return this.all('*', handlers); | ||
} | ||
onError(...handlers: Handler<Record<string, string>>[]) { | ||
this._onErrors.push(...handlers.flat(9)); | ||
return this; | ||
}; | ||
on404 = (...handlers: Handler<Record<string, string>>[]) => { | ||
} | ||
on404(...handlers: Handler<Record<string, string>>[]) { | ||
this._on404s.push(...handlers.flat(9)); | ||
return this; | ||
}; | ||
} | ||
fetch = async (request: Request, server: Server) => { | ||
@@ -215,3 +240,2 @@ const context = new Context(request, server, this); | ||
).toUpperCase(); | ||
// @ts-expect-error | ||
const filter = filters[method] || getPathMatchFilter(method); | ||
@@ -218,0 +242,0 @@ const matched = this.pathMatcher.match(pathname, filter, this._on404s); |
import { BunFile } from 'bun'; | ||
import path from 'node:path'; | ||
import Context from '../Context/Context.ts'; | ||
import { gzipString } from '../gzip/gzip.ts'; | ||
export const text = getResponseFactory('text/plain; charset=utf-8'); | ||
export const js = getResponseFactory('text/javascript; charset=utf-8'); | ||
export const html = getResponseFactory('text/html; charset=utf-8'); | ||
export const xml = getResponseFactory('text/xml; charset=utf-8'); | ||
export const json = (data: any, init: ResponseInit = {}) => { | ||
return new Response(JSON.stringify(data), { | ||
...init, | ||
export type Factory = (body: string, init?: ResponseInit) => Response; | ||
const textEncoder = new TextEncoder(); | ||
// body must be large enough to be worth compressing | ||
// (54 is minimum size of gzip after metadata; 100 is arbitrary choice) | ||
export let minGzipSize = 100; | ||
export function json(this: Context, data: any, init: ResponseInit = {}) { | ||
let body: string | Uint8Array = JSON.stringify(data); | ||
// @ts-expect-error | ||
init.headers = new Headers(init.headers || {}); | ||
init.headers.set('Content-type', `application/json; charset=utf-8`); | ||
// body must be large enough to be worth compressing | ||
if (body.length >= minGzipSize) { | ||
body = gzipString(body); | ||
init.headers.set('Content-Encoding', 'gzip'); | ||
init.headers.set('Content-Length', String(body.length)); | ||
} | ||
// @ts-expect-error | ||
return new Response(body, init); | ||
} | ||
export function factory(contentType: string): Factory { | ||
return function (this: Context, body: string, init: ResponseInit = {}) { | ||
// @ts-expect-error | ||
headers: { | ||
...(init.headers || {}), | ||
'Content-Type': 'application/json; charset=utf-8', | ||
}, | ||
}); | ||
}; | ||
init.headers = new Headers(init.headers || {}); | ||
init.headers.set('Content-type', `${contentType}; charset=utf-8`); | ||
if ( | ||
// client must expect gzip | ||
this.request.headers.get('Accept-Encoding')?.includes('gzip') && | ||
// body must be large enough to be worth compressing | ||
body.length >= minGzipSize | ||
) { | ||
// @ts-expect-error | ||
body = gzipString(body); | ||
init.headers.set('Content-Encoding', 'gzip'); | ||
} | ||
init.headers.set('Content-Length', String(body.length)); | ||
// @ts-expect-error | ||
return new Response(body, init); | ||
}; | ||
} | ||
@@ -30,2 +62,5 @@ export const redirect = (url: string, status = 302) => { | ||
chunkSize?: number; | ||
gzip?: boolean; | ||
disposition?: 'inline' | 'attachment'; | ||
acceptRanges?: boolean; | ||
}; | ||
@@ -40,4 +75,3 @@ export const file = async ( | ||
: filenameOrBunFile; | ||
const totalFileSize = file.size; | ||
if (totalFileSize === 0) { | ||
if (!(await file.exists())) { | ||
return new Response('File not found', { status: 404 }); | ||
@@ -51,5 +85,17 @@ } | ||
method: 'GET', | ||
gzip: fileOptions.gzip, | ||
}); | ||
// tell the client that we are capable of handling range requests | ||
resp.headers.set('Accept-Ranges', 'bytes'); | ||
if (fileOptions.acceptRanges !== false) { | ||
// tell the client that we are capable of handling range requests | ||
resp.headers.set('Accept-Ranges', 'bytes'); | ||
} | ||
if (fileOptions.disposition === 'attachment') { | ||
const filename = path.basename(file.name!); | ||
resp.headers.set( | ||
'Content-Disposition', | ||
`${fileOptions.disposition}; filename="${filename}"` | ||
); | ||
} else if (fileOptions.disposition === 'inline') { | ||
resp.headers.set('Content-Disposition', 'inline'); | ||
} | ||
return resp; | ||
@@ -77,6 +123,2 @@ }; | ||
async start(controller: ReadableStreamDefaultController) { | ||
// Step 1: create encoder to handle utf8 | ||
const encoder = new TextEncoder(); | ||
// Step 2: define the send and close functions | ||
function send( | ||
@@ -90,3 +132,3 @@ eventName: string, | ||
if (arguments.length === 1) { | ||
encoded = encoder.encode(`data: ${eventName}\n\n`); | ||
encoded = textEncoder.encode(`data: ${eventName}\n\n`); | ||
} else { | ||
@@ -104,3 +146,3 @@ if (data && typeof data !== 'string') { | ||
message += '\n\n'; | ||
encoded = encoder.encode(message); | ||
encoded = textEncoder.encode(message); | ||
} | ||
@@ -161,15 +203,2 @@ if (signal.aborted) { | ||
function getResponseFactory(contentType: string) { | ||
return function (content: any, init: ResponseInit = {}) { | ||
return new Response(content, { | ||
...init, | ||
// @ts-ignore | ||
headers: { | ||
...(init.headers || {}), | ||
'Content-Type': contentType, | ||
}, | ||
}); | ||
}; | ||
} | ||
export async function buildFileResponse({ | ||
@@ -181,3 +210,3 @@ file, | ||
method, | ||
responseInit, | ||
gzip, | ||
}: { | ||
@@ -189,3 +218,3 @@ file: BunFile; | ||
method: string; | ||
responseInit?: ResponseInit; | ||
gzip?: boolean; | ||
}) { | ||
@@ -192,0 +221,0 @@ let response: Response; |
import type { Server } from 'bun'; | ||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; | ||
import { globby } from 'globby'; | ||
import fs from 'node:fs/promises'; | ||
import path from 'path'; | ||
@@ -299,2 +301,333 @@ import HttpRouter from '../../HttpRouter/HttpRouter.ts'; | ||
}); | ||
describe('gzip', () => { | ||
describe('NeverCache', () => { | ||
it('should gzip file on demand', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 0, | ||
maxFileSize: 100000, | ||
cache: { type: 'never' }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/1.js`); | ||
const text = await resp.text(); | ||
expect(text).toBe('// This is file number one\n'); | ||
expect(resp.headers.get('content-encoding')).toBe('gzip'); | ||
}); | ||
it('should ignore if file is too small', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 1000, | ||
maxFileSize: 100000, | ||
cache: { type: 'never' }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/1.js`); | ||
const text = await resp.text(); | ||
expect(text).toBe('// This is file number one\n'); | ||
expect(resp.headers.get('content-encoding')).toBe(null); | ||
}); | ||
it('should ignore if file is too big', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 0, | ||
maxFileSize: 10, | ||
cache: { type: 'never' }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/1.js`); | ||
const text = await resp.text(); | ||
expect(text).toBe('// This is file number one\n'); | ||
expect(resp.headers.get('content-encoding')).toBe(null); | ||
}); | ||
it('should not gzip jpeg file', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 0, | ||
maxFileSize: 1e6, | ||
cache: { type: 'never' }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/dream.jpg`); | ||
expect(resp.headers.get('content-encoding')).toBe(null); | ||
}); | ||
}); | ||
describe('MemoryCache', () => { | ||
it('should gzip', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 0, | ||
maxFileSize: 100000, | ||
cache: { type: 'memory', maxBytes: 500 }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/2.css`); | ||
const text = await resp.text(); | ||
expect(text).toBe('/* This is file number two */\n'); | ||
expect(resp.headers.get('content-encoding')).toBe('gzip'); | ||
}); | ||
it('should ignore if file is too small', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 1000, | ||
maxFileSize: 100000, | ||
cache: { type: 'memory', maxBytes: 500 }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/1.js`); | ||
const text = await resp.text(); | ||
expect(text).toBe('// This is file number one\n'); | ||
expect(resp.headers.get('content-encoding')).toBe(null); | ||
}); | ||
it('should ignore if file is too big', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 0, | ||
maxFileSize: 10, | ||
cache: { type: 'memory', maxBytes: 500 }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/1.js`); | ||
const text = await resp.text(); | ||
expect(text).toBe('// This is file number one\n'); | ||
expect(resp.headers.get('content-encoding')).toBe(null); | ||
}); | ||
it('should not gzip jpeg file', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 0, | ||
maxFileSize: 1e6, | ||
cache: { type: 'never' }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/dream.jpg`); | ||
expect(resp.headers.get('content-encoding')).toBe(null); | ||
}); | ||
}); | ||
describe('FileCache', () => { | ||
beforeEach(async () => { | ||
const paths = await globby(['/tmp/*~src~testFixtures~toGzip~*.gz']); | ||
for (const path of paths) { | ||
await fs.unlink(path); | ||
} | ||
}); | ||
it('should gzip with file cache', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 0, | ||
maxFileSize: 100000, | ||
cache: { type: 'file', maxBytes: 500, path: '/tmp' }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/1.js`); | ||
const text = await resp.text(); | ||
expect(text).toBe('// This is file number one\n'); | ||
expect(resp.headers.get('content-encoding')).toBe('gzip'); | ||
// check if it properly disposes of the oldest accessed file (1.js) | ||
await fetch(`${server.url}toGzip/2.css`); | ||
await fetch(`${server.url}toGzip/3.html`); | ||
const paths = await globby(['/tmp/*~src~testFixtures~toGzip~*.gz']); | ||
expect(paths.some(p => p.includes('1.js'))).toBe(false); | ||
expect(paths.some(p => p.includes('2.css'))).toBe(true); | ||
expect(paths.some(p => p.includes('3.html'))).toBe(true); | ||
}); | ||
it('should ignore if file is too small', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 1000, | ||
maxFileSize: 100000, | ||
cache: { type: 'file', maxBytes: 500, path: '/tmp' }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/1.js`); | ||
const text = await resp.text(); | ||
expect(text).toBe('// This is file number one\n'); | ||
expect(resp.headers.get('content-encoding')).toBe(null); | ||
}); | ||
it('should ignore if file is too big', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 0, | ||
maxFileSize: 10, | ||
cache: { type: 'file', maxBytes: 500, path: '/tmp' }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/1.js`); | ||
const text = await resp.text(); | ||
expect(text).toBe('// This is file number one\n'); | ||
expect(resp.headers.get('content-encoding')).toBe(null); | ||
}); | ||
it('should not gzip jpeg file', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 0, | ||
maxFileSize: 1e6, | ||
cache: { type: 'never' }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/dream.jpg`); | ||
expect(resp.headers.get('content-encoding')).toBe(null); | ||
}); | ||
}); | ||
describe('PrecompressCache', () => { | ||
beforeEach(async () => { | ||
const paths = await globby(['/tmp/*~src~testFixtures~toGzip~**.gz']); | ||
for (const path of paths) { | ||
await fs.unlink(path); | ||
} | ||
}); | ||
it('should gzip with file cache', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 0, | ||
maxFileSize: 100000, | ||
cache: { type: 'precompress', maxBytes: 7500, path: '/tmp' }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/1.js`); | ||
const text = await resp.text(); | ||
expect(text).toBe('// This is file number one\n'); | ||
expect(resp.headers.get('content-encoding')).toBe('gzip'); | ||
// check if it properly pre-zipped all 4 files | ||
const paths = await globby(['/tmp/*~src~testFixtures~toGzip~**.gz']); | ||
expect(paths).toHaveLength(4); | ||
}); | ||
it('should gzip some with file cache under limited maxBytes', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 0, | ||
maxFileSize: 100000, | ||
cache: { type: 'precompress', maxBytes: 75, path: '/tmp' }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/1.js`); | ||
const text = await resp.text(); | ||
// check if it properly pre-zipped the files | ||
const paths = await globby(['/tmp/*~src~testFixtures~toGzip~*.gz']); | ||
expect(paths).toHaveLength(2); | ||
}); | ||
it('should ignore if file is too small', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 1000, | ||
maxFileSize: 100000, | ||
cache: { type: 'precompress', maxBytes: 500, path: '/tmp' }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/1.js`); | ||
const text = await resp.text(); | ||
expect(text).toBe('// This is file number one\n'); | ||
expect(resp.headers.get('content-encoding')).toBe(null); | ||
}); | ||
it('should ignore if file is too big', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 0, | ||
maxFileSize: 10, | ||
cache: { type: 'precompress', maxBytes: 500, path: '/tmp' }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/1.js`); | ||
const text = await resp.text(); | ||
expect(text).toBe('// This is file number one\n'); | ||
expect(resp.headers.get('content-encoding')).toBe(null); | ||
}); | ||
it('should not gzip jpeg file', async () => { | ||
app.get( | ||
'/toGzip/*', | ||
serveFiles(`${fixturesPath}/toGzip`, { | ||
gzip: { | ||
minFileSize: 0, | ||
maxFileSize: 1e6, | ||
cache: { type: 'never' }, | ||
}, | ||
}), | ||
c => c.text('Hello') | ||
); | ||
server = app.listen(); | ||
const resp = await fetch(`${server.url}toGzip/dream.jpg`); | ||
expect(resp.headers.get('content-encoding')).toBe(null); | ||
}); | ||
}); | ||
}); | ||
}); |
@@ -0,1 +1,2 @@ | ||
import { ZlibCompressionOptions } from 'bun'; | ||
import ms from 'ms'; | ||
@@ -5,2 +6,3 @@ import path from 'path'; | ||
import { buildFileResponse } from '../../HttpRouter/responseFactories.ts'; | ||
import { FileGzipper } from '../../gzip/FileGzipper.ts'; | ||
@@ -19,4 +21,34 @@ // see https://expressjs.com/en/4x/api.html#express.static | ||
maxAge?: number | string; | ||
gzip?: GzipOptions; | ||
}; | ||
export type GzipOptions = { | ||
minFileSize?: number; | ||
maxFileSize?: number; | ||
mimeTypes?: Array<string | RegExp>; | ||
zlibOptions?: ZlibCompressionOptions; | ||
cache: | ||
| false | ||
| { | ||
type: 'file' | 'precompress' | 'memory' | 'never'; | ||
maxBytes?: number; | ||
path?: string; | ||
}; | ||
}; | ||
const defaultGzipOptions: GzipOptions = { | ||
minFileSize: 1024, | ||
maxFileSize: 1024 * 1024 * 25, | ||
mimeTypes: [ | ||
/^text\/.*/, | ||
/^application\/json/, | ||
/^image\/svg/, | ||
/^font\/(otf|ttf|eot)/, | ||
], | ||
zlibOptions: {}, | ||
cache: { | ||
type: 'never', | ||
}, | ||
}; | ||
export function serveFiles( | ||
@@ -34,2 +66,3 @@ directory: string, | ||
maxAge = undefined, | ||
gzip = undefined, | ||
}: StaticOptions = {} | ||
@@ -39,3 +72,10 @@ ): Middleware { | ||
maxAge === undefined ? null : getCacheControl(maxAge, immutable); | ||
const gzipper = gzip | ||
? new FileGzipper(directory, { ...defaultGzipOptions, ...gzip }) | ||
: undefined; | ||
return async c => { | ||
if (gzipper) { | ||
// wait for setup cache if not done already | ||
await gzipper.setupPromise; | ||
} | ||
const filename = c.params[0] || c.url.pathname; | ||
@@ -83,10 +123,17 @@ if (filename.startsWith('.')) { | ||
} | ||
// get base response | ||
const response = await buildFileResponse({ | ||
file, | ||
acceptRanges, | ||
chunkSize: 0, | ||
rangeHeader: c.request.headers.get('range'), | ||
method: c.request.method, | ||
}); | ||
const rangeHeader = c.request.headers.get('range'); | ||
let response: Response; | ||
if (rangeHeader || !gzipper) { | ||
// get base response | ||
response = await buildFileResponse({ | ||
file, | ||
acceptRanges, | ||
chunkSize: 0, | ||
rangeHeader, | ||
method: c.request.method, | ||
gzip: false, | ||
}); | ||
} else { | ||
response = await gzipper.fetch(file); | ||
} | ||
// add current date | ||
@@ -93,0 +140,0 @@ response.headers.set('Date', new Date().toUTCString()); |
{ | ||
"compilerOptions": { | ||
"include": ["*"], | ||
"include": ["src/**/*"], | ||
"lib": ["ESNext"], | ||
@@ -9,2 +9,4 @@ "module": "esnext", | ||
"moduleDetection": "force", | ||
"isolatedModules": true, | ||
"esModuleInterop": true, | ||
"allowImportingTsExtensions": true, | ||
@@ -16,3 +18,3 @@ "noEmit": true, | ||
"skipLibCheck": true, | ||
"jsx": "react-jsx", | ||
"noImplicitAny": false, | ||
"allowSyntheticDefaultImports": true, | ||
@@ -19,0 +21,0 @@ "forceConsistentCasingInFileNames": true, |
Sorry, the diff of this file is not supported yet
No contributors or author data
MaintenancePackage does not specify a list of contributors or an author in package.json.
Found 1 instance in 1 package
No bug tracker
MaintenancePackage does not have a linked bug tracker in package.json.
Found 1 instance in 1 package
No repository
Supply chain riskPackage does not have a linked source code repository. Without this field, a package will have no reference to the location of the source code use to generate the package.
Found 1 instance in 1 package
No website
QualityPackage does not have a website.
Found 1 instance in 1 package
252509
61
4655
0
1
980
0
10