| 1 | // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt |
| 2 | |
| 3 | import { ORIGINAL_CWD, VERSION, CONFIG_FILE, IS_BINARY } from './const' |
| 4 | import { watchLoad } from './watchLoad' |
| 5 | import yaml from 'yaml' |
| 6 | import _ from 'lodash' |
| 7 | import { DAY, newObj, prefix, throw_, tryJson, wait, with_ } from './cross' |
| 8 | import { debounceAsync } from './debounceAsync' |
| 9 | import { statSync } from 'fs' |
| 10 | import { basename, join, resolve } from 'path' |
| 11 | import events from './events' |
| 12 | import { copyFile } from 'fs/promises' |
| 13 | import { argv } from './argv' |
| 14 | import { statWithTimeout } from './util-files' |
| 15 | |
| 16 | // keep definition of config properties |
| 17 | const configProps: Record<string, { defaultValue?: unknown }> = {} |
| 18 | |
| 19 | let started = false // this will tell the difference for subscribeConfig()s that are called before or after config is loaded |
| 20 | let state: Record<string, any> = {} // current state of config properties |
| 21 | const filePath = with_(argv.config || process.env.HFS_CONFIG, p => { |
| 22 | if (!p) |
| 23 | return CONFIG_FILE |
| 24 | p = resolve(ORIGINAL_CWD, p) |
| 25 | try { |
| 26 | if (statSync(p).isDirectory()) // try to detect if path points to a folder, in which case we add the standard filename |
| 27 | return join(p, CONFIG_FILE) |
| 28 | } |
| 29 | catch {} |
| 30 | return p |
| 31 | }) |
| 32 | // takes a semver like 1.2.3-alpha1, but alpha and beta numbers must share the number progression |
| 33 | export const versionToScalar = _.memoize((ver: string) => { // memoize so we don't have to care about converting same value twice |
| 34 | // this regexp is supposed to be resistant to optional leading "v" and an optional custom name after a space |
| 35 | const res = /^v?(\d+)\.(\d+)\.(\d+)(?:-\D+([0-9.]+))?/.exec(ver) |
| 36 | if (!res) return NaN |
| 37 | const [,a,b,c,beta] = res.map(Number) |
| 38 | const officialScalar = c! + b! * 1E3 + a! * 1E6 // gives 3 digits for each number |
| 39 | const betaScalar = 1 / (1 + beta! || Infinity) // beta tends to 0, while non-beta is 0. +1 to make it work even in case of alpha0 |
| 40 | return officialScalar - betaScalar |
| 41 | }) |
| 42 | |
| 43 | export class Version extends String { |
| 44 | readonly scalar |
| 45 | constructor(v: string) { |
| 46 | super(v) |
| 47 | this.scalar = versionToScalar(v) |
| 48 | } |
| 49 | olderThan(otherVersion: string) { |
| 50 | return this.scalar < versionToScalar(otherVersion) |
| 51 | } |
| 52 | } |
| 53 | |
| 54 | const CONFIG_CHANGE_EVENT_PREFIX = 'config.' |
| 55 | export const currentVersion = new Version(VERSION) |
| 56 | const configVersion = defineConfig('version', VERSION, v => new Version(v)) |
| 57 | |
| 58 | type Subscriber<T,R=void> = (v:T, more: { was?: T, version?: Version, defaultValue: T, k: string, object: object, onlyCompileChanged?: true }) => R |
| 59 | export function defineConfig<T, CT=unknown>(k: string, defaultValue: T, compiler?: Subscriber<T,CT>) { |
| 60 | configProps[k] = { defaultValue } |
| 61 | type Updater = (currentValue:T) => T |
| 62 | const object = { // consider a Class |
| 63 | key() { |
| 64 | return k |
| 65 | }, |
| 66 | get(): T { |
| 67 | return getConfig(k) |
| 68 | }, |
| 69 | async getWhenReady() { |
| 70 | await configReady |
| 71 | return this.get() |
| 72 | }, |
| 73 | sub(cb: Subscriber<T>) { |
| 74 | if (started) // initial event already passed, we'll make the first call |
| 75 | cb(getConfig(k), { k, was: defaultValue, defaultValue, version: configVersion.compiled(), object }) |
| 76 | return events.on(CONFIG_CHANGE_EVENT_PREFIX + k, (v, was, version, onlyCompileChanged) => { |
| 77 | if (stack.includes(cb)) return // avoid infinite loop in case a subscriber changes the value |
| 78 | stack.push(cb) |
| 79 | try { return cb(v, { k, was, version, defaultValue, object, onlyCompileChanged }) } |
| 80 | finally { stack.pop() } |
| 81 | }, { warnAfter: 1000 }) // e.g. each plugin watch enable_plugins |
| 82 | }, |
| 83 | set(v: T | Updater) { |
| 84 | if (typeof v === 'function') { |
| 85 | const draft = structuredClone(this.get()) |
| 86 | this.set((v as Updater)(draft) ?? draft) // use return value if provided |
| 87 | } |
| 88 | else |
| 89 | setConfig1(k, v) |
| 90 | }, |
| 91 | compiled: () => (compiler ? compiled : throw_("missing compiler")) as CT, |
| 92 | setCompiled(v: CT) { |
| 93 | compiled = v |
| 94 | const was = getConfig(k) |
| 95 | return events.emitAsync(CONFIG_CHANGE_EVENT_PREFIX + k, was, was, VERSION, true) |
| 96 | } |
| 97 | } |
| 98 | let compiled = compiler?.(defaultValue, { k, version: currentVersion, defaultValue, object }) |
| 99 | if (compiler) |
| 100 | object.sub((v, more) => { |
| 101 | if (!more.onlyCompileChanged) |
| 102 | return compiled = compiler(v, more) |
| 103 | }) |
| 104 | return object |
| 105 | } |
| 106 | |
| 107 | export function configKeyExists(k: string) { |
| 108 | return configProps.hasOwnProperty(k) |
| 109 | } |
| 110 | |
| 111 | const stack: any[] = [] |
| 112 | |
| 113 | export function getConfig(k:string) { |
| 114 | return state[k] ?? _.cloneDeep(configProps[k]?.defaultValue) // clone to avoid changing |
| 115 | } |
| 116 | |
| 117 | export function getWholeConfig({ omit, only }: { omit?:string[], only?:string[] }) { |
| 118 | const defs = newObj(configProps, x => x.defaultValue) |
| 119 | let copy = _.defaults({}, state, defs) |
| 120 | if (omit?.length) |
| 121 | copy = _.omit(copy, omit) |
| 122 | if (only) |
| 123 | copy = _.pick(copy, only) |
| 124 | return _.cloneDeep(copy) |
| 125 | } |
| 126 | |
| 127 | // pass a value to `save` to force saving decision, or leave undefined for auto. Passing false will also reset previously loaded configs. |
| 128 | export async function setConfig(newCfg: Record<string,unknown>, save?: boolean) { |
| 129 | const version = _.isString(newCfg.version) ? new Version(newCfg.version) : undefined |
| 130 | const considerEnvs = !process.env['HFS_ENV_BOOTSTRAP'] || !started && _.isEmpty(newCfg) |
| 131 | // first time we consider also CLI args |
| 132 | const argCfg = !started && _.pickBy( |
| 133 | newObj(configProps, (_x, k) => |
| 134 | tryJson(k in argv ? argv[k] : considerEnvs ? process.env['HFS_' + k.toUpperCase().replaceAll('-','_')] : '', _.identity) ), |
| 135 | x => x !== undefined ) |
| 136 | if (!_.isEmpty(argCfg)) { |
| 137 | saveConfigAsap() // don't set `save` argument, as it would interfere below, at check `save===false` |
| 138 | Object.assign(newCfg, argCfg) |
| 139 | } |
| 140 | await Promise.allSettled(Object.keys(newCfg).map(k => |
| 141 | apply(k, newCfg[k]))) |
| 142 | if (save) { |
| 143 | saveConfigAsap() |
| 144 | return |
| 145 | } |
| 146 | if (started) { |
| 147 | if (save === false) // false is used when loading whole config, and in such case we should not leave previous values untreated. Also, we need this only after we already `started`. |
| 148 | await Promise.allSettled(Object.keys(state).map(k => |
| 149 | newCfg.hasOwnProperty(k) || apply(k, undefined))) |
| 150 | return |
| 151 | } |
| 152 | // first time we emit also for the default values |
| 153 | await Promise.allSettled(Object.keys(configProps).map(k => |
| 154 | newCfg.hasOwnProperty(k) || apply(k, undefined, true))) |
| 155 | started = true |
| 156 | events.emit('configReady', startedWithoutConfig) |
| 157 | if (version?.valueOf() !== VERSION) // be sure to save the new version in the file |
| 158 | saveConfigAsap() |
| 159 | |
| 160 | function apply(k: string, newV: any, isDefault=false) { |
| 161 | return setConfig1(k, newV, save === undefined, argCfg && k in argCfg || isDefault ? currentVersion : version) |
| 162 | } |
| 163 | } |
| 164 | |
| 165 | async function setConfig1(k: string, newV: unknown, saveChanges=true, valueVersion?: Version) { |
| 166 | if (_.isPlainObject(newV)) |
| 167 | newV = _.pickBy(newV as any, x => x !== undefined) |
| 168 | const def = configProps[k]?.defaultValue |
| 169 | if (same(newV ?? null, def ?? null)) |
| 170 | newV = undefined |
| 171 | if (started && same(newV, state[k])) return // no change |
| 172 | const was = getConfig(k) // include cloned default, if necessary |
| 173 | state[k] = newV |
| 174 | await events.emitAsync(CONFIG_CHANGE_EVENT_PREFIX + k, getConfig(k), was, valueVersion, false) |
| 175 | if (saveChanges) |
| 176 | saveConfigAsap() |
| 177 | |
| 178 | function same(a: any, b: any) { // we want to consider order of object entries as well (eg: mime) |
| 179 | return a === b || JSON.stringify(a) === JSON.stringify(b) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | const saveDebounced = debounceAsync(async () => { |
| 184 | while (!started) |
| 185 | await wait(100) |
| 186 | // keep backup |
| 187 | const bak = filePath + '.bak' |
| 188 | const aWeekAgo = Date.now() - DAY * 7 |
| 189 | if (await statWithTimeout(bak).then(x => aWeekAgo > x.mtimeMs, () => true)) |
| 190 | await copyFile(filePath, bak).catch(() => {}) // ignore errors |
| 191 | |
| 192 | await configFile.save(stringify({ |
| 193 | ...state, |
| 194 | version: VERSION, |
| 195 | platform: `${process.platform}-${process.arch}${prefix('-', !IS_BINARY && basename(process.execPath))}`, |
| 196 | })).catch(err => console.error('Failed at saving config file, please ensure it is writable.', String(err))) |
| 197 | }) |
| 198 | export const saveConfigAsap = () => void saveDebounced() |
| 199 | |
| 200 | function stringify(obj: any) { |
| 201 | return yaml.stringify(obj, { lineWidth:1000 }) |
| 202 | } |
| 203 | |
| 204 | let startedWithoutConfig = false |
| 205 | console.log("Config", filePath) |
| 206 | export const configFile = watchLoad(filePath, text => { |
| 207 | startedWithoutConfig = !text |
| 208 | try { return setConfig(yaml.parse(text, { uniqueKeys: false }) || {}, false) } |
| 209 | catch(e: any) { console.error("Error in", filePath, ':', e.message || String(e)) } |
| 210 | }, { |
| 211 | immediateFirst: true, |
| 212 | failedOnFirstAttempt(){ |
| 213 | startedWithoutConfig = true |
| 214 | console.log("No config file, using defaults") |
| 215 | setTimeout(() => // this is called synchronously, but we need to call setConfig after first tick, when all configs are defined |
| 216 | setConfig({}, false)) |
| 217 | } |
| 218 | }) |
| 219 | |
| 220 | export function subMultipleConfigs(cb: () => any, configs: Array<ReturnType<typeof defineConfig>>) { |
| 221 | // we depend on multiple configs, so wait for all of them to be ready |
| 222 | const unsub_s = configReady.then(() => |
| 223 | configs.map(x => x.sub(cb)) ) |
| 224 | return async () => { |
| 225 | for (const x of await unsub_s) |
| 226 | x() |
| 227 | } |
| 228 | } |
| 229 | |
| 230 | export const showHelp = argv.help |
| 231 | export const configReady = events.once('configReady').then(x => x[0] as Boolean) // the value is startedWithoutConfig. The .then also avoids exposing the cancel-subscription function. |
| 232 | configReady.then(() => { |
| 233 | if (!showHelp) return |
| 234 | console.log(`HELP |
| 235 | You can pass any configuration in the form: --name value |
| 236 | Most common configurations: |
| 237 | --create-admin <password> |
| 238 | --port <port> |
| 239 | --cert <path> |
| 240 | --private_key <path> |
| 241 | --consoleFile <path> |
| 242 | |
| 243 | For a description of each configuration, please refer to https://rejetto.com/hfs-config |
| 244 | Other options: |
| 245 | --debug will print extra information |
| 246 | `) |
| 247 | process.exit(0) |
| 248 | }) |