feat(common): extend RuntimeConfig for WebUI listener settings

Add webuiHost / webuiTls.enabled / trustProxy to RuntimeConfig as the shared
system-settings model for WebUI Wave A1 (HTTPS + self-config panel). Refactor
load into pure, tested normalizeRuntimeConfig + resolveRuntimeEnvOverrides
(env wins, in-memory only; SNOWLUMA_WEBUI_HOST added alongside the existing
PORT/TRUST_PROXY overrides). loadRuntimeConfig backfills older runtime.json
with the new defaults. Adds vitest to @snowluma/common (was typecheck-only).
This commit is contained in:
DrSmoothl
2026-06-18 14:19:45 +08:00
parent 90d3f7c0d6
commit 77b16faebb
5 changed files with 182 additions and 91 deletions
+4 -2
View File
@@ -45,10 +45,12 @@
}
},
"scripts": {
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"devDependencies": {
"@types/node": "^24.12.2",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "^4.1.4"
}
}
+87 -50
View File
@@ -7,71 +7,81 @@ export interface RuntimeConfig {
* the HookManager. Also overridable at runtime via SNOWLUMA_HOOK_AUTOLOAD.
* Defaults to false; the Docker image flips it on in supervisord.conf. */
hookAutoLoad?: boolean;
/** WebUI listener bind address. '0.0.0.0' = all interfaces (default),
* '127.0.0.1' = localhost-only. Listener-level: change needs a restart. */
webuiHost?: string;
/** WebUI TLS. Cert/key live in config/cert.pem + config/key.pem; this
* only flips serving on/off. Listener-level: change needs a restart. */
webuiTls?: { enabled?: boolean };
/** Raw trust-proxy directive (same domain as SNOWLUMA_WEBUI_TRUST_PROXY),
* consumed by the WebUI's client-ip resolver. '' = trust nobody. */
trustProxy?: string;
}
const CONFIG_DIR = 'config';
const RUNTIME_CONFIG_PATH = path.join(CONFIG_DIR, 'runtime.json');
const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = {
webuiPort: 5099,
hookAutoLoad: false,
};
const DEFAULT_WEBUI_PORT = 5099;
const DEFAULT_WEBUI_HOST = '0.0.0.0';
/**
* Pure on-disk-object → typed config normalization (defaults + validation,
* no fs / no env). Exported for testing; `loadRuntimeConfig` wraps it.
*/
export function normalizeRuntimeConfig(parsed: unknown): RuntimeConfig {
const obj = isObject(parsed) ? parsed : {};
return {
webuiPort: normalizePort(obj.webuiPort ?? DEFAULT_WEBUI_PORT, DEFAULT_WEBUI_PORT),
hookAutoLoad: normalizeBool(obj.hookAutoLoad, false),
webuiHost: normalizeHost(obj.webuiHost),
webuiTls: { enabled: isObject(obj.webuiTls) ? normalizeBool(obj.webuiTls.enabled, false) : false },
trustProxy: typeof obj.trustProxy === 'string' ? obj.trustProxy : '',
};
}
/**
* Pure SNOWLUMA_* env → override patch (no fs). Env wins over runtime.json
* (a trusted launcher like SnowLumaDesktop pins these per-launch without
* rewriting the file). Absent vars produce no key.
*/
export function resolveRuntimeEnvOverrides(env: NodeJS.ProcessEnv): Partial<RuntimeConfig> {
const out: Partial<RuntimeConfig> = {};
const port = parsePortString(env.SNOWLUMA_WEBUI_PORT);
if (port !== undefined) out.webuiPort = port;
const host = env.SNOWLUMA_WEBUI_HOST;
if (typeof host === 'string' && host.trim()) out.webuiHost = host.trim();
// Present-but-"0"/"off" is a deliberate override (trust nobody), distinct
// from "absent" — so gate on key presence, not truthiness.
const tp = env.SNOWLUMA_WEBUI_TRUST_PROXY;
if (typeof tp === 'string') out.trustProxy = tp;
return out;
}
export function loadRuntimeConfig(): RuntimeConfig {
fs.mkdirSync(CONFIG_DIR, { recursive: true });
const loaded = tryLoadRuntimeConfig();
let normalized: RuntimeConfig;
if (!loaded) {
saveRuntimeConfig(DEFAULT_RUNTIME_CONFIG);
normalized = { ...DEFAULT_RUNTIME_CONFIG };
} else {
normalized = {
webuiPort: normalizePort(loaded.webuiPort ?? 5099, 5099),
hookAutoLoad: normalizeBool(loaded.hookAutoLoad, false),
};
if (
normalized.webuiPort !== loaded.webuiPort
|| normalized.hookAutoLoad !== loaded.hookAutoLoad
) {
saveRuntimeConfig(normalized);
}
const parsed = tryLoadRuntimeConfig();
const normalized = normalizeRuntimeConfig(parsed ?? {});
// Persist when the file is absent or when normalization changed/backfilled
// anything (e.g. an older runtime.json lacking webuiHost/webuiTls).
if (parsed === null || !sameRuntimeConfig(parsed, normalized)) {
saveRuntimeConfig(normalized);
}
// SNOWLUMA_WEBUI_PORT overrides the on-disk value at runtime without
// rewriting runtime.json. Lets a trusted launcher (e.g. SnowLumaDesktop)
// pick a free port per-launch while CLI/Docker users continue to drive
// it from runtime.json.
const envPort = envPortOverride();
if (envPort !== undefined) {
normalized = { ...normalized, webuiPort: envPort };
}
return normalized;
// Env overrides apply in-memory only — never written back to disk.
return { ...normalized, ...resolveRuntimeEnvOverrides(process.env) };
}
function envPortOverride(): number | undefined {
const raw = process.env.SNOWLUMA_WEBUI_PORT;
if (!raw || typeof raw !== 'string') return undefined;
const n = Number(raw.trim());
if (!Number.isFinite(n)) return undefined;
const port = Math.trunc(n);
if (port <= 0 || port > 65535) return undefined;
return port;
}
function tryLoadRuntimeConfig(): RuntimeConfig | null {
function tryLoadRuntimeConfig(): Record<string, unknown> | null {
if (!fs.existsSync(RUNTIME_CONFIG_PATH)) return null;
try {
const raw = fs.readFileSync(RUNTIME_CONFIG_PATH, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (!isObject(parsed)) return null;
return {
webuiPort: normalizePort(parsed.webuiPort ?? 5099, 5099),
hookAutoLoad: normalizeBool(parsed.hookAutoLoad, false),
};
const parsed = JSON.parse(fs.readFileSync(RUNTIME_CONFIG_PATH, 'utf8')) as unknown;
return isObject(parsed) ? parsed : null;
} catch {
return null;
}
@@ -81,6 +91,33 @@ function saveRuntimeConfig(config: RuntimeConfig): void {
fs.writeFileSync(RUNTIME_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
}
/** True when the raw on-disk object already matches the normalized config
* on every known field (so we can skip a needless rewrite). */
function sameRuntimeConfig(parsed: Record<string, unknown>, n: RuntimeConfig): boolean {
const parsedTls = isObject(parsed.webuiTls) ? parsed.webuiTls.enabled : undefined;
return (
parsed.webuiPort === n.webuiPort
&& parsed.hookAutoLoad === n.hookAutoLoad
&& parsed.webuiHost === n.webuiHost
&& parsedTls === n.webuiTls?.enabled
&& parsed.trustProxy === n.trustProxy
);
}
function parsePortString(raw: unknown): number | undefined {
if (typeof raw !== 'string' || !raw.trim()) return undefined;
const n = Number(raw.trim());
if (!Number.isFinite(n)) return undefined;
const port = Math.trunc(n);
if (port <= 0 || port > 65535) return undefined;
return port;
}
function normalizeHost(value: unknown): string {
if (typeof value === 'string' && value.trim()) return value.trim();
return DEFAULT_WEBUI_HOST;
}
function normalizePort(value: unknown, fallback: number): number {
if (typeof value === 'number' && Number.isFinite(value)) {
const n = Math.trunc(value);
+76
View File
@@ -0,0 +1,76 @@
import { describe, it, expect } from 'vitest';
import { normalizeRuntimeConfig, resolveRuntimeEnvOverrides } from '../src/runtime';
describe('normalizeRuntimeConfig', () => {
it('applies all defaults for an empty object', () => {
expect(normalizeRuntimeConfig({})).toEqual({
webuiPort: 5099,
hookAutoLoad: false,
webuiHost: '0.0.0.0',
webuiTls: { enabled: false },
trustProxy: '',
});
});
it('passes through valid values', () => {
expect(normalizeRuntimeConfig({
webuiPort: 8080,
hookAutoLoad: true,
webuiHost: '127.0.0.1',
webuiTls: { enabled: true },
trustProxy: '1',
})).toEqual({
webuiPort: 8080,
hookAutoLoad: true,
webuiHost: '127.0.0.1',
webuiTls: { enabled: true },
trustProxy: '1',
});
});
it('falls back on invalid types', () => {
const out = normalizeRuntimeConfig({
webuiPort: 0, // out of range → default
webuiHost: 123, // non-string → default
webuiTls: 'yes', // non-object → default
trustProxy: 5, // non-string → default
});
expect(out.webuiPort).toBe(5099);
expect(out.webuiHost).toBe('0.0.0.0');
expect(out.webuiTls).toEqual({ enabled: false });
expect(out.trustProxy).toBe('');
});
it('coerces webuiTls.enabled loosely and trims a blank host to default', () => {
expect(normalizeRuntimeConfig({ webuiTls: { enabled: 1 } }).webuiTls).toEqual({ enabled: true });
expect(normalizeRuntimeConfig({ webuiHost: ' ' }).webuiHost).toBe('0.0.0.0');
});
it('rejects a non-object input back to full defaults', () => {
expect(normalizeRuntimeConfig(null).webuiPort).toBe(5099);
expect(normalizeRuntimeConfig('nope').webuiHost).toBe('0.0.0.0');
});
});
describe('resolveRuntimeEnvOverrides', () => {
it('returns empty when no SNOWLUMA_* vars are set', () => {
expect(resolveRuntimeEnvOverrides({})).toEqual({});
});
it('parses port / host / trustProxy from env', () => {
expect(resolveRuntimeEnvOverrides({
SNOWLUMA_WEBUI_PORT: '6700',
SNOWLUMA_WEBUI_HOST: '127.0.0.1',
SNOWLUMA_WEBUI_TRUST_PROXY: '1',
})).toEqual({ webuiPort: 6700, webuiHost: '127.0.0.1', trustProxy: '1' });
});
it('ignores an out-of-range / non-numeric port env', () => {
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_PORT: '0' })).toEqual({});
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_PORT: 'abc' })).toEqual({});
});
it('treats trustProxy="0"/"off" as a real override (not absent)', () => {
expect(resolveRuntimeEnvOverrides({ SNOWLUMA_WEBUI_TRUST_PROXY: '0' })).toEqual({ trustProxy: '0' });
});
});
+10
View File
@@ -0,0 +1,10 @@
// Vitest config for @snowluma/common. Pure-TS package (no proton), so a
// plain node-environment runner over tests/ is enough.
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
include: ['tests/**/*.test.ts'],
environment: 'node',
},
});
+5 -39
View File
@@ -81,6 +81,9 @@ importers:
typescript:
specifier: 'catalog:'
version: 6.0.2
vitest:
specifier: ^4.1.4
version: 4.1.4(@types/node@24.12.2)(@vitest/coverage-istanbul@4.1.4)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0))
packages/core:
dependencies:
@@ -4437,7 +4440,7 @@ snapshots:
magicast: 0.5.3
obug: 2.1.1
tinyrainbow: 3.1.0
vitest: 4.1.4(@types/node@25.6.0)(@vitest/coverage-istanbul@4.1.4)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0))
vitest: 4.1.4(@types/node@24.12.2)(@vitest/coverage-istanbul@4.1.4)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0))
transitivePeerDependencies:
- supports-color
@@ -4453,7 +4456,7 @@ snapshots:
obug: 2.1.1
std-env: 4.1.0
tinyrainbow: 3.1.0
vitest: 4.1.4(@types/node@25.6.0)(@vitest/coverage-istanbul@4.1.4)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0))
vitest: 4.1.4(@types/node@24.12.2)(@vitest/coverage-istanbul@4.1.4)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0))
'@vitest/expect@4.1.4':
dependencies:
@@ -4472,14 +4475,6 @@ snapshots:
optionalDependencies:
vite: 8.0.8(@types/node@24.12.2)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0)
'@vitest/mocker@4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0))':
dependencies:
'@vitest/spy': 4.1.4
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0)
'@vitest/pretty-format@4.1.4':
dependencies:
tinyrainbow: 3.1.0
@@ -5777,35 +5772,6 @@ snapshots:
transitivePeerDependencies:
- msw
vitest@4.1.4(@types/node@25.6.0)(@vitest/coverage-istanbul@4.1.4)(@vitest/coverage-v8@4.1.4)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0)):
dependencies:
'@vitest/expect': 4.1.4
'@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0))
'@vitest/pretty-format': 4.1.4
'@vitest/runner': 4.1.4
'@vitest/snapshot': 4.1.4
'@vitest/spy': 4.1.4
'@vitest/utils': 4.1.4
es-module-lexer: 2.0.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.1
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(sass@1.99.0)(tsx@4.21.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.6.0
'@vitest/coverage-istanbul': 4.1.4(vitest@4.1.4)
'@vitest/coverage-v8': 4.1.4(vitest@4.1.4)
transitivePeerDependencies:
- msw
vp-runtime-helper@1.0.10:
dependencies:
'@types/fs-extra': 11.0.4