server.mjs•6.3 kB
#!/usr/bin/env node
import http from "node:http";
import process from "node:process";
import path from "node:path";
import { URL, fileURLToPath } from "node:url";
import { config as loadEnv } from "dotenv";
import {
parseNaturalQuery,
searchBizinfo,
formatBizinfoResults,
} from "./lib/bizinfo.js";
const __filename = fileURLToPath(import.meta.url);
loadEnv({ path: path.resolve(process.cwd(), ".env"), override: false });
const PORT = Number.parseInt(process.env.PORT ?? "3000", 10);
const HOST = process.env.HOST ?? "0.0.0.0";
function setCorsHeaders(res) {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET,POST,OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
}
function sendJson(res, statusCode, payload) {
const body = JSON.stringify(payload);
res.statusCode = statusCode;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.setHeader("Content-Length", Buffer.byteLength(body));
setCorsHeaders(res);
res.end(body);
}
function sendError(res, statusCode, message, details) {
sendJson(res, statusCode, {
error: {
message,
...(details ? { details } : {}),
},
});
}
function parseInteger(value, fallback) {
if (value === null || value === undefined || value === "") {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
async function readJsonBody(req) {
return new Promise((resolve, reject) => {
let raw = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
raw += chunk;
if (raw.length > 1_000_000) {
req.destroy();
reject(new Error("요청 본문이 너무 큽니다."));
}
});
req.on("end", () => {
if (!raw.trim()) {
resolve({});
return;
}
try {
resolve(JSON.parse(raw));
} catch (error) {
reject(new Error("JSON 형식이 올바르지 않습니다."));
}
});
req.on("error", (error) => reject(error));
});
}
async function handleSearch(query) {
const params = {
region: query.get("region") ?? undefined,
target: query.get("target") ?? undefined,
keywords: query.get("keywords") ?? undefined,
page: parseInteger(query.get("page"), 1),
pageSize: parseInteger(query.get("page_size") ?? query.get("pageSize"), 20),
};
if (!params.region) {
throw new HttpError(400, "region 쿼리 파라미터가 필요합니다.");
}
const items = await searchBizinfo(params);
return {
params,
count: items.length,
items,
summary: formatBizinfoResults(items, params),
};
}
async function handleSearchPost(body) {
const params = {
region: body?.region,
target: body?.target,
keywords: body?.keywords,
page: parseInteger(body?.page, 1),
pageSize: parseInteger(body?.page_size ?? body?.pageSize, 20),
};
if (!params.region) {
throw new HttpError(400, "region 필드는 필수입니다.");
}
const items = await searchBizinfo(params);
return {
params,
count: items.length,
items,
summary: formatBizinfoResults(items, params),
};
}
async function handleSearchNatural(body) {
const prompt = body?.prompt;
if (!prompt || !prompt.trim()) {
throw new HttpError(400, "prompt 필드는 필수입니다.");
}
const params = parseNaturalQuery(prompt);
if (!params.region) {
throw new HttpError(400, "지역을 인식하지 못했습니다. 예: '서울', '부산', '충청남도'");
}
const items = await searchBizinfo(params);
return {
prompt,
params,
count: items.length,
items,
summary: formatBizinfoResults(items, params),
};
}
class HttpError extends Error {
constructor(statusCode, message) {
super(message);
this.name = "HttpError";
this.statusCode = statusCode;
}
}
async function requestListener(req, res) {
setCorsHeaders(res);
if (req.method === "OPTIONS") {
res.statusCode = 204;
res.end();
return;
}
const origin = req.headers.host ? `http://${req.headers.host}` : "http://localhost";
const url = new URL(req.url ?? "/", origin);
try {
if (req.method === "GET" && url.pathname === "/health") {
sendJson(res, 200, { status: "ok" });
return;
}
if (req.method === "GET" && url.pathname === "/search") {
const payload = await handleSearch(url.searchParams);
sendJson(res, 200, payload);
return;
}
if (req.method === "POST" && url.pathname === "/search") {
const body = await readJsonBody(req);
const payload = await handleSearchPost(body);
sendJson(res, 200, payload);
return;
}
if (req.method === "POST" && url.pathname === "/search-natural") {
const body = await readJsonBody(req);
const payload = await handleSearchNatural(body);
sendJson(res, 200, payload);
return;
}
sendError(res, 404, "요청한 경로를 찾을 수 없습니다.");
} catch (error) {
if (error instanceof HttpError) {
sendError(res, error.statusCode, error.message);
return;
}
console.error(error);
sendError(res, 500, "서버 내부 오류가 발생했습니다.");
}
}
function startServer() {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
requestListener(req, res).catch((error) => {
console.error(error);
if (!res.headersSent) {
sendError(res, 500, "서버 내부 오류가 발생했습니다.");
} else {
res.end();
}
});
});
const onError = (error) => {
server.off("error", onError);
reject(error);
};
server.once("error", onError);
server.listen(PORT, HOST, () => {
server.off("error", onError);
console.log(`Bizinfo HTTP 서버가 http://${HOST}:${PORT} 에서 대기 중입니다.`);
server.on("error", (error) => {
console.error("HTTP 서버 오류:", error);
});
resolve(server);
});
});
}
const isMainModule = process.argv[1] && path.resolve(process.argv[1]) === __filename;
if (isMainModule) {
startServer().catch((error) => {
console.error(error);
process.exit(1);
});
}
export { startServer };