Skip to content
Foxipa

Suggest an edit

Tell us what should be corrected, clarified, or improved. Submission will be connected to Foxipa’s contribution service later.

No information is sent anywhere yet. Your suggestion is only prepared in your browser.

Learning

15.4 njs: Scripting the Request

Everything config can’t express: parsing tokens, classifying clients, generating JSON, deciding routes — in sandboxed JavaScript that couldn’t segfault your worker if it tried.

Levels: Advanced ~35 min read

Advanced

What you will learn

  1. The njs model: what it is, what it's not, and why that's the point.

  2. The four hooks: js_import, js_set, js_var, js_content — with live receipts for each.

  3. The sandbox, probed: what this build has ( atob) and hasn't ( String.bytesFrom(s,"base64")) — and how we know.

  4. Where njs hooks the request (phase model reunion), and the patterns worth stealing.


1. The model

njs is a from-scratch ECMAScript engine inside the nginx worker. Not Node, not V8:

Node.js

njs

Event loop

owns it

borrows nginx's

Filesystem/network/process

yes

no — by design

Modules

require("fs")

ES import of your files only

Lifetime

process

request/variable evaluation

Failure mode

can take the process down

sandboxed; worst case an exception in one request

The "no" column is the feature: the code can't open sockets, read disks, or fork — so the blast radius of bad JS is one response, never one worker. What it CAN do is everything the request object r exposes: read/patch headers and args, set variables, return bodies, and call subrequests.

2. The four hooks

js_import — bring your code in (path relative to the conf directory — one of those facts you earn once, like njs's other path quirks):

nginx
js_import app from js/app.js;     # conf/js/app.js

js_set — a variable computed by a function, evaluated lazily (first read) and cached per request. The JWT reader:

nginx
js_set $auth_sub    app.authSub;
js_set $client_tier app.clientTier;
javascript
function authSub(r) {
    var h = r.headersIn.Authorization || "";
    var m = h.match(/^Bearer ([A-Za-z0-9_-]+)\.([A-Za-z0-9_-]+)\./);
    if (!m) return "anonymous";
    var payload = m[2].replace(/-/g, "+").replace(/_/g, "/");
    while (payload.length % 4) payload += "=";
    try { return JSON.parse(atob(payload)).sub || "no-sub-claim"; }
    catch (e) { return "bad-token"; }
}
javascript
function clientTier(r) {
    var t = r.headersIn["X-Client-Tier"];
    return ["standard", "premium", "staff"].indexOf(t) >= 0 ? t : "standard";
}

Receipts (real):

text
with token:  sub=demo-user tier=cli greeting=hello from njs
without:     sub=anonymous tier=cli greeting=hello from njs

Note the shape of the function: pure decision logic, total (every path returns), no side effects — the js_set contract. And note what it deliberately does NOT do: verify the token. Reading a claim is arithmetic; proving it is cryptography — that’s auth_request + a real verifier (Lesson 11.3), or njs crypto APIs in a dedicated js_access phase handler. The lesson’s functions are for routing and labeling.

js_var — a variable born with a JS value (constant or settable via js_set-style functions):

nginx
js_var $js_greeting "hello from njs";

js_content — JavaScript IS the content handler; no upstream, no static file:

nginx
location /compute { js_content app.compute; }
javascript
function compute(r) {
    var n = parseInt(r.args.n || "10", 10);
    if (isNaN(n) || n < 1 || n > 30) {
        r.return(400, '{"error":"n must be 1..30"}');   // validation lives here
        return;
    }
    var a = 0, b = 1, i;
    for (i = 0; i < n; i++) { var t = a + b; a = b; b = t; }
    r.headersOut["Content-Type"] = "application/json";
    r.return(200, JSON.stringify({
        computed_by: "njs", n: n, fib: a,
        request_id: r.variables.request_id,          // nginx's world, readable
        seen_auth_sub: r.variables.auth_sub,         // other js_set vars too
    }));
}

Receipts (real):

json
{"computed_by":"njs","n":10,"fib":55,"request_id":"015a71d426b96756b4d9e42093267a0e","seen_auth_sub":"anonymous"}
text
{"error":"n must be 1..30"}   status=400     <- input validation, in JS

And the capstone — a js_set variable steering a routing decision (Module 11's sanctioned if doing what it's for):

nginx
location /route {
    if ($client_tier = cli) { proxy_pass http://127.0.0.1:9001; }
    proxy_pass http://127.0.0.1:9002;
}
text
curl UA  -> b-node-1        (tier=cli computed by JS)
Mozilla  -> b-node-2        (tier=browser)

JavaScript looked at the User-Agent and made the routing call — config executed a decision computed in a real language.

3. The sandbox, probed

Building this example, our first JWT decode returned bad-token for every valid token. The diagnosis is a lesson in itself: don't assume the sandbox — probe it. We added a temporary endpoint that returns capability facts:

json
{"atob":"function","bytesFrom_b64":"ERR:undefined is not a function"}

atob existed; the two-argument String.bytesFrom(s, "base64") didn't (this njs build predates/supports a different signature). Our decode chain used the missing one inside a try — every exception collapsed into bad-token, hiding the real error. The fix was the one-liner JSON.parse(atob(payload)).

Two portable habits fall out:

  1. Probe your runtime when behavior mismatches expectations — a /probe endpoint returning typeof/feature-checks beats guessing, and njs versions differ meaningfully across nginx releases.

  2. Catch narrowly, message loudly — a catch (e) that flattens every failure into one sentinel value is a debugging blackout. Log the exception ( r.error(e.message) or r.log()), then degrade.

4. Phases, and the patterns worth stealing

njs hooks land in the Module 11 phase model, which decides what each hook may touch:

Hook

Runs in/around

Can do

js_set/ js_var

when the variable is first read (often rewrite phase)

pure computation, header reading

js_content

content phase

full response generation, subrequests

js_header_filter/ js_body_filter

response filters

rewrite replies (the mirror of gzip's position)

js_access*

access phase

allow/deny decisions (auth-shaped)

*js_access ships in newer njs; the pattern transfers from js_set + sanctioned if meanwhile.

The patterns this lesson demonstrated, in stealing order:

  • Claims → variables → headers: decode in js_set, forward with proxy_set_header (Module 11's auth pipeline, now without a sidecar validator for the read-only case).

  • Computed classification → routing: tier/UA/geo logic in JS, the decision consumed by config.

  • Micro-endpoints: /compute-shaped endpoints that would embarrass a backend service — health math, badge SVGs, formatted redirects.

And the anti-pattern, named: don't build applications in njs. The moment you want a database, a cache beyond nginx's, or a framework — that's Lesson 15.1's tier-D conversation.

Try it yourself

  • nginx -T 2>&1 | grep js_ — all four hooks wired; then replay the receipts above on your own setup (token and no-token, good and bad n, both user agents).

  • Extend js/app.js: a js_set that parses $http_accept_language into a primary locale and routes /localized/ by it.

  • Break the sandbox on purpose: try to read a file or open a socket from js/app.js — meet the ReferenceError wall, and appreciate it.

Q&A

Q: Performance — how much JS is too much? A: Rule of thumb: milliseconds are a bug. String ops and JSON parsing are microseconds; loops over big payloads are where you'd feel it. js_set's lazy evaluation means unreferenced variables cost zero.

Q: Can njs make subrequests? A: Yes — r.subrequest() (async, with promises in recent versions): an internal GET like auth_request, but with JS shaping the request and consuming the response. That's the bridge to "verify JWT via auth service" done entirely in njs.

Q: Hot reload of JS code? A: js_import files re-read on SIGHUP reload — no restart needed for logic changes. (Cache headers aside, this makes njs logic the softest thing in your config to change.)

Q: Alternatives — Lua? A: OpenResty's Lua is faster and vastly richer (Lesson 15.1's tier D) — and brings the platform. njs is the choice when "some real logic, zero new platforms" is the requirement.

Takeaways

  • njs is sandboxed by design: no fs/network/process — worst case one bad response, never one dead worker. The r object is your entire world, and it's enough.

  • Four hooks, one mental model: js_import brings code, js_set/ js_var compute variables (lazy, cached), js_content generates responses — all landing in the Module 11 phase model.

  • Receipts: JWT claims decoded to a variable, a fibonacci endpoint with input validation, and JS-driven routing ( curl → node-1, Mozilla → node-2).

  • Probe your sandbox (our atob yes, bytesFrom no receipt) and catch exceptions loudly — silent degradation is the debugging blackout we barely escaped.

Was this page helpful?

Mark this learning resource complete to track your learning progress.