Skip to content

Commit 4fd6457

Browse files
committed
feat: add format query string to control the image format
1 parent 6eae5ac commit 4fd6457

File tree

2 files changed

+183
-36
lines changed

2 files changed

+183
-36
lines changed

index.js

Lines changed: 106 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,67 +10,54 @@ exports.handler = async (event, context, callback) => {
1010
const request = event.Records[0].cf.request;
1111
const response = event.Records[0].cf.response;
1212

13-
if (
14-
'200' !== response.status ||
15-
!request.origin ||
16-
!request.origin.s3 ||
17-
!request.origin.s3.domainName
18-
) {
13+
if (shouldSkipProcessing(request, response)) {
1914
return callback(null, response);
2015
}
2116

22-
const match = request.origin.s3.domainName.match(/([^.]*)\.s3(\.[^.]*)?\.amazonaws\.com/i);
17+
const bucketDetails = extractBucketDetails(request);
2318

24-
if (!match || !match[1] || 'string' !== typeof match[1]) {
19+
if (!bucketDetails) {
2520
return callback(null, response);
2621
}
2722

2823
const allowedContentTypes = ['image/gif', 'image/jpeg', 'image/png'];
29-
const bucket = match[1];
24+
const bucket = bucketDetails;
3025
const key = decodeURIComponent(request.uri.substring(1));
26+
const params = new URLSearchParams(request.querystring);
27+
const formatSetting = params.get('format');
3128

32-
const getObjectCommand = new GetObjectCommand({ Bucket: bucket, Key: key });
33-
const objectResponse = await s3Client.send(getObjectCommand);
29+
const objectResponse = await fetchOriginalImage(bucket, key);
3430

35-
if (
36-
!objectResponse.ContentType ||
37-
!allowedContentTypes.includes(objectResponse.ContentType) ||
38-
('image/gif' === objectResponse.ContentType &&
39-
animated(await streamToBuffer(objectResponse.Body)))
40-
) {
31+
if (shouldSkipImageProcessing(objectResponse, allowedContentTypes)) {
32+
return callback(null, response);
33+
}
34+
35+
const isAnimatedGif =
36+
'image/gif' === objectResponse.ContentType &&
37+
animated(await streamToBuffer(objectResponse.Body));
38+
39+
if (isAnimatedGif) {
4140
return callback(null, response);
4241
}
4342

44-
let contentType = null;
4543
const objectBody = await streamToBuffer(objectResponse.Body);
4644
const image = sharp(objectBody);
47-
const params = new URLSearchParams(request.querystring);
45+
const preserveOriginalFormat = formatSetting === 'original';
4846

49-
if ('image/gif' === objectResponse.ContentType) {
50-
image.png();
51-
contentType = [{ key: 'Content-Type', value: 'image/png' }];
52-
}
47+
let contentType = null;
5348

54-
if (request.headers['accept'] && request.headers['accept'][0].value.match('image/webp')) {
55-
image.webp({
56-
quality: Math.round(Math.min(Math.max(parseInt(params.get('quality'), 10) || 82, 0), 100)),
57-
});
58-
contentType = [{ key: 'Content-Type', value: 'image/webp' }];
49+
if (!preserveOriginalFormat) {
50+
contentType = await processImageFormat(image, objectResponse, request, params, formatSetting);
5951
}
6052

6153
if (params.has('width') || params.has('height')) {
62-
image.resize({
63-
width: parseInt(params.get('width'), 10) || null,
64-
height: parseInt(params.get('height'), 10) || null,
65-
fit: params.has('cropped') ? sharp.fit.cover : sharp.fit.inside,
66-
withoutEnlargement: true,
67-
});
54+
applyResize(image, params);
6855
}
6956

7057
const buffer = await image.toBuffer();
7158
const responseBody = buffer.toString('base64');
7259

73-
if (1330000 < Buffer.byteLength(responseBody)) {
60+
if (isResponseTooLarge(responseBody)) {
7461
return callback(null, response);
7562
}
7663

@@ -87,7 +74,90 @@ exports.handler = async (event, context, callback) => {
8774
}
8875
};
8976

90-
// Helper function to convert a stream to a buffer
77+
function shouldSkipProcessing(request, response) {
78+
return (
79+
'200' !== response.status ||
80+
!request.origin ||
81+
!request.origin.s3 ||
82+
!request.origin.s3.domainName
83+
);
84+
}
85+
86+
function extractBucketDetails(request) {
87+
const match = request.origin.s3.domainName.match(/([^.]*)\.s3(\.[^.]*)?\.amazonaws\.com/i);
88+
89+
if (!match || !match[1] || 'string' !== typeof match[1]) {
90+
return null;
91+
}
92+
93+
return match[1];
94+
}
95+
96+
function fetchOriginalImage(bucket, key) {
97+
const getObjectCommand = new GetObjectCommand({ Bucket: bucket, Key: key });
98+
return s3Client.send(getObjectCommand);
99+
}
100+
101+
function shouldSkipImageProcessing(objectResponse, allowedContentTypes) {
102+
return !objectResponse.ContentType || !allowedContentTypes.includes(objectResponse.ContentType);
103+
}
104+
105+
function processImageFormat(image, objectResponse, request, params, formatSetting) {
106+
let contentType = null;
107+
108+
if (formatSetting && formatSetting !== 'auto') {
109+
return applyExplicitFormat(image, formatSetting);
110+
}
111+
112+
if ('image/gif' === objectResponse.ContentType) {
113+
image.png();
114+
contentType = [{ key: 'Content-Type', value: 'image/png' }];
115+
}
116+
117+
if (request.headers['accept'] && request.headers['accept'][0].value.match('image/webp')) {
118+
const quality = calculateQuality(params);
119+
image.webp({ quality });
120+
contentType = [{ key: 'Content-Type', value: 'image/webp' }];
121+
}
122+
123+
return contentType;
124+
}
125+
126+
function applyExplicitFormat(image, formatSetting) {
127+
switch (formatSetting.toLowerCase()) {
128+
case 'webp':
129+
image.webp();
130+
return [{ key: 'Content-Type', value: 'image/webp' }];
131+
case 'png':
132+
image.png();
133+
return [{ key: 'Content-Type', value: 'image/png' }];
134+
case 'jpeg':
135+
case 'jpg':
136+
image.jpeg();
137+
return [{ key: 'Content-Type', value: 'image/jpeg' }];
138+
default:
139+
return null;
140+
}
141+
}
142+
143+
function calculateQuality(params) {
144+
const rawQuality = parseInt(params.get('quality'), 10) || 82;
145+
return Math.round(Math.min(Math.max(rawQuality, 0), 100));
146+
}
147+
148+
function applyResize(image, params) {
149+
image.resize({
150+
width: parseInt(params.get('width'), 10) || null,
151+
height: parseInt(params.get('height'), 10) || null,
152+
fit: params.has('cropped') ? sharp.fit.cover : sharp.fit.inside,
153+
withoutEnlargement: true,
154+
});
155+
}
156+
157+
function isResponseTooLarge(responseBody) {
158+
return 1330000 < Buffer.byteLength(responseBody);
159+
}
160+
91161
async function streamToBuffer(stream) {
92162
const chunks = [];
93163

tests/image-processing-integration.test.js

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,4 +283,81 @@ describe('Image Processing Integration', () => {
283283
expect(callback.mock.calls[0][1]).toBe(originalResponse);
284284
expect(callback.mock.calls[0][1].bodyEncoding).toBeUndefined();
285285
});
286+
287+
test('should preserve original GIF format when format=original is specified', async () => {
288+
const event = createCloudFrontEvent({
289+
uri: '/test-image.gif',
290+
querystring: 'format=original',
291+
});
292+
293+
const callback = jest.fn();
294+
await handler(event, {}, callback);
295+
296+
expect(callback).toHaveBeenCalledTimes(1);
297+
expect(callback).toHaveBeenCalledWith(null, expect.any(Object));
298+
299+
const response = callback.mock.calls[0][1];
300+
301+
expect(response.status).toBe('200');
302+
expect(response.bodyEncoding).toBe('base64');
303+
304+
expect(response.headers['content-type']).toBeDefined();
305+
306+
const responseBuffer = Buffer.from(response.body, 'base64');
307+
const metadata = await sharp(responseBuffer).metadata();
308+
309+
expect(metadata.format).toBe('gif');
310+
});
311+
312+
test('should preserve original JPEG format when format=original is specified, even with WebP Accept header', async () => {
313+
const event = createCloudFrontEvent({
314+
uri: '/test-image.jpg',
315+
querystring: 'format=original',
316+
headers: {
317+
accept: [{ key: 'Accept', value: 'image/webp,image/*' }],
318+
},
319+
});
320+
321+
const callback = jest.fn();
322+
await handler(event, {}, callback);
323+
324+
expect(callback).toHaveBeenCalledTimes(1);
325+
expect(callback).toHaveBeenCalledWith(null, expect.any(Object));
326+
327+
const response = callback.mock.calls[0][1];
328+
329+
expect(response.status).toBe('200');
330+
expect(response.bodyEncoding).toBe('base64');
331+
332+
expect(response.headers['content-type']).toBeDefined();
333+
expect(response.headers['content-type'][0].value).toBe('image/jpeg');
334+
335+
const responseBuffer = Buffer.from(response.body, 'base64');
336+
const metadata = await sharp(responseBuffer).metadata();
337+
338+
expect(metadata.format).toBe('jpeg');
339+
});
340+
341+
test('should force conversion to specific format when explicitly requested', async () => {
342+
const event = createCloudFrontEvent({
343+
uri: '/test-image.jpg',
344+
querystring: 'format=webp',
345+
});
346+
347+
const callback = jest.fn();
348+
await handler(event, {}, callback);
349+
350+
expect(callback).toHaveBeenCalledTimes(1);
351+
352+
const response = callback.mock.calls[0][1];
353+
354+
expect(response.headers['content-type']).toEqual([
355+
{ key: 'Content-Type', value: 'image/webp' },
356+
]);
357+
358+
const responseBuffer = Buffer.from(response.body, 'base64');
359+
const metadata = await sharp(responseBuffer).metadata();
360+
361+
expect(metadata.format).toBe('webp');
362+
});
286363
});

0 commit comments

Comments
 (0)