Use Nodejs to extend Openresty/Nginx

time_gem◎ Eye of Agamotto

As known, Node.js is a popular javascript runtime based on V8 and libuv, with many third-party packages managed by npm.

How to enable nodejs in openresty, so that we could reuse its rich ecosystem?

Check https://github.com/kingluo/lua-resty-ffi/tree/main/examples/nodejs for source code.

lua-resty-ffi

https://github.com/kingluo/lua-resty-ffi

lua-resty-ffi provides an efficient and generic API to do hybrid programming in openresty with mainstream languages (Go, Python, Java, Rust, Node.js, etc.).

Features:

  • nonblocking, in coroutine way
  • simple but extensible interface, supports any C ABI compliant language
  • once and for all, no need to write C/Lua codes to do coupling anymore
  • high performance, faster than unix domain socket way
  • generic loader library for python/java/nodejs
  • any serialization message format you like

Just like Python, we could implement a loader library to embed nodejs in each nginx worker process.

Runtime initialization

In lua-resty-ffi, libffi_init() is defined in language specific shared library, used to initialize a ffi runtime (you could have multiple runtimes upon the same shared library, but with different configurations). One instance of runtime is normally one poll thread and other threads. How to implement libffi_init() in nodejs?

nodejs allows only one instance in one process, i.e. node::Environment, although you could have multiple instances of v8, i.e. v8::Isolate. So all ffi runtimes share this nodejs instance. Unlike python, nodejs does not provide C API to interact with nodejs when it is running, so we have to figure out some way to do init.

I choose unix domain socket.

The main thread of nodejs creates an unix socket server when libffi_init() was called for the first time. When nginx calls libffi_init() each time, it sends an init message, which contains the task queue pointer address and configuration string to the unix socket server. When the nodejs receives the init message, it would load the specific js module to do the real init, and finally, it returns the result to nginx.

Note that the unix socket is only used to do ffi runtime init, but not request-response flow between nginx and ffi runtime later, which uses lua-resty-ffi IPC there.

init_flow◎ init flow

Threading Model

Node.js is single threading. The main thread uses libuv to provide event-driven nonblocking I/O processing. But nodejs supports worker thread, which is usually used to do CPU-intensive or other blocking tasks. Polling tasks from nginx is blocking, so we can take advantage of one worker thread to do polling. Once the polling thread gets one task, it passes the task to the main thread via parentPort, which is MessagePort allowing communication with the parent thread.

req-rsp-flow◎ request flow

Wrap lua-resty-ffi API

Unlike python cffi, nodejs lacks a good ffi library.

The most famous one is node-ffi-napi, but I don’t know how to use it to convert a long integer into pointer, which is exactly what I need for task queue pointer conversion. And this project said that it has a non-trivial call overhead, so it is not suitable for busy requests flows.

Luckily, in nodejs, you could develop an addon module to couple with C at ease, based on Node-API.

Node-API (formerly N-API) is an API for building native Addons. It is independent from the underlying JavaScript runtime (for example, V8) and is maintained as part of Node.js itself. This API will be Application Binary Interface (ABI) stable across versions of Node.js.

The function prototype is flexible, you can get arbitrary numbers and types of input arguments from javascript, and return any type of output argument to javascript.

For example, I wrap ngx_http_lua_ffi_get_req() into get_req() function of Javascript, which returns a string to represent the request from nginx.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
napi_value get_req(napi_env env, napi_callback_info args) {
    size_t argc = 1;
    napi_value argv[1];
    napi_status status = napi_get_cb_info(env, args, &argc, argv, NULL, NULL);
    if (status != napi_ok || argc != 1) {
        napi_throw_type_error(env, NULL, "Wrong number of arguments.");
        return NULL;
    }

    uint64_t ptr = 0;
    bool lossless;
    napi_get_value_bigint_uint64(env, argv[0], &ptr, &lossless);
    (void)lossless;

    void* tsk = (void*)ptr;
    int len;
    char* req = ngx_http_lua_ffi_get_req(tsk, &len);
    if (req) {
        napi_value ret;
        status = napi_create_string_utf8(env, req, len, &ret);
        if (status != napi_ok) return nullptr;
        return ret;
    }

    return nullptr;
}

How to use it?

This is the simplest example:

demo/echo.js

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
const { Worker, isMainThread, parentPort } = require('node:worker_threads');
const ffi = require("resty_ffi")

if (isMainThread) {
  // main thread logic
  // define a module method to do init
  exports.init = (cfg, tq) => {
    // create a worker thread
    // note that the worker thread also requires this module
    var worker = new Worker(__filename);
    // get a new task from nginx
    worker.on('message', (tsk) => {
      const req = ffi.get_req(tsk)
      // just echo back to nginx, rc=0
      ffi.respond(tsk, 0, req)
    });
    // tell the worker thread to do polling
    worker.postMessage(tq);
    return 0;
  };
} else {
  // worker thread logic
  parentPort.once('message', (tq) => {
    while (true) {
      // poll task from nginx
      const tsk = ffi.poll_task(tq)
      // if task queue is done, i.e. __unload() by lua, then exit
      if (tsk == 0) {
        console.log('exit ffi nodejs echo runtime');
        break;
      }
      // notify the main thread to handle the request
      parentPort.postMessage(tsk);
    }
  });
}

Specify the entry module and function in lua and use it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
-- note that the nodejs loader library symbols must be opened in global
local demo = ngx.load_ffi("ffi_nodejs", "demo/echo,init", {is_global=true})
local ok, res = demo:echo("hello")
assert(ok)
assert(res == "hello")

-- You could use '?' as module suffix to indicate that the module is hot-reload.
-- When the module gets loaded next time (after previous unload),
-- it would reload the module instead of the VM cached version
local demo = ngx.load_ffi("ffi_nodejs", "demo/echo?,init", {is_global=true})

Conclusion

With lua-resty-ffi, you could use your favorite mainstream programming language, e.g. Go, Java, Python, Rust, or Node.js, to do development in Openresty/Nginx, so that you could enjoy their rich ecosystem directly.

Welcome to discuss and like my github repo:

https://github.com/kingluo/lua-resty-ffi