@tomphttp/bare-server-node
Version:
The Bare Server implementation in NodeJS.
286 lines • 11.2 kB
JavaScript
;
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.json = exports.pkg = exports.BareError = void 0;
const node_events_1 = __importDefault(require("node:events"));
const node_fs_1 = require("node:fs");
const node_path_1 = require("node:path");
const node_stream_1 = require("node:stream");
const http_errors_1 = __importDefault(require("http-errors"));
const rate_limiter_flexible_1 = require("rate-limiter-flexible");
const requestUtil_js_1 = require("./requestUtil.js");
class BareError extends Error {
status;
body;
constructor(status, body) {
super(body.message || body.code);
this.status = status;
this.body = body;
}
}
exports.BareError = BareError;
exports.pkg = JSON.parse((0, node_fs_1.readFileSync)((0, node_path_1.join)(__dirname, '..', 'package.json'), 'utf-8'));
const project = {
name: 'bare-server-node',
description: 'TOMPHTTP NodeJS Bare Server',
repository: 'https://github.com/tomphttp/bare-server-node',
version: exports.pkg.version,
};
function json(status, json) {
const send = Buffer.from(JSON.stringify(json, null, '\t'));
return new Response(send, {
status,
headers: {
'content-type': 'application/json',
'content-length': send.byteLength.toString(),
},
});
}
exports.json = json;
class Server extends node_events_1.default {
directory;
routes = new Map();
socketRoutes = new Map();
versions = [];
closed = false;
options;
rateLimiter;
/**
* @internal
*/
constructor(directory, options) {
super();
this.directory = directory;
this.options = options;
if (options.connectionLimiter) {
const maxConnections = options.connectionLimiter.maxConnectionsPerIP ?? 10;
const duration = options.connectionLimiter.windowDuration ?? 60;
const blockDuration = options.connectionLimiter.blockDuration ?? 60;
this.rateLimiter = new rate_limiter_flexible_1.RateLimiterMemory({
points: maxConnections,
duration,
blockDuration,
});
}
}
/**
* Extracts client IP address from incoming request.
* Checks headers in order of preference: `x-forwarded-for`, `x-real-ip`, then socket address.
* @param req HTTP request to extract IP from.
* @return Client IP address as string, or `'unknown'` if not determinable.
*/
getClientIP(req) {
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) {
return forwarded.split(',')[0].trim();
}
const realIP = req.headers['x-real-ip'];
if (realIP) {
return realIP;
}
return req.socket.remoteAddress || 'unknown';
}
/**
* Checks if request should be rate limited based on connection type and IP to prevent resource exhaustion.
* @param req HTTP request to check.
* @return Promise resolving to rate limit result with allowed status and limiter response.
*/
async checkRateLimit(req) {
if (!this.rateLimiter) {
return { allowed: true };
}
const ip = this.getClientIP(req);
try {
const connection = req.headers.connection?.toLowerCase();
const keepAlive = connection === 'keep-alive' ||
(req.httpVersion === '1.1' && connection !== 'close');
if (keepAlive) {
const rateLimiterRes = await this.rateLimiter.consume(ip);
return { allowed: true, rateLimiterRes };
}
else {
const rateLimiterRes = await this.rateLimiter.get(ip);
if (rateLimiterRes && rateLimiterRes.remainingPoints <= 0) {
return { allowed: false, rateLimiterRes };
}
return { allowed: true };
}
}
catch (rateLimiterRes) {
return {
allowed: false,
rateLimiterRes: rateLimiterRes,
};
}
}
/**
* Remove all timers and listeners
*/
close() {
this.closed = true;
this.emit('close');
}
shouldRoute(request) {
return (!this.closed &&
request.url !== undefined &&
request.url.startsWith(this.directory));
}
get instanceInfo() {
return {
versions: this.versions,
language: 'NodeJS',
memoryUsage: Math.round((process.memoryUsage().heapUsed / 1024 / 1024) * 100) / 100,
maintainer: this.options.maintainer,
project,
};
}
async routeUpgrade(req, socket, head) {
const rateResult = await this.checkRateLimit(req);
if (!rateResult.allowed) {
const retryAfter = rateResult.rateLimiterRes
? Math.round(rateResult.rateLimiterRes.msBeforeNext / 1000) || 1
: 60;
const maxConnections = this.options.connectionLimiter?.maxConnectionsPerIP ?? 10;
socket.write('HTTP/1.1 429 Too Many Connections\r\n' +
'Content-Type: application/json\r\n' +
`Retry-After: ${retryAfter}\r\n` +
`RateLimit-Limit: ${maxConnections}\r\n` +
`RateLimit-Remaining: ${rateResult.rateLimiterRes?.remainingPoints ?? 0}\r\n` +
`RateLimit-Reset: ${rateResult.rateLimiterRes
? Math.ceil(rateResult.rateLimiterRes.msBeforeNext / 1000)
: 60}\r\n` +
'\r\n' +
JSON.stringify({
code: 'CONNECTION_LIMIT_EXCEEDED',
id: 'error.TooManyConnections',
message: 'Too many keep-alive connections from this IP address',
}));
socket.end();
return;
}
const request = new Request(new URL(req.url, 'http://bare-server-node'), {
method: req.method,
body: requestUtil_js_1.nullMethod.includes(req.method || '') ? undefined : req,
headers: req.headers,
});
request.native = req;
const service = new URL(request.url).pathname.slice(this.directory.length - 1);
if (this.socketRoutes.has(service)) {
const call = this.socketRoutes.get(service);
try {
await call(request, socket, head, this.options);
}
catch (error) {
if (this.options.logErrors) {
console.error(error);
}
socket.end();
}
}
else {
socket.end();
}
}
async routeRequest(req, res) {
const rateResult = await this.checkRateLimit(req);
if (!rateResult.allowed) {
const retryAfter = rateResult.rateLimiterRes
? Math.round(rateResult.rateLimiterRes.msBeforeNext / 1000) || 1
: 60;
const maxConnections = this.options.connectionLimiter?.maxConnectionsPerIP ?? 10;
res.writeHead(429, 'Too Many Connections', {
'Content-Type': 'application/json',
'Retry-After': retryAfter.toString(),
'RateLimit-Limit': maxConnections.toString(),
'RateLimit-Remaining': (rateResult.rateLimiterRes?.remainingPoints ?? 0).toString(),
'RateLimit-Reset': (rateResult.rateLimiterRes
? Math.ceil(rateResult.rateLimiterRes.msBeforeNext / 1000)
: 60).toString(),
});
res.end(JSON.stringify({
code: 'CONNECTION_LIMIT_EXCEEDED',
id: 'error.TooManyConnections',
message: 'Too many keep-alive connections from this IP address',
}));
return;
}
const request = new Request(new URL(req.url, 'http://bare-server-node'), {
method: req.method,
body: requestUtil_js_1.nullMethod.includes(req.method || '') ? undefined : req,
headers: req.headers,
duplex: 'half',
});
request.native = req;
const service = new URL(request.url).pathname.slice(this.directory.length - 1);
let response;
try {
if (request.method === 'OPTIONS') {
response = new Response(undefined, { status: 200 });
}
else if (service === '/') {
response = json(200, this.instanceInfo);
}
else if (this.routes.has(service)) {
const call = this.routes.get(service);
response = await call(request, res, this.options);
}
else {
throw new http_errors_1.default.NotFound();
}
}
catch (error) {
if (this.options.logErrors)
console.error(error);
if (http_errors_1.default.isHttpError(error)) {
response = json(error.statusCode, {
code: 'UNKNOWN',
id: `error.${error.name}`,
message: error.message,
stack: error.stack,
});
}
else if (error instanceof Error) {
response = json(500, {
code: 'UNKNOWN',
id: `error.${error.name}`,
message: error.message,
stack: error.stack,
});
}
else {
response = json(500, {
code: 'UNKNOWN',
id: 'error.Exception',
message: error,
stack: new Error(error).stack,
});
}
if (!(response instanceof Response)) {
if (this.options.logErrors) {
console.error('Cannot', request.method, new URL(request.url).pathname, ': Route did not return a response.');
}
throw new http_errors_1.default.InternalServerError();
}
}
response.headers.set('x-robots-tag', 'noindex');
response.headers.set('access-control-allow-headers', '*');
response.headers.set('access-control-allow-origin', '*');
response.headers.set('access-control-allow-methods', '*');
response.headers.set('access-control-expose-headers', '*');
// don't fetch preflight on every request...
// instead, fetch preflight every 10 minutes
response.headers.set('access-control-max-age', '7200');
res.writeHead(response.status, response.statusText, Object.fromEntries(response.headers));
if (response.body) {
const body = node_stream_1.Readable.fromWeb(response.body);
body.pipe(res);
res.on('close', () => body.destroy());
}
else
res.end();
}
}
exports.default = Server;
//# sourceMappingURL=BareServer.js.map