aboutsummaryrefslogtreecommitdiff
path: root/packages/integrations/cloudflare/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/integrations/cloudflare/src')
-rw-r--r--packages/integrations/cloudflare/src/entrypoints/image-endpoint.ts15
-rw-r--r--packages/integrations/cloudflare/src/entrypoints/image-service.ts40
-rw-r--r--packages/integrations/cloudflare/src/entrypoints/middleware.ts12
-rw-r--r--packages/integrations/cloudflare/src/entrypoints/server.ts112
-rw-r--r--packages/integrations/cloudflare/src/index.ts419
-rw-r--r--packages/integrations/cloudflare/src/utils/assets.ts76
-rw-r--r--packages/integrations/cloudflare/src/utils/cloudflare-module-loader.ts252
-rw-r--r--packages/integrations/cloudflare/src/utils/env.ts15
-rw-r--r--packages/integrations/cloudflare/src/utils/generate-routes-json.ts347
-rw-r--r--packages/integrations/cloudflare/src/utils/image-config.ts46
10 files changed, 1334 insertions, 0 deletions
diff --git a/packages/integrations/cloudflare/src/entrypoints/image-endpoint.ts b/packages/integrations/cloudflare/src/entrypoints/image-endpoint.ts
new file mode 100644
index 000000000..dbc1e1f5a
--- /dev/null
+++ b/packages/integrations/cloudflare/src/entrypoints/image-endpoint.ts
@@ -0,0 +1,15 @@
+import type { APIRoute } from 'astro';
+
+export const prerender = false;
+
+export const GET: APIRoute = (ctx) => {
+ const href = ctx.url.searchParams.get('href');
+ if (!href) {
+ return new Response("Missing 'href' query parameter", {
+ status: 400,
+ statusText: "Missing 'href' query parameter",
+ });
+ }
+
+ return fetch(new URL(href, ctx.url.origin));
+};
diff --git a/packages/integrations/cloudflare/src/entrypoints/image-service.ts b/packages/integrations/cloudflare/src/entrypoints/image-service.ts
new file mode 100644
index 000000000..1c3aa5758
--- /dev/null
+++ b/packages/integrations/cloudflare/src/entrypoints/image-service.ts
@@ -0,0 +1,40 @@
+import type { ExternalImageService } from 'astro';
+
+import { joinPaths } from '@astrojs/internal-helpers/path';
+import { baseService } from 'astro/assets';
+import { isESMImportedImage } from 'astro/assets/utils';
+import { isRemoteAllowed } from '../utils/assets.js';
+
+const service: ExternalImageService = {
+ ...baseService,
+ getURL: (options, imageConfig) => {
+ const resizingParams = ['onerror=redirect'];
+ 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 Can't recognise import.meta.env
+ import.meta.env.BASE_URL,
+ '/cdn-cgi/image',
+ resizingParams.join(','),
+ imageSource,
+ );
+
+ return imageEndpoint;
+ },
+};
+
+export default service;
diff --git a/packages/integrations/cloudflare/src/entrypoints/middleware.ts b/packages/integrations/cloudflare/src/entrypoints/middleware.ts
new file mode 100644
index 000000000..187aface5
--- /dev/null
+++ b/packages/integrations/cloudflare/src/entrypoints/middleware.ts
@@ -0,0 +1,12 @@
+import type { MiddlewareHandler } from 'astro';
+
+export const onRequest: MiddlewareHandler = (context, next) => {
+ if (context.isPrerendered) {
+ // @ts-expect-error
+ context.locals.runtime ??= {
+ env: process.env,
+ };
+ }
+
+ return next();
+};
diff --git a/packages/integrations/cloudflare/src/entrypoints/server.ts b/packages/integrations/cloudflare/src/entrypoints/server.ts
new file mode 100644
index 000000000..a37f820ab
--- /dev/null
+++ b/packages/integrations/cloudflare/src/entrypoints/server.ts
@@ -0,0 +1,112 @@
+import { env as globalEnv } from 'cloudflare:workers';
+import type {
+ CacheStorage as CLOUDFLARE_CACHESTORAGE,
+ Request as CLOUDFLARE_REQUEST,
+ ExecutionContext,
+} from '@cloudflare/workers-types';
+import type { SSRManifest } from 'astro';
+import { App } from 'astro/app';
+import { setGetEnv } from 'astro/env/setup';
+import { createGetEnv } from '../utils/env.js';
+
+setGetEnv(createGetEnv(globalEnv as Env));
+
+type Env = {
+ [key: string]: unknown;
+ ASSETS: { fetch: (req: Request | string) => Promise<Response> };
+ ASTRO_STUDIO_APP_TOKEN?: string;
+};
+
+export interface Runtime<T extends object = object> {
+ runtime: {
+ env: Env & T;
+ cf: CLOUDFLARE_REQUEST['cf'];
+ caches: CLOUDFLARE_CACHESTORAGE;
+ ctx: ExecutionContext;
+ };
+}
+
+declare global {
+ // This is not a real global, but is injected using Vite define to allow us to specify the session binding name in the config.
+ // eslint-disable-next-line no-var
+ var __ASTRO_SESSION_BINDING_NAME: string;
+
+ // Just used to pass the KV binding to unstorage.
+ // eslint-disable-next-line no-var
+ var __env__: Partial<Env>;
+}
+
+export function createExports(manifest: SSRManifest) {
+ const app = new App(manifest);
+
+ const fetch = async (
+ request: Request & CLOUDFLARE_REQUEST,
+ env: Env,
+ context: ExecutionContext,
+ ) => {
+ const { pathname } = new URL(request.url);
+ const bindingName = globalThis.__ASTRO_SESSION_BINDING_NAME;
+ // Assigning the KV binding to globalThis allows unstorage to access it for session storage.
+ // unstorage checks in globalThis and globalThis.__env__ for the binding.
+ globalThis.__env__ ??= {};
+ globalThis.__env__[bindingName] = env[bindingName];
+
+ // static assets fallback, in case default _routes.json is not used
+ if (manifest.assets.has(pathname)) {
+ return env.ASSETS.fetch(request.url.replace(/\.html$/, ''));
+ }
+
+ const routeData = app.match(request);
+ if (!routeData) {
+ // https://developers.cloudflare.com/pages/functions/api-reference/#envassetsfetch
+ const asset = await env.ASSETS.fetch(
+ request.url.replace(/index.html$/, '').replace(/\.html$/, ''),
+ );
+ if (asset.status !== 404) {
+ return asset;
+ }
+ }
+
+ Reflect.set(
+ request,
+ Symbol.for('astro.clientAddress'),
+ request.headers.get('cf-connecting-ip'),
+ );
+
+ process.env.ASTRO_STUDIO_APP_TOKEN ??= (() => {
+ if (typeof env.ASTRO_STUDIO_APP_TOKEN === 'string') {
+ return env.ASTRO_STUDIO_APP_TOKEN;
+ }
+ })();
+
+ const locals: Runtime = {
+ runtime: {
+ env: env,
+ cf: request.cf,
+ caches: caches as unknown as CLOUDFLARE_CACHESTORAGE,
+ ctx: {
+ waitUntil: (promise: Promise<any>) => context.waitUntil(promise),
+ // Currently not available: https://developers.cloudflare.com/pages/platform/known-issues/#pages-functions
+ passThroughOnException: () => {
+ throw new Error(
+ '`passThroughOnException` is currently not available in Cloudflare Pages. See https://developers.cloudflare.com/pages/platform/known-issues/#pages-functions.',
+ );
+ },
+ props: {},
+ },
+ },
+ };
+
+ const response = await app.render(request, { routeData, locals });
+
+ if (app.setCookieHeaders) {
+ for (const setCookieHeader of app.setCookieHeaders(response)) {
+ response.headers.append('Set-Cookie', setCookieHeader);
+ }
+ }
+
+ return response;
+ };
+
+ return { default: { fetch } };
+}
diff --git a/packages/integrations/cloudflare/src/index.ts b/packages/integrations/cloudflare/src/index.ts
new file mode 100644
index 000000000..7476c29ff
--- /dev/null
+++ b/packages/integrations/cloudflare/src/index.ts
@@ -0,0 +1,419 @@
+import type {
+ AstroConfig,
+ AstroIntegration,
+ HookParameters,
+ IntegrationResolvedRoute,
+} from 'astro';
+import type { PluginOption } from 'vite';
+
+import { createReadStream } from 'node:fs';
+import { appendFile, stat } from 'node:fs/promises';
+import { createInterface } from 'node:readline/promises';
+import { fileURLToPath } from 'node:url';
+import {
+ appendForwardSlash,
+ prependForwardSlash,
+ removeLeadingForwardSlash,
+} from '@astrojs/internal-helpers/path';
+import { createRedirectsFromAstroRoutes } from '@astrojs/underscore-redirects';
+import { AstroError } from 'astro/errors';
+import { defaultClientConditions } from 'vite';
+import { type GetPlatformProxyOptions, getPlatformProxy } from 'wrangler';
+import {
+ type CloudflareModulePluginExtra,
+ cloudflareModuleLoader,
+} from './utils/cloudflare-module-loader.js';
+import { createGetEnv } from './utils/env.js';
+import { createRoutesFile, getParts } from './utils/generate-routes-json.js';
+import { type ImageService, setImageConfig } from './utils/image-config.js';
+
+export type { Runtime } from './entrypoints/server.js';
+
+export type Options = {
+ /** Options for handling images. */
+ imageService?: ImageService;
+ /** Configuration for `_routes.json` generation. A _routes.json file controls when your Function is invoked. This file will include three different properties:
+ *
+ * - version: Defines the version of the schema. Currently there is only one version of the schema (version 1), however, we may add more in the future and aim to be backwards compatible.
+ * - include: Defines routes that will be invoked by Functions. Accepts wildcard behavior.
+ * - exclude: Defines routes that will not be invoked by Functions. Accepts wildcard behavior. `exclude` always take priority over `include`.
+ *
+ * Wildcards match any number of path segments (slashes). For example, `/users/*` will match everything after the `/users/` path.
+ *
+ */
+ routes?: {
+ /** Extend `_routes.json` */
+ extend: {
+ /** Paths which should be routed to the SSR function */
+ include?: {
+ /** Generally this is in pathname format, but does support wildcards, e.g. `/users`, `/products/*` */
+ pattern: string;
+ }[];
+ /** Paths which should be routed as static assets */
+ exclude?: {
+ /** Generally this is in pathname format, but does support wildcards, e.g. `/static`, `/assets/*`, `/images/avatar.jpg` */
+ pattern: string;
+ }[];
+ };
+ };
+ /**
+ * Proxy configuration for the platform.
+ */
+ platformProxy?: GetPlatformProxyOptions & {
+ /** Toggle the proxy. Default `undefined`, which equals to `true`. */
+ enabled?: boolean;
+ };
+
+ /**
+ * Allow bundling cloudflare worker specific file types as importable modules. Defaults to true.
+ * When enabled, allows imports of '.wasm', '.bin', and '.txt' file types
+ *
+ * See https://developers.cloudflare.com/pages/functions/module-support/
+ * for reference on how these file types are exported
+ */
+ cloudflareModules?: boolean;
+
+ /**
+ * By default, Astro will be configured to use Cloudflare KV to store session data. If you want to use sessions,
+ * you must create a KV namespace and declare it in your wrangler config file. You can do this with the wrangler command:
+ *
+ * ```sh
+ * npx wrangler kv namespace create SESSION
+ * ```
+ *
+ * This will log the id of the created namespace. You can then add it to your `wrangler.json` file like this:
+ *
+ * ```json
+ * {
+ * "kv_namespaces": [
+ * {
+ * "binding": "SESSION",
+ * "id": "<your kv namespace id here>"
+ * }
+ * ]
+ * }
+ * ```
+ * By default, the driver looks for the binding named `SESSION`, but you can override this by providing a different name here.
+ *
+ * See https://developers.cloudflare.com/kv/concepts/kv-namespaces/ for more details on using KV namespaces.
+ *
+ */
+
+ sessionKVBindingName?: string;
+};
+
+function wrapWithSlashes(path: string): string {
+ return prependForwardSlash(appendForwardSlash(path));
+}
+
+function setProcessEnv(config: AstroConfig, env: Record<string, unknown>) {
+ const getEnv = createGetEnv(env);
+
+ if (config.env?.schema) {
+ for (const key of Object.keys(config.env.schema)) {
+ const value = getEnv(key);
+ if (value !== undefined) {
+ process.env[key] = value;
+ }
+ }
+ }
+}
+
+export default function createIntegration(args?: Options): AstroIntegration {
+ let _config: AstroConfig;
+ let finalBuildOutput: HookParameters<'astro:config:done'>['buildOutput'];
+
+ const cloudflareModulePlugin: PluginOption & CloudflareModulePluginExtra = cloudflareModuleLoader(
+ args?.cloudflareModules ?? true,
+ );
+
+ let _routes: IntegrationResolvedRoute[];
+
+ return {
+ name: '@astrojs/cloudflare',
+ hooks: {
+ 'astro:config:setup': ({
+ command,
+ config,
+ updateConfig,
+ logger,
+ addWatchFile,
+ addMiddleware,
+ createCodegenDir,
+ }) => {
+ let session = config.session;
+
+ const isBuild = command === 'build';
+
+ if (!session?.driver) {
+ const sessionDir = isBuild ? undefined : createCodegenDir();
+ const bindingName = args?.sessionKVBindingName ?? 'SESSION';
+
+ if (isBuild) {
+ logger.info(
+ `Enabling sessions with Cloudflare KV for production with the "${bindingName}" KV binding.`,
+ );
+ logger.info(
+ `If you see the error "Invalid binding \`${bindingName}\`" in your build output, you need to add the binding to your wrangler config file.`,
+ );
+ }
+
+ session = isBuild
+ ? {
+ ...session,
+ driver: 'cloudflare-kv-binding',
+ options: {
+ binding: bindingName,
+ ...session?.options,
+ },
+ }
+ : {
+ ...session,
+ driver: 'fs-lite',
+ options: {
+ base: fileURLToPath(new URL('sessions', sessionDir)),
+ ...session?.options,
+ },
+ };
+ }
+
+ updateConfig({
+ build: {
+ client: new URL(`.${wrapWithSlashes(config.base)}`, config.outDir),
+ server: new URL('./_worker.js/', config.outDir),
+ serverEntry: 'index.js',
+ redirects: false,
+ },
+ session,
+ vite: {
+ plugins: [
+ // https://developers.cloudflare.com/pages/functions/module-support/
+ // Allows imports of '.wasm', '.bin', and '.txt' file types
+ cloudflareModulePlugin,
+ {
+ name: 'vite:cf-imports',
+ enforce: 'pre',
+ resolveId(source) {
+ if (source.startsWith('cloudflare:')) {
+ return { id: source, external: true };
+ }
+ return null;
+ },
+ },
+ ],
+ },
+ image: setImageConfig(args?.imageService ?? 'compile', config.image, command, logger),
+ });
+ if (args?.platformProxy?.configPath) {
+ addWatchFile(new URL(args.platformProxy.configPath, config.root));
+ } else {
+ addWatchFile(new URL('./wrangler.toml', config.root));
+ addWatchFile(new URL('./wrangler.json', config.root));
+ addWatchFile(new URL('./wrangler.jsonc', config.root));
+ }
+ addMiddleware({
+ entrypoint: '@astrojs/cloudflare/entrypoints/middleware.js',
+ order: 'pre',
+ });
+ },
+ 'astro:routes:resolved': ({ routes }) => {
+ _routes = routes;
+ },
+ 'astro:config:done': ({ setAdapter, config, buildOutput, logger }) => {
+ if (buildOutput === 'static') {
+ logger.warn(
+ '[@astrojs/cloudflare] This adapter is intended to be used with server rendered pages, which this project does not contain any of. As such, this adapter is unnecessary.',
+ );
+ }
+
+ _config = config;
+ finalBuildOutput = buildOutput;
+
+ setAdapter({
+ name: '@astrojs/cloudflare',
+ serverEntrypoint: '@astrojs/cloudflare/entrypoints/server.js',
+ exports: ['default'],
+ adapterFeatures: {
+ edgeMiddleware: false,
+ buildOutput: 'server',
+ },
+ supportedAstroFeatures: {
+ serverOutput: 'stable',
+ hybridOutput: 'stable',
+ staticOutput: 'unsupported',
+ i18nDomains: 'experimental',
+ sharpImageService: {
+ support: 'limited',
+ message:
+ 'Cloudflare does not support sharp at runtime. However, you can configure `imageService: "compile"` to optimize images with sharp on prerendered pages during build time.',
+ // For explicitly set image services, we suppress the warning about sharp not being supported at runtime,
+ // inferring the user is aware of the limitations.
+ suppress: args?.imageService ? 'all' : 'default',
+ },
+ envGetSecret: 'stable',
+ },
+ });
+ },
+ 'astro:server:setup': async ({ server }) => {
+ if ((args?.platformProxy?.enabled ?? true) === true) {
+ const platformProxy = await getPlatformProxy(args?.platformProxy);
+
+ setProcessEnv(_config, platformProxy.env);
+
+ const clientLocalsSymbol = Symbol.for('astro.locals');
+
+ server.middlewares.use(async function middleware(req, _res, next) {
+ Reflect.set(req, clientLocalsSymbol, {
+ runtime: {
+ env: platformProxy.env,
+ cf: platformProxy.cf,
+ caches: platformProxy.caches,
+ ctx: {
+ waitUntil: (promise: Promise<any>) => platformProxy.ctx.waitUntil(promise),
+ // Currently not available: https://developers.cloudflare.com/pages/platform/known-issues/#pages-functions
+ passThroughOnException: () => {
+ throw new AstroError(
+ '`passThroughOnException` is currently not available in Cloudflare Pages. See https://developers.cloudflare.com/pages/platform/known-issues/#pages-functions.',
+ );
+ },
+ },
+ },
+ });
+ next();
+ });
+ }
+ },
+ 'astro:build:setup': ({ vite, target }) => {
+ if (target === 'server') {
+ vite.resolve ||= {};
+ vite.resolve.alias ||= {};
+
+ const aliases = [
+ {
+ find: 'react-dom/server',
+ replacement: 'react-dom/server.browser',
+ },
+ ];
+
+ if (Array.isArray(vite.resolve.alias)) {
+ vite.resolve.alias = [...vite.resolve.alias, ...aliases];
+ } else {
+ for (const alias of aliases) {
+ (vite.resolve.alias as Record<string, string>)[alias.find] = alias.replacement;
+ }
+ }
+
+ // Support `workerd` and `worker` conditions for the ssr environment
+ // (previously supported in esbuild instead: https://github.com/withastro/astro/pull/7092)
+ vite.ssr ||= {};
+ vite.ssr.resolve ||= {};
+ vite.ssr.resolve.conditions ||= [...defaultClientConditions];
+ vite.ssr.resolve.conditions.push('workerd', 'worker');
+
+ vite.ssr.target = 'webworker';
+ vite.ssr.noExternal = true;
+
+ vite.build ||= {};
+ vite.build.rollupOptions ||= {};
+ vite.build.rollupOptions.output ||= {};
+ // @ts-expect-error
+ vite.build.rollupOptions.output.banner ||=
+ 'globalThis.process ??= {}; globalThis.process.env ??= {};';
+
+ // Cloudflare env is only available per request. This isn't feasible for code that access env vars
+ // in a global way, so we shim their access as `process.env.*`. This is not the recommended way for users to access environment variables. But we'll add this for compatibility for chosen variables. Mainly to support `@astrojs/db`
+ vite.define = {
+ 'process.env': 'process.env',
+ // Allows the request handler to know what the binding name is
+ 'globalThis.__ASTRO_SESSION_BINDING_NAME': JSON.stringify(
+ args?.sessionKVBindingName ?? 'SESSION',
+ ),
+ ...vite.define,
+ };
+ }
+ },
+ 'astro:build:done': async ({ pages, dir, logger, assets }) => {
+ await cloudflareModulePlugin.afterBuildCompleted(_config);
+
+ let redirectsExists = false;
+ try {
+ const redirectsStat = await stat(new URL('./_redirects', _config.outDir));
+ if (redirectsStat.isFile()) {
+ redirectsExists = true;
+ }
+ } catch (_error) {
+ redirectsExists = false;
+ }
+
+ const redirects: IntegrationResolvedRoute['segments'][] = [];
+ if (redirectsExists) {
+ const rl = createInterface({
+ input: createReadStream(new URL('./_redirects', _config.outDir)),
+ crlfDelay: Number.POSITIVE_INFINITY,
+ });
+
+ for await (const line of rl) {
+ const parts = line.split(' ');
+ if (parts.length >= 2) {
+ const p = removeLeadingForwardSlash(parts[0])
+ .split('/')
+ .filter(Boolean)
+ .map((s: string) => {
+ const syntax = s
+ .replace(/\/:.*?(?=\/|$)/g, '/*')
+ // remove query params as they are not supported by cloudflare
+ .replace(/\?.*$/, '');
+ return getParts(syntax);
+ });
+ redirects.push(p);
+ }
+ }
+ }
+
+ let routesExists = false;
+ try {
+ const routesStat = await stat(new URL('./_routes.json', _config.outDir));
+ if (routesStat.isFile()) {
+ routesExists = true;
+ }
+ } catch (_error) {
+ routesExists = false;
+ }
+
+ if (!routesExists) {
+ await createRoutesFile(
+ _config,
+ logger,
+ _routes,
+ pages,
+ redirects,
+ args?.routes?.extend?.include,
+ args?.routes?.extend?.exclude,
+ );
+ }
+
+ const trueRedirects = createRedirectsFromAstroRoutes({
+ config: _config,
+ routeToDynamicTargetMap: new Map(
+ Array.from(
+ _routes
+ .filter((route) => route.type === 'redirect')
+ .map((route) => [route, ''] as const),
+ ),
+ ),
+ dir,
+ buildOutput: finalBuildOutput,
+ assets,
+ });
+
+ if (!trueRedirects.empty()) {
+ try {
+ await appendFile(new URL('./_redirects', _config.outDir), trueRedirects.print());
+ } catch (_error) {
+ logger.error('Failed to write _redirects file');
+ }
+ }
+ },
+ },
+ };
+}
diff --git a/packages/integrations/cloudflare/src/utils/assets.ts b/packages/integrations/cloudflare/src/utils/assets.ts
new file mode 100644
index 000000000..4b784f865
--- /dev/null
+++ b/packages/integrations/cloudflare/src/utils/assets.ts
@@ -0,0 +1,76 @@
+import { isRemotePath } from '@astrojs/internal-helpers/path';
+import type { AstroConfig, RemotePattern } from 'astro';
+
+function matchHostname(url: URL, hostname?: string, allowWildcard?: boolean) {
+ if (!hostname) {
+ return true;
+ }
+ if (!allowWildcard || !hostname.startsWith('*')) {
+ return hostname === url.hostname;
+ }
+ if (hostname.startsWith('**.')) {
+ const slicedHostname = hostname.slice(2); // ** length
+ return slicedHostname !== url.hostname && url.hostname.endsWith(slicedHostname);
+ }
+ if (hostname.startsWith('*.')) {
+ const slicedHostname = hostname.slice(1); // * length
+ const additionalSubdomains = url.hostname
+ .replace(slicedHostname, '')
+ .split('.')
+ .filter(Boolean);
+ return additionalSubdomains.length === 1;
+ }
+
+ return false;
+}
+function matchPort(url: URL, port?: string) {
+ return !port || port === url.port;
+}
+function matchProtocol(url: URL, protocol?: string) {
+ return !protocol || protocol === url.protocol.slice(0, -1);
+}
+function matchPathname(url: URL, pathname?: string, allowWildcard?: boolean) {
+ if (!pathname) {
+ return true;
+ }
+ if (!allowWildcard || !pathname.endsWith('*')) {
+ return pathname === url.pathname;
+ }
+ if (pathname.endsWith('/**')) {
+ const slicedPathname = pathname.slice(0, -2); // ** length
+ return slicedPathname !== url.pathname && url.pathname.startsWith(slicedPathname);
+ }
+ 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;
+}
+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))
+ );
+}
diff --git a/packages/integrations/cloudflare/src/utils/cloudflare-module-loader.ts b/packages/integrations/cloudflare/src/utils/cloudflare-module-loader.ts
new file mode 100644
index 000000000..15e9f1a8b
--- /dev/null
+++ b/packages/integrations/cloudflare/src/utils/cloudflare-module-loader.ts
@@ -0,0 +1,252 @@
+import * as fs from 'node:fs/promises';
+import * as path from 'node:path';
+import * as url from 'node:url';
+import type { AstroConfig } from 'astro';
+import type { OutputBundle } from 'rollup';
+import type { PluginOption } from 'vite';
+
+export interface CloudflareModulePluginExtra {
+ afterBuildCompleted(config: AstroConfig): Promise<void>;
+}
+
+type ModuleType = 'CompiledWasm' | 'Text' | 'Data';
+
+/**
+ * Enables support for various non-standard extensions in module imports that cloudflare workers supports.
+ *
+ * See https://developers.cloudflare.com/pages/functions/module-support/ for reference
+ *
+ * This adds supports for imports in the following formats:
+ * - .wasm
+ * - .wasm?module
+ * - .bin
+ * - .txt
+ *
+ * @param enabled - if true, will load all cloudflare pages supported types
+ * @returns Vite plugin with additional extension method to hook into astro build
+ */
+export function cloudflareModuleLoader(
+ enabled: boolean,
+): PluginOption & CloudflareModulePluginExtra {
+ /**
+ * It's likely that eventually cloudflare will add support for custom extensions, like they do in vanilla cloudflare workers,
+ * by adding rules to your wrangler.tome
+ * https://developers.cloudflare.com/workers/wrangler/bundling/
+ */
+ const adaptersByExtension: Record<string, ModuleType> = enabled ? { ...defaultAdapters } : {};
+
+ const extensions = Object.keys(adaptersByExtension);
+
+ let isDev = false;
+ const MAGIC_STRING = '__CLOUDFLARE_ASSET__';
+ const replacements: Replacement[] = [];
+
+ return {
+ name: 'vite:cf-module-loader',
+ enforce: 'pre',
+ configResolved(config) {
+ isDev = config.command === 'serve';
+ },
+ config(_, __) {
+ // let vite know that file format and the magic import string is intentional, and will be handled in this plugin
+ return {
+ assetsInclude: extensions.map((x) => `**/*${x}`),
+ build: {
+ rollupOptions: {
+ // mark the wasm files as external so that they are not bundled and instead are loaded from the files
+ external: extensions.map(
+ (x) => new RegExp(`^${MAGIC_STRING}.+${escapeRegExp(x)}.mjs$`, 'i'),
+ ),
+ },
+ },
+ };
+ },
+
+ async load(id, _) {
+ const maybeExtension = extensions.find((x) => id.endsWith(x));
+ const moduleType: ModuleType | undefined =
+ (maybeExtension && adaptersByExtension[maybeExtension]) || undefined;
+ if (!moduleType || !maybeExtension) {
+ return;
+ }
+ if (!enabled) {
+ throw new Error(
+ `Cloudflare module loading is experimental. The ${maybeExtension} module cannot be loaded unless you add \`cloudflareModules: true\` to your astro config.`,
+ );
+ }
+
+ const moduleLoader = renderers[moduleType];
+
+ const filePath = id.replace(/\?\w+$/, '');
+ const extension = maybeExtension.replace(/\?\w+$/, '');
+
+ const data = await fs.readFile(filePath);
+ const base64 = data.toString('base64');
+
+ const inlineModule = moduleLoader(data);
+
+ if (isDev) {
+ // no need to wire up the assets in dev mode, just rewrite
+ return inlineModule;
+ }
+ // just some shared ID
+ const hash = hashString(base64);
+ // emit the wasm binary as an asset file, to be picked up later by the esbuild bundle for the worker.
+ // give it a shared deterministic name to make things easy for esbuild to switch on later
+ const assetName = `${path.basename(filePath).split('.')[0]}.${hash}${extension}`;
+ this.emitFile({
+ type: 'asset',
+ // emit the data explicitly as an esset with `fileName` rather than `name` so that
+ // vite doesn't give it a random hash-id in its name--We need to be able to easily rewrite from
+ // the .mjs loader and the actual wasm asset later in the ESbuild for the worker
+ fileName: assetName,
+ source: data,
+ });
+
+ // however, by default, the SSG generator cannot import the .wasm as a module, so embed as a base64 string
+ const chunkId = this.emitFile({
+ type: 'prebuilt-chunk',
+ fileName: `${assetName}.mjs`,
+ code: inlineModule,
+ });
+
+ return `import module from "${MAGIC_STRING}${chunkId}${extension}.mjs";export default module;`;
+ },
+
+ // output original wasm file relative to the chunk now that chunking has been achieved
+ renderChunk(code, chunk, _) {
+ if (isDev) return;
+
+ if (!code.includes(MAGIC_STRING)) return;
+
+ // SSR will need the .mjs suffix removed from the import before this works in cloudflare, but this is done as a final step
+ // so as to support prerendering from nodejs runtime
+ let replaced = code;
+ for (const ext of extensions) {
+ const extension = ext.replace(/\?\w+$/, '');
+ // chunk id can be many things, (alpha numeric, dollars, or underscores, maybe more)
+ replaced = replaced.replaceAll(
+ new RegExp(`${MAGIC_STRING}([^\\s]+?)${escapeRegExp(extension)}\\.mjs`, 'g'),
+ (_s, assetId) => {
+ const fileName = this.getFileName(assetId);
+ const relativePath = path
+ .relative(path.dirname(chunk.fileName), fileName)
+ .replaceAll('\\', '/'); // fix windows paths for import
+
+ // record this replacement for later, to adjust it to import the unbundled asset
+ replacements.push({
+ chunkName: chunk.name,
+ cloudflareImport: relativePath.replace(/\.mjs$/, ''),
+ nodejsImport: relativePath,
+ });
+ return `./${relativePath}`;
+ },
+ );
+ }
+
+ return { code: replaced };
+ },
+
+ generateBundle(_, bundle: OutputBundle) {
+ // associate the chunk name to the final file name. After the prerendering is done, we can use this to replace the imports in the _worker.js
+ // in a targetted way
+ const replacementsByChunkName = new Map<string, Replacement[]>();
+ for (const replacement of replacements) {
+ const repls = replacementsByChunkName.get(replacement.chunkName) || [];
+ if (!repls.length) {
+ replacementsByChunkName.set(replacement.chunkName, repls);
+ }
+ repls.push(replacement);
+ }
+ for (const chunk of Object.values(bundle)) {
+ const repls = chunk.name && replacementsByChunkName.get(chunk.name);
+ for (const replacement of repls || []) {
+ if (!replacement.fileName) {
+ replacement.fileName = [] as string[];
+ }
+ replacement.fileName.push(chunk.fileName);
+ }
+ }
+ },
+
+ /**
+ * Once prerendering is complete, restore the imports in the _worker.js to cloudflare compatible ones, removing the .mjs suffix.
+ */
+ async afterBuildCompleted(config: AstroConfig) {
+ const baseDir = url.fileURLToPath(config.outDir);
+ const replacementsByFileName = new Map<string, Replacement[]>();
+ for (const replacement of replacements) {
+ if (!replacement.fileName) {
+ continue;
+ }
+ for (const fileName of replacement.fileName) {
+ const repls = replacementsByFileName.get(fileName) || [];
+ if (!repls.length) {
+ replacementsByFileName.set(fileName, repls);
+ }
+ repls.push(replacement);
+ }
+ }
+ for (const [fileName, repls] of replacementsByFileName.entries()) {
+ const filepath = path.join(baseDir, '_worker.js', fileName);
+ const contents = await fs.readFile(filepath, 'utf-8');
+ let updated = contents;
+ for (const replacement of repls) {
+ updated = updated.replaceAll(replacement.nodejsImport, replacement.cloudflareImport);
+ }
+ await fs.writeFile(filepath, updated, 'utf-8');
+ }
+ },
+ };
+}
+
+interface Replacement {
+ fileName?: string[];
+ chunkName: string;
+ // desired import for cloudflare
+ cloudflareImport: string;
+ // nodejs import that simulates a wasm module
+ nodejsImport: string;
+}
+
+const renderers: Record<ModuleType, (fileContents: Buffer) => string> = {
+ CompiledWasm(fileContents: Buffer) {
+ const base64 = fileContents.toString('base64');
+ return `const wasmModule = new WebAssembly.Module(Uint8Array.from(atob("${base64}"), c => c.charCodeAt(0)));export default wasmModule;`;
+ },
+ Data(fileContents: Buffer) {
+ const base64 = fileContents.toString('base64');
+ return `const binModule = Uint8Array.from(atob("${base64}"), c => c.charCodeAt(0)).buffer;export default binModule;`;
+ },
+ Text(fileContents: Buffer) {
+ const escaped = JSON.stringify(fileContents.toString('utf-8'));
+ return `const stringModule = ${escaped};export default stringModule;`;
+ },
+};
+
+const defaultAdapters: Record<string, ModuleType> = {
+ // Loads '*.wasm?module' imports as WebAssembly modules, which is the only way to load WASM in cloudflare workers.
+ // Current proposal for WASM modules: https://github.com/WebAssembly/esm-integration/tree/main/proposals/esm-integration
+ '.wasm?module': 'CompiledWasm',
+ // treats the module as a WASM module
+ '.wasm': 'CompiledWasm',
+ '.bin': 'Data',
+ '.txt': 'Text',
+};
+
+/**
+ * Returns a deterministic 32 bit hash code from a string
+ */
+function hashString(str: string): string {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ const char = str.charCodeAt(i);
+ hash = (hash << 5) - hash + char;
+ hash &= hash; // Convert to 32bit integer
+ }
+ return new Uint32Array([hash])[0].toString(36);
+}
+
+function escapeRegExp(string: string) {
+ return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string
+}
diff --git a/packages/integrations/cloudflare/src/utils/env.ts b/packages/integrations/cloudflare/src/utils/env.ts
new file mode 100644
index 000000000..e450038eb
--- /dev/null
+++ b/packages/integrations/cloudflare/src/utils/env.ts
@@ -0,0 +1,15 @@
+import type { GetEnv } from 'astro/env/setup';
+
+export const createGetEnv =
+ (env: Record<string, unknown>): GetEnv =>
+ (key) => {
+ const v = env[key];
+ if (typeof v === 'undefined' || typeof v === 'string') {
+ return v;
+ }
+ if (typeof v === 'boolean' || typeof v === 'number') {
+ // let astro:env handle the validation and transformation
+ return v.toString();
+ }
+ return undefined;
+ };
diff --git a/packages/integrations/cloudflare/src/utils/generate-routes-json.ts b/packages/integrations/cloudflare/src/utils/generate-routes-json.ts
new file mode 100644
index 000000000..f1d9e4384
--- /dev/null
+++ b/packages/integrations/cloudflare/src/utils/generate-routes-json.ts
@@ -0,0 +1,347 @@
+import type {
+ AstroConfig,
+ AstroIntegrationLogger,
+ IntegrationResolvedRoute,
+ RoutePart,
+} from 'astro';
+
+import { existsSync } from 'node:fs';
+import { writeFile } from 'node:fs/promises';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ prependForwardSlash,
+ removeLeadingForwardSlash,
+ removeTrailingForwardSlash,
+} from '@astrojs/internal-helpers/path';
+import { glob } from 'tinyglobby';
+
+// Copied from https://github.com/withastro/astro/blob/3776ecf0aa9e08a992d3ae76e90682fd04093721/packages/astro/src/core/routing/manifest/create.ts#L45-L70
+// We're not sure how to improve this regex yet
+// eslint-disable-next-line regexp/no-super-linear-backtracking
+const ROUTE_DYNAMIC_SPLIT = /\[(.+?\(.+?\)|.+?)\]/;
+const ROUTE_SPREAD = /^\.{3}.+$/;
+export function getParts(part: string) {
+ const result: RoutePart[] = [];
+ part.split(ROUTE_DYNAMIC_SPLIT).map((str, i) => {
+ if (!str) return;
+ const dynamic = i % 2 === 1;
+
+ const [, content] = dynamic ? /([^(]+)$/.exec(str) || [null, null] : [null, str];
+
+ if (!content || (dynamic && !/^(?:\.\.\.)?[\w$]+$/.test(content))) {
+ throw new Error('Parameter name must match /^[a-zA-Z0-9_$]+$/');
+ }
+
+ result.push({
+ content,
+ dynamic,
+ spread: dynamic && ROUTE_SPREAD.test(content),
+ });
+ });
+
+ return result;
+}
+
+async function writeRoutesFileToOutDir(
+ _config: AstroConfig,
+ logger: AstroIntegrationLogger,
+ include: string[],
+ exclude: string[],
+) {
+ try {
+ await writeFile(
+ new URL('./_routes.json', _config.outDir),
+ JSON.stringify(
+ {
+ version: 1,
+ include: include,
+ exclude: exclude,
+ },
+ null,
+ 2,
+ ),
+ 'utf-8',
+ );
+ } catch (_error) {
+ logger.error("There was an error writing the '_routes.json' file to the output directory.");
+ }
+}
+
+function segmentsToCfSyntax(segments: IntegrationResolvedRoute['segments'], _config: AstroConfig) {
+ const pathSegments = [];
+ if (removeLeadingForwardSlash(removeTrailingForwardSlash(_config.base)).length > 0) {
+ pathSegments.push(removeLeadingForwardSlash(removeTrailingForwardSlash(_config.base)));
+ }
+ for (const segment of segments.flat()) {
+ if (segment.dynamic) pathSegments.push('*');
+ else pathSegments.push(segment.content);
+ }
+ return pathSegments;
+}
+
+class TrieNode {
+ children = new Map<string, TrieNode>();
+ isEndOfPath = false;
+ hasWildcardChild = false;
+}
+
+class PathTrie {
+ root: TrieNode;
+ returnHasWildcard = false;
+
+ constructor() {
+ this.root = new TrieNode();
+ }
+
+ insert(thisPath: string[]) {
+ let node = this.root;
+ for (const segment of thisPath) {
+ if (segment === '*') {
+ node.hasWildcardChild = true;
+ break;
+ }
+ if (!node.children.has(segment)) {
+ node.children.set(segment, new TrieNode());
+ }
+
+ node = node.children.get(segment)!;
+ }
+
+ node.isEndOfPath = true;
+ }
+
+ /**
+ * Depth-first search (dfs), traverses the "graph" segment by segment until the end or wildcard (*).
+ * It makes sure that all necessary paths are returned, but not paths with an existing wildcard prefix.
+ * e.g. if we have a path like /foo/* and /foo/bar, we only want to return /foo/*
+ */
+ private dfs(node: TrieNode, thisPath: string[], allPaths: string[][]): void {
+ if (node.hasWildcardChild) {
+ this.returnHasWildcard = true;
+ allPaths.push([...thisPath, '*']);
+ return;
+ }
+
+ if (node.isEndOfPath) {
+ allPaths.push([...thisPath]);
+ }
+
+ for (const [segment, childNode] of node.children) {
+ this.dfs(childNode, [...thisPath, segment], allPaths);
+ }
+ }
+
+ /**
+ * The reduce function is used to remove unnecessary paths from the trie.
+ * It receives a trie node to compare with the current node.
+ */
+ private reduce(compNode: TrieNode, node: TrieNode): void {
+ if (node.hasWildcardChild || compNode.hasWildcardChild) return;
+
+ for (const [segment, childNode] of node.children) {
+ if (childNode.children.size === 0) continue;
+
+ const compChildNode = compNode.children.get(segment);
+ if (compChildNode === undefined) {
+ childNode.hasWildcardChild = true;
+ continue;
+ }
+
+ this.reduce(compChildNode, childNode);
+ }
+ }
+
+ reduceAllPaths(compTrie: PathTrie): this {
+ this.reduce(compTrie.root, this.root);
+ return this;
+ }
+
+ getAllPaths(): [string[][], boolean] {
+ const allPaths: string[][] = [];
+ this.dfs(this.root, [], allPaths);
+ return [allPaths, this.returnHasWildcard];
+ }
+}
+
+export async function createRoutesFile(
+ _config: AstroConfig,
+ logger: AstroIntegrationLogger,
+ routes: IntegrationResolvedRoute[],
+ pages: {
+ pathname: string;
+ }[],
+ redirects: IntegrationResolvedRoute['segments'][],
+ includeExtends:
+ | {
+ pattern: string;
+ }[]
+ | undefined,
+ excludeExtends:
+ | {
+ pattern: string;
+ }[]
+ | undefined,
+) {
+ const includePaths: string[][] = [];
+ const excludePaths: string[][] = [];
+
+ /**
+ * All files in the `_config.build.assets` path, e.g. `_astro`
+ * are considered static assets and should not be handled by the function
+ * therefore we exclude a wildcard for that, e.g. `/_astro/*`
+ */
+ const assetsPath = segmentsToCfSyntax(
+ [
+ [{ content: _config.build.assets, dynamic: false, spread: false }],
+ [{ content: '', dynamic: true, spread: false }],
+ ],
+ _config,
+ );
+ excludePaths.push(assetsPath);
+
+ for (const redirect of redirects) {
+ excludePaths.push(segmentsToCfSyntax(redirect, _config));
+ }
+
+ if (existsSync(fileURLToPath(_config.publicDir))) {
+ const staticFiles = await glob(`**/*`, {
+ cwd: fileURLToPath(_config.publicDir),
+ dot: true,
+ });
+ for (const staticFile of staticFiles) {
+ if (['_headers', '_redirects', '_routes.json'].includes(staticFile)) continue;
+ const staticPath = staticFile;
+
+ const segments = removeLeadingForwardSlash(staticPath)
+ .split(path.sep)
+ .filter(Boolean)
+ .map((s: string) => {
+ return getParts(s);
+ });
+ excludePaths.push(segmentsToCfSyntax(segments, _config));
+ }
+ }
+
+ let hasPrerendered404 = false;
+ for (const route of routes) {
+ const convertedPath = segmentsToCfSyntax(route.segments, _config);
+ if (route.pathname === '/404' && route.isPrerendered === true) hasPrerendered404 = true;
+
+ // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check
+ switch (route.type) {
+ case 'page':
+ if (route.isPrerendered === false) includePaths.push(convertedPath);
+
+ break;
+
+ case 'endpoint':
+ if (route.isPrerendered === false) includePaths.push(convertedPath);
+ else excludePaths.push(convertedPath);
+
+ break;
+
+ case 'redirect':
+ excludePaths.push(convertedPath);
+
+ break;
+
+ default:
+ /**
+ * We don't know the type, so we are conservative!
+ * Invoking the function on these is a safe-bet because
+ * the function will fallback to static asset fetching
+ */
+ includePaths.push(convertedPath);
+
+ break;
+ }
+ }
+
+ for (const page of pages) {
+ if (page.pathname === '404') hasPrerendered404 = true;
+ const pageSegments = removeLeadingForwardSlash(page.pathname)
+ .split(path.posix.sep)
+ .filter(Boolean)
+ .map((s) => {
+ return getParts(s);
+ });
+ excludePaths.push(segmentsToCfSyntax(pageSegments, _config));
+ }
+
+ const includeTrie = new PathTrie();
+ for (const includePath of includePaths) {
+ includeTrie.insert(includePath);
+ }
+
+ const excludeTrie = new PathTrie();
+ for (const excludePath of excludePaths) {
+ /**
+ * A excludePath with starts with a wildcard (*) is a catch-all
+ * that would mean all routes are static, that would be equal to a full SSG project
+ * the adapter is not needed in this case, so we do not consider such paths
+ */
+ if (excludePath[0] === '*') continue;
+ excludeTrie.insert(excludePath);
+ }
+
+ const [deduplicatedIncludePaths, includedPathsHaveWildcard] = includeTrie
+ .reduceAllPaths(excludeTrie)
+ .getAllPaths();
+
+ const [deduplicatedExcludePaths, _excludedPathsHaveWildcard] = excludeTrie
+ .reduceAllPaths(includeTrie)
+ .getAllPaths();
+
+ /**
+ * Cloudflare allows no more than 100 include/exclude rules combined
+ * https://developers.cloudflare.com/pages/functions/routing/#limits
+ */
+ const CLOUDFLARE_COMBINED_LIMIT = 100;
+ /**
+ * Caluclate the number of automated and extended include rules
+ */
+ const AUTOMATIC_INCLUDE_RULES_COUNT = deduplicatedIncludePaths.length;
+ const EXTENDED_INCLUDE_RULES_COUNT = includeExtends?.length ?? 0;
+ const INCLUDE_RULES_COUNT = AUTOMATIC_INCLUDE_RULES_COUNT + EXTENDED_INCLUDE_RULES_COUNT;
+ /**
+ * Caluclate the number of automated and extended exclude rules
+ */
+ const AUTOMATIC_EXCLUDE_RULES_COUNT = deduplicatedExcludePaths.length;
+ const EXTENDED_EXCLUDE_RULES_COUNT = excludeExtends?.length ?? 0;
+ const EXCLUDE_RULES_COUNT = AUTOMATIC_EXCLUDE_RULES_COUNT + EXTENDED_EXCLUDE_RULES_COUNT;
+
+ const OPTION2_TOTAL_COUNT =
+ INCLUDE_RULES_COUNT + (includedPathsHaveWildcard ? EXCLUDE_RULES_COUNT : 0);
+
+ if (!hasPrerendered404 || OPTION2_TOTAL_COUNT > CLOUDFLARE_COMBINED_LIMIT) {
+ await writeRoutesFileToOutDir(
+ _config,
+ logger,
+ ['/*'].concat(includeExtends?.map((entry) => entry.pattern) ?? []),
+ deduplicatedExcludePaths
+ .map((thisPath) => `${prependForwardSlash(thisPath.join('/'))}`)
+ .slice(
+ 0,
+ CLOUDFLARE_COMBINED_LIMIT -
+ EXTENDED_INCLUDE_RULES_COUNT -
+ EXTENDED_EXCLUDE_RULES_COUNT -
+ 1,
+ )
+ .concat(excludeExtends?.map((entry) => entry.pattern) ?? []),
+ );
+ } else {
+ await writeRoutesFileToOutDir(
+ _config,
+ logger,
+ deduplicatedIncludePaths
+ .map((thisPath) => `${prependForwardSlash(thisPath.join('/'))}`)
+ .concat(includeExtends?.map((entry) => entry.pattern) ?? []),
+ includedPathsHaveWildcard
+ ? deduplicatedExcludePaths
+ .map((thisPath) => `${prependForwardSlash(thisPath.join('/'))}`)
+ .concat(excludeExtends?.map((entry) => entry.pattern) ?? [])
+ : [],
+ );
+ }
+}
diff --git a/packages/integrations/cloudflare/src/utils/image-config.ts b/packages/integrations/cloudflare/src/utils/image-config.ts
new file mode 100644
index 000000000..967b1b014
--- /dev/null
+++ b/packages/integrations/cloudflare/src/utils/image-config.ts
@@ -0,0 +1,46 @@
+import type { AstroConfig, AstroIntegrationLogger, HookParameters } from 'astro';
+import { passthroughImageService, sharpImageService } from 'astro/config';
+
+export type ImageService = 'passthrough' | 'cloudflare' | 'compile' | 'custom';
+
+export function setImageConfig(
+ service: ImageService,
+ config: AstroConfig['image'],
+ command: HookParameters<'astro:config:setup'>['command'],
+ logger: AstroIntegrationLogger,
+) {
+ switch (service) {
+ case 'passthrough':
+ return { ...config, service: passthroughImageService() };
+
+ case 'cloudflare':
+ return {
+ ...config,
+ service:
+ command === 'dev'
+ ? sharpImageService()
+ : { entrypoint: '@astrojs/cloudflare/image-service' },
+ };
+
+ case 'compile':
+ return {
+ ...config,
+ service: sharpImageService(),
+ endpoint: {
+ entrypoint: command === 'dev' ? undefined : '@astrojs/cloudflare/image-endpoint',
+ },
+ };
+
+ case 'custom':
+ return { ...config };
+
+ default:
+ if (config.service.entrypoint === 'astro/assets/services/sharp') {
+ 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 'passthrough' option. See https://docs.astro.build/en/reference/configuration-reference/#imageservice`,
+ );
+ return { ...config, service: passthroughImageService() };
+ }
+ return { ...config };
+ }
+}