diff --git a/.changeset/lucky-horses-worry.md b/.changeset/lucky-horses-worry.md new file mode 100644 index 000000000..a5181b584 --- /dev/null +++ b/.changeset/lucky-horses-worry.md @@ -0,0 +1,19 @@ +--- +"@opennextjs/aws": patch +--- + +add: s3 lite override for loading images in the image optimization server + +`s3-lite` override for image loading. Uses `aws4fetch` to get the objects from your s3 bucket. This will make the image optimization server work without the aws s3 sdk. This override introduces a new environment variable called `BUCKET_REGION`. It will fallback to `AWS_REGION` ?? `AWS_DEFAULT_REGION` if undefined. This will require no additional change in IAC for most users. + +```ts +import type { OpenNextConfig } from '@opennextjs/aws/types/open-next'; +const config = { + default: {}, + imageOptimization: { + loader: 's3-lite', + }, +} satisfies OpenNextConfig; + +export default config; +``` \ No newline at end of file diff --git a/packages/open-next/src/overrides/imageLoader/s3-lite.ts b/packages/open-next/src/overrides/imageLoader/s3-lite.ts new file mode 100644 index 000000000..3abb801dc --- /dev/null +++ b/packages/open-next/src/overrides/imageLoader/s3-lite.ts @@ -0,0 +1,81 @@ +import { Readable } from "node:stream"; +import type { ReadableStream } from "node:stream/web"; +import { AwsClient } from "aws4fetch"; + +import type { ImageLoader } from "types/overrides"; +import { FatalError } from "utils/error"; + +let awsClient: AwsClient | null = null; + +const { BUCKET_NAME, BUCKET_KEY_PREFIX } = process.env; +// https://docs.aws.amazon.com/lambda/latest/dg/configuration-envvars.html#configuration-envvars-runtime +// If AWS_REGION is defined it will override AWS_DEFAULT_REGION +const BUCKET_REGION = + process.env.BUCKET_REGION ?? + process.env.AWS_REGION ?? + process.env.AWS_DEFAULT_REGION; + +const getAwsClient = () => { + if (awsClient) { + return awsClient; + } + awsClient = new AwsClient({ + accessKeyId: process.env.AWS_ACCESS_KEY_ID!, + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!, + sessionToken: process.env.AWS_SESSION_TOKEN, + region: BUCKET_REGION, + }); + return awsClient; +}; + +function ensureEnvExists() { + if (!(BUCKET_NAME || BUCKET_KEY_PREFIX || BUCKET_REGION)) { + throw new FatalError("Bucket name, region and key prefix must be defined!"); + } +} + +const awsFetch = async (key: string, options: RequestInit) => { + const client = getAwsClient(); + const url = `https://${BUCKET_NAME}.s3.${BUCKET_REGION}.amazonaws.com/${key}`; + return client.fetch(url, options); +}; + +const s3Loader: ImageLoader = { + name: "s3-lite", + load: async (key: string) => { + ensureEnvExists(); + const keyPrefix = BUCKET_KEY_PREFIX?.replace(/^\/|\/$/g, ""); + const response = await awsFetch( + keyPrefix + ? `${keyPrefix}/${key.replace(/^\//, "")}` + : key.replace(/^\//, ""), + { + method: "GET", + }, + ); + + if (response.status === 404) { + throw new FatalError("The specified key does not exist."); + } + if (response.status !== 200) { + throw new FatalError( + `Failed to get image. Status code: ${response.status}`, + ); + } + + if (!response.body) { + throw new FatalError("No body in aws4fetch s3 response"); + } + + // We need to cast it else there will be a TypeError: o.pipe is not a function + const body = Readable.fromWeb(response.body as ReadableStream); + + return { + body: body, + contentType: response.headers.get("content-type") ?? undefined, + cacheControl: response.headers.get("cache-control") ?? undefined, + }; + }, +}; + +export default s3Loader; diff --git a/packages/open-next/src/types/open-next.ts b/packages/open-next/src/types/open-next.ts index 0a23d76ff..3a76482a2 100644 --- a/packages/open-next/src/types/open-next.ts +++ b/packages/open-next/src/types/open-next.ts @@ -151,7 +151,12 @@ export type IncludedTagCache = | "fs-dev" | "dummy"; -export type IncludedImageLoader = "s3" | "host" | "fs-dev" | "dummy"; +export type IncludedImageLoader = + | "s3" + | "s3-lite" + | "host" + | "fs-dev" + | "dummy"; export type IncludedOriginResolver = "pattern-env" | "dummy";