From 2eaaa7e3a2dc739bcc8fa3d32c7089ae0ec0760b Mon Sep 17 00:00:00 2001 From: dangreen Date: Sat, 8 Aug 2026 17:32:10 +0400 Subject: [PATCH] feat(cloudflare): add Cloudflare runtime srcset builder --- packages/cloudflare/.size-limit.json | 8 + packages/cloudflare/README.md | 82 ++++++++ packages/cloudflare/oxlint.config.ts | 16 ++ packages/cloudflare/package.json | 76 +++++++ packages/cloudflare/src/image.spec.ts | 255 ++++++++++++++++++++++++ packages/cloudflare/src/image.ts | 165 +++++++++++++++ packages/cloudflare/src/index.ts | 3 + packages/cloudflare/src/types.ts | 64 ++++++ packages/cloudflare/src/url.spec.ts | 75 +++++++ packages/cloudflare/src/url.ts | 63 ++++++ packages/cloudflare/src/utils.spec.ts | 27 +++ packages/cloudflare/src/utils.ts | 17 ++ packages/cloudflare/tsconfig.build.json | 21 ++ packages/cloudflare/tsconfig.json | 14 ++ packages/cloudflare/vite.config.js | 30 +++ pnpm-lock.yaml | 16 ++ 16 files changed, 932 insertions(+) create mode 100644 packages/cloudflare/.size-limit.json create mode 100644 packages/cloudflare/README.md create mode 100644 packages/cloudflare/oxlint.config.ts create mode 100644 packages/cloudflare/package.json create mode 100644 packages/cloudflare/src/image.spec.ts create mode 100644 packages/cloudflare/src/image.ts create mode 100644 packages/cloudflare/src/index.ts create mode 100644 packages/cloudflare/src/types.ts create mode 100644 packages/cloudflare/src/url.spec.ts create mode 100644 packages/cloudflare/src/url.ts create mode 100644 packages/cloudflare/src/utils.spec.ts create mode 100644 packages/cloudflare/src/utils.ts create mode 100644 packages/cloudflare/tsconfig.build.json create mode 100644 packages/cloudflare/tsconfig.json create mode 100644 packages/cloudflare/vite.config.js diff --git a/packages/cloudflare/.size-limit.json b/packages/cloudflare/.size-limit.json new file mode 100644 index 0000000..6dd27b2 --- /dev/null +++ b/packages/cloudflare/.size-limit.json @@ -0,0 +1,8 @@ +[ + { + "name": "All publics", + "path": "dist/index.js", + "import": "*", + "limit": "1.5 kB" + } +] diff --git a/packages/cloudflare/README.md b/packages/cloudflare/README.md new file mode 100644 index 0000000..8af45c4 --- /dev/null +++ b/packages/cloudflare/README.md @@ -0,0 +1,82 @@ +# @srcset/cloudflare + +[![ESM-only package][package]][package-url] +[![NPM version][npm]][npm-url] +[![Node version][node]][node-url] +[![Dependencies status][deps]][deps-url] +[![Install size][size]][size-url] +[![Build status][build]][build-url] +[![Coverage status][coverage]][coverage-url] + +[package]: https://img.shields.io/badge/package-ESM--only-ffe536.svg +[package-url]: https://nodejs.org/api/esm.html + +[npm]: https://img.shields.io/npm/v/%40srcset%2Fcloudflare.svg +[npm-url]: https://npmjs.com/package/@srcset/cloudflare + +[node]: https://img.shields.io/node/v/%40srcset%2Fcloudflare.svg +[node-url]: https://nodejs.org + +[deps]: https://img.shields.io/librariesio/release/npm/%40srcset%2Fcloudflare +[deps-url]: https://libraries.io/npm/%40srcset%2Fcloudflare + +[size]: https://deno.bundlejs.com/badge?q=%40srcset%2Fcloudflare +[size-url]: https://bundlejs.com/?q=%40srcset%2Fcloudflare + +[build]: https://img.shields.io/github/actions/workflow/status/TrigenSoftware/srcset/tests.yml?branch=main +[build-url]: https://github.com/TrigenSoftware/srcset/actions + +[coverage]: https://img.shields.io/codecov/c/github/TrigenSoftware/srcset.svg +[coverage-url]: https://app.codecov.io/gh/TrigenSoftware/srcset + +Adapter for [Cloudflare image transformations](https://developers.cloudflare.com/images/transform-images/): srcset variant urls for content images, e.g. from an API or a CMS. + +- 🌐 Runtime builder of loader-shaped srcset objects, isomorphic: browser and server +- 🚀 Zero configuration on a Cloudflare zone: relative urls from `/cdn-cgi/image`, no signing +- 🔧 Custom transformations via the `processing` hook, e.g. `format=auto` negotiation +- 🛠 Passthrough mode for local development outside of a Cloudflare zone + +## Install + +```bash +# pnpm +pnpm add @srcset/cloudflare @srcset/runtime +# yarn +yarn add @srcset/cloudflare @srcset/runtime +# npm +npm i @srcset/cloudflare @srcset/runtime +``` + +## Usage + +Srcset objects for CMS images, on a Cloudflare zone: + +```ts +import { Cloudflare } from '@srcset/cloudflare' + +const cloudflare = new Cloudflare() +const { src, srcSet, srcMap } = cloudflare.image(photo.url, { + width: [600, 1200], + format: ['webp', 'jpg'] +}) +``` + +Format negotiation by the `Accept` header, instead of format variants: + +```ts +const cloudflare = new Cloudflare({ + processing: ({ width }) => `width=${width},format=auto` +}) +``` + +Passthrough mode - untouched source urls, e.g. for local development outside of a Cloudflare zone: + +```ts +const cloudflare = new Cloudflare({ + passthrough: import.meta.env.DEV +}) +``` + +## Documentation + +For more details, guides and API references, check out the [documentation website](https://srcset.js.org/proxies/cloudflare/). diff --git a/packages/cloudflare/oxlint.config.ts b/packages/cloudflare/oxlint.config.ts new file mode 100644 index 0000000..30be5d0 --- /dev/null +++ b/packages/cloudflare/oxlint.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from '@trigen/oxlint' +import testConfig from '@trigen/oxlint-config/test' +import tsTypeCheckedConfig from '@trigen/oxlint-config/typescript-type-checked' +import rootConfig from '../../oxlint.config.ts' + +export default defineConfig({ + extends: [ + rootConfig, + tsTypeCheckedConfig, + testConfig + ], + env: { + node: false, + browser: true + } +}) diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json new file mode 100644 index 0000000..5667802 --- /dev/null +++ b/packages/cloudflare/package.json @@ -0,0 +1,76 @@ +{ + "name": "@srcset/cloudflare", + "type": "module", + "version": "1.0.0", + "description": "Adapter for Cloudflare image transformations: srcset variant urls for content images, e.g. from an API or a CMS.", + "author": { + "name": "Dan Onoshko", + "email": "danon0404@gmail.com", + "url": "https://github.com/dangreen" + }, + "license": "MIT", + "homepage": "https://srcset.js.org/proxies/cloudflare/", + "funding": "https://ko-fi.com/dangreen", + "repository": { + "type": "git", + "url": "https://github.com/TrigenSoftware/srcset.git", + "directory": "packages/cloudflare" + }, + "bugs": { + "url": "https://github.com/TrigenSoftware/srcset/issues" + }, + "keywords": [ + "srcset", + "image", + "responsive", + "cloudflare" + ], + "engines": { + "node": ">=22" + }, + "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, + "publishConfig": { + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "directory": "package", + "linkDirectory": false + }, + "files": [ + "dist" + ], + "scripts": { + "clear:package": "del ./package", + "clear:dist": "del ./dist", + "clear": "del ./package ./dist ./coverage", + "prepublishOnly": "run build clear:package clean-publish", + "postpublish": "pnpm clear:package", + "emitDeclarations": "tsc -p ./tsconfig.build.json --emitDeclarationOnly", + "build:dist": "run -p emitDeclarations [ vite build ]", + "build": "run clear:dist build:dist", + "lint": "oxlint", + "format": "oxlint --fix", + "test:unit": "vitest run --coverage", + "test:unit:watch": "vitest watch", + "test:size": "size-limit", + "test:types": "tsc --noEmit", + "test": "run -p lint test:unit test:types" + }, + "peerDependencies": { + "@srcset/runtime": "workspace:^" + }, + "devDependencies": { + "@size-limit/preset-small-lib": "^12.0.0", + "@srcset/runtime": "workspace:^", + "@types/node": "^22.0.0", + "size-limit": "^12.0.0" + } +} diff --git a/packages/cloudflare/src/image.spec.ts b/packages/cloudflare/src/image.spec.ts new file mode 100644 index 0000000..9b17a83 --- /dev/null +++ b/packages/cloudflare/src/image.spec.ts @@ -0,0 +1,255 @@ +import { + describe, + it, + expect, + vi +} from 'vitest' +import { Cloudflare } from './image.ts' + +const cloudflare = new Cloudflare() +const passthrough = new Cloudflare({ + passthrough: true +}) +const sourceUrl = 'https://cdn.example.com/photo.jpg' + +describe('cloudflare', () => { + describe('image', () => { + describe('Cloudflare', () => { + it('should build loader-shaped image', () => { + const image = cloudflare.image(sourceUrl, { + width: 1200 + }) + + expect(image.src).toEqual({ + id: 'jpg1200', + format: 'jpg', + type: 'image/jpeg', + width: 1200, + url: '/cdn-cgi/image/width=1200,format=jpeg/https://cdn.example.com/photo.jpg' + }) + expect(image.url).toBe(image.src.url) + expect(image.srcSet).toEqual([image.src]) + expect(image.srcMap).toEqual({ + jpg1200: image.src.url + }) + }) + + it('should default the format to the url extension', () => { + const image = cloudflare.image('https://cdn.example.com/picture.png?v=2', { + width: 600 + }) + + expect(image.src.format).toBe('png') + // Cloudflare can not force png, so the format option is omitted. + expect(image.url).toBe('/cdn-cgi/image/width=600/https://cdn.example.com/picture.png?v=2') + }) + + it('should fall back to jpg without a known url extension', () => { + const image = cloudflare.image('https://cdn.example.com/api/image/42', { + width: 600 + }) + + expect(image.src.format).toBe('jpg') + }) + + it('should pass an svg source through', () => { + const image = cloudflare.image('https://cdn.example.com/logo.svg', { + width: 600 + }) + + expect(image.url).toBe('https://cdn.example.com/logo.svg') + expect(image.src.format).toBe('svg') + expect(image.src.type).toBe('image/svg+xml') + expect(image.srcSet).toEqual([]) + expect(image.srcMap).toEqual({}) + }) + + it('should pass an svg source through despite rule formats', () => { + const image = cloudflare.image('https://cdn.example.com/logo.svg', { + width: 600, + format: 'webp' + }) + + expect(image.url).toBe('https://cdn.example.com/logo.svg') + expect(image.src.format).toBe('svg') + }) + + it('should not infer the format from the query string', () => { + const image = cloudflare.image('https://cdn.example.com/api/image?file=photo.png', { + width: 600 + }) + + expect(image.src.format).toBe('jpg') + }) + + it('should ignore prototype properties as formats', () => { + const image = cloudflare.image('https://cdn.example.com/photo.constructor', { + width: 600 + }) + + expect(image.src.format).toBe('jpg') + }) + + it('should build variants for widths and formats', () => { + const image = cloudflare.image(sourceUrl, { + width: [1200, 600], + format: ['webp', 'jpg'] + }) + + expect(image.srcSet.map(({ id }) => id)).toEqual([ + 'webp1200', + 'webp600', + 'jpg1200', + 'jpg600' + ]) + expect(image.srcMap.webp600).toBe( + '/cdn-cgi/image/width=600,format=webp/https://cdn.example.com/photo.jpg' + ) + }) + + it('should throw on formats Cloudflare can not force', () => { + expect(() => cloudflare.image(sourceUrl, { + width: 600, + format: 'png' + })).toThrow('force') + }) + + it('should allow the source format in the rule', () => { + const image = cloudflare.image('https://cdn.example.com/picture.png', { + width: 600, + format: ['webp', 'png'] + }) + + expect(image.srcSet.map(({ id }) => id)).toEqual(['webp600', 'png600']) + expect(image.srcMap.png600).toBe('/cdn-cgi/image/width=600/https://cdn.example.com/picture.png') + }) + + it('should deduplicate formats and widths', () => { + expect(cloudflare.image(sourceUrl, { + width: [600, 600], + format: ['jpg', 'jpg'] + }).srcSet.length).toBe(1) + }) + + it('should select fallback src of the last format with the largest width', () => { + const image = cloudflare.image(sourceUrl, { + width: [300, 1200, 600], + format: ['avif', 'webp', 'jpg'] + }) + + expect(image.src.id).toBe('jpg1200') + }) + + it('should use custom endpoint', () => { + const zone = new Cloudflare({ + endpoint: 'https://example.com/cdn-cgi/image' + }) + const image = zone.image('/assets/photo.jpg', { + width: 600 + }) + + expect(image.url).toBe('https://example.com/cdn-cgi/image/width=600,format=jpeg/assets/photo.jpg') + }) + + it('should use custom processing builder', () => { + const negotiated = new Cloudflare({ + processing: ({ width }) => `width=${width},format=auto` + }) + const image = negotiated.image(sourceUrl, { + width: 600, + format: 'webp' + }) + + expect(image.url).toBe( + '/cdn-cgi/image/width=600,format=auto/https://cdn.example.com/photo.jpg' + ) + }) + + it('should pass quality to default processing', () => { + const withQuality = new Cloudflare({ + quality: 75 + }) + const image = withQuality.image(sourceUrl, { + width: 1200 + }) + + expect(image.url).toContain('/width=1200,format=jpeg,quality=75/') + }) + + it('should throw on multiplier widths', () => { + expect(() => cloudflare.image(sourceUrl, { + width: [0.5] + })).toThrow('absolute') + }) + + it('should throw on non-integer widths', () => { + expect(() => cloudflare.image(sourceUrl, { + width: Number.NaN + })).toThrow(TypeError) + expect(() => cloudflare.image(sourceUrl, { + width: Number.POSITIVE_INFINITY + })).toThrow(TypeError) + expect(() => cloudflare.image(sourceUrl, { + width: 640.5 + })).toThrow(TypeError) + }) + + it('should pass the source url through in passthrough mode', () => { + const image = passthrough.image(sourceUrl, { + width: [600, 1200], + format: ['webp', 'jpg'] + }) + + expect(image.url).toBe(sourceUrl) + expect(image.src).toEqual({ + id: 'jpg1200', + format: 'jpg', + type: 'image/jpeg', + width: 1200, + url: sourceUrl + }) + expect(image.srcSet).toEqual([]) + expect(image.srcMap).toEqual({}) + }) + + it('should validate the rule in passthrough mode', () => { + expect(() => passthrough.image(sourceUrl, { + width: [0.5] + })).toThrow('absolute') + expect(() => passthrough.image(sourceUrl, {})).toThrow(TypeError) + }) + + it('should keep the svg format of a passthrough source', () => { + const image = passthrough.image('https://cdn.example.com/logo.svg', { + width: 600 + }) + + expect(image.src.format).toBe('svg') + expect(image.src.type).toBe('image/svg+xml') + expect(image.src.url).toBe('https://cdn.example.com/logo.svg') + }) + + it('should not call processing in passthrough mode', () => { + const processing = vi.fn(() => 'width=600') + const withHook = new Cloudflare({ + passthrough: true, + processing + }) + + withHook.image(sourceUrl, { + width: 600 + }) + + expect(processing).not.toHaveBeenCalled() + }) + + it('should throw without variants', () => { + expect(() => cloudflare.image(sourceUrl, {})).toThrow(TypeError) + expect(() => cloudflare.image(sourceUrl, { + width: 100, + format: 'svg' + })).toThrow(TypeError) + }) + }) + }) +}) diff --git a/packages/cloudflare/src/image.ts b/packages/cloudflare/src/image.ts new file mode 100644 index 0000000..9e29fd1 --- /dev/null +++ b/packages/cloudflare/src/image.ts @@ -0,0 +1,165 @@ +import { + type SrcSetEntry, + type ImageFormat, + mimeTypes +} from '@srcset/runtime' +import type { + CloudflareOptions, + CloudflareRule, + ProcessingBuilder +} from './types.ts' +import { + DEFAULT_ENDPOINT, + canOutputFormat, + buildCloudflareUrl, + createDefaultProcessing +} from './url.ts' +import { toArray } from './utils.ts' + +const FORMAT_PATTERN = /\.(\w+)$/ +const QUERY_OR_FRAGMENT_PATTERN = /[?#]/ + +/** + * Get the image format from the url file extension. + * @param url - Image url. + * @returns Image format, `jpg` if unknown. + */ +function formatFromUrl(url: string) { + // Only the path part: query and fragment can contain dotted values. + const [pathname] = url.split(QUERY_OR_FRAGMENT_PATTERN, 1) + const extension = FORMAT_PATTERN.exec(pathname)?.[1].toLowerCase() + const format = extension === 'jpeg' ? 'jpg' : extension + + return format && Object.hasOwn(mimeTypes, format) ? format as ImageFormat : 'jpg' +} + +export interface CloudflareImage { + /** + * Url of the `src` variant. + */ + url: string + /** + * Fallback image variant: the last format, the largest width. + */ + src: SrcSetEntry + /** + * Generated image variants. + */ + srcSet: SrcSetEntry[] + /** + * Id-to-url map of generated image variants. + */ + srcMap: Record +} + +/** + * Builder of loader-shaped srcset objects for content images, + * e.g. from an API or a CMS, with Cloudflare image transformations urls. + */ +export class Cloudflare { + readonly #endpoint: string + readonly #processing: ProcessingBuilder + readonly #passthrough: boolean + + constructor(options: CloudflareOptions = {}) { + const { + endpoint = DEFAULT_ENDPOINT, + quality, + processing = createDefaultProcessing(quality), + passthrough = false + } = options + + this.#endpoint = endpoint + this.#processing = processing + this.#passthrough = passthrough + } + + /** + * Build a loader-shaped srcset object for the image. + * The source size is unknown on the client, so the rule + * needs absolute widths, and the variants have no height. + * In the passthrough mode the object carries the untouched + * source url with empty variants. An svg source always passes + * through: Cloudflare returns svg as is. + * @param sourceUrl - Public url of the original image. + * @param rule - Rule to compute variants. + * @returns Image srcset object. + */ + image(sourceUrl: string, rule: CloudflareRule): CloudflareImage { + const sourceFormat = formatFromUrl(sourceUrl) + const isSvgSource = sourceFormat === 'svg' + // Cloudflare returns svg sources as is, ignoring all transformations, + // so an svg source always passes through; jpg drives the variant loop. + const passthrough = this.#passthrough || isSvgSource + const formats: ImageFormat[] = isSvgSource ? ['jpg'] : toArray(rule.format, sourceFormat) + const widths = toArray(rule.width) + const srcSet: SrcSetEntry[] = [] + const srcMap: Record = {} + let src: SrcSetEntry | undefined + + for (const format of new Set(formats)) { + if (!canOutputFormat(format, sourceFormat)) { + throw new TypeError(`Cloudflare can not force the ${format} output format.`) + } + + for (const width of new Set(widths)) { + // The source size is unknown on the client, so multipliers can't be + // resolved, and `w` descriptors need integer pixel widths. + if (!Number.isInteger(width) || width <= 1) { + throw new TypeError('The Cloudflare image builder needs absolute integer widths in the rule.') + } + + const entry: SrcSetEntry = { + id: `${format}${width}`, + format, + type: mimeTypes[format], + width, + url: passthrough + ? sourceUrl + : buildCloudflareUrl(this.#endpoint, this.#processing({ + format, + width + }), sourceUrl) + } + + if (!passthrough) { + srcSet.push(entry) + srcMap[entry.id] = entry.url + } + + // Formats go first: on format change the entry starts the next format + // group, so the src candidate ends up in the last group, the largest width. + if (!src || src.format !== entry.format || entry.width > src.width) { + src = entry + } + } + } + + if (!src) { + throw new TypeError('No image variants: set the `width` and a non-svg `format` in the rule.') + } + + // The passthrough image is the untouched original, so there are no variants to pick from. + if (passthrough) { + return { + url: sourceUrl, + src: { + id: `${sourceFormat}${src.width}`, + format: sourceFormat, + type: mimeTypes[sourceFormat], + width: src.width, + url: sourceUrl + }, + srcSet, + srcMap + } + } + + return { + url: src.url, + src, + srcSet, + srcMap + } + } +} diff --git a/packages/cloudflare/src/index.ts b/packages/cloudflare/src/index.ts new file mode 100644 index 0000000..acda7d0 --- /dev/null +++ b/packages/cloudflare/src/index.ts @@ -0,0 +1,3 @@ +export type * from './types.ts' +export * from './url.ts' +export * from './image.ts' diff --git a/packages/cloudflare/src/types.ts b/packages/cloudflare/src/types.ts new file mode 100644 index 0000000..b6533d7 --- /dev/null +++ b/packages/cloudflare/src/types.ts @@ -0,0 +1,64 @@ +import type { ImageFormat } from '@srcset/runtime' + +/** + * Descriptor of the image variant to build an url for. + */ +export interface SrcSetImageRequest { + /** + * Image variant format. + */ + format: ImageFormat + /** + * Width of the image variant in pixels. + */ + width: number +} + +/** + * Processing options builder: makes the transformation options segment for the variant. + * E.g. ``({ width }) => `width=${width},format=auto` `` to let Cloudflare + * negotiate the format by the `Accept` header. + * @param variant - Image variant descriptor. + * @returns Transformation options segment. + */ +export type ProcessingBuilder = (variant: SrcSetImageRequest) => string + +export interface CloudflareOptions { + /** + * Image transformations endpoint: an absolute url, or a path + * on the zone serving the site. Defaults to `/cdn-cgi/image`. + */ + endpoint?: string + /** + * Return source urls untouched instead of building transformation urls, + * e.g. for local development outside of a Cloudflare zone. + */ + passthrough?: boolean + /** + * Quality of the variants for the default processing builder. + */ + quality?: number + /** + * Processing options builder. Defaults to `width={width},format={format}[,quality={quality}]`. + */ + processing?: ProcessingBuilder +} + +/** + * Rule to compute image variants. + */ +export interface CloudflareRule { + /** + * Output image format(s). The last one is used as the `src` fallback. + * Defaults to the url file extension. + */ + format?: ImageFormat | ImageFormat[] + /** + * Absolute output image width(s) in pixels: the source size + * is unknown on the client, so multipliers can't be resolved. + * Keep the widths within the source width: Cloudflare never + * upscales, so a larger variant is served at the source width + * and its `w` descriptor would mislead the browser. + */ + width?: number | number[] +} diff --git a/packages/cloudflare/src/url.spec.ts b/packages/cloudflare/src/url.spec.ts new file mode 100644 index 0000000..7b769a2 --- /dev/null +++ b/packages/cloudflare/src/url.spec.ts @@ -0,0 +1,75 @@ +import { + describe, + it, + expect +} from 'vitest' +import { + canOutputFormat, + createDefaultProcessing, + buildCloudflareUrl +} from './url.ts' + +describe('cloudflare', () => { + describe('url', () => { + describe('canOutputFormat', () => { + it('should allow forceable formats and the source format', () => { + expect(canOutputFormat('webp', 'jpg')).toBe(true) + expect(canOutputFormat('avif', 'png')).toBe(true) + expect(canOutputFormat('png', 'png')).toBe(true) + expect(canOutputFormat('png', 'jpg')).toBe(false) + expect(canOutputFormat('svg', 'jpg')).toBe(false) + }) + }) + + describe('createDefaultProcessing', () => { + it('should build width and format options', () => { + expect(createDefaultProcessing()({ + format: 'jpg', + width: 800 + })).toBe('width=800,format=jpeg') + expect(createDefaultProcessing()({ + format: 'webp', + width: 320 + })).toBe('width=320,format=webp') + }) + + it('should skip formats Cloudflare can not force', () => { + expect(createDefaultProcessing()({ + format: 'png', + width: 800 + })).toBe('width=800') + }) + + it('should add quality option', () => { + expect(createDefaultProcessing(75)({ + format: 'jpg', + width: 800 + })).toBe('width=800,format=jpeg,quality=75') + }) + }) + + describe('buildCloudflareUrl', () => { + it('should join path source without the leading slash', () => { + expect(buildCloudflareUrl('/cdn-cgi/image', 'width=800,format=jpeg', '/assets/image.jpg')).toBe( + '/cdn-cgi/image/width=800,format=jpeg/assets/image.jpg' + ) + }) + + it('should keep absolute source url as is', () => { + expect(buildCloudflareUrl( + 'https://example.com/cdn-cgi/image', + 'width=800,format=jpeg', + 'https://cdn.example.com/photo.jpg' + )).toBe( + 'https://example.com/cdn-cgi/image/width=800,format=jpeg/https://cdn.example.com/photo.jpg' + ) + }) + + it('should normalize endpoint trailing slash', () => { + expect(buildCloudflareUrl('/cdn-cgi/image/', 'width=800', 'assets/image.jpg')).toBe( + '/cdn-cgi/image/width=800/assets/image.jpg' + ) + }) + }) + }) +}) diff --git a/packages/cloudflare/src/url.ts b/packages/cloudflare/src/url.ts new file mode 100644 index 0000000..fceb872 --- /dev/null +++ b/packages/cloudflare/src/url.ts @@ -0,0 +1,63 @@ +import type { ImageFormat } from '@srcset/runtime' +import type { + SrcSetImageRequest, + ProcessingBuilder +} from './types.ts' + +export const DEFAULT_ENDPOINT = '/cdn-cgi/image' + +// Cloudflare can force only these output formats, the rest pass through as is. +const CLOUDFLARE_FORMATS: Partial> = { + jpg: 'jpeg', + webp: 'webp', + avif: 'avif' +} + +/** + * Check that Cloudflare can output the format for the source: + * jpeg, webp and avif can be forced, a non-forceable format + * is only valid as the source format passing through unconverted. + * @param format - Output image format. + * @param sourceFormat - Source image format. + * @returns Whether Cloudflare can output the format. + */ +export function canOutputFormat(format: ImageFormat, sourceFormat: ImageFormat) { + return Object.hasOwn(CLOUDFLARE_FORMATS, format) || format === sourceFormat +} + +/** + * Create the default processing options builder: `width={width},format={format}[,quality={quality}]`. + * Formats Cloudflare can not force (png, gif, svg) go without the `format` option. + * @param quality - Quality of the variants. + * @returns Processing options builder. + */ +export function createDefaultProcessing(quality?: number): ProcessingBuilder { + return ({ + format, + width + }) => { + const outputFormat = CLOUDFLARE_FORMATS[format] + + return `width=${width}${outputFormat ? `,format=${outputFormat}` : ''}${quality ? `,quality=${quality}` : ''}` + } +} + +/** + * Build a Cloudflare image transformations url for the image variant. + * @param endpoint - Image transformations endpoint. + * @param processing - Transformation options segment. + * @param sourceUrl - Source image url: absolute, or a path resolved against the zone. + * @returns Cloudflare url. + */ +export function buildCloudflareUrl( + endpoint: string, + processing: string, + sourceUrl: string +) { + // Path source goes without the leading slash: /cdn-cgi/image/width=800/assets/image.jpg + const source = sourceUrl.startsWith('/') ? sourceUrl.slice(1) : sourceUrl + + return `${endpoint.replace(/\/$/, '')}/${processing}/${source}` +} + +export type { SrcSetImageRequest } diff --git a/packages/cloudflare/src/utils.spec.ts b/packages/cloudflare/src/utils.spec.ts new file mode 100644 index 0000000..7d85640 --- /dev/null +++ b/packages/cloudflare/src/utils.spec.ts @@ -0,0 +1,27 @@ +import { + describe, + it, + expect +} from 'vitest' +import { toArray } from './utils.ts' + +describe('cloudflare', () => { + describe('utils', () => { + describe('toArray', () => { + it('should return array as is', () => { + const value = [1, 2] + + expect(toArray(value)).toBe(value) + }) + + it('should wrap single value', () => { + expect(toArray(1)).toEqual([1]) + }) + + it('should fall back for undefined', () => { + expect(toArray(undefined, 1)).toEqual([1]) + expect(toArray(undefined)).toEqual([]) + }) + }) + }) +}) diff --git a/packages/cloudflare/src/utils.ts b/packages/cloudflare/src/utils.ts new file mode 100644 index 0000000..ff4a2aa --- /dev/null +++ b/packages/cloudflare/src/utils.ts @@ -0,0 +1,17 @@ +/** + * Normalize an optional single-or-array value to an array. + * @param value - Single value, array, or `undefined`. + * @param fallback - Value for the `undefined` case. + * @returns Array of values. + */ +export function toArray(value: T | T[] | undefined, fallback?: T): T[] { + if (Array.isArray(value)) { + return value + } + + if (value !== undefined) { + return [value] + } + + return fallback === undefined ? [] : [fallback] +} diff --git a/packages/cloudflare/tsconfig.build.json b/packages/cloudflare/tsconfig.build.json new file mode 100644 index 0000000..8dc6903 --- /dev/null +++ b/packages/cloudflare/tsconfig.build.json @@ -0,0 +1,21 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "lib": [ + "esnext", + "dom" + ], + "types": [ + "node" + ] + }, + "include": [ + "src" + ], + "exclude": [ + "**/*.config.ts", + "**/*.spec.ts" + ] +} diff --git a/packages/cloudflare/tsconfig.json b/packages/cloudflare/tsconfig.json new file mode 100644 index 0000000..2783c96 --- /dev/null +++ b/packages/cloudflare/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "rootDir": "../../", + "allowJs": true + }, + "include": [ + "src", + "test", + "*.js", + "*.ts" + ], + "exclude": [] +} diff --git a/packages/cloudflare/vite.config.js b/packages/cloudflare/vite.config.js new file mode 100644 index 0000000..5a61ba0 --- /dev/null +++ b/packages/cloudflare/vite.config.js @@ -0,0 +1,30 @@ +import { defineConfig } from 'vite' +import { configDefaults } from 'vitest/config' + +export default defineConfig({ + build: { + target: 'esnext', + lib: { + formats: ['es'], + entry: { + index: './src/index.ts' + } + }, + rolldownOptions: { + external: id => /^(?:node:|@srcset\/)/.test(id), + output: { + topLevelVar: false + } + }, + sourcemap: true, + minify: false, + emptyOutDir: false + }, + test: { + exclude: [...configDefaults.exclude, './package'], + coverage: { + reporter: ['lcovonly', 'text'], + include: ['src/**/*'] + } + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1c58653..3f9d1e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,6 +105,22 @@ importers: version: 0.35.3(@types/node@22.20.1) publishDirectory: package + packages/cloudflare: + devDependencies: + '@size-limit/preset-small-lib': + specifier: ^12.0.0 + version: 12.1.0(size-limit@12.1.0(jiti@2.6.1)) + '@srcset/runtime': + specifier: workspace:^ + version: link:../runtime + '@types/node': + specifier: ^22.0.0 + version: 22.20.1 + size-limit: + specifier: ^12.0.0 + version: 12.1.0(jiti@2.6.1) + publishDirectory: package + packages/core: dependencies: css-mediaquery: