Nginx Javascript Module

Nginx Javascript Module

A few years ago, at my previous company, we were exploring a new tech stack for our website. To evaluate its effectiveness, we conducted A/B testing, directing users to either the existing infrastructure (A) or the new stack (B).

Lacking extensive Nginx experience, we introduced an intermediary service (a reverse proxy), which sat between our existing Nginx reverse proxy and the application servers. This intermediary service split traffic based on a `user_id` cookie. While we were aware of the possibility of developing a custom Nginx module in C, we opted for the .NET solution due to its ease of development. This approach, however, added an extra hop to our request pipeline.

Article content

I'm currently facing a similar migration challenge. We aim to transition our website from a client-side rendering architecture to server-side rendering. To ensure a smooth transition, we're again employing A/B testing.

This time, however, we're leveraging Nginx's Layer 7 capabilities and its JavaScript module (NJS) to manage traffic splitting directly within Nginx, eliminating the need for an intermediary service.

Article content

This approach not only streamlines our architecture but also allows us to utilize Nginx as a powerful reverse proxy and API gateway. We can now implement features like generating correlation IDs, rewriting headers, content-based routing, and response parsing to redact sensitive information, all within Nginx.


Here's an example Nginx configuration:

load_module modules/ngx_http_js_module.so;

http {
  js_path "/etc/nginx/njs/";
  js_import utils.js;

  upstream backend_a {
    server host.docker.internal:3000;
  }
  upstream backend_b {
    server host.docker.internal:3001;
  }

  map $server_to_map $backend  {
    "variant_a"      backend_a;
    "variant_b"      backend_b;
    default          backend_a;
  }
  map $time_local $expiry {
    default "$time_local + 365d";
  }

  server {
    listen 80;
    location / {

      js_set $user_id utils.userIdCookie;
      set $server_to_map 'default'; 
      if ($user_id ~ "0$") {
        set $server_to_map 'variant_a';
      }
      if ($user_id ~ "1$") {
        set $server_to_map 'variant_b';
      }
      if ($cookie_user_id = "") {
        add_header Set-Cookie "user_id=$user_id; Expires=$expiry; Path=/; HttpOnly";
      }
      proxy_pass http://$backend;
      proxy_pass_request_headers on;
      proxy_set_header   X-User-Id $user_id;
    }
  }
}        


Let's break down the configuration step by step:

1. Loading the JavaScript Module:

This line loads the ngx_http_js_module, which allows you to execute JavaScript code within your Nginx configuration.

load_module modules/ngx_http_js_module.so;        


2. HTTP Block

  • js_path "/etc/nginx/njs/": This directive sets the base path for resolving JavaScript files included using the js_import directive. In this case, it's set to /etc/nginx/njs/, so Nginx will look for JavaScript files in that directory.
  • js_import utils.js: This imports another JavaScript file named utils.js, which presumably contains utility functions used in the configuration.

http {
  js_path "/etc/nginx/njs/";
  js_import utils.js;
  # ...
}        


3. Upstream Blocks

These blocks define two upstream servers: backend_a and backend_b, both running on the host machine (host.docker.internal) but on different ports (3000 and 3001). These upstreams likely represent different versions of your application or different environments (e.g., staging, production).

upstream backend_a {
  server host.docker.internal:3000;
}
upstream backend_b {
  server host.docker.internal:3001;
}        

4. Map Blocks

  • The first map block defines a mapping between a variable $server_to_map and the $backend variable. This is used to dynamically select an upstream based on the value of $server_to_map.
  • The second map block calculates an expiry date for a cookie, adding 365 days to the current time.

map $server_to_map $backend  {
  "variant_a"      backend_a;
  "variant_b"      backend_b;
  default          backend_a;
}
map $time_local $expiry {
  default  "$time_local + 365d";
}        

5. Server Block

  • listen 80: This directive tells Nginx to listen for incoming HTTP requests on port 80.
  • location /: This block defines the configuration for requests to the root path (/).

  • js_set $user_id utils.userIdCookie: This executes the userIdCookie function from the imported utils.js file and assigns the result to the $user_id variable. This function likely retrieves or generates a unique user ID.
  • set $server_to_map 'default': This sets the $server_to_map variable to "default" initially.
  • if ($user_id ~ "0$") { ... }: These if conditions check the last digit of the $user_id and conditionally set the $server_to_map variable to either "variant_a" or "variant_b". This is how the traffic splitting for A/B testing is implemented.
  • if ($cookie_user_id = "") { ... }: This condition checks if a cookie named user_id is present in the request. If not, it sets a new cookie with the generated $user_id and an expiry date calculated earlier.
  • proxy_pass http://$backend: This directive forwards the request to the upstream server specified by the $backend variable, which is dynamically

 server {
    listen 80;
    location / {

      js_set $user_id utils.userIdCookie;
      set $server_to_map 'default'; 
      if ($user_id ~ "0$") {
        set $server_to_map 'variant_a';
      }
      if ($user_id ~ "1$") {
        set $server_to_map 'variant_b';
      }
      if ($cookie_user_id = "") {
        add_header Set-Cookie "user_id=$user_id; Expires=$expiry; Path=/; HttpOnly";
      }
      proxy_pass http://$backend;
      proxy_pass_request_headers on;
      proxy_set_header   X-User-Id $user_id;
    }
  }        


The following is utils.js which nginx loaded from above setting.

function userIdCookie(r) {
    var uuid = global.uuid
    var cookieStr = r.headersIn.cookie;
    var userId = null;

    if (cookieStr) {
        var cookies = cookieStr.split(';');

        for (var i = 0; i < cookies.length; i++) {
            var cookie = cookies[i].trim();

            if (cookie.startsWith("user_id=")) {
                userId = cookie.split("=")[1];
                break;
            }
        }
        if (userId) {
            return userId;
        }
    }
    
    var id = uuid.v4()
    return id;
}

export default { userIdCookie };
        

To clarify, the global.uuid object used on line #2 is provided by the uuidv4 library. Since this post is already quite extensive, I'll dedicate a separate blog post to explaining how to incorporate external Node.js modules like uuidv4 into your Nginx JavaScript configuration.

Click this link: Nginx JavaScript (NJS): Using Node Modules to see how we do it.

In summary, this Nginx configuration sets up a reverse proxy that performs A/B testing by splitting traffic between two upstream servers based on a user ID. It uses JavaScript code to generate user IDs, manage cookies, and dynamically select the appropriate upstream server.

To view or add a comment, sign in

More articles by Sanhapon Thadapradit

Others also viewed

Explore content categories