Enhance server health check functionality and update development script. Added server health check logic in main process, integrated with preload and renderer components, and modified the dev script to include a build step for the main process.

This commit is contained in:
이정수 2026-01-28 12:27:31 +09:00
parent 6e347224d3
commit 9a88d44769
7 changed files with 5410 additions and 23 deletions

1097
docs/endpoints.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -5,7 +5,7 @@
"main": "dist/main/main.js",
"private": true,
"scripts": {
"dev": "concurrently -k \"vite\" \"wait-on http://localhost:5173 && cross-env VITE_DEV_SERVER_URL=http://localhost:5173 electron .\"",
"dev": "yarn build:main && concurrently -k \"vite\" \"wait-on http://localhost:5173 && cross-env NODE_OPTIONS= VITE_DEV_SERVER_URL=http://localhost:5173 electron .\"",
"build:renderer": "vite build",
"build:main": "tsc -p tsconfig.main.json",
"build": "yarn build:renderer && yarn build:main",

View File

@ -1,14 +1,89 @@
import { app, BrowserWindow } from "electron";
import path from "node:path";
import { app, BrowserWindow, ipcMain, net, session } from "electron";
import * as path from "node:path";
const isDev = Boolean(process.env.VITE_DEV_SERVER_URL);
type ServerHealthResult = {
ok: boolean;
status?: number;
latencyMs?: number;
error?: string;
url?: string;
};
// SPT 서버는 기본적으로 self-signed TLS(자체서명 인증서)를 쓰는 경우가 많아서
// 런처에서는 HTTPS + 인증서 예외 처리(certificate exception)를 고려합니다.
const SERVER_BASE_URL = "https://pandoli365.com:5069/";
const SERVER_HEALTHCHECK_PATH = "/launcher/ping";
const SERVER_HEALTHCHECK_TIMEOUT_MS = 2000;
const checkServerHealth = async (): Promise<ServerHealthResult> => {
const startedAt = Date.now();
const url = new URL(SERVER_HEALTHCHECK_PATH, SERVER_BASE_URL).toString();
console.info(`[spt-launcher] healthcheck -> GET ${url}`);
return await new Promise<ServerHealthResult>((resolve) => {
const request = net.request({
method: "GET",
url
});
const timeout = setTimeout(() => {
request.abort();
resolve({
ok: false,
latencyMs: Date.now() - startedAt,
error: "timeout",
url
});
}, SERVER_HEALTHCHECK_TIMEOUT_MS);
request.on("response", (response) => {
// data를 소비(consumption)하지 않으면 연결이 정리되지 않을 수 있어 drain 처리
response.on("data", () => undefined);
response.on("end", () => undefined);
clearTimeout(timeout);
const status = response.statusCode;
console.info(
`[spt-launcher] healthcheck <- ${status} (${Date.now() - startedAt}ms)`
);
resolve({
ok: typeof status === "number" ? status >= 200 && status < 400 : false,
status,
latencyMs: Date.now() - startedAt,
url
});
});
request.on("error", (error) => {
clearTimeout(timeout);
const message = error instanceof Error ? error.message : String(error);
console.warn(
`[spt-launcher] healthcheck !! ${message} (${Date.now() - startedAt}ms)`
);
resolve({
ok: false,
latencyMs: Date.now() - startedAt,
error: message,
url
});
});
request.end();
});
};
const createWindow = () => {
const mainWindow = new BrowserWindow({
width: 1100,
height: 720,
webPreferences: {
preload: path.join(__dirname, "../preload/preload.js")
preload: path.join(__dirname, "../preload/preload.js"),
contextIsolation: true,
nodeIntegration: false
}
});
@ -20,6 +95,38 @@ const createWindow = () => {
};
app.whenReady().then(() => {
const expectedServerUrl = new URL(SERVER_BASE_URL);
const expectedServerOrigin = expectedServerUrl.origin;
const expectedServerHost = expectedServerUrl.hostname;
// `electron.net` 요청은 self-signed TLS에서 막히는 경우가 많아서,
// 특정 호스트(host)만 인증서 검증을 우회(bypass)합니다.
session.defaultSession.setCertificateVerifyProc((request, callback) => {
if (request.hostname === expectedServerHost) {
callback(0);
return;
}
callback(-3);
});
app.on("certificate-error", (event, _webContents, url, _error, _certificate, callback) => {
if (url.startsWith(expectedServerOrigin)) {
event.preventDefault();
callback(true);
return;
}
callback(false);
});
ipcMain.handle("spt:checkServerHealth", async () => {
return await checkServerHealth();
});
// 개발/운영 모두에서 부팅 시 1회 즉시 체크(instant check) 로그를 남겨
// 엔드포인트(endpoint) 확인이 가능하도록 합니다.
void checkServerHealth();
createWindow();
app.on("activate", () => {

View File

@ -1,5 +1,8 @@
import { contextBridge } from "electron";
import { contextBridge, ipcRenderer } from "electron";
console.info("[spt-launcher] preload loaded");
contextBridge.exposeInMainWorld("sptLauncher", {
appName: "SPT Launcher"
appName: "SPT Launcher",
checkServerHealth: () => ipcRenderer.invoke("spt:checkServerHealth")
});

View File

@ -1,38 +1,78 @@
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
type Screen = "serverCheck" | "login" | "signup" | "resetPassword" | "main";
const HEALTHCHECK_INTERVAL_MS = 10000;
const HEALTHCHECK_ENDPOINT_FALLBACK = "https://pandoli365.com:5069/launcher/ping";
const App = () => {
const [screen, setScreen] = useState<Screen>("serverCheck");
const [serverHealthy, setServerHealthy] = useState(true);
const [serverStatusCode, setServerStatusCode] = useState<number | undefined>();
const [serverLatencyMs, setServerLatencyMs] = useState<number | undefined>();
const [serverError, setServerError] = useState<string | undefined>();
const [serverCheckedUrl, setServerCheckedUrl] = useState<string | undefined>();
const [serverCheckInProgress, setServerCheckInProgress] = useState(false);
const [serverCheckedAt, setServerCheckedAt] = useState<number | undefined>();
const [hasSession, setHasSession] = useState(false);
const [syncInProgress, setSyncInProgress] = useState(false);
const [simulateSyncFail, setSimulateSyncFail] = useState(false);
const healthCheckInFlightRef = useRef(false);
const runHealthCheck = useCallback(async () => {
if (healthCheckInFlightRef.current) {
return;
}
healthCheckInFlightRef.current = true;
setServerCheckInProgress(true);
setServerError(undefined);
try {
if (!window.sptLauncher?.checkServerHealth) {
throw new Error("preload(preload) 미로딩: window.sptLauncher가 없습니다.");
}
const result = await window.sptLauncher.checkServerHealth();
setServerHealthy(result.ok);
setServerStatusCode(result.status);
setServerLatencyMs(result.latencyMs);
setServerError(result.error);
setServerCheckedUrl(result.url ?? HEALTHCHECK_ENDPOINT_FALLBACK);
setServerCheckedAt(Date.now());
if (screen === "serverCheck" && result.ok) {
setScreen(hasSession ? "main" : "login");
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setServerHealthy(false);
setServerStatusCode(undefined);
setServerLatencyMs(undefined);
setServerError(message);
setServerCheckedUrl(undefined);
setServerCheckedAt(Date.now());
} finally {
healthCheckInFlightRef.current = false;
setServerCheckInProgress(false);
}
}, [screen, hasSession]);
useEffect(() => {
if (screen !== "serverCheck") {
return;
}
const timer = window.setTimeout(() => {
setServerHealthy(true);
setScreen(hasSession ? "main" : "login");
}, 900);
return () => window.clearTimeout(timer);
}, [screen, hasSession]);
void runHealthCheck();
}, [screen, runHealthCheck]);
useEffect(() => {
if (screen !== "main") {
return;
}
const interval = window.setInterval(() => {
setServerHealthy((current) => current);
}, 5000);
void runHealthCheck();
}, HEALTHCHECK_INTERVAL_MS);
return () => window.clearInterval(interval);
}, [screen]);
}, [runHealthCheck]);
const serverStatusLabel = useMemo(() => {
return serverHealthy ? "정상" : "불가";
@ -92,8 +132,28 @@ const App = () => {
{screen === "serverCheck" && (
<section className="card hero">
<h2> </h2>
<p className="status idle"> ...</p>
<p className="helper"> .</p>
<p className={`status ${serverCheckInProgress ? "idle" : serverHealthy ? "ok" : "blocked"}`}>
{serverCheckInProgress ? "체크 중..." : serverHealthy ? "정상" : "불가"}
</p>
<p className="helper">
{serverHealthy
? "서버 상태 확인 완료. 로그인 화면으로 이동합니다."
: "서버 연결이 불가합니다. 10초마다 자동으로 재시도합니다."}
</p>
<p className="notice">
{serverCheckedUrl ? `Endpoint(endpoint): ${serverCheckedUrl}` : "Endpoint(endpoint): (unknown)"}
{serverStatusCode ? ` · HTTP ${serverStatusCode}` : ""}
{typeof serverLatencyMs === "number" ? ` · ${serverLatencyMs}ms` : ""}
{serverError ? ` · ${serverError}` : ""}
{serverCheckedAt ? ` · ${new Date(serverCheckedAt).toLocaleTimeString()}` : ""}
</p>
{!serverCheckInProgress && !serverHealthy && (
<>
<button type="button" onClick={() => void runHealthCheck()}>
</button>
</>
)}
</section>
)}

View File

@ -3,5 +3,12 @@
interface Window {
sptLauncher: {
appName: string;
checkServerHealth: () => Promise<{
ok: boolean;
status?: number;
latencyMs?: number;
error?: string;
url?: string;
}>;
};
}

4113
yarn.lock Normal file

File diff suppressed because it is too large Load Diff