Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frameworks/node/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# No dependencies, so no build stage: the entry is one file on the node image.
FROM node:26-trixie-slim
WORKDIR /app
COPY server.js .
ENV NODE_ENV=production
EXPOSE 8080
CMD ["node", "server.js"]
37 changes: 37 additions & 0 deletions frameworks/node/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# node

Node's own HTTP server, `node:http`, with no framework on top and no dependencies at all.

## Stack

- **Language:** JavaScript
- **Runtime:** Node.js 26
- **Framework:** none, `http.createServer` from the standard library
- **Build:** Single stage on `node:26-trixie-slim`

## 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 |
| `/json/:count` | GET | Serializes a slice of the dataset, gzipped when the client accepts it |
| `/upload` | POST | Counts the bytes of the request body |

## Notes

- Node was on the board only through frameworks. This entry is the plain HTTP floor of the
runtime itself, so express, fastify, koa, nestjs and the two h3 entries can be read against it.
- `node:cluster` forks one worker per core, but the round robin of the cluster primary is not in
the path: each worker binds 8080 itself with `reusePort`, so the kernel spreads the accepts the
same way bun and deno do. node sets `exclusive` on its own when `reusePort` is true, which is
what takes the cluster listen path out. The other node entries here still use the round robin.
- Routing is a handful of string comparisons on `req.url`, and the query is parsed by hand, since
with no framework there is no router and no parser to measure.
- `node:http` negotiates nothing, so `/json` gzips its own body with `zlib` when `Accept-Encoding`
asks for it, at the default level, and sends it uncompressed otherwise.
- `/upload` counts the body chunk by chunk instead of buffering it, which keeps 20 MB requests on
hundreds of connections out of memory.
- The dataset is read once per worker at startup. A missing file leaves an empty list, since the
profiles other than json run without the mount.
19 changes: 19 additions & 0 deletions frameworks/node/meta.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"display_name": "node",
"language": "JS",
"type": "engine",
"mode": "standard",
"engine": "node:http",
"description": "Node's own HTTP server, node:http, with no framework and no dependencies, and one worker per core sharing the port through reusePort.",
"repo": "https://github.com/nodejs/node",
"enabled": true,
"tests": [
"baseline",
"pipelined",
"limited-conn",
"json",
"json-comp",
"upload"
],
"maintainers": []
}
149 changes: 149 additions & 0 deletions frameworks/node/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
// node:http with nothing on top: no framework, no router, no dependencies. This is
// the floor the node framework entries are read against.
const cluster = require('node:cluster');
const http = require('node:http');
const os = require('node:os');
const fs = require('node:fs');
const zlib = require('node:zlib');

// The container is pinned to a cpuset or a cpu quota, so availableParallelism()
// alone would fork one worker per host core. Same helper as the other node entries.
function getCPUCount() {
try {
const max = fs.readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim();
const [quota, period] = max.split(' ');
if (quota !== 'max') {
const cgroup = Math.floor(Number(quota) / Number(period));
if (cgroup >= 1) return cgroup;
}
} catch {}
return os.availableParallelism ? os.availableParallelism() : os.cpus().length;
}

if (cluster.isPrimary) {
const numCPUs = getCPUCount();
for (let i = 0; i < numCPUs; i++) cluster.fork();
} else {
// A missing dataset serves an empty list instead of taking the worker down
let datasetItems = [];
try {
datasetItems = JSON.parse(fs.readFileSync(process.env.DATASET_PATH || '/data/dataset.json', 'utf8'));
} catch (e) {}

const SERVER_HDR = 'node';

function sendText(res, body) {
res.writeHead(200, {
'content-type': 'text/plain',
'content-length': Buffer.byteLength(body),
'server': SERVER_HDR
});
res.end(body);
}

// No querystring module and no URL object: the profiles send a handful of
// integer parameters, and parsing them by hand is the whole cost here.
function sumQuery(query) {
let sum = 0;
for (const pair of query.split('&')) {
const eq = pair.indexOf('=');
if (eq < 0) continue;
const n = parseInt(pair.slice(eq + 1), 10);
if (n === n) sum += n;
}
return sum;
}

function queryValue(query, name) {
for (const pair of query.split('&')) {
if (pair.startsWith(name) && pair[name.length] === '=') {
return pair.slice(name.length + 1);
}
}
return '';
}

function json(req, res, path, query) {
let count = parseInt(path.slice(6), 10) || 0;
if (count < 0) count = 0;
if (count > datasetItems.length) count = datasetItems.length;
const m = parseInt(queryValue(query, '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: node:http negotiates nothing, so Accept-Encoding is read here,
// per request, with the zlib defaults. Nothing at all when it is not asked for
const accept = req.headers['accept-encoding'];
if (accept !== undefined && accept.includes('gzip')) {
const gz = zlib.gzipSync(body);
res.writeHead(200, {
'content-type': 'application/json',
'content-encoding': 'gzip',
'vary': 'accept-encoding',
'content-length': gz.length,
'server': SERVER_HDR
});
res.end(gz);
return;
}
res.writeHead(200, {
'content-type': 'application/json',
'content-length': Buffer.byteLength(body),
'server': SERVER_HDR
});
res.end(body);
}

const server = http.createServer((req, res) => {
// req.url is the request target, so the path is everything before the "?"
const url = req.url;
const mark = url.indexOf('?');
const path = mark < 0 ? url : url.slice(0, mark);
const query = mark < 0 ? '' : url.slice(mark + 1);

if (path === '/pipeline') return sendText(res, 'ok');

if (path === '/baseline11') {
const querySum = sumQuery(query);
if (req.method !== 'POST') return sendText(res, String(querySum));
// Content-Length or chunked, node:http gives the same data events either way
let body = '';
req.setEncoding('utf8');
req.on('data', chunk => body += chunk);
req.on('end', () => {
let total = querySum;
const n = parseInt(body.trim(), 10);
if (n === n) total += n;
sendText(res, String(total));
});
return;
}

if (path.startsWith('/json/')) return json(req, res, path, query);

if (path === '/upload' && req.method === 'POST') {
// Counted chunk by chunk: the profile posts up to 20 MB per request over
// hundreds of connections, and buffering the bodies would only cost memory
let size = 0;
req.on('data', chunk => size += chunk.length);
req.on('end', () => sendText(res, String(size)));
return;
}

if (path === '/baseline2') return sendText(res, String(sumQuery(query)));

res.writeHead(404, { 'content-type': 'text/plain', 'content-length': 9, 'server': SERVER_HDR });
res.end('Not found');
});

// Scaling is cluster to fork the workers, but not its round robin: with reusePort
// every worker binds 8080 itself with SO_REUSEPORT and the kernel spreads the
// accepts, the way bun and deno do it. node sets exclusive on its own when
// reusePort is true, so the cluster listen path is out of the way.
server.listen({ port: 8080, host: '0.0.0.0', reusePort: true });
}