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 |
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 | 
2 |
3 | # remix-image-cloudflare
4 |
5 | []()
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 |
--------------------------------------------------------------------------------