// @ts-ignore import PrerenderManifest from "./prerender-manifest.json"; // @ts-ignore import Manifest from "./manifest.json"; // @ts-ignore import RoutesManifestJson from "./routes-manifest.json"; // @ts-ignore import lambdaAtEdgeCompat from "@sls-next/next-aws-cloudfront"; import { renderStaticPage } from "./render/renderStaticPage"; import { getCustomHeaders, handleDefault, handleFallback } from "@sls-next/core/dist/module/handle"; import { handlePublicFiles, routeDefault } from "@sls-next/core/dist/module/route"; import { getStaticRegenerationResponse, getThrottledStaticRegenerationCachePolicy } from "@sls-next/core/dist/module/revalidate"; import { ExternalRoute, PublicFileRoute, Route, StaticRoute, NextStaticFileRoute } from "@sls-next/core/dist/module/types"; import { CloudFrontRequest, CloudFrontResultResponse, CloudFrontS3Origin } from "aws-lambda"; import { OriginRequestDefaultHandlerManifest, OriginRequestEvent, OriginResponseEvent, RoutesManifest } from "./types"; import { PreRenderedManifest as PrerenderManifestType, PerfLogger } from "@sls-next/core/dist/module/types"; import { performance } from "perf_hooks"; import type { Readable } from "stream"; import { externalRewrite } from "./routing/rewriter"; import { removeBlacklistedHeaders } from "./headers/removeBlacklistedHeaders"; import { s3BucketNameFromEventRequest } from "./s3/s3BucketNameFromEventRequest"; import { triggerStaticRegeneration } from "./lib/triggerStaticRegeneration"; import { s3StorePage } from "./s3/s3StorePage"; import { createRedirectResponse } from "@sls-next/core/dist/module/route/redirect"; import { redirectByPageProps } from "@sls-next/core/dist/module/handle/redirect"; import { S3Client, GetObjectCommand } from "@aws-sdk/client-s3"; import getStream from "get-stream"; const basePath = RoutesManifestJson.basePath; const perfLogger = (logLambdaExecutionTimes?: boolean): PerfLogger => { if (logLambdaExecutionTimes) { return { now: () => performance.now(), log: (metricDescription: string, t1?: number, t2?: number): void => { if (!t1 || !t2) return; console.log(`${metricDescription}: ${t2 - t1} (ms)`); } }; } return { now: () => 0, // eslint-disable-next-line @typescript-eslint/no-empty-function log: () => {} }; }; const addS3HostHeader = ( req: CloudFrontRequest, s3DomainName: string ): void => { req.headers["host"] = [{ key: "host", value: s3DomainName }]; }; const normaliseS3OriginDomain = (s3Origin: CloudFrontS3Origin): string => { if (s3Origin.region === "us-east-1") { return s3Origin.domainName; } if (!s3Origin.domainName.includes(s3Origin.region)) { const regionalEndpoint = s3Origin.domainName.replace( "s3.amazonaws.com", `s3.${s3Origin.region}.amazonaws.com` ); return regionalEndpoint; } return s3Origin.domainName; }; export const handler = async ( event: OriginRequestEvent | OriginResponseEvent ): Promise => { const manifest: OriginRequestDefaultHandlerManifest = Manifest; let response: CloudFrontResultResponse | CloudFrontRequest; const prerenderManifest: PrerenderManifestType = PrerenderManifest; const routesManifest: RoutesManifest = RoutesManifestJson; const { now, log } = perfLogger(manifest.logLambdaExecutionTimes); const tHandlerBegin = now(); if (isOriginResponse(event)) { response = await handleOriginResponse({ event, manifest, prerenderManifest, routesManifest }); } else { response = await handleOriginRequest({ event, manifest, prerenderManifest, routesManifest }); } // Remove blacklisted headers if (response.headers) { removeBlacklistedHeaders(response.headers); } const tHandlerEnd = now(); log("handler execution time", tHandlerBegin, tHandlerEnd); return response; }; const staticRequest = async ( event: OriginRequestEvent, file: string, path: string, route: Route, manifest: OriginRequestDefaultHandlerManifest, routesManifest: RoutesManifest ) => { const request = event.Records[0].cf.request; if (manifest.disableOriginResponseHandler) { const { req, res, responsePromise } = lambdaAtEdgeCompat( event.Records[0].cf, { enableHTTPCompression: manifest.enableHTTPCompression } ); const bucketName = s3BucketNameFromEventRequest(request) ?? ""; const s3Key = (path + file).slice(1); // need to remove leading slash from path for s3 key return await renderStaticPage({ route: route, request: request, req: req, res: res, responsePromise: responsePromise, manifest: manifest, routesManifest: routesManifest, bucketName: bucketName, s3Key: s3Key, s3Uri: file, basePath: basePath }); } else { const s3Origin = request.origin?.s3 as CloudFrontS3Origin; const s3Domain = normaliseS3OriginDomain(s3Origin); s3Origin.domainName = s3Domain; s3Origin.path = path; request.uri = file; addS3HostHeader(request, s3Domain); return request; } }; const reconstructOriginalRequestUri = ( s3Uri: string, manifest: OriginRequestDefaultHandlerManifest ) => { // For public files we do not replace .html as it can cause public HTML files to be classified with wrong status code const publicFile = handlePublicFiles(s3Uri, manifest); if (publicFile) { return `${basePath}${s3Uri}`; } let originalUri = `${basePath}${s3Uri.replace( /(\.html)?$/, manifest.trailingSlash ? "/" : "" )}`; // For index.html page, it will become "/index" or "/index/", which is not a route so normalize it to "/" originalUri = originalUri.replace( manifest.trailingSlash ? /\/index\/$/ : /\/index$/, "/" ); return originalUri; }; const handleOriginRequest = async ({ event, manifest, prerenderManifest, routesManifest }: { event: OriginRequestEvent; manifest: OriginRequestDefaultHandlerManifest; prerenderManifest: PrerenderManifestType; routesManifest: RoutesManifest; }) => { const request = event.Records[0].cf.request; const { req, res, responsePromise } = lambdaAtEdgeCompat( event.Records[0].cf, { enableHTTPCompression: manifest.enableHTTPCompression } ); const { now, log } = perfLogger(manifest.logLambdaExecutionTimes); let tBeforeSSR = null; const getPage = (pagePath: string) => { const tBeforePageRequire = now(); const page = require(`./${pagePath}`); // eslint-disable-line const tAfterPageRequire = (tBeforeSSR = now()); log("require JS execution time", tBeforePageRequire, tAfterPageRequire); return page; }; const route = await handleDefault( { req, res, responsePromise }, manifest, prerenderManifest, routesManifest, getPage ); if (tBeforeSSR) { const tAfterSSR = now(); log("SSR execution time", tBeforeSSR, tAfterSSR); } if (!route) { return await responsePromise; } if (route.isPublicFile) { const { file } = route as PublicFileRoute; return await staticRequest( event, file, `${routesManifest.basePath}/public`, route, manifest, routesManifest ); } if (route.isNextStaticFile) { const { file } = route as NextStaticFileRoute; return await staticRequest( event, file, `${routesManifest.basePath}/_next/static`, route, manifest, routesManifest ); } if (route.isStatic) { const { file, isData } = route as StaticRoute; const path = isData ? `${routesManifest.basePath}` : `${routesManifest.basePath}/static-pages/${manifest.buildId}`; const relativeFile = isData ? file : file.slice("pages".length); return await staticRequest( event, relativeFile, path, route, manifest, routesManifest ); } const external: ExternalRoute = route; const { path } = external; return externalRewrite(event, manifest.enableHTTPCompression, path); }; const handleOriginResponse = async ({ event, manifest, prerenderManifest, routesManifest }: { event: OriginResponseEvent; manifest: OriginRequestDefaultHandlerManifest; prerenderManifest: PrerenderManifestType; routesManifest: RoutesManifest; }) => { const response = event.Records[0].cf.response; const request = event.Records[0].cf.request; const bucketName = s3BucketNameFromEventRequest(request); // Reconstruct valid request uri for routing const s3Uri = request.uri; request.uri = reconstructOriginalRequestUri(s3Uri, manifest); const route = await routeDefault( request, manifest, prerenderManifest, routesManifest ); const staticRoute = route.isStatic ? (route as StaticRoute) : undefined; const statusCode = route?.statusCode; // These statuses are returned when S3 does not have access to the page. // 404 will also be returned if CloudFront has permissions to list objects. if (response.status !== "403" && response.status !== "404") { response.headers = { ...response.headers, ...getCustomHeaders(request.uri, routesManifest) }; // Set 404 status code for static 404 page. if (statusCode === 404) { response.status = "404"; response.statusDescription = "Not Found"; return response; } // Set 500 status code for static 500 page. if (statusCode === 500) { response.status = "500"; response.statusDescription = "Internal Server Error"; response.headers["cache-control"] = [ { key: "Cache-Control", value: "public, max-age=0, s-maxage=0, must-revalidate" // server error page should not be cached } ]; return response; } const staticRegenerationResponse = getStaticRegenerationResponse({ expiresHeader: response.headers?.expires?.[0]?.value || "", lastModifiedHeader: response.headers?.["last-modified"]?.[0]?.value || "", initialRevalidateSeconds: staticRoute?.revalidate }); if (staticRegenerationResponse) { response.headers["cache-control"] = [ { key: "Cache-Control", value: staticRegenerationResponse.cacheControl } ]; // We don't want the `expires` header to be sent to the client we manage // the cache at the edge using the s-maxage directive in the cache-control // header delete response.headers.expires; if ( staticRoute?.page && staticRegenerationResponse.secondsRemainingUntilRevalidation === 0 ) { const regenerationQueueName = manifest.regenerationQueueName ?? `${bucketName}.fifo`; // if queue name not specified, we used [bucketName].fifo as used in deployment if (!regenerationQueueName) { throw new Error("Regeneration queue name is undefined."); } const { throttle } = await triggerStaticRegeneration({ basePath, request, pageS3Path: s3Uri, eTag: response.headers["etag"]?.[0].value, lastModified: response.headers["etag"]?.[0].value, pagePath: staticRoute.page, queueName: regenerationQueueName }); // Occasionally we will get rate-limited by the Queue (in the event we // send it too many messages) and so we we use the cache to reduce // requests to the queue for a short period. if (throttle) { response.headers["cache-control"] = [ { key: "Cache-Control", value: getThrottledStaticRegenerationCachePolicy(1).cacheControl } ]; } } } return response; } // For PUT or DELETE just return the response as these should be unsupported S3 methods if (request.method === "PUT" || request.method === "DELETE") { return response; } const { req, res, responsePromise } = lambdaAtEdgeCompat( event.Records[0].cf, { enableHTTPCompression: manifest.enableHTTPCompression } ); const getPage = (pagePath: string) => { return require(`./${pagePath}`); }; const fallbackRoute = await handleFallback( { req, res, responsePromise }, route, manifest, routesManifest, getPage ); // Already handled dynamic error path if (!fallbackRoute) { return await responsePromise; } const s3 = new S3Client({ region: request.origin?.s3?.region, maxAttempts: 3 }); const s3BasePath = basePath ? `${basePath.replace(/^\//, "")}/` : ""; // Either a fallback: true page or a static error page if (fallbackRoute.isStatic) { const file = fallbackRoute.file.slice("pages".length); const s3Key = `${s3BasePath}static-pages/${manifest.buildId}${file}`; const s3Params = { Bucket: bucketName, Key: s3Key }; const s3Response = await s3.send(new GetObjectCommand(s3Params)); // S3 Body is stream per: https://github.com/aws/aws-sdk-js-v3/issues/1096 const bodyBuffer = await getStream.buffer(s3Response.Body as Readable); const statusCode = fallbackRoute.statusCode || 200; const is500 = statusCode === 500; const cacheControl = is500 ? "public, max-age=0, s-maxage=0, must-revalidate" // static 500 page should never be cached : s3Response.CacheControl ?? (fallbackRoute.fallback // Use cache-control from S3 response if possible, otherwise use defaults ? "public, max-age=0, s-maxage=0, must-revalidate" // fallback should never be cached : "public, max-age=0, s-maxage=2678400, must-revalidate"); res.writeHead(statusCode, { "Cache-Control": cacheControl, "Content-Type": "text/html" }); res.end(bodyBuffer); return await responsePromise; } // This is a fallback route that should be stored in S3 before returning it const { renderOpts, html } = fallbackRoute; // Check if response is a redirect if ( typeof renderOpts.pageData !== "undefined" && typeof renderOpts.pageData.pageProps !== "undefined" && typeof renderOpts.pageData.pageProps.__N_REDIRECT !== "undefined" ) { const statusCode = renderOpts.pageData.pageProps.__N_REDIRECT_STATUS; const redirectPath = renderOpts.pageData.pageProps.__N_REDIRECT; const redirectResponse = createRedirectResponse( redirectPath, request.querystring, statusCode ); redirectByPageProps({ req, res, responsePromise }, redirectResponse); return await responsePromise; } const { expires } = await s3StorePage({ html, uri: s3Uri, basePath, bucketName: bucketName || "", buildId: manifest.buildId, pageData: renderOpts.pageData, region: request.origin?.s3?.region || "", revalidate: renderOpts.revalidate }); const isrResponse = expires ? getStaticRegenerationResponse({ expiresHeader: expires.toJSON(), lastModifiedHeader: undefined, initialRevalidateSeconds: staticRoute?.revalidate }) : null; const cacheControl = (isrResponse && isrResponse.cacheControl) || "public, max-age=0, s-maxage=2678400, must-revalidate"; res.setHeader("Cache-Control", cacheControl); if (fallbackRoute.route.isData) { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(renderOpts.pageData)); } else { res.setHeader("Content-Type", "text/html"); res.end(html); } return await responsePromise; }; const isOriginResponse = ( event: OriginRequestEvent | OriginResponseEvent ): event is OriginResponseEvent => { return event.Records[0].cf.config.eventType === "origin-response"; };