"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); Object.defineProperty(exports, "__esModule", { value: true }); exports.FileHelper = void 0; const TOML = __importStar(require("@iarna/toml")); const fast_xml_parser_1 = require("fast-xml-parser"); const INI = __importStar(require("ini")); const fs = __importStar(require("node:fs/promises")); const YAML = __importStar(require("yaml")); const zod_1 = require("zod"); const util_1 = require("../../../base/lib/util"); const Watchable_1 = require("../../../base/lib/util/Watchable"); const previousPath = /(.+?)\/([^/]*)$/; const exists = (path) => fs.access(path).then(() => true, () => false); async function onCreated(path) { if (path === '/') return; if (!path.startsWith('/')) path = `${process.cwd()}/${path}`; if (await exists(path)) { return; } const split = path.split('/'); const filename = split.pop(); const parent = split.join('/'); await onCreated(parent); const ctrl = new AbortController(); const watch = fs.watch(parent, { persistent: false, signal: ctrl.signal }); if (await exists(path)) { ctrl.abort(); return; } if (await fs.access(path).then(() => true, () => false)) { ctrl.abort(); return; } for await (let event of watch) { if (event.filename === filename) { ctrl.abort('finished'); return; } } } function fileMerge(...args) { let res = args.shift(); for (const arg of args) { if (res === arg) continue; else if (res && arg && typeof res === 'object' && typeof arg === 'object' && !Array.isArray(res) && !Array.isArray(arg)) { for (const key of Object.keys(arg)) { res[key] = fileMerge(res[key], arg[key]); } } else res = arg; } return res; } function filterUndefined(a) { if (a && typeof a === 'object') { if (Array.isArray(a)) { return a.map(filterUndefined); } return Object.entries(a).reduce((acc, [k, v]) => { if (v !== undefined) { acc[k] = filterUndefined(v); } return acc; }, {}); } return a; } function toPath(path) { if (typeof path === 'string') { return path; } return path.base.subpath(path.subpath); } /** * @description Use this class to read/write an underlying configuration file belonging to the upstream service. * * These type definitions should reflect the underlying file as closely as possible. For example, if the service does not require a particular value, it should be marked as optional(), even if your package requires it. * * It is recommended to use onMismatch() whenever possible. This provides an escape hatch in case the user edits the file manually and accidentally sets a value to an unsupported type. * * Officially supported file types are json, yaml, and toml. Other files types can use "raw" * * Choose between officially supported file formats (), or a custom format (raw). * * @example * Below are a few examples * * ``` * import { matches, FileHelper } from '@start9labs/start-sdk' * const { arrayOf, boolean, literal, literals, object, natural, string } = matches * * export const jsonFile = FileHelper.json('./inputSpec.json', object({ * passwords: arrayOf(string).onMismatch([]) * type: literals('private', 'public').optional().onMismatch(undefined) * })) * * export const tomlFile = FileHelper.toml('./inputSpec.toml', object({ * url: literal('https://start9.com').onMismatch('https://start9.com') * public: boolean.onMismatch(true) * })) * * export const yamlFile = FileHelper.yaml('./inputSpec.yml', object({ * name: string.optional().onMismatch(undefined) * age: natural.optional().onMismatch(undefined) * })) * * export const bitcoinConfFile = FileHelper.raw( * './service.conf', * (obj: CustomType) => customConvertObjToFormattedString(obj), * (str) => customParseStringToTypedObj(str), * ) * ``` */ class FileHelper { constructor(path, writeData, readData, validate) { this.path = path; this.writeData = writeData; this.readData = readData; this.validate = validate; this.consts = []; } async writeFileRaw(data) { const parent = previousPath.exec(this.path); if (parent) { await fs.mkdir(parent[1], { recursive: true }); } await fs.writeFile(this.path, data); return null; } /** * Accepts structured data and overwrites the existing file on disk. */ async writeFile(data) { return await this.writeFileRaw(this.writeData(data)); } async readFileRaw() { if (!(await exists(this.path))) { return null; } return await fs.readFile(this.path).then((data) => data.toString('utf-8')); } async readFile() { const raw = await this.readFileRaw(); if (raw === null) { return raw; } return this.readData(raw); } /** * Reads the file from disk and converts it to structured data. */ async readOnce(map) { const data = await this.readFile(); if (!data) return null; return map(this.validate(data)); } createFileWatchable(effects, map, eq) { const doRead = async () => { const data = await this.readFile(); if (!data) return null; return this.validate(data); }; const filePath = this.path; const fileHelper = this; const wrappedMap = (raw) => { if (raw === null) return null; return map(raw); }; return new (class extends Watchable_1.Watchable { constructor() { super(...arguments); this.label = 'FileHelper'; } async fetch() { return doRead(); } async *produce(abort) { while (this.effects.isInContext && !abort.aborted) { if (await exists(filePath)) { const ctrl = new AbortController(); abort.addEventListener('abort', () => ctrl.abort()); const watch = fs.watch(filePath, { persistent: false, signal: ctrl.signal, }); yield await doRead(); await Promise.resolve() .then(async () => { for await (const _ of watch) { ctrl.abort(); return null; } }) .catch((e) => console.error((0, util_1.asError)(e))); } else { yield null; await onCreated(filePath).catch((e) => console.error((0, util_1.asError)(e))); } } } onConstRegistered(value) { if (!this.effects.constRetry) return; const record = [ this.effects.constRetry, value, wrappedMap, eq, ]; fileHelper.consts.push(record); return () => { fileHelper.consts = fileHelper.consts.filter((r) => r !== record); }; } })(effects, { map: wrappedMap, eq }); } read(map, eq) { map = map ?? ((a) => a); eq = eq ?? util_1.deepEqual; return { once: () => this.readOnce(map), const: (effects) => this.createFileWatchable(effects, map, eq).const(), watch: (effects, abort) => this.createFileWatchable(effects, map, eq).watch(abort), onChange: (effects, callback) => this.createFileWatchable(effects, map, eq).onChange(callback), waitFor: (effects, pred) => this.createFileWatchable(effects, map, eq).waitFor(pred), }; } /** * Accepts full structured data and overwrites the existing file on disk if it exists. */ async write(effects, data, options = {}) { const newData = this.validate(data); await this.writeFile(newData); if (!options.allowWriteAfterConst && effects.constRetry) { const records = this.consts.filter(([c]) => c === effects.constRetry); for (const record of records) { const [_, prev, map, eq] = record; if (!eq(prev, map(newData))) { throw new Error(`Canceled: write after const: ${this.path}`); } } } return null; } /** * Accepts partial structured data and performs a merge with the existing file on disk. */ async merge(effects, data, options = {}) { const fileDataRaw = await this.readFileRaw(); let fileData = fileDataRaw === null ? null : this.readData(fileDataRaw); try { fileData = this.validate(fileData); } catch (_) { } const mergeData = this.validate(fileMerge({}, fileData, data)); const toWrite = this.writeData(mergeData); if (toWrite !== fileDataRaw) { await this.writeFile(mergeData); if (!options.allowWriteAfterConst && effects.constRetry) { const records = this.consts.filter(([c]) => c === effects.constRetry); for (const record of records) { const [_, prev, map, eq] = record; if (!eq(prev, map(mergeData))) { throw new Error(`Canceled: write after const: ${this.path}`); } } } } return null; } /** * We wanted to be able to have a fileHelper, and just modify the path later in time. * Like one behavior of another dependency or something similar. */ withPath(path) { return new FileHelper(toPath(path), this.writeData, this.readData, this.validate); } /** * Create a File Helper for an arbitrary file type. * * Provide custom functions for translating data to/from the file format. */ static raw(path, toFile, fromFile, validate) { return new FileHelper(toPath(path), toFile, fromFile, validate); } static rawTransformed(path, toFile, fromFile, validate, transformers) { return FileHelper.raw(path, (inData) => { if (transformers) { return toFile(transformers.onWrite(inData)); } return toFile(inData); }, (fileData) => { if (transformers) { return transformers.onRead(fromFile(fileData)); } return fromFile(fileData); }, validate); } static string(path, shape, transformers) { return FileHelper.rawTransformed(path, (inData) => inData, (inString) => inString, (data) => (shape || zod_1.z.string()).parse(data), transformers); } static json(path, shape, transformers) { return FileHelper.rawTransformed(path, (inData) => JSON.stringify(inData, null, 2), (inString) => JSON.parse(inString), (data) => shape.parse(data), transformers); } static yaml(path, shape, transformers) { return FileHelper.rawTransformed(path, (inData) => YAML.stringify(inData, null, 2), (inString) => YAML.parse(inString), (data) => shape.parse(data), transformers); } static toml(path, shape, transformers) { return FileHelper.rawTransformed(path, (inData) => TOML.stringify(inData), (inString) => TOML.parse(inString), (data) => shape.parse(data), transformers); } static ini(path, shape, options, transformers) { return FileHelper.rawTransformed(path, (inData) => INI.stringify(filterUndefined(inData), options), (inString) => INI.parse(inString, options), (data) => shape.parse(data), transformers); } static env(path, shape, transformers) { return FileHelper.rawTransformed(path, (inData) => Object.entries(inData) .map(([k, v]) => `${k}=${v}`) .join('\n'), (inString) => Object.fromEntries(inString .split('\n') .map((line) => line.trim()) .filter((line) => !line.startsWith('#') && line.includes('=')) .map((line) => { const pos = line.indexOf('='); return [line.slice(0, pos), line.slice(pos + 1)]; })), (data) => shape.parse(data), transformers); } static xml(path, shape, options, transformers) { const parser = new fast_xml_parser_1.XMLParser(options?.parser); const builder = new fast_xml_parser_1.XMLBuilder(options?.builder); return FileHelper.rawTransformed(path, (inData) => builder.build(inData), (inString) => parser.parse(inString), (data) => shape.parse(data), transformers); } } exports.FileHelper = FileHelper; exports.default = FileHelper; //# sourceMappingURL=fileHelper.js.map