Skip to content

Commit

Permalink
add host image loader
Browse files Browse the repository at this point in the history
  • Loading branch information
conico974 committed Jun 26, 2024
1 parent a83813f commit 0c9c78b
Show file tree
Hide file tree
Showing 9 changed files with 143 additions and 55 deletions.
66 changes: 14 additions & 52 deletions packages/open-next/src/adapters/image-optimization-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import https from "node:https";
import path from "node:path";
import { Writable } from "node:stream";

import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { loadBuildId, loadConfig } from "config/util.js";
import { OpenNextNodeResponse, StreamCreator } from "http/openNextResponse.js";
// @ts-ignore
Expand All @@ -19,16 +18,14 @@ import {
} from "next/dist/server/image-optimizer";
// @ts-ignore
import type { NextUrlWithParsedQuery } from "next/dist/server/request-meta";
import { ImageLoader, InternalEvent, InternalResult } from "types/open-next.js";
import { InternalEvent, InternalResult } from "types/open-next.js";

import { createGenericHandler } from "../core/createGenericHandler.js";
import { awsLogger, debug, error } from "./logger.js";
import { resolveImageLoader } from "../core/resolve.js";
import { debug, error } from "./logger.js";
import { optimizeImage } from "./plugins/image-optimization/image-optimization.js";
import { setNodeEnv } from "./util.js";

// Expected environment variables
const { BUCKET_NAME, BUCKET_KEY_PREFIX } = process.env;

setNodeEnv();
const nextDir = path.join(__dirname, ".next");
const config = loadConfig(nextDir);
Expand All @@ -42,7 +39,6 @@ const nextConfig = {
};
debug("Init config", {
nextDir,
BUCKET_NAME,
nextConfig,
});

Expand All @@ -64,7 +60,14 @@ export async function defaultHandler(
const { headers, query: queryString } = event;

try {
// const headers = normalizeHeaderKeysToLowercase(rawHeaders);
// Set the HOST environment variable to the host header if it is not set
// If it is set it is assumed to be set by the user and should be used instead
// It might be useful for cases where the user wants to use a different host than the one in the request
// It could even allow to have multiple hosts for the image optimization by setting the HOST environment variable in the wrapper for example
if (!process.env.HOST) {
const headersHost = headers["x-forwarded-host"] || headers["host"];
process.env.HOST = headersHost;
}

const imageParams = validateImageParams(
headers,
Expand Down Expand Up @@ -101,20 +104,6 @@ export async function defaultHandler(
// Helper functions //
//////////////////////

// function normalizeHeaderKeysToLowercase(headers: APIGatewayProxyEventHeaders) {
// // Make header keys lowercase to ensure integrity
// return Object.entries(headers).reduce(
// (acc, [key, value]) => ({ ...acc, [key.toLowerCase()]: value }),
// {} as APIGatewayProxyEventHeaders,
// );
// }

function ensureBucketExists() {
if (!BUCKET_NAME) {
throw new Error("Bucket name must be defined!");
}
}

function validateImageParams(
headers: OutgoingHttpHeaders,
query?: InternalEvent["query"],
Expand Down Expand Up @@ -218,36 +207,9 @@ function buildFailureResponse(
};
}

const resolveLoader = () => {
const openNextParams = globalThis.openNextConfig.imageOptimization;
if (typeof openNextParams?.loader === "function") {
return openNextParams.loader();
} else {
const s3Client = new S3Client({ logger: awsLogger });
return Promise.resolve<ImageLoader>({
name: "s3",
// @ts-ignore
load: async (key: string) => {
ensureBucketExists();
const keyPrefix = BUCKET_KEY_PREFIX?.replace(/^\/|\/$/g, "");
const response = await s3Client.send(
new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: keyPrefix
? keyPrefix + "/" + key.replace(/^\//, "")
: key.replace(/^\//, ""),
}),
);
return {
body: response.Body,
contentType: response.ContentType,
cacheControl: response.CacheControl,
};
},
});
}
};
const loader = await resolveLoader();
const loader = await resolveImageLoader(
globalThis.openNextConfig.imageOptimization?.loader ?? "s3",
);

async function downloadHandler(
_req: IncomingMessage,
Expand Down
1 change: 1 addition & 0 deletions packages/open-next/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -324,6 +324,7 @@ async function createImageOptimizationBundle(config: OpenNextConfig) {
overrides: {
converter: config.imageOptimization?.override?.converter,
wrapper: config.imageOptimization?.override?.wrapper,
imageLoader: config.imageOptimization?.loader,
},
}),
];
Expand Down
7 changes: 6 additions & 1 deletion packages/open-next/src/converters/edge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,12 @@ const converter: Converter<
},
});

const cfCache = result.isISR ? { cacheEverything: true } : {};
const cfCache =
(result.isISR ||
result.internalEvent.rawPath.startsWith("/_next/image")) &&
process.env.DISABLE_CACHE !== "true"
? { cacheEverything: true }
: {};

return fetch(req, {
// This is a hack to make sure that the response is cached by Cloudflare
Expand Down
18 changes: 18 additions & 0 deletions packages/open-next/src/core/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import {
BaseEventOrResult,
Converter,
DefaultOverrideOptions,
ImageLoader,
InternalEvent,
InternalResult,
LazyLoadedOverride,
OverrideOptions,
Wrapper,
} from "types/open-next.js";
Expand Down Expand Up @@ -88,3 +90,19 @@ export async function resolveIncrementalCache(
return m_1.default;
}
}

/**
* @param imageLoader
* @returns
* @__PURE__
*/
export async function resolveImageLoader(
imageLoader: LazyLoadedOverride<ImageLoader> | string,
) {
if (typeof imageLoader === "function") {
return imageLoader();
} else {
const m_1 = await import("../overrides/imageLoader/s3.js");
return m_1.default;
}
}
4 changes: 3 additions & 1 deletion packages/open-next/src/http/openNextResponse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,10 @@ export class OpenNextNodeResponse extends Transform implements ServerResponse {
if (!this.headersSent) {
this.flushHeaders();
}
// In some cases we might not have a store i.e. for example in the image optimization function
// We may want to reconsider this in the future, it might be intersting to have access to this store everywhere
globalThis.__als
.getStore()
?.getStore()
?.pendingPromiseRunner.add(onEnd(this.headers));
const bodyLength = this.body.length;
this.streamCreator?.onFinish(bodyLength);
Expand Down
35 changes: 35 additions & 0 deletions packages/open-next/src/overrides/imageLoader/host.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Readable } from "node:stream";
import { ReadableStream } from "node:stream/web";

import { ImageLoader } from "types/open-next";
import { FatalError } from "utils/error";

const hostLoader: ImageLoader = {
name: "host",
load: async (key: string) => {
const host = process.env.HOST;
if (!host) {
throw new FatalError("Host must be defined!");
}
const url = `https://${host}${key}`;
const response = await fetch(url);
if (!response.ok) {
throw new FatalError(`Failed to fetch image from ${url}`);
}
if (!response.body) {
throw new FatalError("No body in response");
}
const body = Readable.fromWeb(response.body as ReadableStream<Uint8Array>);
const contentType = response.headers.get("content-type") ?? "image/jpeg";
const cacheControl =
response.headers.get("cache-control") ??
"private, max-age=0, must-revalidate";
return {
body,
contentType,
cacheControl,
};
},
};

export default hostLoader;
46 changes: 46 additions & 0 deletions packages/open-next/src/overrides/imageLoader/s3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { Readable } from "node:stream";

import { GetObjectCommand, S3Client } from "@aws-sdk/client-s3";
import { ImageLoader } from "types/open-next";
import { FatalError } from "utils/error";

import { awsLogger } from "../../adapters/logger";

const { BUCKET_NAME, BUCKET_KEY_PREFIX } = process.env;

function ensureBucketExists() {
if (!BUCKET_NAME) {
throw new Error("Bucket name must be defined!");
}
}

const s3Loader: ImageLoader = {
name: "s3",
load: async (key: string) => {
const s3Client = new S3Client({ logger: awsLogger });

ensureBucketExists();
const keyPrefix = BUCKET_KEY_PREFIX?.replace(/^\/|\/$/g, "");
const response = await s3Client.send(
new GetObjectCommand({
Bucket: BUCKET_NAME,
Key: keyPrefix
? keyPrefix + "/" + key.replace(/^\//, "")
: key.replace(/^\//, ""),
}),
);
const body = response.Body as Readable | undefined;

if (!body) {
throw new FatalError("No body in S3 response");
}

return {
body: body,
contentType: response.ContentType,
cacheControl: response.CacheControl,
};
},
};

export default s3Loader;
13 changes: 13 additions & 0 deletions packages/open-next/src/plugins/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { readFileSync } from "node:fs";
import { Plugin } from "esbuild";
import type {
DefaultOverrideOptions,
ImageLoader,
IncludedImageLoader,
LazyLoadedOverride,
OverrideOptions,
} from "types/open-next";
Expand All @@ -16,6 +18,7 @@ export interface IPluginSettings {
tagCache?: OverrideOptions["tagCache"];
queue?: OverrideOptions["queue"];
incrementalCache?: OverrideOptions["incrementalCache"];
imageLoader?: LazyLoadedOverride<ImageLoader> | IncludedImageLoader;
};
fnName?: string;
}
Expand Down Expand Up @@ -43,6 +46,7 @@ export function openNextResolvePlugin({
logger.debug(`OpenNext Resolve plugin for ${fnName}`);
build.onLoad({ filter: /core\/resolve.js/g }, async (args) => {
let contents = readFileSync(args.path, "utf-8");
//TODO: refactor this. Every override should be at the same place so we can generate this dynamically
if (overrides?.wrapper) {
contents = contents.replace(
"../wrappers/aws-lambda.js",
Expand Down Expand Up @@ -85,6 +89,15 @@ export function openNextResolvePlugin({
)}.js`,
);
}
if (overrides?.imageLoader) {
contents = contents.replace(
"../overrides/imageLoader/s3.js",
`../overrides/imageLoader/${getOverrideOrDefault(
overrides.imageLoader,
"s3",
)}.js`,
);
}
return {
contents,
};
Expand Down
8 changes: 7 additions & 1 deletion packages/open-next/src/types/open-next.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ export type IncludedIncrementalCache = "s3" | "s3-lite";

export type IncludedTagCache = "dynamodb" | "dynamodb-lite";

export type IncludedImageLoader = "s3" | "host";

export interface DefaultOverrideOptions<
E extends BaseEventOrResult = InternalEvent,
R extends BaseEventOrResult = InternalResult,
Expand Down Expand Up @@ -287,7 +289,11 @@ export interface OpenNextConfig {
* Supports only node runtime
*/
imageOptimization?: DefaultFunctionOptions & {
loader?: "s3" | LazyLoadedOverride<ImageLoader>;
/**
* The image loader is used to load the image from the source.
* @default "s3"
*/
loader?: IncludedImageLoader | LazyLoadedOverride<ImageLoader>;
/**
* @default "arm64"
*/
Expand Down

0 comments on commit 0c9c78b

Please sign in to comment.