feat: reverse-proxy to VM instead of 302 (keep clean domain) + bump 0.0.4

This commit is contained in:
“Naeel”
2026-09-02 19:15:08 +03:00
parent 87399a35c9
commit a556cab7ea
3 changed files with 123 additions and 61 deletions
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "tf-docs",
"version": "0.0.3",
"description": "Terraform docs redirect router (302 to VM static server)",
"version": "0.0.4",
"description": "Terraform docs reverse-proxy (stream from VM static server)",
"main": "server.js",
"scripts": {
"start": "node server.js",
+91 -46
View File
@@ -1,79 +1,124 @@
'use strict';
const http = require('http');
const https = require('https');
const PORT = Number(process.env.PORT) || 3000;
const VM_DOCS_BASE_URL = (process.env.VM_DOCS_BASE_URL || '').replace(/\/+$/, '');
const PROXY_TIMEOUT_MS = Number(process.env.PROXY_TIMEOUT_MS) || 30000;
const VM_DOCS_BASE_URL = process.env.VM_DOCS_BASE_URL;
const STAND_RE = /^[A-Za-z0-9._-]+$/;
function send(res, status, body, headers = {}) {
const data = Buffer.from(String(body));
res.writeHead(status, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': data.length,
...headers,
});
res.end(data);
if (!VM_DOCS_BASE_URL) {
throw new Error('VM_DOCS_BASE_URL environment variable is required');
}
function redirect(res, location) {
send(res, 302, '', { Location: location });
const UPSTREAM = new URL(VM_DOCS_BASE_URL);
if (UPSTREAM.protocol !== 'http:' && UPSTREAM.protocol !== 'https:') {
throw new Error('VM_DOCS_BASE_URL must start with http:// or https://');
}
const UPSTREAM_MODULE = UPSTREAM.protocol === 'https:' ? https : http;
const UPSTREAM_BASE_PATH = UPSTREAM.pathname.replace(/\/+$/, '');
const UPSTREAM_PORT = UPSTREAM.port || (UPSTREAM.protocol === 'https:' ? 443 : 80);
const STAND_RE = /^[A-Za-z0-9._-]+$/;
const FORWARD_HEADERS = ['content-type', 'content-length', 'cache-control', 'content-encoding'];
function respond(res, status, body) {
if (!res.headersSent) {
res.writeHead(status, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': Buffer.byteLength(body),
});
}
res.end(body);
}
function proxyRequest(req, res, targetPath) {
const options = {
protocol: UPSTREAM.protocol,
hostname: UPSTREAM.hostname,
port: UPSTREAM_PORT,
method: req.method,
path: targetPath,
timeout: PROXY_TIMEOUT_MS,
};
const upstreamReq = UPSTREAM_MODULE.request(options, (upstreamRes) => {
const headers = {};
for (const name of FORWARD_HEADERS) {
if (upstreamRes.headers[name] !== undefined) {
headers[name] = upstreamRes.headers[name];
}
}
res.writeHead(upstreamRes.statusCode || 502, headers);
upstreamRes.pipe(res);
});
upstreamReq.on('timeout', () => {
upstreamReq.destroy(new Error('Upstream timeout'));
});
upstreamReq.on('error', () => {
if (res.headersSent) {
res.destroy();
} else {
respond(res, 502, 'Bad Gateway');
}
});
req.on('aborted', () => upstreamReq.destroy());
upstreamReq.end();
}
const server = http.createServer((req, res) => {
if (req.method !== 'GET' && req.method !== 'HEAD') {
send(res, 405, 'Method Not Allowed');
respond(res, 405, 'Method Not Allowed');
return;
}
let pathname;
const rawUrl = req.url || '/';
const qIndex = rawUrl.indexOf('?');
const rawPath = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
const rawQuery = qIndex === -1 ? '' : rawUrl.slice(qIndex);
if (rawPath === '/health') {
respond(res, 200, 'ok');
return;
}
let decoded;
try {
pathname = decodeURIComponent(req.url.split('?')[0]);
} catch (err) {
send(res, 400, 'Bad Request');
decoded = decodeURIComponent(rawPath);
} catch (_) {
respond(res, 400, 'Bad Request');
return;
}
if (pathname === '/health') {
send(res, 200, 'ok');
if (decoded.split('/').some((seg) => seg === '..')) {
respond(res, 400, 'Bad Request');
return;
}
if (pathname === '/') {
if (!VM_DOCS_BASE_URL) {
send(res, 500, 'VM_DOCS_BASE_URL is not configured');
let targetPath;
if (decoded === '/') {
targetPath = UPSTREAM_BASE_PATH + '/' + rawQuery;
} else {
const stand = decoded.split('/').filter(Boolean)[0];
if (!stand || !STAND_RE.test(stand)) {
respond(res, 400, 'Bad Request');
return;
}
redirect(res, VM_DOCS_BASE_URL + '/');
return;
targetPath = UPSTREAM_BASE_PATH + rawPath + rawQuery;
}
if (!VM_DOCS_BASE_URL) {
send(res, 500, 'VM_DOCS_BASE_URL is not configured');
return;
}
const segments = pathname.split('/').filter((s) => s !== '');
if (segments.some((s) => s === '..')) {
send(res, 400, 'Bad Request');
return;
}
const stand = segments[0];
if (!stand || !STAND_RE.test(stand)) {
send(res, 404, 'Not Found');
return;
}
redirect(res, VM_DOCS_BASE_URL + pathname);
proxyRequest(req, res, targetPath);
});
if (require.main === module) {
server.listen(PORT, () => {
console.log(`redirect router listening on :${PORT}`);
console.log(`VM_DOCS_BASE_URL=${VM_DOCS_BASE_URL || '(not set)'}`);
console.log(`[docs-proxy] listening on port ${PORT}, upstream: ${VM_DOCS_BASE_URL}`);
});
}
module.exports = { server, send, redirect };
module.exports = { server };
+30 -13
View File
@@ -1,19 +1,36 @@
'use strict';
process.env.VM_DOCS_BASE_URL = process.env.VM_DOCS_BASE_URL || 'http://vm.example';
const http = require('node:http');
const test = require('node:test');
const assert = require('node:assert');
const { server } = require('../server.js');
let server;
const upstream = http.createServer((req, res) => {
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Content-Length': Buffer.byteLength('<html>ok</html>'),
});
res.end('<html>ok</html>');
});
test.before(async () => {
await new Promise((r) => upstream.listen(0, '127.0.0.1', r));
process.env.VM_DOCS_BASE_URL = `http://127.0.0.1:${upstream.address().port}`;
({ server } = require('../server.js'));
});
test.after(async () => {
await new Promise((r) => upstream.close(r));
});
async function withServer(fn) {
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
try {
await fn(base);
} finally {
await new Promise((resolve) => server.close(resolve));
await new Promise((r) => server.close(r));
}
}
@@ -25,19 +42,19 @@ test('GET /health -> 200 ok', async () => {
});
});
test('GET /nubes-test/akhq/ -> 302 to VM', async () => {
test('GET /nubes-test/ -> proxies to upstream', async () => {
await withServer(async (base) => {
const res = await fetch(`${base}/nubes-test/akhq/`, { redirect: 'manual' });
assert.strictEqual(res.status, 302);
assert.strictEqual(res.headers.get('location'), 'http://vm.example/nubes-test/akhq/');
const res = await fetch(`${base}/nubes-test/`);
assert.strictEqual(res.status, 200);
assert.strictEqual(await res.text(), '<html>ok</html>');
});
});
test('GET / -> 302 to VM root', async () => {
test('GET / -> proxies to upstream root', async () => {
await withServer(async (base) => {
const res = await fetch(`${base}/`, { redirect: 'manual' });
assert.strictEqual(res.status, 302);
assert.strictEqual(res.headers.get('location'), 'http://vm.example/');
const res = await fetch(`${base}/`);
assert.strictEqual(res.status, 200);
assert.strictEqual(await res.text(), '<html>ok</html>');
});
});