├── pure.d.ts ├── server.d.ts ├── src ├── index.tsx ├── server │ ├── index.ts │ ├── types │ │ ├── index.ts │ │ ├── file.ts │ │ ├── resolver.ts │ │ ├── transformer.ts │ │ ├── error.ts │ │ ├── cache.ts │ │ ├── loader.ts │ │ └── image.ts │ ├── utils │ │ ├── response.ts │ │ └── url.ts │ ├── transformers │ │ └── pureTransformer.ts │ ├── caches │ │ └── kvCache.ts │ ├── resolvers │ │ ├── fetchResolver.ts │ │ ├── cfResolver.ts │ │ └── kvResolver.ts │ └── loaders │ │ └── imageLoader.ts ├── pure │ └── index.ts └── component │ ├── types │ ├── file.ts │ ├── error.ts │ └── image.ts │ ├── index.tsx │ ├── utils │ └── url.ts │ └── hooks │ └── responsiveImage.ts ├── .prettierignore ├── .npmignore ├── .prettierrc ├── examples └── cf-workers │ ├── .gitignore │ ├── .eslintrc │ ├── bindings.d.ts │ ├── public │ └── favicon.ico │ ├── app │ ├── entry.client.tsx │ ├── entry.server.tsx │ ├── root.tsx │ ├── styles │ │ └── shared.css │ └── routes │ │ ├── api │ │ └── image │ │ │ ├── pure.ts │ │ │ └── index.ts │ │ ├── cf.tsx │ │ ├── pure │ │ └── index.tsx │ │ └── index.tsx │ ├── remix.env.d.ts │ ├── server.js │ ├── .eslintignore │ ├── purge.sh │ ├── remix.config.js │ ├── tsconfig.json │ ├── README.md │ ├── wrangler.toml │ └── package.json ├── .eslintrc ├── .eslintignore ├── test └── tryout.js ├── .gitignore ├── tsconfig.json ├── README.md ├── rollup.config.js └── package.json /pure.d.ts: -------------------------------------------------------------------------------- 1 | export * from './build/pure' 2 | -------------------------------------------------------------------------------- /server.d.ts: -------------------------------------------------------------------------------- 1 | export * from './build/server' 2 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | export * from './component' 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /build 4 | /public/build 5 | .env 6 | 7 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # source code 2 | src 3 | 4 | # config files 5 | .* 6 | tscofig* 7 | 8 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true, 4 | "printWidth": 140 5 | } 6 | -------------------------------------------------------------------------------- /examples/cf-workers/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /public/build 6 | .env 7 | -------------------------------------------------------------------------------- /examples/cf-workers/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"] 3 | } 4 | -------------------------------------------------------------------------------- /examples/cf-workers/bindings.d.ts: -------------------------------------------------------------------------------- 1 | export {} 2 | 3 | declare global { 4 | const SELF_URL: string 5 | const IMAGE_KV: KVNamespace 6 | } 7 | -------------------------------------------------------------------------------- /examples/cf-workers/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/styxlab/remix-image-cloudflare/HEAD/examples/cf-workers/public/favicon.ico -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "sourceType": "module", 4 | "ecmaVersion": 2020 5 | }, 6 | "extends": ["prettier"] 7 | } 8 | -------------------------------------------------------------------------------- /examples/cf-workers/app/entry.client.tsx: -------------------------------------------------------------------------------- 1 | import { RemixBrowser } from "@remix-run/react"; 2 | import { hydrate } from "react-dom"; 3 | 4 | hydrate(, document); 5 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | out/ 2 | build/ 3 | coverage/ 4 | dist/ 5 | node_modules 6 | 7 | server.d.ts 8 | serverPure.d.ts 9 | 10 | docs/ 11 | 12 | jest.config.js 13 | jest.setup.ts 14 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | export * from './caches/kvCache' 2 | export * from './resolvers/kvResolver' 3 | export * from './resolvers/cfResolver' 4 | export * from './loaders/imageLoader' 5 | -------------------------------------------------------------------------------- /test/tryout.js: -------------------------------------------------------------------------------- 1 | import assert from 'assert' 2 | import { Image } from 'remix-image-cloudflare' 3 | 4 | assert.strict.match(typeof Image, /object/) 5 | 6 | console.log(typeof Image) 7 | -------------------------------------------------------------------------------- /examples/cf-workers/remix.env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | /// 4 | -------------------------------------------------------------------------------- /examples/cf-workers/server.js: -------------------------------------------------------------------------------- 1 | import { createEventHandler } from '@remix-run/cloudflare-workers' 2 | import * as build from '@remix-run/dev/server-build' 3 | 4 | addEventListener('fetch', createEventHandler({ build })) 5 | -------------------------------------------------------------------------------- /src/server/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cache' 2 | export * from './error' 3 | export * from './file' 4 | export * from './image' 5 | export * from './loader' 6 | export * from './resolver' 7 | export * from './transformer' 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | /.cache 4 | /build 5 | /dist 6 | /public/build 7 | /.mf 8 | .env 9 | 10 | .pnp.* 11 | .yarn/* 12 | !.yarn/patches 13 | !.yarn/plugins 14 | !.yarn/releases 15 | !.yarn/sdks 16 | !.yarn/versions 17 | -------------------------------------------------------------------------------- /examples/cf-workers/.eslintignore: -------------------------------------------------------------------------------- 1 | out/ 2 | build/ 3 | coverage/ 4 | dist/ 5 | node_modules/ 6 | .idea_modules/ 7 | .idea/ 8 | 9 | server.d.ts 10 | serverPure.d.ts 11 | 12 | examples/ 13 | docs/ 14 | 15 | jest.config.js 16 | jest.setup.ts 17 | -------------------------------------------------------------------------------- /examples/cf-workers/purge.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | list=$(wrangler kv:key list --binding IMAGE_KV | jq '.[] | .name' | sed 's/"//g') 4 | 5 | for value in $list 6 | do 7 | echo $value 8 | wrangler kv:key delete --binding IMAGE_KV $value --force 9 | done 10 | -------------------------------------------------------------------------------- /src/pure/index.ts: -------------------------------------------------------------------------------- 1 | export * from '../server/caches/kvCache' 2 | export * from '../server/transformers/pureTransformer' 3 | export * from '../server/resolvers/fetchResolver' 4 | export * from '../server/resolvers/kvResolver' 5 | export * from '../server/loaders/imageLoader' 6 | -------------------------------------------------------------------------------- /src/component/types/file.ts: -------------------------------------------------------------------------------- 1 | export enum MimeType { 2 | SVG = 'image/svg+xml', 3 | JPEG = 'image/jpeg', 4 | PNG = 'image/png', 5 | GIF = 'image/gif', 6 | WEBP = 'image/webp', 7 | BMP = 'image/bmp', 8 | TIFF = 'image/tiff', 9 | AVIF = 'image/avif', 10 | } 11 | -------------------------------------------------------------------------------- /src/server/types/file.ts: -------------------------------------------------------------------------------- 1 | export enum MimeType { 2 | SVG = 'image/svg+xml', 3 | JPEG = 'image/jpeg', 4 | PNG = 'image/png', 5 | GIF = 'image/gif', 6 | WEBP = 'image/webp', 7 | BMP = 'image/bmp', 8 | TIFF = 'image/tiff', 9 | AVIF = 'image/avif', 10 | } 11 | -------------------------------------------------------------------------------- /src/server/types/resolver.ts: -------------------------------------------------------------------------------- 1 | import type { MimeType } from './file' 2 | import type { TransformOptions } from './image' 3 | 4 | export type Resolver = ( 5 | asset: string, 6 | url: string, 7 | options: TransformOptions, 8 | basePath: string 9 | ) => Promise<{ 10 | buffer: Uint8Array 11 | contentType: MimeType 12 | shouldTransform: boolean 13 | }> 14 | -------------------------------------------------------------------------------- /src/server/types/transformer.ts: -------------------------------------------------------------------------------- 1 | import type { MimeType } from './file' 2 | import type { TransformOptions } from './image' 3 | 4 | export type Transformer = { 5 | name: string 6 | supportedInputs: Set 7 | supportedOutputs: Set 8 | transform: ( 9 | input: { 10 | url: string 11 | data: Uint8Array 12 | contentType: MimeType 13 | }, 14 | output: TransformOptions 15 | ) => Promise 16 | } 17 | -------------------------------------------------------------------------------- /src/server/utils/response.ts: -------------------------------------------------------------------------------- 1 | export const textResponse = (status: number, message = ""): Response => 2 | new Response(message, { 3 | status, 4 | }); 5 | 6 | export const imageResponse = ( 7 | file: Uint8Array, 8 | status: number, 9 | contentType: string, 10 | cacheControl: string 11 | ): Response => 12 | new Response(file, { 13 | status, 14 | headers: { 15 | "Content-Type": contentType, 16 | "Cache-Control": cacheControl, 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /examples/cf-workers/remix.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @type {import('@remix-run/dev').AppConfig} 3 | */ 4 | module.exports = { 5 | serverBuildTarget: 'cloudflare-workers', 6 | server: './server.js', 7 | devServerBroadcastDelay: 1000, 8 | ignoredRouteFiles: ['.*'], 9 | serverDependenciesToBundle: ['remix-image-cloudflare', 'remix-image-cloudflare/component', 'remix-image-cloudflare/server'], 10 | // appDirectory: "app", 11 | // assetsBuildDirectory: "public/build", 12 | // serverBuildPath: "build/index.js", 13 | // publicPath: "/build/", 14 | } 15 | -------------------------------------------------------------------------------- /examples/cf-workers/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2019"], 5 | "isolatedModules": true, 6 | "esModuleInterop": true, 7 | "jsx": "react-jsx", 8 | "moduleResolution": "node", 9 | "resolveJsonModule": true, 10 | "target": "ES2019", 11 | "strict": true, 12 | "baseUrl": ".", 13 | "paths": { 14 | "~/*": ["./app/*"] 15 | }, 16 | 17 | // Remix takes care of building everything in `remix build`. 18 | "noEmit": true 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/server/types/error.ts: -------------------------------------------------------------------------------- 1 | export class RemixImageError extends Error { 2 | errorCode: number 3 | 4 | constructor(message: string, errorCode?: number) { 5 | super(message) 6 | Object.setPrototypeOf(this, RemixImageError.prototype) 7 | 8 | this.errorCode = errorCode || 500 9 | } 10 | 11 | toString() { 12 | return this.message 13 | } 14 | } 15 | 16 | export class UnsupportedImageError extends RemixImageError { 17 | constructor(message: string) { 18 | super(message, 415) 19 | Object.setPrototypeOf(this, UnsupportedImageError.prototype) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/component/types/error.ts: -------------------------------------------------------------------------------- 1 | export class RemixImageError extends Error { 2 | errorCode: number 3 | 4 | constructor(message: string, errorCode?: number) { 5 | super(message) 6 | Object.setPrototypeOf(this, RemixImageError.prototype) 7 | 8 | this.errorCode = errorCode || 500 9 | } 10 | 11 | toString() { 12 | return this.message 13 | } 14 | } 15 | 16 | export class UnsupportedImageError extends RemixImageError { 17 | constructor(message: string) { 18 | super(message, 415) 19 | Object.setPrototypeOf(this, UnsupportedImageError.prototype) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /examples/cf-workers/README.md: -------------------------------------------------------------------------------- 1 | # Cloudflare Workers Example 2 | 3 | Shows usage of the remix-image-cloudflare component in a Cloudflare Workers project. The following cases are covered: 4 | 5 | | Route | Description | 6 | | ----- | -------------------------------------------------------------------- | 7 | | / | Standard img component with resized images | 8 | | /pure | Image component with pure JS transformer for on-demand optimizations | 9 | | /cf | Image component with Cloudlfare image optimizer (see CF pricing) | 10 | -------------------------------------------------------------------------------- /src/server/transformers/pureTransformer.ts: -------------------------------------------------------------------------------- 1 | import { imageTransformer } from 'js-image-lib' 2 | import { MimeType } from '../types/file' 3 | import type { Transformer } from '../types/transformer' 4 | 5 | const supportedInputs = new Set([MimeType.JPEG, MimeType.PNG, MimeType.GIF, MimeType.BMP, MimeType.TIFF]) 6 | 7 | const supportedOutputs = new Set([MimeType.JPEG, MimeType.PNG, MimeType.GIF, MimeType.BMP]) 8 | 9 | export const pureTransformer: Transformer = { 10 | name: 'pureTransformer', 11 | supportedInputs, 12 | supportedOutputs, 13 | transform: async (src, options) => imageTransformer(src, options), 14 | } 15 | -------------------------------------------------------------------------------- /examples/cf-workers/app/entry.server.tsx: -------------------------------------------------------------------------------- 1 | import type { EntryContext } from "@remix-run/node"; 2 | import { RemixServer } from "@remix-run/react"; 3 | import { renderToString } from "react-dom/server"; 4 | 5 | export default function handleRequest( 6 | request: Request, 7 | responseStatusCode: number, 8 | responseHeaders: Headers, 9 | remixContext: EntryContext 10 | ) { 11 | let markup = renderToString( 12 | 13 | ); 14 | 15 | responseHeaders.set("Content-Type", "text/html"); 16 | 17 | return new Response("" + markup, { 18 | status: responseStatusCode, 19 | headers: responseHeaders, 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /examples/cf-workers/wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "remix-cloudflare-workers" 2 | type = "javascript" 3 | 4 | account_id = "e25e6c13d803ac1f3393cc7ca983110b" 5 | workers_dev = true 6 | compatibility_date = "2022-04-10" 7 | 8 | zone_id = "b84397848620d1764ead89fb32129045" 9 | routes = ["*blogody.net/*"] 10 | 11 | kv_namespaces = [ 12 | { binding = "IMAGE_KV", id = "c998b2c559774f04b104ef01e3c8d27f" } 13 | ] 14 | 15 | [site] 16 | bucket = "./public" 17 | entry-point = "." 18 | 19 | [build] 20 | command = "" 21 | 22 | [build.upload] 23 | format="service-worker" 24 | 25 | [vars] 26 | SELF_URL = "http://127.0.0.1:8787" 27 | 28 | [env.production.vars] 29 | SELF_URL = "https://image.apps3009.workers.dev/" 30 | -------------------------------------------------------------------------------- /src/component/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useResponsiveImage } from './hooks/responsiveImage' 3 | import type { ResponsiveSize, SizelessOptions } from './types/image' 4 | 5 | export interface ImageProps extends React.ComponentProps<'img'> { 6 | loaderUrl?: string 7 | responsive?: ResponsiveSize[] 8 | options?: SizelessOptions 9 | } 10 | 11 | export const Image = React.forwardRef( 12 | ({ loaderUrl = '/api/image', responsive = [], options = {}, ...imgProps }, ref) => { 13 | const responsiveProps = useResponsiveImage(imgProps, loaderUrl, responsive, options) 14 | 15 | return 16 | } 17 | ) 18 | 19 | Image.displayName = 'Image' 20 | -------------------------------------------------------------------------------- /examples/cf-workers/app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { MetaFunction } from '@remix-run/node' 2 | import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react' 3 | 4 | import styles from '~/styles/shared.css' 5 | 6 | export function links() { 7 | return [{ rel: 'stylesheet', href: styles }] 8 | } 9 | 10 | export const meta: MetaFunction = () => ({ 11 | charset: 'utf-8', 12 | title: 'New Remix App', 13 | viewport: 'width=device-width,initial-scale=1', 14 | }) 15 | 16 | export default function App() { 17 | return ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ) 31 | } 32 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "outDir": "build", 5 | "declarationDir": "build", 6 | "module": "ESNext", 7 | "target": "ESNext", 8 | "lib": ["DOM", "DOM.Iterable", "ESNext"], 9 | "types": ["@cloudflare/workers-types"], 10 | "jsx": "react", 11 | "moduleResolution": "node", 12 | "allowSyntheticDefaultImports": true, 13 | "sourceMap": false, 14 | "esModuleInterop": true, 15 | "noImplicitReturns": true, 16 | "noUnusedParameters": true, 17 | "strict": true, 18 | "skipLibCheck": true, 19 | /* Experimental Options */ 20 | "resolveJsonModule": true, 21 | "experimentalDecorators": true, 22 | "forceConsistentCasingInFileNames": true 23 | }, 24 | "include": ["src/**/*", "tests/**/*"], 25 | "exclude": ["node_modules", "build"] 26 | } 27 | -------------------------------------------------------------------------------- /examples/cf-workers/app/styles/shared.css: -------------------------------------------------------------------------------- 1 | .fade-in { 2 | animation: fadeIn ease 0.5s; 3 | -webkit-animation: fadeIn ease 0.5s; 4 | -moz-animation: fadeIn ease 0.5s; 5 | -o-animation: fadeIn ease 0.5s; 6 | -ms-animation: fadeIn ease 0.5s; 7 | } 8 | @keyframes fadeIn { 9 | 0% { 10 | opacity: 0; 11 | } 12 | 100% { 13 | opacity: 1; 14 | } 15 | } 16 | 17 | @-moz-keyframes fadeIn { 18 | 0% { 19 | opacity: 0; 20 | } 21 | 100% { 22 | opacity: 1; 23 | } 24 | } 25 | 26 | @-webkit-keyframes fadeIn { 27 | 0% { 28 | opacity: 0; 29 | } 30 | 100% { 31 | opacity: 1; 32 | } 33 | } 34 | 35 | @-o-keyframes fadeIn { 36 | 0% { 37 | opacity: 0; 38 | } 39 | 100% { 40 | opacity: 1; 41 | } 42 | } 43 | 44 | @-ms-keyframes fadeIn { 45 | 0% { 46 | opacity: 0; 47 | } 48 | 100% { 49 | opacity: 1; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/server/caches/kvCache.ts: -------------------------------------------------------------------------------- 1 | import { Cache, type CacheConfig } from '../types/cache' 2 | 3 | export class KVCache extends Cache { 4 | config: CacheConfig 5 | cache: KVNamespace | null | undefined 6 | 7 | constructor(config: Partial | null | undefined = {}) { 8 | super() 9 | 10 | this.config = { 11 | ttl: 24 * 60 * 60, 12 | tbd: 365 * 24 * 60 * 60, 13 | ...config, 14 | } 15 | 16 | this.cache = config?.namespace 17 | } 18 | 19 | async get(key: string): Promise { 20 | return ( 21 | this.cache?.get(key, { 22 | type: 'arrayBuffer', 23 | //cacheTtl: this.config.ttl, 24 | }) ?? null 25 | ) 26 | } 27 | 28 | async put(key: string, resultImg: ArrayBuffer): Promise { 29 | this.cache?.put(key, resultImg, { expirationTtl: this.config.ttl }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/server/types/cache.ts: -------------------------------------------------------------------------------- 1 | export interface CacheConfig { 2 | /** 3 | * KV namespace 4 | */ 5 | namespace?: KVNamespace | null 6 | /** 7 | * Time To Live: how long a key should remain in the cache 8 | */ 9 | ttl: number 10 | /** 11 | * Time Before Deletion: how long a key should remain in the cache after expired (ttl) 12 | */ 13 | tbd: number 14 | } 15 | 16 | export enum CacheStatus { 17 | /** 18 | * The cache contains the key and it has not yet expired 19 | */ 20 | HIT = 'hit', 21 | /** 22 | * The cache contains the key but it has expired 23 | */ 24 | STALE = 'stale', 25 | /** 26 | * The cache does not contain the key 27 | */ 28 | MISS = 'miss', 29 | } 30 | 31 | export abstract class Cache { 32 | abstract config: CacheConfig 33 | 34 | abstract get(key: string): Promise 35 | 36 | abstract put(key: string, resultImg: ArrayBuffer): Promise 37 | } 38 | -------------------------------------------------------------------------------- /examples/cf-workers/app/routes/api/image/pure.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from '@remix-run/cloudflare' 2 | import { imageLoader, KVCache, kvResolver, fetchResolver, type Resolver } from 'remix-image-cloudflare/pure' 3 | 4 | const whitelistedDomains = new Set([SELF_URL, 'images.unsplash.com', 'assets.blogody.com', 'i.picsum.photos']) 5 | 6 | export const myResolver: Resolver = async (asset, url, options, basePath) => { 7 | if (asset.startsWith('/') && (asset.length === 1 || asset[1] !== '/')) { 8 | return kvResolver(asset, url, options, basePath) 9 | } else { 10 | if (!whitelistedDomains.has(new URL(url).host)) { 11 | throw new Error('Domain not allowed!') 12 | } 13 | 14 | return fetchResolver(asset, url, options, basePath) 15 | } 16 | } 17 | 18 | const config = { 19 | selfUrl: SELF_URL, 20 | cache: new KVCache({ namespace: IMAGE_KV }), 21 | resolver: myResolver, 22 | } 23 | 24 | export const loader: LoaderFunction = ({ request }) => { 25 | return imageLoader(config, request) 26 | } 27 | -------------------------------------------------------------------------------- /src/server/resolvers/fetchResolver.ts: -------------------------------------------------------------------------------- 1 | import { RemixImageError } from '../types/error' 2 | import type { MimeType } from '../types/file' 3 | import type { Resolver } from '../types/resolver' 4 | 5 | export type { Resolver } 6 | 7 | export const fetchResolver: Resolver = async (_asset, url) => { 8 | const imgRequest = new Request(url, { 9 | headers: { 10 | accept: 'image/*', 11 | }, 12 | //cf: { 13 | // cacheTtl: 60, 14 | // cacheEverything: true, 15 | //}, 16 | }) 17 | 18 | const imageResponse = await fetch(imgRequest) 19 | //console.log('fetch', imageResponse.status) 20 | 21 | if (!imageResponse.ok) { 22 | throw new RemixImageError('Failed to fetch image!') 23 | } 24 | 25 | const arrBuff = await imageResponse.arrayBuffer() 26 | //console.log('buffer.length', arrBuff.byteLength) 27 | 28 | if (!arrBuff || arrBuff.byteLength < 2) { 29 | throw new RemixImageError('Invalid image retrieved from resolver!') 30 | } 31 | 32 | const buffer = new Uint8Array(arrBuff) 33 | const contentType = imageResponse.headers.get('content-type')! as MimeType 34 | 35 | return { 36 | buffer, 37 | contentType, 38 | shouldTransform: true, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /examples/cf-workers/app/routes/api/image/index.ts: -------------------------------------------------------------------------------- 1 | import type { LoaderFunction } from '@remix-run/cloudflare' 2 | import { imageLoader, KVCache, kvResolver, cloudflareResolver, type Resolver } from 'remix-image-cloudflare/server' 3 | 4 | const whitelistedDomains = new Set([SELF_URL, 'images.unsplash.com', 'assets.blogody.com', 'i.picsum.photos']) 5 | 6 | export const myResolver: Resolver = async (asset, url, options, basePath) => { 7 | if (asset.startsWith('/') && (asset.length === 1 || asset[1] !== '/')) { 8 | return kvResolver(asset, url, options, basePath) 9 | } else { 10 | if (!whitelistedDomains.has(new URL(url).host)) { 11 | throw new Error('Domain not allowed!') 12 | } 13 | 14 | return cloudflareResolver(asset, url, options, basePath) 15 | } 16 | } 17 | 18 | const config = { 19 | selfUrl: SELF_URL, 20 | cache: new KVCache({ namespace: IMAGE_KV }), 21 | resolver: myResolver, 22 | transformer: null, 23 | rewrite: null, 24 | } 25 | 26 | export const loader: LoaderFunction = ({ request }) => { 27 | return imageLoader(config, request) 28 | } 29 | 30 | //const rewriteAssetUrl = (url: string) => { 31 | // const parsed = new URL(url) 32 | // if (parsed.host === 'assets.blogody.io' && parsed.pathname.startsWith('/image')) { 33 | // parsed.host = SUPABASE_STORAGE_URL 34 | // parsed.pathname = SUPABASE_STORAGE_PATH + parsed.pathname.substr(6) 35 | // return parsed.toString() 36 | // } 37 | // return url 38 | //} 39 | -------------------------------------------------------------------------------- /examples/cf-workers/app/routes/cf.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@remix-run/react' 2 | 3 | import { Image } from 'remix-image-cloudflare' 4 | 5 | //const featureImage = 'https://i.picsum.photos/id/1002/4312/2868.jpg?hmac=5LlLE-NY9oMnmIQp7ms6IfdvSUQOzP_O3DPMWmyNxwo' 6 | const featureImage = 'https://i.picsum.photos/id/1002/2156/1434.jpg?hmac=Gg9l2GJVLl8-ukWaDFyx1nhEOz2w7XpzCmeWFY5Ir9Y' 7 | 8 | export default function Index() { 9 | return ( 10 |
11 |

Remix Image Component for Cloudflare

12 |

13 | This image is loaded with the {''} component. Compare with Standard Image. 14 |

15 |
16 | Featured 42 |
43 |
44 | ) 45 | } 46 | -------------------------------------------------------------------------------- /examples/cf-workers/app/routes/pure/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@remix-run/react' 2 | 3 | import { Image } from 'remix-image-cloudflare' 4 | 5 | const featureImage = 'https://i.picsum.photos/id/1002/2156/1434.jpg?hmac=Gg9l2GJVLl8-ukWaDFyx1nhEOz2w7XpzCmeWFY5Ir9Y' 6 | //const featureImage = 'https://i.picsum.photos/id/1002/800/532.jpg?hmac=coTm0kTIfKYkmbIhldwXbdUEx4VLlKliH7C_Vw6Rnns' 7 | 8 | export default function Index() { 9 | return ( 10 |
11 |

Remix Image Component for Cloudflare

12 |

13 | This image is loaded with the {''} component. Compare with Image Component utilizing a Cloudflare 14 | image optimizer. 15 |

16 |
17 | Featured 44 |
45 |
46 | ) 47 | } 48 | -------------------------------------------------------------------------------- /src/server/utils/url.ts: -------------------------------------------------------------------------------- 1 | import { stringifyUrl, parse } from 'query-string' 2 | import { RemixImageError } from '../types/error' 3 | import type { TransformOptions } from '../types/image' 4 | 5 | export const decodeQuery = (queryParams: URLSearchParams, key: string): string | null => 6 | queryParams.has(key) ? decodeURIComponent(queryParams.get(key)!) : null 7 | 8 | export const encodeQuery = (url: string, query: Record): string => { 9 | const fixedQuery = query 10 | 11 | if (Object.prototype.hasOwnProperty.call(query, 'crop')) { 12 | fixedQuery.crop = JSON.stringify(fixedQuery.crop) 13 | } 14 | 15 | return stringifyUrl( 16 | { 17 | url, 18 | query: fixedQuery, 19 | }, 20 | { 21 | skipNull: true, 22 | arrayFormat: 'bracket', 23 | sort: false, 24 | } 25 | ) 26 | } 27 | 28 | export const decodeTransformQuery = (queryString: string): Partial => { 29 | const parsed = parse(queryString, { 30 | arrayFormat: 'bracket', 31 | parseNumbers: true, 32 | parseBooleans: true, 33 | sort: false, 34 | }) 35 | 36 | if (Object.prototype.hasOwnProperty.call(parsed, 'crop') && parsed.crop != null) { 37 | parsed.crop = JSON.parse(parsed.crop as string) 38 | } 39 | 40 | return parsed 41 | } 42 | 43 | export const parseURL = (rawUrl: string, baseUrl?: URL | string): URL => { 44 | let urlObject: URL 45 | 46 | try { 47 | urlObject = new URL(rawUrl, baseUrl) 48 | } catch (error) { 49 | throw new RemixImageError(`Invalid URL: ${rawUrl}`, 400) 50 | } 51 | 52 | return urlObject 53 | } 54 | -------------------------------------------------------------------------------- /src/component/utils/url.ts: -------------------------------------------------------------------------------- 1 | import { stringifyUrl, parse } from 'query-string' 2 | import { RemixImageError } from '../types/error' 3 | import type { TransformOptions } from '../types/image' 4 | 5 | export const decodeQuery = (queryParams: URLSearchParams, key: string): string | null => 6 | queryParams.has(key) ? decodeURIComponent(queryParams.get(key)!) : null 7 | 8 | export const encodeQuery = (url: string, query: Record): string => { 9 | const fixedQuery = query 10 | 11 | if (Object.prototype.hasOwnProperty.call(query, 'crop')) { 12 | fixedQuery.crop = JSON.stringify(fixedQuery.crop) 13 | } 14 | 15 | return stringifyUrl( 16 | { 17 | url, 18 | query: fixedQuery, 19 | }, 20 | { 21 | skipNull: true, 22 | arrayFormat: 'bracket', 23 | sort: false, 24 | } 25 | ) 26 | } 27 | 28 | export const decodeTransformQuery = (queryString: string): Partial => { 29 | const parsed = parse(queryString, { 30 | arrayFormat: 'bracket', 31 | parseNumbers: true, 32 | parseBooleans: true, 33 | sort: false, 34 | }) 35 | 36 | if (Object.prototype.hasOwnProperty.call(parsed, 'crop') && parsed.crop != null) { 37 | parsed.crop = JSON.parse(parsed.crop as string) 38 | } 39 | 40 | return parsed 41 | } 42 | 43 | export const parseURL = (rawUrl: string, baseUrl?: URL | string): URL => { 44 | let urlObject: URL 45 | 46 | try { 47 | urlObject = new URL(rawUrl, baseUrl) 48 | } catch (error) { 49 | throw new RemixImageError(`Invalid URL: ${rawUrl}`, 400) 50 | } 51 | 52 | return urlObject 53 | } 54 | -------------------------------------------------------------------------------- /examples/cf-workers/app/routes/index.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@remix-run/react' 2 | 3 | //const featureImage = 'https://i.picsum.photos/id/1002/4312/2868.jpg?hmac=5LlLE-NY9oMnmIQp7ms6IfdvSUQOzP_O3DPMWmyNxwo' 4 | const featureImage = 'https://i.picsum.photos/id/1002/2156/1434.jpg?hmac=Gg9l2GJVLl8-ukWaDFyx1nhEOz2w7XpzCmeWFY5Ir9Y' 5 | const featureImage800 = 'https://i.picsum.photos/id/1002/800/532.jpg?hmac=coTm0kTIfKYkmbIhldwXbdUEx4VLlKliH7C_Vw6Rnns' 6 | const featureImage400 = 'https://i.picsum.photos/id/1002/400/266.jpg?hmac=TusO-RP3iv2MlXg8BPCS54_EWWiF64QD7vuuxfoQGq8' 7 | 8 | export default function Index() { 9 | return ( 10 |
11 |

Remix Image Component for Cloudflare

12 |

13 | This image is loaded with the standard HTML {``} tag. Note that different sized images must be provided in advance. Compare 14 | with Image Component utilizing a pure JS transformer. 15 |

16 |
17 | Featured 32 |
33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/component/hooks/responsiveImage.ts: -------------------------------------------------------------------------------- 1 | import type { ResponsiveSize, SizelessOptions } from '../types/image' 2 | import { encodeQuery } from '../utils/url' 3 | 4 | export type ImageSource = { 5 | src?: string 6 | } 7 | 8 | export type ResponsiveHookResult = { 9 | src: string 10 | srcSet?: string 11 | sizes?: string 12 | } 13 | 14 | export const useResponsiveImage = ( 15 | image: ImageSource, 16 | loaderUrl: string, 17 | responsive: ResponsiveSize[], 18 | options: SizelessOptions = {} 19 | ): ResponsiveHookResult => { 20 | let largestSrc = image.src || '' 21 | let largestWidth = 0 22 | const srcSet: string[] = [] 23 | 24 | for (const { size } of responsive) { 25 | const srcSetUrl = encodeQuery(loaderUrl, { 26 | src: encodeURI(image.src || ''), 27 | width: size.width, 28 | height: size.height, 29 | ...options, 30 | }) 31 | 32 | srcSet.push(srcSetUrl + ` ${size.width}w`) 33 | 34 | if (size.width > largestWidth) { 35 | largestWidth = size.width 36 | largestSrc = srcSetUrl 37 | } 38 | } 39 | 40 | const sizes = [...responsive] 41 | .sort((resp1, resp2) => resp1.size.width - resp2.size.width) 42 | .map((resp) => (resp.maxWidth ? `(max-width: ${resp.maxWidth}px) ${resp.size.width}px` : `${resp.size.width}px`)) 43 | 44 | if (responsive.length === 1 && responsive[0].maxWidth != null) { 45 | sizes.push(`${responsive[0].size.width}px`) 46 | } 47 | 48 | return { 49 | src: largestSrc, 50 | ...(srcSet.length && { 51 | srcSet: srcSet.join(', '), 52 | sizes: sizes.join(', '), 53 | }), 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /examples/cf-workers/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cf-workers", 3 | "version": "0.0.1", 4 | "private": true, 5 | "description": "", 6 | "license": "", 7 | "sideEffects": false, 8 | "main": "build/index.js", 9 | "scripts": { 10 | "build": "remix build", 11 | "deploy": "npm run build && wrangler publish", 12 | "dev": "run-p dev:remix dev:miniflare", 13 | "dev:remix": "remix watch", 14 | "dev:miniflare": "cross-env NODE_ENV=development miniflare ./build/index.js --watch", 15 | "postinstall": "remix setup cloudflare", 16 | "start": "cross-env NODE_ENV=production miniflare ./build/index.js", 17 | "typecheck": "tsc --project tsconfig.json --noEmit", 18 | "purge": "sh ./purge.sh" 19 | }, 20 | "dependencies": { 21 | "@cloudflare/kv-asset-handler": "^0.2.0", 22 | "@remix-run/cloudflare-workers": "^1.3.5", 23 | "@remix-run/react": "1.3.5", 24 | "@remix-run/server-runtime": "^1.3.5", 25 | "cross-env": "^7.0.3", 26 | "query-string": "^7.1.1", 27 | "react": "^18.0.0", 28 | "react-dom": "^18.0.0", 29 | "remix-image-cloudflare": "*" 30 | }, 31 | "devDependencies": { 32 | "@cloudflare/workers-types": "^3.4.0", 33 | "@cloudflare/wrangler": "^1.19.11", 34 | "@remix-run/dev": "^1.4.1", 35 | "@remix-run/eslint-config": "1.3.5", 36 | "@types/react": "^18.0.3", 37 | "@types/react-dom": "^18.0.0", 38 | "eslint": "^8.13.0", 39 | "miniflare": "^2.4.0", 40 | "remix": "^1.3.5", 41 | "typescript": "^4.6.3", 42 | "run-all": "^1.0.1" 43 | }, 44 | "engines": { 45 | "node": ">=14" 46 | }, 47 | "browser": { 48 | "./server.js": "./server.browser.js" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/server/resolvers/cfResolver.ts: -------------------------------------------------------------------------------- 1 | import { ImageFit, RemixImageError } from '../types' 2 | import type { MimeType, Resolver } from '../types' 3 | export type { Resolver } 4 | 5 | const fitMap = { 6 | [ImageFit.CONTAIN]: 'contain', 7 | [ImageFit.COVER]: 'cover', 8 | [ImageFit.FILL]: 'contain', 9 | [ImageFit.INSIDE]: 'pad', 10 | [ImageFit.OUTSIDE]: 'cover', 11 | } 12 | 13 | export const cloudflareResolver: Resolver = async (_asset, url, { width, height, fit = ImageFit.CONTAIN, quality, position }) => { 14 | const imgRequest = new Request(url, { 15 | headers: { 16 | accept: 'image/*', 17 | }, 18 | }) 19 | 20 | const imageResponse = await fetch(imgRequest, { 21 | cf: { 22 | image: { 23 | width, 24 | height, 25 | fit: fitMap[fit] as any, 26 | quality, 27 | gravity: position, 28 | format: 'avif', 29 | }, 30 | }, 31 | }) 32 | 33 | if (imageResponse.status > 250) { 34 | throw new RemixImageError('cloudflareResolver failed with status ' + imageResponse.status) 35 | } 36 | 37 | //imageResponse.headers.forEach((value, key) => console.log(key, value)) 38 | //console.log(imageResponse.status) 39 | //console.log(imageResponse.headers.get('cf-resized')) 40 | //console.log(imageResponse.headers.get('content-type')) 41 | //console.log(imageResponse.headers.get('server')) 42 | //console.log(imageResponse.headers.get('cf-cache-status')) 43 | //console.log(imageResponse.headers.get('cf-ray')) 44 | 45 | const arrBuff = await imageResponse.arrayBuffer() 46 | 47 | const buffer = new Uint8Array(arrBuff) 48 | const contentType = imageResponse.headers.get('content-type')! as MimeType 49 | 50 | return { 51 | buffer, 52 | contentType, 53 | shouldTransform: false, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/server/types/loader.ts: -------------------------------------------------------------------------------- 1 | import type { Cache } from './cache' 2 | import type { MimeType } from './file' 3 | import type { SizelessOptions } from './image' 4 | import type { Resolver } from './resolver' 5 | import type { Transformer } from './transformer' 6 | 7 | export interface LoaderConfig { 8 | /** The URL for this Remix server. */ 9 | selfUrl: string 10 | /** A resolver function that handles retrieving image assets. (optional, default fetchResolver) */ 11 | resolver: Resolver 12 | /** A transformer function that handles mutations of images. If this option is null, transformation will be skipped. (optional, default pureTransformer) */ 13 | transformer?: Transformer | null 14 | /** If RemixImage should fallback to the fallback mime type if the output type is not supported. (optional, default true) */ 15 | useFallbackFormat?: boolean 16 | /** The output mime type the image should fallback to if the provided output type is not supported. (optional, default MimeType.JPEG) */ 17 | fallbackFormat?: MimeType 18 | /** A cache to store computed RemixImage transformations. (optional) */ 19 | cache?: Cache | null 20 | /** Default TransformOptions to use, can be overridden by the client. (optional) */ 21 | defaultOptions?: Partial 22 | /** Redirect image to original source if RemixImage fails. (optional, default false) */ 23 | redirectOnFail?: boolean 24 | /** A set of mime types that should be returned without transformation. (optional, default Set([MimeType.SVG]) */ 25 | skipFormats?: Set | null 26 | /** The base file path used for the resolver. (optional, default "public) */ 27 | basePath?: string 28 | /** Function that rewrites the fetched image url*/ 29 | rewrite?: ((url: string) => string) | null 30 | } 31 | 32 | export type AssetLoader = (config: LoaderConfig, request: Request) => Promise 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![remix-image-cloudflare](https://remix.run/img/og.1.jpg) 2 | 3 | # remix-image-cloudflare 4 | 5 | [![PRs welcome!](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)]() 6 | 7 | #### Remix Image Component with Custom Optimizers for Cloudflare 8 | 9 | A responsive image component and custom loader for on-demand optimizing of images. The component has been built with [remix](https://remix.run/) in mind, but can be used in any React project. The primary focus of this project is to provide an easy integration with Cloudflare workers. 10 | 11 | ## ✨ Features 12 | 13 | - Small footprint: image component 13 KB, cf-optimizer: 22 KB, js-optimizer: 63 KB 14 | - Easy replacement of standard `` tags 15 | - Image caching with Cloudflare KV store 16 | - Cloudflare image optimizer **or** custom transformers 17 | - Supports local and remote images 18 | - Cache, loader and transformer can be replaced with your own 19 | - Supports rewriting of image URLs 20 | - Example showing the use of the component in a remix project 21 | 22 |   23 | 24 | ## Credits 25 | 26 | - [remix-image](https://github.com/Josh-McFarlin/remix-image) 27 | 28 |   29 | 30 | ## Useful References 31 | 32 | - [ESM modules](https://gils-blog.tayar.org/posts/using-jsm-esm-in-nodejs-a-practical-guide-part-1/) 33 | - [nextjs/image](https://github.com/vercel/next.js/blob/canary/packages/next/client/image.tsx) 34 | 35 |   36 | 37 | ## Troubleshoot 38 | 39 | - Note: You must be on the Pro plan and enable "Image Resizing" under domain -> Speed -> Optimizations 40 | - https://support.cloudflare.com/hc/en-us/articles/4412024022029-Troubleshoot-Image-Resizing-problems 41 | 42 | ## 🧐 Disclaimer 43 | 44 | This project is not affiliated with [Remix](https://remix.run/) or [Cloudflare](https://workers.cloudflare.com/). 45 | 46 |   47 | 48 | # Copyright & License 49 | 50 | Copyright (c) 2022 styxlab - Released under the [MIT license](LICENSE). 51 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import peerDepsExternal from 'rollup-plugin-peer-deps-external' 2 | import resolve from '@rollup/plugin-node-resolve' 3 | import commonjs from '@rollup/plugin-commonjs' 4 | import json from '@rollup/plugin-json' 5 | import typescript from 'rollup-plugin-typescript2' 6 | import { terser } from 'rollup-plugin-terser' 7 | import replace from '@rollup/plugin-replace' 8 | import filesize from 'rollup-plugin-filesize' 9 | 10 | export default [ 11 | { 12 | input: 'src/index.tsx', 13 | output: [ 14 | { 15 | file: 'build/index.js', 16 | format: 'esm', 17 | sourcemap: true, 18 | }, 19 | ], 20 | plugins: [ 21 | peerDepsExternal(), 22 | json(), 23 | typescript({ 24 | tsconfigOverride: { 25 | exclude: ['node_modules', 'build', 'tests'], 26 | }, 27 | }), 28 | resolve(), 29 | commonjs(), 30 | filesize(), 31 | ], 32 | }, 33 | { 34 | input: 'src/server/index.ts', 35 | output: [ 36 | { 37 | file: 'build/server.js', 38 | format: 'esm', 39 | sourcemap: true, 40 | }, 41 | ], 42 | plugins: [ 43 | peerDepsExternal(), 44 | json(), 45 | typescript({ 46 | tsconfigOverride: { 47 | exclude: ['node_modules', 'build', 'tests'], 48 | }, 49 | }), 50 | resolve(), 51 | commonjs(), 52 | terser(), 53 | filesize(), 54 | ], 55 | }, 56 | { 57 | input: 'src/pure/index.ts', 58 | output: [ 59 | { 60 | file: 'build/pure.js', 61 | format: 'esm', 62 | sourcemap: true, 63 | }, 64 | ], 65 | plugins: [ 66 | peerDepsExternal(), 67 | json(), 68 | typescript({ 69 | tsconfigOverride: { 70 | exclude: ['node_modules', 'build', 'tests'], 71 | }, 72 | }), 73 | resolve(), 74 | commonjs(), 75 | replace({ 76 | preventAssignment: true, 77 | include: ['node_modules/jpeg-js/**/*.js'], 78 | values: { 79 | 'Buffer.from': 'new Uint8Array', 80 | }, 81 | }), 82 | terser(), 83 | filesize(), 84 | ], 85 | }, 86 | ] 87 | -------------------------------------------------------------------------------- /src/server/resolvers/kvResolver.ts: -------------------------------------------------------------------------------- 1 | import type { Options as KvAssetHandlerOptions } from '@cloudflare/kv-asset-handler' 2 | import { getAssetFromKV, NotFoundError } from '@cloudflare/kv-asset-handler' 3 | import isSvg from 'is-svg' 4 | import mimeFromBuffer from 'mime-tree' 5 | import { MimeType, RemixImageError, UnsupportedImageError } from '../types' 6 | import type { Resolver } from '../types/resolver' 7 | 8 | export interface FetchEvent { 9 | request: Request 10 | waitUntil: (params: unknown) => Promise 11 | } 12 | 13 | const noOp = async () => {} 14 | 15 | const handleAsset = (event: FetchEvent, options?: Partial): Promise => { 16 | //@ts-ignore 17 | if (process.env.NODE_ENV === 'development') { 18 | return getAssetFromKV(event, { 19 | cacheControl: { 20 | bypassCache: true, 21 | }, 22 | ...options, 23 | }) 24 | } 25 | 26 | let cacheControl = {} 27 | const url = new URL(event.request.url) 28 | const assetpath = '/build' 29 | const requestpath = url.pathname.split('/').slice(0, -1).join('/') 30 | 31 | if (requestpath.startsWith(assetpath)) { 32 | cacheControl = { 33 | bypassCache: false, 34 | edgeTTL: 31536000, 35 | browserTTL: 31536000, 36 | } 37 | } 38 | 39 | return getAssetFromKV(event, { 40 | cacheControl, 41 | ...options, 42 | }) 43 | } 44 | 45 | export const kvResolver: Resolver = async (_asset, url) => { 46 | const imgRequest = new Request(url) 47 | 48 | const imageResponse = await handleAsset({ 49 | request: imgRequest, 50 | waitUntil: noOp, 51 | }) 52 | 53 | if (!imageResponse) { 54 | throw new NotFoundError('Image not found!') 55 | } 56 | 57 | const arrBuff = await imageResponse.arrayBuffer() 58 | 59 | if (!arrBuff || arrBuff.byteLength < 2) { 60 | throw new RemixImageError('Invalid image retrieved from resolver!') 61 | } 62 | 63 | const buffer = new Uint8Array(arrBuff) 64 | let contentType: MimeType | null = null 65 | try { 66 | contentType = mimeFromBuffer(buffer) 67 | } catch (error) { 68 | if (isSvg(new TextDecoder().decode(buffer))) { 69 | contentType = MimeType.SVG 70 | } else { 71 | throw new UnsupportedImageError('Buffer is not a supported image type!') 72 | } 73 | } 74 | 75 | return { 76 | buffer, 77 | contentType, 78 | shouldTransform: true, 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/server/types/image.ts: -------------------------------------------------------------------------------- 1 | import type { MimeType } from './file' 2 | 3 | export type ResponsiveSize = { 4 | size: { 5 | width: number 6 | height?: number 7 | } 8 | maxWidth?: number 9 | } 10 | 11 | /** RGBA hex values 0...255 */ 12 | export type Color = [number, number, number, number] 13 | 14 | export enum ImageFit { 15 | CONTAIN = 'contain', 16 | COVER = 'cover', 17 | FILL = 'fill', 18 | INSIDE = 'inside', 19 | OUTSIDE = 'outside', 20 | } 21 | 22 | export enum ImagePosition { 23 | LEFT = 'left', 24 | CENTER = 'center', 25 | RIGHT = 'right', 26 | } 27 | 28 | export enum FlipDirection { 29 | HORIZONTAL = 'horizontal', 30 | VERTICAL = 'vertical', 31 | BOTH = 'both', 32 | } 33 | 34 | export interface CropOptions { 35 | /** The x position of the upper left pixel. */ 36 | x: number 37 | /** The y position of the upper left pixel. */ 38 | y: number 39 | /** The number of pixels wide to crop the image. */ 40 | width: number 41 | /** The number of pixels high to crop the image. */ 42 | height: number 43 | } 44 | 45 | export interface TransformOptions { 46 | /** Width of resulting image. */ 47 | width: number 48 | /** Height of resulting image. If width is present, this take priority. */ 49 | height?: number 50 | /** The content type of the resulting image. (optional, default source type) */ 51 | contentType?: MimeType 52 | /** How the image should be resized to fit both provided dimensions. (optional, default 'contain') */ 53 | fit?: ImageFit 54 | /** Position to use when fit is cover or contain. (optional, default 'center') */ 55 | position?: ImagePosition 56 | /** Background color of resulting image. (optional, default [0x00, 0x00, 0x00, 0x00]) */ 57 | background?: Color 58 | /** Quality, integer 1-100. (optional, default 80) */ 59 | quality?: number 60 | /** zlib compression level, 0-9. (optional, default 9) */ 61 | compressionLevel?: number 62 | /** Number of animation iterations, use 0 for infinite animation. (optional, default 0) */ 63 | loop?: number 64 | /** Delay between animation frames (in milliseconds). (optional, default 100) */ 65 | delay?: number 66 | /** The number of pixels to blur the image by. (optional, default null) */ 67 | blurRadius?: number | null 68 | /** The number of degrees to rotate the image by. (optional, default null) */ 69 | rotate?: number | null 70 | /** The direction to mirror the image by. (optional, default null) */ 71 | flip?: FlipDirection | null 72 | /** The location to crop the source image before any other operations are applied. (optional, default null) */ 73 | crop?: CropOptions | null 74 | } 75 | 76 | export type SizelessOptions = Omit 77 | -------------------------------------------------------------------------------- /src/component/types/image.ts: -------------------------------------------------------------------------------- 1 | import type { MimeType } from './file' 2 | 3 | export type ResponsiveSize = { 4 | size: { 5 | width: number 6 | height?: number 7 | } 8 | maxWidth?: number 9 | } 10 | 11 | /** RGBA hex values 0...255 */ 12 | export type Color = [number, number, number, number] 13 | 14 | export enum ImageFit { 15 | CONTAIN = 'contain', 16 | COVER = 'cover', 17 | FILL = 'fill', 18 | INSIDE = 'inside', 19 | OUTSIDE = 'outside', 20 | } 21 | 22 | export enum ImagePosition { 23 | LEFT = 'left', 24 | CENTER = 'center', 25 | RIGHT = 'right', 26 | } 27 | 28 | export enum FlipDirection { 29 | HORIZONTAL = 'horizontal', 30 | VERTICAL = 'vertical', 31 | BOTH = 'both', 32 | } 33 | 34 | export interface CropOptions { 35 | /** The x position of the upper left pixel. */ 36 | x: number 37 | /** The y position of the upper left pixel. */ 38 | y: number 39 | /** The number of pixels wide to crop the image. */ 40 | width: number 41 | /** The number of pixels high to crop the image. */ 42 | height: number 43 | } 44 | 45 | export interface TransformOptions { 46 | /** Width of resulting image. */ 47 | width: number 48 | /** Height of resulting image. If width is present, this take priority. */ 49 | height?: number 50 | /** The content type of the resulting image. (optional, default source type) */ 51 | contentType?: MimeType 52 | /** How the image should be resized to fit both provided dimensions. (optional, default 'contain') */ 53 | fit?: ImageFit 54 | /** Position to use when fit is cover or contain. (optional, default 'center') */ 55 | position?: ImagePosition 56 | /** Background color of resulting image. (optional, default [0x00, 0x00, 0x00, 0x00]) */ 57 | background?: Color 58 | /** Quality, integer 1-100. (optional, default 80) */ 59 | quality?: number 60 | /** zlib compression level, 0-9. (optional, default 9) */ 61 | compressionLevel?: number 62 | /** Number of animation iterations, use 0 for infinite animation. (optional, default 0) */ 63 | loop?: number 64 | /** Delay between animation frames (in milliseconds). (optional, default 100) */ 65 | delay?: number 66 | /** The number of pixels to blur the image by. (optional, default null) */ 67 | blurRadius?: number | null 68 | /** The number of degrees to rotate the image by. (optional, default null) */ 69 | rotate?: number | null 70 | /** The direction to mirror the image by. (optional, default null) */ 71 | flip?: FlipDirection | null 72 | /** The location to crop the source image before any other operations are applied. (optional, default null) */ 73 | crop?: CropOptions | null 74 | } 75 | 76 | export type SizelessOptions = Omit 77 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "remix-image-cloudflare", 3 | "version": "0.1.5", 4 | "description": "A React image component and on-demand image optimizer for Cloudflare workers", 5 | "author": "Joost Jansky", 6 | "license": "MIT", 7 | "sideEffects": false, 8 | "workspaces": [ 9 | "examples/*" 10 | ], 11 | "keywords": [ 12 | "react", 13 | "remix", 14 | "image", 15 | "responsive", 16 | "cloudflare" 17 | ], 18 | "repository": { 19 | "type": "git", 20 | "url": "https://github.com/styxlab/remix-image-cloudflare.git" 21 | }, 22 | "bugs": { 23 | "url": "https://github.com/styxlab/remix-image-cloudflare/issues" 24 | }, 25 | "main": "build/index.js", 26 | "type": "module", 27 | "types": "build/index.d.ts", 28 | "exports": { 29 | ".": "./build/index.js", 30 | "./package.json": "./build/package.json", 31 | "./server": "./build/server.js", 32 | "./pure": "./build/pure.js" 33 | }, 34 | "files": [ 35 | "build", 36 | "package.json", 37 | "README.md", 38 | "server.d.ts", 39 | "pure.d.ts" 40 | ], 41 | "scripts": { 42 | "build": "run-s build:module workers.build", 43 | "build:module": "rollup -c", 44 | "watch": "rollup -c -w", 45 | "dev": "run-s workers.dev", 46 | "start": "run-s workers.start", 47 | "test": "node test/tryout.js", 48 | "lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .", 49 | "clean": "rimraf ./build node_modules examples/build examples/.cache examples/node_modules", 50 | "typecheck": "tsc --noEmit", 51 | "workers.build": "npm run build --workspace=cf-workers", 52 | "workers.dev": "npm run dev --workspace=cf-workers", 53 | "workers.start": "npm run start --workspace=cf-workers", 54 | "deploy": "npm run deploy --workspace=cf-workers", 55 | "purge": "npm run purge --workspace=cf-workers", 56 | "prepublishOnly": "npm run build" 57 | }, 58 | "dependencies": { 59 | "@cloudflare/kv-asset-handler": "^0.2.0", 60 | "is-svg": "^4.3.2", 61 | "js-image-lib": "^0.1.7", 62 | "mime-tree": "^0.1.4", 63 | "react": "^18.0.0", 64 | "react-dom": "^18.0.0" 65 | }, 66 | "devDependencies": { 67 | "@cloudflare/workers-types": "^3.4.0", 68 | "@remix-run/eslint-config": "^1.3.5", 69 | "@rollup/plugin-commonjs": "^21.0.3", 70 | "@rollup/plugin-inject": "^4.0.4", 71 | "@rollup/plugin-json": "^4.1.0", 72 | "@rollup/plugin-node-resolve": "^13.2.0", 73 | "@rollup/plugin-replace": "^4.0.0", 74 | "@types/eslint": "^8.4.1", 75 | "@types/node": "^17.0.23", 76 | "@types/react": "^18.0.3", 77 | "@types/react-dom": "^18.0.0", 78 | "eslint": "^8.13.0", 79 | "eslint-config-prettier": "^8.5.0", 80 | "npm-run-all": "^4.1.5", 81 | "prettier": "^2.6.2", 82 | "rimraf": "^3.0.2", 83 | "rollup": "^2.70.1", 84 | "rollup-plugin-filesize": "^9.1.2", 85 | "rollup-plugin-peer-deps-external": "^2.2.4", 86 | "rollup-plugin-terser": "^7.0.2", 87 | "rollup-plugin-typescript2": "^0.31.2", 88 | "run-all": "^1.0.1", 89 | "typescript": "next" 90 | }, 91 | "engines": { 92 | "node": ">=14" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/server/loaders/imageLoader.ts: -------------------------------------------------------------------------------- 1 | import { redirect } from '@remix-run/server-runtime' 2 | import mimeFromBuffer from 'mime-tree' 3 | import { MimeType } from '../types/file' 4 | import { ImageFit, ImagePosition, type TransformOptions } from '../types/image' 5 | import { RemixImageError, UnsupportedImageError } from '../types/error' 6 | import type { AssetLoader } from '../types/loader' 7 | import { imageResponse, textResponse } from '../utils/response' 8 | import { decodeQuery, decodeTransformQuery, parseURL } from '../utils/url' 9 | 10 | export const imageLoader: AssetLoader = async ( 11 | { 12 | selfUrl, 13 | cache = null, 14 | resolver = null, 15 | transformer = null, 16 | useFallbackFormat = true, 17 | fallbackFormat = MimeType.JPEG, 18 | defaultOptions = {}, 19 | redirectOnFail = false, 20 | skipFormats = new Set([MimeType.SVG]), 21 | basePath = 'public', 22 | rewrite = null, 23 | }, 24 | request 25 | ) => { 26 | const reqUrl = parseURL(request.url) 27 | let src: string | null = null 28 | 29 | try { 30 | if (!selfUrl) { 31 | throw new RemixImageError('selfUrl is required in RemixImage loader config!', 500) 32 | } 33 | if (!resolver) { 34 | throw new RemixImageError('resolver is required in RemixImage loader config!', 500) 35 | } 36 | 37 | src = decodeQuery(reqUrl.searchParams, 'src') 38 | 39 | if (!src) { 40 | throw new RemixImageError('An image URL must be provided!', 400) 41 | } 42 | try { 43 | src = decodeURI(src) 44 | } catch (error) { 45 | throw new RemixImageError('An invalid image URL was provided!', 400) 46 | } 47 | 48 | const decodedQuery = decodeTransformQuery(reqUrl.search) 49 | const transformOptions: TransformOptions = { 50 | fit: ImageFit.CONTAIN, 51 | position: ImagePosition.CENTER, 52 | background: [0x00, 0x00, 0x00, 0x00], 53 | quality: 80, 54 | compressionLevel: 9, 55 | loop: 0, 56 | delay: 100, 57 | blurRadius: null, 58 | rotate: null, 59 | flip: null, 60 | ...defaultOptions, 61 | ...decodedQuery, 62 | } as TransformOptions 63 | 64 | const assetUrl = parseURL(src, selfUrl) 65 | 66 | if (!transformOptions.width) { 67 | throw new RemixImageError('A width is required!', 400) 68 | } 69 | if (transformOptions.width && transformOptions.width > 8000) { 70 | throw new RemixImageError('Requested Image too large!', 406) 71 | } 72 | if (transformOptions.height && transformOptions.height > 8000) { 73 | throw new RemixImageError('Requested Image too large!', 406) 74 | } 75 | 76 | const cacheKey = reqUrl.search 77 | let isNewImage = true 78 | let shouldTransform = true 79 | let loadedImg: Uint8Array | undefined 80 | let resultImg: Uint8Array | undefined 81 | let inputContentType: MimeType | undefined 82 | let outputContentType: MimeType | undefined 83 | 84 | if (cache) { 85 | const cacheValue = await cache.get(cacheKey) 86 | 87 | if (cacheValue) { 88 | console.log(`Retrieved image [${cacheKey}] from cache.`) 89 | isNewImage = false 90 | shouldTransform = false 91 | 92 | loadedImg = new Uint8Array(cacheValue) 93 | inputContentType = mimeFromBuffer(loadedImg) 94 | } 95 | } 96 | 97 | if (!loadedImg) { 98 | const start = new Date().getTime() 99 | rewrite = rewrite ?? ((url) => url) 100 | const res = await resolver(src, rewrite(assetUrl.toString()), transformOptions, basePath) 101 | const end = new Date().getTime() 102 | console.log(`Fetched image [${cacheKey}] directly using resolver: ${resolver.name}. Took ${end - start}ms.`) 103 | isNewImage = true 104 | 105 | shouldTransform = res.shouldTransform 106 | loadedImg = res.buffer 107 | inputContentType = res.contentType 108 | } 109 | 110 | if (!loadedImg || !inputContentType) { 111 | throw new RemixImageError('Failed to transform requested image!', 500) 112 | } 113 | 114 | if (!outputContentType) { 115 | outputContentType = inputContentType 116 | } 117 | 118 | if (!shouldTransform || skipFormats?.has(inputContentType)) { 119 | resultImg = loadedImg 120 | } else if (transformer != null) { 121 | let curTransformer = transformer 122 | 123 | if (!curTransformer.supportedOutputs.has(outputContentType)) { 124 | if (useFallbackFormat && curTransformer.supportedOutputs.has(fallbackFormat)) { 125 | console.error( 126 | `Transformer does not allow this output content type: ${outputContentType}! Falling back to mime type: ${fallbackFormat}` 127 | ) 128 | outputContentType = fallbackFormat 129 | } else { 130 | throw new UnsupportedImageError(`Transformer does not allow this output content type: ${outputContentType}!`) 131 | } 132 | } 133 | 134 | resultImg = await curTransformer.transform( 135 | { 136 | url: assetUrl.toString(), 137 | data: loadedImg, 138 | contentType: inputContentType!, 139 | }, 140 | { 141 | ...transformOptions, 142 | contentType: outputContentType!, 143 | } 144 | ) 145 | console.log(`Successfully transformed image using transformer: ${curTransformer.name}.`) 146 | } 147 | 148 | if (!resultImg) { 149 | throw new RemixImageError('Failed to transform requested image!', 500) 150 | } 151 | 152 | if (isNewImage && cache) { 153 | await cache.put(cacheKey, resultImg.buffer) 154 | } 155 | 156 | return imageResponse( 157 | resultImg, 158 | 200, 159 | outputContentType, 160 | cache ? `private, max-age=${cache.config.ttl}, max-stale=${cache.config.tbd}` : `public, max-age=${60 * 60 * 24 * 365}` 161 | ) 162 | } catch (error: any) { 163 | console.error('RemixImage loader error:', error?.message) 164 | console.error(error) 165 | 166 | if (redirectOnFail && src) { 167 | return redirect(src) 168 | } 169 | 170 | if (error instanceof RemixImageError) { 171 | return textResponse(error.errorCode || 500, error.message) 172 | } else { 173 | return textResponse(500, 'RemixImage encountered an unknown error!') 174 | } 175 | } 176 | } 177 | --------------------------------------------------------------------------------