« Back to home

Node.js is a super popular technology with a vibrant community, tons of libraries, updates, new language features, etc. Node.js is quite fast, it's easy to use, and it's literally perfect for full-stack people, because you have same language on front-end and back-end, which does have multiple benefits. Why would anybody choose something else?

There were three main things, that pushed me to try Openresty:

  1. Openresty is basically Nginx with a possibility to write code in Lua. I use Nginx already in almost every one of my projects. I use it in Kubernetes, and I use it in custom VMs. I like it a lot. It's super efficient, lightweight, very configurable, and I never had a single problem with Nginx. Nginx does a lot of work for me already: e.g. routing, rate limiting, authentication, SSL termination. It feels quite natural to give it a chance to take one more duty - implement the business logic.
  2. I stumbled upon a benchmark comparing Openresty with Node.js, Go, Java, C#, etc. And then I looked into several other benchmarks with Openresty. Benchmarks are tricky and not to be trusted - I realize that. However, you can learn some things from them, and one thing I learned was, Openresty is mindblowingly memory efficient (and very fast too). Which is quite cool! :)
  3. And probably the main breaking point was - Typescript! I stumbled upon the TypescriptToLua project, which works in a very similar way as my very own TS2C. And it's much easier to generate Lua from TS, so unlike TS2C, TypescriptToLua is production ready and seems to work pretty well! So yes, if I can use Openresty, and still continue writing my code in Typescript... I must try it!

Openresty "Hello World" written in Typescript

Let's jump right into it:

npm i -D typescript-to-lua lua-types openresty-lua-types

Create tsconfig.json:

{
    "compilerOptions": {
        "target": "esnext",
        "lib": [
            "esnext"
        ],
        "moduleResolution": "node",
        "types": ["lua-types/jit", "openresty-lua-types"],
        "strict": true
    },
    "tstl": {
        "luaTarget": "JIT"
    }
}

Notice "types" section - this is where you define TS types every time you install them from npm.

Now, create a basic Nginx config (e.g. nginx.conf):

worker_processes 1;

events {
    worker_connections 128;
}

http {
    resolver 8.8.8.8;

    server {
        listen 127.0.0.1:80;
        server_name localhost;

        location = /test {
            content_by_lua_file helloworld.lua;
        }
    }
}

Of course, we don't want to write in Lua. Instead, let's create a helloworld.ts file:

ngx.say("Hello world!");
ngx.exit(ngx.HTTP_OK);

At this point, there should already be a full intellisense support - code completion, hints, etc.

We're ready to build the project! Run tstl in the terminal, you should get helloworld.lua generated.

In order to test that it works:

  1. Ensure that you have Openresty installed
  2. Ensure that you have created a logs directory: mkdir logs
  3. Run openresty by executing openresty -p `pwd`/ -c nginx.conf
  4. Test with e.g. curl localhost:3000/test or opening this URL in your browser

By the way, if you're on Windows, Windows installer is also available (or alternatively you can use WSL of course).

Using Lua libraries

Obviously, as soon as you start creating some real world code, you would have to use libraries. Unfortunately, not many TS definitions for Openresty Lua libraries are available via NPM at the moment. But it's quite easy to create your own.

Creating TS definitions for a Lua library

For example, I wanted to use lua-resty-mail module for sending emails, so what I did, I created a definition file types/resty.mail.d.ts in my project and put the following code there:

declare module "resty.mail" {
    /** @noSelf */
    interface MailConstructor {
        ["new"](options: MailOptions): Mail;
    }

    interface Mail {
        send(msg: MailMessage): LuaMultiReturn<[boolean, string]>;
    }

    var mail: MailConstructor;
    export = mail;
}

Notice, there are few quirks comparing to ordinary TS definitions.

For example, Lua methods and functions can return multiple values, and this is not the same as returning an array! Typescript cannot return multiple values, it can only return a single value (which might be an array). This is why we must use LuaMultiReturn to indicate that this particular function actually returns multiple values rather than a table.

Another quirk is that I am using /** @noSelf */ jsdoc annotation to indicate, that "new" is a static method. The thing is, in Lua, there are two ways to call methods: either with dog.bark() or with dog:bark(). The ":" is basically a syntax sugar that gets dog passed as the first argument to the bark method, i.e. dog:bark() is equivalent to dog.bark(dog).

Actually, a pretty similar mechanism exists in Javascript, except, Javascript uses this parameter implicitly rather than passing extra parameter explicitly. And we all know how annoying it is to always remember to bind the correct this parameter when passing method around... Honestly, I like Lua's approach better :D

Anyways, the point is, it's not always easy to map features from one language to another, so sometimes we need to provide some additional information in form of jsdoc annotations or special types.

More details about writing declarations is available in TypeScriptToLua's documentation:

Configuring module resolution

Additionally, I had to update the tsconfig and add "resty.mail" to noResolvePaths:

From the documentation:

noResolvePaths

An array of require paths that will NOT be resolved. For example ["require1", "sub.require2"] will stop tstl from trying to resolve Lua sources for require("require1") and require("sub.require2")

{
    "compilerOptions": {
        /*...*/
    },
    "tstl": {
        /*...*/
        "noResolvePaths": ["resty.mail"]
    }
}

If you don't do that, LuaToTypescript will try to search for a TypeScript (not Lua!) module "resty.mail" and will fail with an error...

Once you have the Typescript definitions, you can use installation instructions from the Lua library to actually install it (typically via LuaRocks or OPM).

Regular expressions

It seems that currently, TypescriptToLua doesn't support compiling TS regexps to Lua regexps (which is a pity).

So for example this doesn't work:

const refererMatch = ngx.var.http_referer.match(/^https?:\/\/[a-zA-Z0-9\.:-]+/);

instead, you have to use Lua string functions, e.g. like this:

const refererMatch = string.match(ngx.var.http_referer, "^https?:\/\/[a-zA-Z0-9\.:-]+");

Even though it looks the same in this particular case, but in more general case, Lua's patterns are more limited and have slightly different syntax. I still think that TypescriptToLua could have done a better work with regular expressions, e.g. map what can be mapped and only throw if some unsupported features are used. Maybe I will contribute that, let's see :)

Multiple endpoints and request methods

One thing I wanted to have in my setup, is to be able to handle GET and POST requests separately, with separate files.

After some experimentation, I was able to achieve it in Nginx with the following config:

location = /api/user-settings {
    add_header Cache-control no-store always;

    if ($request_method !~ "^GET|POST$") {
        return 404;
    }

    content_by_lua_file endpoints/user-settings-$request_method.lua;
}
location = /api/get-in-touch {
    add_header Cache-control no-store always;

    if ($request_method != POST) {
        return 404;
    }
    client_body_buffer_size 8k;
    client_max_body_size 8k;

    content_by_lua_file endpoints/get-in-touch-$request_method.lua;
}

The folder structure will look something like this:

.
├── endpoints
│   ├── get-in-touch-POST.ts
│   ├── user-settings-GET.ts
│   └── user-settings-POST.ts
├── types
│   ├── resty.moongoo.d.ts
│   └── resty.mail.d.ts
├── nginx.conf
├── package.json
├── package-lock.json
└── tsconfig.json

I find this setup very handy to manage different endpoints in a microservice. Actually, we can improve it even further if needed - by splitting the nginx.conf file to separate location blocks and using include to import them.

Using even more Nginx features

One of the things that I really liked about openresty, it is fully integrated with Nginx and you can utilize all existing Nginx features.

Notice that in the previous example, I was using add_header to ensure correct cache settings, and client_max_body_size to limit size of the body to a reasonable value (within this particular context). Request limiting is also super easy to configure.

Another interesting example of leveraging Nginx power is making HTTP requests. Instead of using some Lua alternative of axios (which is also available), it turns out you can make http requests by leveraging Nginx internal location with proxy_pass:

location /api/hello {
    content_by_lua_block {
        local cjson = require "cjson"
        local res = ngx.location.capture('/fetchFromS3/settings.json')
        local settings = cjson.decode(res.body);
        return settings.helloMessage;
    }
}

location /fetchFromS3/ {
    internal;
    proxy_pass https://my-bucket.s3.us-west-2.amazonaws.com/;
}

How cool is that!?

Note: Please be aware that ngx.location.capture always loads the whole response into memory! Never use it for fetching big files, you can use lua-resty-http or lua-resty-requests instead, they allow body streaming. Or maybe if you just want to serve a file, you can use Nginx streaming functionality and use lua only e.g. for access control via access_by_lua block.

Other notes

According to Netcraft's March 2022 Web Server Survey, Openresty is the number 3 web server in the world by popularity (after Nginx and Apache) and used on 7.72% percent of the websites out of over 1 billion websites that they have checked.

Openresty is used by many big companies such as Cloudflare, Shopify, Netflix, Mailchimp, World Health Organization, and even P*rnhub :)

Of course, in most cases, it is used as intelligent reverse proxy, while my idea is to use it as a full-scale web server for small and light-weight microservices.

The ecosystem is kind of bleak though

Even though it seems to be much better than it used to be (back in ~2012, when I worked with Lua a lot, it was hard to find even simple code examples!), it is still worlds apart from Node.js ecosystem. Compared to a blooming Node.js world, it's like suddenly finding yourself in a withered, desolate land...

Very few articles, only couple of conferences (all of them in China), sad story with Lua version fragmentation, every second openresty library on Github - "last updated 5-7 years ago", etc.

In practice, it means, you have to dig a lot on your own, and write a lot of stuff yourself. This is fine for me, but I understand that for many people it is very hard. So if you wanted to try Openresty and expect a hassle free journey, I am afraid that would not be the case!

Conclusion

I converted one of my microservices to Openresty - it's running in production already, - and for now, I am pretty happy with the results. I like the power of Nginx and it's efficiency, and I like that thanks to Nginx native features I don't need to write a lot of code to achieve what I need.

I like a lot that I can still write code in Typescript, and I will try to find ways to write universal TS code that can be used both in Node.js and Openresty.

I definitely don't like the state of the ecosystem and also certain TypescriptToLua quirks/limitations, but at least the latter is very solvable, especially with help of my experience from TS2C.

So yes, Openresty is cool! :)