main
ts 248 lines 10.1 KB
Raw
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 })