From 7ead488fa1ab2e6580dcec4dcd2f7070b5b66530 Mon Sep 17 00:00:00 2001 From: Peter Bouquet Date: Mon, 4 Apr 2022 16:53:31 +0100 Subject: [PATCH] fix(core, lambda-at-edge) Serve original cached image headers from file system --- .../libs/core/src/images/imageOptimizer.ts | 40 ++++++-- .../core/tests/images/imageOptimizer.test.ts | 94 +++++++++++-------- 2 files changed, 87 insertions(+), 47 deletions(-) diff --git a/packages/libs/core/src/images/imageOptimizer.ts b/packages/libs/core/src/images/imageOptimizer.ts index dcfd19bdd5..feb9d3e0ed 100644 --- a/packages/libs/core/src/images/imageOptimizer.ts +++ b/packages/libs/core/src/images/imageOptimizer.ts @@ -212,7 +212,9 @@ export async function imageOptimizer( const hash = getHash([CACHE_VERSION, href, width, quality, mimeType]); const imagesDir = join("/tmp", "cache", "images"); // Use Lambda tmp directory + const imagesMetaDir = join("/tmp", "cache", "imageMeta"); const hashDir = join(imagesDir, hash); + const metaDir = join(imagesMetaDir, hash); const now = Date.now(); if (fs.existsSync(hashDir)) { @@ -223,8 +225,15 @@ export async function imageOptimizer( const contentType = getContentType(extension); const fsPath = join(hashDir, file); if (now < expireAt) { + const meta = JSON.parse( + (await promises.readFile(join(metaDir, `${file}.json`))).toString() + ); if (!res.getHeader("Cache-Control")) { - res.setHeader("Cache-Control", "public, max-age=60"); + if (meta.headers["Cache-Control"]) { + res.setHeader("Cache-Control", meta.headers["Cache-Control"]); + } else { + res.setHeader("Cache-Control", "public, max-age=60"); + } } if (sendEtagResponse(req, res, etag)) { return { finished: true }; @@ -243,6 +252,7 @@ export async function imageOptimizer( let upstreamBuffer: Buffer | undefined; let upstreamType: string | undefined; let maxAge: number; + let cacheControl: string | undefined | null; if (isAbsolute) { const upstreamRes = await fetch(href); @@ -256,12 +266,10 @@ export async function imageOptimizer( res.statusCode = upstreamRes.status; upstreamBuffer = Buffer.from(await upstreamRes.arrayBuffer()); upstreamType = upstreamRes.headers.get("Content-Type") ?? undefined; - maxAge = getMaxAge(upstreamRes.headers.get("Cache-Control") ?? undefined); - if (upstreamRes.headers.get("Cache-Control")) { - res.setHeader( - "Cache-Control", - upstreamRes.headers.get("Cache-Control") as string - ); + cacheControl = upstreamRes.headers.get("Cache-Control"); + maxAge = getMaxAge(cacheControl ?? undefined); + if (cacheControl) { + res.setHeader("Cache-Control", cacheControl as string); } } else { let objectKey; @@ -284,6 +292,7 @@ export async function imageOptimizer( upstreamBuffer = response.body ?? Buffer.of(); upstreamType = response.contentType ?? undefined; + cacheControl = response.cacheControl; maxAge = getMaxAge(response.cacheControl); // If object response provides cache control header, use that @@ -357,11 +366,22 @@ export async function imageOptimizer( } const optimizedBuffer = await transformer.toBuffer(); - await promises.mkdir(hashDir, { recursive: true }); + await Promise.all([ + promises.mkdir(hashDir, { recursive: true }), + promises.mkdir(metaDir, { recursive: true }) + ]); const extension = getExtension(contentType); const etag = getHash([optimizedBuffer]); - const filename = join(hashDir, `${expireAt}.${etag}.${extension}`); - await promises.writeFile(filename, optimizedBuffer); + const fileName = `${expireAt}.${etag}.${extension}`; + const filePath = join(hashDir, fileName); + const metaFilename = join(metaDir, `${fileName}.json`); + await Promise.all([ + promises.writeFile(filePath, optimizedBuffer), + promises.writeFile( + metaFilename, + JSON.stringify({ headers: { "Cache-Control": cacheControl } }) + ) + ]); sendResponse(req, res, contentType, optimizedBuffer); } catch (error: any) { console.error( diff --git a/packages/libs/core/tests/images/imageOptimizer.test.ts b/packages/libs/core/tests/images/imageOptimizer.test.ts index 6f6c9beae6..65bfdf1601 100644 --- a/packages/libs/core/tests/images/imageOptimizer.test.ts +++ b/packages/libs/core/tests/images/imageOptimizer.test.ts @@ -1,11 +1,11 @@ import sharp from "sharp"; -import { ImagesManifest } from "../../src"; +import { ImagesManifest, PlatformClient } from "../../src"; import { imageOptimizer } from "../../src/images/imageOptimizer"; import imagesManifest from "./image-images-manifest.json"; +import fs from "fs"; import url from "url"; import http from "http"; import Stream from "stream"; -import { PlatformClient } from "../../src"; import { jest } from "@jest/globals"; jest.mock("node-fetch", () => require("fetch-mock-jest").sandbox()); @@ -103,7 +103,7 @@ describe("Image optimizer", () => { ); }; - beforeEach(async () => { + const setupPlatformClientResponse = async (cacheControlHeader?: string) => { const imageBuffer: Buffer = await sharp({ create: { width: 100, @@ -122,9 +122,14 @@ describe("Image optimizer", () => { expires: undefined, eTag: "etag", statusCode: 200, - cacheControl: undefined, + cacheControl: cacheControlHeader, contentType: "image/png" }); + }; + + beforeEach(() => { + fs.rmSync("/tmp/cache/images", { recursive: true, force: true }); + fs.rmSync("/tmp/cache/imageMeta", { recursive: true, force: true }); }); describe("Routes", () => { @@ -137,6 +142,7 @@ describe("Image optimizer", () => { `( "serves image request", async ({ imagePath, accept, expectedObjectKey }) => { + await setupPlatformClientResponse(); const { parsedUrl, req, res } = createEventByImagePath(imagePath, { accept: accept }); @@ -162,47 +168,61 @@ describe("Image optimizer", () => { ); it.each` - imagePath - ${"/test-image-cached.png"} - `("serves cached image on second request", async ({ imagePath }) => { - const { - parsedUrl: parsedUrl1, - req: req1, - res: res1 - } = createEventByImagePath(imagePath); - const { - parsedUrl: parsedUrl2, - req: req2, - res: res2 - } = createEventByImagePath(imagePath); + imagePath | cacheControlHeader + ${"/test-image-cached.png"} | ${undefined} + ${"/test-image-cached.png"} | ${"public,max-age=31536000,immutable"} + `( + "serves cached image on second request with $cacheControlHeader cache header", + async ({ imagePath, cacheControlHeader }) => { + await setupPlatformClientResponse(cacheControlHeader); + const { + parsedUrl: parsedUrl1, + req: req1, + res: res1 + } = createEventByImagePath(imagePath); + const { + parsedUrl: parsedUrl2, + req: req2, + res: res2 + } = createEventByImagePath(imagePath); - await imageOptimizer( - "", - imagesManifest as ImagesManifest, - req1, - res1, - parsedUrl1, - mockPlatformClient as PlatformClient - ); - await imageOptimizer( - "", - imagesManifest as ImagesManifest, - req2, - res2, - parsedUrl2, - mockPlatformClient as PlatformClient - ); + await imageOptimizer( + "", + imagesManifest as ImagesManifest, + req1, + res1, + parsedUrl1, + mockPlatformClient as PlatformClient + ); + await imageOptimizer( + "", + imagesManifest as ImagesManifest, + req2, + res2, + parsedUrl2, + mockPlatformClient as PlatformClient + ); - expect(res1.statusCode).toEqual(200); - expect(res2.statusCode).toEqual(200); + expect(res1.statusCode).toEqual(200); + expect(res2.statusCode).toEqual(200); - expect(mockPlatformClient.getObject).toBeCalledTimes(1); - }); + let defaultCacheHeader = "public, max-age=60"; + expect(res1.headers["cache-control"]).toEqual( + cacheControlHeader ?? defaultCacheHeader + ); + expect(res2.headers["cache-control"]).toEqual( + cacheControlHeader ?? defaultCacheHeader + ); + + expect(mockPlatformClient.getObject).toBeCalledTimes(1); + } + ); it.each` imagePath ${"/test-image-etag.png"} `("serves 304 when etag matches", async ({ imagePath }) => { + await setupPlatformClientResponse(); const { parsedUrl: parsedUrl1, req: req1,