@serverless-stack/nextjs-lambda
Version:
Provides handlers that can be used in CloudFront Lambda@Edge to deploy next.js applications to the edge
347 lines (346 loc) • 12.9 kB
JavaScript
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getMaxAge = exports.imageOptimizer = void 0;
const path_1 = require("path");
const accept_1 = require("@hapi/accept");
const fs_1 = require("fs");
const crypto_1 = require("crypto");
const serveStatic_1 = require("./serveStatic");
const is_animated_1 = __importDefault(require("is-animated"));
const sendEtagResponse_1 = require("../lib/sendEtagResponse");
const imageConfig_1 = require("./imageConfig");
const fs = __importStar(require("fs"));
const node_fetch_1 = __importDefault(require("node-fetch"));
let sharp;
const WEBP = "image/webp";
const PNG = "image/png";
const JPEG = "image/jpeg";
const GIF = "image/gif";
const SVG = "image/svg+xml";
const CACHE_VERSION = 2;
const MODERN_TYPES = [WEBP];
const ANIMATABLE_TYPES = [WEBP, PNG, GIF];
const VECTOR_TYPES = [SVG];
async function imageOptimizer({ basePath, bucketName, region }, imagesManifest, req, res, parsedUrl) {
var _a;
const imageConfig = (_a = imagesManifest === null || imagesManifest === void 0 ? void 0 : imagesManifest.images) !== null && _a !== void 0 ? _a : imageConfig_1.imageConfigDefault;
const { deviceSizes = [], imageSizes = [], domains = [], loader } = imageConfig;
const sizes = [...deviceSizes, ...imageSizes];
if (loader !== "default") {
res.statusCode = 404;
res.end("default loader not found");
return { finished: true };
}
const { headers } = req;
const { url, w, q } = parsedUrl.query;
const mimeType = getSupportedMimeType(MODERN_TYPES, headers.accept);
let href;
if (!url) {
res.statusCode = 400;
res.end('"url" parameter is required');
return { finished: true };
}
else if (Array.isArray(url)) {
res.statusCode = 400;
res.end('"url" parameter cannot be an array');
return { finished: true };
}
let isAbsolute;
if (url.startsWith("/")) {
href = url;
isAbsolute = false;
}
else {
let hrefParsed;
try {
hrefParsed = new URL(url);
href = hrefParsed.toString();
isAbsolute = true;
}
catch (_error) {
res.statusCode = 400;
res.end('"url" parameter is invalid');
return { finished: true };
}
if (!["http:", "https:"].includes(hrefParsed.protocol)) {
res.statusCode = 400;
res.end('"url" parameter is invalid');
return { finished: true };
}
if (!domains.includes(hrefParsed.hostname)) {
res.statusCode = 400;
res.end('"url" parameter is not allowed');
return { finished: true };
}
}
if (!w) {
res.statusCode = 400;
res.end('"w" parameter (width) is required');
return { finished: true };
}
else if (Array.isArray(w)) {
res.statusCode = 400;
res.end('"w" parameter (width) cannot be an array');
return { finished: true };
}
if (!q) {
res.statusCode = 400;
res.end('"q" parameter (quality) is required');
return { finished: true };
}
else if (Array.isArray(q)) {
res.statusCode = 400;
res.end('"q" parameter (quality) cannot be an array');
return { finished: true };
}
const width = parseInt(w, 10);
if (!width || isNaN(width)) {
res.statusCode = 400;
res.end('"w" parameter (width) must be a number greater than 0');
return { finished: true };
}
if (!sizes.includes(width)) {
res.statusCode = 400;
res.end(`"w" parameter (width) of ${width} is not allowed`);
return { finished: true };
}
const quality = parseInt(q);
if (isNaN(quality) || quality < 1 || quality > 100) {
res.statusCode = 400;
res.end('"q" parameter (quality) must be a number between 1 and 100');
return { finished: true };
}
const hash = getHash([CACHE_VERSION, href, width, quality, mimeType]);
const imagesDir = (0, path_1.join)("/tmp", "cache", "images");
const hashDir = (0, path_1.join)(imagesDir, hash);
const now = Date.now();
if (fs.existsSync(hashDir)) {
const files = await fs_1.promises.readdir(hashDir);
for (const file of files) {
const [prefix, etag, extension] = file.split(".");
const expireAt = Number(prefix);
const contentType = (0, serveStatic_1.getContentType)(extension);
const fsPath = (0, path_1.join)(hashDir, file);
if (now < expireAt) {
if (!res.getHeader("Cache-Control")) {
res.setHeader("Cache-Control", "public, max-age=60");
}
if ((0, sendEtagResponse_1.sendEtagResponse)(req, res, etag)) {
return { finished: true };
}
if (contentType) {
res.setHeader("Content-Type", contentType);
}
(0, fs_1.createReadStream)(fsPath).pipe(res);
return { finished: true };
}
else {
await fs_1.promises.unlink(fsPath);
}
}
}
let upstreamBuffer;
let upstreamType;
let maxAge;
if (isAbsolute) {
const upstreamRes = await (0, node_fetch_1.default)(href);
if (!upstreamRes.ok) {
res.statusCode = upstreamRes.status;
res.end('"url" parameter is valid but upstream response is invalid');
return { finished: true };
}
res.statusCode = upstreamRes.status;
upstreamBuffer = Buffer.from(await upstreamRes.arrayBuffer());
upstreamType = upstreamRes.headers.get("Content-Type");
maxAge = getMaxAge(upstreamRes.headers.get("Cache-Control"));
}
else {
let s3Key;
try {
if (href.startsWith(`${basePath}/static`) ||
href.startsWith(`${basePath}/_next/static`)) {
s3Key = href;
}
else {
s3Key = `${basePath}/public` + href;
}
if (s3Key.startsWith("/")) {
s3Key = s3Key.slice(1);
}
const s3Params = {
Bucket: bucketName,
Key: s3Key
};
const { S3Client } = await Promise.resolve().then(() => __importStar(require("@aws-sdk/client-s3/S3Client")));
const s3 = new S3Client({
region: region,
maxAttempts: 3
});
const { GetObjectCommand } = await Promise.resolve().then(() => __importStar(require("@aws-sdk/client-s3/commands/GetObjectCommand")));
const { Body, CacheControl, ContentType } = await s3.send(new GetObjectCommand(s3Params));
const s3chunks = [];
for await (const s3Chunk of Body) {
s3chunks.push(s3Chunk);
}
res.statusCode = 200;
upstreamBuffer = Buffer.concat(s3chunks);
upstreamType = ContentType !== null && ContentType !== void 0 ? ContentType : null;
maxAge = getMaxAge(CacheControl !== null && CacheControl !== void 0 ? CacheControl : null);
if (CacheControl) {
res.setHeader("Cache-Control", CacheControl);
}
}
catch (err) {
res.statusCode = 500;
res.end('"url" parameter is valid but upstream response is invalid');
console.error(`Error processing upstream response due to S3 error for s3Key: ${s3Key}, bucket: ${bucketName} and region: ${region}. Stack trace: ` +
err.stack);
return { finished: true };
}
}
if (upstreamType) {
const vector = VECTOR_TYPES.includes(upstreamType);
const animate = ANIMATABLE_TYPES.includes(upstreamType) && (0, is_animated_1.default)(upstreamBuffer);
if (vector || animate) {
sendResponse(req, res, upstreamType, upstreamBuffer);
return { finished: true };
}
}
const expireAt = maxAge * 1000 + now;
let contentType;
if (mimeType) {
contentType = mimeType;
}
else if ((upstreamType === null || upstreamType === void 0 ? void 0 : upstreamType.startsWith("image/")) && (0, serveStatic_1.getExtension)(upstreamType)) {
contentType = upstreamType;
}
else {
contentType = JPEG;
}
if (!sharp) {
try {
sharp = require("sharp");
}
catch (error) {
if (error.code === "MODULE_NOT_FOUND") {
error.message += "\n\nLearn more: https://err.sh/next.js/install-sharp";
console.error(error.stack);
sendResponse(req, res, upstreamType, upstreamBuffer);
return { finished: true };
}
throw error;
}
}
try {
const transformer = sharp(upstreamBuffer);
transformer.rotate();
const { width: metaWidth } = await transformer.metadata();
if (metaWidth && metaWidth > width) {
transformer.resize(width);
}
if (contentType === WEBP) {
transformer.webp({ quality });
}
else if (contentType === PNG) {
transformer.png({ quality });
}
else if (contentType === JPEG) {
transformer.jpeg({ quality });
}
const optimizedBuffer = await transformer.toBuffer();
await fs_1.promises.mkdir(hashDir, { recursive: true });
const extension = (0, serveStatic_1.getExtension)(contentType);
const etag = getHash([optimizedBuffer]);
const filename = (0, path_1.join)(hashDir, `${expireAt}.${etag}.${extension}`);
await fs_1.promises.writeFile(filename, optimizedBuffer);
sendResponse(req, res, contentType, optimizedBuffer);
}
catch (error) {
console.error("Error processing image with sharp, returning upstream image as fallback instead: " +
error.stack);
sendResponse(req, res, upstreamType, upstreamBuffer);
}
return { finished: true };
}
exports.imageOptimizer = imageOptimizer;
function sendResponse(req, res, contentType, buffer) {
const etag = getHash([buffer]);
if (!res.getHeader("Cache-Control")) {
res.setHeader("Cache-Control", "public, max-age=60");
}
if ((0, sendEtagResponse_1.sendEtagResponse)(req, res, etag)) {
return;
}
if (contentType) {
res.setHeader("Content-Type", contentType);
}
res.end(buffer);
}
function getSupportedMimeType(options, accept = "") {
const mimeType = (0, accept_1.mediaType)(accept, options);
return accept.includes(mimeType) ? mimeType : "";
}
function getHash(items) {
const hash = (0, crypto_1.createHash)("sha256");
for (const item of items) {
if (typeof item === "number")
hash.update(String(item));
else {
hash.update(item);
}
}
return hash.digest("base64").replace(/\//g, "-");
}
function parseCacheControl(str) {
const map = new Map();
if (!str) {
return map;
}
for (const directive of str.split(",")) {
let [key, value] = directive.trim().split("=");
key = key.toLowerCase();
if (value) {
value = value.toLowerCase();
}
map.set(key, value);
}
return map;
}
function getMaxAge(str) {
const minimum = 60;
const map = parseCacheControl(str);
if (map) {
let age = map.get("s-maxage") || map.get("max-age") || "";
if (age.startsWith('"') && age.endsWith('"')) {
age = age.slice(1, -1);
}
const n = parseInt(age, 10);
if (!isNaN(n)) {
return Math.max(n, minimum);
}
}
return minimum;
}
exports.getMaxAge = getMaxAge;
;