UNPKG

@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
"use strict"; 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;