diff --git a/packages/common/package.json b/packages/common/package.json index 23563203..4186c200 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -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" } } diff --git a/packages/common/src/runtime.ts b/packages/common/src/runtime.ts index d1f5e610..ecde226e 100644 --- a/packages/common/src/runtime.ts +++ b/packages/common/src/runtime.ts @@ -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 { + const out: Partial = {}; + + 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 | 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, 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); diff --git a/packages/common/tests/runtime.test.ts b/packages/common/tests/runtime.test.ts new file mode 100644 index 00000000..32221cc1 --- /dev/null +++ b/packages/common/tests/runtime.test.ts @@ -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' }); + }); +}); diff --git a/packages/common/vitest.config.ts b/packages/common/vitest.config.ts new file mode 100644 index 00000000..d807e895 --- /dev/null +++ b/packages/common/vitest.config.ts @@ -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', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3dbee74c..c68c8d21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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