diff --git a/frameworks/hyper-express/README.md b/frameworks/hyper-express/README.md index 435e7fe77..6a72a8483 100644 --- a/frameworks/hyper-express/README.md +++ b/frameworks/hyper-express/README.md @@ -15,6 +15,12 @@ An Express-like API on uWebSockets.js, with the cluster module for multi-core sc |----------|--------|-------------| | `/pipeline` | GET | Returns `ok` (plain text) | | `/baseline11` | GET/POST | Sums query parameter values, plus the body for POST | +| `/baseline2` | GET | Sums query parameter values | +| `/async-db` | GET | Reads from PostgreSQL through a pool of four | +| `/static/:filename` | GET | Serves a file from disk, the brotli or gzip variant when the client accepts one | +| `/crud/items` | GET/POST | Lists items by category with paging, or inserts one | +| `/crud/items/:id` | GET/PUT | Reads one item through a Redis cache-aside, or updates it and drops the cached copy | +| `/fortunes` | GET | Reads 200 rows from PostgreSQL, appends the runtime row, sorts and renders the HTML table | | `/json/:count` | GET | Serializes a slice of the dataset, gzip or brotli when the client accepts one | | `/upload` | POST | Counts the bytes of the request body | @@ -36,3 +42,15 @@ than where it looks the same: Every worker binds `:8080` on its own: uWebSockets.js shares the port across processes unless `exclusive_port` is asked for, so the cluster fork per core needs nothing else. + +`json-tls` and `static-tls` listen on `8081` when `/certs/server.crt` and `/certs/server.key` are +mounted. hyper-express takes TLS material through the uWS options at construction rather than +through a separate `https` server, so the port is a second `Server` carrying the same routes. +Every worker in the cluster binds it, exactly as they all bind `8080`. + +Static file bodies are read from disk on every request, per the arena rules: only the list of +names, existing pre-compressed variants and content types is scanned at startup. + +`fortunes` is rendered by hand rather than through a template engine, which tuned mode allows; +the handler still queries per request, appends the runtime row, sorts and escapes `<`, `>`, `&`, +`"` and `'`. diff --git a/frameworks/hyper-express/app.js b/frameworks/hyper-express/app.js index ead9e5d0f..f236593ca 100644 --- a/frameworks/hyper-express/app.js +++ b/frameworks/hyper-express/app.js @@ -24,11 +24,6 @@ if (cluster.isPrimary) { // enough that a higher level buys bytes nobody counts const GZIP_OPTS = { level: 1 }; - // max_body_length defaults to 250 KB and answers 413 above it, so the upload profile, - // which posts up to 20 MB, needs the cap raised. Every worker binds :8080 on its own: - // uWebSockets.js shares the port between processes unless exclusive_port is asked for. - const server = new Server({ max_body_length: 32 * 1024 * 1024 }); - const SERVER_HDR = 'hyper-express'; // Dataset @@ -37,6 +32,51 @@ if (cluster.isPrimary) { datasetItems = JSON.parse(fs.readFileSync(process.env.DATASET_PATH || '/data/dataset.json', 'utf8')); } catch (e) {} + // PostgreSQL, for async-db, api-4/api-16, crud and fortunes. The pool is per worker + // and this entry forks one per core, so the harness's budget is split across them + // rather than opened by each. + let pgPool; + if (process.env.DATABASE_URL) { + try { + const { Pool } = require('pg'); + pgPool = new Pool({ connectionString: process.env.DATABASE_URL, max: 4 }); + pgPool.on('error', () => {}); + } catch (e) {} + } + + // Redis, for the crud cache-aside only. One connection per worker, so the cache is + // shared across the cluster where a per-worker map would not be. + let redis; + if (process.env.REDIS_URL) { + try { + const Redis = require('ioredis'); + redis = new Redis(process.env.REDIS_URL, { enableAutoPipelining: true }); + redis.on('error', () => {}); + } catch (e) {} + } + + const MIME_TYPES = { + '.css': 'text/css', '.js': 'application/javascript', '.html': 'text/html', + '.woff2': 'font/woff2', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.json': 'application/json', + }; + + // No file data lives in memory, per the arena rules: this scans names only, so a request + // knows which pre-compressed variants exist and the content type. The bytes are read from + // disk on every request. + const staticFiles = {}; + try { + for (const name of fs.readdirSync('/data/static')) { + if (name.endsWith('.br') || name.endsWith('.gz')) continue; + const ext = name.slice(name.lastIndexOf('.')); + staticFiles[name] = { + path: `/data/static/${name}`, + br: fs.existsSync(`/data/static/${name}.br`), + gz: fs.existsSync(`/data/static/${name}.gz`), + ct: MIME_TYPES[ext] || 'application/octet-stream' + }; + } + } catch (e) {} + function sumQuery(query) { let sum = 0; for (const k in query) { @@ -46,59 +86,262 @@ if (cluster.isPrimary) { return sum; } - server.get('/pipeline', (request, response) => { - response.header('server', SERVER_HDR).type('text/plain').send('ok'); + const ITEM_COLUMNS = + 'id, name, category, price, quantity, active, tags, rating_score, rating_count'; + const itemShape = (r) => ({ + id: r.id, name: r.name, category: r.category, price: r.price, + quantity: r.quantity, active: r.active, tags: r.tags, + rating: { score: r.rating_score, count: r.rating_count } }); - server.get('/json/:count', (request, response) => { - let count = parseInt(request.path_parameters.count, 10) || 0; - if (count < 0) count = 0; - if (count > datasetItems.length) count = datasetItems.length; - const m = parseInt(request.query_parameters.m, 10) || 1; - const items = datasetItems.slice(0, count).map(d => ({ - id: d.id, name: d.name, category: d.category, - price: d.price, quantity: d.quantity, active: d.active, - tags: d.tags, rating: d.rating, - total: d.price * d.quantity * m - })); - const body = JSON.stringify({ items, count }); - // json-comp profile: negotiated per request. hyper-express ships no response - // compression of its own, so the encoding is picked here and nothing is sent - // compressed without Accept-Encoding. - const ae = request.headers['accept-encoding'] || ''; - if (ae.includes('gzip')) { - response.header('server', SERVER_HDR) - .header('content-encoding', 'gzip') - .type('application/json') - .send(zlib.gzipSync(body, GZIP_OPTS)); - } else if (ae.includes('br')) { - response.header('server', SERVER_HDR) - .header('content-encoding', 'br') - .type('application/json') - .send(zlib.brotliCompressSync(body, { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 3 } })); - } else { - response.header('server', SERVER_HDR).type('application/json').send(body); - } - }); + function sendJson(response, body, status = 200, extra = null) { + response.status(status).header('server', SERVER_HDR).type('application/json'); + if (extra) for (const k in extra) response.header(k, extra[k]); + response.send(body); + } + const dbError = (response, msg, status = 500) => sendJson(response, `{"error":"${msg}"}`, status); + + // The profile reads and writes the same ids, so a long TTL would answer from a copy + // the writes have already moved past. + const CRUD_TTL_MS = 200; + + // ── fortunes ──────────────────────────────────────────────────────────────── + // Tuned mode, so the page is emitted by hand rather than through an engine. Row 11 + // of the seed carries a