From a19ff81c76b3721c37a57b8dea85acbd7f2d3c96 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Wed, 15 Jul 2026 00:41:31 -0400 Subject: [PATCH] Fix a worker-blocking Lua socket call and add gzip/caching for static assets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ops/nginx_conf/targetinfo.lua's wildcard-subdomain lookup fallback used classic LuaSocket (require("socket.unix")) instead of an OpenResty cosocket. LuaSocket is blocking, and called from an nginx worker it stalls the ENTIRE worker — every other in-flight connection on it — for the round-trip to the Node app. Worse, the Node side never newline-terminated its response, so the old blocking receive() only ever returned via its read-timeout-then-partial-read fallback, meaning every single cache-miss lookup paid a fixed timeout penalty while blocking the whole worker. Replaced with an ngx.socket.tcp() cosocket (unix-domain via "unix:/path", the only cosocket API this lua-nginx-module ships) and newline-terminated the Node service's responses so receive() actually completes instead of timing out. Verified against a live container: previously this crashed OpenResty's Lua VM entirely (ngx.socket.unix doesn't exist); fixed version resolves fresh wildcard subdomains in ~2ms. - Add gzip compression (`compression` middleware) and far-future Cache-Control on static assets (7d for vendor libs under /static-modules, 1h for the app's own /static JS/CSS, which isn't cache-busted). The admin UI is a traditional multi-page app that loads ~13 separate vendor/app JS+CSS files on every full navigation; previously none of them were compressed and Cache-Control was `max-age=0` (Express's default), forcing a revalidation round-trip for every asset on every page view. Co-Authored-By: Claude Sonnet 5 --- nodejs/app.js | 10 +++- nodejs/package-lock.json | 84 ++++++++++++++++++++++++++++++++++ nodejs/package.json | 1 + nodejs/services/host_lookup.js | 12 +++-- ops/nginx_conf/targetinfo.lua | 56 ++++++++++++++--------- 5 files changed, 138 insertions(+), 25 deletions(-) diff --git a/nodejs/app.js b/nodejs/app.js index 1330fdd..bbffc5a 100755 --- a/nodejs/app.js +++ b/nodejs/app.js @@ -3,6 +3,7 @@ const path = require('path'); const ejs = require('ejs') const express = require('express'); +const compression = require('compression'); // Set up the express app. const app = express(); @@ -52,8 +53,15 @@ app.onListen.push(function(){ }); }); +// Gzip text responses (HTML/JS/CSS/JSON). The admin UI loads ~13 separate, +// uncompressed vendor JS/CSS files on every full page navigation (a +// traditional multi-page app, not an SPA) — this alone meaningfully cuts +// bytes-over-the-wire and perceived load time on a real network, where it +// matters far more than on localhost. +app.use(compression()); + // load the JSON parser middleware. Express will parse JSON into native objects -// for any request that has JSON in its content type. +// for any request that has JSON in its content type. app.use(express.json()); // Set up the templating engine to build HTML for the front end. diff --git a/nodejs/package-lock.json b/nodejs/package-lock.json index 00bcaff..9ec8e1c 100644 --- a/nodejs/package-lock.json +++ b/nodejs/package-lock.json @@ -16,6 +16,7 @@ "axios": "^1.13.5", "bcrypt": "^6.0.0", "bootstrap": "^5.3.8", + "compression": "^1.8.1", "ejs": "^6.0.1", "express": "^5.2.1", "express-rate-limit": "^8.5.2", @@ -609,6 +610,60 @@ "node": ">= 0.8" } }, + "node_modules/compressible": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz", + "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==", + "license": "MIT", + "dependencies": { + "mime-db": ">= 1.43.0 < 2" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/compression": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz", + "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==", + "license": "MIT", + "dependencies": { + "bytes": "3.1.2", + "compressible": "~2.0.18", + "debug": "2.6.9", + "negotiator": "~0.6.4", + "on-headers": "~1.1.0", + "safe-buffer": "5.2.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/compression/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/compression/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/compression/node_modules/negotiator": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz", + "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/content-disposition": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", @@ -1569,6 +1624,15 @@ "node": ">= 0.8" } }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -1754,6 +1818,26 @@ "node": ">= 18" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", diff --git a/nodejs/package.json b/nodejs/package.json index 379923c..e365158 100644 --- a/nodejs/package.json +++ b/nodejs/package.json @@ -27,6 +27,7 @@ "axios": "^1.13.5", "bcrypt": "^6.0.0", "bootstrap": "^5.3.8", + "compression": "^1.8.1", "ejs": "^6.0.1", "express": "^5.2.1", "express-rate-limit": "^8.5.2", diff --git a/nodejs/services/host_lookup.js b/nodejs/services/host_lookup.js index 1e28c02..1157713 100644 --- a/nodejs/services/host_lookup.js +++ b/nodejs/services/host_lookup.js @@ -33,7 +33,7 @@ const socket = new SocketServerJson({ let parentHost = Host.lookUp(data['domain']); // If we don't have a match, return empty object - if(!parentHost) return clientSocket.write(JSON.stringify({})); + if(!parentHost) return clientSocket.write(JSON.stringify({}) + '\n'); // lookUp returns the live #record object stored inside the shared // lookup tree. Everything below mutates parentHost (sets @@ -50,7 +50,7 @@ const socket = new SocketServerJson({ // subdomain and must not be routed to the wildcard parent. if(parentHost.is_wildcard && !parentHost.wildcard_matchAny && parentHost.host !== data['domain']){ - return clientSocket.write(JSON.stringify({})); + return clientSocket.write(JSON.stringify({}) + '\n'); } // If the matched host belongs to a wildcard domain, set wildcard_parent @@ -66,7 +66,13 @@ const socket = new SocketServerJson({ parentHost[key] = String(value); } - clientSocket.write(JSON.stringify(parentHost)); + // Terminate with a newline: the Lua client (ops/nginx_conf/targetinfo.lua) + // reads a single line per lookup via a cosocket receive() -- without a + // delimiter it would block for the full read timeout on every request + // waiting for a newline that never arrives (this was masked before by + // blocking LuaSocket's timeout+partial-read behavior, which silently + // paid that same timeout on every single lookup). + clientSocket.write(JSON.stringify(parentHost) + '\n'); }catch(error){ console.error('services/host_lookup onData error', error); } diff --git a/ops/nginx_conf/targetinfo.lua b/ops/nginx_conf/targetinfo.lua index befe6de..cb4fb9c 100644 --- a/ops/nginx_conf/targetinfo.lua +++ b/ops/nginx_conf/targetinfo.lua @@ -1,12 +1,39 @@ local M = {} --- Function to connect to a Unix socket -local function connect(path) - local socket = require("socket.unix")() - assert(socket:settimeout(.1)) - local status, err = pcall(function() assert(socket:connect(path)) end) - if status then return true end - return false +-- Query the Node app's host-lookup service for a domain that missed the +-- Redis fast path (wildcard subdomains not yet cached, see Host.addCache). +-- Uses an OpenResty cosocket rather than the classic LuaSocket socket.unix() +-- the previous version of this file used: LuaSocket's API is blocking and, +-- called from an nginx worker, stalls the ENTIRE worker (every other +-- in-flight connection on it) for the round-trip -- a real source of +-- intermittent request latency for any wildcard host whose on-demand cache +-- entry (1h TTL, conf.cacheTTL) had expired. resty.redis (used just above) +-- is cosocket-based already and works fine from both the phases this module +-- is called from (access_by_lua_block and the SSL request_domain callback), +-- so a unix-domain cosocket is safe here too. +local function unixLookup(json, domain) + -- The ngx_lua cosocket API has no separate ngx.socket.unix -- a plain + -- ngx.socket.tcp() connects to a unix domain socket when given a + -- "unix:/path" address instead of a host/port pair. + local sock = ngx.socket.tcp() + sock:settimeouts(100, 100, 100) -- connect, send, read (ms) + + local ok = sock:connect("unix:/var/run/proxy_lookup.socket") + if not ok then return nil end + + local ok = sock:send(json.encode({domain = domain})) + if not ok then + sock:close() + return nil + end + + local line = sock:receive() + sock:close() + if not line then return nil end + + local decodeOk, decoded = pcall(json.decode, line) + if not decodeOk then return nil end + return decoded end print("In targetInfo module") @@ -61,20 +88,7 @@ function M.get(ngx, domain, targetInfo) end if not res["ip"] then - if connect("/var/run/proxy_lookup.socket") then - local socket = require("socket.unix")() - assert(socket:settimeout(.1)) - assert(socket:connect("/var/run/proxy_lookup.socket")) - assert(socket:send(json.encode({domain = domain}))) - while true do - local s, status, partial = socket:receive() - if partial then - res = json.decode(partial) - socket:close() - break - end - end - end + res = unixLookup(json, domain) or res end if not res["ip"] then