services_snapshot.js

/**
 * @file services/snapshot.js
 * @copyright @spmhome @_2025
 * @author Scott Meesseman @spmeesseman
 *//** */

const WpwService = require("./base");
const { isFunction, dateAddDays, isString, isArray, applyIf } = require("@spmhome/type-utils");
const {
    awaitAlways, existsSync, findFiles, isAbsPath, isDirectory, readFileBufAsync, resolvePath, relativePath,
    readFileAsync
} = require("@spmhome/cmn-utils");


class WpwSnapshotService extends WpwService
{
    static dependenciesLogged = false;

    // /**
    //  * @protected
    //  * @type {WebpackCacheFacade}
    //  */
    // wpCache;
    /**
     * @private
     * @type {WebpackCacheFacade}
     */
    wpCompilationCache;
    // /**
    //  * @private
    //  * @type {WebpackCacheFacade}
    //  */
    // wpCacheCompiler;
    /**
     * @private
     * @type {WebpackFileSystemInfo}
     */
    wpFsInfo;


    /**
     * @param {WpwServiceOwnerOptions} options
     */
    constructor(options)
    {
        super(applyIf({ slug: "snapshot" }, options));
    }


    /**
     * @param {string | string[]} fsPathAbs
     * @param {boolean} [isDir]
     * @param {string} [lPad]
     * @returns {boolean}
     * @throws {Error}
     */
    checkRuntimeRequirements = (fsPathAbs, isDir, lPad = "   ") =>
    {
        this.compiler ||= this.build.compiler;
        this.compilation ||= this.build.compilation;
        if (!this.compiler || !this.compilation) {
            throw new Error(
                "snapshot cache not fully initialized",
                { cause: this.MsgCode.WARNING_OPTIONS_INVALID}
            );
        }
        // else if (isDir)
        // {
        //     this.build.addMessage({
        //         code: this.MsgCode.WARNING_OPTIONS_INVALID,
        //         message: "directory snapshot not implemented, files only are supported"
        //     });
        //     return false;
        // }
        else if (!fsPathAbs || (isString(fsPathAbs) && !existsSync(fsPathAbs)))
        {
            throw new Error(
                `asset-${!isDir ? "file" : "directory"} path '${fsPathAbs}' does not exist`,
                { cause: this.MsgCode.ERROR_RESOURCE_MISSING}
            );
        }
        this.updateRuntimeInstances(this.compiler, this.compilation, lPad);
        return true;
    };


    /**
     * @param {string | string[]} fsPath
     * @param {WebpackRawSource | null} [source]
     * @param {string} [lPad]
     * @param {WpwContentTransformFn} [transform]
     * @returns {Promise<CacheResultNoSnapshot>}
     */
    checkSnapshot = async (fsPath, source, lPad, transform) =>
    {
        const now = Date.now(),
              l = this.build.logger,
              vDir = this.build.virtualEntry.dir,
              isAbs = isString(fsPath) ? isAbsPath(fsPath) : false,
              fsPathAbs = isAbs ? fsPath : resolvePath(vDir, isString(fsPath) ? fsPath : ""),
              fsPathRel = isAbs ? relativePath(vDir, isString(fsPath) ? fsPath : ".") : isString(fsPath) ? fsPath :"",
              isDir = isString(fsPathAbs) ? isDirectory(fsPathAbs) : false,
              /** @type {CacheResult} */
              result = { file: fsPathRel, snapshot: null, source: null, up2date: false };

        l.write(`check for cached snapshot @ path '${fsPathRel}'`, 1, lPad);
        l.value("   asset absolute path", fsPathAbs, 2, lPad);

        try{
            this.checkRuntimeRequirements(fsPathAbs, isDir, lPad + "   ");
        }
        catch (e) {
            throw new Error(e.message);
        }

        let hash, cachedEntry;
        try {
            cachedEntry = await this.getEntry(fsPathRel, null, lPad + "   ");
        }
        catch (e) {
            throw new Error(e.message);
        }

        if (cachedEntry && cachedEntry.snapshot)
        {
            l.write("   snapshot acquired from cache, perform validation", 2, lPad);
            try {
                result.up2date = await this.isValidSnapshot(cachedEntry.snapshot, lPad + "   ");
            }
            catch (e) {
                throw new Error(e.message);
            }
            if (result.up2date) {
                l.write("   cached snapshot validated", 1, lPad);
                if (!isDir) { result.source = cachedEntry.source; }
            }
            else { l.write("   cached snapshot was invalidated, refresh", 1, lPad); }
        }
        else {
            l.write("   no cached snapshot exists for this asset", 1, lPad);
        }

        if (isDir || !result.up2date)
        {   try
            {   if (isArray(fsPath))
                {
                    result.snapshot = await this.createSnapshot(now, fsPath);
                    hash = this.getContentHash(Buffer.from(fsPath.join("|")));
                }
                else if (!isDir && isString(fsPathAbs))
                {   l.write("   read file-asset source content", 3, lPad);
                    let data = await readFileBufAsync(fsPathAbs);
                    if (isFunction(transform)) {
                        data = await awaitAlways(transform(data));
                    }
                    l.write("   create new file-asset snapshot", 1, lPad);
                    result.source = new this.build.wp.sources.RawSource(data);
                    result.snapshot = await this.createSnapshot(now, [ fsPath ]);
                    hash = this.getContentHash(data);
                }
                else
                {   l.write("   create new directory-asset snapshot", 1, lPad);
                    const files = await findFiles("**/*", { cwd: fsPath, nodir: true });
                    let data = "";
                    for (const f of files) {
                        data += (await readFileAsync(f));
                    }
                    let buf = Buffer.from(data, "utf8");
                    if (isFunction(transform)) {
                        buf = await awaitAlways(transform(buf));
                    }
                    result.snapshot = await this.createSnapshot(now, files, [ fsPath ]);
                    hash = this.getContentHash(buf);
                }
            }
            catch (e) {
                throw new Error(e.message);
            }

            try
            {   result.snapshot.setFileTimestamps(new Map([
                    [ "timestamp", { timestamp: now, safeTime: dateAddDays(now, 1).getTime() }]
                ]));
                result.snapshot.setFileHashes(new Map([[ "hash", hash ]]));
            }
            catch (e) {
                throw new Error(e.message);
            }

            try
            {   l.value("   persist snapshot to store", fsPathRel, 2, lPad);
                await this.wpCompilationCache.storePromise(
                    this.getIdentifier(fsPath), null, { source, snapshot: result.snapshot, hash }
                );
            }
            catch (e) {
                throw new Error(e.message);
            }

            l.write(`completed cached snapshot check for asset '${fsPathRel}`, 1, lPad);
            return result;
        }
    };

    /*
    async getSnapshot()
    {
        const newAssetJson = JSON.stringify(assets);
        if (isCompilationCached && options.cache && assetJson === newAssetJson)
        {
                previousEmittedAssets.forEach(({ name, html }) => {
                compilation.emitAsset(name, new webpack.sources.RawSource(html, false));
            });
            return;
        }
        else {
            previousEmittedAssets = [];
            assetJson = newAssetJson;
        }

        const filename = options.filename.replace(/\[templatehash([^\]]*)\]/g, require('util').deprecate(
        (match, options) => `[contenthash${options}]`,
        '[templatehash] is now [contenthash]')
        );
        const replacedFilename = replacePlaceholdersInFilename(filename, html, compilation);
        // Add the evaluated html code to the webpack assets
        compilation.emitAsset(replacedFilename.path, new webpack.sources.RawSource(html, false), replacedFilename.info);
        previousEmittedAssets.push({ name: replacedFilename.path, html });
        return replacedFilename.path;

		logger.write(`   finished execution of jsdoc build @ italic(${relative(this.build.paths.base, outDir)})`, 3);
 	}
    */
    // logger.write("   check compilation cache for snapshot", 4);
    // try {
    //     persistedCache = this.cache.data....
    //     cacheEntry = await this.wpCacheCompilation.getPromise(`${resourcePath}|${identifier}`, null);
    // }
    // catch (e) {
    //     throw new WpwError("jsdoc loader failed: " + e.message, "loaders/jsdoc.js");
    // }

    // if (cacheEntry)
    // {
    //     let isValidSnapshot;
    //     logger.write("      check snapshot valid", 4);
    //     try {
    //         isValidSnapshot = await this.checkSnapshotValid(cacheEntry.snapshot);
    //     }
    //     catch (e) {
    //         throw new WpwError("jsdoc loader failed" + e.message, "loaders/jsdoc.js", "checking snapshot");
    //     }
    //     if (isValidSnapshot)
    //     {
    //         logger.write("      snapshot is valid", 4);
    //         ({ hash, source } = cacheEntry);
    //         newHash = newHash || this.getContentHash(source);
    //         if (newHash === hash)
    //         {
    //             logger.write("      asset is unchanged since last snapshot", 4);
    //         }
    //         else {
    //             logger.write("      asset has changed since last snapshot", 4);
    //         }
    //     }
    //     else {
    //         logger.write("      snapshot is invalid", 4);
    //     }
    // }

    // if (!source)
    // {
    //     let snapshot;
    //     const startTime = Date.now();
    //     data = data || await readFile(resourcePath);
    //     source = new this.build.wp.sources.RawSource(data);
    //     logger.write("      create snapshot", 4);
    //     try {
    //         snapshot = await this.createSnapshot(startTime, resourcePath);
    //     }
    //     catch (e) {
    //         throw new WpwError("jsdoc loader failed" + e.message,
    //                        "loaders/jsdoc.js", "creating snapshot " + resourcePathRel);
    //     }
    //     if (snapshot)
    //     {
    //         logger.write("      cache snapshot", 4);
    //         try {
    //             newHash = newHash || this.getContentHash(source.buffer());
    //             snapshot.setFileHashes(hash);
    //             await this.wpCacheCompilation.storePromise(`${resourcePath}|${identifier}`,
    //                                  null, { source, snapshot, hash });
    //             cacheEntry = await this.wpCacheCompilation.getPromise(`${resourcePath}|${identifier}`, null);
    //         }
    //         catch (e) {
    //             throw new WpwError(
    //                 "jsdoc loader failed" + e.message, "loaders/jsdoc.js", "caching snapshot " + resourcePathRe
    //             );
    //         }
    //     }
    // }

    // newHash = newHash || this.getContentHash(data);
    // if (newHash === persistedCache[resourcePathRel])
    // {
    //     logger.write("   asset is unchanged", 4);
    // }
    // else {
    //     logger.write("   asset has changed, update hash in persistent cache", 4);
    //     persistedCache[resourcePathRel] = newHash;
    //     this.cache.set(persistedCache);
    // }

    // const info = {
    //     // contenthash: newHash,
    //     immutable: true, // newHash === persistedCache[filePathRel],
    //     javascriptModule: false,
    //     jsdoc: true
    // };
    // this.compilation.buildDependencies.add(filePathRel);
    // this.compilation.buildDependencies.add(resourcePath);
    // this.compilation.compilationDependencies.add();
    // this.compilation.contextDependencies.add();

    // const cache = this.compiler.getCache(`${this.build.name}_${this.build.type}_${this.build.wpc.target}`.toLowerCase());

    // this.compilation.emitAsset(resourcePathRel, source, info);

    // this.compilation.additionalChunkAssets.push(filePathRel);

    // const existingAsset = this.compilation.getAsset(resourcePathRel);
    // if (existingAsset)
    // {
    //     logger.write("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
    //     logger.write("!!!!!!!!!!!*********TEST********** update jsdoc asset !!!!!!!!!!!!!!!!!!!!!!!!!");
    //     logger.write("!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!");
    // }
    // if (!existingAsset)
    // {
        // logger.write("   emit jsdoc asset", 3);
        // this.compilation.emitAsset(resourcePathRel, source, info);

        // const fileName = loaderUtils.interpolateName(this, "[name]-[contenthash].[ext]", {content: source});
        // this.emitFile(fileName, resourcePath);
        // this.addDependency(resourcePath);
    // }
    // else if (this.options.force)
    // {
    //     logger.write("   update asset", 3);
    //     this.compilation.updateAsset(resourcePathRel, source, info);
    // }
    // else {
    //     logger.write("      asset compared for emit", 3);
    //     this.compilation.buildDependencies.add(resourcePathRel);
    //     this.compilation.comparedForEmitAssets.add(resourcePathRel);
    //     this.compilation.compilationDependencies.add(resourcePathRel);
    // }


    /**
     * @private
     * @param {WebpackSnapshot} snapshot
     * @param {string} [lPad]
     * @returns {Promise<boolean | undefined>} Promise<boolean | undefined>
     */
    isValidSnapshot = (snapshot, lPad = "   ") =>
    {
    	return new Promise((resolve, reject) =>
    	{
            const l = this.build.logger;
            l.write("check snapshot validity", 1, lPad);
    		this.wpFsInfo.checkSnapshotValid(snapshot, (e, isValid) =>
            {   if (e) {
                    l.error("snapshot validity check resulted in an error", lPad);
                    reject(e);
                }
                else { resolve(isValid); }
            });
    	});
    };


    /**
     * @private
     * @param {number} now
     * @param {string[] | null} files
     * @param {string[] | null} dirs
     * @param {string[] | null} missing
     * @param {WebpackSnapshotOptionsFileSystemInfo | null} [options]
     * @returns {Promise<WebpackSnapshot | undefined | null>} Promise<WebpackSnapshot | undefined | null>
     */
    createSnapshot(now, files, dirs = null, missing = null, options = {})
    {
    	return new Promise((ok, fail) =>
    	{
            this.wpFsInfo.createSnapshot(
                now || Date.now(), files, dirs, missing,
                { ...{ hash: true, timestamp: true }, ...options },
                (e, ss) => e ? fail(e) : ok(ss)
    		);
    	});
    };


    /**
     * @private
     * @param {string} fsPath
     * @param {WebpackEtag | null} [etag]
     * @param {string} [lPad]
     * @returns {Promise<CacheResult | undefined>}
     */
    async getEntry(fsPath, etag, lPad = "   ")
    {
        const l = this.logger,
              isAbs = isAbsPath(fsPath),
              fsPathAbs = !isAbs ? resolvePath(this.compiler.outputPath, fsPath) : fsPath,
              fsPathRel = isAbs ? relativePath(this.compiler.outputPath, fsPath) : fsPath;
        try {
            const key = this.getIdentifier(fsPathRel);
            l.value("query store for cached snapshot", fsPathRel, 1, lPad);
            l.value("   key", key, 2, lPad);
            l.value("   etag", etag, 2, lPad);
            l.value("   relative path", fsPathRel, 2, lPad);
            l.value("   absolute path", fsPathAbs, 3, lPad);
            const result = /** @type {CacheResult} */(
                await this.wpCompilationCache.getPromise(key, etag || null)
            );
            // l.write("found cached snapshot entry", 2, lPad);
            // const ts =  epochToMSMS(result.snapshot.fileTimestamps.get("timestamp").timestamp),
            //       tsSafe =  epochToMSMS(result.snapshot.fileTimestamps.get("timestamp").safeTime);
            // l.value("   safe time", tsSafe, 2, lPad);
            // l.value("   timestamp", ts, 2, lPad);
            return result;
        }
        catch (e) {
            throw new Error(e.message);
        }
    }


    /**
     * @param {Buffer} source
     * @returns {string} string
     */
    getContentHash(source)
    {
        const {hashDigest, hashDigestLength, hashFunction, hashSalt } = this.compilation.outputOptions,
            hash = this.build.wp.util.createHash(/** @type {string} */(hashFunction));
        if (hashSalt) { hash.update(hashSalt); }
        return hash.update(source).digest(hashDigest).toString().slice(0, hashDigestLength);
    }


    /**
     * @private
     * @param {string | string[]} fsPath
     * @returns {string} string
     */
    getIdentifier(fsPath) { return `${this._arr_.asArray(fsPath).join("|").replace(/[/\\:.\W]/g, "")}_${this.build.name}`; }


    /**
     * @private
     * @param {boolean} [force]
     * @param {string} [lPad]
     */
    printDependencies(force, lPad = "   ")
    {
        const c = this.compilation;
        if (force || !WpwSnapshotService.dependenciesLogged &&
           (c.buildDependencies.size || c.contextDependencies.size || c.fileDependencies.size || c.missingDependencies.size))
        {
            const l = this.build.logger;
            l.value("# of build dependencies", c.buildDependencies.size, 1, lPad);
            if (c.buildDependencies.size > 0 && l.level >= 4) {
                l.write("   build dependencies:", 4, lPad);
                c.buildDependencies.forEach((d) => l.write(d, 4, lPad + "   "), this);
            }
            l.value("# of context dependencies", c.contextDependencies.size, 1, lPad);
            if (c.contextDependencies.size > 0 && l.level >= 4) {
                l.write("context dependencies:", 4, lPad);
                c.contextDependencies.forEach((d) => l.write(d, 4, lPad + "   "), this);
            }
            l.value("# of file dependencies", c.fileDependencies.size, 1, lPad);
            if (c.fileDependencies.size > 0 && l.level >= 4) {
                l.write("file dependencies:", 4, lPad);
                c.fileDependencies.forEach((d) => l.write(d, 4, lPad + "   "), this);
            }
            l.value("# of missing dependencies", c.missingDependencies.size, 1, lPad);
            if (c.missingDependencies.size > 0 && l.level >= 4) {
                l.write("missing dependencies:", 4, lPad);
                c.missingDependencies.forEach((d) => l.write(d, 4, lPad + "   "), this);
            }
            WpwSnapshotService.dependenciesLogged = true;
        }
    }


    /**
     * @param {WebpackCompiler} compiler
     * @param {WebpackCompilation} compilation
     * @param {string} [lPad]
     * @throws {Error}
     */
    updateRuntimeInstances(compiler, compilation, lPad = "   ")
    {
        this.compiler = compiler;
        this.compilation = compilation;
        const b = this.build, errs = b.errorCount > 0;
        if (!errs && b.wpc.cache.type === "filesystem" && !this.wpCompilationCache && this.compiler && this.compilation)
        {
            b.logger.value("finish initialization for webpack snapshot cache", b.wpc.cache.cacheDirectory, 1, lPad);
            b.logger.write("   get compilation cache facade", 2, lPad);
            this.wpFsInfo = this.compilation.fileSystemInfo;
            this.wpCompilationCache = this.compilation.getCache(b.wpc.cache.name);
            this.printDependencies(false, lPad + "   ");
            b.logger.write("webpack cache initialized", 2, lPad);
        }
        // this.printDependencies(false, lPad + "   ");
    }

}


module.exports = WpwSnapshotService;