Files

280 lines
9.8 KiB
JavaScript

// 독립 로컬 서버. 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 };