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:
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
Generated
+5
-39
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user