270 lines
9.6 KiB
JavaScript
270 lines
9.6 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);
|
|
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-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-issues.js",
|
|
"native-attachments.js",
|
|
"native-standard.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}`) {
|
|
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, "127.0.0.1", () => console.log(`EST 독립 웹앱: http://127.0.0.1:${port}`));
|
|
module.exports = { server, calculate };
|