diff --git a/frameworks/morojs/Dockerfile b/frameworks/morojs/Dockerfile new file mode 100644 index 000000000..8898ceefb --- /dev/null +++ b/frameworks/morojs/Dockerfile @@ -0,0 +1,20 @@ +FROM node:26-trixie-slim AS build +WORKDIR /app +COPY package.json . +# @morojs/engine ships prebuilt binaries, so no toolchain is needed here +RUN npm install --omit=dev --no-audit --no-fund + +FROM node:26-trixie-slim +WORKDIR /app +COPY --from=build /app/node_modules ./node_modules +COPY app.mjs . +COPY views ./views +ENV NODE_ENV=production +# The libuv threadpool carries the compression and file reads of every worker +# thread in the process. Moro sizes it to 64 itself, but only at listen(), +# after its startup has already initialised the pool at Node's default of 4; +# an environment variable at process start is what Node reads. 64 matches the +# benchmark cpuset. +ENV UV_THREADPOOL_SIZE=64 +EXPOSE 8080 +CMD ["node", "app.mjs"] diff --git a/frameworks/morojs/README.md b/frameworks/morojs/README.md new file mode 100644 index 000000000..4629694f5 --- /dev/null +++ b/frameworks/morojs/README.md @@ -0,0 +1,76 @@ +# morojs + +MoroJS on its native HTTP engine, clustered by the framework itself. + +## Stack + +- **Language:** JavaScript +- **Runtime:** Node.js 26 +- **Framework:** [MoroJS 1.8](https://github.com/Moro-JS/moro) on `@morojs/engine`, its own native HTTP engine +- **Build:** Multi-stage on `node:26-trixie-slim`; the engine ships prebuilt binaries, so no toolchain + +## Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/pipeline` | GET | Returns `ok` (plain text) | +| `/baseline11` | GET/POST | Sums query parameter values, plus the body for POST | +| `/baseline2` | GET | Sums query parameter values (behind the gateway proxies) | +| `/delay/:ms` | GET | Waits `ms` milliseconds on an awaited timer, then returns `ms` | +| `/json/:count` | GET | Serializes a slice of the dataset, brotli or gzip when the client asks for it | +| `/echo` | POST | Returns the request body back verbatim (TLS listener) | +| `/ws` | WebSocket | Echoes every text and binary frame unchanged | +| `/async-db` | GET | Reads from PostgreSQL through `pg`, prepared statement, pool sized under `DATABASE_MAX_CONN` | +| `/fortunes` | GET | Reads 200 rows from PostgreSQL, appends the runtime row, sorts and renders `views/fortunes.ejs` | +| `/static/:filename` | GET | Serves a file from disk through the framework's `staticFiles` middleware (TLS listener) | +| `/public/baseline`, `/public/json/:count` | GET | The baseline and JSON routes as the production-stack edge forwards them, no auth | +| `/api/items/:id` | GET/POST | Cache-aside read with `X-Cache: HIT` or `MISS`, or a JSON-body update that invalidates the entry and answers 204 | +| `/api/me` | GET | Reads the user named by `X-User-Id` through the same cache-aside | + +## Notes + +- Standard mode: the app is `createApp()` with its defaults on the native engine (`engine: 'moro'`, + which is also the default). Request tracking and request logging are turned off through their + documented options; nothing else is configured. +- `/pipeline` is a literal handler, `.handler('ok')`: the framework's static route, answered inside + the engine without entering JS, with the same `text/plain` reply `res.send('ok')` would give. +- Clustering is Moro's own (`performance.clustering`): one worker per core, which on the native + engine means worker threads that each bind the port through `SO_REUSEPORT`. The count is passed + in explicitly from the cgroup quota, because Moro's `auto` counts every host core even under + `--cpuset-cpus`. +- `json-comp` is the framework's `compression()` middleware attached to the `/json` route alone, + so no other endpoint pays for the encoder. It negotiates off `Accept-Encoding` per request and + leaves the body alone when the header is absent. gzip is preferred over brotli through the + middleware's `encodings` option: at the default level it costs about half the CPU of brotli at + the default quality for a body a tenth larger, and the profile scores bytes squared against + rate; brotli remains available to a client that accepts nothing else. +- `UV_THREADPOOL_SIZE=64` is set in the Dockerfile. The libuv pool serves the compression and + file reads of every worker thread in the process; Moro sets 64 itself but only at `listen()`, + after its startup has initialised the pool at Node's default of 4, and Node reads the variable + only at process start. +- `json-tls`, `static-tls` and `8gbit` listen on `8081` behind the engine's own TLS listener + (TLS 1.3, ALPN `http/1.1`), when `/certs/server.crt` and `/certs/server.key` are mounted. Moro + locks one configuration per process, so that listener is a second process running the same file + with `MORO_ROLE=tls`, clustered the same way; it is not started when the certificates are absent. +- `tls_check` is opted into: a third process of the same file (`MORO_ROLE=tlscheck`) listens on + `9000` reading `/certs-tls`, with `server.ssl.watch: true`, so a pair replaced on disk is + validated and swapped onto the running listener without a restart and without touching + connections already established. Only started when `/certs-tls` is mounted, which validate.sh + alone does. +- `/echo` sends back `req.rawBody`, the framework's undecoded copy of the request body, so the + bytes are the bytes that arrived, `Content-Length` or chunked alike. +- `/ws` is the engine's own RFC 6455 support through `app.websocket()` with `{ raw: true }`, so a + text frame reaches the handler as the string it carried and `socket.send()` returns it as one. +- Static files go through `staticFiles({ root: '/data/static', precompressed: true })`, which + stats and reads the file on every request, so a replaced file is served on the next response, + and serves the `.br` or `.gz` sidecar on disk when the client accepts it. It is mounted on the + TLS listener behind a path check so the JSON route on that port does not pay for the lookup. +- `fortunes` goes through EJS directly: Moro ships no view layer, so the template is rendered by + the engine's own `renderFile`, a separate file with `<%= %>` escaping every cell. +- The gateway and production-stack edges are stock nginx and Caddy, configured as the fulmine + entry configures them: `/static/*` off disk at the edge, everything else forwarded over + loopback h1 with a keepalive pool, and `/api/*` past the shared JWT verifier first. +- The production-stack cache-aside goes through the framework's cache adapters: + `RedisCacheAdapter` on the stack's `REDIS_URL`, one connection per worker so an update on any + worker invalidates what every other one reads, and `MemoryCacheAdapter` otherwise. Items live + for at most a second, users for thirty. diff --git a/frameworks/morojs/app.mjs b/frameworks/morojs/app.mjs new file mode 100644 index 000000000..ddfdaf86c --- /dev/null +++ b/frameworks/morojs/app.mjs @@ -0,0 +1,422 @@ +// morojs - MoroJS on its native HTTP engine (@morojs/engine), default +// configuration. Clustering is the framework's own: performance.clustering +// starts one worker per core, and on the native engine those are worker +// threads that each bind the port through SO_REUSEPORT. Moro re-runs this +// file in every worker; the primary only starts and supervises them. + +import { spawn } from 'node:child_process'; +import cluster from 'node:cluster'; +import { existsSync, readFileSync } from 'node:fs'; +import { availableParallelism } from 'node:os'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { isMainThread } from 'node:worker_threads'; +import { createApp, middleware, MemoryCacheAdapter, RedisCacheAdapter } from '@morojs/moro'; +import ejs from 'ejs'; +import pg from 'pg'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const FORTUNES_VIEW = join(HERE, 'views', 'fortunes.ejs'); +const DATASET_PATH = process.env.DATASET_PATH || '/data/dataset.json'; +const STATIC_ROOT = '/data/static'; + +// One process per listener, because Moro locks one configuration per +// process. 'plain' serves :8080; 'tls' serves json-tls, static-tls and 8gbit +// on :8081 from /certs; 'tlscheck' serves the opt-in TLS hardening section on +// :9000 from /certs-tls, reloading the pair whenever the files change. +const ROLE = process.env.MORO_ROLE || 'plain'; +const LISTENERS = { + plain: { port: 8080 }, + tls: { port: 8081, certs: '/certs' }, + tlscheck: { port: 9000, certs: '/certs-tls', watch: true }, +}; +const pairIn = dir => ({ keyFile: join(dir, 'server.key'), certFile: join(dir, 'server.crt') }); +const hasPair = dir => existsSync(pairIn(dir).keyFile) && existsSync(pairIn(dir).certFile); + +// The container is pinned to a cpuset, so the cgroup quota is read first and +// availableParallelism(), which honours the affinity mask, is the fallback. +// Moro's 'auto' counts the same way now; the number is computed here as well +// because the Postgres pool below has to divide by it. +function cpuCount() { + try { + const [quota, period] = readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim().split(' '); + if (quota !== 'max') { + const n = Math.floor(Number(quota) / Number(period)); + if (n >= 1) return n; + } + } catch {} + return availableParallelism(); +} +const workers = cpuCount(); + +// A worker thread on the native engine, a node:cluster process on the +// fallback transport. The primary serves nothing, so it loads no dataset and +// opens no database pool. +const isWorker = !isMainThread || cluster.isWorker; +const isPrimary = !isWorker; + +// The harness mounts /certs for the TLS profiles and /certs-tls for the +// tls_check section only; a listener whose pair is absent is not started. +if (ROLE === 'plain' && isPrimary) { + for (const role of ['tls', 'tlscheck']) { + if (!hasPair(LISTENERS[role].certs)) continue; + const child = spawn(process.execPath, [fileURLToPath(import.meta.url)], { + stdio: 'inherit', + env: { ...process.env, MORO_ROLE: role }, + }); + child.on('exit', (code, signal) => { + console.error(`morojs: ${role} process exited (${signal ?? code})`); + }); + for (const signal of ['SIGTERM', 'SIGINT']) { + process.on(signal, () => child.kill(signal)); + } + } +} + +// Dataset for /json; a missing file serves an empty list instead of taking +// the worker down. +let dataset = []; +if (isWorker) { + try { + dataset = JSON.parse(readFileSync(DATASET_PATH, 'utf8')); + } catch {} +} + +// Postgres for /async-db, /fortunes and the production-stack api, all served +// on :8080. The pool is sized from DATABASE_MAX_CONN across the workers, so +// the cluster as a whole stays inside what Postgres allows. +let pool = null; +if (isWorker && ROLE === 'plain' && process.env.DATABASE_URL) { + const maxConn = parseInt(process.env.DATABASE_MAX_CONN, 10) || 256; + pool = new pg.Pool({ + connectionString: process.env.DATABASE_URL, + max: Math.max(1, Math.floor(maxConn / workers)), + }); + pool.on('error', () => {}); +} + +// The production-stack cache-aside goes through the framework's own cache +// adapters: Redis when the stack provides one, shared across the cluster so +// a write on any worker invalidates what every other one reads, and the +// in-process memory adapter otherwise. TTLs are seconds. +let cache = null; +if (isWorker && ROLE === 'plain') { + cache = process.env.REDIS_URL + ? new RedisCacheAdapter({ url: process.env.REDIS_URL, keyPrefix: 'httparena:' }) + : new MemoryCacheAdapter(); +} +const ITEM_TTL = 1; +const USER_TTL = 30; + +function sumQuery(query) { + let sum = 0; + for (const key in query) { + const n = parseInt(query[key], 10); + if (n === n) sum += n; + } + return sum; +} + +const EMPTY = { items: [], count: 0 }; +const ITEM_COLUMNS = 'id, name, category, price, quantity, active, tags, rating_score, rating_count'; +const ASYNC_DB_SQL = `SELECT ${ITEM_COLUMNS} FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3`; +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 }, +}); +const dbError = (res, message) => res.status(500).json({ error: message }); +const RUNTIME_FORTUNE = 'Additional fortune added at request time.'; + +// The /json response: the first count items with total = price x quantity x m. +function jsonItems(req) { + let count = parseInt(req.params.count, 10) || 0; + if (count < 0) count = 0; + if (count > dataset.length) count = dataset.length; + const m = parseInt(req.query.m, 10) || 1; + const items = new Array(count); + for (let i = 0; i < count; i++) { + const d = dataset[i]; + items[i] = { + 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, + }; + } + return { items, count }; +} + +function defineRoutes(app) { + // A literal body in place of a handler is the framework's documented + // static route: @morojs/engine answers it without entering JS, the same + // way Bun's static routes serve the bun entry's /pipeline. It goes out as + // res.send('ok') would, text/plain. + app.get('/pipeline').handler('ok'); + + // The framework hands the body over parsed: text/plain arrives as a + // string whether it came with a Content-Length or chunked, so POST adds + // it to the query sum. + const baseline11 = (req, res) => { + let total = sumQuery(req.query); + if (req.method === 'POST' && typeof req.body === 'string') { + const n = parseInt(req.body.trim(), 10); + if (n === n) total += n; + } + res.send(String(total)); + }; + app.get('/baseline11').handler(baseline11); + app.post('/baseline11').handler(baseline11); + + // Behind the gateway proxies, which terminate TLS and h2 and forward this + // over loopback h1. + app.get('/baseline2').handler((req, res) => { + res.send(String(sumQuery(req.query))); + }); + + // An awaited timer suspends the request and frees the thread, so the + // waits in flight are bounded by memory. The delay is read from the path + // on every request. + app.get('/delay/:ms').handler(async (req, res) => { + const ms = Number.parseInt(req.params.ms, 10); + if (!Number.isInteger(ms) || ms < 0) { + res.status(404).send('Not found'); + return; + } + if (ms > 0) await new Promise(resolve => setTimeout(resolve, ms)); + res.send(String(ms)); + }); + + // json-comp: the framework's compression middleware on this route alone, + // so no other endpoint pays for the encoder. It negotiates off + // Accept-Encoding per request and sends the body as is when none is sent, + // which is what json-tls on :8081 gets. gzip is preferred over brotli: + // at the middleware's default level it costs about half the CPU of + // brotli at the default quality for a body one tenth larger, and the + // profile prices bytes squared against rate. brotli stays available for + // a client that accepts nothing else. + app.get('/json/:count') + .before(middleware.compression({ encodings: ['gzip', 'br'] })) + .handler((req, res) => { + res.json(jsonItems(req)); + }); + + // 8gbit: the body exactly as it arrived, Content-Length or chunked, sent + // back as the same bytes. req.rawBody is the framework's undecoded copy. + app.post('/echo').handler((req, res) => { + res.setHeader('Content-Type', 'application/octet-stream'); + res.send(req.rawBody ?? Buffer.alloc(0)); + }); + + app.get('/async-db').handler(async (req, res) => { + if (!pool) { + res.json(EMPTY); + return; + } + const min = parseInt(req.query.min, 10) || 10; + const max = parseInt(req.query.max, 10) || 50; + let limit = parseInt(req.query.limit, 10) || 50; + if (limit < 1) limit = 1; + if (limit > 50) limit = 50; + try { + const { rows } = await pool.query({ name: 'async-db', text: ASYNC_DB_SQL, values: [min, max, limit] }); + res.json({ items: rows.map(itemShape), count: rows.length }); + } catch { + res.json(EMPTY); + } + }); + + // fortunes: Moro has no view layer of its own, so the page goes through + // EJS directly, a real template engine rendering a separate template file + // per request, with <%= %> escaping the row that carries a