Files
healer-man/scripts/content/content-objects.mjs
T
2026-09-11 14:26:15 -04:00

63 lines
3.0 KiB
JavaScript

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),
};
}