refactor: load server settings from JSON configuration

This commit is contained in:
2026-10-30 16:55:11 +09:00
parent 31d6eac4c2
commit 28a45ff8e3
10 changed files with 104 additions and 28 deletions
+13
View File
@@ -0,0 +1,13 @@
{
"host": "127.0.0.1",
"port": 3000,
"mongoUri": "mongodb://172.16.0.7:27017",
"mongoDatabase": "sunrise",
"visitStore": "mongo",
"allowedOrigins": [
"http://127.0.0.1:3000",
"http://localhost:3000",
"http://127.0.0.1:5173",
"http://localhost:5173"
]
}
-8
View File
@@ -1,8 +0,0 @@
# Copy to .env. Never put MongoDB credentials in VITE_* variables.
MONGODB_URI=mongodb://172.16.0.7:27017
MONGODB_DATABASE=sunrise
HOST=127.0.0.1
PORT=3000
ALLOWED_ORIGINS=http://127.0.0.1:3000,http://localhost:3000,http://127.0.0.1:5173,http://localhost:5173
# Explicit temporary development mode only; visit records are not persisted:
# VISIT_STORE=memory
+1 -1
View File
@@ -18,4 +18,4 @@ __pycache__/
# Environment secrets (keep the documented template)
.env.*
!.env.example
/.config.json
+1 -1
View File
@@ -15,7 +15,7 @@ OSM 건물의 외곽선·높이·층수와 OSM 대조 기반 선별을 제거했
관악산 채널 1은 최대 100명을 수용합니다. Node.js + Socket.IO로 이동·감정표현·우천 장비를 공유하고 MongoDB에는 접속 기록만 저장합니다. `npm run build` 후 `npm start`로 실행합니다. 기본 주소는 `http://127.0.0.1:3000`입니다.
환경설정·개발 실행·검증 방법과 운영 한계는 [서버 설명](docs/multiplayer-server.md)을 참고하세요. 기본 MongoDB는 `172.16.0.7:27017`의 `sunrise.visitSessions`이며 `.env.example`에서 변경할 수 있습니다.
환경설정·개발 실행·검증 방법과 운영 한계는 [서버 설명](docs/multiplayer-server.md)을 참고하세요. 기본 MongoDB는 `172.16.0.7:27017`의 `sunrise.visitSessions`이며 `.config.example.json`을 `.config.json`으로 복사한 뒤 변경합니다.
## 모바일·웹 몰입형 UI (2026-10-30)
+9 -4
View File
@@ -6,16 +6,21 @@ Node.js 22.18 이상. 단일 프로세스에서 `gwanaksan` 장소의 `gwanaksan
```powershell
npm ci
# 인증이 필요하거나 주소/포트를 바꿀 때 .env.example을 .env로 복사해 편집
Copy-Item .config.example.json .config.json
# .config.json에서 DB 주소와 허용 도메인을 편집
npm run build
npm start
```
기본 접속: `http://127.0.0.1:3000`. 기본 DB: `mongodb://172.16.0.7:27017`, DB 이름 `sunrise`, 컬렉션 `visitSessions`. 인증이 필요하면 `MONGODB_URI`에 계정과 `authSource` 등 해당 서버 설정을 지정한다. URI를 브라우저용 `VITE_*` 변수에 넣지 않는다. `.env`는 버전 관리에서 제외한다.
예제 설정의 접속 주소: `http://127.0.0.1:3000`. DB: `mongodb://172.16.0.7:27017`, DB 이름 `sunrise`, 컬렉션 `visitSessions`. 인증이 필요하면 `mongoUri`에 계정과 `authSource` 등 해당 서버 설정을 지정한다. URI를 브라우저용 `VITE_*` 변수에 넣지 않는다. `.config.json`은 버전 관리에서 제외한다.
Linux에서는 `cp .config.example.json .config.json`으로 복사한다. 설정 파일은 작업 디렉터리와 관계없이 프로젝트 루트에서 읽으며 모든 예제 항목을 유지한다. 파일이 없거나 JSON 또는 항목 형식이 잘못되면 서버 시작을 중단한다. 서버 설정은 `.env`나 환경변수에서 읽지 않는다. 수정 후 서버를 재시작한다. `npm run verify:mongo`도 같은 파일의 `mongoUri`를 사용한다.
운영 환경에서는 `allowedOrigins`를 실제 접속 주소의 배열(예: `["https://sunrise.example.com"]`)로 바꾸고 `visitStore`는 `"mongo"`로 유지한다. JSON에는 주석을 넣지 않는다.
개발 시 별도 터미널에서 `npm run dev:server`와 `npm run dev`를 실행한다. Vite의 `/socket.io`, `/health`, `/api` 요청은 3000 포트 서버로 전달된다. `npm run preview`는 프런트 미리보기이며 공동 방 서버를 대신하지 않는다.
`HOST` 기본값은 `127.0.0.1`이라 현재 컴퓨터에서만 접속할 수 있다. LAN/운영 배포 시 실제 바인딩 주소, `PORT`, `ALLOWED_ORIGINS`를 지정하고 TLS를 제공하는 프록시와 방화벽을 구성한다. 이번 구현은 외부 호스팅 배포까지 수행하지 않는다. 동일 방/정원을 여러 Node 프로세스에서 따로 실행하지 않는다. 프로세스별 메모리는 공유되지 않으며 현재는 단일 인스턴스 구성이다.
`host` 기본값은 `127.0.0.1`이라 현재 컴퓨터에서만 접속할 수 있다. LAN/운영 배포 시 실제 바인딩 주소, `port`, `allowedOrigins`를 지정하고 TLS를 제공하는 프록시와 방화벽을 구성한다. 이번 구현은 외부 호스팅 배포까지 수행하지 않는다. 동일 방/정원을 여러 Node 프로세스에서 따로 실행하지 않는다. 프로세스별 메모리는 공유되지 않으며 현재는 단일 인스턴스 구성이다.
## 동작
@@ -36,7 +41,7 @@ npm start
서버 시작 시 과거 기록을 자동으로 변경하지 않는다. 강제 종료 또는 DB 장애로 퇴장 기록을 남기지 못하면 `leftAt`이 null로 남을 수 있다. 이는 현재 접속 중이라는 증거가 아니다. 실제 정원은 메모리에서 계산하며 `lastSeenAt`은 마지막 확인 시각일 뿐 정확한 퇴장 시각이 아니다. 로그 무손실 보장을 위한 디스크 큐와 자동 복구 작업은 포함하지 않는다.
DB 연결 실패 시 서버 시작을 중단한다. 실행 중 기록 저장 실패 시 `/health`는 503이고 신규 입장은 막으며 기존 참여자의 이동은 계속 처리한다. 원인을 해결한 후 서버를 재시작한다. 임의로 메모리 저장소로 전환하지 않는다. 개발·테스트에서만 `VISIT_STORE=memory`를 명시하면 기록을 영구 저장하지 않는 모드로 실행한다.
DB 연결 실패 시 서버 시작을 중단한다. 실행 중 기록 저장 실패 시 `/health`는 503이고 신규 입장은 막으며 기존 참여자의 이동은 계속 처리한다. 원인을 해결한 후 서버를 재시작한다. 임의로 메모리 저장소로 전환하지 않는다. 개발·테스트에서만 `visitStore=memory`를 명시하면 기록을 영구 저장하지 않는 모드로 실행한다.
## 검증
+3 -3
View File
@@ -15,11 +15,11 @@
"prepare:data-notices": "node scripts/prepare-data-notices.ts",
"prebuild": "npm run check:buildings && npm run check:summit && npm run prepare:data-notices",
"build": "tsc --noEmit && tsc -p tsconfig.server.json && vite build",
"dev:server": "node --env-file-if-exists=.env --watch server/index.ts",
"start": "node --env-file-if-exists=.env server/index.ts",
"dev:server": "node --watch server/index.ts",
"start": "node server/index.ts",
"check:server": "tsc -p tsconfig.server.json",
"test:load": "node scripts/load-multiplayer.ts",
"verify:mongo": "node --env-file-if-exists=.env scripts/verify-multiplayer-mongo.ts",
"verify:mongo": "node scripts/verify-multiplayer-mongo.ts",
"preview": "vite preview --host 127.0.0.1",
"test": "node --test tests/*.test.ts",
"verify:solar": "node tests/solar-report.ts"
+3 -2
View File
@@ -2,10 +2,11 @@ import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { MongoClient } from 'mongodb';
import { MongoVisitStore } from '../server/visits.ts';
import { loadServerConfig } from '../server/config.ts';
// Isolated disposable database: never uses the application's MONGODB_DATABASE.
// Isolated disposable database: never uses the application's mongoDatabase.
const database = `sunrise_verify_${randomUUID().replaceAll('-', '')}`;
const uri = process.env.MONGODB_URI ?? 'mongodb://172.16.0.7:27017';
const uri = loadServerConfig().mongoUri;
const reader = new MongoClient(uri, { serverSelectionTimeoutMS: 5000 });
let store: MongoVisitStore | undefined;
try {
+33
View File
@@ -0,0 +1,33 @@
import { readFileSync } from 'node:fs';
export type ServerConfig = {
host: string;
port: number;
mongoUri: string;
mongoDatabase: string;
visitStore: 'mongo' | 'memory';
allowedOrigins: string[];
};
export function loadServerConfig(path: string | URL = new URL('../.config.json', import.meta.url)): ServerConfig {
let contents: string;
try { contents = readFileSync(path, 'utf8'); }
catch { throw new Error('Cannot read .config.json; copy .config.example.json to .config.json and configure the server.'); }
let value: unknown;
try { value = JSON.parse(contents); }
catch { throw new Error('Invalid JSON in .config.json'); }
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid .config.json: expected an object');
const config = value as Record<string, unknown>;
const nonempty = (value: unknown): value is string => typeof value === 'string' && value.trim().length > 0;
for (const field of ['host', 'mongoUri', 'mongoDatabase']) {
if (!nonempty(config[field])) throw new Error(`Invalid .config.json field: ${field}`);
}
if (typeof config.port !== 'number' || !Number.isInteger(config.port) || config.port < 0 || config.port > 65535) {
throw new Error('Invalid .config.json field: port');
}
if (config.visitStore !== 'mongo' && config.visitStore !== 'memory') throw new Error('Invalid .config.json field: visitStore');
if (!Array.isArray(config.allowedOrigins) || !config.allowedOrigins.length || !config.allowedOrigins.every(nonempty)) {
throw new Error('Invalid .config.json field: allowedOrigins');
}
return config as ServerConfig;
}
+8 -9
View File
@@ -1,24 +1,23 @@
import { createApp } from './app.ts';
import { loadServerConfig } from './config.ts';
import { MemoryVisitStore, MongoVisitStore, type VisitStore } from './visits.ts';
async function main() {
const port = Number(process.env.PORT ?? 3000);
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error('Invalid PORT');
if (process.env.VISIT_STORE && !['memory', 'mongo'].includes(process.env.VISIT_STORE)) throw new Error('Invalid VISIT_STORE');
const config = loadServerConfig();
let visitStore: VisitStore;
if (process.env.VISIT_STORE === 'memory') {
console.warn('VISIT_STORE=memory: visit history is volatile; development/tests only.');
if (config.visitStore === 'memory') {
console.warn('visitStore=memory: visit history is volatile; development/tests only.');
visitStore = new MemoryVisitStore();
} else {
visitStore = await MongoVisitStore.connect(process.env.MONGODB_URI ?? 'mongodb://172.16.0.7:27017', process.env.MONGODB_DATABASE ?? 'sunrise');
visitStore = await MongoVisitStore.connect(config.mongoUri, config.mongoDatabase);
}
const app = createApp({ visitStore,
allowedOrigins: process.env.ALLOWED_ORIGINS?.split(',').map(origin => origin.trim()).filter(Boolean) });
allowedOrigins: config.allowedOrigins });
const stop = () => { void app.close().catch(error => { console.error('Shutdown failed', error); process.exitCode = 1; }); };
process.once('SIGINT', stop); process.once('SIGTERM', stop);
app.httpServer.once('error', error => { console.error('HTTP server failed', error); process.exitCode = 1; stop(); });
app.httpServer.listen(port, process.env.HOST ?? '127.0.0.1', () => {
console.log(`Sunrise single-instance server listening on ${process.env.HOST ?? '127.0.0.1'}:${(app.httpServer.address() as { port: number }).port}`);
app.httpServer.listen(config.port, config.host, () => {
console.log(`Sunrise single-instance server listening on ${config.host}:${(app.httpServer.address() as { port: number }).port}`);
});
}
void main().catch(error => { console.error('Server startup failed; admission disabled', error); process.exitCode = 1; });
+33
View File
@@ -0,0 +1,33 @@
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import { loadServerConfig } from '../server/config.ts';
test('server and Mongo verifier can load the documented JSON configuration', () => {
const example = new URL('../.config.example.json', import.meta.url);
const config = loadServerConfig(example);
assert.equal(config.port, 3000);
assert.equal(config.visitStore, 'mongo');
assert.equal(config.mongoDatabase, 'sunrise');
assert.ok(config.allowedOrigins.includes('http://127.0.0.1:3000'));
});
test('missing or invalid configuration fails without exposing its contents', () => {
const directory = mkdtempSync(join(tmpdir(), 'sunrise-config-'));
const path = join(directory, '.config.json');
try {
assert.throws(() => loadServerConfig(path), /Cannot read/);
writeFileSync(path, '{"mongoUri":"secret",');
assert.throws(() => loadServerConfig(path), /^Error: Invalid JSON in .config.json$/);
const example = JSON.parse(readFileSync(new URL('../.config.example.json', import.meta.url), 'utf8'));
for (const override of [{ port: '3000' }, { port: 65536 }, { host: '' },
{ allowedOrigins: 'https://example.com' }, { visitStore: 'invalid' }, { mongoUri: null }]) {
writeFileSync(path, JSON.stringify({ ...example, ...override }));
assert.throws(() => loadServerConfig(path), /Invalid .config.json field/);
}
} finally {
rmSync(directory, { recursive: true, force: true });
}
});