diff --git a/src/server/PublicAssetManifest.ts b/src/server/PublicAssetManifest.ts index 85e79b4f3..e46f37e50 100644 --- a/src/server/PublicAssetManifest.ts +++ b/src/server/PublicAssetManifest.ts @@ -43,6 +43,36 @@ function createContentHash(filePath: string): string { return createHash("sha256").update(content).digest("hex").slice(0, 12); } +function createStringHash(content: string): string { + return createHash("sha256").update(content).digest("hex").slice(0, 12); +} + +function createHashedAssetUrl(relativePath: string, hash: string): string { + const parsed = path.posix.parse(toPosixPath(relativePath)); + const hashedFileName = `${parsed.name}.${hash}${parsed.ext}`; + const hashedRelativePath = path.posix.join( + "_assets", + parsed.dir, + hashedFileName, + ); + return `/${encodeAssetPath(hashedRelativePath)}`; +} + +function renderWebManifest( + resourcesDir: string, + assetManifest: AssetManifest, +): string { + const sourcePath = path.join(resourcesDir, "manifest.json"); + const manifest = JSON.parse(fs.readFileSync(sourcePath, "utf8")) as { + icons?: Array<{ src?: string }>; + }; + manifest.icons = manifest.icons?.map((icon) => ({ + ...icon, + src: buildAssetUrl(icon.src ?? "", assetManifest), + })); + return `${JSON.stringify(manifest, null, 2)}\n`; +} + export function getResourcesDir(rootDir: string = process.cwd()): string { return path.join(rootDir, "resources"); } @@ -86,18 +116,21 @@ export function buildPublicAssetManifest(resourcesDir: string): AssetManifest { const manifest: AssetManifest = {}; for (const relativePath of listHashedPublicAssetPaths(resourcesDir)) { + if (relativePath === "manifest.json") { + continue; + } + const absolutePath = path.join(resourcesDir, relativePath); - const parsed = path.posix.parse(toPosixPath(relativePath)); const hash = createContentHash(absolutePath); - const hashedFileName = `${parsed.name}.${hash}${parsed.ext}`; - const hashedRelativePath = path.posix.join( - "_assets", - parsed.dir, - hashedFileName, - ); - manifest[relativePath] = `/${encodeAssetPath(hashedRelativePath)}`; + manifest[relativePath] = createHashedAssetUrl(relativePath, hash); } + const renderedWebManifest = renderWebManifest(resourcesDir, manifest); + manifest["manifest.json"] = createHashedAssetUrl( + "manifest.json", + createStringHash(renderedWebManifest), + ); + manifestCache.set(resourcesDir, manifest); return manifest; } @@ -117,14 +150,10 @@ export function createHashedPublicAssetFiles( fs.mkdirSync(path.dirname(outputPath), { recursive: true }); if (relativePath === "manifest.json") { - const manifest = JSON.parse(fs.readFileSync(sourcePath, "utf8")) as { - icons?: Array<{ src?: string }>; - }; - manifest.icons = manifest.icons?.map((icon) => ({ - ...icon, - src: buildAssetUrl(icon.src ?? "", assetManifest), - })); - fs.writeFileSync(outputPath, `${JSON.stringify(manifest, null, 2)}\n`); + fs.writeFileSync( + outputPath, + renderWebManifest(resourcesDir, assetManifest), + ); continue; } diff --git a/tests/server/PublicAssetManifest.test.ts b/tests/server/PublicAssetManifest.test.ts new file mode 100644 index 000000000..60bd46b7d --- /dev/null +++ b/tests/server/PublicAssetManifest.test.ts @@ -0,0 +1,71 @@ +import fs from "fs/promises"; +import os from "os"; +import path from "path"; +import { afterEach, describe, expect, test } from "vitest"; +import { + buildPublicAssetManifest, + clearPublicAssetManifestCache, + createHashedPublicAssetFiles, +} from "../../src/server/PublicAssetManifest"; + +describe("PublicAssetManifest", () => { + let tempDir: string | null = null; + + afterEach(async () => { + clearPublicAssetManifestCache(); + if (tempDir) { + await fs.rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + test("hashes manifest.json from its rewritten content", async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "public-assets-")); + const resourcesDir = path.join(tempDir, "resources"); + const outDir = path.join(tempDir, "static"); + + await fs.mkdir(path.join(resourcesDir, "icons"), { recursive: true }); + await fs.writeFile( + path.join(resourcesDir, "manifest.json"), + JSON.stringify( + { + name: "OpenFront", + icons: [{ src: "icons/app-icon.png" }], + }, + null, + 2, + ), + ); + await fs.writeFile( + path.join(resourcesDir, "icons", "app-icon.png"), + "icon-v1", + "utf8", + ); + + const firstManifest = buildPublicAssetManifest(resourcesDir); + const firstManifestHref = firstManifest["manifest.json"]; + const firstIconHref = firstManifest["icons/app-icon.png"]; + + createHashedPublicAssetFiles(resourcesDir, outDir, firstManifest); + const firstOutput = await fs.readFile( + path.join(outDir, firstManifestHref.slice(1)), + "utf8", + ); + + await fs.writeFile( + path.join(resourcesDir, "icons", "app-icon.png"), + "icon-v2", + "utf8", + ); + clearPublicAssetManifestCache(); + + const secondManifest = buildPublicAssetManifest(resourcesDir); + const secondManifestHref = secondManifest["manifest.json"]; + const secondIconHref = secondManifest["icons/app-icon.png"]; + + expect(firstIconHref).not.toBe(secondIconHref); + expect(firstManifestHref).not.toBe(secondManifestHref); + expect(firstOutput).toContain(firstIconHref); + expect(firstOutput).not.toContain(secondIconHref); + }); +});