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:
parent
6e347224d3
commit
9a88d44769
File diff suppressed because it is too large
Load Diff
|
|
@ -5,7 +5,7 @@
|
||||||
"main": "dist/main/main.js",
|
"main": "dist/main/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"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:renderer": "vite build",
|
||||||
"build:main": "tsc -p tsconfig.main.json",
|
"build:main": "tsc -p tsconfig.main.json",
|
||||||
"build": "yarn build:renderer && yarn build:main",
|
"build": "yarn build:renderer && yarn build:main",
|
||||||
|
|
|
||||||
113
src/main/main.ts
113
src/main/main.ts
|
|
@ -1,14 +1,89 @@
|
||||||
import { app, BrowserWindow } from "electron";
|
import { app, BrowserWindow, ipcMain, net, session } from "electron";
|
||||||
import path from "node:path";
|
import * as path from "node:path";
|
||||||
|
|
||||||
const isDev = Boolean(process.env.VITE_DEV_SERVER_URL);
|
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 createWindow = () => {
|
||||||
const mainWindow = new BrowserWindow({
|
const mainWindow = new BrowserWindow({
|
||||||
width: 1100,
|
width: 1100,
|
||||||
height: 720,
|
height: 720,
|
||||||
webPreferences: {
|
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(() => {
|
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();
|
createWindow();
|
||||||
|
|
||||||
app.on("activate", () => {
|
app.on("activate", () => {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,8 @@
|
||||||
import { contextBridge } from "electron";
|
import { contextBridge, ipcRenderer } from "electron";
|
||||||
|
|
||||||
|
console.info("[spt-launcher] preload loaded");
|
||||||
|
|
||||||
contextBridge.exposeInMainWorld("sptLauncher", {
|
contextBridge.exposeInMainWorld("sptLauncher", {
|
||||||
appName: "SPT Launcher"
|
appName: "SPT Launcher",
|
||||||
|
checkServerHealth: () => ipcRenderer.invoke("spt:checkServerHealth")
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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";
|
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 App = () => {
|
||||||
const [screen, setScreen] = useState<Screen>("serverCheck");
|
const [screen, setScreen] = useState<Screen>("serverCheck");
|
||||||
const [serverHealthy, setServerHealthy] = useState(true);
|
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 [hasSession, setHasSession] = useState(false);
|
||||||
const [syncInProgress, setSyncInProgress] = useState(false);
|
const [syncInProgress, setSyncInProgress] = useState(false);
|
||||||
const [simulateSyncFail, setSimulateSyncFail] = 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(() => {
|
useEffect(() => {
|
||||||
if (screen !== "serverCheck") {
|
if (screen !== "serverCheck") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const timer = window.setTimeout(() => {
|
void runHealthCheck();
|
||||||
setServerHealthy(true);
|
}, [screen, runHealthCheck]);
|
||||||
setScreen(hasSession ? "main" : "login");
|
|
||||||
}, 900);
|
|
||||||
|
|
||||||
return () => window.clearTimeout(timer);
|
|
||||||
}, [screen, hasSession]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (screen !== "main") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const interval = window.setInterval(() => {
|
const interval = window.setInterval(() => {
|
||||||
setServerHealthy((current) => current);
|
void runHealthCheck();
|
||||||
}, 5000);
|
}, HEALTHCHECK_INTERVAL_MS);
|
||||||
|
|
||||||
return () => window.clearInterval(interval);
|
return () => window.clearInterval(interval);
|
||||||
}, [screen]);
|
}, [runHealthCheck]);
|
||||||
|
|
||||||
const serverStatusLabel = useMemo(() => {
|
const serverStatusLabel = useMemo(() => {
|
||||||
return serverHealthy ? "정상" : "불가";
|
return serverHealthy ? "정상" : "불가";
|
||||||
|
|
@ -92,8 +132,28 @@ const App = () => {
|
||||||
{screen === "serverCheck" && (
|
{screen === "serverCheck" && (
|
||||||
<section className="card hero">
|
<section className="card hero">
|
||||||
<h2>서버 상태 확인 중</h2>
|
<h2>서버 상태 확인 중</h2>
|
||||||
<p className="status idle">체크 중...</p>
|
<p className={`status ${serverCheckInProgress ? "idle" : serverHealthy ? "ok" : "blocked"}`}>
|
||||||
<p className="helper">서버 상태 확인 후 로그인 화면으로 이동합니다.</p>
|
{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>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,12 @@
|
||||||
interface Window {
|
interface Window {
|
||||||
sptLauncher: {
|
sptLauncher: {
|
||||||
appName: string;
|
appName: string;
|
||||||
|
checkServerHealth: () => Promise<{
|
||||||
|
ok: boolean;
|
||||||
|
status?: number;
|
||||||
|
latencyMs?: number;
|
||||||
|
error?: string;
|
||||||
|
url?: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue