Skip to content

Commit

Permalink
feat(cloudflare): imageService adapter config (#34)
Browse files Browse the repository at this point in the history
Co-authored-by: Erika <3019731+Princesseuh@users.noreply.github.com>
Co-authored-by: Arsh <69170106+lilnasy@users.noreply.github.com>
Co-authored-by: Paul Valladares <85648028+dreyfus92@users.noreply.github.com>
Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com>
  • Loading branch information
5 people authored Nov 13, 2023
1 parent 3540bd2 commit 4e1060b
Show file tree
Hide file tree
Showing 44 changed files with 643 additions and 570 deletions.
5 changes: 5 additions & 0 deletions .changeset/dull-candles-perform.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': minor
---

Adds an `imageService` adapter option to configure which image service is used. Read more in the [Cloudflare adapter docs](https://docs.astro.build/en/guides/integrations-guide/cloudflare/).
5 changes: 5 additions & 0 deletions .changeset/shaggy-wasps-care.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@astrojs/cloudflare': minor
---

Adds support for using Cloudflare's Image Resizing service as an external image service in Astro. See [Cloudflare's image docs](https://developers.cloudflare.com/images/image-resizing/) for more information about pricing and features.
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,23 +46,23 @@
}
},
"devDependencies": {
"@astrojs/check": "^0.1.0",
"@astrojs/check": "^0.3.1",
"@changesets/changelog-github": "^0.4.8",
"@changesets/cli": "^2.26.2",
"@types/node": "^18.17.8",
"@typescript-eslint/eslint-plugin": "^6.4.1",
"@typescript-eslint/parser": "^6.4.1",
"esbuild": "^0.19.2",
"esbuild": "^0.19.5",
"eslint": "^8.47.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-no-only-tests": "^3.1.0",
"eslint-plugin-prettier": "^5.0.0",
"only-allow": "^1.1.1",
"organize-imports-cli": "^0.10.0",
"prettier": "^3.0.3",
"prettier-plugin-astro": "^0.12.0",
"prettier-plugin-astro": "^0.12.1",
"tiny-glob": "^0.2.9",
"turbo": "^1.10.12",
"typescript": "~5.1.6"
"typescript": "^5.2.2"
}
}
9 changes: 9 additions & 0 deletions packages/cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,15 @@ The following example automatically generates `_routes.json` while including and
});
```

### `imageService`

`imageService: "passthrough" | "cloudflare"`

Determines which image service is used by the adapter. The adapter will default to `passthrough` mode when an incompatible image service is configured. Otherwise, it will use the globally configured image service:

- **`cloudflare`:** Uses the [Cloudflare Image Resizing](https://developers.cloudflare.com/images/image-resizing/) service.
- **`passthrough`:** Uses the existing [`noop`](https://docs.astro.build/en/guides/images/#configure-no-op-passthrough-service) service.

### `wasmModuleImports`

`wasmModuleImports: boolean`
Expand Down
17 changes: 9 additions & 8 deletions packages/cloudflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
".": "./dist/index.js",
"./entrypoints/server.advanced.js": "./dist/entrypoints/server.advanced.js",
"./entrypoints/server.directory.js": "./dist/entrypoints/server.directory.js",
"./image-service": "./dist/entrypoints/image-service.js",
"./package.json": "./package.json"
},
"files": [
Expand All @@ -34,28 +35,28 @@
},
"dependencies": {
"@astrojs/underscore-redirects": "^0.3.3",
"@cloudflare/workers-types": "^4.20230821.0",
"miniflare": "3.20231010.0",
"@cloudflare/workers-types": "^4.20231025.0",
"miniflare": "3.20231025.1",
"@iarna/toml": "^2.2.5",
"dotenv": "^16.3.1",
"esbuild": "^0.19.2",
"esbuild": "^0.19.5",
"find-up": "^6.3.0",
"tiny-glob": "^0.2.9",
"vite": "^4.4.9"
"vite": "^4.5.0"
},
"peerDependencies": {
"astro": "workspace:^3.3.0"
"astro": "^3.4.3"
},
"devDependencies": {
"execa": "^8.0.1",
"fast-glob": "^3.3.1",
"@types/iarna__toml": "^2.0.2",
"strip-ansi": "^7.1.0",
"astro": "^3.2.3",
"chai": "^4.3.7",
"astro": "^3.4.3",
"chai": "^4.3.10",
"cheerio": "1.0.0-rc.12",
"mocha": "^10.2.0",
"wrangler": "^3.11.0",
"wrangler": "^3.15.0",
"@astrojs/test-utils": "workspace:*"
},
"publishConfig": {
Expand Down
38 changes: 38 additions & 0 deletions packages/cloudflare/src/entrypoints/image-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { ExternalImageService } from 'astro';

import { baseService } from 'astro/assets';
import { isESMImportedImage, isRemoteAllowed, joinPaths } from '../utils/assets.js';

const service: ExternalImageService = {
...baseService,
getURL: (options, imageConfig) => {
const resizingParams = [];
if (options.width) resizingParams.push(`width=${options.width}`);
if (options.height) resizingParams.push(`height=${options.height}`);
if (options.quality) resizingParams.push(`quality=${options.quality}`);
if (options.fit) resizingParams.push(`fit=${options.fit}`);
if (options.format) resizingParams.push(`format=${options.format}`);

let imageSource = '';
if (isESMImportedImage(options.src)) {
imageSource = options.src.src;
} else if (isRemoteAllowed(options.src, imageConfig)) {
imageSource = options.src;
} else {
// If it's not an imported image, nor is it allowed using the current domains or remote patterns, we'll just return the original URL
return options.src;
}

const imageEndpoint = joinPaths(
// @ts-expect-error - Property 'env' does not exist on type 'ImportMeta'.ts(2339)
import.meta.env.BASE_URL,
'/cdn-cgi/image',
resizingParams.join(','),
imageSource
);

return imageEndpoint;
},
};

export default service;
19 changes: 4 additions & 15 deletions packages/cloudflare/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { AstroConfig, AstroIntegration, RouteData } from 'astro';

import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects';
import { passthroughImageService } from 'astro/config';
import { AstroError } from 'astro/errors';
import esbuild from 'esbuild';
import { Miniflare } from 'miniflare';
Expand All @@ -13,6 +12,7 @@ import glob from 'tiny-glob';
import { getAdapter } from './getAdapter.js';
import { deduplicatePatterns } from './utils/deduplicatePatterns.js';
import { getCFObject } from './utils/getCFObject.js';
import { prepareImageConfig } from './utils/image-config.js';
import {
getD1Bindings,
getDOBindings,
Expand All @@ -31,6 +31,7 @@ type CF_RUNTIME = { mode: 'off' } | { mode: 'remote' } | { mode: 'local'; persis
type Options = {
mode?: 'directory' | 'advanced';
functionPerRoute?: boolean;
imageService?: 'passthrough' | 'cloudflare';
/** Configure automatic `routes.json` generation */
routes?: {
/** Strategy for generating `include` and `exclude` patterns
Expand Down Expand Up @@ -105,17 +106,7 @@ export default function createIntegration(args?: Options): AstroIntegration {
return {
name: '@astrojs/cloudflare',
hooks: {
'astro:config:setup': ({ config, updateConfig, logger }) => {
let imageConfigOverwrite = false;
if (
config.image.service.entrypoint === 'astro/assets/services/sharp' ||
config.image.service.entrypoint === 'astro/assets/services/squoosh'
) {
logger.warn(
`The current configuration does not support image optimization. To allow your project to build with the original, unoptimized images, the image service has been automatically switched to the 'noop' option. See https://docs.astro.build/en/reference/configuration-reference/#imageservice`
);
imageConfigOverwrite = true;
}
'astro:config:setup': ({ command, config, updateConfig, logger }) => {
updateConfig({
build: {
client: new URL(`.${config.base}`, config.outDir),
Expand All @@ -132,9 +123,7 @@ export default function createIntegration(args?: Options): AstroIntegration {
}),
],
},
image: imageConfigOverwrite
? { ...config.image, service: passthroughImageService() }
: config.image,
image: prepareImageConfig(args?.imageService ?? 'DEFAULT', config.image, command, logger),
});
},
'astro:config:done': ({ setAdapter, config, logger }) => {
Expand Down
101 changes: 101 additions & 0 deletions packages/cloudflare/src/utils/assets.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { AstroConfig, ImageMetadata, RemotePattern } from 'astro';

export function isESMImportedImage(src: ImageMetadata | string): src is ImageMetadata {
return typeof src === 'object';
}
export function isRemotePath(src: string) {
return /^(http|ftp|https|ws):?\/\//.test(src) || src.startsWith('data:');
}
export function matchHostname(url: URL, hostname?: string, allowWildcard?: boolean) {
if (!hostname) {
return true;
} else if (!allowWildcard || !hostname.startsWith('*')) {
return hostname === url.hostname;
} else if (hostname.startsWith('**.')) {
const slicedHostname = hostname.slice(2); // ** length
return slicedHostname !== url.hostname && url.hostname.endsWith(slicedHostname);
} else if (hostname.startsWith('*.')) {
const slicedHostname = hostname.slice(1); // * length
const additionalSubdomains = url.hostname
.replace(slicedHostname, '')
.split('.')
.filter(Boolean);
return additionalSubdomains.length === 1;
}

return false;
}
export function matchPort(url: URL, port?: string) {
return !port || port === url.port;
}
export function matchProtocol(url: URL, protocol?: string) {
return !protocol || protocol === url.protocol.slice(0, -1);
}
export function matchPathname(url: URL, pathname?: string, allowWildcard?: boolean) {
if (!pathname) {
return true;
} else if (!allowWildcard || !pathname.endsWith('*')) {
return pathname === url.pathname;
} else if (pathname.endsWith('/**')) {
const slicedPathname = pathname.slice(0, -2); // ** length
return slicedPathname !== url.pathname && url.pathname.startsWith(slicedPathname);
} else if (pathname.endsWith('/*')) {
const slicedPathname = pathname.slice(0, -1); // * length
const additionalPathChunks = url.pathname
.replace(slicedPathname, '')
.split('/')
.filter(Boolean);
return additionalPathChunks.length === 1;
}

return false;
}
export function matchPattern(url: URL, remotePattern: RemotePattern) {
return (
matchProtocol(url, remotePattern.protocol) &&
matchHostname(url, remotePattern.hostname, true) &&
matchPort(url, remotePattern.port) &&
matchPathname(url, remotePattern.pathname, true)
);
}
export function isRemoteAllowed(
src: string,
{
domains = [],
remotePatterns = [],
}: Partial<Pick<AstroConfig['image'], 'domains' | 'remotePatterns'>>
): boolean {
if (!isRemotePath(src)) return false;

const url = new URL(src);
return (
domains.some((domain) => matchHostname(url, domain)) ||
remotePatterns.some((remotePattern) => matchPattern(url, remotePattern))
);
}
export function isString(path: unknown): path is string {
return typeof path === 'string' || path instanceof String;
}
export function removeTrailingForwardSlash(path: string) {
return path.endsWith('/') ? path.slice(0, path.length - 1) : path;
}
export function removeLeadingForwardSlash(path: string) {
return path.startsWith('/') ? path.substring(1) : path;
}
export function trimSlashes(path: string) {
return path.replace(/^\/|\/$/g, '');
}
export function joinPaths(...paths: (string | undefined)[]) {
return paths
.filter(isString)
.map((path, i) => {
if (i === 0) {
return removeTrailingForwardSlash(path);
} else if (i === paths.length - 1) {
return removeLeadingForwardSlash(path);
} else {
return trimSlashes(path);
}
})
.join('/');
}
35 changes: 35 additions & 0 deletions packages/cloudflare/src/utils/image-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import type { AstroConfig, AstroIntegrationLogger } from 'astro';
import { passthroughImageService, sharpImageService } from 'astro/config';

export function prepareImageConfig(
service: string,
config: AstroConfig['image'],
command: 'dev' | 'build' | 'preview',
logger: AstroIntegrationLogger
) {
switch (service) {
case 'passthrough':
return { ...config, service: passthroughImageService() };

case 'cloudflare':
return {
...config,
service:
command === 'dev'
? sharpImageService()
: { entrypoint: '@astrojs/cloudflare/image-service' },
};

default:
if (
config.service.entrypoint === 'astro/assets/services/sharp' ||
config.service.entrypoint === 'astro/assets/services/squoosh'
) {
logger.warn(
`The current configuration does not support image optimization. To allow your project to build with the original, unoptimized images, the image service has been automatically switched to the 'noop' option. See https://docs.astro.build/en/reference/configuration-reference/#imageservice`
);
return { ...config, service: passthroughImageService() };
}
return { ...config };
}
}
2 changes: 1 addition & 1 deletion packages/cloudflare/test/dev-runtime.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url';
import { astroCli } from './_test-utils.js';

const root = new URL('./fixtures/dev-runtime/', import.meta.url);
describe('Runtime Astro Dev', () => {
describe('DevRuntime', () => {
let cli;
before(async () => {
cli = astroCli(fileURLToPath(root), 'dev', '--host', '127.0.0.1');
Expand Down
14 changes: 14 additions & 0 deletions packages/cloudflare/test/exteral-image-service.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { expect } from 'chai';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { astroCli } from './_test-utils.js';

const root = new URL('./fixtures/external-image-service/', import.meta.url);

describe('ExternalImageService', () => {
it('has correct image service', async () => {
await astroCli(fileURLToPath(root), 'build');
const outFileToCheck = readFileSync(fileURLToPath(new URL('dist/_worker.js', root)), 'utf-8');
expect(outFileToCheck).to.include('cdn-cgi/image');
});
});
4 changes: 2 additions & 2 deletions packages/cloudflare/test/fixtures/dev-runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@
"private": true,
"dependencies": {
"@astrojs/cloudflare": "workspace:*",
"astro": "^3.2.3"
"astro": "^3.4.3"
},
"devDependencies": {
"wrangler": "^3.13.1"
"wrangler": "^3.15.0"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
"private": true,
"dependencies": {
"@astrojs/cloudflare": "workspace:*",
"astro": "^3.2.3"
"astro": "^3.4.3"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from 'astro/config';
import cloudflare from '@astrojs/cloudflare';

export default defineConfig({
adapter: cloudflare({
imageService: 'cloudflare',
}),
output: 'server',
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "@test/astro-cloudflare-external-image-service",
"version": "0.0.0",
"private": true,
"dependencies": {
"@astrojs/cloudflare": "workspace:*",
"astro": "^3.4.3"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/// <reference types="astro/client" />
Loading

0 comments on commit 4e1060b

Please sign in to comment.