Files

886 lines
28 KiB
TypeScript

import http from "node:http";
import { spawn } from "node:child_process";
import { pathToFileURL } from "node:url";
import path from "node:path";
import { readFile, readdir } from "node:fs/promises";
type ModuleDefinition = {
id: string;
label: string;
entrypoint: string;
queries: Record<string, { name: string; params: string[]; display: unknown }>;
implementations: Record<string, Record<string, (args: Record<string, unknown>) => { sql: string }>>;
schedules?: Record<string, unknown>;
};
const root = process.cwd();
const srcDir = path.join(root, "src");
const portArg = Number(process.argv.find((arg) => arg.startsWith("--port="))?.split("=")[1]);
const startPort = Number.isFinite(portArg) && portArg > 0 ? portArg : 4317;
async function findModuleFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const files = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) return findModuleFiles(fullPath);
if (entry.isFile() && entry.name.endsWith(".module.ts")) return [fullPath];
return [];
}),
);
return files.flat();
}
async function loadModules() {
const files = await findModuleFiles(srcDir);
const modules = await Promise.all(
files.map(async (file) => {
const imported = await import(pathToFileURL(file).href + `?t=${Date.now()}`);
const module = imported.default as ModuleDefinition;
return {
file: path.relative(root, file).replace(/\\/g, "/"),
module,
};
}),
);
return modules.sort((a, b) => a.module.id.localeCompare(b.module.id));
}
function json(res: http.ServerResponse, status: number, data: unknown) {
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify(data, null, 2));
}
function text(res: http.ServerResponse, status: number, data: string) {
res.writeHead(status, { "content-type": "text/plain; charset=utf-8" });
res.end(data);
}
function html(res: http.ServerResponse, status: number, data: string) {
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
res.end(data);
}
function jpg(res: http.ServerResponse, status: number, data: Buffer) {
res.writeHead(status, {
"content-type": "image/jpeg",
"cache-control": "public, max-age=3600",
});
res.end(data);
}
async function readJsonBody(req: http.IncomingMessage) {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
const raw = Buffer.concat(chunks).toString("utf8");
return raw ? JSON.parse(raw) : {};
}
function defaultArgs(params: string[]) {
const args: Record<string, string> = {
ctx_user_companies: "1,2,3",
ctx_user_companies_for_module: "1,2,3",
};
for (const param of params) args[param] = `:${param}`;
return args;
}
function vetProject() {
return new Promise<{ code: number | null; output: string }>((resolve) => {
const child = spawn(process.platform === "win32" ? "npm.cmd" : "npm", ["run", "vet"], {
cwd: root,
shell: false,
});
let output = "";
child.stdout.on("data", (chunk) => (output += chunk.toString()));
child.stderr.on("data", (chunk) => (output += chunk.toString()));
child.on("close", (code) => resolve({ code, output }));
});
}
function parseManifestValue(value: string) {
const trimmed = value.trim();
if (trimmed.toLowerCase() === "null") return null;
if (trimmed.toLowerCase() === "true") return true;
if (trimmed.toLowerCase() === "false") return false;
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
if (
(trimmed.startsWith("'") && trimmed.endsWith("'")) ||
(trimmed.startsWith('"') && trimmed.endsWith('"'))
) {
return trimmed.slice(1, -1);
}
return value;
}
function findBearerToken(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
if (
"token" in value &&
typeof (value as Record<string, unknown>).token === "string" &&
(value as Record<string, string>).token.trim()
) {
return (value as Record<string, string>).token.replace(/^Bearer\s+/i, "").trim();
}
const preferredKeys = new Set([
"access_token",
"accessToken",
"bearer",
"bearerToken",
"token",
"jwt",
]);
const preferredLowerKeys = new Set([...preferredKeys].map((key) => key.toLowerCase()));
const queue: unknown[] = [value];
while (queue.length) {
const current = queue.shift();
if (!current || typeof current !== "object") continue;
for (const [key, nested] of Object.entries(current)) {
if (typeof nested === "string" && preferredKeys.has(key)) {
return nested.replace(/^Bearer\s+/i, "").trim();
}
if (typeof nested === "string" && preferredLowerKeys.has(key.toLowerCase())) {
return nested.replace(/^Bearer\s+/i, "").trim();
}
if (nested && typeof nested === "object") queue.push(nested);
}
}
return null;
}
async function postLogin(loginUrl: string, payload: Record<string, string>) {
const response = await fetch(loginUrl, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload),
});
const textBody = await response.text();
let parsedBody: unknown = textBody;
try {
parsedBody = textBody ? JSON.parse(textBody) : null;
} catch {
parsedBody = textBody;
}
return {
ok: response.ok,
status: response.status,
bearerToken: findBearerToken(parsedBody),
responseBody: parsedBody,
};
}
async function handleApi(req: http.IncomingMessage, res: http.ServerResponse) {
const url = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
if (req.method === "GET" && url.pathname === "/api/modules") {
const modules = await loadModules();
return json(
res,
200,
modules.map(({ file, module }) => ({
file,
id: module.id,
label: module.label,
entrypoint: module.entrypoint,
queries: Object.entries(module.queries).map(([key, query]) => ({
key,
name: query.name,
params: query.params,
})),
systems: Object.keys(module.implementations || {}),
})),
);
}
if (req.method === "GET" && url.pathname === "/assets/logo-davinti.jpg") {
const image = await readFile(path.join(root, "scripts", "assets", "logo-davinti.jpg"));
return jpg(res, 200, image);
}
if (req.method === "GET" && url.pathname === "/api/docs") {
const markdown = await readFile(path.join(root, "scripts", "module-test-app.md"), "utf8");
return json(res, 200, { markdown });
}
if (req.method === "POST" && url.pathname === "/api/render") {
const body = await readJsonBody(req);
const modules = await loadModules();
const found = modules.find(({ module }) => module.id === body.moduleId);
if (!found) return json(res, 404, { error: "Module not found" });
const query = found.module.queries[body.queryKey];
const implementation = found.module.implementations?.[body.system]?.[body.queryKey];
if (!query || !implementation) return json(res, 404, { error: "Query implementation not found" });
const args = { ...defaultArgs(query.params), ...(body.args || {}) };
const result = implementation(args);
return json(res, 200, { sql: result.sql, args });
}
if (req.method === "POST" && url.pathname === "/api/login") {
const body = await readJsonBody(req);
const loginUrl = String(body.loginUrl || "");
const email = String(body.email || "");
const senha = String(body.senha || "");
if (!loginUrl || !email || !senha) {
return json(res, 400, { error: "loginUrl, email e senha sao obrigatorios" });
}
const attempts = [
{ email, senha },
{ email, password: senha },
];
const results = [];
for (const payload of attempts) {
const result = await postLogin(loginUrl, payload);
results.push(result);
if (result.ok && result.bearerToken) {
return json(res, 200, {
status: result.status,
bearerToken: result.bearerToken,
responseBody: result.responseBody,
});
}
}
const lastResult = results[results.length - 1];
const successfulWithoutToken = results.find((result) => result.ok);
if (successfulWithoutToken) {
return json(res, 502, {
error: "Login realizado, mas nao encontrei token no retorno",
status: successfulWithoutToken.status,
responseBody: successfulWithoutToken.responseBody,
});
}
return json(res, lastResult.status, {
error: "Login retornou erro HTTP " + lastResult.status,
status: lastResult.status,
responseBody: lastResult.responseBody,
});
}
if (req.method === "POST" && url.pathname === "/api/manifest-execute") {
const body = await readJsonBody(req);
const baseUrl = String(body.baseUrl || "").replace(/\/+$/, "");
const moduleId = String(body.moduleId || "");
const queryKey = String(body.queryKey || "");
const clientId = String(body.clientId || "");
const bearerToken = String(body.bearerToken || "");
const params = body.params || {};
if (!baseUrl || !moduleId || !queryKey) {
return json(res, 400, { error: "baseUrl, moduleId e queryKey sao obrigatorios" });
}
const endpoint = `${baseUrl}/api/manifest/modules/${encodeURIComponent(moduleId)}/queries/${encodeURIComponent(queryKey)}/execute`;
const headers: Record<string, string> = {
"content-type": "application/json",
};
if (clientId) headers["x-client-id"] = clientId;
if (bearerToken) headers.authorization = `Bearer ${bearerToken}`;
const response = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify(params),
});
const textBody = await response.text();
let parsedBody: unknown = textBody;
try {
parsedBody = textBody ? JSON.parse(textBody) : null;
} catch {
parsedBody = textBody;
}
return json(res, 200, {
status: response.status,
ok: response.ok,
url: endpoint,
requestBody: params,
responseBody: parsedBody,
});
}
if (req.method === "POST" && url.pathname === "/api/vet") {
const result = await vetProject();
return json(res, result.code === 0 ? 200 : 500, result);
}
return false;
}
const page = String.raw`<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>App Dono Modulos - Teste Local</title>
<style>
:root {
color-scheme: light;
font-family: Inter, "Segoe UI", Roboto, Arial, sans-serif;
--ink: #17212f;
--muted: #637186;
--line: #dce5ef;
--surface: #ffffff;
--surface-soft: #f5f8fb;
--brand-navy: #0f2b46;
--brand-blue: #0b74b8;
--brand-cyan: #14a8c9;
--brand-orange: #f28c28;
--brand-green: #22a06b;
--code-bg: #0b1726;
--code-border: #16314f;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background:
linear-gradient(180deg, rgba(11, 116, 184, 0.09), transparent 290px),
#eef4f8;
color: var(--ink);
}
header {
background: var(--brand-navy);
color: white;
border-bottom: 4px solid var(--brand-orange);
box-shadow: 0 16px 40px rgba(15, 43, 70, 0.18);
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
max-width: 1440px;
margin: 0 auto;
padding: 18px 22px;
}
.brand {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.brand-logo {
display: flex;
align-items: center;
width: 170px;
height: 50px;
padding: 7px 9px;
border-radius: 8px;
background: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.72);
box-shadow: 0 10px 24px rgba(3, 18, 33, 0.18);
}
.brand-logo img {
display: block;
width: 100%;
height: auto;
border-radius: 4px;
}
.brand-title {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.brand-title strong {
font-size: 18px;
letter-spacing: 0;
white-space: nowrap;
}
.brand-title span,
.environment {
color: #c9d8e8;
font-size: 12px;
font-weight: 600;
}
.environment {
padding: 8px 10px;
border: 1px solid rgba(255, 255, 255, 0.22);
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
white-space: nowrap;
}
main {
display: grid;
grid-template-columns: minmax(330px, 410px) minmax(0, 1fr);
gap: 18px;
max-width: 1440px;
margin: 0 auto;
padding: 18px;
}
section {
background: var(--surface);
border: 1px solid var(--line);
border-radius: 8px;
box-shadow: 0 12px 34px rgba(15, 43, 70, 0.08);
}
.controls {
padding: 16px;
align-self: start;
}
.output {
padding: 0;
overflow: hidden;
min-width: 0;
}
.panel-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--line);
}
.panel-heading h1 {
margin: 0;
font-size: 18px;
line-height: 1.2;
letter-spacing: 0;
}
.panel-heading span {
color: var(--muted);
font-size: 12px;
font-weight: 600;
text-align: right;
}
.group {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid #edf2f7;
}
.group:first-of-type {
margin-top: 0;
padding-top: 0;
border-top: 0;
}
.group-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 10px;
color: var(--brand-navy);
font-size: 12px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0;
}
.group-title::before {
content: "";
width: 8px;
height: 8px;
border-radius: 2px;
background: var(--brand-orange);
}
label {
display: block;
margin: 10px 0 6px;
color: #435166;
font-size: 12px;
font-weight: 800;
}
select, input, textarea, button {
width: 100%;
font: inherit;
}
select, input, textarea {
border: 1px solid #cbd8e6;
border-radius: 6px;
padding: 10px 11px;
background: white;
color: var(--ink);
outline: none;
transition: border-color 140ms ease, box-shadow 140ms ease, background 140ms ease;
}
select:focus, input:focus, textarea:focus {
border-color: var(--brand-blue);
box-shadow: 0 0 0 3px rgba(11, 116, 184, 0.16);
}
input::placeholder { color: #9aa8b8; }
button {
min-height: 42px;
border: 0;
border-radius: 6px;
padding: 10px 12px;
background: var(--brand-blue);
color: white;
font-weight: 800;
cursor: pointer;
box-shadow: 0 8px 18px rgba(11, 116, 184, 0.2);
transition: transform 120ms ease, filter 120ms ease, box-shadow 120ms ease;
}
button:hover {
filter: brightness(1.05);
transform: translateY(-1px);
box-shadow: 0 10px 22px rgba(11, 116, 184, 0.24);
}
button:active {
transform: translateY(0);
}
button.secondary {
background: #31465e;
box-shadow: 0 8px 18px rgba(49, 70, 94, 0.16);
}
button.accent {
background: var(--brand-orange);
box-shadow: 0 8px 18px rgba(242, 140, 40, 0.22);
}
.row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
margin-top: 10px;
}
.params {
display: grid;
gap: 8px;
padding: 10px;
border-radius: 8px;
background: var(--surface-soft);
border: 1px solid #e3ebf3;
}
.params label {
margin-top: 0;
}
.hint {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
margin-top: 8px;
overflow-wrap: anywhere;
}
.status {
margin-top: 12px;
padding: 10px 12px;
min-height: 38px;
border-radius: 8px;
background: #eef8f3;
border: 1px solid #ccebdd;
color: #22543d;
white-space: pre-wrap;
font-size: 12px;
line-height: 1.45;
}
pre {
height: calc(100vh - 116px);
min-height: 560px;
overflow: auto;
margin: 0;
padding: 18px;
border-top: 4px solid var(--brand-cyan);
background:
linear-gradient(180deg, rgba(20, 168, 201, 0.09), transparent 160px),
var(--code-bg);
color: #d7f7ed;
font-family: "Cascadia Code", Consolas, "Courier New", monospace;
font-size: 13px;
line-height: 1.55;
tab-size: 2;
}
@media (max-width: 980px) {
main { grid-template-columns: 1fr; }
pre { height: auto; min-height: 460px; }
}
@media (max-width: 620px) {
.topbar { align-items: flex-start; flex-direction: column; }
.brand { align-items: flex-start; flex-direction: column; }
.brand-logo { width: 184px; }
main { padding: 12px; }
.row { grid-template-columns: 1fr; }
.environment { white-space: normal; }
}
</style>
</head>
<body>
<header>
<div class="topbar">
<div class="brand">
<div class="brand-logo">
<img src="/assets/logo-davinti.jpg" alt="Davinti solucoes em tecnologia" />
</div>
<div class="brand-title">
<strong>App Dono Modulos</strong>
<span>Teste local de queries e manifesto remoto</span>
</div>
</div>
<div class="environment">Vitruvio manifest tools</div>
</div>
</header>
<main>
<section class="controls">
<div class="panel-heading">
<h1>Modulo de teste</h1>
<span>Local + remoto</span>
</div>
<div class="group">
<p class="group-title">Selecao</p>
<label>Modulo</label>
<select id="module"></select>
<label>Query</label>
<select id="query"></select>
<label>Sistema</label>
<select id="system"></select>
<div class="hint">Os valores sao expressoes SQL cruas. Para testar literal de data, use aspas: '2026-05-08'.</div>
</div>
<div class="group">
<p class="group-title">Autenticacao</p>
<label>Login remoto</label>
<input id="loginUrl" value="https://app-dono.vitruvio.com.br/api/login" />
<div class="row">
<input id="loginEmail" type="email" placeholder="email" autocomplete="username" />
<input id="loginPassword" type="password" placeholder="senha" autocomplete="current-password" />
</div>
<button id="login" class="accent" style="margin-top: 8px;">Gerar bearer</button>
<label>Bearer token</label>
<input id="bearerToken" placeholder="eyJ..." />
<label>Client ID</label>
<input id="clientId" placeholder="019d..." />
</div>
<div class="group">
<p class="group-title">Parametros</p>
<div id="params" class="params"></div>
</div>
<div class="group">
<p class="group-title">Manifesto</p>
<label>Manifesto remoto</label>
<input id="baseUrl" value="https://app-dono.vitruvio.com.br/api" />
<div class="hint" id="remoteUrl"></div>
</div>
<div class="row">
<button id="render">Renderizar SQL</button>
<button id="copy" class="secondary">Copiar</button>
</div>
<div class="row">
<button id="executeManifest">Executar manifesto</button>
<button id="copyResponse" class="secondary">Copiar resposta</button>
</div>
<div class="row">
<button id="vet" class="secondary">Rodar vet</button>
<button id="reload" class="secondary">Recarregar</button>
</div>
<div id="status" class="status"></div>
</section>
<section class="output">
<pre id="sql"></pre>
</section>
</main>
<script>
let modules = [];
const els = {
module: document.getElementById("module"),
query: document.getElementById("query"),
system: document.getElementById("system"),
params: document.getElementById("params"),
sql: document.getElementById("sql"),
status: document.getElementById("status"),
loginUrl: document.getElementById("loginUrl"),
loginEmail: document.getElementById("loginEmail"),
loginPassword: document.getElementById("loginPassword"),
baseUrl: document.getElementById("baseUrl"),
bearerToken: document.getElementById("bearerToken"),
clientId: document.getElementById("clientId"),
remoteUrl: document.getElementById("remoteUrl"),
};
async function api(path, options) {
const res = await fetch(path, options);
const data = await res.json();
if (!res.ok) {
const detail = data.responseBody ? "\\n" + JSON.stringify(data.responseBody, null, 2) : "";
throw new Error((data.error || data.output || "Erro") + detail);
}
return data;
}
function selectedModule() { return modules.find(m => m.id === els.module.value); }
function selectedQuery() { return selectedModule()?.queries.find(q => q.key === els.query.value); }
function fillSelect(select, items, valueFn, labelFn) {
select.innerHTML = "";
for (const item of items) {
const opt = document.createElement("option");
opt.value = valueFn(item);
opt.textContent = labelFn(item);
select.appendChild(opt);
}
}
function refreshQueries() {
const mod = selectedModule();
fillSelect(els.query, mod?.queries || [], q => q.key, q => q.key + " - " + q.name);
fillSelect(els.system, mod?.systems || [], s => s, s => s);
refreshParams();
}
function refreshParams() {
const query = selectedQuery();
els.params.innerHTML = "";
const params = [...(query?.params || []), "ctx_user_companies_for_module", "ctx_user_companies"];
for (const param of params) {
const wrap = document.createElement("div");
const label = document.createElement("label");
const input = document.createElement("input");
label.textContent = param;
input.name = param;
input.value = param.startsWith("ctx_") ? "1,2,3" : ":" + param;
wrap.append(label, input);
els.params.appendChild(wrap);
}
refreshRemoteUrl();
}
function refreshRemoteUrl() {
const baseUrl = String(els.baseUrl.value || "").replace(/\/+$/, "");
els.remoteUrl.textContent = baseUrl + "/api/manifest/modules/" + els.module.value + "/queries/" + els.query.value + "/execute";
}
function collectArgs() {
return Object.fromEntries([...els.params.querySelectorAll("input")].map(input => [input.name, input.value]));
}
function parseManifestValue(value) {
const trimmed = String(value).trim();
if (trimmed.toLowerCase() === "null") return null;
if (trimmed.toLowerCase() === "true") return true;
if (trimmed.toLowerCase() === "false") return false;
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return Number(trimmed);
if ((trimmed.startsWith("'") && trimmed.endsWith("'")) || (trimmed.startsWith('"') && trimmed.endsWith('"'))) {
return trimmed.slice(1, -1);
}
return value;
}
function collectManifestParams() {
const query = selectedQuery();
const allArgs = collectArgs();
return Object.fromEntries((query?.params || []).map(param => [param, parseManifestValue(allArgs[param] || "")]));
}
async function render() {
els.status.textContent = "Renderizando...";
const data = await api("/api/render", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
moduleId: els.module.value,
queryKey: els.query.value,
system: els.system.value,
args: collectArgs(),
}),
});
els.sql.textContent = data.sql || "";
els.status.textContent = "OK";
}
async function login() {
els.status.textContent = "Gerando bearer...";
const data = await api("/api/login", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
loginUrl: els.loginUrl.value,
email: els.loginEmail.value,
senha: els.loginPassword.value,
}),
});
els.bearerToken.value = data.bearerToken || "";
els.status.textContent = "Bearer gerado e preenchido";
}
async function executeManifest() {
els.status.textContent = "Executando manifesto...";
const data = await api("/api/manifest-execute", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
baseUrl: els.baseUrl.value,
bearerToken: els.bearerToken.value,
clientId: els.clientId.value,
moduleId: els.module.value,
queryKey: els.query.value,
params: collectManifestParams(),
}),
});
const body = data.responseBody;
els.sql.textContent = typeof body === "string" ? body : JSON.stringify(body, null, 2);
els.status.textContent = (data.ok ? "Manifesto executado" : "Manifesto retornou erro HTTP " + data.status)
+ "\\nHTTP " + data.status
+ "\\n" + data.url;
}
async function load() {
modules = await api("/api/modules");
fillSelect(els.module, modules, m => m.id, m => m.id + " (" + m.file + ")");
refreshQueries();
const docs = await api("/api/docs");
els.sql.textContent = docs.markdown;
els.status.textContent = "Documentacao carregada";
}
els.module.addEventListener("change", refreshQueries);
els.query.addEventListener("change", refreshParams);
els.baseUrl.addEventListener("input", refreshRemoteUrl);
document.getElementById("render").addEventListener("click", render);
document.getElementById("login").addEventListener("click", login);
document.getElementById("reload").addEventListener("click", load);
document.getElementById("copy").addEventListener("click", async () => {
await navigator.clipboard.writeText(els.sql.textContent);
els.status.textContent = "SQL copiada";
});
document.getElementById("executeManifest").addEventListener("click", executeManifest);
document.getElementById("copyResponse").addEventListener("click", async () => {
await navigator.clipboard.writeText(els.sql.textContent);
els.status.textContent = "Resposta copiada";
});
document.getElementById("vet").addEventListener("click", async () => {
els.status.textContent = "Rodando vet...";
const data = await api("/api/vet", { method: "POST" });
els.status.textContent = data.output;
});
load().catch(err => els.status.textContent = err.message);
</script>
</body>
</html>`;
const server = http.createServer(async (req, res) => {
try {
const handled = await handleApi(req, res);
if (handled !== false) return;
if (req.method === "GET" && (req.url === "/" || req.url?.startsWith("/?"))) {
return html(res, 200, page);
}
return text(res, 404, "Not found");
} catch (error) {
return json(res, 500, { error: error instanceof Error ? error.message : String(error) });
}
});
function listen(port: number) {
server.once("error", (error: NodeJS.ErrnoException) => {
if (error.code === "EADDRINUSE") listen(port + 1);
else throw error;
});
server.listen(port, () => {
const address = server.address();
const actualPort = typeof address === "object" && address ? address.port : port;
console.log(`Module test app: http://localhost:${actualPort}`);
});
}
listen(startPort);