diff --git a/.changeset/tired-phones-cover.md b/.changeset/tired-phones-cover.md new file mode 100644 index 000000000..ba6286d9f --- /dev/null +++ b/.changeset/tired-phones-cover.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/aws": patch +--- + +Refactor image optimization replacement function to be more readable diff --git a/packages/open-next/src/adapters/plugins/image-optimization/image-optimization.replacement.ts b/packages/open-next/src/adapters/plugins/image-optimization/image-optimization.replacement.ts index 7af3128d6..e990cd7c2 100644 --- a/packages/open-next/src/adapters/plugins/image-optimization/image-optimization.replacement.ts +++ b/packages/open-next/src/adapters/plugins/image-optimization/image-optimization.replacement.ts @@ -27,61 +27,82 @@ export async function optimizeImage( ) { const { isAbsolute, href } = imageParams; - // Signature for the fetch Image functions have changed across Next.js versions. - const isV15After15510 = - compareSemver(globalThis.nextVersion, ">=", "15.5.10") && - compareSemver(globalThis.nextVersion, "<", "16"); - const isV15After15515 = - compareSemver(globalThis.nextVersion, ">=", "15.5.16") && - compareSemver(globalThis.nextVersion, "<", "16"); - const isV16Plus = compareSemver(globalThis.nextVersion, ">=", "16"); - const isAfter1615 = compareSemver(globalThis.nextVersion, ">=", "16.1.5"); - const isAfter1625 = compareSemver(globalThis.nextVersion, ">=", "16.2.5"); - - // fetchInternalImage signature varies across Next.js versions: - // <=v15.5.15, v16.0.0–v16.2.4: fetchInternalImage(href, req, res, handleRequest) - // v15.5.16–v15.x, v16.2.5+: fetchInternalImage(href, req, res, maximumResponseBody, handleRequest) - const isNewArgsForInternalFetch = isAfter1625 || isV15After15515; - - // fetchExternalImage signature varies across Next.js versions: - // <=v15.5.9: fetchExternalImage(href) - // v15.5.10–v15.x: fetchExternalImage(href, maximumResponseBody) - // v16.0.0–v16.1.4: fetchExternalImage(href, dangerouslyAllowLocalIP) - // v16.1.5+: fetchExternalImage(href, dangerouslyAllowLocalIP, maximumResponseBody) - // The default value for maximumResponseBody is 50KB in Next code // https://github.com/vercel/next.js/blob/0f38c522/packages/next/src/shared/lib/image-config.ts#L164 const maximumResponseBody = nextConfig.images?.maximumResponseBody || 50_000_000; + /** + * fetchExternalImage signature varies across Next.js versions: + * <=v15.5.9: fetchExternalImage(href) + * v15.5.10–v15.x: fetchExternalImage(href, maximumResponseBody) + * v16.0.0–v16.1.4: fetchExternalImage(href, dangerouslyAllowLocalIP) + * v16.1.5+: fetchExternalImage(href, dangerouslyAllowLocalIP, maximumResponseBody) + * + * https://github.com/vercel/next.js/blob/bfe2ab4/packages/next/src/server/image-optimizer.ts#L711 + */ + const callFetchExternalImage = () => { + const version = globalThis.nextVersion; + + // v16.1.5+ + if (compareSemver(version, ">=", "16.1.5")) { + return fetchExternalImage(href, false, maximumResponseBody); + } + + // v16.0.0–v16.1.4 + if (compareSemver(version, ">=", "16")) { + // @ts-expect-error - fetchExternalImage signature varies across Next.js versions + return fetchExternalImage(href, false); + } + + // v15.5.10–v15.x + if (compareSemver(version, ">=", "15.5.10")) { + // @ts-expect-error - fetchExternalImage signature varies across Next.js versions + return fetchExternalImage(href, maximumResponseBody); + } + + // <=v15.5.9 + // @ts-expect-error - fetchExternalImage signature varies across Next.js versions + return fetchExternalImage(href); + }; + + /** + * fetchInternalImage signature varies across Next.js versions: + * <=v15.5.15, v16.0.0–v16.2.4: fetchInternalImage(href, req, res, handleRequest) + * v15.5.16–v15.x, v16.2.5+: fetchInternalImage(href, req, res, maximumResponseBody, handleRequest) + */ + const callFetchInternalImage = () => { + const version = globalThis.nextVersion; + + const isV15WithNewArgs = + compareSemver(version, ">=", "15.5.16") && + compareSemver(version, "<", "16"); + + // v15.5.16–v15.x, v16.2.5+ + if (isV15WithNewArgs || compareSemver(version, ">=", "16.2.5")) { + return fetchInternalImage( + href, + // @ts-expect-error - It is supposed to be an IncomingMessage object, but only the headers are used. + { headers }, + {}, // res object is not necessary as it's not actually used. + maximumResponseBody, + handleRequest, + ); + } + + // <=v15.5.15, v16.0.0–v16.2.4 + // @ts-expect-error - fetchInternalImage signature has changed in Next.js 15.5.16 and 16.2.5 + return fetchInternalImage( + href, + { headers }, + {}, // res object is not necessary as it's not actually used. + handleRequest, + ); + }; + const imageUpstream = isAbsolute - ? // https://github.com/vercel/next.js/blob/bfe2ab4/packages/next/src/server/image-optimizer.ts#L711 - await (isAfter1615 - ? fetchExternalImage(href, false, maximumResponseBody) - : isV16Plus - ? // @ts-expect-error - fetchExternalImage signature varies across Next.js versions - fetchExternalImage(href, false) - : isV15After15510 - ? // @ts-expect-error - fetchExternalImage signature varies across Next.js versions - fetchExternalImage(href, maximumResponseBody) - : // @ts-expect-error - fetchExternalImage signature varies across Next.js versions - fetchExternalImage(href)) - : await (isNewArgsForInternalFetch - ? fetchInternalImage( - href, - // @ts-expect-error - It is supposed to be an IncomingMessage object, but only the headers are used. - { headers }, - {}, // res object is not necessary as it's not actually used. - maximumResponseBody, - handleRequest, - ) - : // @ts-expect-error - fetchInternalImage signature has changed in Next.js 15.5.16 and 16.2.5 - fetchInternalImage( - href, - { headers }, - {}, // res object is not necessary as it's not actually used. - handleRequest, - )); + ? await callFetchExternalImage() + : await callFetchInternalImage(); const result = await imageOptimizer( imageUpstream, diff --git a/packages/tests-unit/tests/adapters/image-optimization.test.ts b/packages/tests-unit/tests/adapters/image-optimization.test.ts new file mode 100644 index 000000000..3d283709b --- /dev/null +++ b/packages/tests-unit/tests/adapters/image-optimization.test.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +// The file under test imports from `next/dist/server/image-optimizer` which +// is only available at runtime inside a user's built Next.js app. We mock the +// module so the tests can run independently of any Next.js installation. +const mockFetchExternalImage = vi.fn(); +const mockFetchInternalImage = vi.fn(); +const mockImageOptimizer = vi.fn(); + +vi.mock("next/dist/server/image-optimizer", () => ({ + fetchExternalImage: (...args: unknown[]) => mockFetchExternalImage(...args), + fetchInternalImage: (...args: unknown[]) => mockFetchInternalImage(...args), + imageOptimizer: (...args: unknown[]) => mockImageOptimizer(...args), +})); + +vi.mock("@opennextjs/aws/adapters/logger.js", () => ({ + debug: vi.fn(), +})); + +// Imported after the mocks are registered so the module uses the mocked +// versions. +const { optimizeImage } = await import( + "@opennextjs/aws/adapters/plugins/image-optimization/image-optimization.replacement.js" +); + +declare global { + var nextVersion: string; +} + +const HREF = "/some/image.png"; +const UPSTREAM = { buffer: Buffer.from(""), contentType: "image/png" }; +const OPTIMIZED = { + buffer: Buffer.from("optimized"), + contentType: "image/png", +}; + +const handleRequest = vi.fn(); + +function callOptimizeImage( + overrides: { + isAbsolute?: boolean; + maximumResponseBody?: number; + } = {}, +) { + const { isAbsolute = false, maximumResponseBody } = overrides; + const headers = { "x-custom": "value" }; + const imageParams = { isAbsolute, href: HREF }; + const nextConfig = { + images: maximumResponseBody ? { maximumResponseBody } : {}, + } as any; + + return optimizeImage(headers, imageParams, nextConfig, handleRequest); +} + +describe("optimizeImage (replacement)", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetchExternalImage.mockResolvedValue(UPSTREAM); + mockFetchInternalImage.mockResolvedValue(UPSTREAM); + mockImageOptimizer.mockResolvedValue(OPTIMIZED); + }); + + describe("fetchExternalImage (isAbsolute=true)", () => { + it("calls fetchExternalImage(href) for <= v15.5.9", async () => { + globalThis.nextVersion = "15.5.9"; + await callOptimizeImage({ isAbsolute: true }); + expect(mockFetchExternalImage).toHaveBeenCalledTimes(1); + expect(mockFetchExternalImage).toHaveBeenCalledWith(HREF); + }); + + it("calls fetchExternalImage(href, maximumResponseBody) for v15.5.10", async () => { + globalThis.nextVersion = "15.5.10"; + await callOptimizeImage({ isAbsolute: true }); + expect(mockFetchExternalImage).toHaveBeenCalledWith(HREF, 50_000_000); + }); + + it("calls fetchExternalImage(href, maximumResponseBody) for v15.5.15", async () => { + globalThis.nextVersion = "15.5.15"; + await callOptimizeImage({ isAbsolute: true }); + expect(mockFetchExternalImage).toHaveBeenCalledWith(HREF, 50_000_000); + }); + + it("calls fetchExternalImage(href, maximumResponseBody) for v15.5.16", async () => { + // Internal fetch args change at 15.5.16, but external args stay + // the same as 15.5.10+ until v16. + globalThis.nextVersion = "15.5.16"; + await callOptimizeImage({ isAbsolute: true }); + expect(mockFetchExternalImage).toHaveBeenCalledWith(HREF, 50_000_000); + }); + + it("calls fetchExternalImage(href, false) for v16.0.0", async () => { + globalThis.nextVersion = "16.0.0"; + await callOptimizeImage({ isAbsolute: true }); + expect(mockFetchExternalImage).toHaveBeenCalledWith(HREF, false); + }); + + it("calls fetchExternalImage(href, false) for v16.1.4", async () => { + globalThis.nextVersion = "16.1.4"; + await callOptimizeImage({ isAbsolute: true }); + expect(mockFetchExternalImage).toHaveBeenCalledWith(HREF, false); + }); + + it("calls fetchExternalImage(href, false, maximumResponseBody) for v16.1.5", async () => { + globalThis.nextVersion = "16.1.5"; + await callOptimizeImage({ isAbsolute: true }); + expect(mockFetchExternalImage).toHaveBeenCalledWith( + HREF, + false, + 50_000_000, + ); + }); + + it("calls fetchExternalImage(href, false, maximumResponseBody) for v16.2.5", async () => { + globalThis.nextVersion = "16.2.5"; + await callOptimizeImage({ isAbsolute: true }); + expect(mockFetchExternalImage).toHaveBeenCalledWith( + HREF, + false, + 50_000_000, + ); + }); + + it("forwards a custom maximumResponseBody from nextConfig", async () => { + globalThis.nextVersion = "16.2.5"; + await callOptimizeImage({ + isAbsolute: true, + maximumResponseBody: 1_234, + }); + expect(mockFetchExternalImage).toHaveBeenCalledWith(HREF, false, 1_234); + }); + + it("does not call fetchInternalImage", async () => { + globalThis.nextVersion = "16.2.5"; + await callOptimizeImage({ isAbsolute: true }); + expect(mockFetchInternalImage).not.toHaveBeenCalled(); + }); + }); + + describe("fetchInternalImage (isAbsolute=false)", () => { + it("uses the old signature for v15.5.15", async () => { + globalThis.nextVersion = "15.5.15"; + await callOptimizeImage({ isAbsolute: false }); + expect(mockFetchInternalImage).toHaveBeenCalledTimes(1); + expect(mockFetchInternalImage).toHaveBeenCalledWith( + HREF, + { headers: { "x-custom": "value" } }, + {}, + handleRequest, + ); + }); + + it("uses the new signature for v15.5.16 (with maximumResponseBody)", async () => { + globalThis.nextVersion = "15.5.16"; + await callOptimizeImage({ isAbsolute: false }); + expect(mockFetchInternalImage).toHaveBeenCalledWith( + HREF, + { headers: { "x-custom": "value" } }, + {}, + 50_000_000, + handleRequest, + ); + }); + + it("uses the old signature for v16.0.0 (< 16.2.5)", async () => { + globalThis.nextVersion = "16.0.0"; + await callOptimizeImage({ isAbsolute: false }); + expect(mockFetchInternalImage).toHaveBeenCalledWith( + HREF, + { headers: { "x-custom": "value" } }, + {}, + handleRequest, + ); + }); + + it("uses the old signature for v16.2.4", async () => { + globalThis.nextVersion = "16.2.4"; + await callOptimizeImage({ isAbsolute: false }); + expect(mockFetchInternalImage).toHaveBeenCalledWith( + HREF, + { headers: { "x-custom": "value" } }, + {}, + handleRequest, + ); + }); + + it("uses the new signature for v16.2.5+", async () => { + globalThis.nextVersion = "16.2.5"; + await callOptimizeImage({ isAbsolute: false }); + expect(mockFetchInternalImage).toHaveBeenCalledWith( + HREF, + { headers: { "x-custom": "value" } }, + {}, + 50_000_000, + handleRequest, + ); + }); + + it("does not call fetchExternalImage", async () => { + globalThis.nextVersion = "16.2.5"; + await callOptimizeImage({ isAbsolute: false }); + expect(mockFetchExternalImage).not.toHaveBeenCalled(); + }); + }); + + describe("imageOptimizer", () => { + it("is called with the upstream result and returns its value", async () => { + globalThis.nextVersion = "16.2.5"; + const result = await callOptimizeImage({ isAbsolute: true }); + expect(mockImageOptimizer).toHaveBeenCalledTimes(1); + expect(mockImageOptimizer).toHaveBeenCalledWith( + UPSTREAM, + { isAbsolute: true, href: HREF }, + expect.any(Object), + false, + ); + expect(result).toBe(OPTIMIZED); + }); + }); +});