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.
Advanced
What you will learn
The njs model: what it is, what it's not, and why that's the point.
The four hooks:
js_import,js_set,js_var,js_content— with live receipts for each.The sandbox, probed: what this build has (
atob) and hasn't (String.bytesFrom(s,"base64")) — and how we know.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 |
| ES
|
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):
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:
js_set $auth_sub app.authSub;
js_set $client_tier app.clientTier;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"; }
}function clientTier(r) {
var t = r.headersIn["X-Client-Tier"];
return ["standard", "premium", "staff"].indexOf(t) >= 0 ? t : "standard";
}Receipts (real):
with token: sub=demo-user tier=cli greeting=hello from njs
without: sub=anonymous tier=cli greeting=hello from njsNote 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):
js_var $js_greeting "hello from njs";
js_content — JavaScript IS the content handler; no upstream, no static file:
location /compute { js_content app.compute; }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):
{"computed_by":"njs","n":10,"fib":55,"request_id":"015a71d426b96756b4d9e42093267a0e","seen_auth_sub":"anonymous"}{"error":"n must be 1..30"} status=400 <- input validation, in JSAnd the capstone — a
js_set variable steering a routing decision (Module 11's sanctioned
if doing what it's for):
location /route {
if ($client_tier = cli) { proxy_pass http://127.0.0.1:9001; }
proxy_pass http://127.0.0.1:9002;
}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:
{"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:
Probe your runtime when behavior mismatches expectations — a
/probeendpoint returningtypeof/feature-checks beats guessing, and njs versions differ meaningfully across nginx releases.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)orr.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 |
|---|---|---|
| when the variable is first read (often rewrite phase) | pure computation, header reading |
| content phase | full response generation, subrequests |
| response filters | rewrite replies (the mirror of gzip's position) |
| 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 withproxy_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: ajs_setthat parses$http_accept_languageinto 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
robject is your entire world, and it's enough.Four hooks, one mental model:
js_importbrings code,js_set/js_varcompute variables (lazy, cached),js_contentgenerates 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
atobyes,bytesFromno receipt) and catch exceptions loudly — silent degradation is the debugging blackout we barely escaped.
Thanks for your feedback.
Mark this learning resource complete to track your learning progress.