import { createHash } from "node:crypto"; import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; import { gzip, gunzip } from "node:zlib"; const compress = promisify(gzip); const decompress = promisify(gunzip); const digest = (bytes) => createHash("sha256").update(bytes).digest("hex"); /** Original hashes remain the runtime contract; gzip is only a storage representation. */ export async function prepareContentObjects(files, { directory, baseUrl, keepOriginal = false }) { await mkdir(directory, { recursive: true }); const unique = new Map(); for (const file of files) unique.set(`${file.sha256}${file.extension}`, file); const objects = [...unique.entries()]; const prepared = new Map(); let next = 0; let writtenObjects = 0; const writeVerified = async (name, bytes) => { const destination = path.join(directory, name); try { const existing = await readFile(destination); if (!existing.equals(bytes)) throw new Error(`Published object differs from its hash: ${destination}`); } catch (error) { if (error.code !== "ENOENT") throw error; await writeFile(destination, bytes, { flag: "wx" }); writtenObjects++; } }; await Promise.all(Array.from({ length: 4 }, async () => { while (next < objects.length) { const [name, file] = objects[next++]; const original = await readFile(file.sourcePath); if (original.length !== file.bytes || digest(original) !== file.sha256) { throw new Error(`Asset changed during packaging: ${file.logicalPath}`); } const compressed = await compress(original, { level: 6 }); const useful = compressed.length + 64 < original.length; let download; if (useful) { if (!(await decompress(compressed)).equals(original)) throw new Error(`Gzip round trip failed: ${file.logicalPath}`); const compressedHash = digest(compressed); // AAPT expands and renames .gz assets even with noCompress. Keep gzip // bytes under a neutral suffix so APK and browser catalogs stay identical. const compressedName = `${compressedHash}${file.extension}.gzip`; await writeVerified(compressedName, compressed); download = { url: `${baseUrl}/${compressedName}`, encoding: "gzip", sha256: compressedHash, bytes: compressed.length }; } if (keepOriginal || !useful) await writeVerified(name, original); prepared.set(name, { objectUrl: `${baseUrl}/${name}`, ...(download ? { download } : {}), storedBytes: useful ? compressed.length : original.length }); } })); return { files: files.map((file) => ({ ...file, ...prepared.get(`${file.sha256}${file.extension}`) })), writtenObjects, uniqueObjects: objects.length, logicalBytes: files.reduce((sum, file) => sum + file.bytes, 0), uniqueBytes: objects.reduce((sum, [, file]) => sum + file.bytes, 0), storedBytes: [...prepared.values()].reduce((sum, object) => sum + object.storedBytes, 0), }; }