// 독립 로컬 서버. 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 base = __dirname, port = Number(process.env.EST_TEST_PORT || 5175); 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-business.js", "native-resources.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( "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-extra.js", "native-settings.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}`) { 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/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" && ["/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, "127.0.0.1", () => console.log(`EST 독립 웹앱: http://127.0.0.1:${port}`)); module.exports = { server, calculate };