Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow to use external config #145

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@

# Build output
/lib/dist
/examples/*/out
4 changes: 4 additions & 0 deletions lib/declarations.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ declare global {
TF_NEXTIMAGE_SOURCE_BUCKET?: string;
__DEBUG__USE_LOCAL_BUCKET?: string;
NEXT_SHARP_PATH?: string;
/**
* Use this endpoint to fetch the Next.js config.
*/
IMAGE_CONFIG_ENDPOINT?: string;
}
}
}
Expand Down
56 changes: 56 additions & 0 deletions lib/fetch-timeout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* TODO: Use native AbortController from Node.js, once we upgraded to v16.x
*/
import { AbortController } from 'abort-controller';
import NodeFetch, { RequestInit, Response } from 'node-fetch';

/**
* Fetch with timeout
* @param timeout Timeout in milliseconds
* @param url
* @param etag
* @returns
*/
export async function fetchTimeout(
fetch: typeof NodeFetch,
timeout: number,
url: string,
etag?: string
): Promise<Response> {
const controller = new AbortController();
const timeoutFunc = setTimeout(() => {
controller.abort();
}, timeout);

let error: Error | undefined;
let fetchResponse: Response | undefined;

const params: RequestInit = { signal: controller.signal };

// Apply If-None-Match header if etag is present
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match
if (etag) {
params.headers = {
'If-None-Match': `"${etag}"`,
};
}

try {
fetchResponse = await fetch(url, params);
} catch (err: any) {
if (err.name === 'AbortError') {
error = new Error(`Timeout while fetching from ${url}`);
} else {
error = err;
}
} finally {
clearTimeout(timeoutFunc);
}

if (error) {
throw error;
}

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return fetchResponse!;
}
96 changes: 25 additions & 71 deletions lib/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@ process.env.NEXT_SHARP_PATH = require.resolve('sharp');

import { parse as parseUrl } from 'url';

import {
defaultConfig,
NextConfigComplete,
} from 'next/dist/server/config-shared';
import { ETagCache } from '@millihq/etag-cache';
import createFetch from '@vercel/fetch-cached-dns';
import type {
APIGatewayProxyEventV2,
APIGatewayProxyStructuredResultV2,
Expand All @@ -18,14 +16,15 @@ import type {
// eslint-disable-next-line import/no-unresolved
} from 'aws-lambda';
import S3 from 'aws-sdk/clients/s3';
import nodeFetch from 'node-fetch';

import { imageOptimizer, S3Config } from './image-optimizer';
import { normalizeHeaders } from './normalized-headers';

/* -----------------------------------------------------------------------------
* Types
* ---------------------------------------------------------------------------*/
type ImageConfig = Partial<NextConfigComplete['images']>;
import {
fetchImageConfigGenerator,
getImageConfig,
NextImageConfig,
} from './image-config';

/* -----------------------------------------------------------------------------
* Utils
Expand All @@ -51,64 +50,21 @@ function generateS3Config(bucketName?: string): S3Config | undefined {
};
}

function parseFromEnv<T>(key: string, defaultValue: T) {
try {
const envValue = process.env[key];
if (typeof envValue === 'string') {
return JSON.parse(envValue) as T;
}

return defaultValue;
} catch (err) {
console.error(`Could not parse ${key} from environment variable`);
console.error(err);
return defaultValue;
}
}

/* -----------------------------------------------------------------------------
* Globals
* ---------------------------------------------------------------------------*/
// `images` property is defined on default config
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const imageConfigDefault = defaultConfig.images!;

const domains = parseFromEnv(
'TF_NEXTIMAGE_DOMAINS',
imageConfigDefault.domains ?? []
);
const deviceSizes = parseFromEnv(
'TF_NEXTIMAGE_DEVICE_SIZES',
imageConfigDefault.deviceSizes
);
const formats = parseFromEnv(
'TF_NEXTIMAGE_FORMATS',
imageConfigDefault.formats
);
const imageSizes = parseFromEnv(
'TF_NEXTIMAGE_IMAGE_SIZES',
imageConfigDefault.imageSizes
);
const dangerouslyAllowSVG = parseFromEnv(
'TF_NEXTIMAGE_DANGEROUSLY_ALLOW_SVG',
imageConfigDefault.dangerouslyAllowSVG
);
const contentSecurityPolicy = parseFromEnv(
'TF_NEXTIMAGE_CONTENT_SECURITY_POLICY',
imageConfigDefault.contentSecurityPolicy
);

const sourceBucket = process.env.TF_NEXTIMAGE_SOURCE_BUCKET ?? undefined;
const baseOriginUrl = process.env.TF_NEXTIMAGE_BASE_ORIGIN ?? undefined;

const imageConfig: ImageConfig = {
...imageConfigDefault,
domains,
deviceSizes,
formats,
imageSizes,
dangerouslyAllowSVG,
contentSecurityPolicy,
};
/**
* We use a custom fetch implementation here that caches DNS resolutions
* to improve performance for repeated requests.
*/
// eslint-disable-next-line
const fetch = createFetch();
const fetchImageConfig = fetchImageConfigGenerator(fetch as typeof nodeFetch);
const configCache = new ETagCache<NextImageConfig>(60, fetchImageConfig);

/* -----------------------------------------------------------------------------
* Handler
Expand All @@ -117,18 +73,16 @@ const imageConfig: ImageConfig = {
export async function handler(
event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyStructuredResultV2> {
const headers = normalizeHeaders(event.headers);
const hostname = headers['host'];
const imageConfig = await getImageConfig({ configCache, hostname });
const s3Config = generateS3Config(sourceBucket);

const parsedUrl = parseUrl(`/?${event.rawQueryString}`, true);
const imageOptimizerResult = await imageOptimizer(
{ headers: normalizeHeaders(event.headers) },
imageConfig,
{
baseOriginUrl,
parsedUrl,
s3Config,
}
);
const imageOptimizerResult = await imageOptimizer({ headers }, imageConfig, {
baseOriginUrl,
parsedUrl,
s3Config,
});

if ('error' in imageOptimizerResult) {
return {
Expand Down
148 changes: 148 additions & 0 deletions lib/image-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { ETagCache } from '@millihq/etag-cache';
import {
defaultConfig,
NextConfigComplete,
} from 'next/dist/server/config-shared';
import { fetchTimeout } from './fetch-timeout';

type NextImageConfig = Partial<NextConfigComplete['images']>;
type NodeFetch = typeof import('node-fetch').default;

/* -----------------------------------------------------------------------------
* Default Configuration
* ---------------------------------------------------------------------------*/

// Timeout the connection before 30000ms to be able to print an error message
// See Lambda@Edge Limits for origin-request event here:
// https://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html#limits-lambda-at-edge
const FETCH_TIMEOUT = 29500;

// `images` property is defined on default config
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const defaultImageConfig = defaultConfig.images!;

const domains = parseFromEnv<typeof defaultImageConfig.domains>(
'TF_NEXTIMAGE_DOMAINS'
);
const deviceSizes = parseFromEnv<typeof defaultImageConfig.deviceSizes>(
'TF_NEXTIMAGE_DEVICE_SIZES'
);
const formats = parseFromEnv<typeof defaultImageConfig.formats>(
'TF_NEXTIMAGE_FORMATS'
);
const imageSizes = parseFromEnv<typeof defaultImageConfig.imageSizes>(
'TF_NEXTIMAGE_IMAGE_SIZES'
);
const dangerouslyAllowSVG = parseFromEnv<
typeof defaultImageConfig.dangerouslyAllowSVG
>('TF_NEXTIMAGE_DANGEROUSLY_ALLOW_SVG');
const contentSecurityPolicy = parseFromEnv<
typeof defaultImageConfig.contentSecurityPolicy
>('TF_NEXTIMAGE_CONTENT_SECURITY_POLICY');

const imageConfigEndpoint = process.env.IMAGE_CONFIG_ENDPOINT;

function parseFromEnv<T>(key: string): T | undefined {
try {
const envValue = process.env[key];
if (typeof envValue === 'string') {
return JSON.parse(envValue) as T;
}

return undefined;
} catch (err) {
console.error(`Could not parse ${key} from environment variable`);
console.error(err);
return undefined;
}
}

/* -----------------------------------------------------------------------------
* fetchImageConfig
* ---------------------------------------------------------------------------*/
function fetchImageConfigGenerator(fetch: NodeFetch) {
return async function fetchImageConfig(
hostname: string,
eTag?: string
): Promise<{ item?: NextImageConfig; eTag: string } | null> {
const url = imageConfigEndpoint + '/' + hostname;
const response = await fetchTimeout(fetch, FETCH_TIMEOUT, url, eTag);

// Existing cache is still valid
if (response.status === 304 && eTag) {
return {
eTag,
};
}

if (response.status === 200) {
const nextConfig = (await response.json()) as NextConfigComplete;
// Etag is always present on CloudFront responses
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const responseEtag = response.headers.get('etag')!;

return {
eTag: responseEtag,
item: nextConfig.images,
};
}

return null;
};
}

/* -----------------------------------------------------------------------------
* getImageConfig
* ---------------------------------------------------------------------------*/

/**
* Filters undefined values from an object
* @param obj
* @returns
*/
function filterObject(obj: { [key: string]: any }) {
const ret: { [key: string]: any } = {};
Object.keys(obj)
.filter((key) => obj[key] !== undefined)
.forEach((key) => (ret[key] = obj[key]));
return ret;
}

type GetImageConfigOptions = {
configCache: ETagCache<any>;
hostname: string;
};

async function getImageConfig({
configCache,
hostname,
}: GetImageConfigOptions): Promise<NextImageConfig> {
let externalConfig: NextImageConfig | undefined;

// Use external Config
if (imageConfigEndpoint) {
externalConfig = await configCache.get(hostname);
}

// Generate config object by merging the items in the following order:
// 1. defaultConfig
// 2. externalConfig (if exists)
// 3. vales set from environment variables
const imageConfig: NextImageConfig = {
...defaultConfig,
...externalConfig,
...filterObject({
domains,
deviceSizes,
formats,
imageSizes,
dangerouslyAllowSVG,
contentSecurityPolicy,
}),
};

return imageConfig;
}

export { fetchImageConfigGenerator, getImageConfig };
export type { NextImageConfig };
7 changes: 5 additions & 2 deletions lib/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
"postpack": "rm ./LICENSE ./third-party-licenses.txt"
},
"dependencies": {
"@millihq/etag-cache": "^1.1.0",
"@millihq/pixel-core": "4.2.0",
"@vercel/fetch-cached-dns": "^2.1.0",
"abort-controller": "3.0.0",
"aws-sdk": "*",
"next": "12.1.4",
"node-fetch": "2.6.7",
Expand All @@ -25,11 +28,11 @@
},
"devDependencies": {
"@types/aws-lambda": "8.10.56",
"@types/node-fetch": "^2.5.7",
"@types/node-fetch": "^2.6.2",
"@types/react": "^17.0.3",
"@types/react-dom": "^17.0.3",
"glob": "^7.1.6",
"typescript": "^4.1.3"
"typescript": "*"
},
"files": [
"dist.zip",
Expand Down
Loading