bunshine
Version:
A Bun HTTP & WebSocket server that is a little ray of sunshine.
510 lines (507 loc) • 16.9 kB
text/typescript
import type { Server } from 'bun';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import HttpRouter from './HttpRouter';
// @ts-expect-error
const server: Server = {};
describe('HttpRouter', () => {
describe('handlers', () => {
let app: HttpRouter;
let oldEnv: string | undefined;
beforeEach(() => {
app = new HttpRouter();
oldEnv = Bun.env.NODE_ENV;
});
afterEach(() => {
Bun.env.NODE_ENV = oldEnv;
});
it('should respond to GET', async () => {
app.get('/', () => new Response('Hi'));
const resp = await app.fetch(new Request('http://localhost/'), server);
expect(resp.status).toBe(200);
expect(await resp.text()).toBe('Hi');
});
it('should flatten handlers', async () => {
let i = 0;
const inc = () => {
i++;
};
app.get('/', [[inc, inc, [inc]], inc], inc, () => new Response('Hi'));
const resp = await app.fetch(new Request('http://localhost/'), server);
expect(i).toBe(5);
expect(resp.status).toBe(200);
expect(await resp.text()).toBe('Hi');
});
it('should support X-HTTP-Method-Override', async () => {
app.put('/home', () => new Response('Hi'));
const resp = await app.fetch(
new Request('http://localhost/home', {
headers: {
'X-HTTP-Method-Override': 'PUT',
},
}),
server
);
expect(resp.status).toBe(200);
expect(await resp.text()).toBe('Hi');
});
it('should allow registering 404 handler', async () => {
app.on404(() => new Response('Sad Face', { status: 404 }));
app.get('/', () => new Response('Hi'));
const resp = await app.fetch(
new Request('http://localhost/home'),
server
);
expect(resp.status).toBe(404);
expect(await resp.text()).toBe('Sad Face');
});
it('should handle fallback 404', async () => {
app.get('/', () => new Response('Hi'));
const resp = await app.fetch(
new Request('http://localhost/home'),
server
);
expect(resp.status).toBe(404);
expect(resp.headers.get('Content-type')).toBe('text/plain');
expect(await resp.text()).toBe('404 Not Found');
});
it('should handle fallback 404 in dev', async () => {
Bun.env.NODE_ENV = 'development';
app.get('/', () => new Response('Hi'));
const resp = await app.fetch(
new Request('http://localhost/home'),
server
);
expect(resp.status).toBe(404);
expect(resp.headers.get('Content-type')).toBe('text/html');
expect(resp.headers.get('Reason')).toBe(
'Handlers failed to return a Response'
);
expect(await resp.text()).toContain('<h1>404 Not Found</h1>');
});
it('should allow registering 500 handler', async () => {
app.onError(() => new Response('Bad news', { status: 502 }));
app.get('/', () => {
throw new Error('Oops');
});
const resp = await app.fetch(new Request('http://localhost/'), server);
expect(await resp.text()).toBe('Bad news');
expect(resp.status).toBe(502);
});
it('should handle fallback 500', async () => {
app.get('/', () => {
throw new Error('Oops');
});
const resp = await app.fetch(new Request('http://localhost/'), server);
expect(resp.headers.get('Content-type')).toBe('text/plain');
expect(resp.status).toBe(500);
expect(await resp.text()).toBe('500 Server Error');
});
it('should handle fallback 500 in dev', async () => {
Bun.env.NODE_ENV = 'development';
app.get('/', () => {
throw new Error('Oops');
});
const resp = await app.fetch(new Request('http://localhost/'), server);
expect(resp.headers.get('Content-type')).toBe('text/html');
expect(resp.status).toBe(500);
expect(await resp.text()).toContain('<h1>500 Server Error</h1>');
});
it('should allow throwing response', async () => {
app.get('/', () => {
throw new Response(null, {
status: 302,
headers: {
Location: '/home',
},
});
});
const resp = await app.fetch(new Request('http://localhost/'), server);
expect(resp.status).toBe(302);
expect(resp.headers.get('location')).toBe('/home');
});
it('should extract params', async () => {
app.get('/users/:id', c => {
throw new Response(c.params.id, {
status: 200,
headers: {
'Content-type': 'text/plain',
},
});
});
const resp = await app.fetch(
new Request('http://localhost/users/1337'),
server
);
expect(resp.status).toBe(200);
expect(await resp.text()).toBe('1337');
});
it('should give params for * routes', async () => {
app.get('/abc/*', c => {
throw new Response(c.params[0], {
status: 200,
headers: {
'Content-type': 'text/plain',
},
});
});
const resp = await app.fetch(
new Request('http://localhost/abc/index.html'),
server
);
expect(resp.status).toBe(200);
expect(await resp.text()).toBe('index.html');
});
it('should allow registering multiple methods', async () => {
app.on(['POST', 'PUT'], '/user', c => {
return new Response('Method was ' + c.request.method);
});
const resp = await app.fetch(
new Request('http://localhost/user', { method: 'POST' }),
server
);
expect(resp.status).toBe(200);
expect(await resp.text()).toBe('Method was POST');
const resp2 = await app.fetch(
new Request('http://localhost/user', { method: 'PUT' }),
server
);
expect(resp2.status).toBe(200);
expect(await resp2.text()).toBe('Method was PUT');
});
it('should allow RegExp paths', async () => {
app.get(/^\/user\/(.+)\/(.+)/, c => {
return new Response(
JSON.stringify({
pathname: c.url.pathname,
params: c.params,
}),
{
headers: {
'Content-type': 'application/json',
},
}
);
});
const resp = await app.fetch(
new Request('http://localhost/user/123/account'),
server
);
expect(resp.status).toBe(200);
expect(await resp.json()).toEqual({
pathname: '/user/123/account',
params: {
'0': '123',
'1': 'account',
},
});
});
});
describe('middleware', () => {
let app: HttpRouter;
beforeEach(() => {
app = new HttpRouter();
});
it('should allow returning', async () => {
app.get('*', () => new Response('Unauthorized', { status: 401 }));
app.get('/', () => new Response('Hi'));
const resp = await app.fetch(new Request('http://localhost/'), server);
expect(resp.status).toBe(401);
expect(await resp.text()).toBe('Unauthorized');
});
it('should allow registration with with .use()', async () => {
app.use(() => new Response('Unauthorized', { status: 401 }));
app.get('/', () => new Response('Hi'));
const resp = await app.fetch(new Request('http://localhost/'), server);
expect(resp.status).toBe(401);
expect(await resp.text()).toBe('Unauthorized');
});
it('should allow doing nothing', async () => {
app.get('*', () => {});
app.get('/', () => new Response('Hi'));
const resp = await app.fetch(new Request('http://localhost/'), server);
expect(resp.status).toBe(200);
expect(await resp.text()).toBe('Hi');
});
it('should allow altering response', async () => {
app.get('*', async (_, next) => {
const resp = await next();
resp.headers.set('x-powered-by', 'bun');
return resp;
});
app.get('/', () => new Response('Hi'));
const resp = await app.fetch(new Request('http://localhost/'), server);
expect(resp.status).toBe(200);
expect(await resp.text()).toBe('Hi');
expect(resp.headers.get('x-powered-by')).toBe('bun');
});
it('should allow inspection but returning a different response', async () => {
app.get('*', async (_, next) => {
await next();
return new Response('Unauthorized', { status: 401 });
});
app.get('/', () => new Response('Hi'));
const resp = await app.fetch(new Request('http://localhost/'), server);
expect(resp.status).toBe(401);
expect(await resp.text()).toBe('Unauthorized');
});
it('should allow registering multiple verbs', async () => {
app.on(['GET', 'POST'], '/user', () => new Response('Hi'));
const resp = await app.fetch(
new Request('http://localhost/user'),
server
);
expect(resp.status).toBe(200);
expect(await resp.text()).toBe('Hi');
const resp2 = await app.fetch(
new Request('http://localhost/user', {
method: 'POST',
}),
server
);
expect(resp2.status).toBe(200);
expect(await resp2.text()).toBe('Hi');
});
});
describe('listening', () => {
let server: Server;
afterEach(() => {
server.stop(true);
});
it('should assign default port', async () => {
const app = new HttpRouter();
server = app.listen();
app.get('/', () => new Response('Hi'));
const resp = await fetch(server.url);
expect(typeof server.port).toBe('number');
expect(server.port).toBeGreaterThan(0);
expect(resp.status).toBe(200);
expect(await resp.text()).toBe('Hi');
});
});
describe('server', () => {
let app: HttpRouter;
let server: Server;
beforeEach(() => {
app = new HttpRouter();
server = app.listen({ port: 0 });
});
afterEach(() => {
server.stop(true);
});
it('should get client ip info', async () => {
app.get('/', c => c.json(c.ip));
const resp = await fetch(server.url);
const info = (await resp.json()) as {
address: string;
family: string;
port: number;
};
expect(info.address).toBe('::1');
expect(info.family).toBe('IPv6');
expect(info.port).toBeGreaterThan(0);
});
it('should emit url', async () => {
app.all('/', () => new Response('Hi'));
let output: string = '';
const to = (message: string) => (output = message);
app.emitUrl({ to });
expect(output).toContain(String(server.url));
});
it('should handle all', async () => {
app.all('/', () => new Response('Hi'));
const resp = await fetch(server.url);
expect(resp.status).toBe(200);
expect(await resp.text()).toBe('Hi');
});
it('should handle GET', async () => {
app.get('/', () => new Response('Hi'));
const resp = await fetch(server.url);
expect(resp.status).toBe(200);
expect(await resp.text()).toBe('Hi');
});
it('should handle PUT', async () => {
app.put('/', async ({ request, json }) => {
return json(await request.json());
});
const resp = await fetch(server.url, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: 'Alice' }),
});
const body = await resp.json();
expect(resp.status).toBe(200);
expect(body).toEqual({ name: 'Alice' });
});
it('should handle HEAD', async () => {
app.head('/hi', ({ url }) => {
const name = url.searchParams.get('name');
return new Response(null, {
status: 200,
headers: {
'Content-length': '0',
Message: `Hi ${name}`,
},
});
});
const resp = await fetch(`${server.url}/hi?name=Bob`, {
method: 'HEAD',
});
expect(resp.status).toBe(200);
expect(resp.headers.get('Message')).toBe('Hi Bob');
});
it('should handle POST', async () => {
app.post('/parrot', async ({ request }) => {
const formData = await request.formData();
const json = JSON.stringify(Object.fromEntries(formData));
return new Response(json, {
status: 200,
headers: {
'Content-type': 'application/json',
},
});
});
const formData = new URLSearchParams();
formData.append('key', 'secret');
const resp = await fetch(`${server.url}/parrot`, {
method: 'POST',
headers: {
'Content-type': 'application/x-www-form-urlencoded',
},
body: formData,
});
expect(resp.status).toBe(200);
expect(await resp.json()).toEqual({ key: 'secret' });
});
it('should handle POST', async () => {
app.post('/parrot', async ({ request }) => {
const formData = await request.formData();
const json = JSON.stringify(Object.fromEntries(formData));
return new Response(json, {
status: 200,
headers: {
'Content-type': 'application/json',
},
});
});
const formData = new FormData();
formData.append('key2', 'secret2');
const resp = await fetch(`${server.url}/parrot`, {
method: 'POST',
body: formData,
});
expect(resp.status).toBe(200);
expect(await resp.json()).toEqual({ key2: 'secret2' });
});
it('should handle PATCH', async () => {
app.patch('/', async ({ request, json }) => {
return json(await request.json());
});
const resp = await fetch(server.url, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name: 'Charlie' }),
});
const body = await resp.json();
expect(resp.status).toBe(200);
expect(body).toEqual({ name: 'Charlie' });
});
it('should handle TRACE', async () => {
let body: { name: string } = { name: '' };
app.trace('/', () => {
return new Response(null, {
headers: {
'Content-type': 'message/http',
},
});
});
const resp = await fetch(server.url, {
method: 'TRACE',
headers: {
'Max-Forwards': '0',
},
});
expect(resp.status).toBe(200);
expect(resp.headers.get('Content-type')).toBe('message/http');
});
it('should handle DELETE', async () => {
let id: string = 'N/A';
app.delete('/users/:id', ({ params }) => {
id = params.id;
return new Response(null, {
status: 204,
headers: {
'Content-type': 'application/json',
},
});
});
const resp = await fetch(`${server.url}/users/42`, {
method: 'DELETE',
});
expect(resp.status).toBe(204);
expect(id).toBe('42');
});
it('should handle OPTIONS', async () => {
app.options('*', () => {
return new Response(null, {
status: 204,
headers: {
'Access-Control-Allow-Methods': 'GET,POST',
},
});
});
const resp = await fetch(`${server.url}/users/42`, {
method: 'OPTIONS',
});
expect(resp.status).toBe(204);
expect(resp.headers.get('Access-Control-Allow-Methods')).toBe('GET,POST');
});
it('should store data on locals', async () => {
app.get('/home', ({ app }) => {
return new Response(app.locals.foo);
});
app.locals.foo = 'bar';
const resp = await fetch(`${server.url}/home`);
expect(resp.status).toBe(200);
expect(await resp.text()).toBe('bar');
});
});
describe('ssl', () => {
let app: HttpRouter;
let server: Server;
beforeEach(() => {
app = new HttpRouter();
server = app.listen({
port: 0,
reusePort: true,
tls: {
// files generated with the following:
// openssl req -newkey rsa:2048 -nodes -keyout private.key -x509 -days 365 -out certificate.crt -subj "/C=US/ST=State/L=City/O=Organization/OU=Unit/CN=localhost"
key: Bun.file(`${import.meta.dir}/../../testFixtures/private.key`),
cert: Bun.file(
`${import.meta.dir}/../../testFixtures/certificate.crt`
),
},
});
});
afterEach(() => {
server.stop(true);
});
it('should work on https', async () => {
app.get('/', () => new Response('Hello https'));
expect(server.url.protocol).toBe('https:');
const resp = await fetch(`${server.url}`, {
// @ts-expect-error Type is wrong
tls: {
// Accept self-signed certificates
rejectUnauthorized: false,
},
});
expect(resp.status).toBe(200);
expect(await resp.text()).toBe('Hello https');
});
});
});