Xerus

Phillip England_

Xerus

An Express-like HTTP Library for Bun

Docs

Read the docs

Installation

1bun add github:phillip-england/xerus

Quickstart

 1import { HTTPContext, logger, Xerus } from "xerus/xerus";
 2
 3let app = new Xerus()
 4
 5app.use(logger)
 6
 7app.get("/static/*", async (c: HTTPContext) => {
 8  return await c.file("." + c.path);
 9});
10
11app.get('/', async (c: HTTPContext) => {
12  return c.html(`<h1>O'Doyle Rules!</h1>`)
13})
14
15await app.listen()

HTTPHandlerFunc

An HTTPHandlerFunc takes in an HTTPContext and returns Promise<Response>:

1let handler = async (c: HTTPContext) => {
2  return c.html(`<h1>O'Doyle Rules</h1>`)
3}
4
5app.get('/', handler)

Routing

Xerus supports static, dynamic, and wildcard paths:

1app.get('/', handler)
2app.get('/user/:id', handler)
3app.get('/static/*', handler)

Group routing is also supported:

1app.group('/api')
2  .post('/user/:id', handler)
3  .post('/user/post/:postNumber', handler)

File Based Routing

Xerus offers a file-based routing system which can be used as follows:

1import { FileRouter } from "xerus";
2
3let router = await FileRouter.new({
4  "src": path.join(process.cwd(), "app"),
5  "port": 8080,
6});

App Initalization (file based routing)

Assuming ./app is your project root, ./app/+init.tsx is where you can run any init logic:

Below, I set up logging and serving static files.

./app/+init.tsx:

1let module = new InitModule();
2
3module.init(async (app: Xerus) => {
4  app.use(logger);
5  app.static("static");
6});
7
8export default module;
9

Routes (file based routing)

Assuming ./app is your project root, ./app/+route.tsx will provide the logic for all routes hitting /:

./app/+route.tsx:

 1let module = new RouteModule();
 2
 3module.get(async (c: HTTPContext) => {
 4  return c.jsx(
 5    <GuestLayout title="Home Page">
 6      <h1>Home Page!</h1>
 7    </GuestLayout>,
 8  );
 9});
10
11export default module;

For /about, place the following in ./app/about/route.tsx:

 1let module = new RouteModule();
 2
 3module.get(async (c: HTTPContext) => {
 4  return c.jsx(
 5    <GuestLayout title="About Page">
 6      <h1>Home Page!</h1>
 7    </GuestLayout>,
 8  );
 9});
10
11export default module;

For dynamic routing, try ./app/user/:id/+route.tsx:

 1let module = new RouteModule();
 2
 3module.get(async (c: HTTPContext) => {
 4  return c.jsx(
 5    <GuestLayout title="User Page">
 6      <h1>User {c.getParam('id')}</h1>
 7    </GuestLayout>,
 8  );
 9});
10
11export default module;

Middlware (file based routing)

Middlware is export out of +route.tsx file. All middlware can be of type Cascade or Isolate.

In short, Cascade middleware will pour down onto any file beneath itself in the filesystem whereas Isolate middleware will only affect the route it is exported from.

For example, here we apply the same middleware, once as Isolate, then again as Cascade:

 1const testmw = new Middleware(
 2  async (c: HTTPContext, next: MiddlewareNextFn) => {
 3    console.log("before");
 4    await next();
 5    console.log("after");
 6  },
 7);
 8
 9
10let module = new RouteModule();
11
12module.get(async (c: HTTPContext) => {
13  return c.jsx(
14    <GuestLayout title="Home Page">
15      <h1>User {c.getParam('id')}</h1>
16    </GuestLayout>,
17  );
18}, isolate(testmw), cascade(testmw));
19
20export default module;

Middleware is excuted from top to bottom in sync with the filesystem.

Static Files

Use a wildcard to serve static files from ./static:

1app.get("/static/*", async (c: HTTPContext) => {
2  return await c.file("." + c.path);
3});

Middleware

Middleware executes in the following order:

  1. Global
  2. Group
  3. Route

Create a new Middleware:

1let mw = new Middleware(
2  async (c: HTTPContext, next: MiddlewareNextFn): Promise<void | Response> => {
3    console.log('logic before handler');
4    next();
5    console.log("logic after handler");
6  },
7);

Link it globally:

1app.use(mw)

Or to a group:

1app.group('/api', mw) // <=====
2  .post('/user/:id', handler)
3  .post('/user/post/:postNumber', handler)

Or to a route:

1app.get('/', handler, mw) // <=====

Chain as many as you'd like to all three types:

1app.use(mw, mw, mw)
2
3app.group('/api', mw, mw, mw)
4  .post('/user/:id', handler)
5  .post('/user/post/:postNumber', handler)
6
7app.get('/', handler, mw, mw, mw)

HTTPContext

HTTPContext allows us to work with the incoming requests and prepare responses. Here are the features it provides.

Redirect The Request

1app.get('/', async (c: HTTPContext) => {
2  return c.html(`<h1>O'Doyle Rules</h1>`)
3})
4
5app.get('/redirect', async(c: HTTPContext) => {
6  return c.redirect('/')
7})

Parse The Request Body

Use the BodyType enum to enforce a specific type of data in the request body:

 1app.post('/body/text', async (c: HTTPContext) => {
 2  let data = await c.parseBody(BodyType.TEXT)
 3  return c.json({data: data})
 4})
 5
 6app.post('/body/json', async (c: HTTPContext) => {
 7  let data = await c.parseBody(BodyType.JSON)
 8  return c.json({data: data})
 9})
10
11app.post('/body/multipart', async (c: HTTPContext) => {
12  let data = await c.parseBody(BodyType.MULTIPART_FORM)
13  return c.json({data: data})
14})
15
16app.post('/body/form', async (c: HTTPContext) => {
17  let data = await c.parseBody(BodyType.FORM)
18  return c.json({data: data})
19})

Get Dynamic Path Param

1app.get('/user/:id', async (c: HTTPContext) => {
2  let id = c.getParam('id')
3  return c.html(`<h1>O'Doyle Rules Times ${id}!</h1>`)
4})

Set Status Code

1app.get('/', async (c: HTTPContext) => {
2  return c.setStatus(404).html(`<h1>O'Doyle Not Found</h1>`)
3})

Set Response Headers

1app.get('/', async (c: HTTPContext) => {
2  c.setHeader('X-Who-Rules', `O'Doyle Rules`)
3  return c.html(`<h1>O'Doyle Rules!</h1>`)
4})

Get Request Header

1app.get('/', async (c: HTTPContext) => {
2  let headerVal = c.getHeader('X-Who-Rules')
3  if (headerVal) {
4    return c.html(`<h1>${headerVal}</h1>`)
5  }
6  return c.html(`<h1>Header missing</h1>`)
7})

Respond with HTML, JSON, or TEXT

 1app.get('/html', async (c: HTTPContext) => {
 2  return c.html(`<h1>O'Doyle Rules!</h1>`)
 3})
 4
 5app.get('/json', async (c: HTTPContext) => {
 6  return c.json({message: `O'Doyle Rules!`})
 7})
 8
 9app.get('/text', async (c: HTTPContext) => {
10  return c.text(`O'Doyle Rules!`)
11})

Stream A Response

 1app.get('/', async (c: HTTPContext) => {
 2  const stream = new ReadableStream({
 3    start(controller) {
 4      const encoder = new TextEncoder();
 5      let count = 0;
 6      const interval = setInterval(() => {
 7        controller.enqueue(encoder.encode(`O'Doyle Rules! ${count}\n`));
 8        count++;
 9        if (count >= 3) {
10          clearInterval(interval);
11          controller.close();
12        }
13      }, 1000);
14    }
15  });
16  c.setHeader("Content-Type", "text/plain");
17  c.setHeader("Content-Disposition", 'attachment; filename="odoyle_rules.txt"');
18  return c.stream(stream);
19});

Response With A File

1app.get('/', async (c: HTTPContext) => {
2  return c.file("./path/to/file");
3});

Stream A File

1app.get('/', async (c: HTTPContext) => {
2  return c.file("./path/to/file", true);
3});

Set, Get, And Clear Cookies

 1app.get('/set', async (c: HTTPContext) => {
 2  c.setCookie('secret', "O'Doyle_Rules!")
 3  return c.redirect('/get')
 4});
 5
 6app.get('/get', async (c: HTTPContext) => {
 7  let cookie = c.getCookie('secret')
 8  if (cookie) {
 9    return c.text(`visit /clear to clear the cookie with the value: ${cookie}`)
10  }
11  return c.text('visit /set to set the cookie')
12})
13
14app.get('/clear', async (c: HTTPContext) => {
15  c.clearCookie('secret')
16  return c.redirect('/get')
17})

Custom 404

1app.onNotFound(async (c: HTTPContext): Promise<Response> => {
2  return c.setStatus(404).text("404 Not Found");
3});

Custom Error Handling

1app.onErr(async (c: HTTPContext): Promise<Response> => {
2  let err = c.getErr();
3  console.error(err);
4  return c.setStatus(500).text("internal server error");
5});

Web Sockets

Setup a new websocket route, using onConnect for pre-connect authorization:

 1app.ws("/chat", {
 2  async open(ws) {
 3    let c = ws.data // get the context
 4    
 5  },
 6  async message(ws, message) {
 7
 8  },
 9  async close(ws, code, message) {
10
11  },
12  async onConnect(c: WSContext) {
13    c.set('secret', "O'Doyle") // set pre-connect data
14  }
15});