also looking at this
feat: imgx integration — on-the-fly image transforms from query params
#2264
15 files
+1118
-16
| @@ -52,6 +52,7 @@ export default { | ||
| 52 | 52 | { text: 'SSL Termination', link: '/features/ssl-termination' }, |
| 53 | 53 | { text: 'Load Balancing', link: '/features/load-balancing' }, |
| 54 | 54 | { text: 'Request Routing', link: '/features/request-routing' }, |
| 55 | { text: 'Image Transforms (imgx)', link: '/features/imgx' }, | |
| 55 | 56 | ], |
| 56 | 57 | }, |
| 57 | 58 | { |
| @@ -22,6 +22,7 @@ | ||
| 22 | 22 | - ��️ Auto HTTP-to-HTTPS Redirection |
| 23 | 23 | - ✏️ `/etc/hosts` Management _(fully disable-able for real servers)_ |
| 24 | 24 | - �� Clean URLs _(removes `.html` extension)_ |
| 25 | - ��️ Image Transforms _(imgx integration: `?w=200&h=1200&q=80` on any image response)_ | |
| 25 | 26 | - �� CLI & Library Support |
| 26 | 27 | |
| 27 | 28 | ## Install |
bun.lockmodified+7-4
| @@ -13,14 +13,14 @@ | ||
| 13 | 13 | }, |
| 14 | 14 | "packages/bun-plugin": { |
| 15 | 15 | "name": "bun-plugin-rpx", |
| 16 | "version": "0.11.42", | |
| 16 | "version": "0.11.51", | |
| 17 | 17 | "dependencies": { |
| 18 | 18 | "@stacksjs/rpx": "workspace:*", |
| 19 | 19 | }, |
| 20 | 20 | }, |
| 21 | 21 | "packages/rpx": { |
| 22 | 22 | "name": "@stacksjs/rpx", |
| 23 | "version": "0.11.42", | |
| 23 | "version": "0.11.51", | |
| 24 | 24 | "bin": { |
| 25 | 25 | "rpx": "./dist/bin/cli.js", |
| 26 | 26 | "reverse-proxy": "./dist/bin/cli.js", |
| @@ -28,6 +28,7 @@ | ||
| 28 | 28 | "dependencies": { |
| 29 | 29 | "@stacksjs/clapp": "^0.2.10", |
| 30 | 30 | "@stacksjs/tlsx": "^0.13.19", |
| 31 | "ts-images": "^0.1.11", | |
| 31 | 32 | }, |
| 32 | 33 | "devDependencies": { |
| 33 | 34 | "bunfig": "^0.15.6", |
| @@ -37,7 +38,7 @@ | ||
| 37 | 38 | }, |
| 38 | 39 | "packages/vite-plugin": { |
| 39 | 40 | "name": "vite-plugin-rpx", |
| 40 | "version": "0.11.42", | |
| 41 | "version": "0.11.51", | |
| 41 | 42 | "dependencies": { |
| 42 | 43 | "@stacksjs/rpx": "workspace:*", |
| 43 | 44 | "@stacksjs/tlsx": "^0.13.19", |
| @@ -406,7 +407,7 @@ | ||
| 406 | 407 | |
| 407 | 408 | "ts-gif": ["ts-gif@0.1.2", "", { "dependencies": { "data-uri-to-buffer": "^6.0.2" } }, "sha512-jbraplO1qqJtrtkFeepNPUGPad7w8FQHWIQFsn1OJbHeWKUbdqdkrX18XzNXI5Fw7+s6A3nbNTdj+Ukn4d3f8g=="], |
| 408 | 409 | |
| 409 | "ts-images": ["ts-images@0.1.8", "", { "dependencies": { "@stacksjs/ts-avif": "^0.1.0", "@stacksjs/ts-bmp": "^0.1.0", "@stacksjs/ts-png": "^0.1.2", "@stacksjs/ts-svg": "^0.1.1", "@stacksjs/ts-webp": "^0.1.0", "ts-gif": "^0.1.2", "ts-jpeg": "^0.3.1" }, "bin": { "imgx": "./dist/bin/cli.js" } }, "sha512-z1S9gRanhORW6/N6QOtRwx2E/jTwIsJMh1KIRm31MEVulq5nsoNI3YFVWdkYnrLnRc3s2A83ptY4DeEPtY7Sqg=="], | |
| 410 | "ts-images": ["ts-images@0.1.11", "", { "dependencies": { "@stacksjs/ts-avif": "^0.1.0", "@stacksjs/ts-bmp": "^0.1.0", "@stacksjs/ts-png": "^0.1.2", "@stacksjs/ts-svg": "^0.1.1", "@stacksjs/ts-webp": "^0.1.0", "ts-gif": "^0.1.2", "ts-jpeg": "^0.3.1" }, "bin": { "imgx": "./dist/bin/cli.js" } }, "sha512-b5g1uADQR1Ytg4mS0EyFEce9jWhxC/kj1f5ChTVnnpPZV8NhI+aEW8YbH+LMDRCzCE1HRJiUIweUPqMkMAytIA=="], | |
| 410 | 411 | |
| 411 | 412 | "ts-jpeg": ["ts-jpeg@0.3.1", "", {}, "sha512-KIGTJj4UIlkGoCFEofx8jHHQEkJfSLFJY0MHz60Mu4Scjrcj7K/0UJGCbL9QvfIaFGAOGdf0xNFpYSuzUpimwA=="], |
| 412 | 413 | |
| @@ -436,6 +437,8 @@ | ||
| 436 | 437 | |
| 437 | 438 | "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], |
| 438 | 439 | |
| 440 | "@stacksjs/stx/ts-images": ["ts-images@0.1.8", "", { "dependencies": { "@stacksjs/ts-avif": "^0.1.0", "@stacksjs/ts-bmp": "^0.1.0", "@stacksjs/ts-png": "^0.1.2", "@stacksjs/ts-svg": "^0.1.1", "@stacksjs/ts-webp": "^0.1.0", "ts-gif": "^0.1.2", "ts-jpeg": "^0.3.1" }, "bin": { "imgx": "./dist/bin/cli.js" } }, "sha512-z1S9gRanhORW6/N6QOtRwx2E/jTwIsJMh1KIRm31MEVulq5nsoNI3YFVWdkYnrLnRc3s2A83ptY4DeEPtY7Sqg=="], | |
| 441 | ||
| 439 | 442 | "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], |
| 440 | 443 | |
| 441 | 444 | "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], |
| @@ -0,0 +1,102 @@ | ||
| 1 | # Image Transforms (imgx) | |
| 2 | ||
| 3 | rpx can transform image responses on the fly, driven by imgix/meema-style query params — powered by the [imgx](https://github.com/stacksjs/ts-images) pure-TypeScript pipeline. Enable it and every image your proxy (or static route) serves understands URLs like: | |
| 4 | ||
| 5 | ```txt | |
| 6 | https://app.localhost/hero.png?w=200&h=1200&q=80 | |
| 7 | https://app.localhost/hero.png?w=640&fm=webp | |
| 8 | https://app.localhost/hero.png?w=400&h=400&fit=crop&crop=top | |
| 9 | ``` | |
| 10 | ||
| 11 | The upstream never sees anything but the original request — any dev server, static directory, or app gains image resizing for free. | |
| 12 | ||
| 13 | ## Enabling | |
| 14 | ||
| 15 | ```ts | |
| 16 | import { startProxy } from '@stacksjs/rpx' | |
| 17 | ||
| 18 | await startProxy({ | |
| 19 | from: 'localhost:5173', | |
| 20 | to: 'my-app.localhost', | |
| 21 | imgx: true, // defaults; or pass an options object | |
| 22 | }) | |
| 23 | ``` | |
| 24 | ||
| 25 | Per-proxy in multi-proxy configs (overrides the shared setting): | |
| 26 | ||
| 27 | ```ts | |
| 28 | await startProxy({ | |
| 29 | imgx: true, | |
| 30 | proxies: [ | |
| 31 | { from: 'localhost:5173', to: 'app.localhost' }, | |
| 32 | { from: 'localhost:3000', to: 'api.localhost', imgx: false }, | |
| 33 | ], | |
| 34 | }) | |
| 35 | ``` | |
| 36 | ||
| 37 | Or from the CLI: | |
| 38 | ||
| 39 | ```bash | |
| 40 | rpx start --from localhost:5173 --to my-app.localhost --imgx | |
| 41 | ``` | |
| 42 | ||
| 43 | ## Supported query params | |
| 44 | ||
| 45 | Sizing: | |
| 46 | ||
| 47 | | Param | Meaning | | |
| 48 | | --- | --- | | |
| 49 | | `w`, `h` | Output width/height in px, or a `0–1` fraction of the source dimension | | |
| 50 | | `fit` | `clip` (default, fit within preserving AR), `max` (clip, never enlarge), `crop` (fill + crop overflow), `min` (crop, never enlarge), `scale` (stretch), `fill` (letterbox with `bg`) | | |
| 51 | | `crop` | Crop anchor for `fit=crop`: `top`, `bottom`, `left`, `right`, or combos (`top,left`) | | |
| 52 | | `ar` | Aspect ratio (`16:9`) used to derive a missing dimension | | |
| 53 | | `dpr` | Device pixel ratio multiplier, `0–5` (default `1`) | | |
| 54 | ||
| 55 | Output: | |
| 56 | ||
| 57 | | Param | Meaning | | |
| 58 | | --- | --- | | |
| 59 | | `q` | Quality `1–100` (default `80`, configurable) | | |
| 60 | | `fm` / `format` | `jpeg`, `png`, `webp`, `avif`, `gif`, `bmp`, or `auto` | | |
| 61 | | `auto` | `format` (negotiate from `Accept`, prefers WebP), `compress` | | |
| 62 | | `lossless` | Lossless WebP/AVIF encoding | | |
| 63 | ||
| 64 | Adjustments: | |
| 65 | ||
| 66 | | Param | Meaning | | |
| 67 | | --- | --- | | |
| 68 | | `blur` | Gaussian blur, imgix `0–2000` scale | | |
| 69 | | `sharp` | Sharpen | | |
| 70 | | `greyscale` / `grayscale` | Convert to grayscale | | |
| 71 | | `bri`, `sat` | Brightness/saturation offset `−100..100` | | |
| 72 | | `hue` | Hue rotation in degrees | | |
| 73 | | `tint` | Tint color (`tint=ff0000`) | | |
| 74 | | `rot` | Rotate by degrees (`bg` fills the corners) | | |
| 75 | | `flip`, `flop` | Vertical / horizontal mirror | | |
| 76 | | `bg` | Background color for `fit=fill` and `rot` (hex or `white`/`black`/`transparent`) | | |
| 77 | ||
| 78 | ## Options | |
| 79 | ||
| 80 | ```ts | |
| 81 | await startProxy({ | |
| 82 | from: 'localhost:5173', | |
| 83 | to: 'my-app.localhost', | |
| 84 | imgx: { | |
| 85 | quality: 80, // encode quality when the request has no `q` | |
| 86 | maxWidth: 8192, // clamp on requested output width | |
| 87 | maxHeight: 8192, // clamp on requested output height | |
| 88 | maxInputBytes: 32 * 1024 * 1024, // larger responses pass through untouched | |
| 89 | cache: true, // cache transformed variants in memory | |
| 90 | cacheMaxBytes: 64 * 1024 * 1024, // total cache byte budget | |
| 91 | cacheMaxEntries: 500, // max cached variants | |
| 92 | }, | |
| 93 | }) | |
| 94 | ``` | |
| 95 | ||
| 96 | ## Behavior notes | |
| 97 | ||
| 98 | - Only `GET` requests with a `200` image response (`jpeg`, `png`, `gif`, `bmp`, `webp`, `avif`) and at least one transform param are touched — everything else streams through untouched. | |
| 99 | - Transformed variants are cached in memory, keyed by path, params, and a hash of the upstream bytes: an unchanged image is never re-encoded twice, and a changed one misses naturally (no TTLs to tune). Cache state is visible in the `x-rpx-imgx: hit|miss` response header. | |
| 100 | - Any decode/encode failure falls back to serving the original bytes — the integration can slow an image down, but never break it. | |
| 101 | - Upstream `ETag`/`Last-Modified` headers are dropped from transformed responses (they described the original entity); `Cache-Control` is passed through. `auto=format` responses set `Vary: accept`. | |
| 102 | - WebP/AVIF encoding uses system `cwebp`/`avifenc` when available and falls back to the bundled pure-TS encoders; `auto=format` deliberately negotiates WebP (not AVIF) for that reason. | |
| @@ -54,6 +54,7 @@ interface CLIOptions { | ||
| 54 | 54 | verbose?: boolean |
| 55 | 55 | viaDaemon?: boolean |
| 56 | 56 | id?: string |
| 57 | imgx?: boolean | |
| 57 | 58 | } |
| 58 | 59 | |
| 59 | 60 | cli |
| @@ -69,6 +70,7 @@ cli | ||
| 69 | 70 | .option('--start-cwd <path>', 'Current working directory for the dev server') |
| 70 | 71 | .option('--start-env <env>', 'Environment variables for the dev server') |
| 71 | 72 | .option('--change-origin', 'Change the origin of the host header to the target URL') |
| 73 | .option('--imgx', 'Transform image responses on the fly from imgix-style query params (?w=200&h=1200&q=80) via imgx') | |
| 72 | 74 | .option('--single-port-mode', 'Route all proxies through one shared listener (by Host header) instead of a port per proxy') |
| 73 | 75 | .option('--http-port <port>', 'Port for the shared HTTP listener / redirect (default: 80)') |
| 74 | 76 | .option('--https-port <port>', 'Port for the shared HTTPS listener (default: 443)') |
| @@ -101,6 +103,7 @@ cli | ||
| 101 | 103 | }, |
| 102 | 104 | verbose: options.verbose || false, |
| 103 | 105 | changeOrigin: options.changeOrigin || false, |
| 106 | imgx: options.imgx || false, | |
| 104 | 107 | singlePortMode: options.singlePortMode || false, |
| 105 | 108 | httpPort: options.httpPort !== undefined ? Number(options.httpPort) : undefined, |
| 106 | 109 | httpsPort: options.httpsPort !== undefined ? Number(options.httpsPort) : undefined, |
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | * |
| 11 | 11 | * The daemon's PID-GC reaps anything we miss if this process dies `kill -9`. |
| 12 | 12 | */ |
| 13 | import type { LoadBalancerConfig, PathRewrite, ProxyFrom, StaticRouteConfig } from './types' | |
| 13 | import type { ImgxOptions, LoadBalancerConfig, PathRewrite, ProxyFrom, StaticRouteConfig } from './types' | |
| 14 | 14 | import * as fs from 'node:fs' |
| 15 | 15 | import * as path from 'node:path' |
| 16 | 16 | import * as process from 'node:process' |
| @@ -36,6 +36,8 @@ export interface DaemonRunnerProxy { | ||
| 36 | 36 | path?: string |
| 37 | 37 | cleanUrls?: boolean |
| 38 | 38 | changeOrigin?: boolean |
| 39 | /** Transform image responses from imgix-style query params — see {@link ImgxOptions}. */ | |
| 40 | imgx?: boolean | ImgxOptions | |
| 39 | 41 | pathRewrites?: PathRewrite[] |
| 40 | 42 | /** Serve a local directory for this route instead of proxying. */ |
| 41 | 43 | static?: string | StaticRouteConfig |
| @@ -118,6 +120,7 @@ export async function runViaDaemon(opts: DaemonRunnerOptions): Promise<void> { | ||
| 118 | 120 | createdAt, |
| 119 | 121 | cleanUrls: p.cleanUrls, |
| 120 | 122 | changeOrigin: p.changeOrigin, |
| 123 | imgx: p.imgx, | |
| 121 | 124 | pathRewrites: p.pathRewrites, |
| 122 | 125 | static: p.static, |
| 123 | 126 | loadBalancer: p.loadBalancer, |
| @@ -37,6 +37,7 @@ import { | ||
| 37 | 37 | import { createUpstreamPool, primaryUpstreamUrl, resolveUpstreamUrls, startHealthChecks, stopHealthChecks } from './load-balancer' |
| 38 | 38 | import type { UpstreamPool } from './load-balancer' |
| 39 | 39 | import { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler' |
| 40 | import { resolveImgx } from './imgx' | |
| 40 | 41 | import { readAcmeChallenge } from './acme-challenge' |
| 41 | 42 | import { buildHostRoutes, matchHostList, matchHostRoute, normalizePathPrefix } from './host-routes' |
| 42 | 43 | import type { HostRoutes } from './host-routes' |
| @@ -280,6 +281,7 @@ export function entryToRoute(entry: RegistryEntry): ProxyRoute { | ||
| 280 | 281 | return { |
| 281 | 282 | static: resolveStaticRoute(entry.static, cleanUrls), |
| 282 | 283 | cleanUrls, |
| 284 | imgx: resolveImgx(entry.imgx), | |
| 283 | 285 | basePath, |
| 284 | 286 | auth, |
| 285 | 287 | } |
| @@ -308,6 +310,7 @@ export function entryToRoute(entry: RegistryEntry): ProxyRoute { | ||
| 308 | 310 | upstreamPool: pool, |
| 309 | 311 | cleanUrls, |
| 310 | 312 | changeOrigin: entry.changeOrigin ?? false, |
| 313 | imgx: resolveImgx(entry.imgx), | |
| 311 | 314 | pathRewrites: entry.pathRewrites, |
| 312 | 315 | basePath, |
| 313 | 316 | auth, |
packages/rpx/src/imgx.tsadded+669-0
| @@ -0,0 +1,669 @@ | ||
| 1 | /** | |
| 2 | * imgx integration: on-the-fly image transformation driven by query params. | |
| 3 | * | |
| 4 | * When a route enables `imgx`, the proxy inspects image responses whose request | |
| 5 | * carries imgix/meema-style params (`?w=200&h=1200&q=80&format=webp`, see | |
| 6 | * {@link parseImgxParams}) and re-encodes the upstream bytes through the | |
| 7 | * pure-TypeScript `ts-images` pipeline (decode → resize/ops → encode) before | |
| 8 | * answering. The upstream itself never sees anything but the original request, | |
| 9 | * so any dev server, static dir, or app gains image resizing for free. | |
| 10 | * | |
| 11 | * Design constraints: | |
| 12 | * - Never break serving: any parse/decode/encode failure falls back to the | |
| 13 | * original upstream bytes (the response was already buffered by then). | |
| 14 | * - Never buffer unbounded: responses larger than `maxInputBytes` pass through | |
| 15 | * untouched, still streaming. | |
| 16 | * - Never re-encode twice: transformed variants are cached in-memory, keyed by | |
| 17 | * path + normalized params + a hash of the upstream bytes — an unchanged | |
| 18 | * upstream is a cache hit, a changed one misses naturally, with no TTLs. | |
| 19 | * - `ts-images` is imported lazily on the first transform so enabling the | |
| 20 | * integration costs nothing at startup and rpx works without the package | |
| 21 | * installed (transforms just pass through, with a one-time warning). | |
| 22 | */ | |
| 23 | import type { ImgxOptions } from './types' | |
| 24 | import { debugLog } from './utils' | |
| 25 | ||
| 26 | /** Query params that trigger a transform (as opposed to only modifying one). */ | |
| 27 | const TRIGGER_PARAMS = /[?&](?:w|h|width|height|q|fm|format|auto|blur|sharp|greyscale|grayscale|bri|sat|hue|tint|rot|flip|flop|lossless)=/ | |
| 28 | ||
| 29 | /** Response content types the ts-images codecs can decode. */ | |
| 30 | const DECODABLE_TYPES = new Set([ | |
| 31 | 'image/jpeg', | |
| 32 | 'image/jpg', | |
| 33 | 'image/png', | |
| 34 | 'image/gif', | |
| 35 | 'image/bmp', | |
| 36 | 'image/webp', | |
| 37 | 'image/avif', | |
| 38 | ]) | |
| 39 | ||
| 40 | /** Path extensions accepted when the upstream sends no content-type. */ | |
| 41 | const DECODABLE_EXTENSIONS: Record<string, string> = { | |
| 42 | '.jpg': 'image/jpeg', | |
| 43 | '.jpeg': 'image/jpeg', | |
| 44 | '.png': 'image/png', | |
| 45 | '.gif': 'image/gif', | |
| 46 | '.bmp': 'image/bmp', | |
| 47 | '.webp': 'image/webp', | |
| 48 | '.avif': 'image/avif', | |
| 49 | } | |
| 50 | ||
| 51 | type OutputFormat = 'jpeg' | 'png' | 'webp' | 'avif' | 'gif' | 'bmp' | |
| 52 | ||
| 53 | const OUTPUT_FORMATS = new Set<string>(['jpeg', 'png', 'webp', 'avif', 'gif', 'bmp']) | |
| 54 | ||
| 55 | /** imgix `fit` values rpx implements (`clamp`→clip and `fillmax`→fill degrade). */ | |
| 56 | type FitMode = 'clip' | 'max' | 'min' | 'crop' | 'scale' | 'fill' | |
| 57 | ||
| 58 | type CropPosition = 'center' | 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | |
| 59 | ||
| 60 | interface Rgba { r: number, g: number, b: number, a?: number } | |
| 61 | ||
| 62 | /** A request's parsed transform, normalized and clamped. */ | |
| 63 | export interface ImgxTransform { | |
| 64 | width?: number | |
| 65 | height?: number | |
| 66 | fit: FitMode | |
| 67 | position: CropPosition | |
| 68 | aspectRatio?: number | |
| 69 | dpr: number | |
| 70 | quality?: number | |
| 71 | format?: OutputFormat | |
| 72 | autoFormat: boolean | |
| 73 | lossless?: boolean | |
| 74 | blur?: number | |
| 75 | sharpen: boolean | |
| 76 | grayscale: boolean | |
| 77 | brightness?: number | |
| 78 | saturation?: number | |
| 79 | hue?: number | |
| 80 | tint?: Rgba | |
| 81 | rotate?: number | |
| 82 | flip: boolean | |
| 83 | flop: boolean | |
| 84 | background?: Rgba | |
| 85 | } | |
| 86 | ||
| 87 | export interface ResolvedImgxOptions { | |
| 88 | /** Encode quality used when the request has no `q`. */ | |
| 89 | quality: number | |
| 90 | /** Upper clamp for requested output width. */ | |
| 91 | maxWidth: number | |
| 92 | /** Upper clamp for requested output height. */ | |
| 93 | maxHeight: number | |
| 94 | /** Responses larger than this pass through untransformed (still streaming). */ | |
| 95 | maxInputBytes: number | |
| 96 | /** In-memory cache of transformed variants for this route, or null when disabled. */ | |
| 97 | cache: ImgxCache | null | |
| 98 | } | |
| 99 | ||
| 100 | /** | |
| 101 | * Bounded LRU for transformed variants. Keys embed a hash of the source bytes, | |
| 102 | * so entries never go stale — a changed upstream simply misses. | |
| 103 | */ | |
| 104 | export class ImgxCache { | |
| 105 | private entries = new Map<string, { bytes: Uint8Array, contentType: string }>() | |
| 106 | private totalBytes = 0 | |
| 107 | ||
| 108 | constructor(private maxBytes: number, private maxEntries: number) {} | |
| 109 | ||
| 110 | get(key: string): { bytes: Uint8Array, contentType: string } | undefined { | |
| 111 | const hit = this.entries.get(key) | |
| 112 | if (hit) { | |
| 113 | // Refresh recency: Map iteration order is insertion order. | |
| 114 | this.entries.delete(key) | |
| 115 | this.entries.set(key, hit) | |
| 116 | } | |
| 117 | return hit | |
| 118 | } | |
| 119 | ||
| 120 | set(key: string, value: { bytes: Uint8Array, contentType: string }): void { | |
| 121 | if (value.bytes.byteLength > this.maxBytes) | |
| 122 | return | |
| 123 | const existing = this.entries.get(key) | |
| 124 | if (existing) { | |
| 125 | this.entries.delete(key) | |
| 126 | this.totalBytes -= existing.bytes.byteLength | |
| 127 | } | |
| 128 | this.entries.set(key, value) | |
| 129 | this.totalBytes += value.bytes.byteLength | |
| 130 | while (this.entries.size > this.maxEntries || this.totalBytes > this.maxBytes) { | |
| 131 | const oldest = this.entries.keys().next().value | |
| 132 | if (oldest === undefined) | |
| 133 | break | |
| 134 | this.totalBytes -= this.entries.get(oldest)!.bytes.byteLength | |
| 135 | this.entries.delete(oldest) | |
| 136 | } | |
| 137 | } | |
| 138 | ||
| 139 | get size(): number { | |
| 140 | return this.entries.size | |
| 141 | } | |
| 142 | } | |
| 143 | ||
| 144 | /** | |
| 145 | * Resolve a route's `imgx` config value into runtime options. `false`/absent | |
| 146 | * disables the integration for the route. | |
| 147 | */ | |
| 148 | export function resolveImgx(value: boolean | ImgxOptions | undefined): ResolvedImgxOptions | undefined { | |
| 149 | if (!value) | |
| 150 | return undefined | |
| 151 | const opts = value === true ? {} : value | |
| 152 | const cacheEnabled = opts.cache !== false | |
| 153 | return { | |
| 154 | quality: clampInt(opts.quality, 1, 100) ?? 80, | |
| 155 | maxWidth: clampInt(opts.maxWidth, 2, 16384) ?? 8192, | |
| 156 | maxHeight: clampInt(opts.maxHeight, 2, 16384) ?? 8192, | |
| 157 | maxInputBytes: opts.maxInputBytes && opts.maxInputBytes > 0 ? opts.maxInputBytes : 32 * 1024 * 1024, | |
| 158 | cache: cacheEnabled | |
| 159 | ? new ImgxCache( | |
| 160 | opts.cacheMaxBytes && opts.cacheMaxBytes > 0 ? opts.cacheMaxBytes : 64 * 1024 * 1024, | |
| 161 | opts.cacheMaxEntries && opts.cacheMaxEntries > 0 ? opts.cacheMaxEntries : 500, | |
| 162 | ) | |
| 163 | : null, | |
| 164 | } | |
| 165 | } | |
| 166 | ||
| 167 | /** Cheap pre-check so requests without transform params never pay for parsing. */ | |
| 168 | export function hasImgxParams(search: string): boolean { | |
| 169 | return search.length > 1 && TRIGGER_PARAMS.test(search) | |
| 170 | } | |
| 171 | ||
| 172 | function clampInt(value: number | undefined, min: number, max: number): number | undefined { | |
| 173 | if (value === undefined || !Number.isFinite(value)) | |
| 174 | return undefined | |
| 175 | return Math.min(max, Math.max(min, Math.round(value))) | |
| 176 | } | |
| 177 | ||
| 178 | function parseNumber(value: string | null): number | undefined { | |
| 179 | if (value === null || value === '') | |
| 180 | return undefined | |
| 181 | const n = Number(value) | |
| 182 | return Number.isFinite(n) ? n : undefined | |
| 183 | } | |
| 184 | ||
| 185 | /** `''`, `1`, `true`, `yes` → true (bare `?flip` arrives as an empty string). */ | |
| 186 | function parseBool(value: string | null): boolean { | |
| 187 | if (value === null) | |
| 188 | return false | |
| 189 | return value === '' || value === '1' || value === 'true' || value === 'yes' | |
| 190 | } | |
| 191 | ||
| 192 | /** | |
| 193 | * Dimension params accept absolute pixels or, imgix-style, a 0–1 fraction of | |
| 194 | * the source dimension (resolved against the decoded image later). | |
| 195 | */ | |
| 196 | function parseDimension(value: string | null): number | undefined { | |
| 197 | const n = parseNumber(value) | |
| 198 | if (n === undefined || n <= 0) | |
| 199 | return undefined | |
| 200 | return n | |
| 201 | } | |
| 202 | ||
| 203 | /** `#rgb`, `#rrggbb`, `#rrggbbaa` (leading `#` optional) or a few CSS keywords. */ | |
| 204 | function parseColor(value: string | null): Rgba | undefined { | |
| 205 | if (!value) | |
| 206 | return undefined | |
| 207 | const keywords: Record<string, Rgba> = { | |
| 208 | white: { r: 255, g: 255, b: 255 }, | |
| 209 | black: { r: 0, g: 0, b: 0 }, | |
| 210 | transparent: { r: 0, g: 0, b: 0, a: 0 }, | |
| 211 | } | |
| 212 | const keyword = keywords[value.toLowerCase()] | |
| 213 | if (keyword) | |
| 214 | return keyword | |
| 215 | const hex = value.startsWith('#') ? value.slice(1) : value | |
| 216 | if (!/^[0-9a-f]+$/i.test(hex)) | |
| 217 | return undefined | |
| 218 | if (hex.length === 3 || hex.length === 4) { | |
| 219 | const [r, g, b, a] = hex.split('').map(c => Number.parseInt(c + c, 16)) | |
| 220 | return { r, g, b, ...(a === undefined ? {} : { a }) } | |
| 221 | } | |
| 222 | if (hex.length === 6 || hex.length === 8) { | |
| 223 | const r = Number.parseInt(hex.slice(0, 2), 16) | |
| 224 | const g = Number.parseInt(hex.slice(2, 4), 16) | |
| 225 | const b = Number.parseInt(hex.slice(4, 6), 16) | |
| 226 | const a = hex.length === 8 ? Number.parseInt(hex.slice(6, 8), 16) : undefined | |
| 227 | return { r, g, b, ...(a === undefined ? {} : { a }) } | |
| 228 | } | |
| 229 | return undefined | |
| 230 | } | |
| 231 | ||
| 232 | /** `crop=top,left` → ts-images' `top-left` anchor (unsupported modes → center). */ | |
| 233 | function parseCropPosition(value: string | null): CropPosition { | |
| 234 | if (!value) | |
| 235 | return 'center' | |
| 236 | const parts = new Set(value.split(',').map(p => p.trim().toLowerCase())) | |
| 237 | const vertical = parts.has('top') ? 'top' : parts.has('bottom') ? 'bottom' : '' | |
| 238 | const horizontal = parts.has('left') ? 'left' : parts.has('right') ? 'right' : '' | |
| 239 | if (vertical && horizontal) | |
| 240 | return `${vertical}-${horizontal}` as CropPosition | |
| 241 | if (vertical) | |
| 242 | return vertical as CropPosition | |
| 243 | if (horizontal) | |
| 244 | return horizontal as CropPosition | |
| 245 | return 'center' | |
| 246 | } | |
| 247 | ||
| 248 | /** | |
| 249 | * Parse the meema/imgix-compatible transform params out of a query string | |
| 250 | * (`search` includes the leading `?`). Returns `undefined` when no param that | |
| 251 | * triggers a transform is present — modifier-only queries (`?fit=crop`) and | |
| 252 | * unrelated queries cost nothing downstream. | |
| 253 | * | |
| 254 | * Supported params: `w`/`h` (px, or a 0–1 fraction of the source), `fit` | |
| 255 | * (`clip|max|min|crop|scale|fill`), `crop` (position keywords), `ar` (`16:9`), | |
| 256 | * `dpr`, `q`, `fm`/`format` (+`auto`), `auto=format|compress`, `lossless`, | |
| 257 | * `blur` (imgix 0–2000 scale), `sharp`, `greyscale`/`grayscale`, `bri`/`sat` | |
| 258 | * (−100..100), `hue` (degrees), `tint`, `rot`, `flip`, `flop`, `bg`. | |
| 259 | */ | |
| 260 | export function parseImgxParams(search: string): ImgxTransform | undefined { | |
| 261 | if (!hasImgxParams(search)) | |
| 262 | return undefined | |
| 263 | const params = new URLSearchParams(search.slice(1)) | |
| 264 | ||
| 265 | const auto = new Set((params.get('auto') ?? '').split(',').map(v => v.trim().toLowerCase()).filter(Boolean)) | |
| 266 | ||
| 267 | let format: OutputFormat | undefined | |
| 268 | let autoFormat = auto.has('format') | |
| 269 | const rawFormat = (params.get('fm') ?? params.get('format'))?.toLowerCase() | |
| 270 | if (rawFormat === 'auto') | |
| 271 | autoFormat = true | |
| 272 | else if (rawFormat === 'jpg') | |
| 273 | format = 'jpeg' | |
| 274 | else if (rawFormat && OUTPUT_FORMATS.has(rawFormat)) | |
| 275 | format = rawFormat as OutputFormat | |
| 276 | ||
| 277 | let fit = (params.get('fit') ?? params.get('f') ?? 'clip').toLowerCase() | |
| 278 | // imgix modes without a ts-images equivalent degrade to their closest match. | |
| 279 | if (fit === 'clamp') | |
| 280 | fit = 'clip' | |
| 281 | if (fit === 'fillmax') | |
| 282 | fit = 'fill' | |
| 283 | if (!['clip', 'max', 'min', 'crop', 'scale', 'fill'].includes(fit)) | |
| 284 | fit = 'clip' | |
| 285 | ||
| 286 | let aspectRatio: number | undefined | |
| 287 | const ar = params.get('ar') | |
| 288 | if (ar) { | |
| 289 | const match = ar.match(/^(\d+(?:\.\d+)?):(\d+(?:\.\d+)?)$/) | |
| 290 | if (match) { | |
| 291 | const [, arw, arh] = match | |
| 292 | const ratio = Number(arw) / Number(arh) | |
| 293 | if (Number.isFinite(ratio) && ratio > 0) | |
| 294 | aspectRatio = ratio | |
| 295 | } | |
| 296 | } | |
| 297 | ||
| 298 | const quality = clampInt(parseNumber(params.get('q')), 1, 100) | |
| 299 | const compressQuality = auto.has('compress') && quality === undefined ? 75 : undefined | |
| 300 | ||
| 301 | const rawBlur = parseNumber(params.get('blur')) | |
| 302 | ||
| 303 | const transform: ImgxTransform = { | |
| 304 | width: parseDimension(params.get('w') ?? params.get('width')), | |
| 305 | height: parseDimension(params.get('h') ?? params.get('height')), | |
| 306 | fit: fit as FitMode, | |
| 307 | position: parseCropPosition(params.get('crop')), | |
| 308 | aspectRatio, | |
| 309 | dpr: Math.min(5, Math.max(0.1, parseNumber(params.get('dpr')) ?? 1)), | |
| 310 | quality: quality ?? compressQuality, | |
| 311 | format, | |
| 312 | autoFormat, | |
| 313 | lossless: params.has('lossless') ? parseBool(params.get('lossless')) : undefined, | |
| 314 | // imgix blur is 0–2000; ts-images takes a gaussian sigma. Same remap meema uses. | |
| 315 | blur: rawBlur && rawBlur > 0 ? Math.min(2000, rawBlur) * 0.22 : undefined, | |
| 316 | sharpen: parseBool(params.get('sharp')), | |
| 317 | grayscale: parseBool(params.get('greyscale')) || parseBool(params.get('grayscale')), | |
| 318 | // imgix bri/sat are −100..100 offsets; ts-images modulate takes multipliers. | |
| 319 | brightness: mapOffsetToMultiplier(parseNumber(params.get('bri'))), | |
| 320 | saturation: mapOffsetToMultiplier(parseNumber(params.get('sat'))), | |
| 321 | hue: parseNumber(params.get('hue')), | |
| 322 | tint: parseColor(params.get('tint')), | |
| 323 | rotate: parseNumber(params.get('rot')), | |
| 324 | flip: parseBool(params.get('flip')), | |
| 325 | flop: parseBool(params.get('flop')), | |
| 326 | background: parseColor(params.get('bg')), | |
| 327 | } | |
| 328 | ||
| 329 | const triggers = transform.width !== undefined | |
| 330 | || transform.height !== undefined | |
| 331 | || transform.quality !== undefined | |
| 332 | || transform.format !== undefined | |
| 333 | || transform.autoFormat | |
| 334 | || transform.lossless !== undefined | |
| 335 | || transform.blur !== undefined | |
| 336 | || transform.sharpen | |
| 337 | || transform.grayscale | |
| 338 | || transform.brightness !== undefined | |
| 339 | || transform.saturation !== undefined | |
| 340 | || transform.hue !== undefined | |
| 341 | || transform.tint !== undefined | |
| 342 | || transform.rotate !== undefined | |
| 343 | || transform.flip | |
| 344 | || transform.flop | |
| 345 | return triggers ? transform : undefined | |
| 346 | } | |
| 347 | ||
| 348 | function mapOffsetToMultiplier(offset: number | undefined): number | undefined { | |
| 349 | if (offset === undefined) | |
| 350 | return undefined | |
| 351 | return Math.max(0, 1 + Math.min(100, Math.max(-100, offset)) / 100) | |
| 352 | } | |
| 353 | ||
| 354 | /** | |
| 355 | * Lazy `ts-images` loader. The import is deferred to the first transform and | |
| 356 | * failures are remembered — a missing package means transforms pass through | |
| 357 | * with a single warning instead of a per-request error. | |
| 358 | */ | |
| 359 | type TsImages = typeof import('ts-images') | |
| 360 | let tsImagesPromise: Promise<TsImages | null> | undefined | |
| 361 | ||
| 362 | function loadTsImages(verbose?: boolean): Promise<TsImages | null> { | |
| 363 | tsImagesPromise ??= import('ts-images').catch((err) => { | |
| 364 | console.warn(`[rpx] imgx integration disabled: failed to load ts-images (${err instanceof Error ? err.message : err})`) | |
| 365 | debugLog('imgx', `ts-images import failed: ${err}`, verbose) | |
| 366 | return null | |
| 367 | }) | |
| 368 | return tsImagesPromise | |
| 369 | } | |
| 370 | ||
| 371 | /** Test hook: reset the memoized ts-images import. */ | |
| 372 | export function resetImgxRuntime(): void { | |
| 373 | tsImagesPromise = undefined | |
| 374 | } | |
| 375 | ||
| 376 | function contentTypeOf(res: Response): string | undefined { | |
| 377 | const raw = res.headers.get('content-type') | |
| 378 | if (!raw) | |
| 379 | return undefined | |
| 380 | const semi = raw.indexOf(';') | |
| 381 | return (semi === -1 ? raw : raw.slice(0, semi)).trim().toLowerCase() | |
| 382 | } | |
| 383 | ||
| 384 | function extensionType(pathname: string): string | undefined { | |
| 385 | const dot = pathname.lastIndexOf('.') | |
| 386 | if (dot === -1) | |
| 387 | return undefined | |
| 388 | return DECODABLE_EXTENSIONS[pathname.slice(dot).toLowerCase()] | |
| 389 | } | |
| 390 | ||
| 391 | /** | |
| 392 | * Read a body into memory up to `cap` bytes. When the cap is exceeded the | |
| 393 | * already-read chunks are stitched back in front of the remaining stream so the | |
| 394 | * caller can return an untouched passthrough response. | |
| 395 | */ | |
| 396 | async function bufferBody(res: Response, cap: number): Promise<{ bytes: Uint8Array } | { overflow: ReadableStream<Uint8Array> | null }> { | |
| 397 | if (!res.body) | |
| 398 | return { overflow: null } | |
| 399 | const reader = res.body.getReader() | |
| 400 | const chunks: Uint8Array[] = [] | |
| 401 | let total = 0 | |
| 402 | while (true) { | |
| 403 | const { done, value } = await reader.read() | |
| 404 | if (done) | |
| 405 | break | |
| 406 | chunks.push(value) | |
| 407 | total += value.byteLength | |
| 408 | if (total > cap) { | |
| 409 | const replay = new ReadableStream<Uint8Array>({ | |
| 410 | async start(controller) { | |
| 411 | for (const chunk of chunks) | |
| 412 | controller.enqueue(chunk) | |
| 413 | }, | |
| 414 | async pull(controller) { | |
| 415 | const { done: d, value: v } = await reader.read() | |
| 416 | if (d) | |
| 417 | controller.close() | |
| 418 | else | |
| 419 | controller.enqueue(v) | |
| 420 | }, | |
| 421 | cancel(reason) { | |
| 422 | return reader.cancel(reason) | |
| 423 | }, | |
| 424 | }) | |
| 425 | return { overflow: replay } | |
| 426 | } | |
| 427 | } | |
| 428 | const out = new Uint8Array(total) | |
| 429 | let offset = 0 | |
| 430 | for (const chunk of chunks) { | |
| 431 | out.set(chunk, offset) | |
| 432 | offset += chunk.byteLength | |
| 433 | } | |
| 434 | return { bytes: out } | |
| 435 | } | |
| 436 | ||
| 437 | /** Compute the exact output dimensions for a fit mode (rpx does its own math | |
| 438 | * so imgix semantics don't depend on ts-images' internal dimension rules). */ | |
| 439 | function planResize( | |
| 440 | srcW: number, | |
| 441 | srcH: number, | |
| 442 | t: ImgxTransform, | |
| 443 | opts: ResolvedImgxOptions, | |
| 444 | ): { width: number, height: number, fit: 'fill' | 'cover' | 'contain' } | undefined { | |
| 445 | let w = t.width | |
| 446 | let h = t.height | |
| 447 | if (w === undefined && h === undefined) | |
| 448 | return undefined | |
| 449 | ||
| 450 | // 0–1 fractions are relative to the source dimension. | |
| 451 | if (w !== undefined && w < 1) | |
| 452 | w = srcW * w | |
| 453 | if (h !== undefined && h < 1) | |
| 454 | h = srcH * h | |
| 455 | ||
| 456 | // Derive the missing dimension from `ar` (crop workflows) or the source AR. | |
| 457 | const aspect = srcW / srcH | |
| 458 | if (w === undefined) | |
| 459 | w = h! * (t.aspectRatio ?? aspect) | |
| 460 | else if (h === undefined) | |
| 461 | h = w / (t.aspectRatio ?? aspect) | |
| 462 | ||
| 463 | w *= t.dpr | |
| 464 | h! *= t.dpr | |
| 465 | ||
| 466 | w = Math.min(w, opts.maxWidth) | |
| 467 | h = Math.min(h!, opts.maxHeight) | |
| 468 | ||
| 469 | switch (t.fit) { | |
| 470 | case 'scale': | |
| 471 | // Distort to the exact box. | |
| 472 | break | |
| 473 | case 'crop': | |
| 474 | // Fill the box, cropping overflow at `position`. | |
| 475 | return { width: dim(w), height: dim(h), fit: 'cover' } | |
| 476 | case 'min': { | |
| 477 | // Like crop, but never enlarge beyond the source. | |
| 478 | const k = Math.min(srcW / w, srcH / h, 1) | |
| 479 | return { width: dim(w * k), height: dim(h * k), fit: 'cover' } | |
| 480 | } | |
| 481 | case 'fill': | |
| 482 | // Letterbox to the exact box with `bg` padding. | |
| 483 | return { width: dim(w), height: dim(h), fit: 'contain' } | |
| 484 | case 'max': | |
| 485 | case 'clip': { | |
| 486 | // Preserve AR inside the box; `max` additionally never enlarges. | |
| 487 | let scale = Math.min(w / srcW, h / srcH) | |
| 488 | if (t.fit === 'max') | |
| 489 | scale = Math.min(scale, 1) | |
| 490 | return { width: dim(srcW * scale), height: dim(srcH * scale), fit: 'fill' } | |
| 491 | } | |
| 492 | } | |
| 493 | return { width: dim(w), height: dim(h), fit: 'fill' } | |
| 494 | } | |
| 495 | ||
| 496 | function dim(value: number): number { | |
| 497 | return Math.max(1, Math.round(value)) | |
| 498 | } | |
| 499 | ||
| 500 | const FORMAT_CONTENT_TYPES: Record<OutputFormat, string> = { | |
| 501 | jpeg: 'image/jpeg', | |
| 502 | png: 'image/png', | |
| 503 | webp: 'image/webp', | |
| 504 | avif: 'image/avif', | |
| 505 | gif: 'image/gif', | |
| 506 | bmp: 'image/bmp', | |
| 507 | } | |
| 508 | ||
| 509 | /** | |
| 510 | * Decide the encode format: explicit `fm`/`format` wins; `auto=format` | |
| 511 | * negotiates from `Accept` (webp when accepted — the ts-images avif encoder | |
| 512 | * needs a system `avifenc` to produce real AV1, so rpx doesn't auto-pick it); | |
| 513 | * otherwise keep the source format. | |
| 514 | */ | |
| 515 | function resolveOutputFormat(t: ImgxTransform, sourceFormat: string, accept: string | null): OutputFormat { | |
| 516 | if (t.format) | |
| 517 | return t.format | |
| 518 | if (t.autoFormat && accept && accept.includes('image/webp')) | |
| 519 | return 'webp' | |
| 520 | const normalized = sourceFormat === 'jpg' ? 'jpeg' : sourceFormat | |
| 521 | return OUTPUT_FORMATS.has(normalized) ? normalized as OutputFormat : 'png' | |
| 522 | } | |
| 523 | ||
| 524 | /** Serialize the parts of a transform that affect output bytes into a cache key. */ | |
| 525 | function transformKey(t: ImgxTransform, outputFormat: OutputFormat, quality: number): string { | |
| 526 | return [ | |
| 527 | t.width, t.height, t.fit, t.position, t.aspectRatio, t.dpr, | |
| 528 | outputFormat, quality, t.lossless, | |
| 529 | t.blur, t.sharpen, t.grayscale, t.brightness, t.saturation, t.hue, | |
| 530 | t.tint && `${t.tint.r}.${t.tint.g}.${t.tint.b}`, | |
| 531 | t.rotate, t.flip, t.flop, | |
| 532 | t.background && `${t.background.r}.${t.background.g}.${t.background.b}.${t.background.a ?? 255}`, | |
| 533 | ].join('|') | |
| 534 | } | |
| 535 | ||
| 536 | /** | |
| 537 | * Post-upstream hook: given the request context and the upstream response, | |
| 538 | * return either the transformed image response or the original response | |
| 539 | * untouched. This must never throw — every failure path degrades to the | |
| 540 | * original bytes. | |
| 541 | */ | |
| 542 | export async function applyImgxTransform( | |
| 543 | req: Request, | |
| 544 | pathname: string, | |
| 545 | search: string, | |
| 546 | imgx: ResolvedImgxOptions | undefined, | |
| 547 | res: Response, | |
| 548 | verbose?: boolean, | |
| 549 | ): Promise<Response> { | |
| 550 | if (!imgx || req.method !== 'GET' || res.status !== 200 || res.headers.has('content-range')) | |
| 551 | return res | |
| 552 | ||
| 553 | const transform = parseImgxParams(search) | |
| 554 | if (!transform) | |
| 555 | return res | |
| 556 | ||
| 557 | const contentType = contentTypeOf(res) | |
| 558 | if (contentType ? !DECODABLE_TYPES.has(contentType) : !extensionType(pathname)) | |
| 559 | return res | |
| 560 | ||
| 561 | const declaredLength = Number(res.headers.get('content-length')) | |
| 562 | if (Number.isFinite(declaredLength) && declaredLength > imgx.maxInputBytes) | |
| 563 | return res | |
| 564 | ||
| 565 | let bytes: Uint8Array | |
| 566 | try { | |
| 567 | const buffered = await bufferBody(res, imgx.maxInputBytes) | |
| 568 | if (!('bytes' in buffered)) { | |
| 569 | debugLog('imgx', `response for ${pathname} exceeds maxInputBytes; passing through`, verbose) | |
| 570 | return new Response(buffered.overflow, { status: res.status, statusText: res.statusText, headers: res.headers }) | |
| 571 | } | |
| 572 | bytes = buffered.bytes | |
| 573 | } | |
| 574 | catch (err) { | |
| 575 | debugLog('imgx', `failed to buffer ${pathname}: ${err}`, verbose) | |
| 576 | return new Response(null, { status: 502 }) | |
| 577 | } | |
| 578 | ||
| 579 | const passthrough = () => new Response(bytes, { status: 200, statusText: res.statusText, headers: res.headers }) | |
| 580 | ||
| 581 | const mod = await loadTsImages(verbose) | |
| 582 | if (!mod) | |
| 583 | return passthrough() | |
| 584 | ||
| 585 | try { | |
| 586 | const sourceFormat = await mod.getMetadata(bytes).then(meta => meta.format) | |
| 587 | .catch(() => contentType ? contentType.slice('image/'.length) : '') | |
| 588 | if (!sourceFormat) | |
| 589 | return passthrough() | |
| 590 | ||
| 591 | const outputFormat = resolveOutputFormat(transform, sourceFormat, req.headers.get('accept')) | |
| 592 | const quality = transform.quality ?? imgx.quality | |
| 593 | const cacheKey = `${pathname}|${transformKey(transform, outputFormat, quality)}|${Bun.hash(bytes).toString(36)}` | |
| 594 | ||
| 595 | const cached = imgx.cache?.get(cacheKey) | |
| 596 | if (cached) { | |
| 597 | debugLog('imgx', `cache hit for ${pathname}`, verbose) | |
| 598 | return buildResponse(res, cached.bytes, cached.contentType, transform, 'hit') | |
| 599 | } | |
| 600 | ||
| 601 | let img = await mod.decode(bytes) | |
| 602 | ||
| 603 | if (transform.rotate) | |
| 604 | img = mod.rotate(img, transform.rotate, transform.background ? { background: transform.background } : {}) | |
| 605 | if (transform.flip) | |
| 606 | img = mod.flip(img) | |
| 607 | if (transform.flop) | |
| 608 | img = mod.flop(img) | |
| 609 | ||
| 610 | const plan = planResize(img.width, img.height, transform, imgx) | |
| 611 | if (plan && (plan.width !== img.width || plan.height !== img.height || plan.fit !== 'fill')) { | |
| 612 | img = mod.resize(img, { | |
| 613 | width: plan.width, | |
| 614 | height: plan.height, | |
| 615 | fit: plan.fit, | |
| 616 | position: transform.position, | |
| 617 | background: transform.background ?? { r: 255, g: 255, b: 255 }, | |
| 618 | }) | |
| 619 | } | |
| 620 | ||
| 621 | if (transform.blur) | |
| 622 | img = mod.blur(img, transform.blur) | |
| 623 | if (transform.sharpen) | |
| 624 | img = mod.sharpen(img) | |
| 625 | if (transform.grayscale) | |
| 626 | img = mod.grayscale(img) | |
| 627 | if (transform.brightness !== undefined || transform.saturation !== undefined || transform.hue !== undefined) { | |
| 628 | img = mod.modulate(img, { | |
| 629 | brightness: transform.brightness, | |
| 630 | saturation: transform.saturation, | |
| 631 | hue: transform.hue, | |
| 632 | }) | |
| 633 | } | |
| 634 | if (transform.tint) | |
| 635 | img = mod.tint(img, transform.tint) | |
| 636 | ||
| 637 | const encoded = await mod.encode(img, outputFormat, { | |
| 638 | quality, | |
| 639 | ...(transform.lossless === undefined ? {} : { lossless: transform.lossless }), | |
| 640 | }) | |
| 641 | const outBytes = encoded instanceof Uint8Array ? encoded : new Uint8Array(encoded) | |
| 642 | const outContentType = FORMAT_CONTENT_TYPES[outputFormat] | |
| 643 | ||
| 644 | imgx.cache?.set(cacheKey, { bytes: outBytes, contentType: outContentType }) | |
| 645 | debugLog('imgx', `transformed ${pathname} (${bytes.byteLength}B ${sourceFormat} → ${outBytes.byteLength}B ${outputFormat})`, verbose) | |
| 646 | return buildResponse(res, outBytes, outContentType, transform, 'miss') | |
| 647 | } | |
| 648 | catch (err) { | |
| 649 | debugLog('imgx', `transform failed for ${pathname}, serving original: ${err}`, verbose) | |
| 650 | return passthrough() | |
| 651 | } | |
| 652 | } | |
| 653 | ||
| 654 | /** Assemble the transformed response, carrying upstream headers that still | |
| 655 | * apply and dropping the ones invalidated by re-encoding. */ | |
| 656 | function buildResponse(upstream: Response, bytes: Uint8Array, contentType: string, t: ImgxTransform, cache: 'hit' | 'miss'): Response { | |
| 657 | const headers = new Headers(upstream.headers) | |
| 658 | // The entity changed: upstream validators and framing no longer describe it. | |
| 659 | headers.delete('content-encoding') | |
| 660 | headers.delete('transfer-encoding') | |
| 661 | headers.delete('etag') | |
| 662 | headers.delete('last-modified') | |
| 663 | headers.set('content-type', contentType) | |
| 664 | headers.set('content-length', String(bytes.byteLength)) | |
| 665 | headers.set('x-rpx-imgx', cache) | |
| 666 | if (t.autoFormat && !t.format) | |
| 667 | headers.set('vary', headers.has('vary') ? `${headers.get('vary')}, accept` : 'accept') | |
| 668 | return new Response(bytes, { status: 200, headers }) | |
| 669 | } | |
| @@ -125,6 +125,9 @@ export type { | ||
| 125 | 125 | StopDaemonResult, |
| 126 | 126 | } from './daemon' |
| 127 | 127 | |
| 128 | export { applyImgxTransform, hasImgxParams, parseImgxParams, resolveImgx } from './imgx' | |
| 129 | export type { ImgxTransform, ResolvedImgxOptions } from './imgx' | |
| 130 | ||
| 128 | 131 | export { createProxyFetchHandler, createProxyWebSocketHandler, stripBasePath } from './proxy-handler' |
| 129 | 132 | export type { GetRoute, NoRouteOutcome, OnNoRoute, ProxyFetchHandler, ProxyRoute, ProxyServer } from './proxy-handler' |
| 130 | 133 | |
| @@ -15,11 +15,13 @@ | ||
| 15 | 15 | */ |
| 16 | 16 | import type { ServerWebSocket } from 'bun' |
| 17 | 17 | import type { ResolvedAuth } from './auth' |
| 18 | import type { ResolvedImgxOptions } from './imgx' | |
| 18 | 19 | import type { UpstreamPool, UpstreamState } from './load-balancer' |
| 19 | 20 | import type { ResolvedRedirect } from './redirect' |
| 20 | 21 | import type { ResolvedStaticRoute } from './static-files' |
| 21 | 22 | import type { PathRewrite } from './types' |
| 22 | 23 | import { enforceBasicAuth } from './auth' |
| 24 | import { applyImgxTransform } from './imgx' | |
| 23 | 25 | import { markFailure, markSuccess, selectUpstream } from './load-balancer' |
| 24 | 26 | import { FALLBACK, POOL_BUSY, proxyViaPool, TIMEOUT } from './proxy-pool' |
| 25 | 27 | import { buildRedirectLocation } from './redirect' |
| @@ -47,6 +49,12 @@ export interface ProxyRoute { | ||
| 47 | 49 | changeOrigin?: boolean |
| 48 | 50 | /** Per-route path rewrites (vite/nginx-style prefix routing). */ |
| 49 | 51 | pathRewrites?: PathRewrite[] |
| 52 | /** | |
| 53 | * When set, transform image responses on the fly from imgix/meema-style | |
| 54 | * query params (`?w=200&h=1200&q=80`) via the imgx (ts-images) pipeline. | |
| 55 | * Applies to proxied and static responses alike. | |
| 56 | */ | |
| 57 | imgx?: ResolvedImgxOptions | |
| 50 | 58 | /** When set, serve files from a local directory instead of proxying. */ |
| 51 | 59 | static?: ResolvedStaticRoute |
| 52 | 60 | /** When set, answer with an HTTP redirect (Location) instead of proxying/serving. */ |
| @@ -446,7 +454,8 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean, o | ||
| 446 | 454 | if (route.static) { |
| 447 | 455 | const strip = route.stripBasePathPrefix ?? true |
| 448 | 456 | const staticPath = strip ? stripBasePath(pathname, route.basePath) : pathname |
| 449 | return serveStaticFile(staticPath, route.static) | |
| 457 | const staticRes = await serveStaticFile(staticPath, route.static) | |
| 458 | return applyImgxTransform(req, pathname, search, route.imgx, staticRes, verbose) | |
| 450 | 459 | } |
| 451 | 460 | |
| 452 | 461 | // WebSocket upgrade: hand the socket to Bun and dial the upstream on open. |
| @@ -524,7 +533,7 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean, o | ||
| 524 | 533 | }) |
| 525 | 534 | if (pool) |
| 526 | 535 | markSuccess(pool, upstream) |
| 527 | return res | |
| 536 | return await applyImgxTransform(req, pathname, search, route.imgx, res, verbose) | |
| 528 | 537 | } |
| 529 | 538 | catch (err) { |
| 530 | 539 | // Upstream stalled past the configured timeout → 504 (no fetch retry — it |
| @@ -562,7 +571,7 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean, o | ||
| 562 | 571 | }) |
| 563 | 572 | if (pool) |
| 564 | 573 | markSuccess(pool, upstream) |
| 565 | return res | |
| 574 | return await applyImgxTransform(req, pathname, search, route.imgx, res, verbose) | |
| 566 | 575 | } |
| 567 | 576 | catch (fetchErr) { |
| 568 | 577 | debugLog('request', `Proxy error for ${hostname}: ${fetchErr}`, verbose) |
| @@ -14,7 +14,7 @@ | ||
| 14 | 14 | * - `id` is validated against a strict charset to keep it from escaping |
| 15 | 15 | * the registry directory. |
| 16 | 16 | */ |
| 17 | import type { BasicAuthConfig, LoadBalancerConfig, PathRewrite, ProxyFrom, StaticRouteConfig } from './types' | |
| 17 | import type { BasicAuthConfig, ImgxOptions, LoadBalancerConfig, PathRewrite, ProxyFrom, StaticRouteConfig } from './types' | |
| 18 | 18 | import * as fs from 'node:fs' |
| 19 | 19 | import * as fsp from 'node:fs/promises' |
| 20 | 20 | import { homedir } from 'node:os' |
| @@ -50,6 +50,8 @@ export interface RegistryEntry { | ||
| 50 | 50 | pathRewrites?: PathRewrite[] |
| 51 | 51 | cleanUrls?: boolean |
| 52 | 52 | changeOrigin?: boolean |
| 53 | /** Transform image responses from imgix-style query params — see {@link ImgxOptions}. */ | |
| 54 | imgx?: boolean | ImgxOptions | |
| 53 | 55 | /** Serve a local directory for this route instead of proxying. */ |
| 54 | 56 | static?: string | StaticRouteConfig |
| 55 | 57 | /** Optional HTTP Basic auth gate for this route. */ |
| @@ -300,6 +302,7 @@ export function watchRegistry( | ||
| 300 | 302 | pathRewrites: entry.pathRewrites, |
| 301 | 303 | cleanUrls: entry.cleanUrls, |
| 302 | 304 | changeOrigin: entry.changeOrigin, |
| 305 | imgx: entry.imgx, | |
| 303 | 306 | static: entry.static, |
| 304 | 307 | loadBalancer: entry.loadBalancer, |
| 305 | 308 | })) |
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /* eslint-disable no-console */ |
| 2 | import type { BaseProxyConfig, CleanupOptions, LoadBalancerConfig, ProxyConfig, ProxyFrom, ProxyOption, ProxyOptions, ProxySetupOptions, ResolvedProxyOptions, SingleProxyConfig, SSLConfig, StartOptions } from './types' | |
| 2 | import type { BaseProxyConfig, CleanupOptions, ImgxOptions, LoadBalancerConfig, ProxyConfig, ProxyFrom, ProxyOption, ProxyOptions, ProxySetupOptions, ResolvedProxyOptions, SingleProxyConfig, SSLConfig, StartOptions } from './types' | |
| 3 | 3 | import { exec, execSync } from 'node:child_process' |
| 4 | 4 | import * as fs from 'node:fs' |
| 5 | 5 | import * as http from 'node:http' |
| @@ -15,6 +15,7 @@ import { config } from './config' | ||
| 15 | 15 | import { runViaDaemon } from './daemon-runner' |
| 16 | 16 | import { addHosts, checkHosts, removeHosts } from './hosts' |
| 17 | 17 | import { checkExistingCertificates, cleanupCertificates, generateCertificate, httpsConfig, loadSSLConfig } from './https' |
| 18 | import { resolveImgx } from './imgx' | |
| 18 | 19 | import { createUpstreamPool, primaryUpstreamUrl, startHealthChecks, stopHealthChecks } from './load-balancer' |
| 19 | 20 | import type { UpstreamPool } from './load-balancer' |
| 20 | 21 | import { DefaultPortManager, findAvailablePort, isPortInUse } from './port-manager' |
| @@ -486,6 +487,7 @@ async function createProxyServer( | ||
| 486 | 487 | auth?: ResolvedAuth, |
| 487 | 488 | originalFrom?: ProxyFrom, |
| 488 | 489 | loadBalancer?: LoadBalancerConfig, |
| 490 | imgx?: boolean | ImgxOptions, | |
| 489 | 491 | ): Promise<void> { |
| 490 | 492 | debugLog('proxy', `Creating proxy server ${from} -> ${to} with cleanUrls: ${cleanUrls}`, verbose) |
| 491 | 493 | |
| @@ -511,6 +513,7 @@ async function createProxyServer( | ||
| 511 | 513 | upstreamPool: pool, |
| 512 | 514 | cleanUrls: cleanUrls || false, |
| 513 | 515 | changeOrigin: changeOrigin || false, |
| 516 | imgx: resolveImgx(imgx), | |
| 514 | 517 | basePath: '/', |
| 515 | 518 | auth, |
| 516 | 519 | } as ProxyRoute, |
| @@ -630,7 +633,7 @@ export async function setupProxy(options: ProxySetupOptions): Promise<void> { | ||
| 630 | 633 | debugLog('setup', `Using standard ${targetPort === 443 ? 'HTTPS' : 'HTTP'} port ${targetPort} for ${to}`, verbose) |
| 631 | 634 | } |
| 632 | 635 | |
| 633 | await createProxyServer(from, to, finalPort, sourceUrl, ssl, vitePluginUsage, verbose, cleanUrls, changeOrigin, resolveAuth((options as { auth?: import('./types').BasicAuthConfig }).auth), originalFrom, options.loadBalancer) | |
| 636 | await createProxyServer(from, to, finalPort, sourceUrl, ssl, vitePluginUsage, verbose, cleanUrls, changeOrigin, resolveAuth((options as { auth?: import('./types').BasicAuthConfig }).auth), originalFrom, options.loadBalancer, options.imgx) | |
| 634 | 637 | } |
| 635 | 638 | catch (err) { |
| 636 | 639 | debugLog('setup', `Setup failed: ${err}`, verbose) |
| @@ -735,6 +738,7 @@ export function startProxy(options: ProxyOption): void { | ||
| 735 | 738 | path: mergedOptions.path, |
| 736 | 739 | cleanUrls: mergedOptions.cleanUrls, |
| 737 | 740 | changeOrigin: mergedOptions.changeOrigin, |
| 741 | imgx: mergedOptions.imgx, | |
| 738 | 742 | pathRewrites: mergedOptions.pathRewrites, |
| 739 | 743 | }], |
| 740 | 744 | verbose: mergedOptions.verbose, |
| @@ -796,6 +800,7 @@ export function startProxy(options: ProxyOption): void { | ||
| 796 | 800 | cleanup: mergedOptions.cleanup, |
| 797 | 801 | vitePluginUsage: mergedOptions.vitePluginUsage, |
| 798 | 802 | changeOrigin: mergedOptions.changeOrigin, |
| 803 | imgx: mergedOptions.imgx, | |
| 799 | 804 | verbose: mergedOptions.verbose, |
| 800 | 805 | regenerateUntrustedCerts: mergedOptions.regenerateUntrustedCerts, |
| 801 | 806 | } |
| @@ -855,6 +860,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> { | ||
| 855 | 860 | verbose: false, |
| 856 | 861 | cleanUrls: false, |
| 857 | 862 | changeOrigin: false, |
| 863 | imgx: false, | |
| 858 | 864 | regenerateUntrustedCerts: true, |
| 859 | 865 | } as ResolvedProxyOptions |
| 860 | 866 | |
| @@ -889,7 +895,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> { | ||
| 889 | 895 | if (mergedOptions.viaDaemon) { |
| 890 | 896 | const isMulti = 'proxies' in mergedOptions && Array.isArray(mergedOptions.proxies) |
| 891 | 897 | const proxies = isMulti |
| 892 | ? (mergedOptions.proxies as Array<BaseProxyConfig & { cleanUrls?: boolean, changeOrigin?: boolean }>) | |
| 898 | ? (mergedOptions.proxies as Array<BaseProxyConfig & { cleanUrls?: boolean, changeOrigin?: boolean, imgx?: boolean | ImgxOptions }>) | |
| 893 | 899 | .map(p => ({ |
| 894 | 900 | id: p.id, |
| 895 | 901 | from: p.from, |
| @@ -897,6 +903,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> { | ||
| 897 | 903 | path: p.path, |
| 898 | 904 | cleanUrls: p.cleanUrls ?? mergedOptions.cleanUrls, |
| 899 | 905 | changeOrigin: p.changeOrigin ?? mergedOptions.changeOrigin, |
| 906 | imgx: p.imgx ?? mergedOptions.imgx, | |
| 900 | 907 | pathRewrites: p.pathRewrites, |
| 901 | 908 | })) |
| 902 | 909 | : [{ |
| @@ -906,6 +913,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> { | ||
| 906 | 913 | path: mergedOptions.path, |
| 907 | 914 | cleanUrls: mergedOptions.cleanUrls, |
| 908 | 915 | changeOrigin: mergedOptions.changeOrigin, |
| 916 | imgx: mergedOptions.imgx, | |
| 909 | 917 | pathRewrites: mergedOptions.pathRewrites, |
| 910 | 918 | }] |
| 911 | 919 | await runViaDaemon({ proxies, verbose }) |
| @@ -1080,6 +1088,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> { | ||
| 1080 | 1088 | cleanUrls: proxy.cleanUrls ?? ('cleanUrls' in mergedOptions ? mergedOptions.cleanUrls : false), |
| 1081 | 1089 | vitePluginUsage: mergedOptions.vitePluginUsage, |
| 1082 | 1090 | changeOrigin: proxy.changeOrigin ?? mergedOptions.changeOrigin, |
| 1091 | imgx: proxy.imgx ?? mergedOptions.imgx, | |
| 1083 | 1092 | verbose, |
| 1084 | 1093 | _cachedSSLConfig: mergedOptions._cachedSSLConfig, |
| 1085 | 1094 | } as ProxyOption)) |
| @@ -1092,6 +1101,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> { | ||
| 1092 | 1101 | vitePluginUsage: mergedOptions.vitePluginUsage, |
| 1093 | 1102 | start: ('start' in mergedOptions) ? mergedOptions.start : undefined, |
| 1094 | 1103 | changeOrigin: mergedOptions.changeOrigin, |
| 1104 | imgx: mergedOptions.imgx, | |
| 1095 | 1105 | auth: 'auth' in mergedOptions ? mergedOptions.auth : undefined, |
| 1096 | 1106 | verbose, |
| 1097 | 1107 | _cachedSSLConfig: mergedOptions._cachedSSLConfig, |
| @@ -1355,6 +1365,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> { | ||
| 1355 | 1365 | verbose: option.verbose || false, |
| 1356 | 1366 | _cachedSSLConfig: mergedOptions._cachedSSLConfig, |
| 1357 | 1367 | changeOrigin: option.changeOrigin || false, |
| 1368 | imgx: option.imgx, | |
| 1358 | 1369 | loadBalancer: option.loadBalancer, |
| 1359 | 1370 | auth: option.auth, |
| 1360 | 1371 | path: option.path, |
| @@ -1397,6 +1408,7 @@ export async function collectRouteEntries( | ||
| 1397 | 1408 | const basePath = normalizePathPrefix(routePath) |
| 1398 | 1409 | |
| 1399 | 1410 | const auth = resolveAuth(option.auth) |
| 1411 | const imgx = resolveImgx(option.imgx) | |
| 1400 | 1412 | |
| 1401 | 1413 | // Redirect route: answer with a Location (e.g. an alternate domain → its |
| 1402 | 1414 | // canonical host). Takes precedence over proxy/static — no upstream needed. |
| @@ -1414,7 +1426,7 @@ export async function collectRouteEntries( | ||
| 1414 | 1426 | routeEntries.push({ |
| 1415 | 1427 | host: domain, |
| 1416 | 1428 | path: routePath, |
| 1417 | route: { static: resolveStaticRoute(option.static, cleanUrls), cleanUrls, basePath, auth }, | |
| 1429 | route: { static: resolveStaticRoute(option.static, cleanUrls), cleanUrls, imgx, basePath, auth }, | |
| 1418 | 1430 | }) |
| 1419 | 1431 | debugLog('proxies', `Route: ${domain}${routePath ?? ''} → static ${typeof option.static === 'string' ? option.static : option.static.dir}${auth ? ' (auth)' : ''}`, verbose) |
| 1420 | 1432 | } |
| @@ -1435,6 +1447,7 @@ export async function collectRouteEntries( | ||
| 1435 | 1447 | upstreamPool: pool, |
| 1436 | 1448 | cleanUrls, |
| 1437 | 1449 | changeOrigin: option.changeOrigin || false, |
| 1450 | imgx, | |
| 1438 | 1451 | pathRewrites: option.pathRewrites, |
| 1439 | 1452 | basePath, |
| 1440 | 1453 | auth, |
| @@ -404,6 +404,34 @@ export interface LocalCaConfig { | ||
| 404 | 404 | renewBeforeDays?: number |
| 405 | 405 | } |
| 406 | 406 | |
| 407 | /** | |
| 408 | * imgx integration: serve on-the-fly image transforms driven by | |
| 409 | * imgix/meema-style query params (`?w=200&h=1200&q=80&format=webp`), powered by | |
| 410 | * the pure-TypeScript `ts-images` pipeline. `true` enables the defaults; an | |
| 411 | * object tunes them. Enabled globally or per proxy; transformed variants are | |
| 412 | * cached in memory keyed by the upstream bytes, so an unchanged image is never | |
| 413 | * re-encoded twice. | |
| 414 | */ | |
| 415 | export interface ImgxOptions { | |
| 416 | /** Encode quality used when the request has no `q` param. @default 80 */ | |
| 417 | quality?: number | |
| 418 | /** Upper clamp for the requested output width. @default 8192 */ | |
| 419 | maxWidth?: number | |
| 420 | /** Upper clamp for the requested output height. @default 8192 */ | |
| 421 | maxHeight?: number | |
| 422 | /** | |
| 423 | * Responses larger than this many bytes pass through untransformed (they | |
| 424 | * keep streaming; nothing is buffered past the cap). @default 33554432 (32 MiB) | |
| 425 | */ | |
| 426 | maxInputBytes?: number | |
| 427 | /** Cache transformed variants in memory. @default true */ | |
| 428 | cache?: boolean | |
| 429 | /** Total byte budget for the transform cache. @default 67108864 (64 MiB) */ | |
| 430 | cacheMaxBytes?: number | |
| 431 | /** Max entries in the transform cache. @default 500 */ | |
| 432 | cacheMaxEntries?: number | |
| 433 | } | |
| 434 | ||
| 407 | 435 | export interface SharedProxyConfig { |
| 408 | 436 | /** |
| 409 | 437 | * `true` mints and trusts a local dev certificate, a {@link TlsOption} points |
| @@ -419,6 +447,12 @@ export interface SharedProxyConfig { | ||
| 419 | 447 | start?: StartOptions |
| 420 | 448 | cleanUrls: boolean |
| 421 | 449 | changeOrigin?: boolean // default: false - changes the origin of the host header to the target URL |
| 450 | /** | |
| 451 | * Transform image responses on the fly from imgix/meema-style query params | |
| 452 | * (`?w=200&h=1200&q=80`) via the imgx (ts-images) pipeline — see {@link ImgxOptions}. | |
| 453 | * Default: `false`. Overridable per proxy in multi-proxy configs. | |
| 454 | */ | |
| 455 | imgx?: boolean | ImgxOptions | |
| 422 | 456 | regenerateUntrustedCerts?: boolean // If true, will regenerate and re-trust certs that exist but are not trusted by the system. |
| 423 | 457 | /** |
| 424 | 458 | * Route every proxy through a single shared listener instead of binding a |
| @@ -512,7 +546,7 @@ export type SharedProxyOptions = Partial<SharedProxyConfig> | ||
| 512 | 546 | export interface SingleProxyConfig extends BaseProxyConfig, SharedProxyConfig {} |
| 513 | 547 | |
| 514 | 548 | export interface MultiProxyConfig extends SharedProxyConfig { |
| 515 | proxies: Array<BaseProxyConfig & { cleanUrls: boolean, pathRewrites?: PathRewrite[] }> | |
| 549 | proxies: Array<BaseProxyConfig & { cleanUrls: boolean, pathRewrites?: PathRewrite[], imgx?: boolean | ImgxOptions }> | |
| 516 | 550 | } |
| 517 | 551 | |
| 518 | 552 | export type ProxyConfig = SingleProxyConfig |
| @@ -530,7 +564,7 @@ export type ProxyOptions = Partial<SingleProxyConfig> | Partial<MultiProxyConfig | ||
| 530 | 564 | * single and multi shapes without falling back to `any`. |
| 531 | 565 | */ |
| 532 | 566 | export type ResolvedProxyOptions = Partial<SingleProxyConfig> & { |
| 533 | proxies?: Array<BaseProxyConfig & { cleanUrls?: boolean, changeOrigin?: boolean, pathRewrites?: PathRewrite[] }> | |
| 567 | proxies?: Array<BaseProxyConfig & { cleanUrls?: boolean, changeOrigin?: boolean, pathRewrites?: PathRewrite[], imgx?: boolean | ImgxOptions }> | |
| 534 | 568 | } |
| 535 | 569 | |
| 536 | 570 | export interface SSLConfig { |
| @@ -0,0 +1,254 @@ | ||
| 1 | import { afterAll, beforeAll, describe, expect, it } from 'bun:test' | |
| 2 | import * as fsp from 'node:fs/promises' | |
| 3 | import * as os from 'node:os' | |
| 4 | import * as path from 'node:path' | |
| 5 | import { createImageData, decode, encode } from 'ts-images' | |
| 6 | import { ImgxCache, hasImgxParams, parseImgxParams, resolveImgx } from '../src/imgx' | |
| 7 | import { createProxyFetchHandler } from '../src/proxy-handler' | |
| 8 | import { resolveStaticRoute } from '../src/static-files' | |
| 9 | ||
| 10 | function req(url: string, headers: Record<string, string> = {}): Request { | |
| 11 | const u = new URL(url) | |
| 12 | return new Request(url, { headers: { host: u.host, ...headers } }) | |
| 13 | } | |
| 14 | ||
| 15 | /** A solid-color RGBA test image encoded as PNG. */ | |
| 16 | async function makePng(width: number, height: number): Promise<Uint8Array<ArrayBuffer>> { | |
| 17 | const img = createImageData(width, height) | |
| 18 | for (let i = 0; i < img.data.length; i += 4) { | |
| 19 | img.data[i] = 200 | |
| 20 | img.data[i + 1] = 40 | |
| 21 | img.data[i + 2] = 40 | |
| 22 | img.data[i + 3] = 255 | |
| 23 | } | |
| 24 | return new Uint8Array(await encode(img, 'png', {})) | |
| 25 | } | |
| 26 | ||
| 27 | describe('parseImgxParams', () => { | |
| 28 | it('returns undefined without trigger params', () => { | |
| 29 | expect(parseImgxParams('')).toBeUndefined() | |
| 30 | expect(parseImgxParams('?foo=bar')).toBeUndefined() | |
| 31 | // Modifier-only params don't trigger a transform on their own. | |
| 32 | expect(parseImgxParams('?fit=crop&dpr=2&bg=fff')).toBeUndefined() | |
| 33 | }) | |
| 34 | ||
| 35 | it('parses the meema-style core params', () => { | |
| 36 | const t = parseImgxParams('?w=200&h=1200&q=80') | |
| 37 | expect(t).toBeDefined() | |
| 38 | expect(t!.width).toBe(200) | |
| 39 | expect(t!.height).toBe(1200) | |
| 40 | expect(t!.quality).toBe(80) | |
| 41 | expect(t!.fit).toBe('clip') | |
| 42 | expect(t!.dpr).toBe(1) | |
| 43 | }) | |
| 44 | ||
| 45 | it('clamps quality and dpr, and keeps 0-1 fractions', () => { | |
| 46 | expect(parseImgxParams('?q=500')!.quality).toBe(100) | |
| 47 | expect(parseImgxParams('?q=0')!.quality).toBe(1) | |
| 48 | expect(parseImgxParams('?w=16&dpr=99')!.dpr).toBe(5) | |
| 49 | expect(parseImgxParams('?w=0.5')!.width).toBe(0.5) | |
| 50 | }) | |
| 51 | ||
| 52 | it('normalizes format aliases and auto', () => { | |
| 53 | expect(parseImgxParams('?fm=jpg')!.format).toBe('jpeg') | |
| 54 | expect(parseImgxParams('?format=webp')!.format).toBe('webp') | |
| 55 | expect(parseImgxParams('?fm=auto')!.autoFormat).toBe(true) | |
| 56 | expect(parseImgxParams('?auto=format')!.autoFormat).toBe(true) | |
| 57 | // Unknown formats don't trigger a transform by themselves. | |
| 58 | expect(parseImgxParams('?fm=tiff')).toBeUndefined() | |
| 59 | }) | |
| 60 | ||
| 61 | it('maps fit fallbacks and crop positions', () => { | |
| 62 | expect(parseImgxParams('?w=10&fit=clamp')!.fit).toBe('clip') | |
| 63 | expect(parseImgxParams('?w=10&fit=fillmax')!.fit).toBe('fill') | |
| 64 | expect(parseImgxParams('?w=10&fit=crop&crop=top,left')!.position).toBe('top-left') | |
| 65 | expect(parseImgxParams('?w=10&fit=crop&crop=faces')!.position).toBe('center') | |
| 66 | }) | |
| 67 | ||
| 68 | it('pre-checks cheaply via hasImgxParams', () => { | |
| 69 | expect(hasImgxParams('?w=1')).toBe(true) | |
| 70 | expect(hasImgxParams('?word=1')).toBe(false) | |
| 71 | expect(hasImgxParams('')).toBe(false) | |
| 72 | }) | |
| 73 | }) | |
| 74 | ||
| 75 | describe('ImgxCache', () => { | |
| 76 | it('evicts least-recently-used entries beyond limits', () => { | |
| 77 | const cache = new ImgxCache(1024, 2) | |
| 78 | cache.set('a', { bytes: new Uint8Array(10), contentType: 'image/png' }) | |
| 79 | cache.set('b', { bytes: new Uint8Array(10), contentType: 'image/png' }) | |
| 80 | expect(cache.get('a')).toBeDefined() // refresh a | |
| 81 | cache.set('c', { bytes: new Uint8Array(10), contentType: 'image/png' }) | |
| 82 | expect(cache.get('b')).toBeUndefined() // b was the LRU entry | |
| 83 | expect(cache.get('a')).toBeDefined() | |
| 84 | expect(cache.get('c')).toBeDefined() | |
| 85 | }) | |
| 86 | ||
| 87 | it('bounds total bytes', () => { | |
| 88 | const cache = new ImgxCache(25, 10) | |
| 89 | cache.set('a', { bytes: new Uint8Array(10), contentType: 'image/png' }) | |
| 90 | cache.set('b', { bytes: new Uint8Array(10), contentType: 'image/png' }) | |
| 91 | cache.set('c', { bytes: new Uint8Array(10), contentType: 'image/png' }) | |
| 92 | expect(cache.size).toBe(2) | |
| 93 | expect(cache.get('a')).toBeUndefined() | |
| 94 | }) | |
| 95 | }) | |
| 96 | ||
| 97 | describe('imgx proxy integration', () => { | |
| 98 | let origin: ReturnType<typeof Bun.serve> | null = null | |
| 99 | let originHost = '' | |
| 100 | let png: Uint8Array<ArrayBuffer> | |
| 101 | ||
| 102 | beforeAll(async () => { | |
| 103 | png = await makePng(64, 48) | |
| 104 | origin = Bun.serve({ | |
| 105 | port: 0, | |
| 106 | hostname: '127.0.0.1', | |
| 107 | fetch(request: Request) { | |
| 108 | const { pathname } = new URL(request.url) | |
| 109 | if (pathname === '/img.png') | |
| 110 | return new Response(png, { headers: { 'content-type': 'image/png', 'cache-control': 'max-age=60', 'etag': '"origin"' } }) | |
| 111 | if (pathname === '/broken.png') | |
| 112 | return new Response(new Uint8Array([1, 2, 3, 4]), { headers: { 'content-type': 'image/png' } }) | |
| 113 | if (pathname === '/plain.txt') | |
| 114 | return new Response('hello', { headers: { 'content-type': 'text/plain' } }) | |
| 115 | return new Response('not found', { status: 404 }) | |
| 116 | }, | |
| 117 | }) | |
| 118 | originHost = `127.0.0.1:${origin.port}` | |
| 119 | }) | |
| 120 | ||
| 121 | afterAll(() => { | |
| 122 | origin?.stop(true) | |
| 123 | }) | |
| 124 | ||
| 125 | function handlerWith(imgx: ReturnType<typeof resolveImgx>) { | |
| 126 | return createProxyFetchHandler(() => ({ sourceHost: originHost, imgx })) | |
| 127 | } | |
| 128 | ||
| 129 | it('resizes to a requested width preserving aspect ratio', async () => { | |
| 130 | const res = await handlerWith(resolveImgx(true))(req('http://app.localhost/img.png?w=32')) | |
| 131 | expect(res!.status).toBe(200) | |
| 132 | expect(res!.headers.get('content-type')).toBe('image/png') | |
| 133 | expect(res!.headers.get('x-rpx-imgx')).toBe('miss') | |
| 134 | // Upstream validators no longer describe the transformed entity. | |
| 135 | expect(res!.headers.get('etag')).toBeNull() | |
| 136 | expect(res!.headers.get('cache-control')).toBe('max-age=60') | |
| 137 | const out = await decode(new Uint8Array(await res!.arrayBuffer())) | |
| 138 | expect(out.width).toBe(32) | |
| 139 | expect(out.height).toBe(24) | |
| 140 | }) | |
| 141 | ||
| 142 | it('crops to exact dimensions with fit=crop', async () => { | |
| 143 | const res = await handlerWith(resolveImgx(true))(req('http://app.localhost/img.png?w=20&h=20&fit=crop')) | |
| 144 | const out = await decode(new Uint8Array(await res!.arrayBuffer())) | |
| 145 | expect(out.width).toBe(20) | |
| 146 | expect(out.height).toBe(20) | |
| 147 | }) | |
| 148 | ||
| 149 | it('treats 0-1 dimensions as fractions of the source', async () => { | |
| 150 | const res = await handlerWith(resolveImgx(true))(req('http://app.localhost/img.png?w=0.5')) | |
| 151 | const out = await decode(new Uint8Array(await res!.arrayBuffer())) | |
| 152 | expect(out.width).toBe(32) | |
| 153 | expect(out.height).toBe(24) | |
| 154 | }) | |
| 155 | ||
| 156 | it('multiplies dimensions by dpr', async () => { | |
| 157 | const res = await handlerWith(resolveImgx(true))(req('http://app.localhost/img.png?w=16&dpr=2')) | |
| 158 | const out = await decode(new Uint8Array(await res!.arrayBuffer())) | |
| 159 | expect(out.width).toBe(32) | |
| 160 | }) | |
| 161 | ||
| 162 | it('never enlarges with fit=max', async () => { | |
| 163 | const res = await handlerWith(resolveImgx(true))(req('http://app.localhost/img.png?w=500&fit=max')) | |
| 164 | const out = await decode(new Uint8Array(await res!.arrayBuffer())) | |
| 165 | expect(out.width).toBe(64) | |
| 166 | expect(out.height).toBe(48) | |
| 167 | }) | |
| 168 | ||
| 169 | it('re-encodes to an explicit format', async () => { | |
| 170 | const res = await handlerWith(resolveImgx(true))(req('http://app.localhost/img.png?fm=jpeg&q=50')) | |
| 171 | expect(res!.headers.get('content-type')).toBe('image/jpeg') | |
| 172 | const out = await decode(new Uint8Array(await res!.arrayBuffer())) | |
| 173 | expect(out.width).toBe(64) | |
| 174 | expect(out.height).toBe(48) | |
| 175 | }) | |
| 176 | ||
| 177 | it('negotiates webp from Accept with auto=format', async () => { | |
| 178 | const res = await handlerWith(resolveImgx(true))( | |
| 179 | req('http://app.localhost/img.png?w=16&auto=format', { accept: 'image/avif,image/webp,*/*' }), | |
| 180 | ) | |
| 181 | expect(res!.headers.get('content-type')).toBe('image/webp') | |
| 182 | expect(res!.headers.get('vary')).toContain('accept') | |
| 183 | }) | |
| 184 | ||
| 185 | it('passes through when no transform params are present', async () => { | |
| 186 | const res = await handlerWith(resolveImgx(true))(req('http://app.localhost/img.png')) | |
| 187 | expect(res!.status).toBe(200) | |
| 188 | expect(res!.headers.get('x-rpx-imgx')).toBeNull() | |
| 189 | expect(new Uint8Array(await res!.arrayBuffer())).toEqual(png) | |
| 190 | }) | |
| 191 | ||
| 192 | it('passes through non-image responses untouched', async () => { | |
| 193 | const res = await handlerWith(resolveImgx(true))(req('http://app.localhost/plain.txt?w=32')) | |
| 194 | expect(res!.headers.get('x-rpx-imgx')).toBeNull() | |
| 195 | expect(await res!.text()).toBe('hello') | |
| 196 | }) | |
| 197 | ||
| 198 | it('ignores transform params when imgx is not enabled for the route', async () => { | |
| 199 | const res = await handlerWith(undefined)(req('http://app.localhost/img.png?w=32')) | |
| 200 | expect(res!.headers.get('x-rpx-imgx')).toBeNull() | |
| 201 | expect(new Uint8Array(await res!.arrayBuffer())).toEqual(png) | |
| 202 | }) | |
| 203 | ||
| 204 | it('serves the original bytes when the image cannot be decoded', async () => { | |
| 205 | const res = await handlerWith(resolveImgx(true))(req('http://app.localhost/broken.png?w=32')) | |
| 206 | expect(res!.status).toBe(200) | |
| 207 | expect(res!.headers.get('x-rpx-imgx')).toBeNull() | |
| 208 | expect(new Uint8Array(await res!.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4])) | |
| 209 | }) | |
| 210 | ||
| 211 | it('passes through responses larger than maxInputBytes', async () => { | |
| 212 | const res = await handlerWith(resolveImgx({ maxInputBytes: 10 }))(req('http://app.localhost/img.png?w=32')) | |
| 213 | expect(res!.status).toBe(200) | |
| 214 | expect(res!.headers.get('x-rpx-imgx')).toBeNull() | |
| 215 | expect(new Uint8Array(await res!.arrayBuffer())).toEqual(png) | |
| 216 | }) | |
| 217 | ||
| 218 | it('serves repeated transforms from the in-memory cache', async () => { | |
| 219 | const handler = handlerWith(resolveImgx(true)) | |
| 220 | const first = await handler(req('http://app.localhost/img.png?w=24&h=24&fit=crop')) | |
| 221 | expect(first!.headers.get('x-rpx-imgx')).toBe('miss') | |
| 222 | const second = await handler(req('http://app.localhost/img.png?w=24&h=24&fit=crop')) | |
| 223 | expect(second!.headers.get('x-rpx-imgx')).toBe('hit') | |
| 224 | expect(new Uint8Array(await second!.arrayBuffer())).toEqual(new Uint8Array(await first!.arrayBuffer())) | |
| 225 | }) | |
| 226 | ||
| 227 | it('honors per-request quality over the route default', async () => { | |
| 228 | const handler = handlerWith(resolveImgx(true)) | |
| 229 | const high = await handler(req('http://app.localhost/img.png?fm=jpeg&q=95')) | |
| 230 | const low = await handler(req('http://app.localhost/img.png?fm=jpeg&q=10')) | |
| 231 | const highBytes = await high!.arrayBuffer() | |
| 232 | const lowBytes = await low!.arrayBuffer() | |
| 233 | expect(lowBytes.byteLength).toBeLessThan(highBytes.byteLength) | |
| 234 | }) | |
| 235 | ||
| 236 | it('transforms images served from a static route', async () => { | |
| 237 | const dir = await fsp.mkdtemp(path.join(os.tmpdir(), 'rpx-imgx-')) | |
| 238 | try { | |
| 239 | await fsp.writeFile(path.join(dir, 'photo.png'), png) | |
| 240 | const handler = createProxyFetchHandler(() => ({ | |
| 241 | static: resolveStaticRoute(dir, false), | |
| 242 | imgx: resolveImgx(true), | |
| 243 | })) | |
| 244 | const res = await handler(req('http://app.localhost/photo.png?w=32')) | |
| 245 | expect(res!.status).toBe(200) | |
| 246 | expect(res!.headers.get('x-rpx-imgx')).toBe('miss') | |
| 247 | const out = await decode(new Uint8Array(await res!.arrayBuffer())) | |
| 248 | expect(out.width).toBe(32) | |
| 249 | } | |
| 250 | finally { | |
| 251 | await fsp.rm(dir, { recursive: true, force: true }) | |
| 252 | } | |
| 253 | }) | |
| 254 | }) | |