diff --git a/packages/imgproxy/.size-limit.json b/packages/imgproxy/.size-limit.json new file mode 100644 index 0000000..6dd27b2 --- /dev/null +++ b/packages/imgproxy/.size-limit.json @@ -0,0 +1,8 @@ +[ + { + "name": "All publics", + "path": "dist/index.js", + "import": "*", + "limit": "1.5 kB" + } +] diff --git a/packages/imgproxy/README.md b/packages/imgproxy/README.md new file mode 100644 index 0000000..2038290 --- /dev/null +++ b/packages/imgproxy/README.md @@ -0,0 +1,86 @@ +# @srcset/imgproxy + +[![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%2Fimgproxy.svg +[npm-url]: https://npmjs.com/package/@srcset/imgproxy + +[node]: https://img.shields.io/node/v/%40srcset%2Fimgproxy.svg +[node-url]: https://nodejs.org + +[deps]: https://img.shields.io/librariesio/release/npm/%40srcset%2Fimgproxy +[deps-url]: https://libraries.io/npm/%40srcset%2Fimgproxy + +[size]: https://deno.bundlejs.com/badge?q=%40srcset%2Fimgproxy +[size-url]: https://bundlejs.com/?q=%40srcset%2Fimgproxy + +[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 [imgproxy](https://imgproxy.net/): 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 +- 🔒 Presets support via the `processing` hook - no transformations exposed to the browser +- ✍️ Url signing on the server side +- 🛠 Passthrough mode for local development without a running imgproxy instance + +## Install + +```bash +# pnpm +pnpm add @srcset/imgproxy @srcset/runtime +# yarn +yarn add @srcset/imgproxy @srcset/runtime +# npm +npm i @srcset/imgproxy @srcset/runtime +``` + +## Usage + +Srcset objects for CMS images: + +```ts +import { Imgproxy } from '@srcset/imgproxy' + +const imgproxy = new Imgproxy({ endpoint: 'https://imgproxy.example.com' }) +const { src, srcSet, srcMap } = imgproxy.image(photo.url, { + width: [600, 1200], + format: ['webp', 'jpg'] +}) +``` + +Signed urls, on the server side: + +```ts +import { sign } from '@srcset/imgproxy/sign' + +const imgproxy = new Imgproxy({ + endpoint: 'https://imgproxy.example.com', + signer: sign({ key, salt }) +}) +``` + +Passthrough mode - untouched source urls, e.g. for local development without a running imgproxy instance: + +```ts +const imgproxy = new Imgproxy({ + endpoint: 'https://imgproxy.example.com', + passthrough: import.meta.env.DEV +}) +``` + +## Documentation + +For more details, guides and API references, check out the [documentation website](https://srcset.js.org/proxies/imgproxy/). diff --git a/packages/imgproxy/oxlint.config.ts b/packages/imgproxy/oxlint.config.ts new file mode 100644 index 0000000..30be5d0 --- /dev/null +++ b/packages/imgproxy/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/imgproxy/package.json b/packages/imgproxy/package.json new file mode 100644 index 0000000..968f8c4 --- /dev/null +++ b/packages/imgproxy/package.json @@ -0,0 +1,82 @@ +{ + "name": "@srcset/imgproxy", + "type": "module", + "version": "1.0.0", + "description": "Adapter for imgproxy: srcset variant urls for content images and static assets.", + "author": { + "name": "Dan Onoshko", + "email": "danon0404@gmail.com", + "url": "https://github.com/dangreen" + }, + "license": "MIT", + "homepage": "https://srcset.js.org/proxies/imgproxy/", + "funding": "https://ko-fi.com/dangreen", + "repository": { + "type": "git", + "url": "https://github.com/TrigenSoftware/srcset.git", + "directory": "packages/imgproxy" + }, + "bugs": { + "url": "https://github.com/TrigenSoftware/srcset/issues" + }, + "keywords": [ + "srcset", + "image", + "responsive", + "imgproxy" + ], + "engines": { + "node": ">=22" + }, + "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts", + "./sign": "./src/sign.ts" + }, + "publishConfig": { + "exports": { + "./package.json": "./package.json", + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./sign": { + "types": "./dist/sign.d.ts", + "default": "./dist/sign.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", + "sharp": "^0.35.3", + "size-limit": "^12.0.0" + } +} diff --git a/packages/imgproxy/src/image.spec.ts b/packages/imgproxy/src/image.spec.ts new file mode 100644 index 0000000..062fee0 --- /dev/null +++ b/packages/imgproxy/src/image.spec.ts @@ -0,0 +1,224 @@ +import { + describe, + it, + expect, + vi +} from 'vitest' +import { Imgproxy } from './image.ts' + +const imgproxy = new Imgproxy({ + endpoint: 'https://imgproxy.example.com' +}) +const passthrough = new Imgproxy({ + endpoint: 'https://imgproxy.example.com', + passthrough: true +}) +const sourceUrl = 'https://cdn.example.com/photo.jpg' + +describe('imgproxy', () => { + describe('image', () => { + describe('Imgproxy', () => { + it('should build loader-shaped image', () => { + const image = imgproxy.image(sourceUrl, { + width: 1200 + }) + + expect(image.src).toEqual({ + id: 'jpg1200', + format: 'jpg', + type: 'image/jpeg', + width: 1200, + url: 'https://imgproxy.example.com/insecure/w:1200/f:jpg/aHR0cHM6Ly9jZG4uZXhhbXBsZS5jb20vcGhvdG8uanBn' + }) + 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 = imgproxy.image('https://cdn.example.com/picture.png?v=2', { + width: 600 + }) + + expect(image.src.format).toBe('png') + expect(image.url).toContain('/w:600/f:png/') + }) + + it('should fall back to jpg without a known url extension', () => { + const image = imgproxy.image('https://cdn.example.com/api/image/42', { + width: 600 + }) + + expect(image.src.format).toBe('jpg') + }) + + it('should default an svg source to jpg', () => { + const image = imgproxy.image('https://cdn.example.com/logo.svg', { + width: 600 + }) + + expect(image.src.format).toBe('jpg') + }) + + it('should not infer the format from the query string', () => { + const image = imgproxy.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 = imgproxy.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 = imgproxy.image(sourceUrl, { + width: [1200, 600], + format: ['webp', 'jpg'] + }) + + expect(image.srcSet.map(({ id }) => id)).toEqual([ + 'webp1200', + 'webp600', + 'jpg1200', + 'jpg600' + ]) + expect(image.srcMap.webp600).toBe( + 'https://imgproxy.example.com/insecure/w:600/f:webp/aHR0cHM6Ly9jZG4uZXhhbXBsZS5jb20vcGhvdG8uanBn' + ) + }) + + it('should deduplicate formats and widths', () => { + expect(imgproxy.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 = imgproxy.image(sourceUrl, { + width: [300, 1200, 600], + format: ['avif', 'webp', 'jpg'] + }) + + expect(image.src.id).toBe('jpg1200') + }) + + it('should use custom processing builder', () => { + const presets = new Imgproxy({ + endpoint: 'https://imgproxy.example.com', + processing: ({ + format, + width + }) => `pr:card_${format}_${width}` + }) + const image = presets.image(sourceUrl, { + width: 600, + format: 'webp' + }) + + expect(image.url).toBe( + 'https://imgproxy.example.com/insecure/pr:card_webp_600/aHR0cHM6Ly9jZG4uZXhhbXBsZS5jb20vcGhvdG8uanBn' + ) + }) + + it('should pass quality to default processing', () => { + const withQuality = new Imgproxy({ + endpoint: 'https://imgproxy.example.com', + quality: 75 + }) + const image = withQuality.image(sourceUrl, { + width: 1200 + }) + + expect(image.url).toContain('/w:1200/f:jpg/q:75/') + }) + + it('should throw on multiplier widths', () => { + expect(() => imgproxy.image(sourceUrl, { + width: [0.5] + })).toThrow('absolute') + }) + + it('should throw on non-integer widths', () => { + expect(() => imgproxy.image(sourceUrl, { + width: Number.NaN + })).toThrow(TypeError) + expect(() => imgproxy.image(sourceUrl, { + width: Number.POSITIVE_INFINITY + })).toThrow(TypeError) + expect(() => imgproxy.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 and signer in passthrough mode', () => { + const processing = vi.fn(() => 'w:600/f:jpg') + const signer = vi.fn(() => 'signature') + const withHooks = new Imgproxy({ + endpoint: 'https://imgproxy.example.com', + passthrough: true, + signer, + processing + }) + + withHooks.image(sourceUrl, { + width: 600 + }) + + expect(processing).not.toHaveBeenCalled() + expect(signer).not.toHaveBeenCalled() + }) + + it('should throw without variants', () => { + expect(() => imgproxy.image(sourceUrl, {})).toThrow(TypeError) + expect(() => imgproxy.image(sourceUrl, { + width: 100, + format: 'svg' + })).toThrow(TypeError) + }) + }) + }) +}) diff --git a/packages/imgproxy/src/image.ts b/packages/imgproxy/src/image.ts new file mode 100644 index 0000000..6562418 --- /dev/null +++ b/packages/imgproxy/src/image.ts @@ -0,0 +1,176 @@ +import { + type SrcSetEntry, + type ImageFormat, + mimeTypes +} from '@srcset/runtime' +import type { + ImgproxyOptions, + ImgproxyRule, + ImgproxySigner, + ProcessingBuilder +} from './types.ts' +import { + buildImgproxyUrl, + 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' +} + +/** + * Get the default output format from the url file extension. + * Svg is not an output format, so an svg source rasterizes to the fallback. + * @param url - Image url. + * @returns Image format, `jpg` if not an output format. + */ +function outputFormatFromUrl(url: string) { + const format = formatFromUrl(url) + + return format === 'svg' ? 'jpg' : format +} + +export interface ImgproxyImage { + /** + * 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 imgproxy urls. + */ +export class Imgproxy { + readonly #endpoint: string + readonly #signer: ImgproxySigner | undefined + readonly #processing: ProcessingBuilder + readonly #passthrough: boolean + + constructor(options: ImgproxyOptions) { + const { + endpoint, + signer, + quality, + processing = createDefaultProcessing(quality), + passthrough = false + } = options + + this.#endpoint = endpoint + this.#signer = signer + 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. + * @param sourceUrl - Public url of the original image. + * @param rule - Rule to compute variants. + * @returns Image srcset object. + */ + image(sourceUrl: string, rule: ImgproxyRule): ImgproxyImage { + const formats = toArray(rule.format, outputFormatFromUrl(sourceUrl)) + const widths = toArray(rule.width) + const srcSet: SrcSetEntry[] = [] + const srcMap: Record = {} + let src: SrcSetEntry | undefined + + for (const format of new Set(formats)) { + // Raster image can't be converted to SVG. + if (format === 'svg') { + continue + } + + 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 imgproxy image builder needs absolute integer widths in the rule.') + } + + const entry: SrcSetEntry = { + id: `${format}${width}`, + format, + type: mimeTypes[format], + width, + url: this.#passthrough + ? sourceUrl + : buildImgproxyUrl(this.#endpoint, this.#processing({ + format, + width + }), sourceUrl, this.#signer) + } + + if (!this.#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 (this.#passthrough) { + const format = formatFromUrl(sourceUrl) + + return { + url: sourceUrl, + src: { + id: `${format}${src.width}`, + format, + type: mimeTypes[format], + width: src.width, + url: sourceUrl + }, + srcSet, + srcMap + } + } + + return { + url: src.url, + src, + srcSet, + srcMap + } + } +} diff --git a/packages/imgproxy/src/index.ts b/packages/imgproxy/src/index.ts new file mode 100644 index 0000000..acda7d0 --- /dev/null +++ b/packages/imgproxy/src/index.ts @@ -0,0 +1,3 @@ +export type * from './types.ts' +export * from './url.ts' +export * from './image.ts' diff --git a/packages/imgproxy/src/sign.spec.ts b/packages/imgproxy/src/sign.spec.ts new file mode 100644 index 0000000..77fa076 --- /dev/null +++ b/packages/imgproxy/src/sign.spec.ts @@ -0,0 +1,52 @@ +import { + describe, + it, + expect +} from 'vitest' +import { sign } from './sign.ts' +import { buildImgproxyUrl } from './url.ts' + +const signer = sign({ + key: '943b421c9eb07c830af81030552c86009268de4e532ba2ee2eab8247c6da0881', + salt: '520f986b998545b4785e0defbc4f3c1203f22de2374a3d53cb7a7fe9fea309c5' +}) + +describe('imgproxy', () => { + describe('sign', () => { + it('should sign path with hmac sha256 of salted path', () => { + expect(signer('/w:320/f:webp/aHR0cHM6Ly9jZG4uZXhhbXBsZS5jb20vcGhvdG8uanBn')).toBe( + '8u-9XHPfYQr3LUxk71Wj-k4IvCoA68dMhU26199tQ80' + ) + }) + + it('should throw on malformed key or salt', () => { + expect(() => sign({ + key: '', + salt: '520f' + })).toThrow(TypeError) + expect(() => sign({ + key: '943b421', + salt: '520f' + })).toThrow(TypeError) + expect(() => sign({ + key: 'not-hex!', + salt: '520f' + })).toThrow(TypeError) + expect(() => sign({ + key: '943b', + salt: '520g' + })).toThrow(TypeError) + }) + + it('should sign url built with buildImgproxyUrl', () => { + expect(buildImgproxyUrl( + 'https://imgproxy.example.com', + 'w:320/f:webp', + 'https://cdn.example.com/photo.jpg', + signer + )).toBe( + 'https://imgproxy.example.com/8u-9XHPfYQr3LUxk71Wj-k4IvCoA68dMhU26199tQ80/w:320/f:webp/aHR0cHM6Ly9jZG4uZXhhbXBsZS5jb20vcGhvdG8uanBn' + ) + }) + }) +}) diff --git a/packages/imgproxy/src/sign.ts b/packages/imgproxy/src/sign.ts new file mode 100644 index 0000000..ac0cf10 --- /dev/null +++ b/packages/imgproxy/src/sign.ts @@ -0,0 +1,46 @@ +import { Buffer } from 'node:buffer' +import { createHmac } from 'node:crypto' +import type { ImgproxySigner } from './types.ts' + +const HEX_PATTERN = /^(?:[\da-f]{2})+$/i + +export interface ImgproxySignerOptions { + /** + * Hex-encoded imgproxy key. + */ + key: string + /** + * Hex-encoded imgproxy salt. + */ + salt: string +} + +/** + * Decode a hex string, rejecting input that `Buffer.from` would silently truncate. + * @param name - Option name for the error message. + * @param value - Hex string. + * @returns Decoded bytes. + */ +function decodeHex(name: string, value: string) { + if (!HEX_PATTERN.test(value)) { + throw new TypeError(`The imgproxy signer needs a non-empty hex-encoded ${name}.`) + } + + return Buffer.from(value, 'hex') +} + +/** + * Create an imgproxy url path signer: HMAC-SHA256 of the salted path. + * Server side only - never expose the key and the salt to the browser. + * @param options - Hex-encoded key and salt. + * @returns Url path signer. + */ +export function sign(options: ImgproxySignerOptions): ImgproxySigner { + const key = decodeHex('key', options.key) + const salt = decodeHex('salt', options.salt) + + return path => createHmac('sha256', key) + .update(salt) + .update(path) + .digest('base64url') +} diff --git a/packages/imgproxy/src/types.ts b/packages/imgproxy/src/types.ts new file mode 100644 index 0000000..7d12ca3 --- /dev/null +++ b/packages/imgproxy/src/types.ts @@ -0,0 +1,72 @@ +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 processing path segment for the variant. + * The way to use imgproxy presets, e.g. ``({ format, width }) => `pr:card_${format}_${width}` ``. + * @param variant - Image variant descriptor. + * @returns Processing path segment. + */ +export type ProcessingBuilder = (variant: SrcSetImageRequest) => string + +/** + * Url path signer. + * @param path - Url path to sign, starting with `/`. + * @returns Signature string. + */ +export type ImgproxySigner = (path: string) => string + +export interface ImgproxyOptions { + /** + * imgproxy endpoint url. + */ + endpoint: string + /** + * Return source urls untouched instead of building imgproxy urls, + * e.g. for local development without a running imgproxy instance. + */ + passthrough?: boolean + /** + * Url path signer, e.g. created with `sign` from `@srcset/imgproxy/sign`. + * Without a signer urls are built with the `insecure` signature. + * Do not sign urls in the browser - it leaks the keys; sign on the server or use presets. + */ + signer?: ImgproxySigner + /** + * Quality of the variants for the default processing builder. + */ + quality?: number + /** + * Processing options builder. Defaults to `w:{width}/f:{format}[/q:{quality}]`. + */ + processing?: ProcessingBuilder +} + +/** + * Rule to compute image variants. + */ +export interface ImgproxyRule { + /** + * 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. + */ + width?: number | number[] +} diff --git a/packages/imgproxy/src/url.spec.ts b/packages/imgproxy/src/url.spec.ts new file mode 100644 index 0000000..7c5decd --- /dev/null +++ b/packages/imgproxy/src/url.spec.ts @@ -0,0 +1,73 @@ +import { + describe, + it, + expect +} from 'vitest' +import { Buffer } from 'node:buffer' +import { + buildImgproxyUrl, + createDefaultProcessing +} from './url.ts' + +const variant = { + format: 'webp', + width: 320, + height: 240 +} as const + +describe('imgproxy', () => { + describe('url', () => { + describe('createDefaultProcessing', () => { + it('should build width and format options', () => { + expect(createDefaultProcessing()(variant)).toBe('w:320/f:webp') + }) + + it('should add quality option', () => { + expect(createDefaultProcessing(80)(variant)).toBe('w:320/f:webp/q:80') + }) + }) + + describe('buildImgproxyUrl', () => { + it('should build insecure url with base64-encoded source', () => { + expect(buildImgproxyUrl( + 'https://imgproxy.example.com', + 'w:320/f:webp', + 'https://cdn.example.com/photo.jpg' + )).toBe( + `https://imgproxy.example.com/insecure/w:320/f:webp/${Buffer.from('https://cdn.example.com/photo.jpg').toString('base64url')}` + ) + }) + + it('should trim trailing slash of the endpoint', () => { + expect(buildImgproxyUrl( + 'https://imgproxy.example.com/', + 'w:320/f:webp', + 'https://cdn.example.com/photo.jpg' + )).toBe( + `https://imgproxy.example.com/insecure/w:320/f:webp/${Buffer.from('https://cdn.example.com/photo.jpg').toString('base64url')}` + ) + }) + + it('should base64-encode non-ascii source url', () => { + expect(buildImgproxyUrl( + 'https://imgproxy.example.com', + 'w:320/f:webp', + 'https://cdn.example.com/\u0444\u043e\u0442\u043e.jpg?v=2' + )).toBe( + `https://imgproxy.example.com/insecure/w:320/f:webp/${Buffer.from('https://cdn.example.com/\u0444\u043e\u0442\u043e.jpg?v=2').toString('base64url')}` + ) + }) + + it('should sign the path with given signer', () => { + const source = Buffer.from('https://cdn.example.com/photo.jpg').toString('base64url') + + expect(buildImgproxyUrl( + 'https://imgproxy.example.com', + 'w:320/f:webp', + 'https://cdn.example.com/photo.jpg', + path => `signed:${path.length}` + )).toBe(`https://imgproxy.example.com/signed:${`/w:320/f:webp/${source}`.length}/w:320/f:webp/${source}`) + }) + }) + }) +}) diff --git a/packages/imgproxy/src/url.ts b/packages/imgproxy/src/url.ts new file mode 100644 index 0000000..a08b8e7 --- /dev/null +++ b/packages/imgproxy/src/url.ts @@ -0,0 +1,55 @@ +import type { + SrcSetImageRequest, + ImgproxySigner, + ProcessingBuilder +} from './types.ts' + +const INSECURE_SIGNATURE = 'insecure' + +/** + * Create the default processing options builder: `w:{width}/f:{format}[/q:{quality}]`. + * @param quality - Quality of the variants. + * @returns Processing options builder. + */ +export function createDefaultProcessing(quality?: number): ProcessingBuilder { + return ({ + format, + width + }) => `w:${width}/f:${format}${quality ? `/q:${quality}` : ''}` +} + +// The url is always base64-encoded: the plain form needs escaping +// of query strings, percent and at signs, and non-ascii characters. +function encodeSourceUrl(sourceUrl: string) { + // Utf-8 bytes first: `btoa` throws on characters above `\u00ff`. + const bytes = new TextEncoder().encode(sourceUrl) + let binary = '' + + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + + return btoa(binary).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '') +} + +/** + * Build an imgproxy url for the image variant. + * @param endpoint - imgproxy endpoint url. + * @param processing - Processing path segment. + * @param sourceUrl - Source image url. + * @param signer - Url path signer, `insecure` signature without it. + * @returns imgproxy url. + */ +export function buildImgproxyUrl( + endpoint: string, + processing: string, + sourceUrl: string, + signer?: ImgproxySigner +) { + const path = `/${processing}/${encodeSourceUrl(sourceUrl)}` + const signature = signer ? signer(path) : INSECURE_SIGNATURE + + return `${endpoint.replace(/\/$/, '')}/${signature}${path}` +} + +export type { SrcSetImageRequest } diff --git a/packages/imgproxy/src/utils.spec.ts b/packages/imgproxy/src/utils.spec.ts new file mode 100644 index 0000000..653b9e0 --- /dev/null +++ b/packages/imgproxy/src/utils.spec.ts @@ -0,0 +1,27 @@ +import { + describe, + it, + expect +} from 'vitest' +import { toArray } from './utils.ts' + +describe('imgproxy', () => { + 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/imgproxy/src/utils.ts b/packages/imgproxy/src/utils.ts new file mode 100644 index 0000000..9066388 --- /dev/null +++ b/packages/imgproxy/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, omitted for an empty array. + * @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/imgproxy/tsconfig.build.json b/packages/imgproxy/tsconfig.build.json new file mode 100644 index 0000000..8dc6903 --- /dev/null +++ b/packages/imgproxy/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/imgproxy/tsconfig.json b/packages/imgproxy/tsconfig.json new file mode 100644 index 0000000..2783c96 --- /dev/null +++ b/packages/imgproxy/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.build.json", + "compilerOptions": { + "rootDir": "../../", + "allowJs": true + }, + "include": [ + "src", + "test", + "*.js", + "*.ts" + ], + "exclude": [] +} diff --git a/packages/imgproxy/vite.config.js b/packages/imgproxy/vite.config.js new file mode 100644 index 0000000..181ee14 --- /dev/null +++ b/packages/imgproxy/vite.config.js @@ -0,0 +1,31 @@ +import { defineConfig } from 'vite' +import { configDefaults } from 'vitest/config' + +export default defineConfig({ + build: { + target: 'esnext', + lib: { + formats: ['es'], + entry: { + index: './src/index.ts', + sign: './src/sign.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 709ca1b..1c58653 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -131,6 +131,25 @@ importers: version: 4.0.3 publishDirectory: package + packages/imgproxy: + 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 + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@22.20.1) + size-limit: + specifier: ^12.0.0 + version: 12.1.0(jiti@2.6.1) + publishDirectory: package + packages/loader: dependencies: '@srcset/bundler-utils':