feat(est): reproduce native tables and calculations
This commit is contained in:
@@ -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 };
|
||||
Reference in New Issue
Block a user