Commit 945fdbcd authored by nanahira's avatar nanahira

migrate config to configurer

parent b7dfc180
Pipeline #43222 passed with stages
in 1 minute
......@@ -3,7 +3,8 @@ port: 7911
redisUrl: ""
logLevel: info
wsPort: 0
sslPath: ""
enableSsl: 0
sslPath: ./ssl
sslCert: ""
sslKey: ""
trustedProxies:
......
......@@ -16,7 +16,7 @@
"https-proxy-agent": "^7.0.6",
"ipaddr.js": "^2.3.0",
"koishipro-core.js": "^1.3.3",
"nfkit": "^1.0.27",
"nfkit": "^1.0.29",
"p-queue": "6.6.2",
"pino": "^10.3.1",
"pino-pretty": "^13.1.3",
......@@ -5291,9 +5291,9 @@
"license": "MIT"
},
"node_modules/nfkit": {
"version": "1.0.27",
"resolved": "https://registry.npmjs.org/nfkit/-/nfkit-1.0.27.tgz",
"integrity": "sha512-xgsVsp1aHgrkvxWeTb6Zv55kji9Hq3KcFTZqaIDtQmBBgDwcRtmC2o2/BTQ4ZYjzhaopX+uJCFacfRn39xfL2w==",
"version": "1.0.29",
"resolved": "https://registry.npmjs.org/nfkit/-/nfkit-1.0.29.tgz",
"integrity": "sha512-dt3fhhlvAt594N6OxNkFx4VQwrbd86iFR//NuMvAGPevxGTozekF4oT3J76a45K+f06TLS0icxzxXH5Ux2KbqA==",
"license": "MIT"
},
"node_modules/node-int64": {
......
......@@ -12,7 +12,7 @@ import { FeatsModule } from './feats/feats-module';
const core = createAppContext()
.provide(ConfigService, {
merge: ['getConfig'],
merge: ['config'],
})
.provide(Logger, { merge: ['createLogger'] })
.provide(Emitter, { merge: ['dispatch', 'middleware', 'removeMiddleware'] })
......
import { Context } from '../app';
import { Client } from './client';
import * as ipaddr from 'ipaddr.js';
import { convertStringArray } from '../utility/convert-string-array';
import { parseConfigBoolean } from '../utility/parse-config-boolean';
export class IpResolver {
private logger = this.ctx.createLogger('IpResolver');
......@@ -11,9 +9,7 @@ export class IpResolver {
private trustedProxies: Array<[ipaddr.IPv4 | ipaddr.IPv6, number]> = [];
constructor(private ctx: Context) {
const proxies = convertStringArray(
this.ctx.getConfig('TRUSTED_PROXIES', '127.0.0.0/8,::1/128'),
);
const proxies = this.ctx.config.getStringArray('TRUSTED_PROXIES');
for (const trusted of proxies) {
try {
......@@ -111,8 +107,8 @@ export class IpResolver {
client.isLocal = isLocal;
// Increment count for new IP
const noConnectCountLimit = parseConfigBoolean(
this.ctx.getConfig('NO_CONNECT_COUNT_LIMIT', ''),
const noConnectCountLimit = this.ctx.config.getBoolean(
'NO_CONNECT_COUNT_LIMIT',
);
let connectCount = this.connectedIpCount.get(newIp) || 0;
......
......@@ -20,9 +20,10 @@ type LoadedCandidate = {
export class SSLFinder {
constructor(private ctx: Context) {}
private sslPath = this.ctx.getConfig('SSL_PATH', './ssl');
private sslKey = this.ctx.getConfig('SSL_KEY', '');
private sslCert = this.ctx.getConfig('SSL_CERT', '');
private enableSSL = this.ctx.config.getBoolean('ENABLE_SSL');
private sslPath = this.ctx.config.getString('SSL_PATH');
private sslKey = this.ctx.config.getString('SSL_KEY');
private sslCert = this.ctx.config.getString('SSL_CERT');
private logger = this.ctx.createLogger('SSLFinder');
......@@ -36,6 +37,10 @@ export class SSLFinder {
}
findSSL(): TlsOptions | undefined {
if (!this.enableSSL) {
return undefined;
}
// 1) 优先 SSL_CERT + SSL_KEY
const explicit = this.tryExplicit(this.sslCert, this.sslKey);
if (explicit) return { cert: explicit.certBuf, key: explicit.keyBuf };
......
......@@ -10,14 +10,13 @@ export class TcpServer {
constructor(private ctx: Context) {}
async init(): Promise<void> {
const port = this.ctx.getConfig('PORT', '0');
if (!port || port === '0') {
const portNum = this.ctx.config.getInt('PORT');
if (!portNum) {
this.logger.info('PORT not configured, TCP server will not start');
return;
}
const host = this.ctx.getConfig('HOST', '::');
const portNum = parseInt(port, 10);
const host = this.ctx.config.getString('HOST');
this.server = createServer((socket) => {
this.handleConnection(socket);
......
......@@ -18,16 +18,15 @@ export class WsServer {
constructor(private ctx: Context) {}
async init(): Promise<void> {
const wsPort = this.ctx.getConfig('WS_PORT', '0');
if (!wsPort || wsPort === '0') {
const portNum = this.ctx.config.getInt('WS_PORT');
if (!portNum) {
this.logger.info(
'WS_PORT not configured, WebSocket server will not start',
);
return;
}
const host = this.ctx.getConfig('HOST', '::');
const portNum = parseInt(wsPort, 10);
const host = this.ctx.config.getString('HOST');
// Try to get SSL configuration
const sslFinder = this.ctx.get(() => SSLFinder);
......
import yaml from 'yaml';
import * as fs from 'node:fs';
import { DefaultHostinfo } from './room/default-hostinfo';
import { Prettify } from 'nfkit';
import { normalizeConfigByDefaultKeys } from './utility/normalize-config-by-default-keys';
import { Configurer } from 'nfkit';
export type HostinfoOptions = {
[K in keyof typeof DefaultHostinfo as `HOSTINFO_${Uppercase<K>}`]: string;
......@@ -19,8 +18,11 @@ export const defaultConfig = {
LOG_LEVEL: 'info',
// WebSocket port. Format: integer string. '0' means do not open a separate WS port.
WS_PORT: '0',
// Enable SSL for WebSocket server.
// Boolean parse rule (default false): ''/'0'/'false'/'null' => false, otherwise true.
ENABLE_SSL: '0',
// SSL certificate directory path. Format: filesystem path string.
SSL_PATH: '',
SSL_PATH: './ssl',
// SSL certificate file name. Format: file name string.
SSL_CERT: '',
// SSL private key file name. Format: file name string.
......@@ -75,9 +77,9 @@ export const defaultConfig = {
) as HostinfoOptions),
};
export type Config = Prettify<typeof defaultConfig & HostinfoOptions>;
export const configurer = new Configurer(defaultConfig);
export function loadConfig(): Config {
export function loadConfig() {
let readConfig: Record<string, unknown> = {};
try {
const configText = fs.readFileSync('./config.yaml', 'utf-8');
......@@ -89,14 +91,8 @@ export function loadConfig(): Config {
console.error(`Failed to read config: ${e.toString()}`);
}
const normalizedConfig = normalizeConfigByDefaultKeys(
readConfig,
defaultConfig,
);
return {
...defaultConfig,
...normalizedConfig,
...process.env,
};
return configurer.loadConfig({
obj: readConfig,
env: process.env,
});
}
......@@ -4,16 +4,11 @@ import {
YGOProStocErrorMsg,
} from 'ygopro-msg-encode';
import { Context } from '../app';
import { convertNumberArray } from '../utility/convert-string-array';
const YGOPRO_VERSION = 0x1362;
export class ClientVersionCheck {
private altVersions = convertNumberArray(this.ctx.getConfig('ALT_VERSIONS'));
private altVersions = this.ctx.config.getIntArray('ALT_VERSIONS');
version = parseInt(
this.ctx.getConfig('YGOPRO_VERSION', YGOPRO_VERSION.toString()),
);
version = this.ctx.config.getInt('YGOPRO_VERSION');
constructor(private ctx: Context) {
this.ctx.middleware(
......
......@@ -30,7 +30,6 @@ import { RoomManager } from '../room/room-manager';
import { getSpecificFields } from '../utility/metadata';
import { YGOProCtosDisconnect } from '../utility/ygopro-ctos-disconnect';
import { isUpdateDeckPayloadEqual } from '../utility/deck-compare';
import { parseConfigBoolean } from '../utility/parse-config-boolean';
interface DisconnectInfo {
roomName: string;
......@@ -54,14 +53,11 @@ declare module '../client' {
export class Reconnect {
private disconnectList = new Map<string, DisconnectInfo>();
private isLooseReconnectRule = false; // 宽松匹配模式,日后可能配置支持
private reconnectTimeout = parseInt(
this.ctx.getConfig('RECONNECT_TIMEOUT', '') || '180000',
10,
); // 超时时间,单位:毫秒(默认 180000ms = 3分钟)
private reconnectTimeout = this.ctx.config.getInt('RECONNECT_TIMEOUT'); // 超时时间,单位:毫秒(默认 180000ms = 3分钟)
constructor(private ctx: Context) {
// 检查是否启用断线重连(默认启用)
if (!parseConfigBoolean(this.ctx.getConfig('ENABLE_RECONNECT', ''), true)) {
if (!this.ctx.config.getBoolean('ENABLE_RECONNECT')) {
return;
}
......
......@@ -10,7 +10,7 @@ declare module '../room' {
}
export class Welcome {
private welcomeMessage = this.ctx.getConfig('WELCOME');
private welcomeMessage = this.ctx.config.getString('WELCOME');
constructor(private ctx: Context) {
this.ctx.middleware(OnRoomJoin, async (event, client, next) => {
......
......@@ -9,7 +9,7 @@ export class DefaultHostInfoProvider {
const hostinfo = { ...DefaultHostinfo };
for (const key of Object.keys(hostinfo)) {
const configKey = `HOSTINFO_${key.toUpperCase()}`;
const value = this.ctx.getConfig(configKey as any) as string;
const value = this.ctx.config.getString(configKey as any);
if (value) {
const num = Number(value);
if (!isNaN(num)) {
......
......@@ -89,7 +89,6 @@ import { shuffleDecksBySeed } from '../utility/shuffle-decks-by-seed';
import { isUpdateMessage } from '../utility/is-update-message';
import { getMessageIdentifier } from '../utility/get-message-identifier';
import { canIncreaseTime } from '../utility/can-increase-time';
import { parseConfigBoolean } from '../utility/parse-config-boolean';
import { TimerState } from './timer-state';
import { makeArray } from 'aragami/dist/src/utility/utility';
import path from 'path';
......@@ -738,11 +737,11 @@ export class Room {
const deckError = checkDeck(deck, cardReader, {
ot: this.hostinfo.rule,
lflist: this.lflist,
minMain: parseInt(this.ctx.getConfig('DECK_MAIN_MIN', '40')),
maxMain: parseInt(this.ctx.getConfig('DECK_MAIN_MAX', '60')),
maxExtra: parseInt(this.ctx.getConfig('DECK_EXTRA_MAX', '15')),
maxSide: parseInt(this.ctx.getConfig('DECK_SIDE_MAX', '15')),
maxCopies: parseInt(this.ctx.getConfig('DECK_MAX_COPIES', '3')),
minMain: this.ctx.config.getInt('DECK_MAIN_MIN'),
maxMain: this.ctx.config.getInt('DECK_MAIN_MAX'),
maxExtra: this.ctx.config.getInt('DECK_EXTRA_MAX'),
maxSide: this.ctx.config.getInt('DECK_SIDE_MAX'),
maxCopies: this.ctx.config.getInt('DECK_MAX_COPIES'),
});
this.logger.debug(
......@@ -1211,7 +1210,7 @@ export class Room {
'Initializing OCGCoreWorker',
);
const ocgcoreWasmPathConfig = this.ctx.getConfig('OCGCORE_WASM_PATH', '');
const ocgcoreWasmPathConfig = this.ctx.config.getString('OCGCORE_WASM_PATH');
const ocgcoreWasmPath = ocgcoreWasmPathConfig
? path.resolve(process.cwd(), ocgcoreWasmPathConfig)
: undefined;
......@@ -1283,7 +1282,7 @@ export class Room {
this.ocgcore.message$.subscribe((msg) => {
if (
msg.type === OcgcoreMessageType.DebugMessage &&
!parseConfigBoolean(this.ctx.getConfig('OCGCORE_DEBUG_LOG', ''))
!this.ctx.config.getBoolean('OCGCORE_DEBUG_LOG')
) {
return;
}
......
import { Context } from '../app';
import { loadPaths } from '../utility/load-path';
import { DirCardReader, searchYGOProResource } from 'koishipro-core.js';
import { YGOProLFList } from 'ygopro-lflist-encode';
import path from 'node:path';
......@@ -7,11 +6,13 @@ import path from 'node:path';
export class YGOProResourceLoader {
constructor(private ctx: Context) {}
ygoproPaths = loadPaths(this.ctx.getConfig('YGOPRO_PATH')).flatMap((p) => [
path.join(p, 'expansions'),
p,
]);
extraScriptPaths = loadPaths(this.ctx.getConfig('EXTRA_SCRIPT_PATH'));
ygoproPaths = this.ctx.config
.getStringArray('YGOPRO_PATH')
.map((p) => path.resolve(process.cwd(), p))
.flatMap((p) => [path.join(p, 'expansions'), p]);
extraScriptPaths = this.ctx.config
.getStringArray('EXTRA_SCRIPT_PATH')
.map((p) => path.resolve(process.cwd(), p));
private logger = this.ctx.createLogger(this.constructor.name);
......
import * as fs from 'fs';
import yaml from 'yaml';
import { defaultConfig } from '../config';
function toCamelCaseKey(key: string): string {
const lower = key.toLowerCase();
return lower.replace(/_([a-z0-9])/g, (_, ch: string) => ch.toUpperCase());
}
function toTypedValue(value: string): string | number {
if (/^\d+$/.test(value)) {
return Number.parseInt(value, 10);
}
return value;
}
import { configurer } from '../config';
async function main(): Promise<void> {
const exampleConfig = Object.fromEntries(
Object.entries(defaultConfig).map(([key, value]) => {
if (value.includes(',')) {
const items = value.split(',').map((item) => toTypedValue(item));
return [toCamelCaseKey(key), items];
}
return [toCamelCaseKey(key), toTypedValue(value)];
}),
);
const exampleConfig = configurer.generateExampleObject();
const output = yaml.stringify(exampleConfig);
await fs.promises.writeFile('./config.example.yaml', output, 'utf-8');
console.log('Generated config.example.yaml');
......
......@@ -5,7 +5,7 @@ import { ConfigService } from './config';
export class AragamiService {
constructor(private ctx: AppContext) {}
private redisUrl = this.ctx.get(ConfigService).getConfig('REDIS_URL');
private redisUrl = this.ctx.get(ConfigService).config.getString('REDIS_URL');
aragami = new Aragami({
redis: this.redisUrl ? { uri: this.redisUrl } : undefined,
......
import { AppContext } from 'nfkit';
import { Config, loadConfig } from '../config';
import { loadConfig } from '../config';
export class ConfigService {
constructor(private app: AppContext) {}
config = loadConfig();
getConfig<K extends keyof Config, D extends Config[K]>(
key: K,
defaultValue?: D,
): D extends string ? Config[K] | D : Config[K] | undefined {
return (this.config[key] || (defaultValue ?? undefined)) as any;
}
}
......@@ -6,6 +6,6 @@ import { ConfigService } from './config';
export class HttpClient {
constructor(private ctx: AppContext) {}
http = axios.create({
...useProxy(this.ctx.get(() => ConfigService).getConfig('USE_PROXY', '')),
...useProxy(this.ctx.get(() => ConfigService).config.getString('USE_PROXY')),
});
}
......@@ -5,7 +5,7 @@ import { ConfigService } from './config';
export class Logger {
constructor(private ctx: AppContext) {}
private readonly logger = pino({
level: this.ctx.get(() => ConfigService).getConfig('LOG_LEVEL') || 'info',
level: this.ctx.get(() => ConfigService).config.getString('LOG_LEVEL'),
transport: {
target: 'pino-pretty',
options: {
......
export const convertStringArray = (str: string) =>
str
?.split(',')
.map((s) => s.trim())
.filter((s) => s) || [];
export const convertNumberArray = (str: string) =>
str
?.split(',')
.map((s) => parseInt(s.trim()))
.filter((n) => !isNaN(n)) || [];
import path from 'path';
import { convertStringArray } from './convert-string-array';
export const loadPaths = (pathStr: string) =>
convertStringArray(pathStr).map((p) => path.resolve(process.cwd(), p)) || [];
import { normalizeConfigValue } from './normalize-config-value';
function toCamelCaseKey(key: string): string {
const lower = key.toLowerCase();
return lower.replace(/_([a-z0-9])/g, (_, ch: string) => ch.toUpperCase());
}
export function normalizeConfigByDefaultKeys<T extends Record<string, string>>(
readConfig: Record<string, unknown>,
defaultConfig: T,
): Partial<T> {
const normalizedConfig: Partial<T> = {};
for (const key of Object.keys(defaultConfig) as Array<keyof T>) {
const rawKey = key as string;
const camelKey = toCamelCaseKey(rawKey);
const value =
readConfig[camelKey] !== undefined
? readConfig[camelKey]
: readConfig[rawKey];
const normalized = normalizeConfigValue(value);
if (normalized !== undefined) {
normalizedConfig[key] = normalized as T[typeof key];
}
}
return normalizedConfig;
}
function normalizeArrayItem(value: unknown): string {
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number') {
return value.toString();
}
if (typeof value === 'boolean') {
return value ? '1' : '0';
}
return String(value);
}
export function normalizeConfigValue(value: unknown): string | undefined {
if (value === undefined) {
return undefined;
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number') {
return value.toString();
}
if (typeof value === 'boolean') {
return value ? '1' : '0';
}
if (Array.isArray(value)) {
return value.map((item) => normalizeArrayItem(item)).join(',');
}
return String(value);
}
export function parseConfigBoolean(
value: unknown,
defaultValue = false,
): boolean {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (defaultValue) {
return !(
normalized === '0' ||
normalized === 'false' ||
normalized === 'null'
);
}
return !(
normalized === '' ||
normalized === '0' ||
normalized === 'false' ||
normalized === 'null'
);
}
if (value == null) {
return defaultValue;
}
return Boolean(value);
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment