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

feat: images support via /_next/image #357

Merged
merged 7 commits into from
Jul 3, 2023
Merged
Show file tree
Hide file tree
Changes from 3 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
5 changes: 5 additions & 0 deletions .changeset/lemon-pears-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cloudflare/next-on-pages': minor
---

Support for images via `/_next/image`, falling back to the raw image URL when image resizing is not available.
james-elicx marked this conversation as resolved.
Show resolved Hide resolved
4 changes: 2 additions & 2 deletions packages/next-on-pages/docs/supported.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ If you're application is using a package which relies on unsupported Node.js API
| routes `missing` | ✅ |
| routes `locale` | ✅ |
| routes `middlewarePath` | ✅ |
| images<sup>1</sup> | 🔄 |
| images<sup>1</sup> | |
| wildcard | ✅ |
| overrides | ✅ |
| cache | ❌ |
Expand All @@ -59,7 +59,7 @@ If you're application is using a package which relies on unsupported Node.js API
- 🔄: Not currently supported, but it's probably possible and we may add support in the future
- ❌: Not supported and unlikely to be supported in the future

- _1_ - **images**: If you want to use `next/image`, you can provide your own [custom loader](https://nextjs.org/docs/api-reference/next/image#loader) and use Cloudflare Image Resizing, as per [Cloudflare's Image Resizing documentation](https://developers.cloudflare.com/images/image-resizing/integration-with-frameworks/#nextjs).
- _1_ - **images**: If you want to use `next/image`, there are two options; allow the library to take care of incoming requests, or using a custom loader. Requests are intercepted in the router and image resizing is attempted to be used - if image resizing is not available, it falls back to fetching the normal image URL. Alternatively, you can provide your own [custom loader](https://nextjs.org/docs/api-reference/next/image#loader) and use Cloudflare Image Resizing, as per [Cloudflare's Image Resizing documentation](https://developers.cloudflare.com/images/image-resizing/integration-with-frameworks/#nextjs).
james-elicx marked this conversation as resolved.
Show resolved Hide resolved

### next.config.js Properties

Expand Down
34 changes: 21 additions & 13 deletions packages/next-on-pages/templates/_worker.js/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { handleRequest } from './handleRequest';
import { adjustRequestForVercel } from './utils';
import { adjustRequestForVercel, imageResizing } from './utils';
import type { AsyncLocalStorage } from 'node:async_hooks';

declare const __NODE_ENV__: string;
Expand All @@ -19,18 +19,26 @@ export default {
{ status: 503 }
);
}
return envAsyncLocalStorage.run({ ...env, NODE_ENV: __NODE_ENV__ }, () => {
const adjustedRequest = adjustRequestForVercel(request);
return envAsyncLocalStorage.run(
{ ...env, NODE_ENV: __NODE_ENV__ },
async () => {
const url = new URL(request.url);
if (url.pathname.startsWith('/_next/image')) {
return imageResizing(request, url, __CONFIG__.images);
james-elicx marked this conversation as resolved.
Show resolved Hide resolved
}

return handleRequest(
{
request: adjustedRequest,
ctx,
assetsFetcher: env.ASSETS,
},
__CONFIG__,
__BUILD_OUTPUT__
);
});
const adjustedRequest = adjustRequestForVercel(request);

return handleRequest(
{
request: adjustedRequest,
ctx,
assetsFetcher: env.ASSETS,
},
__CONFIG__,
__BUILD_OUTPUT__
);
}
);
},
} as ExportedHandler<{ ASSETS: Fetcher }>;
184 changes: 184 additions & 0 deletions packages/next-on-pages/templates/_worker.js/utils/images.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* Checks whether the given URL matches the given remote pattern from the Vercel build output
* images configuration.
*
* https://vercel.com/docs/build-output-api/v3/configuration#images
*
* @param url URL to check.
* @param pattern Remote pattern to match against.
* @returns Whether the URL matches the remote pattern.
*/
export function isRemotePatternMatch(
url: URL,
{ protocol, hostname, port, pathname }: VercelImageRemotePattern
): boolean {
// Protocol must match if defined.
if (protocol && url.protocol.replace(/:$/, '') !== protocol) return false;
// Hostname must match regexp.
if (!new RegExp(hostname).test(url.hostname)) return false;
// Port must match regexp if defined.
if (port && !new RegExp(port).test(url.port)) return false;
// Pathname must match regexp if defined.
if (pathname && !new RegExp(pathname).test(url.pathname)) return false;
// All checks passed.
return true;
}

type ResizingProperties = {
imageUrl: URL;
options: RequestInitCfPropertiesImage;
};

/**
* Derives the properties to use for image resizing from the incoming request, respecting the
* images configuration spec from the Vercel build output config.
*
* https://vercel.com/docs/build-output-api/v3/configuration#images
*
* @param request Incoming request.
* @param requestUrl Incoming request's URL.
* @param config Images configuration from the Vercel build output.
* @returns Resizing properties if the request is valid, otherwise undefined.
*/
export function getResizingProperties(
request: Request,
requestUrl: URL,
config?: VercelImagesConfig
): ResizingProperties | undefined {
if (request.method !== 'GET') return undefined;

const { searchParams } = requestUrl;

const rawUrl = searchParams.get('url');
const width = Number.parseInt(searchParams.get('w') ?? '', 10);
const quality = Number.parseInt(searchParams.get('q') ?? '75', 10);
dario-piotrowicz marked this conversation as resolved.
Show resolved Hide resolved

if (!rawUrl || Number.isNaN(width) || Number.isNaN(quality)) return undefined;
if (!config?.sizes?.includes(width)) return undefined;
if (quality < 0 || quality > 100) return undefined;

const url = new URL(rawUrl, requestUrl.origin);

// SVGs must be allowed by the config.
if (url.pathname.endsWith('.svg') && !config?.dangerouslyAllowSVG) {
return undefined;
}

if (
// Relative URL means same origin as deployment and is allowed.
!(rawUrl.startsWith('/') || rawUrl.startsWith('%2F')) &&
// External image URL must be allowed by domains or remote patterns.
!config?.domains?.includes(url.hostname) &&
!config?.remotePatterns?.find(pattern => isRemotePatternMatch(url, pattern))
) {
return undefined;
}

const acceptHeader = request.headers.get('Accept') ?? '';
const format = config?.formats
?.find(format => acceptHeader.includes(format))
?.replace('image/', '') as VercelImageFormatWithoutPrefix;

return {
imageUrl: url,
options: { width, quality, format },
};
}

/**
* Builds an URL to the Cloudflare CDN image resizing endpoint.
james-elicx marked this conversation as resolved.
Show resolved Hide resolved
*
* @param requestUrl Incoming request's URL.
* @param imageUrl Image URL to resize.
* @param properties Image resizing properties.
* @returns URL to the Cloudflare CDN image resizing endpoint.
*/
export function buildCdnCgiImageUrl(
requestUrl: URL,
imageUrl: URL,
{ width, quality, format }: RequestInitCfPropertiesImage
): string {
const opts = [];

if (width) opts.push(`width=${width}`);
if (quality) opts.push(`quality=${quality}`);
if (format) opts.push(`format=${format}`);

const imageHref =
requestUrl.origin === imageUrl.origin
? imageUrl.pathname.slice(1)
: imageUrl.href;
dario-piotrowicz marked this conversation as resolved.
Show resolved Hide resolved

return `${requestUrl.origin}/cdn-cgi/image/${opts.join(',')}/${imageHref}`;
}

/**
* Formats the given response to match the images configuration spec from the Vercel build output
* config.
*
* Applies headers for `Content-Security-Policy` and `Content-Disposition`, if defined in the config.
*
* https://vercel.com/docs/build-output-api/v3/configuration#images
*
* @param resp Response to format.
* @param imageUrl Image URL that was resized.
* @param config Images configuration from the Vercel build output.
* @returns Formatted response.
*/
export function formatResp(
resp: Response,
imageUrl: URL,
config?: VercelImagesConfig
): Response {
const newHeaders = new Headers(resp.headers);

if (config?.contentSecurityPolicy) {
newHeaders.set('Content-Security-Policy', config.contentSecurityPolicy);
}

if (config?.contentDispositionType) {
const fileName = imageUrl.pathname.split('/').pop();
const contentDisposition = fileName
? `${config.contentDispositionType}; filename="${fileName}"`
: config.contentDispositionType;

newHeaders.set('Content-Disposition', contentDisposition);
}

return new Response(resp.body, { headers: newHeaders });
}

/**
* Handles image resizing requests.
*
* @param request Incoming request.
* @param requestUrl Incoming request's URL.
* @param config Images configuration from the Vercel build output.
* @returns Resized image response if the request is valid, otherwise a 400 response.
*/
export async function imageResizing(
james-elicx marked this conversation as resolved.
Show resolved Hide resolved
request: Request,
requestUrl: URL,
config?: VercelImagesConfig
): Promise<Response> {
const opts = getResizingProperties(request, requestUrl, config);

if (!opts) {
return new Response('Invalid image resizing request', { status: 400 });
}

const { imageUrl } = opts;

// NOTE: Pages does not support the RequestInit image resizing yet.
// const imageReq = new Request(imageUrl, { headers: request.headers });
// const imageResp = await fetch(imageReq, { cf: { image: options } });
// if (imageResp.status === 200) return formatResp(imageResp, imageUrl, config);

// NOTE: Pages also doesn't seem to support calling the `/cdn-cgi/image` endpoint either.
// const cdnCgiImageUrl = buildCdnCgiImageUrl(requestUrl, imageUrl, options);
// const cdnCgiResp = await fetch(cdnCgiImageUrl, { headers: request.headers });
// if (cdnCgiResp.status === 200) return formatResp(cdnCgiResp, imageUrl, config);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had a chat with @GregBrimble and if I understood correctly it doesn't seem like this is going to be supported in the future

so I think maybe we should drop this entirely (alongside buildCdnCgiImageUrl) as it is never going to be applied anyways

Greg?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've gone ahead and removed it for now. tbh, I wouldn't really be a fan of falling back to that endpoint even if it were possible.


const imageResp = await fetch(imageUrl, { headers: request.headers });
return formatResp(imageResp, imageUrl, config);
}
1 change: 1 addition & 0 deletions packages/next-on-pages/templates/_worker.js/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './request';
export * from './http';
export * from './pcre';
export * from './routing';
export * from './images';
Loading