refactor: load server settings from JSON configuration
This commit is contained in:
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -18,4 +18,4 @@ __pycache__/
|
|||||||
|
|
||||||
# Environment secrets (keep the documented template)
|
# Environment secrets (keep the documented template)
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
/.config.json
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ OSM 건물의 외곽선·높이·층수와 OSM 대조 기반 선별을 제거했
|
|||||||
|
|
||||||
관악산 채널 1은 최대 100명을 수용합니다. Node.js + Socket.IO로 이동·감정표현·우천 장비를 공유하고 MongoDB에는 접속 기록만 저장합니다. `npm run build` 후 `npm start`로 실행합니다. 기본 주소는 `http://127.0.0.1:3000`입니다.
|
관악산 채널 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)
|
## 모바일·웹 몰입형 UI (2026-10-30)
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,21 @@ Node.js 22.18 이상. 단일 프로세스에서 `gwanaksan` 장소의 `gwanaksan
|
|||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm ci
|
npm ci
|
||||||
# 인증이 필요하거나 주소/포트를 바꿀 때 .env.example을 .env로 복사해 편집
|
Copy-Item .config.example.json .config.json
|
||||||
|
# .config.json에서 DB 주소와 허용 도메인을 편집
|
||||||
npm run build
|
npm run build
|
||||||
npm start
|
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`는 프런트 미리보기이며 공동 방 서버를 대신하지 않는다.
|
개발 시 별도 터미널에서 `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 장애로 퇴장 기록을 남기지 못하면 `leftAt`이 null로 남을 수 있다. 이는 현재 접속 중이라는 증거가 아니다. 실제 정원은 메모리에서 계산하며 `lastSeenAt`은 마지막 확인 시각일 뿐 정확한 퇴장 시각이 아니다. 로그 무손실 보장을 위한 디스크 큐와 자동 복구 작업은 포함하지 않는다.
|
||||||
|
|
||||||
DB 연결 실패 시 서버 시작을 중단한다. 실행 중 기록 저장 실패 시 `/health`는 503이고 신규 입장은 막으며 기존 참여자의 이동은 계속 처리한다. 원인을 해결한 후 서버를 재시작한다. 임의로 메모리 저장소로 전환하지 않는다. 개발·테스트에서만 `VISIT_STORE=memory`를 명시하면 기록을 영구 저장하지 않는 모드로 실행한다.
|
DB 연결 실패 시 서버 시작을 중단한다. 실행 중 기록 저장 실패 시 `/health`는 503이고 신규 입장은 막으며 기존 참여자의 이동은 계속 처리한다. 원인을 해결한 후 서버를 재시작한다. 임의로 메모리 저장소로 전환하지 않는다. 개발·테스트에서만 `visitStore=memory`를 명시하면 기록을 영구 저장하지 않는 모드로 실행한다.
|
||||||
|
|
||||||
## 검증
|
## 검증
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -15,11 +15,11 @@
|
|||||||
"prepare:data-notices": "node scripts/prepare-data-notices.ts",
|
"prepare:data-notices": "node scripts/prepare-data-notices.ts",
|
||||||
"prebuild": "npm run check:buildings && npm run check:summit && npm run prepare:data-notices",
|
"prebuild": "npm run check:buildings && npm run check:summit && npm run prepare:data-notices",
|
||||||
"build": "tsc --noEmit && tsc -p tsconfig.server.json && vite build",
|
"build": "tsc --noEmit && tsc -p tsconfig.server.json && vite build",
|
||||||
"dev:server": "node --env-file-if-exists=.env --watch server/index.ts",
|
"dev:server": "node --watch server/index.ts",
|
||||||
"start": "node --env-file-if-exists=.env server/index.ts",
|
"start": "node server/index.ts",
|
||||||
"check:server": "tsc -p tsconfig.server.json",
|
"check:server": "tsc -p tsconfig.server.json",
|
||||||
"test:load": "node scripts/load-multiplayer.ts",
|
"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",
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
"test": "node --test tests/*.test.ts",
|
"test": "node --test tests/*.test.ts",
|
||||||
"verify:solar": "node tests/solar-report.ts"
|
"verify:solar": "node tests/solar-report.ts"
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import assert from 'node:assert/strict';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { MongoClient } from 'mongodb';
|
import { MongoClient } from 'mongodb';
|
||||||
import { MongoVisitStore } from '../server/visits.ts';
|
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 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 });
|
const reader = new MongoClient(uri, { serverSelectionTimeoutMS: 5000 });
|
||||||
let store: MongoVisitStore | undefined;
|
let store: MongoVisitStore | undefined;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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
@@ -1,24 +1,23 @@
|
|||||||
import { createApp } from './app.ts';
|
import { createApp } from './app.ts';
|
||||||
|
import { loadServerConfig } from './config.ts';
|
||||||
import { MemoryVisitStore, MongoVisitStore, type VisitStore } from './visits.ts';
|
import { MemoryVisitStore, MongoVisitStore, type VisitStore } from './visits.ts';
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const port = Number(process.env.PORT ?? 3000);
|
const config = loadServerConfig();
|
||||||
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');
|
|
||||||
let visitStore: VisitStore;
|
let visitStore: VisitStore;
|
||||||
if (process.env.VISIT_STORE === 'memory') {
|
if (config.visitStore === 'memory') {
|
||||||
console.warn('VISIT_STORE=memory: visit history is volatile; development/tests only.');
|
console.warn('visitStore=memory: visit history is volatile; development/tests only.');
|
||||||
visitStore = new MemoryVisitStore();
|
visitStore = new MemoryVisitStore();
|
||||||
} else {
|
} 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,
|
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; }); };
|
const stop = () => { void app.close().catch(error => { console.error('Shutdown failed', error); process.exitCode = 1; }); };
|
||||||
process.once('SIGINT', stop); process.once('SIGTERM', stop);
|
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.once('error', error => { console.error('HTTP server failed', error); process.exitCode = 1; stop(); });
|
||||||
app.httpServer.listen(port, process.env.HOST ?? '127.0.0.1', () => {
|
app.httpServer.listen(config.port, config.host, () => {
|
||||||
console.log(`Sunrise single-instance server listening on ${process.env.HOST ?? '127.0.0.1'}:${(app.httpServer.address() as { port: number }).port}`);
|
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; });
|
void main().catch(error => { console.error('Server startup failed; admission disabled', error); process.exitCode = 1; });
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user