Skip to content

Commit

Permalink
feat: add support for delegated URLs (#47)
Browse files Browse the repository at this point in the history
  • Loading branch information
ascorbic authored Apr 22, 2023
1 parent 91d068b commit 3f29447
Show file tree
Hide file tree
Showing 14 changed files with 322 additions and 148 deletions.
43 changes: 43 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Contributing

:heart: We love contributions of new CDNs, bug fixes, and improvements to the
code.

To add new domains or subdomains to an existing CDN, add them to `domains.json`
or `subdomains.json` respectively.

To add a new CDN, add the following:

- a new source file in `src/transformers`. This should export a `transform`
function that implements the `UrlTransformer` interface, a `parse` function
that implements the `UrlParser` interface and optionally a `generate` function
that implements the `UrlGenerator` interface.
- if the CDN should delegate remote source images to a different CDN where
possible, implement `delegateUrl` and import it in `src/delegate.ts`. This is
likely to apply to all self-hosted image servers. See the `vercel` transformer
for an example.
- a new test file in `src/transformers`. This should test all of the exported
API functions.
- at least one entry in `domains.json`, `subdomains.json` or `paths.json` to
detect the CDN. Do not include paths that are likely to cause false positives.
e.g. `/assets` is too generic, but `/_mycdn` is ok.
- add the new CDN to the types in `src/types.ts`
- import the new source file in `src/transform.ts` and `src/parse.ts`
- add a sample image to `examples.json` in the demo site. Run the site locally
to see that it works.
- ensure tests pass by installing Deno and running `deno test src`

### Image defaults

When generating image URLs, we expect transformers to use the following defaults
if supported, to ensure consistent behaviour across all CDNs:

- Auto format. If the CDN supports it, then it should deliver the best format
for the browser using content negotiation. If supported, the priority order
should be AVIF, WebP, then the original format.
- Fit = cover. The image should fill the requested dimensions, cropping if
necessary and without distortion. This is the equivalent of the CSS
`object-fit: cover` setting.
- No upscaling. The image should not be upscaled if it is smaller than the
requested dimensions. Instead it should return the largest available size, but
maintain the requested aspect ratio.
43 changes: 15 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,15 +110,18 @@ is not auto-detected.
- Vercel / Next.js
- WordPress.com and Jetpack Site Accelerator

## Usage with Vercel / Next.js

Unpic has special handling for Vercel and Next.js image URLs. It detects supported
image CDNs, and falls back to `/_vercel/image` or `/_next/image` for local and
unsupported remote images.

For more information, see the
[Unpic Vercel / Next.js](https://github.com/ascorbic/unpic/blob/main/vercel.md)
documentation.
## Delegated URLs

Some transformers support URL delegation. This means that the source image URL
is also checked, and if it matches a CDN then the transform is applied directly
to the source image. For example: consider a `next/image` URL that points to an
image on Shopify. The URL is detected as a `nextjs` URL because it starts with
`/_next/image`. The `nextjs` transformer supports delegation, so the source
image URL is then checked. As it matches a Shopify domain, the transform is
applied directly to the Shopify URL. This means that the image is transformed on
the fly by Shopify, rather than by Next.js. However if the source image is not a
supported CDN, or is a local image then the `nextjs` transformer will return a
`/_next/image` URL.

## FAQs

Expand All @@ -139,8 +142,8 @@ documentation.
useful for images that may come from an arbitrary source, such as a CMS. It is
also useful for parsing URLs that may already have transforms applied, because
most CDN SDKs will not parse these URLs correctly.
- **Can you add support for CDN X?** If it supports a URL API and has a public
domain by which it can be identified then yes, please open an issue or PR.
- **Can you add support for CDN X?** If it supports a URL API then yes, please
open an issue or PR.
- **Can you add my domain to CDN X?** If you provide a service where end-users
use your URLs then probably. Examples may be image providers such as Unsplash,
or CMSs. If it is just your own site then probably not. You can manually
Expand All @@ -165,20 +168,4 @@ documentation.

## Contributing

To add new domains or subdomains to an existing CDN, add them to `domains.json`
or `subdomains.json` respectively.

To add a new CDN, add the following:

- a new source file in `src/transformers`. This should export a `transform`
function that implements the `UrlTransformer` interface, a `parse` function
that implements the `UrlParser` interface and optionally a `generate` function
that implements the `UrlGenerator` interface.
- a new test file in `src/transformers`. This should test all of the exported
API functions.
- at least one entry in `domains.json`, `subdomains.json` or `paths.json` to
detect the CDN
- add the new CDN to the types in `src/types.ts`, and import the new source file
in `src/transform.ts`
- add a sample image to `examples.json` in the demo site
- ensure tests pass by installing Deno and running `deno test src`
See the [contributing guide](CONTRIBUTING.md).
1 change: 1 addition & 0 deletions mod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from "./src/types.ts";
export * from "./src/transform.ts";
export * from "./src/detect.ts";
export * from "./src/parse.ts";
export * from "./src/canonical.ts";
81 changes: 81 additions & 0 deletions src/canonical.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
assertEquals,
assertExists,
} from "https://deno.land/std@0.172.0/testing/asserts.ts";
import { getCanonicalCdnForUrl } from "./canonical.ts";

const nextImgLocal =
"https://netlify-plugin-nextjs-demo.netlify.app/_next/image/?url=%2F_next%2Fstatic%2Fmedia%2Funsplash.9a14a3b9.jpg&w=3840&q=75";

const nextImgRemote =
"https://netlify-plugin-nextjs-demo.netlify.app/_next/image/?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto%3Fauto%3Dformat%26fit%3Dcrop%26w%3D200%26q%3D80%26h%3D100&w=384&q=75";

Deno.test("Canonical", async (t) => {
await t.step("should detect a local image server", () => {
const result = getCanonicalCdnForUrl(nextImgLocal) || undefined;
assertExists(result);
assertEquals(
result?.url,
nextImgLocal,
);
assertEquals(
result?.cdn,
"nextjs",
);
});

await t.step(
"should detect a remote image source with a local image server",
() => {
const result = getCanonicalCdnForUrl(nextImgRemote) || undefined;
assertExists(result);
assertEquals(
result?.url,
"https://images.unsplash.com/photo?auto=format&fit=crop&w=200&q=80&h=100",
);
assertEquals(
result?.cdn,
"imgix",
);
},
);

await t.step(
"should fall back to the default CDN for unrecognized image domains",
() => {
const result =
getCanonicalCdnForUrl("https://placekitten.com/100", "vercel") ||
undefined;
assertExists(result);
assertEquals(
result?.url,
"https://placekitten.com/100",
);
assertEquals(
result?.cdn,
"vercel",
);
},
);

await t.step(
"should fall back to the detected local CDN for unrecognized source image domains",
() => {
const unknownDomain =
"https://example.com/_next/image?url=https%3A%2F%2Fplacekitten.com%2F100&w=200&q=75";
const result = getCanonicalCdnForUrl(
unknownDomain,
) ||
undefined;
assertExists(result);
assertEquals(
result?.url,
unknownDomain,
);
assertEquals(
result?.cdn,
"nextjs",
);
},
);
});
44 changes: 44 additions & 0 deletions src/canonical.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { getImageCdnForUrl } from "./detect.ts";
import { CanonicalCdnUrl, ImageCdn, ShouldDelegateUrl } from "./types.ts";
import { delegateUrl as vercel } from "./transformers/vercel.ts";
import { delegateUrl as nextjs } from "./transformers/nextjs.ts";

// Image servers that might delegate to another CDN
const delegators: Partial<Record<ImageCdn, ShouldDelegateUrl>> = {
vercel,
nextjs,
};

export function getDelegatedCdn(
url: string | URL,
cdn: ImageCdn,
): CanonicalCdnUrl | false {
// Most CDNs are authoritative for their own URLs
if (!(cdn in delegators)) {
return false;
}
const maybeDelegate = delegators[cdn];
if (!maybeDelegate) {
return false;
}
return maybeDelegate(url);
}

/**
* Gets the canonical URL and CDN for a given image URL, recursing into
* the source image if it is hosted on another CDN.
*/
export function getCanonicalCdnForUrl(
url: string | URL,
defaultCdn?: ImageCdn | false,
): CanonicalCdnUrl | false {
const cdn = getImageCdnForUrl(url) || defaultCdn;
if (!cdn) {
return false;
}
const maybeDelegated = getDelegatedCdn(url, cdn);
if (maybeDelegated) {
return maybeDelegated;
}
return { cdn, url };
}
19 changes: 17 additions & 2 deletions src/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,16 @@ import { ImageCdn } from "./types.ts";
const cdnDomains = new Map(Object.entries(domains));
const cdnSubdomains = Object.entries(subdomains);

export function getImageCdnForUrl(url: string | URL): ImageCdn | false {
const { hostname, pathname } = new URL(url);
export function getImageCdnForUrl(
url: string | URL,
): ImageCdn | false {
return getImageCdnForUrlByDomain(url) || getImageCdnForUrlByPath(url);
}

export function getImageCdnForUrlByDomain(
url: string | URL,
): ImageCdn | false {
const { hostname } = new URL(url);
if (cdnDomains.has(hostname)) {
return cdnDomains.get(hostname) as ImageCdn;
}
Expand All @@ -16,6 +24,13 @@ export function getImageCdnForUrl(url: string | URL): ImageCdn | false {
return cdn as ImageCdn;
}
}
return false;
}

export function getImageCdnForUrlByPath(
url: string | URL,
): ImageCdn | false {
const { pathname } = new URL(url);
for (const [prefix, cdn] of Object.entries(paths)) {
if (pathname.startsWith(prefix)) {
return cdn as ImageCdn;
Expand Down
44 changes: 44 additions & 0 deletions src/transform.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { assertEquals } from "https://deno.land/std@0.172.0/testing/asserts.ts";
import { transformUrl } from "./transform.ts";

const imgRemote =
"https://netlify-plugin-nextjs-demo.netlify.app/_vercel/image/?url=https%3A%2F%2Fimages.unsplash.com%2Fphoto%3Fauto%3Dformat%26fit%3Dcrop%26w%3D200%26q%3D80%26h%3D100&w=384&q=75";

Deno.test("transformer", async (t) => {
await t.step("should format a remote URL", () => {
const result = transformUrl({
url: imgRemote,
width: 200,
height: 100,
});
assertEquals(
result?.toString(),
"https://images.unsplash.com/photo?auto=format&fit=crop&w=200&q=80&h=100",
);
});

await t.step("should format a remote CDN URL", () => {
const result = transformUrl({
url: "https://images.unsplash.com/photo",
width: 200,
height: 100,
});
assertEquals(
result?.toString(),
"https://images.unsplash.com/photo?w=200&h=100&fit=min&auto=format",
);
});

await t.step("should format a remote, non-CDN image next/image", () => {
const result = transformUrl({
url: "https://placekitten.com/100",
width: 200,
height: 100,
cdn: "nextjs",
});
assertEquals(
result?.toString(),
"/_next/image?url=https%3A%2F%2Fplacekitten.com%2F100&w=200&q=75",
);
});
});
36 changes: 26 additions & 10 deletions src/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { transform as nextjs } from "./transformers/nextjs.ts";
import { transform as scene7 } from "./transformers/scene7.ts";
import { transform as keycdn } from "./transformers/keycdn.ts";
import { ImageCdn, UrlTransformer } from "./types.ts";
import { getCanonicalCdnForUrl } from "./canonical.ts";

export const getTransformer = (cdn: ImageCdn) => ({
imgix,
Expand All @@ -32,13 +33,6 @@ export const getTransformer = (cdn: ImageCdn) => ({
keycdn,
}[cdn]);

/**
* Returns a transformer function if the given URL is from a known image CDN
*/
export const getTransformerForUrl = (
url: string | URL,
): UrlTransformer | undefined => getTransformerForCdn(getImageCdnForUrl(url));

/**
* Returns a transformer function if the given CDN is supported
*/
Expand All @@ -56,8 +50,30 @@ export const getTransformerForCdn = (
* If the URL is not from a known image CDN it returns undefined.
*/
export const transformUrl: UrlTransformer = (options) => {
if (options.cdn) {
return getTransformerForCdn(options.cdn)?.(options);
const cdn = options?.cdn ?? getImageCdnForUrl(options.url);

// Default to recursive
if (!(options.recursive ?? true)) {
return getTransformerForCdn(cdn)?.(options);
}
return getTransformerForUrl(options.url)?.(options);
const canonical = getCanonicalCdnForUrl(
options.url,
cdn,
);
if (!canonical || !canonical.cdn) {
return undefined;
}
return getTransformer(canonical.cdn)?.({
...options,
url: canonical.url,
});
};

/**
* Returns a transformer function if the given URL is from a known image CDN
*
* @deprecated Use `getCanonicalCdnForUrl` and `getTransformerForCdn` instead
*/
export const getTransformerForUrl = (
url: string | URL,
): UrlTransformer | undefined => getTransformerForCdn(getImageCdnForUrl(url));
Loading

0 comments on commit 3f29447

Please sign in to comment.