Build Phase 1 application foundation
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
const path = require("node:path");
|
||||
|
||||
class ConfigError extends Error {
|
||||
constructor(message, field) {
|
||||
super(message);
|
||||
this.name = "ConfigError";
|
||||
this.field = field;
|
||||
}
|
||||
}
|
||||
|
||||
function requiredString(env, name) {
|
||||
const value = env[name]?.trim();
|
||||
if (!value) {
|
||||
throw new ConfigError(`${name} is required`, name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalString(env, name) {
|
||||
const value = env[name]?.trim();
|
||||
return value || null;
|
||||
}
|
||||
|
||||
function integer(env, name, defaultValue, { min, max } = {}) {
|
||||
const raw = env[name];
|
||||
if (raw === undefined || raw === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
if (!/^-?\d+$/.test(raw)) {
|
||||
throw new ConfigError(`${name} must be an integer`, name);
|
||||
}
|
||||
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new ConfigError(`${name} must be a safe integer`, name);
|
||||
}
|
||||
if (min !== undefined && value < min) {
|
||||
throw new ConfigError(`${name} must be at least ${min}`, name);
|
||||
}
|
||||
if (max !== undefined && value > max) {
|
||||
throw new ConfigError(`${name} must be at most ${max}`, name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(env, name, defaultValue) {
|
||||
const raw = env[name];
|
||||
if (raw === undefined || raw === "") {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
const normalized = raw.trim().toLowerCase();
|
||||
if (["true", "1", "yes", "on"].includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
if (["false", "0", "no", "off"].includes(normalized)) {
|
||||
return false;
|
||||
}
|
||||
throw new ConfigError(`${name} must be a boolean`, name);
|
||||
}
|
||||
|
||||
function url(env, name, { required = false, protocols } = {}) {
|
||||
const raw = required ? requiredString(env, name) : optionalString(env, name);
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new ConfigError(`${name} must be a valid URL`, name);
|
||||
}
|
||||
|
||||
if (protocols && !protocols.includes(parsed.protocol)) {
|
||||
throw new ConfigError(
|
||||
`${name} must use one of these protocols: ${protocols.join(", ")}`,
|
||||
name,
|
||||
);
|
||||
}
|
||||
return parsed.toString().replace(/\/$/, "");
|
||||
}
|
||||
|
||||
function bucketName(env) {
|
||||
const value = requiredString(env, "S3_BUCKET");
|
||||
if (!/^[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]$/.test(value)) {
|
||||
throw new ConfigError(
|
||||
"S3_BUCKET must be a valid lowercase S3 bucket name",
|
||||
"S3_BUCKET",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function nodeEnvironment(env) {
|
||||
const value = env.NODE_ENV?.trim() || "development";
|
||||
if (!["development", "test", "production"].includes(value)) {
|
||||
throw new ConfigError(
|
||||
"NODE_ENV must be development, test, or production",
|
||||
"NODE_ENV",
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function timeZone(env) {
|
||||
const value = requiredString(env, "APP_TIMEZONE");
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format();
|
||||
} catch {
|
||||
throw new ConfigError("APP_TIMEZONE must be a valid IANA time zone", "APP_TIMEZONE");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function storageCredential(env, name, environment) {
|
||||
const value = optionalString(env, name);
|
||||
if (environment === "production" && value === null) {
|
||||
throw new ConfigError(`${name} is required in production`, name);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function loadConfig(env = process.env) {
|
||||
const environment = nodeEnvironment(env);
|
||||
const databasePath = requiredString(env, "DATABASE_PATH");
|
||||
if (databasePath !== ":memory:" && !path.isAbsolute(databasePath)) {
|
||||
throw new ConfigError("DATABASE_PATH must be an absolute path", "DATABASE_PATH");
|
||||
}
|
||||
|
||||
return Object.freeze({
|
||||
environment,
|
||||
host: env.HOST?.trim() || "0.0.0.0",
|
||||
port: integer(env, "PORT", 7532, { min: 0, max: 65535 }),
|
||||
timeZone: timeZone(env),
|
||||
shutdownTimeoutMs: integer(env, "SHUTDOWN_TIMEOUT_MS", 10_000, {
|
||||
min: 1_000,
|
||||
max: 60_000,
|
||||
}),
|
||||
trustProxy: boolean(env, "TRUST_PROXY", true),
|
||||
database: Object.freeze({
|
||||
path: databasePath,
|
||||
busyTimeoutMs: integer(env, "DATABASE_BUSY_TIMEOUT_MS", 5_000, {
|
||||
min: 0,
|
||||
max: 60_000,
|
||||
}),
|
||||
}),
|
||||
storage: Object.freeze({
|
||||
endpoint: url(env, "S3_ENDPOINT", {
|
||||
required: true,
|
||||
protocols: ["http:", "https:"],
|
||||
}),
|
||||
region: env.S3_REGION?.trim() || "us-east-1",
|
||||
bucket: bucketName(env),
|
||||
accessKeyId: storageCredential(env, "S3_ACCESS_KEY_ID", environment),
|
||||
secretAccessKey: storageCredential(env, "S3_SECRET_ACCESS_KEY", environment),
|
||||
forcePathStyle: boolean(env, "S3_FORCE_PATH_STYLE", true),
|
||||
}),
|
||||
mqtt: Object.freeze({
|
||||
url: url(env, "MQTT_URL", {
|
||||
protocols: ["mqtt:", "mqtts:", "ws:", "wss:"],
|
||||
}),
|
||||
username: optionalString(env, "MQTT_USERNAME"),
|
||||
password: optionalString(env, "MQTT_PASSWORD"),
|
||||
}),
|
||||
notifications: Object.freeze({
|
||||
homeAssistantWebhookUrl: url(env, "HA_WEBHOOK_URL", {
|
||||
protocols: ["http:", "https:"],
|
||||
}),
|
||||
homeAssistantWebhookSecret: optionalString(env, "HA_WEBHOOK_SECRET"),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function redactedConfig(config) {
|
||||
return {
|
||||
environment: config.environment,
|
||||
host: config.host,
|
||||
port: config.port,
|
||||
timeZone: config.timeZone,
|
||||
trustProxy: config.trustProxy,
|
||||
database: {
|
||||
configured: Boolean(config.database.path),
|
||||
},
|
||||
storage: {
|
||||
configured: Boolean(
|
||||
config.storage.endpoint &&
|
||||
config.storage.bucket &&
|
||||
config.storage.accessKeyId &&
|
||||
config.storage.secretAccessKey,
|
||||
),
|
||||
region: config.storage.region,
|
||||
bucket: config.storage.bucket,
|
||||
forcePathStyle: config.storage.forcePathStyle,
|
||||
},
|
||||
mqtt: {
|
||||
configured: Boolean(config.mqtt.url),
|
||||
},
|
||||
notifications: {
|
||||
configured: Boolean(config.notifications.homeAssistantWebhookUrl),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ConfigError,
|
||||
loadConfig,
|
||||
redactedConfig,
|
||||
};
|
||||
Reference in New Issue
Block a user