feat(est): reproduce native tables and calculations

This commit is contained in:
2026-11-05 06:07:05 +09:00
parent 646eab6245
commit 60ccedcd9a
30 changed files with 5249 additions and 22 deletions
+184
View File
@@ -0,0 +1,184 @@
// 독립 로컬 서버. 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-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",
...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 };