ReviewOS

also looking at this

stacks/rpx

feat: imgx integration — on-the-fly image transforms from query params

#2264
Merged glennmichael123 wants to merge feat/imgx-integration into main
15 files +1118 -16
.config/bunpress.tsmodified+1-0
Changes to .config/bunpress.ts
@@ -52,6 +52,7 @@ export default {
5252 { text: 'SSL Termination', link: '/features/ssl-termination' },
5353 { text: 'Load Balancing', link: '/features/load-balancing' },
5454 { text: 'Request Routing', link: '/features/request-routing' },
55 { text: 'Image Transforms (imgx)', link: '/features/imgx' },
5556 ],
5657 },
5758 {
README.mdmodified+1-0
Changes to README.md
@@ -22,6 +22,7 @@
2222- ��️ Auto HTTP-to-HTTPS Redirection
2323- ✏️ `/etc/hosts` Management _(fully disable-able for real servers)_
2424- �� Clean URLs _(removes `.html` extension)_
25- ��️ Image Transforms _(imgx integration: `?w=200&h=1200&q=80` on any image response)_
2526- �� CLI & Library Support
2627
2728## Install
bun.lockmodified+7-4
Changes to bun.lock
@@ -13,14 +13,14 @@
1313 },
1414 "packages/bun-plugin": {
1515 "name": "bun-plugin-rpx",
16 "version": "0.11.42",
16 "version": "0.11.51",
1717 "dependencies": {
1818 "@stacksjs/rpx": "workspace:*",
1919 },
2020 },
2121 "packages/rpx": {
2222 "name": "@stacksjs/rpx",
23 "version": "0.11.42",
23 "version": "0.11.51",
2424 "bin": {
2525 "rpx": "./dist/bin/cli.js",
2626 "reverse-proxy": "./dist/bin/cli.js",
@@ -28,6 +28,7 @@
2828 "dependencies": {
2929 "@stacksjs/clapp": "^0.2.10",
3030 "@stacksjs/tlsx": "^0.13.19",
31 "ts-images": "^0.1.11",
3132 },
3233 "devDependencies": {
3334 "bunfig": "^0.15.6",
@@ -37,7 +38,7 @@
3738 },
3839 "packages/vite-plugin": {
3940 "name": "vite-plugin-rpx",
40 "version": "0.11.42",
41 "version": "0.11.51",
4142 "dependencies": {
4243 "@stacksjs/rpx": "workspace:*",
4344 "@stacksjs/tlsx": "^0.13.19",
@@ -406,7 +407,7 @@
406407
407408 "ts-gif": ["ts-gif@0.1.2", "", { "dependencies": { "data-uri-to-buffer": "^6.0.2" } }, "sha512-jbraplO1qqJtrtkFeepNPUGPad7w8FQHWIQFsn1OJbHeWKUbdqdkrX18XzNXI5Fw7+s6A3nbNTdj+Ukn4d3f8g=="],
408409
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=="],
410411
411412 "ts-jpeg": ["ts-jpeg@0.3.1", "", {}, "sha512-KIGTJj4UIlkGoCFEofx8jHHQEkJfSLFJY0MHz60Mu4Scjrcj7K/0UJGCbL9QvfIaFGAOGdf0xNFpYSuzUpimwA=="],
412413
@@ -436,6 +437,8 @@
436437
437438 "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=="],
438439
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
439442 "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
440443
441444 "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=="],
docs/features/imgx.mdadded+102-0
Changes to docs/features/imgx.md
@@ -0,0 +1,102 @@
1# Image Transforms (imgx)
2
3rpx 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
6https://app.localhost/hero.png?w=200&h=1200&q=80
7https://app.localhost/hero.png?w=640&fm=webp
8https://app.localhost/hero.png?w=400&h=400&fit=crop&crop=top
9```
10
11The 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
16import { startProxy } from '@stacksjs/rpx'
17
18await startProxy({
19 from: 'localhost:5173',
20 to: 'my-app.localhost',
21 imgx: true, // defaults; or pass an options object
22})
23```
24
25Per-proxy in multi-proxy configs (overrides the shared setting):
26
27```ts
28await 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
37Or from the CLI:
38
39```bash
40rpx start --from localhost:5173 --to my-app.localhost --imgx
41```
42
43## Supported query params
44
45Sizing:
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
55Output:
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
64Adjustments:
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
81await 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.
packages/rpx/bin/cli.tsmodified+3-0
Changes to packages/rpx/bin/cli.ts
@@ -54,6 +54,7 @@ interface CLIOptions {
5454 verbose?: boolean
5555 viaDaemon?: boolean
5656 id?: string
57 imgx?: boolean
5758}
5859
5960cli
@@ -69,6 +70,7 @@ cli
6970 .option('--start-cwd <path>', 'Current working directory for the dev server')
7071 .option('--start-env <env>', 'Environment variables for the dev server')
7172 .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')
7274 .option('--single-port-mode', 'Route all proxies through one shared listener (by Host header) instead of a port per proxy')
7375 .option('--http-port <port>', 'Port for the shared HTTP listener / redirect (default: 80)')
7476 .option('--https-port <port>', 'Port for the shared HTTPS listener (default: 443)')
@@ -101,6 +103,7 @@ cli
101103 },
102104 verbose: options.verbose || false,
103105 changeOrigin: options.changeOrigin || false,
106 imgx: options.imgx || false,
104107 singlePortMode: options.singlePortMode || false,
105108 httpPort: options.httpPort !== undefined ? Number(options.httpPort) : undefined,
106109 httpsPort: options.httpsPort !== undefined ? Number(options.httpsPort) : undefined,
packages/rpx/package.jsonmodified+2-1
Changes to packages/rpx/package.json
@@ -72,7 +72,8 @@
7272 },
7373 "dependencies": {
7474 "@stacksjs/clapp": "^0.2.10",
75 "@stacksjs/tlsx": "^0.13.19"
75 "@stacksjs/tlsx": "^0.13.19",
76 "ts-images": "^0.1.11"
7677 },
7778 "devDependencies": {
7879 "bunfig": "^0.15.6",
packages/rpx/src/daemon-runner.tsmodified+4-1
Changes to packages/rpx/src/daemon-runner.ts
@@ -10,7 +10,7 @@
1010 *
1111 * The daemon's PID-GC reaps anything we miss if this process dies `kill -9`.
1212 */
13import type { LoadBalancerConfig, PathRewrite, ProxyFrom, StaticRouteConfig } from './types'
13import type { ImgxOptions, LoadBalancerConfig, PathRewrite, ProxyFrom, StaticRouteConfig } from './types'
1414import * as fs from 'node:fs'
1515import * as path from 'node:path'
1616import * as process from 'node:process'
@@ -36,6 +36,8 @@ export interface DaemonRunnerProxy {
3636 path?: string
3737 cleanUrls?: boolean
3838 changeOrigin?: boolean
39 /** Transform image responses from imgix-style query params — see {@link ImgxOptions}. */
40 imgx?: boolean | ImgxOptions
3941 pathRewrites?: PathRewrite[]
4042 /** Serve a local directory for this route instead of proxying. */
4143 static?: string | StaticRouteConfig
@@ -118,6 +120,7 @@ export async function runViaDaemon(opts: DaemonRunnerOptions): Promise<void> {
118120 createdAt,
119121 cleanUrls: p.cleanUrls,
120122 changeOrigin: p.changeOrigin,
123 imgx: p.imgx,
121124 pathRewrites: p.pathRewrites,
122125 static: p.static,
123126 loadBalancer: p.loadBalancer,
packages/rpx/src/daemon.tsmodified+3-0
Changes to packages/rpx/src/daemon.ts
@@ -37,6 +37,7 @@ import {
3737import { createUpstreamPool, primaryUpstreamUrl, resolveUpstreamUrls, startHealthChecks, stopHealthChecks } from './load-balancer'
3838import type { UpstreamPool } from './load-balancer'
3939import { createProxyFetchHandler, createProxyWebSocketHandler } from './proxy-handler'
40import { resolveImgx } from './imgx'
4041import { readAcmeChallenge } from './acme-challenge'
4142import { buildHostRoutes, matchHostList, matchHostRoute, normalizePathPrefix } from './host-routes'
4243import type { HostRoutes } from './host-routes'
@@ -280,6 +281,7 @@ export function entryToRoute(entry: RegistryEntry): ProxyRoute {
280281 return {
281282 static: resolveStaticRoute(entry.static, cleanUrls),
282283 cleanUrls,
284 imgx: resolveImgx(entry.imgx),
283285 basePath,
284286 auth,
285287 }
@@ -308,6 +310,7 @@ export function entryToRoute(entry: RegistryEntry): ProxyRoute {
308310 upstreamPool: pool,
309311 cleanUrls,
310312 changeOrigin: entry.changeOrigin ?? false,
313 imgx: resolveImgx(entry.imgx),
311314 pathRewrites: entry.pathRewrites,
312315 basePath,
313316 auth,
packages/rpx/src/imgx.tsadded+669-0
Changes to packages/rpx/src/imgx.ts
@@ -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 */
23import type { ImgxOptions } from './types'
24import { debugLog } from './utils'
25
26/** Query params that trigger a transform (as opposed to only modifying one). */
27const 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. */
30const 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. */
41const 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
51type OutputFormat = 'jpeg' | 'png' | 'webp' | 'avif' | 'gif' | 'bmp'
52
53const OUTPUT_FORMATS = new Set<string>(['jpeg', 'png', 'webp', 'avif', 'gif', 'bmp'])
54
55/** imgix `fit` values rpx implements (`clamp`→clip and `fillmax`→fill degrade). */
56type FitMode = 'clip' | 'max' | 'min' | 'crop' | 'scale' | 'fill'
57
58type CropPosition = 'center' | 'top' | 'bottom' | 'left' | 'right' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
59
60interface Rgba { r: number, g: number, b: number, a?: number }
61
62/** A request's parsed transform, normalized and clamped. */
63export 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
87export 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 */
104export 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 */
148export 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. */
168export function hasImgxParams(search: string): boolean {
169 return search.length > 1 && TRIGGER_PARAMS.test(search)
170}
171
172function 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
178function 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). */
186function 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 */
196function 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. */
204function 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). */
233function 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 */
260export 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
348function 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 */
359type TsImages = typeof import('ts-images')
360let tsImagesPromise: Promise<TsImages | null> | undefined
361
362function 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. */
372export function resetImgxRuntime(): void {
373 tsImagesPromise = undefined
374}
375
376function 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
384function 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 */
396async 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). */
439function 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
496function dim(value: number): number {
497 return Math.max(1, Math.round(value))
498}
499
500const 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 */
515function 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. */
525function 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 */
542export 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. */
656function 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}
packages/rpx/src/index.tsmodified+3-0
Changes to packages/rpx/src/index.ts
@@ -125,6 +125,9 @@ export type {
125125 StopDaemonResult,
126126} from './daemon'
127127
128export { applyImgxTransform, hasImgxParams, parseImgxParams, resolveImgx } from './imgx'
129export type { ImgxTransform, ResolvedImgxOptions } from './imgx'
130
128131export { createProxyFetchHandler, createProxyWebSocketHandler, stripBasePath } from './proxy-handler'
129132export type { GetRoute, NoRouteOutcome, OnNoRoute, ProxyFetchHandler, ProxyRoute, ProxyServer } from './proxy-handler'
130133
packages/rpx/src/proxy-handler.tsmodified+12-3
Changes to packages/rpx/src/proxy-handler.ts
@@ -15,11 +15,13 @@
1515 */
1616import type { ServerWebSocket } from 'bun'
1717import type { ResolvedAuth } from './auth'
18import type { ResolvedImgxOptions } from './imgx'
1819import type { UpstreamPool, UpstreamState } from './load-balancer'
1920import type { ResolvedRedirect } from './redirect'
2021import type { ResolvedStaticRoute } from './static-files'
2122import type { PathRewrite } from './types'
2223import { enforceBasicAuth } from './auth'
24import { applyImgxTransform } from './imgx'
2325import { markFailure, markSuccess, selectUpstream } from './load-balancer'
2426import { FALLBACK, POOL_BUSY, proxyViaPool, TIMEOUT } from './proxy-pool'
2527import { buildRedirectLocation } from './redirect'
@@ -47,6 +49,12 @@ export interface ProxyRoute {
4749 changeOrigin?: boolean
4850 /** Per-route path rewrites (vite/nginx-style prefix routing). */
4951 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
5058 /** When set, serve files from a local directory instead of proxying. */
5159 static?: ResolvedStaticRoute
5260 /** When set, answer with an HTTP redirect (Location) instead of proxying/serving. */
@@ -446,7 +454,8 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean, o
446454 if (route.static) {
447455 const strip = route.stripBasePathPrefix ?? true
448456 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)
450459 }
451460
452461 // 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
524533 })
525534 if (pool)
526535 markSuccess(pool, upstream)
527 return res
536 return await applyImgxTransform(req, pathname, search, route.imgx, res, verbose)
528537 }
529538 catch (err) {
530539 // Upstream stalled past the configured timeout → 504 (no fetch retry — it
@@ -562,7 +571,7 @@ export function createProxyFetchHandler(getRoute: GetRoute, verbose?: boolean, o
562571 })
563572 if (pool)
564573 markSuccess(pool, upstream)
565 return res
574 return await applyImgxTransform(req, pathname, search, route.imgx, res, verbose)
566575 }
567576 catch (fetchErr) {
568577 debugLog('request', `Proxy error for ${hostname}: ${fetchErr}`, verbose)
packages/rpx/src/registry.tsmodified+4-1
Changes to packages/rpx/src/registry.ts
@@ -14,7 +14,7 @@
1414 * - `id` is validated against a strict charset to keep it from escaping
1515 * the registry directory.
1616 */
17import type { BasicAuthConfig, LoadBalancerConfig, PathRewrite, ProxyFrom, StaticRouteConfig } from './types'
17import type { BasicAuthConfig, ImgxOptions, LoadBalancerConfig, PathRewrite, ProxyFrom, StaticRouteConfig } from './types'
1818import * as fs from 'node:fs'
1919import * as fsp from 'node:fs/promises'
2020import { homedir } from 'node:os'
@@ -50,6 +50,8 @@ export interface RegistryEntry {
5050 pathRewrites?: PathRewrite[]
5151 cleanUrls?: boolean
5252 changeOrigin?: boolean
53 /** Transform image responses from imgix-style query params — see {@link ImgxOptions}. */
54 imgx?: boolean | ImgxOptions
5355 /** Serve a local directory for this route instead of proxying. */
5456 static?: string | StaticRouteConfig
5557 /** Optional HTTP Basic auth gate for this route. */
@@ -300,6 +302,7 @@ export function watchRegistry(
300302 pathRewrites: entry.pathRewrites,
301303 cleanUrls: entry.cleanUrls,
302304 changeOrigin: entry.changeOrigin,
305 imgx: entry.imgx,
303306 static: entry.static,
304307 loadBalancer: entry.loadBalancer,
305308 }))
packages/rpx/src/start.tsmodified+17-4
Changes to packages/rpx/src/start.ts
@@ -1,5 +1,5 @@
11/* eslint-disable no-console */
2import type { BaseProxyConfig, CleanupOptions, LoadBalancerConfig, ProxyConfig, ProxyFrom, ProxyOption, ProxyOptions, ProxySetupOptions, ResolvedProxyOptions, SingleProxyConfig, SSLConfig, StartOptions } from './types'
2import type { BaseProxyConfig, CleanupOptions, ImgxOptions, LoadBalancerConfig, ProxyConfig, ProxyFrom, ProxyOption, ProxyOptions, ProxySetupOptions, ResolvedProxyOptions, SingleProxyConfig, SSLConfig, StartOptions } from './types'
33import { exec, execSync } from 'node:child_process'
44import * as fs from 'node:fs'
55import * as http from 'node:http'
@@ -15,6 +15,7 @@ import { config } from './config'
1515import { runViaDaemon } from './daemon-runner'
1616import { addHosts, checkHosts, removeHosts } from './hosts'
1717import { checkExistingCertificates, cleanupCertificates, generateCertificate, httpsConfig, loadSSLConfig } from './https'
18import { resolveImgx } from './imgx'
1819import { createUpstreamPool, primaryUpstreamUrl, startHealthChecks, stopHealthChecks } from './load-balancer'
1920import type { UpstreamPool } from './load-balancer'
2021import { DefaultPortManager, findAvailablePort, isPortInUse } from './port-manager'
@@ -486,6 +487,7 @@ async function createProxyServer(
486487 auth?: ResolvedAuth,
487488 originalFrom?: ProxyFrom,
488489 loadBalancer?: LoadBalancerConfig,
490 imgx?: boolean | ImgxOptions,
489491): Promise<void> {
490492 debugLog('proxy', `Creating proxy server ${from} -> ${to} with cleanUrls: ${cleanUrls}`, verbose)
491493
@@ -511,6 +513,7 @@ async function createProxyServer(
511513 upstreamPool: pool,
512514 cleanUrls: cleanUrls || false,
513515 changeOrigin: changeOrigin || false,
516 imgx: resolveImgx(imgx),
514517 basePath: '/',
515518 auth,
516519 } as ProxyRoute,
@@ -630,7 +633,7 @@ export async function setupProxy(options: ProxySetupOptions): Promise<void> {
630633 debugLog('setup', `Using standard ${targetPort === 443 ? 'HTTPS' : 'HTTP'} port ${targetPort} for ${to}`, verbose)
631634 }
632635
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)
634637 }
635638 catch (err) {
636639 debugLog('setup', `Setup failed: ${err}`, verbose)
@@ -735,6 +738,7 @@ export function startProxy(options: ProxyOption): void {
735738 path: mergedOptions.path,
736739 cleanUrls: mergedOptions.cleanUrls,
737740 changeOrigin: mergedOptions.changeOrigin,
741 imgx: mergedOptions.imgx,
738742 pathRewrites: mergedOptions.pathRewrites,
739743 }],
740744 verbose: mergedOptions.verbose,
@@ -796,6 +800,7 @@ export function startProxy(options: ProxyOption): void {
796800 cleanup: mergedOptions.cleanup,
797801 vitePluginUsage: mergedOptions.vitePluginUsage,
798802 changeOrigin: mergedOptions.changeOrigin,
803 imgx: mergedOptions.imgx,
799804 verbose: mergedOptions.verbose,
800805 regenerateUntrustedCerts: mergedOptions.regenerateUntrustedCerts,
801806 }
@@ -855,6 +860,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
855860 verbose: false,
856861 cleanUrls: false,
857862 changeOrigin: false,
863 imgx: false,
858864 regenerateUntrustedCerts: true,
859865 } as ResolvedProxyOptions
860866
@@ -889,7 +895,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
889895 if (mergedOptions.viaDaemon) {
890896 const isMulti = 'proxies' in mergedOptions && Array.isArray(mergedOptions.proxies)
891897 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 }>)
893899 .map(p => ({
894900 id: p.id,
895901 from: p.from,
@@ -897,6 +903,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
897903 path: p.path,
898904 cleanUrls: p.cleanUrls ?? mergedOptions.cleanUrls,
899905 changeOrigin: p.changeOrigin ?? mergedOptions.changeOrigin,
906 imgx: p.imgx ?? mergedOptions.imgx,
900907 pathRewrites: p.pathRewrites,
901908 }))
902909 : [{
@@ -906,6 +913,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
906913 path: mergedOptions.path,
907914 cleanUrls: mergedOptions.cleanUrls,
908915 changeOrigin: mergedOptions.changeOrigin,
916 imgx: mergedOptions.imgx,
909917 pathRewrites: mergedOptions.pathRewrites,
910918 }]
911919 await runViaDaemon({ proxies, verbose })
@@ -1080,6 +1088,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
10801088 cleanUrls: proxy.cleanUrls ?? ('cleanUrls' in mergedOptions ? mergedOptions.cleanUrls : false),
10811089 vitePluginUsage: mergedOptions.vitePluginUsage,
10821090 changeOrigin: proxy.changeOrigin ?? mergedOptions.changeOrigin,
1091 imgx: proxy.imgx ?? mergedOptions.imgx,
10831092 verbose,
10841093 _cachedSSLConfig: mergedOptions._cachedSSLConfig,
10851094 } as ProxyOption))
@@ -1092,6 +1101,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
10921101 vitePluginUsage: mergedOptions.vitePluginUsage,
10931102 start: ('start' in mergedOptions) ? mergedOptions.start : undefined,
10941103 changeOrigin: mergedOptions.changeOrigin,
1104 imgx: mergedOptions.imgx,
10951105 auth: 'auth' in mergedOptions ? mergedOptions.auth : undefined,
10961106 verbose,
10971107 _cachedSSLConfig: mergedOptions._cachedSSLConfig,
@@ -1355,6 +1365,7 @@ export async function startProxies(options?: ProxyOptions): Promise<void> {
13551365 verbose: option.verbose || false,
13561366 _cachedSSLConfig: mergedOptions._cachedSSLConfig,
13571367 changeOrigin: option.changeOrigin || false,
1368 imgx: option.imgx,
13581369 loadBalancer: option.loadBalancer,
13591370 auth: option.auth,
13601371 path: option.path,
@@ -1397,6 +1408,7 @@ export async function collectRouteEntries(
13971408 const basePath = normalizePathPrefix(routePath)
13981409
13991410 const auth = resolveAuth(option.auth)
1411 const imgx = resolveImgx(option.imgx)
14001412
14011413 // Redirect route: answer with a Location (e.g. an alternate domain → its
14021414 // canonical host). Takes precedence over proxy/static — no upstream needed.
@@ -1414,7 +1426,7 @@ export async function collectRouteEntries(
14141426 routeEntries.push({
14151427 host: domain,
14161428 path: routePath,
1417 route: { static: resolveStaticRoute(option.static, cleanUrls), cleanUrls, basePath, auth },
1429 route: { static: resolveStaticRoute(option.static, cleanUrls), cleanUrls, imgx, basePath, auth },
14181430 })
14191431 debugLog('proxies', `Route: ${domain}${routePath ?? ''} → static ${typeof option.static === 'string' ? option.static : option.static.dir}${auth ? ' (auth)' : ''}`, verbose)
14201432 }
@@ -1435,6 +1447,7 @@ export async function collectRouteEntries(
14351447 upstreamPool: pool,
14361448 cleanUrls,
14371449 changeOrigin: option.changeOrigin || false,
1450 imgx,
14381451 pathRewrites: option.pathRewrites,
14391452 basePath,
14401453 auth,
packages/rpx/src/types.tsmodified+36-2
Changes to packages/rpx/src/types.ts
@@ -404,6 +404,34 @@ export interface LocalCaConfig {
404404 renewBeforeDays?: number
405405}
406406
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 */
415export 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
407435export interface SharedProxyConfig {
408436 /**
409437 * `true` mints and trusts a local dev certificate, a {@link TlsOption} points
@@ -419,6 +447,12 @@ export interface SharedProxyConfig {
419447 start?: StartOptions
420448 cleanUrls: boolean
421449 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
422456 regenerateUntrustedCerts?: boolean // If true, will regenerate and re-trust certs that exist but are not trusted by the system.
423457 /**
424458 * Route every proxy through a single shared listener instead of binding a
@@ -512,7 +546,7 @@ export type SharedProxyOptions = Partial<SharedProxyConfig>
512546export interface SingleProxyConfig extends BaseProxyConfig, SharedProxyConfig {}
513547
514548export interface MultiProxyConfig extends SharedProxyConfig {
515 proxies: Array<BaseProxyConfig & { cleanUrls: boolean, pathRewrites?: PathRewrite[] }>
549 proxies: Array<BaseProxyConfig & { cleanUrls: boolean, pathRewrites?: PathRewrite[], imgx?: boolean | ImgxOptions }>
516550}
517551
518552export type ProxyConfig = SingleProxyConfig
@@ -530,7 +564,7 @@ export type ProxyOptions = Partial<SingleProxyConfig> | Partial<MultiProxyConfig
530564 * single and multi shapes without falling back to `any`.
531565 */
532566export 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 }>
534568}
535569
536570export interface SSLConfig {
packages/rpx/test/imgx.test.tsadded+254-0
Changes to packages/rpx/test/imgx.test.ts
@@ -0,0 +1,254 @@
1import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
2import * as fsp from 'node:fs/promises'
3import * as os from 'node:os'
4import * as path from 'node:path'
5import { createImageData, decode, encode } from 'ts-images'
6import { ImgxCache, hasImgxParams, parseImgxParams, resolveImgx } from '../src/imgx'
7import { createProxyFetchHandler } from '../src/proxy-handler'
8import { resolveStaticRoute } from '../src/static-files'
9
10function 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. */
16async 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
27describe('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
75describe('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
97describe('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})