// 독립 로컬 서버. Aislo의 DB·API·설정·패키지에 의존하지 않는다. const http = require("node:http"), fs = require("node:fs"), path = require("node:path"), vm = require("node:vm"), crypto = require("node:crypto"); const { importEstx } = require("./import-estx.cjs"); const exchange = require("./exchange.cjs"); const { exportEstx } = require("./export-estx.cjs"); const jdl = require("./jdl.cjs"); const base = __dirname, port = Number(process.env.EST_TEST_PORT || 5175), host = process.env.EST_TEST_HOST || "100.65.141.15"; const dataDirectory = process.env.EST_TEST_DATA_DIR || path.join(base, "data"); const scripts = [ "native-math.js", "native-formula.js", "native-machine.js", "native-rows.js", "native-text.js", "native-quantity.cjs", "native-wonga.js", "native-engine.js", "native-row-match.js", "native-escalation.js", "native-progress.js", "native-business.js", "native-review.js", "native-resources.js", "native-schedule.js", "native-model.js", "native-verify.js", ]; const engine = vm.createContext({ structuredClone, console }); for (const file of scripts) vm.runInContext(fs.readFileSync(path.join(base, file), "utf8"), engine, { filename: file }); function calculate(project) { engine.input = project; try { return vm.runInContext( "EstProgress.rebuild(input.options?.AutoCalc === false ? EstModel.validate(input) : EstNative.calculate(EstModel.validate(input)))", engine, { timeout: 20000, }, ); } finally { delete engine.input; } } const allowed = new Set([ "index.html", "native.css", "native-app.js", "native-views.js", "native-actions.js", "native-business-actions.js", "native-block-actions.js", "native-issues.js", "native-attachments.js", "native-standard.js", "native-estimate.js", "native-basic-prices.js", "native-extra.js", "native-settings.js", "native-report.js", "native-forms.js", "native-c3r.js", "native-exchange.js", ...scripts, ]); function reply(res, status, data, type = "application/json; charset=utf-8") { res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", }); res.end(type.startsWith("application/json") ? JSON.stringify(data) : data); } async function body(req) { const chunks = []; let length = 0; for await (const chunk of req) { length += chunk.length; if (length > 256 * 1024 * 1024) throw Error("파일은 256MB 이하만 열 수 있습니다."); chunks.push(chunk); } return Buffer.concat(chunks); } function list() { if (!fs.existsSync(dataDirectory)) return []; return fs .readdirSync(dataDirectory) .filter((name) => /^[a-f0-9]{24}\.json$/.test(name)) .map((name) => { const file = path.join(dataDirectory, name), record = JSON.parse(fs.readFileSync(file, "utf8")); return { id: name.slice(0, -5), name: record.name, modified: fs.statSync(file).mtime.toISOString(), }; }) .sort((a, b) => b.modified.localeCompare(a.modified)); } const server = http.createServer(async (req, res) => { try { const origin = req.headers.origin; if ( origin && origin !== `http://127.0.0.1:${port}` && origin !== `http://localhost:${port}` && origin !== `http://${host}:${port}` ) { reply(res, 403, { error: "다른 사이트의 요청은 허용하지 않습니다." }); return; } const url = new URL(req.url, "http://127.0.0.1"); if (req.method === "GET" && url.pathname === "/api/health") { reply(res, 200, { app: "est-independent", schema: "est-web-2" }); return; } if (req.method === "GET" && url.pathname === "/api/sample") { reply(res, 200, JSON.parse(fs.readFileSync(path.join(base, "sample.json"), "utf8"))); return; } if (req.method === "GET" && url.pathname === "/api/print-catalog") { reply(res, 200, JSON.parse(fs.readFileSync(path.join(base, "print-catalog.json"), "utf8"))); return; } if (req.method === "GET" && url.pathname === "/api/parity") { engine.sample = JSON.parse(fs.readFileSync(path.join(base, "sample.json"), "utf8")); engine.expected = JSON.parse(fs.readFileSync(path.join(base, "parity.json"), "utf8")); try { reply( res, 200, vm.runInContext("EstVerification.run(sample,expected)", engine, { timeout: 20000 }), ); } finally { delete engine.sample; delete engine.expected; } return; } if (req.method === "GET" && url.pathname === "/api/projects") { reply(res, 200, list()); return; } if (req.method === "GET" && /^\/api\/projects\/[a-f0-9]{24}$/.test(url.pathname)) { const id = url.pathname.split("/").pop(), file = path.join(dataDirectory, `${id}.json`); if (!fs.existsSync(file)) { reply(res, 404, { error: "저장된 작업 없음" }); return; } reply(res, 200, JSON.parse(fs.readFileSync(file, "utf8"))); return; } if (req.method === "POST" && url.pathname === "/api/import") { const bytes = await body(req), name = String(url.searchParams.get("name") || "ESTX 가져오기").slice(0, 200); reply(res, 200, importEstx(bytes, name)); return; } if (req.method === "POST" && url.pathname === "/api/import-xlsx") { reply(res, 200, exchange.readWorkbook(await body(req))); return; } if (req.method === "POST" && url.pathname === "/api/import-jdl") { const empty = vm.runInContext("EstNative.empty()", engine); reply(res, 200, jdl.markOriginal(calculate(jdl.read(await body(req), empty)))); return; } if (req.method === "POST" && url.pathname === "/api/export-jdl") { const project = calculate(JSON.parse((await body(req)).toString("utf8"))), out = jdl.output(project); reply(res, 200, { data: out.bytes.toString("base64"), warnings: out.warnings }); return; } if (req.method === "POST" && url.pathname === "/api/export-report-xlsx") { const input = JSON.parse((await body(req)).toString("utf8")); if ( !Array.isArray(input.columns) || !input.columns.length || input.columns.some((x) => typeof x !== "string") || new Set(input.columns).size !== input.columns.length || !Array.isArray(input.rows) || input.rows.some( (row) => !Array.isArray(row) || row.length !== input.columns.length || row.some((value) => !["string", "number", "boolean"].includes(typeof value)), ) || (input.detail != null && typeof input.detail !== "string") || !Array.isArray(input.errors) || input.errors.some((x) => typeof x !== "string") ) throw Error("Excel 출력 데이터 형식 오류"); const rows = input.rows.map((row) => Object.fromEntries(input.columns.map((key, index) => [key, row[index]])), ); const tables = { Report: rows }; if (input.detail) tables.Summary = [{ "결과 요약": input.detail }]; if (input.errors.length) tables.Errors = input.errors.map((message) => ({ "계산 오류": message })); reply( res, 200, exchange.workbook({ name: String(input.name || ""), tables }), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ); return; } if (req.method === "POST" && url.pathname === "/api/export-xlsx") { const project = calculate(JSON.parse((await body(req)).toString("utf8"))); reply( res, 200, exchange.workbook(project), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", ); return; } if (req.method === "POST" && url.pathname === "/api/export-estx") { const project = calculate(JSON.parse((await body(req)).toString("utf8"))); reply(res, 200, exportEstx(project), "application/octet-stream"); return; } if (req.method === "POST" && ["/api/calculate", "/api/save"].includes(url.pathname)) { if (!req.headers["content-type"]?.startsWith("application/json")) { reply(res, 415, { error: "JSON 요청이 필요합니다." }); return; } const project = JSON.parse((await body(req)).toString("utf8")); const result = calculate(project); if (url.pathname === "/api/save") { fs.mkdirSync(dataDirectory, { recursive: true }); result.id = /^[a-f0-9]{24}$/.test(project.id || "") ? project.id : crypto.randomBytes(12).toString("hex"); const target = path.join(dataDirectory, `${result.id}.json`), temporary = `${target}.tmp`; fs.writeFileSync(temporary, JSON.stringify(result), "utf8"); fs.renameSync(temporary, target); } reply(res, 200, result); return; } if (req.method === "GET") { const file = url.pathname === "/" ? "index.html" : decodeURIComponent(url.pathname.slice(1)); if (!allowed.has(file)) { reply(res, 404, { error: "파일 없음" }); return; } const type = file.endsWith(".html") ? "text/html; charset=utf-8" : file.endsWith(".css") ? "text/css; charset=utf-8" : "text/javascript; charset=utf-8"; reply(res, 200, fs.readFileSync(path.join(base, file)), type); return; } reply(res, 404, { error: "요청 경로 없음" }); } catch (error) { reply(res, 400, { error: error.message }); } }); server.on("error", (error) => { console.error( error.code === "EADDRINUSE" ? `포트 ${port}가 이미 사용 중입니다. 기존 서버를 확인하세요.` : error.message, ); process.exitCode = 1; }); if (require.main === module) server.listen(port, host, () => console.log(`EST 독립 웹앱: http://${host}:${port}`)); module.exports = { server, calculate };