import { existsSync, readFileSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join, resolve } from "node:path"; export interface PiWebConfig { host?: string; port?: number; allowedHosts?: string[] | true; } export interface LoadedPiWebConfig { path: string; exists: boolean; config: PiWebConfig; } interface LoadOptions { env?: NodeJS.ProcessEnv; cwd?: string; } export function defaultPiWebConfigPath(env: NodeJS.ProcessEnv = process.env): string { const xdgConfigHome = env["XDG_CONFIG_HOME"]; return join(xdgConfigHome !== undefined && xdgConfigHome !== "" ? xdgConfigHome : join(homedir(), ".config"), "pi-web", "config.json"); } export function piWebConfigPath(env: NodeJS.ProcessEnv = process.env, cwd = process.cwd()): string { const configured = env["PI_WEB_CONFIG"]; if (configured === undefined || configured === "") return defaultPiWebConfigPath(env); return resolve(cwd, configured); } export function loadPiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig { const env = options.env ?? process.env; const path = piWebConfigPath(env, options.cwd ?? process.cwd()); if (!existsSync(path)) return { path, exists: false, config: {} }; const parsed: unknown = JSON.parse(readFileSync(path, "utf8")); if (!isRecord(parsed)) throw new Error(`Pi Web config must be a JSON object: ${path}`); return { path, exists: true, config: parsePiWebConfig(parsed, path) }; } export function effectivePiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig { const loaded = loadPiWebConfig(options); const env = options.env ?? process.env; const host = env["PI_WEB_HOST"]; const port = env["PI_WEB_PORT"] ?? env["PORT"]; const allowedHosts = env["PI_WEB_ALLOWED_HOSTS"]; return { ...loaded, config: { ...loaded.config, ...(host !== undefined && host !== "" ? { host } : {}), ...(port !== undefined && port !== "" ? { port: parsePort(port, "PI_WEB_PORT") } : {}), ...(allowedHosts !== undefined && allowedHosts !== "" ? { allowedHosts: parseAllowedHostsEnv(allowedHosts) } : {}), }, }; } function parsePiWebConfig(value: Record, path: string): PiWebConfig { return { ...(value["host"] !== undefined ? { host: parseString(value["host"], "host", path) } : {}), ...(value["port"] !== undefined ? { port: parsePort(value["port"], "port", path) } : {}), ...(value["allowedHosts"] !== undefined ? { allowedHosts: parseAllowedHosts(value["allowedHosts"], path) } : {}), }; } function parseString(value: unknown, key: string, path: string): string { if (typeof value !== "string" || value === "") throw new Error(`Pi Web config ${key} must be a non-empty string: ${path}`); return value; } function parsePort(value: unknown, key: string, path = "environment"): number { const port = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN; if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Pi Web config ${key} must be an integer from 1 to 65535: ${path}`); return port; } function parseAllowedHosts(value: unknown, path: string): string[] | true { if (value === true) return true; if (!isNonEmptyStringArray(value)) { throw new Error(`Pi Web config allowedHosts must be true or an array of non-empty strings: ${path}`); } return value; } function parseAllowedHostsEnv(value: string): string[] | true { if (value === "true") return true; return value.split(",").map((host) => host.trim()).filter((host) => host !== ""); } function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } function isNonEmptyStringArray(value: unknown): value is string[] { return Array.isArray(value) && value.every((item) => typeof item === "string" && item !== ""); } export function examplePiWebConfig(config: PiWebConfig = {}): string { return `${JSON.stringify({ host: config.host ?? "127.0.0.1", port: config.port ?? 8504, allowedHosts: config.allowedHosts ?? [] }, null, 2)}\n`; } export function piWebConfigDir(env: NodeJS.ProcessEnv = process.env): string { return dirname(defaultPiWebConfigPath(env)); }