import { ObjectResponse, PlatformClient, StorePageOptions, TriggerStaticRegenerationOptions, RegenerationEvent } from "@sls-next/core"; import type { Readable } from "stream"; import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { SendMessageCommand, SQSClient } from "@aws-sdk/client-sqs"; // FIXME: using static imports for AWS clients instead of dynamic imports are not imported correctly (if (1) imported from root @aws-sdk/client-sqs it works but isn't treeshook and // (2) if dynamically imported from deeper @aws-sdk/client-sqs/SQSClient it doesn't resolve and is treated as external. However (2) is working in the old way where AWS clients are direct dependencies of lambda-at-edge. Might be due to nested dynamic imports? // However it should be negligible as these clients are pretty lightweight. /** * Client to access pages/files, store pages to S3 and trigger SQS regeneration. */ export class AwsPlatformClient implements PlatformClient { private readonly bucketRegion: string; private readonly bucketName: string; private readonly regenerationQueueRegion: string | undefined; private readonly regenerationQueueName: string | undefined; constructor( bucketName: string, bucketRegion: string, regenerationQueueName: string | undefined, regenerationQueueRegion: string | undefined ) { this.bucketName = bucketName; this.bucketRegion = bucketRegion; this.regenerationQueueName = regenerationQueueName; this.regenerationQueueRegion = regenerationQueueRegion; } public async getObject(pageKey: string): Promise { const s3 = new S3Client({ region: this.bucketRegion, maxAttempts: 3 }); // S3 Body is stream per: https://github.com/aws/aws-sdk-js-v3/issues/1096 const getStream = await import("get-stream"); const s3Params = { Bucket: this.bucketName, Key: pageKey }; let s3StatusCode; let bodyBuffer; let s3Response; try { s3Response = await s3.send(new GetObjectCommand(s3Params)); bodyBuffer = await getStream.buffer(s3Response.Body as Readable); s3StatusCode = s3Response.$metadata.httpStatusCode ?? 200; // assume OK if not set, but it should be } catch (e: any) { s3StatusCode = e.$metadata.httpStatusCode; console.info( "Got error response from S3. Will default to returning empty response. Error: " + e ); return { body: undefined, headers: {}, statusCode: s3StatusCode, expires: undefined, lastModified: undefined, eTag: undefined, cacheControl: undefined, contentType: undefined }; } return { body: bodyBuffer, headers: { "Cache-Control": s3Response.CacheControl, "Content-Disposition": s3Response.ContentDisposition, "Content-Type": s3Response.ContentType, "Content-Language": s3Response.ContentLanguage, "Content-Length": s3Response.ContentLength?.toString(), "Content-Encoding": s3Response.ContentEncoding, "Content-Range": s3Response.ContentRange, ETag: s3Response.ETag, "Accept-Ranges": s3Response.AcceptRanges }, lastModified: s3Response.LastModified?.toString(), expires: s3Response.Expires?.toString(), eTag: s3Response.ETag, cacheControl: s3Response.CacheControl, statusCode: s3StatusCode, contentType: s3Response.ContentType }; } public async storePage( options: StorePageOptions ): Promise<{ cacheControl: string | undefined; expires: Date | undefined }> { const s3 = new S3Client({ region: this.bucketRegion, maxAttempts: 3 }); const s3BasePath = options.basePath ? `${options.basePath.replace(/^\//, "")}/` : ""; const baseKey = options.uri .replace(/^\/$/, "index") .replace(/^\//, "") .replace(/\.(json|html)$/, "") .replace(/^_next\/data\/[^\/]*\//, ""); const jsonKey = `_next/data/${options.buildId}/${baseKey}.json`; const htmlKey = `static-pages/${options.buildId}/${baseKey}.html`; const cacheControl = options.revalidate ? undefined : "public, max-age=0, s-maxage=2678400, must-revalidate"; const expires = options.revalidate ? new Date(new Date().getTime() + 1000 * options.revalidate) : undefined; const s3JsonParams = { Bucket: this.bucketName, Key: `${s3BasePath}${jsonKey}`, Body: JSON.stringify(options.pageData), ContentType: "application/json", CacheControl: cacheControl, Expires: expires }; const s3HtmlParams = { Bucket: this.bucketName, Key: `${s3BasePath}${htmlKey}`, Body: options.html, ContentType: "text/html", CacheControl: cacheControl, Expires: expires }; await Promise.all([ s3.send(new PutObjectCommand(s3JsonParams)), s3.send(new PutObjectCommand(s3HtmlParams)) ]); return { cacheControl, expires }; } public async triggerStaticRegeneration( options: TriggerStaticRegenerationOptions ): Promise<{ throttle: boolean }> { if (!this.regenerationQueueRegion || !this.regenerationQueueName) { throw new Error("SQS regeneration queue region and name is not set."); } const sqs = new SQSClient({ region: this.regenerationQueueRegion, maxAttempts: 1 }); const regenerationEvent: RegenerationEvent = { request: { url: options.req.url, headers: options.req.headers }, pagePath: options.pagePath, basePath: options.basePath, pageKey: options.pageKey, storeName: this.bucketName, storeRegion: this.bucketRegion }; try { const crypto = await import("crypto"); // Hashed URI for messageGroupId to allow for long URIs, as SQS has limit of 128 characters // MD5 is used since this is only used for grouping purposes const hashedUri = crypto .createHash("md5") .update(options.req.url ?? "") .digest("hex"); await sqs.send( new SendMessageCommand({ QueueUrl: `https://sqs.${this.regenerationQueueRegion}.amazonaws.com/${this.regenerationQueueName}`, MessageBody: JSON.stringify(regenerationEvent), // This is not used, however it is a required property // We only want to trigger the regeneration once for every previous // update. This will prevent the case where this page is being // requested again whilst its already started to regenerate. MessageDeduplicationId: options.eTag ?? (options.lastModified ? new Date(options.lastModified).getTime().toString() : new Date().getTime().toString()), // Only deduplicate based on the object, i.e. we can generate // different pages in parallel, just not the same one MessageGroupId: hashedUri }) ); return { throttle: false }; } catch (error: any) { if (error.code === "RequestThrottled") { return { throttle: true }; } else { throw error; } } } }