🔝

Documentation

⚠️ WIP This is an experimental library right now!

A web server for Bun, Node.js and Functions/Workers with the basics built-in:

import server from "@server/next";

export default server(options)
  .get("/books", () => Book.list())
  .post("/books", BookSchema, (ctx) => {
    return Book.create(ctx.body).save();
  });

It includes all the things you would expect from a modern Server framework, like routing, static file serving, body+file parsing, gzip+brotli, streaming, testing, error handling, websockets, etc.

We also have integrations and adaptors for these:

  • KV Stores: in-memory, Redis, Consul, DynamoDB, Level.
  • Buckets: AWS S3, Cloudflare R2, Backblaze B2.
  • Validation libraries: Zod, Joi, Yup, Validate, etc.
  • Auth: JWT, Session, Cookies, Social login.
// index.test.js
// How to test your server with the built-in methods
import app from "./";  // Import your normal app
const api = app.test();  // Very convenient helper, AXIOS-like interface

it("can retrieve the book list", async () => {
  const { data: books } = await api.get("/books/");
  expect(books[0]).toEqual({ id: 0, name: ... });
});

Getting started

First install it:

npm install @server/next
yarn add @server/next
bun install @server/next

Now you can create your first simple server:

// index.js
import server from "@server/next";

export default server()
  .get("/", () => "Hello world")
  .post("/", (ctx) => {
    console.log(ctx.body);
    return 201;
  });

Then run node . or bun . and open your browser on http://localhost:3000/ to see the message.

There are some major configuration options that you might want to set up though, enumerated in the Basic Usage and explained through the docs.

Guides

Basic usage

Now that you know how to create a barebones server, there are some important bits that you might want to update.

SECRET: create an .env file (ignored in git with .gitignore) with the SECRET= and then a long, unique random secret. It will be used to sign and/or encrypt things as needed.

store: almost anything you want to persist will need a KV store to do so. For dev you can use an in-memory or a file-based (easy for debugging!) store, but for production systems you would normally use something like Redis. Can be as easy as const store = new Map(); for dev.

Note: bucket is still not available

bucket: if you want to accept user files you will need a place to persist them. By default they are put in the filesystem, but since most cloud providers are ephemeral, provide a bucket and it will be used to handle the files directly.

An example of how that works in practice:

// index.js
import server from "@server/next";

import Bucket from "bucket/b2";
import { createClient } from "redis";

const bucket = Bucket("mybucketname", { id, key });
const store = createClient("...").connect();

export default server({ bucket, store })
  .get("/", () => "Hello world")
  .post("/", (ctx) => {
    console.log(ctx.body);
    return 201;
  });

Middleware

Validation

Stores

File handling

To manage files, you need to install and use the library bucket, which is a very thin wrapper for file management systems. It is also created by the makers of Server.js.

The easiest and default is to set a folder in your filesystem:

import FileSystem from "bucket/fs";

const uploads = FileSystem("./uploads");

// All paths are relative to the CWD
export default server({ uploads })
  .get("/", () => "Hello")
  .put("/users/:id", async (ctx) => {
    // This is the plain string as the file name, already in our FS
    const fileName = ctx.body.profile;
    // 'yOuZEdSsNLq8PgZyLhSz0Llh.jpg'

    // Convert it into a File instance
    const file = ctx.uploads.file(fileName);

    // Now we can use other methods if we want
    // .info(), .read(), .write(), .pipe(), .pipeTo(), etc
    const info = await file.info();
    // {
    //    id: "yOuZEdSsNLq8PgZyLhSz0Llh.jpg",
    //    type: "jpg",
    //    size: 435435,
    //    timestamp: "2024-08-07T14:26:37Z",
    //    // Note: this can be customized providing the option "domain"
    //    url: "file:///Users/me/my-project/uploads/yOuZEdSsNLq8PgZyLhSz0Llh.jpg",
    // }

    return 200;
  });

To upload the files to a 3rd party system, you just need to use the corresponding bucket implementation (or write a thin compatibility layer). Let's see an example with Backblaze's B2:

import server from "@server/next";
import Backblaze from "bucket/b2";

const uploads = Backblaze("bucket-name", {
  id: process.env.BACKBLAZE_ID,
  key: process.env.BACKBLAZE_KEY,
});

export default server({ uploads })
  .put("/users/:id", async (ctx) => {
    const fileName = ctx.body.profile;
    // 'yOuZEdSsNLq8PgZyLhSz0Llh.jpg'

    // Convert it into a File instance
    const file = ctx.uploads.file(fileName);

    // Now we can use other methods if we want
    const info = await file.info();
    // {
    //    id: "yOuZEdSsNLq8PgZyLhSz0Llh.jpg",
    //    type: "jpg",
    //    size: 435435,
    //    timestamp: "2024-08-07T14:26:37Z",
    //    url: "https://f???.backblazeb2.com/???/yOuZEdSsNLq8PgZyLhSz0Llh.jpg",
    // }

    return 200;
  });
  .;

Example: resizing the user profile picture

Let's see a complete example of uploading and resizing a user profile picture:

import server, { status } from "@server/next";
import sharp from "sharp";
import FileSystem from "bucket/fs";

const uploads = FileSystem("./uploads");

// All paths are relative to the CWD
export default server({ uploads })
  .get("/", () => "Hello")
  .put("/users/:id", async (ctx) => {
    // Create the instance of the file to read and write
    const src = ctx.uploads.file(ctx.body.profile);
    const dst = ctx.uploads.file("/profile/" + ctx.url.params.id + ".jpg");

    const ext = src.id.split(".").pop();
    if (!["jpg", "jpeg", "png", "webp", "avif"].includes(ext)) {
      await src.remove(); // Don't store it
      return status(400).json({ error: "Invalid file format" });
    }

    // Create the Readable, Transform and Writable Node streams
    await pipeline(
      src.readable("node"),
      sharp().resize(200, 200).jpg(),
      dst.writable("node")
    );

    // We no longer need the original file
    await src.remove();

    return status(200).json({ updated: true });
  });

Note that the option uploads gets converted into a Bucket instance and passed as ctx.uploads. Th

JSX

JSX is an amazing template language and so Server.js supports it when using Bun. To use it in your project, create a bunfig.toml with this content:

jsx = "react-jsx"
jsxImportSource = "@server/next"

Then call your files .jsx and you are ready to go!

import server from '@server/next';

export default server()
  .get('/', () => <Home />)
  .get('/:page', ctx => <Page id={ctx.url.params.page} />);

The main difference to be aware of compared to normal JSX is that you need to include everything, that is the <html> tag, <body> tag, etc. since we will send whatever is passed as the html. This has more advantages than disadvantages so we thought it was worth it, but there are definitely trade-offs:

  • We will send the html fragments unmodified, so () => <div>Hello</div> will render "<div>Hello</div>".
  • Exception: if you define <html>...</html> as the top level, we will automatically inject <!DOCTYPE html>, since it's not possible to injext that with JSX.
  • You also need to define the top level tags and html structure such as <html>, <head>, <body>, etc. We recommend putting those into a template and reusing it, but that's up to your preferences.
  • This is a great match for HTMX!
  • You can use fragments as usual with <></> (but not with ).
  • Since you are using JSX, normal interpolation is safe from XSS since any special characters are encoded as their html entities.
  • You can set up raw HTML, e.g. to avoid having inline JS scripts escaped, like this: <script dangerouslySetInnerHTML={{ __html: "alert('hello world');" }}></script>.

Some examples:

export default server()
  .get('/', () => (
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>My first app</title>
      </head>
      <body>
        <h1>My first app</h1>
        <form>
          Your name:
          <input name="firstname" />
          <br />
          <button>Send</button>
        </form>
        <script src="/handle-form.js"></script>
      </body>
    </html>
  ));

HTMX

While we don't have an explicit HTMX integration, we believe there's two ways of using HTMX with Server.js that are really good! First let's see the vanilla version:

export default server()
  .get('/', () => file('index.html'))
  .post('/action', async (ctx) => {
    // do sth

    return '<div>Success!</div>';
  });

In here, we assume you have a template in index.html and are loading HTMX from there. Then when an action occurs, you can return the raw HTML string.

⚠️ However, if you interpolate strings like that you might be subject to XSS attacks!

.post('/action', async (ctx) => {
  // DO NOT DO THIS
  return `<div>Success! ${ctx.url.query.name}</div>`;
  // DO NOT DO THIS
});

For that reason we recommend that you set up JSX with Server.js and then instead reply like this:

.post('/action', async (ctx) => {
  // This is safe since html entities will be encoded:
  return <div>Success! {ctx.url.query.name}</div>;
});

Since JSX will treat that interpolation as a text interpolation and not a html interpolation, html entities will be escaped as expected and presented as plain text.

Options

Options docs here

Router

Router docs here

Parameters

The route parameters are represented in the path as /:name and then are passed as a string to ctx.url.params.name (see url docs as well):

export default server()
  .get('/:page', ctx => ({
    page: ctx.url.params.page,
    name: ctx.url.query.name
  }));

// test.js
const { body } = await app.test().get('/hello?name=Francisco');
expect(body).toEqual({ page: 'hello', name: 'Francisco' });

Context

Context docs here

Reply

Reply docs here

Testing

Testing is an integral part of Server.js, and so we provide some helpers for it! The main one you should usually be aware of is the .test() method applied to the app. For the usual test setup, you have your normal server file, and then your test file like this:

// src/index.js - where your server runs
export default server()
  .get('/', () => 'Hello world')
  .get('/obj', () => ({ hello: 'world' }));

Then to test that, you can create a test file such as:

// src/index.test.js
import app from './index.js';

// Create a testing endpoint from your app:
const api = app.test();

describe('home', () => {
  it('returns a simple "Hello world"', async () => {
    const { body } = await api.get('/');
    expect(body).toBe('Hello world');
  });

  it('returns a JSON object', async () => {
    const { body, headers } = await api.get('/obj');
    expect(headers['content-type']).toInclude('application/json');
    expect(body).toBe({ hello: 'world' });
  });
});

In this example, since it's a normal test file, we showed how to create a testing instance and then calling methods on it. You could also not even create the api variable and instead just call it like this:

const { body } = await app.test().get('/');

The server is emulating the request/response, but it uses the same logic as Server.js, so tests are blazing fast and follow the proper router/middleware/etc. However, this has the disadvantage that it's not a real HTTP request, so there are some limitations. For example, the compression such as brotli or the content-size header are usually set by the edge proxy, so you won't be able to test those here.

Runtimes

There are many runtimes where Server works! We put a lot of work to make sure it works the same way with minimal changes in them, this includes:

  • Node.js of course, including everywhere that Node is supported (VPS, Heroku, Render, etc).
  • Bun, and it will be even faster!
  • Cloudflare Worker
  • Netlify Function + Edge function

FAQ

How is it different from Hono?

Server.js attempts to run your code unmodified in all runtimes. With Hono you need to change the code for different runtimes:

// Server.js code for Node.js, Bun and Netlify
import server from "@server/next";
export default server().get("/", () => "Hello server!");
// Hono code for Node.js
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello Node.js!'))
serve(app)

// Hono code for Bun
import { Hono } from 'hono'
const app = new Hono()
app.get('/', (c) => c.text('Hello Bun!'))
export default app

// Hono code for Netlify
import { Hono } from 'jsr:@hono/hono'
import { handle } from 'jsr:@hono/hono/netlify'
const app = new Hono()
app.get('/', (c) => c.text('Hello Hono!'))
export default handle(app)