| 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 { FSWatcher, watch } from 'fs' |
| 4 | import fs from 'fs/promises' |
| 5 | import { readFileWithBusyRetry } from './util-files' |
| 6 | import { debounceAsync } from './debounceAsync' |
| 7 | import { BetterEventEmitter } from './events' |
| 8 | |
| 9 | type WatchLoadCanceller = () => void |
| 10 | |
| 11 | interface Options { failedOnFirstAttempt?: ()=>void, immediateFirst?: boolean } |
| 12 | |
| 13 | type WriteFile = (data: string, options?: { reparse: boolean }) => Promise<void> |
| 14 | interface WatchLoadReturn { unwatch:WatchLoadCanceller, save: WriteFile, emitter: BetterEventEmitter, getText: () => string | undefined, getPath: () => string } |
| 15 | export function watchLoad(path:string, parser:(data:any)=>void|Promise<void>, { failedOnFirstAttempt, immediateFirst }:Options={}): WatchLoadReturn { |
| 16 | let doing = false |
| 17 | let watcher: FSWatcher | undefined |
| 18 | const debounced = debounceAsync(load, { wait: 500, maxWait: 1000, reuseRunning: true }) |
| 19 | let retry: NodeJS.Timeout |
| 20 | let last: string | undefined |
| 21 | const emitter = new BetterEventEmitter() |
| 22 | const save = debounceAsync(async (data: string, { reparse=false }={}) => { |
| 23 | await fs.writeFile(path, data, 'utf8') |
| 24 | last = data |
| 25 | if (reparse) |
| 26 | await parser(data) |
| 27 | emitter.emit('change', last) |
| 28 | }) |
| 29 | install(true) |
| 30 | return { unwatch, save, emitter, getText: () => last, getPath: () => path } |
| 31 | |
| 32 | function install(first=false) { |
| 33 | try { |
| 34 | watcher = watch(path, () => { |
| 35 | if (!save.isWorking()) |
| 36 | void debounced() |
| 37 | }) |
| 38 | debounced().catch(x=>x) |
| 39 | if (immediateFirst && first) |
| 40 | void debounced.flush() |
| 41 | } |
| 42 | catch(e) { |
| 43 | retry = setTimeout(install, 3_000) // manual watching until watch is successful |
| 44 | if (first) |
| 45 | failedOnFirstAttempt?.() |
| 46 | } |
| 47 | } |
| 48 | |
| 49 | function unwatch() { |
| 50 | watcher?.close() |
| 51 | clearTimeout(retry) |
| 52 | watcher = undefined |
| 53 | } |
| 54 | |
| 55 | async function load(){ |
| 56 | if (doing) return |
| 57 | doing = true |
| 58 | try { |
| 59 | await save.flush() // apply pending saves first |
| 60 | const text = await readFileWithBusyRetry(path).catch(e => { // ignore read errors |
| 61 | if (e.code === 'EPERM') |
| 62 | console.error("Missing permissions on file", path) // warn user, who could be clueless about this problem |
| 63 | // keep the last good content on transient read failures so we don't apply accidental "empty file" state |
| 64 | if (e.code === 'ENOENT') |
| 65 | return '' |
| 66 | throw e |
| 67 | }) |
| 68 | if (text === last) |
| 69 | return |
| 70 | last = text |
| 71 | emitter.emit('change', last) |
| 72 | console.debug('Loaded', path) |
| 73 | unwatch(); install() // reinstall, as the original file could have been renamed. We watch by the name. |
| 74 | await parser(text) |
| 75 | } |
| 76 | catch(e) { |
| 77 | console.error("Error loading", path, String(e)) |
| 78 | } |
| 79 | finally { |
| 80 | doing = false |
| 81 | } |
| 82 | } |
| 83 | } |