Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tired-phones-cover.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/aws": patch
---

Refactor image optimization replacement function to be more readable
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
219 changes: 219 additions & 0 deletions packages/tests-unit/tests/adapters/image-optimization.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading