Skip to content
Open
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
76 changes: 74 additions & 2 deletions source/constructs/lib/v8/functions/dit-header-normalization.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0




//const FORMAT_PRIORITY = ['webp', 'avif', 'jpeg', 'png', 'heif', 'tiff', 'raw', 'gif'];
// Priority: WebP first to avoid Chromium AVIF top-level-navigation download bug
// (Chrome/Edge fail to render AVIF on direct URL nav even with Content-Disposition: inline).
// Reverts the Issue #5 reorder. AVIF is still served when client explicitly accepts only AVIF.
const FORMAT_PRIORITY = ['webp', 'avif', 'jpeg', 'png', 'heif', 'tiff', 'raw', 'gif'];




const FORMAT_MAPPING = {
'image/webp': 'webp',
'image/avif': 'avif',
Expand All @@ -16,29 +26,81 @@ const FORMAT_MAPPING = {
'image/gif': 'gif'
};

function normalizeAcceptHeader(acceptHeader) {
const NON_ALPHA_FORMATS = ['jpeg'];

// Accept-header wildcard tokens that mean "any image type"
const WILDCARD_TOKENS = ['*/*', 'image/*'];

// User-Agent substrings that confirm WebP support.
// Verified support matrices:
// - Chrome 32+ (Jan 2014), Edge 18+, Firefox 65+, Opera 19+: WebP full support
// - Safari 14+ (macOS 11/iOS 14, Sep 2020): WebP full support
// - Mobile WebViews (Android Chrome, iOS WKWebView 14+): WebP full support
// References: https://caniuse.com/webp
const WEBP_CAPABLE_UA_PATTERNS = [
/Chrome\/\d+/i, // Chrome, Edge (Chromium), Opera, modern Android browsers
/Firefox\/(6[5-9]|[7-9]\d|\d{3,})/i, // Firefox 65+
/Version\/(1[4-9]|[2-9]\d|\d{3,})[\d.]*\s.*Safari\//i, // Safari 14+ desktop and iOS (Mobile token may appear between Version and Safari)
// Safari 14+ desktop and iOS (Mobile token may appear between Version and Safari)
// Safari 14+ (Version token, Safari trailing)
/CriOS\/\d+/i, // Chrome on iOS
/FxiOS\/\d+/i, // Firefox on iOS (uses iOS WebView, WebP support tied to OS)
/EdgiOS\/\d+/i // Edge on iOS
];

function isWebpCapableUserAgent(ua) {
if (!ua) return false;
for (var i = 0; i < WEBP_CAPABLE_UA_PATTERNS.length; i++) {
if (WEBP_CAPABLE_UA_PATTERNS[i].test(ua)) {
return true;
}
}
return false;
}

function normalizeAcceptHeader(acceptHeader, userAgent, excludeFormats) {
if (!acceptHeader) return null;

const mimeTypes = acceptHeader
.split(',')
.map(part => part.split(';')[0].trim().toLowerCase());

var supportedFormats = [];
var hasWildcard = false;
for (var i = 0; i < mimeTypes.length; i++) {
if (WILDCARD_TOKENS.indexOf(mimeTypes[i]) !== -1) {
hasWildcard = true;
continue;
}
if (FORMAT_MAPPING[mimeTypes[i]]) {
supportedFormats.push(FORMAT_MAPPING[mimeTypes[i]]);
}
}

// Issue #2: drop alpha-incompatible formats (e.g. JPEG) when source has alpha
if (excludeFormats && excludeFormats.length > 0) {
supportedFormats = supportedFormats.filter(function (fmt) {
return excludeFormats.indexOf(fmt) === -1;
});
}

// Specific MIME types win over wildcard
for (var j = 0; j < FORMAT_PRIORITY.length; j++) {
if (supportedFormats.indexOf(FORMAT_PRIORITY[j]) !== -1) {
return 'image/' + FORMAT_PRIORITY[j];
}
}

// Wildcard fallback: only set dit-accept if UA confirms WebP support.
// Unknown UAs fall through to null -> no dit-accept -> source format served (status quo).
if (hasWildcard && isWebpCapableUserAgent(userAgent)) {
return 'image/webp';
}

return null;
}


async function handler(event) {
var request = event.request;
var headers = request.headers;
Expand Down Expand Up @@ -81,14 +143,24 @@ async function handler(event) {
request.headers[ditViewportWidthHeader] = { value: normalizedWidth.toString() };
}

// Issue #2: detect alpha-capable source via URL extension to prevent
// transparent PNG/GIF/WebP being converted to JPEG (which loses alpha).
var excludeFormats = [];
var uri = request.uri ? request.uri.toLowerCase() : '';
if (uri.endsWith('.png') || uri.endsWith('.gif') || uri.endsWith('.webp')) {
excludeFormats = NON_ALPHA_FORMATS;
}


// Map standard headers to DIT headers for cache key optimization
if (headers["host"] && ditHostHeader) {
request.headers[ditHostHeader] = { value: headers["host"]["value"] };
}

// Only set dit-accept if format parameter is not present in query string
if (headers["accept"] && ditAcceptHeader && !(request.querystring && request.querystring.format)) {
const normalizedFormat = normalizeAcceptHeader(headers["accept"]["value"]);
const userAgent = headers["user-agent"] && headers["user-agent"]["value"];
const normalizedFormat = normalizeAcceptHeader(headers["accept"]["value"], userAgent, excludeFormats);
if (normalizedFormat) {
request.headers[ditAcceptHeader] = { value: normalizedFormat };
}
Expand Down
172 changes: 172 additions & 0 deletions source/constructs/lib/v8/test/unit/dit-function.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import * as path from "path";
// CloudFront Function types
interface CloudFrontRequest {
headers: Record<string, { value: string }>;
uri?: string;
querystring?: Record<string, { value: string }>;
}


interface CloudFrontEvent {
request: CloudFrontRequest;
}
Expand Down Expand Up @@ -162,6 +165,175 @@ describe("DIT CloudFront Function", () => {
});

describe("Accept header normalization", () => {
test("should fallback to webp for */* with modern Chrome UA", async () => {
const event: CloudFrontEvent = {
request: {
headers: {
host: { value: "test.com" },
accept: { value: "*/*" },
"user-agent": { value: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" }
},
querystring: {}
}
};
const result = await handler(event);
expect(result.headers["dit-accept"]?.value).toBe("image/webp");
});

test("should fallback to webp for image/* with modern Safari UA", async () => {
const event: CloudFrontEvent = {
request: {
headers: {
host: { value: "test.com" },
accept: { value: "image/*,*/*;q=0.8" },
"user-agent": { value: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1" }
},
querystring: {}
}
};
const result = await handler(event);
expect(result.headers["dit-accept"]?.value).toBe("image/webp");
});

test("should NOT set dit-accept for */* with unknown/legacy UA", async () => {
const event: CloudFrontEvent = {
request: {
headers: {
host: { value: "test.com" },
accept: { value: "*/*" },
"user-agent": { value: "curl/7.79.1" }
},
querystring: {}
}
};
const result = await handler(event);
expect(result.headers["dit-accept"]).toBeUndefined();
});

test("should NOT set dit-accept for */* when User-Agent is missing", async () => {
const event: CloudFrontEvent = {
request: {
headers: {
host: { value: "test.com" },
accept: { value: "*/*" }
},
querystring: {}
}
};
const result = await handler(event);
expect(result.headers["dit-accept"]).toBeUndefined();
});

test("specific MIME types still win over wildcard fallback", async () => {
const event: CloudFrontEvent = {
request: {
headers: {
host: { value: "test.com" },
accept: { value: "image/avif,image/webp,*/*" },
"user-agent": { value: "Mozilla/5.0 ... Chrome/120.0.0.0 ..." }
},
querystring: {}
}
};
const result = await handler(event);
expect(result.headers["dit-accept"]?.value).toBe("image/webp"); // WebP first per post-revert priority
});

test("should exclude JPEG when source URL is .png (Issue #2 — preserve alpha)", async () => {
const event: CloudFrontEvent = {
request: {
uri: "/cdn-test/media/foo.png",
headers: {
host: { value: "test.com" },
accept: { value: "image/png, image/jpeg, image/gif, image/*" }
},
querystring: {}
}
};
const result = await handler(event);
expect(result.headers["dit-accept"]?.value).toBe("image/png");
});

test("should exclude JPEG when source URL is .gif", async () => {
const event: CloudFrontEvent = {
request: {
uri: "/foo.gif",
headers: {
host: { value: "test.com" },
accept: { value: "image/jpeg, image/gif" }
},
querystring: {}
}
};
const result = await handler(event);
expect(result.headers["dit-accept"]?.value).toBe("image/gif");
});

test("should exclude JPEG when source URL is .webp", async () => {
const event: CloudFrontEvent = {
request: {
uri: "/foo.webp",
headers: {
host: { value: "test.com" },
accept: { value: "image/jpeg, image/webp" }
},
querystring: {}
}
};
const result = await handler(event);
expect(result.headers["dit-accept"]?.value).toBe("image/webp");
});

//test("should still pick AVIF over WebP when source is .png and both accepted (priority preserved)", async () => {
test("should pick WebP over AVIF when source is .png and both accepted (post-revert priority)", async () => {
const event: CloudFrontEvent = {
request: {
uri: "/foo.png",
headers: {
host: { value: "test.com" },
accept: { value: "image/avif, image/webp, image/jpeg" }
},
querystring: {}
}
};
const result = await handler(event);
expect(result.headers["dit-accept"]?.value).toBe("image/webp");

});

test("should NOT exclude JPEG when source URL is .jpg (no alpha to lose)", async () => {
const event: CloudFrontEvent = {
request: {
uri: "/foo.jpg",
headers: {
host: { value: "test.com" },
accept: { value: "image/jpeg, image/webp" }
},
querystring: {}
}
};
const result = await handler(event);
// webp wins via priority; jpeg not excluded because source is jpg
expect(result.headers["dit-accept"]?.value).toBe("image/webp");
});

test("should handle uppercase PNG extension", async () => {
const event: CloudFrontEvent = {
request: {
uri: "/Foo.PNG",
headers: {
host: { value: "test.com" },
accept: { value: "image/png, image/jpeg" }
},
querystring: {}
}
};
const result = await handler(event);
expect(result.headers["dit-accept"]?.value).toBe("image/png");
});



test("should select highest priority format from Accept header", async () => {
const testCases = [
{ input: "image/avif,image/webp,image/png", expected: "image/webp" },
Expand Down
5 changes: 5 additions & 0 deletions source/container/src/routes/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,13 @@ router.get('*', async (req: Request, res: Response) => {
res.set('Access-Control-Allow-Origin', CORS_ORIGIN);
}
res.type(imageRequest.response.contentType || 'image/jpeg');
// Force inline rendering. Without this, browsers fall back to URL-extension
// heuristics; when the URL ends in .jpeg/.png but DIT serves AVIF/WebP
// (per Issue #5 priority), Chrome treats the mismatch as "download."
res.set('Content-Disposition', 'inline');
res.send(processedImage);


console.log(JSON.stringify({
requestId: imageRequest.requestId,
component: 'ImageRouter',
Expand Down
Loading