feat: implementasi server lokal untuk injeksi konfigurasi dinamis
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const { URL } = require('url');
|
||||
|
||||
const ROOT_DIR = __dirname;
|
||||
const PARENT_DIR = path.join(__dirname, '..', '..');
|
||||
const INDEX_PATH = path.join(ROOT_DIR, 'index.html');
|
||||
const ENV_PATH = path.join(ROOT_DIR, '.env');
|
||||
const HOST = '127.0.0.1';
|
||||
const PORT = Number(process.env.PORT || 3000);
|
||||
|
||||
function logServerError(scope, error, extra = {}) {
|
||||
console.error(`[${scope}]`, error && error.stack ? error.stack : error, extra);
|
||||
}
|
||||
|
||||
function parseEnvFile(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const env = {};
|
||||
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const separatorIndex = line.indexOf('=');
|
||||
if (separatorIndex === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const key = line.slice(0, separatorIndex).trim();
|
||||
let value = line.slice(separatorIndex + 1).trim();
|
||||
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
env[key] = value;
|
||||
}
|
||||
|
||||
return env;
|
||||
}
|
||||
|
||||
function getAppConfig() {
|
||||
const env = {
|
||||
...parseEnvFile(ENV_PATH),
|
||||
...process.env
|
||||
};
|
||||
|
||||
const dashboardProxyTemplates = [
|
||||
env.DASHBOARD_PROXY_1,
|
||||
env.DASHBOARD_PROXY_2,
|
||||
env.DASHBOARD_PROXY_3
|
||||
].filter(Boolean);
|
||||
|
||||
return {
|
||||
dashboardTargetUrl: env.DASHBOARD_TARGET_URL || '',
|
||||
dashboardProxyTemplates
|
||||
};
|
||||
}
|
||||
|
||||
const DASHBOARD_CACHE_TTL_MS = 5 * 60 * 1000;
|
||||
let dashboardCache = { html: null, timestamp: 0 };
|
||||
|
||||
async function fetchDashboardHtml() {
|
||||
const targetUrl = getAppConfig().dashboardTargetUrl;
|
||||
if (!targetUrl) {
|
||||
throw new Error('DASHBOARD_TARGET_URL kosong');
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
if (dashboardCache.html && (now - dashboardCache.timestamp) < DASHBOARD_CACHE_TTL_MS) {
|
||||
return { html: dashboardCache.html, cached: 'HIT' };
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 12000);
|
||||
try {
|
||||
const response = await fetch(targetUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36'
|
||||
}
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP_ERROR_${response.status}`);
|
||||
}
|
||||
const html = await response.text();
|
||||
dashboardCache = { html, timestamp: Date.now() };
|
||||
return { html, cached: 'MISS' };
|
||||
} catch (error) {
|
||||
if (dashboardCache.html) {
|
||||
return { html: dashboardCache.html, cached: 'STALE' };
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
function validateAppConfig(config) {
|
||||
const errors = [];
|
||||
|
||||
if (!config.dashboardTargetUrl) {
|
||||
errors.push('DASHBOARD_TARGET_URL belum diisi.');
|
||||
}
|
||||
|
||||
if (!Array.isArray(config.dashboardProxyTemplates) || !config.dashboardProxyTemplates.length) {
|
||||
errors.push('Minimal satu DASHBOARD_PROXY_* harus diisi.');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
function injectConfig(html) {
|
||||
const configScript = `<script>window.APP_CONFIG = ${JSON.stringify(getAppConfig())};</script>`;
|
||||
const defaultConfigScript = '<script>\n window.APP_CONFIG = window.APP_CONFIG || {\n dashboardTargetUrl: \'\',\n dashboardProxyTemplates: []\n };\n </script>';
|
||||
|
||||
if (html.includes(defaultConfigScript)) {
|
||||
return html.replace(defaultConfigScript, configScript);
|
||||
}
|
||||
|
||||
return html.replace('</head>', ` ${configScript}\n</head>`);
|
||||
}
|
||||
|
||||
function sendHtml(res) {
|
||||
try {
|
||||
const html = fs.readFileSync(INDEX_PATH, 'utf8');
|
||||
const configErrors = validateAppConfig(getAppConfig());
|
||||
if (configErrors.length) {
|
||||
logServerError('config', new Error('Invalid app config'), { configErrors });
|
||||
}
|
||||
|
||||
const withConfig = injectConfig(html);
|
||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
res.end(withConfig);
|
||||
} catch (error) {
|
||||
logServerError('sendHtml', error);
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
}
|
||||
|
||||
function getMimeType(pathname) {
|
||||
const ext = path.extname(pathname).toLowerCase();
|
||||
const mimeTypes = {
|
||||
'.html': 'text/html; charset=utf-8',
|
||||
'.css': 'text/css; charset=utf-8',
|
||||
'.js': 'application/javascript; charset=utf-8',
|
||||
'.json': 'application/json; charset=utf-8',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.webp': 'image/webp',
|
||||
'.woff': 'font/woff',
|
||||
'.woff2': 'font/woff2',
|
||||
'.ttf': 'font/ttf',
|
||||
'.eot': 'application/vnd.ms-fontobject'
|
||||
};
|
||||
return mimeTypes[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function sendStaticFile(res, filepath) {
|
||||
try {
|
||||
if (!fs.existsSync(filepath)) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filepath);
|
||||
const mimeType = getMimeType(filepath);
|
||||
res.writeHead(200, { 'Content-Type': mimeType });
|
||||
res.end(content);
|
||||
} catch (error) {
|
||||
logServerError('sendStaticFile', error, { filepath });
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
}
|
||||
|
||||
function sendNotFound(res) {
|
||||
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Not found');
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
try {
|
||||
const requestUrl = new URL(req.url, `http://${req.headers.host}`);
|
||||
const pathname = requestUrl.pathname;
|
||||
|
||||
if (pathname === '/' || pathname === '/index.html') {
|
||||
sendHtml(res);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/api/dashboard') {
|
||||
fetchDashboardHtml()
|
||||
.then(({ html, cached }) => {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/html; charset=utf-8',
|
||||
'X-Cache': cached
|
||||
});
|
||||
res.end(html);
|
||||
})
|
||||
.catch((error) => {
|
||||
logServerError('api-dashboard', error);
|
||||
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('DASHBOARD_FETCH_FAILED');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle /assets/ from parent directory
|
||||
if (pathname.startsWith('/assets/')) {
|
||||
const assetsFilepath = path.join(PARENT_DIR, pathname);
|
||||
const normalizedAssetsPath = path.normalize(assetsFilepath);
|
||||
|
||||
if (!normalizedAssetsPath.startsWith(path.normalize(PARENT_DIR))) {
|
||||
sendNotFound(res);
|
||||
return;
|
||||
}
|
||||
|
||||
sendStaticFile(res, normalizedAssetsPath);
|
||||
return;
|
||||
}
|
||||
|
||||
// Serve static files from ROOT_DIR
|
||||
const filepath = path.join(ROOT_DIR, pathname);
|
||||
const normalizedPath = path.normalize(filepath);
|
||||
|
||||
// Security check: ensure the requested file is within ROOT_DIR
|
||||
if (!normalizedPath.startsWith(path.normalize(ROOT_DIR))) {
|
||||
sendNotFound(res);
|
||||
return;
|
||||
}
|
||||
|
||||
sendStaticFile(res, normalizedPath);
|
||||
} catch (error) {
|
||||
logServerError('request-handler', error, { url: req.url });
|
||||
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||
res.end('Internal Server Error');
|
||||
}
|
||||
});
|
||||
|
||||
server.on('clientError', (error, socket) => {
|
||||
logServerError('clientError', error);
|
||||
socket.end('HTTP/1.1 400 Bad Request\r\n\r\n');
|
||||
});
|
||||
|
||||
server.listen(PORT, HOST, () => {
|
||||
console.log(`Server ready at http://${HOST}:${PORT}`);
|
||||
});
|
||||
|
||||
server.on('error', (error) => {
|
||||
logServerError('server', error, { host: HOST, port: PORT });
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
logServerError('uncaughtException', error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logServerError('unhandledRejection', reason instanceof Error ? reason : new Error(String(reason)));
|
||||
process.exitCode = 1;
|
||||
});
|
||||
Reference in New Issue
Block a user