From e7dab0f8fdd45ccf9de286cc0dea2d76407fcd04 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 18 May 2026 11:36:43 +0900 Subject: [PATCH] =?UTF-8?q?adminfront=20/api-keys=20=EC=83=88=EB=A1=9C?= =?UTF-8?q?=EA=B3=A0=EC=B9=A8=20404=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- adminfront/scripts/runtime-mode.sh | 4 +- adminfront/scripts/serve-prod.mjs | 153 ++++++++++++++++++ .../features/api-keys/ApiKeyListPage.test.tsx | 20 +++ .../src/features/api-keys/ApiKeyListPage.tsx | 1 + 4 files changed, 176 insertions(+), 2 deletions(-) create mode 100644 adminfront/scripts/serve-prod.mjs diff --git a/adminfront/scripts/runtime-mode.sh b/adminfront/scripts/runtime-mode.sh index be6c0930..075505c8 100644 --- a/adminfront/scripts/runtime-mode.sh +++ b/adminfront/scripts/runtime-mode.sh @@ -74,8 +74,8 @@ ensure_frontend_dependencies() { ensure_frontend_dependencies if [ "$mode" = "production" ]; then - echo "Running in production mode with Vite preview..." - exec sh -c "npm run build && npm run preview -- --host 0.0.0.0" + echo "Running in production mode with custom static server..." + exec sh -c "npm run build && node ./scripts/serve-prod.mjs" fi echo "Running in development mode..." diff --git a/adminfront/scripts/serve-prod.mjs b/adminfront/scripts/serve-prod.mjs new file mode 100644 index 00000000..804a7052 --- /dev/null +++ b/adminfront/scripts/serve-prod.mjs @@ -0,0 +1,153 @@ +import { createServer } from "node:http"; +import { readFile, stat } from "node:fs/promises"; +import { extname, join, normalize, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = fileURLToPath(new URL("..", import.meta.url)); +const distDir = resolve( + process.env.ADMINFRONT_BUILD_OUT_DIR ?? "/tmp/baron-sso-adminfront-dist", +); +const host = process.env.HOST ?? "0.0.0.0"; +const port = Number(process.env.PORT ?? process.env.ADMINFRONT_PORT ?? 5173); +const backendTarget = new URL( + process.env.API_PROXY_TARGET || "http://localhost:3000", +); + +const contentTypes = { + ".css": "text/css; charset=utf-8", + ".html": "text/html; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".mjs": "application/javascript; charset=utf-8", + ".svg": "image/svg+xml", +}; + +function getContentType(filePath) { + return contentTypes[extname(filePath).toLowerCase()] ?? "application/octet-stream"; +} + +function sendJson(res, statusCode, body) { + res.writeHead(statusCode, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + }); + res.end(JSON.stringify(body)); +} + +function toSafePath(pathname) { + const decoded = decodeURIComponent(pathname); + const relative = decoded.replace(/^\/+/, ""); + const safe = normalize(relative).replace(/^(\.\.(?:[\\/]|$))+/, ""); + return join(distDir, safe); +} + +async function tryReadFile(filePath) { + try { + return await readFile(filePath); + } catch { + return null; + } +} + +async function proxyToBackend(req, res, pathname, search) { + const target = new URL(pathname + search, backendTarget); + const headers = new Headers(); + + for (const [key, value] of Object.entries(req.headers)) { + if (!value) continue; + if (key === "host" || key === "content-length" || key === "connection") { + continue; + } + if (Array.isArray(value)) { + headers.set(key, value.join(", ")); + continue; + } + headers.set(key, value); + } + + const hasBody = !["GET", "HEAD"].includes(req.method ?? "GET"); + const response = await fetch(target, { + method: req.method, + headers, + body: hasBody ? req : undefined, + duplex: hasBody ? "half" : undefined, + }); + + const responseHeaders = new Headers(response.headers); + responseHeaders.delete("content-length"); + responseHeaders.delete("transfer-encoding"); + responseHeaders.delete("connection"); + + res.writeHead(response.status, Object.fromEntries(responseHeaders.entries())); + + if (req.method === "HEAD") { + res.end(); + return; + } + + const arrayBuffer = await response.arrayBuffer(); + res.end(Buffer.from(arrayBuffer)); +} + +async function serveStatic(req, res, pathname) { + const indexPath = join(distDir, "index.html"); + const filePath = toSafePath(pathname); + + let resolvedPath = filePath; + try { + const fileStat = await stat(resolvedPath); + if (fileStat.isDirectory()) { + resolvedPath = join(resolvedPath, "index.html"); + } + } catch { + resolvedPath = indexPath; + } + + let body = await tryReadFile(resolvedPath); + if (!body) { + body = await tryReadFile(indexPath); + resolvedPath = indexPath; + } + + if (!body) { + sendJson(res, 500, { error: "dist_not_found" }); + return; + } + + res.writeHead(200, { + "Content-Type": getContentType(resolvedPath), + "Cache-Control": resolvedPath.endsWith("index.html") + ? "no-cache" + : "public, max-age=31536000, immutable", + }); + + if (req.method === "HEAD") { + res.end(); + return; + } + + res.end(body); +} + +createServer(async (req, res) => { + try { + const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + const { pathname, search } = url; + + if (pathname === "/api" || pathname.startsWith("/api/")) { + await proxyToBackend(req, res, pathname, search); + return; + } + + const normalizedPath = pathname === "/" ? "/index.html" : pathname; + await serveStatic(req, res, normalizedPath); + } catch (error) { + sendJson(res, 500, { + error: "internal_server_error", + message: error instanceof Error ? error.message : String(error), + }); + } +}).listen(port, host, () => { + console.log(`Adminfront production server listening on http://${host}:${port}`); +}); diff --git a/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx b/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx index d2a0d47e..43d43135 100644 --- a/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx +++ b/adminfront/src/features/api-keys/ApiKeyListPage.test.tsx @@ -10,6 +10,10 @@ import { } from "../../lib/adminApi"; import ApiKeyListPage from "./ApiKeyListPage"; +vi.mock("../../lib/i18n", () => ({ + t: (_key: string, fallback?: string) => fallback ?? "", +})); + vi.mock("../../lib/adminApi", () => ({ fetchApiKeys: vi.fn(async () => ({ items: [ @@ -102,4 +106,20 @@ describe("ApiKeyListPage", () => { ).toBeInTheDocument(); expect(fetchApiKeys).toHaveBeenCalled(); }); + + it("refresh button refetches the list without navigation", async () => { + const user = userEvent.setup(); + renderPage(); + + await screen.findByText("client-id-stable"); + + const refreshButton = screen.getByRole("button", { name: /새로고침/ }); + expect(refreshButton).toHaveAttribute("type", "button"); + + await user.click(refreshButton); + + await waitFor(() => { + expect(fetchApiKeys).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/adminfront/src/features/api-keys/ApiKeyListPage.tsx b/adminfront/src/features/api-keys/ApiKeyListPage.tsx index c3efe89e..95931ade 100644 --- a/adminfront/src/features/api-keys/ApiKeyListPage.tsx +++ b/adminfront/src/features/api-keys/ApiKeyListPage.tsx @@ -172,6 +172,7 @@ function ApiKeyListPage() { actions={ <>