loading...
Cover image for Meet tinyhttp, a 0-legacy, tiny and fast alternative to Express

Meet tinyhttp, a 0-legacy, tiny and fast alternative to Express

talentlessguy profile image v 1 r t l ・8 min read

What

tinyhttp is a modern Express-like web framework written in TypeScript and compiled to native ESM, that uses a bare minimum amount of dependencies trying to avoid legacy hell.

Here is a short list of most important features that tinyhttp has:

  • 2.5x faster than Express
  • ⚙ Full Express middleware support
  • ↪ Async middleware support
  • ☑ Native ESM and CommonJS support
  • 🚀 No legacy dependencies, just the JavaScript itself
  • 🔨 Types out of the box

tinyhttp only depends on 10 packages, 3 of which are framework's internals.

tinyhttp can be used as a newer alternative to Express for your Node.js backend apps without losing the benefits of Express.

You can visit the website or a repository page to get more info, or just keep reading the article :)

Why

My initial reason to write my own web framework was that I wanted to switch from Express.

There is a list of things I don't like in Express today:

  • No ESM support. Means no named imports support.
  • No TypeScript support. I have to install @types/express
  • A lot of useless polyfills such as array-flatten or safe-buffer of features that are already in JavaScript / Node.js, plus use of deprecated properties
  • Huge and old ES5 codebase. Quite hard to read the source code (subjective / uncommon but still)

So, I started searching for alternatives of Express with the same API. The only good thing I found was Polka. It's really good but what I personally didn't like there was that it doesn't provide req / res extensions out of the box and doesn't have types. So, I decided to write my own web framework.

Get started

Setup

Because tinyhttp supports and targets ESM (but supports CJS as well), let's create a module package to be able to use import / export syntax.

mkdir my-cool-tinyhttp-app
cd my-cool-tinyhttp-app
echo '{ "type": "module" }' > package.json
touch server.js

Note that you must have Node 13.2+ installed in order to use ES modules. If you don't want to upgrade Node.js, either use --experimental-modules flag or change import to require

Installation

Next we need to install tinyhttp. tinyhttp itself is split into small packages to debug / maintain things easier (for me and for a user).

All of the bare necessities to get started with tinyhttp, are placed in an the app module. It contains App, Router, Request and Response interfaces to be able to use routing, middleware connection and req / res extensions.

To install it, simply type:

npm i @tinyhttp/app

Because tinyhttp and middlewares for it reuse packages, it's recommended to use pnpm as a package manager, although it's not obligatory.

Hello World

Alright, we have the module, now let's send "Hello World" from tinyhttp server:

import { App } from '@tinyhttp/app'

const app = new App()

const PORT = 3000

app
  .get('/', (_, res) => void res.send('<h1>Hello World</h1>'))
  .listen(PORT, () => console.log(`Started on http://localhost:${PORT}!`))

This will start a web app on 3000 port. Let's check if it works by making a request using an HTTP client (curl or httpie, whatever your prefer)

➜ http localhost:3000
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 20
Date: Wed, 26 Aug 2020 19:56:53 GMT
Keep-Alive: timeout=5
etag: W/"14-SsoazAISF4H46953FT6rSL7/tvU"

<h1>Hello World</h1>

And it works :)

Main Concepts

tinyhttp inherits most of the concepts from Express so if you know Express you already know tinyhttp, but it's still worth going through some sections to know technical nuances and differences in tinyhttp.

Routing

Let's try to do some basic routing. As in Express, you can handle a request with any method listed in the http.METHODS.

HTTP Methods

So, let's try to handle a POST request:

import { App } from '@tinyhttp/app'
import { once } from 'events'

const app = new App()

const PORT = 3000

app.get('/', (req, res) => {
    res.send('Sent a GET!')
})

app.post('/', async (req, res) => {
    // Nothing complex here, we just listen to 'data' event and return the data as a promise to a `data` variable
    const data = await once(req, 'data').then(d => d.toString())

    // And then we send it
    res.end(`Sent some data: ${data}`)
})


app.listen(PORT, () => console.log(`Started on http://localhost:${PORT}!`))

And in the console we try it:

echo 'tinyhttp is epic' | http POST localhost:3000
HTTP/1.1 200 OK
Connection: keep-alive
Content-Length: 33
Date: Wed, 26 Aug 2020 20:09:15 GMT
Keep-Alive: timeout=5

Sent some data: tinyhttp is epic

And as you see, it works too :D. It's a very basic example and I wouldn't recommend using such request body handling, better use a body parser like Express body-parser.

Route paths and params

Also, you can use both route paths and route params, just like in Express.

Note that instead of an old path-to-regexp tinyhttp uses a faster and newer regexparam module that does exactly the same (matches routes with regex) but does it more quickly.

Example with route paths:

import { App } from '@tinyhttp/app'

const app = new App()

const PORT = 3000

const handler = (req, res) => void res.send('Hello World')

app.get('/route1', handler)

app.get('/route2', handler)

app.listen(PORT, () => console.log(`Started on http://localhost:${PORT}!`))

Example with route params:

import { App } from '@tinyhttp/app'

const app = new App()

const PORT = 3000

app.get('/user/:id', (req, res) => {
    res.send(`Hello ${req.params.id}!`)
})

app.listen(PORT, () => console.log(`Started on http://localhost:${PORT}!`))

And if we try to request /user/v1rtl, we get this:

http -b localhost:3000/user/v1rtl
Hello v1rtl!

As I mentioned earlier, tinyhttp uses regexparam inside, so if you run into issues with something not matching when it should, go check out the author's repo.

Middlewares

As in any backend application, we should be able to use middlewares, both external and custom ones. Because tinyhttp's Request and Response interfaces extend built-in IncomingMessage and ServerResponse, everything is compatible with Express middlewares, including typings (at least I haven't run into issues with using express typescript wares).

Custom wares

But before trying Express middleware, let's first create a custom one and try to attach it to an app. We'll write a very simple logger that will send messages to console once a request gets finished:

import { App } from '@tinyhttp/app'

const app = new App()

const PORT = 3000

function logger(req, res, next) {
  res.on('finish', () => console.log(`${req.method} ${req.url} ${res.statusCode}`))

  next()
}

app
  .use(logger)
  .get((req, res) => res.send('Hello World'))

app.listen(PORT, () => console.log(`Started on http://localhost:${PORT}!`))

As you see, a middleware uses next function. It's required for tinyhttp to understand that we finished doing things in this function and that we need to switch to the next middleware.

Now let's launch our Node.js application and do some random requests at the same time:

➜ http -b localhost:3000
Hello World

➜ http POST localhost:3000 -b
Not Found

➜ http -b localhost:3000/aaa
Not Found

And in the logs we'll see this:

node index.js
Started on http://localhost:3000!
GET / 200
POST / 404
GET /aaa 404

Our logger works as expected, yay!

By the way, if you're looking for a good, yet simple logger, you can try our own @tinyhttp/logger. Although, any logger for Express (and HTTP apps in general, if they use (req, res, next) for wares) will work.

Express wares

So, now let's try to use some express-specific middleware, for instance express-graphql:

npm i graphql express-graphql
import { App } from '@tinyhttp/app'
import graphql from 'graphql'
import expressGraphQL from 'express-graphql'

const app = new App()
const port = parseInt(process.env.PORT) || 3000

const schema = graphql.buildSchema(`
  type Query {
    hello: String
  }
`)

const rootValue = {
  hello: () => 'Hello world!',
}

app.use(
  '/graphql',
  expressGraphQL.graphqlHTTP({
    schema,
    graphiql: { headerEditorEnabled: true },
    rootValue,
  })
)

app.listen(port, () => console.log(`Listening on http://localhost:${port}`))

And if we try to query hello, we'll get Hello world! as expected:

➜ curl 'http://localhost:3000/graphql?query=%7B%0A%20%20hello%20%20%0A%7D%0A'
{
  "data": {
    "hello": "Hello world!"
  }
}

Async wares and error handling

You may noticed previously that we used async for a middleware handler function. tinyhttp supports async handlers, but keep in mind that you better wrap them in try...catch to prevent hanging Unhandled Promise Rejection errors. You can use next function to pass errors to a generic error handler (which is defined in App constructor, see docs for more info)

import { App } from '@tinyhttp/app'
import { promises as fs } from 'fs'

const app = new App()

const PORT = 3000

app
    .use(async (req, res, next) => {
        let file

        try {
            file = await fs.readFile('express 2020.txt')

            res.send(file.toString())
        } catch (e) {
            console.log(`Failed to open ${e.path} file`)
            next(e)
        }
    })

app.listen(PORT, () => console.log(`Started on http://localhost:${PORT}!`))

If we try to open a file with making a request to / (without assuming you created it) we'll get a 500 server error with this message in the console:

http localhost:3000
HTTP/1.1 500 Internal Server Error
Connection: keep-alive
Content-Length: 21
Date: Wed, 26 Aug 2020 20:55:35 GMT
Keep-Alive: timeout=5

Internal Server Error
// node server.js
Failed to open express 2020.txt file

Request / Response extensions

As you remember, Express contains a lot of helper functions in their req and res objects. tinyhttp has them too and is in progress of transferring all of them, but at the moment the most common is already here (for detailed descriptions with examples see docs).

Here's a list of what is present in Request:

  • req.hostname
  • req.query
  • req.route
  • req.params
  • req.protocol
  • req.secure
  • req.xhr
  • req.fresh
  • req.stale
  • req.accepts
  • req.get

and in Response:

  • res.cookie
  • res.clearCookie
  • res.end
  • res.json
  • res.send
  • res.status
  • res.set
  • res.links
  • res.location

I recommend to go through documentation to read more about the extensions you want to use in tinyhttp, but most of them works identically to Express ones.

aaaannd that's all you need to know about tinyhttp!

Conclusion

You can start using it today for backend applications, the repository contains a lot of examples, including MongoDB and GraphQL integrations.

Plans for the future

I'm thinking of keeping working on the framework until 1.0 release. By now, I have to cover all of the codebase in tests (atm only 47% is covered), make more examples and implement all of the middlewares from the list to become independent from Express ecosystem.

Feedback

Let me know what you think about tinyhttp! If you decided to try it, but something didn't work as it should, please consider opening an issue!

Thanks for reading this article :)

and please don't forget to star tinyhttp on github!

Posted on by:

talentlessguy profile

v 1 r t l

@talentlessguy

16yo nullstack dev, OSSer ⚡, expert in nothing

Discussion

pic
Editor guide
 

Really interesting. Did you tried with helmet, throttling, rate limiter, cors limiter also?
Does it have full request and response object?

Seems tremendous work done here, I definitely will check it out :)

 

Hi, sorry for the late response, but I'm gonna answer all of these now:

  • helmet module works nicely with tinyhttp, as well as any other Express-oritented middleware
  • For cors we have @tinyhttp/cors
  • tinyhttp extends http's (node built-in module) IncomingMessage and ServerResponse, so yeah it means that they have all of it

and thanks for ur compliment :D

 

Love Polka, really nice job!