@vertz/cloudflare 0.2.14 → 0.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @vertz/cloudflare
2
2
 
3
+ ## 0.2.16
4
+
5
+ ### Patch Changes
6
+
7
+ - [#1195](https://github.com/vertz-dev/vertz/pull/1195) [`af0b64c`](https://github.com/vertz-dev/vertz/commit/af0b64c62480606cd9bb7ec9a25d7a4f0903d9cf) Thanks [@viniciusdacal](https://github.com/viniciusdacal)! - Add runtime image optimization for dynamic images at the edge. The `<Image>` component now rewrites absolute HTTP(S) URLs through `/_vertz/image` when `configureImageOptimizer()` is called. The Cloudflare handler supports an `imageOptimizer` config option using `cf.image` for edge transformation. Dev server includes a passthrough proxy for development.
8
+
9
+ - Updated dependencies [[`97e9fc9`](https://github.com/vertz-dev/vertz/commit/97e9fc9a80548e2be111542513802269162f4136), [`af0b64c`](https://github.com/vertz-dev/vertz/commit/af0b64c62480606cd9bb7ec9a25d7a4f0903d9cf), [`caf4647`](https://github.com/vertz-dev/vertz/commit/caf464741b53fdd65be1c558cf2330172f6d2feb), [`6c33552`](https://github.com/vertz-dev/vertz/commit/6c3355265cd072d2c5b3d41c3c60e76d75c6e21c), [`d0e9dc5`](https://github.com/vertz-dev/vertz/commit/d0e9dc5065fea630cd046ef55f279fe9fb400086), [`e1938b0`](https://github.com/vertz-dev/vertz/commit/e1938b0f86129396d22f5db57792cfa805387e62), [`02bac2a`](https://github.com/vertz-dev/vertz/commit/02bac2af689750d500f0846d700e89528a02627d), [`ab3f364`](https://github.com/vertz-dev/vertz/commit/ab3f36478018245cc9473217a9a3bf7b04c6a5cb), [`0f6d90a`](https://github.com/vertz-dev/vertz/commit/0f6d90adf785c52ff1e70187e3479941b2db896c), [`d8257a5`](https://github.com/vertz-dev/vertz/commit/d8257a5665704fa0f2c2e6646f3b5ab8c39c5cdc), [`c1c0638`](https://github.com/vertz-dev/vertz/commit/c1c06383b8ad50c833b64aa5009fe7b494bb559b)]:
10
+ - @vertz/ui-server@0.2.16
11
+ - @vertz/core@0.2.16
12
+
13
+ ## 0.2.15
14
+
15
+ ### Patch Changes
16
+
17
+ - Updated dependencies []:
18
+ - @vertz/core@0.2.15
19
+ - @vertz/ui-server@0.2.15
20
+
3
21
  ## 0.2.14
4
22
 
5
23
  ### Patch Changes
package/dist/handler.d.ts CHANGED
@@ -43,6 +43,12 @@ export interface CloudflareHandlerConfig {
43
43
  ssr?: SSRModuleConfig | ((request: Request) => Promise<Response>);
44
44
  /** When true, adds standard security headers to all responses. */
45
45
  securityHeaders?: boolean;
46
+ /**
47
+ * Image optimizer handler for runtime image optimization at the edge.
48
+ * Created via `imageOptimizer()` from `@vertz/cloudflare/image`.
49
+ * Routes `/_vertz/image` requests to the optimizer.
50
+ */
51
+ imageOptimizer?: (request: Request) => Promise<Response>;
46
52
  }
47
53
  /** Generate a cryptographically random nonce for CSP. */
48
54
  export declare function generateNonce(): string;
@@ -1 +1 @@
1
- {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAMtD,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,iEAAiE;IACjE,MAAM,EAAE,SAAS,CAAC;IAClB,wEAAwE;IACxE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,GAAG,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,UAAU,CAAC;IAElC,4EAA4E;IAC5E,QAAQ,EAAE,MAAM,CAAC;IAEjB;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,eAAe,GAAG,CAAC,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;IAElE,kEAAkE;IAClE,eAAe,CAAC,EAAE,OAAO,CAAC;CAC3B;AAMD,yDAAyD;AACzD,wBAAgB,aAAa,IAAI,MAAM,CAStC;AA4CD;;;;;;;;;;;;;;;;;GAiBG;AACH,qDAAqD;AACrD,MAAM,WAAW,sBAAsB;IACrC,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CACjF;AAED,wBAAgB,aAAa,CAC3B,WAAW,EAAE,UAAU,GAAG,uBAAuB,EACjD,OAAO,CAAC,EAAE,wBAAwB,GACjC,sBAAsB,CAQxB;AA+BD,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAchG"}
1
+ {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAE9C,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAMtD,MAAM,WAAW,wBAAwB;IACvC,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED;;;;;GAKG;AACH,MAAM,WAAW,eAAe;IAC9B,iEAAiE;IACjE,MAAM,EAAE,SAAS,CAAC;IAClB,wEAAwE;IACxE,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,4EAA4E;IAC5E,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;GAKG;AACH,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,GAAG,EAAE,CAAC,GAAG,EAAE,OAAO,KAAK,UAAU,CAAC;IAElC,4EAA4E;IAC5E,QAAQ,EAAE,MAAM,CAAC;IAEjB;;;;;;OAMG;IACH,GAAG,CAAC,EAAE,eAAe,GAAG,CAAC,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC;IAElE,kEAAkE;IAClE,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B;;;;OAIG;IACH,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;CAC1D;AAMD,yDAAyD;AACzD,wBAAgB,aAAa,IAAI,MAAM,CAStC;AA4CD;;;;;;;;;;;;;;;;;GAiBG;AACH,qDAAqD;AACrD,MAAM,WAAW,sBAAsB;IACrC,KAAK,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,gBAAgB,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAC;CACjF;AAED,wBAAgB,aAAa,CAC3B,WAAW,EAAE,UAAU,GAAG,uBAAuB,EACjD,OAAO,CAAC,EAAE,wBAAwB,GACjC,sBAAsB,CAQxB;AA+BD,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAchG"}
package/dist/handler.js CHANGED
@@ -99,7 +99,7 @@ function isSSRModuleConfig(ssr) {
99
99
  return typeof ssr === 'object' && 'module' in ssr;
100
100
  }
101
101
  function createFullStackHandler(config) {
102
- const { basePath, ssr, securityHeaders } = config;
102
+ const { basePath, ssr, securityHeaders, imageOptimizer: imageOptimizerHandler } = config;
103
103
  // Install per-request fetch proxy once (idempotent)
104
104
  installFetchProxy();
105
105
  let cachedApp = null;
@@ -144,6 +144,11 @@ function createFullStackHandler(config) {
144
144
  await resolveSSR();
145
145
  const url = new URL(request.url);
146
146
  const nonce = generateNonce();
147
+ // Image optimizer route — highest priority, before API and SSR
148
+ if (url.pathname === '/_vertz/image' && imageOptimizerHandler) {
149
+ const response = await imageOptimizerHandler(request);
150
+ return applyHeaders(response, nonce);
151
+ }
147
152
  // Route splitting: basePath/* → API handler (no URL rewriting — the
148
153
  // app's own basePath/apiPrefix handles prefix matching internally)
149
154
  if (url.pathname.startsWith(basePath)) {
@@ -1 +1 @@
1
- {"version":3,"file":"handler.js","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"handler.js","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,17 @@
1
+ export interface ImageOptimizerConfig {
2
+ /** Required: exact hostnames allowed as image sources. SSRF protection. */
3
+ allowedDomains: string[];
4
+ /** Max output width in CSS pixels. Default: 3840 (4K). */
5
+ maxWidth?: number;
6
+ /** Max output height in CSS pixels. Default: 2160 (4K). */
7
+ maxHeight?: number;
8
+ /** Default image quality 1-100. Default: 80. */
9
+ defaultQuality?: number;
10
+ /** Cache TTL in seconds. Default: 31536000 (1 year). */
11
+ cacheTtl?: number;
12
+ /** Fetch timeout in ms. Default: 10000. */
13
+ fetchTimeout?: number;
14
+ }
15
+ export type ImageOptimizerHandler = (request: Request) => Promise<Response>;
16
+ export declare function imageOptimizer(config: ImageOptimizerConfig): ImageOptimizerHandler;
17
+ //# sourceMappingURL=image-optimizer.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-optimizer.d.ts","sourceRoot":"","sources":["../src/image-optimizer.ts"],"names":[],"mappings":"AAIA,MAAM,WAAW,oBAAoB;IACnC,2EAA2E;IAC3E,cAAc,EAAE,MAAM,EAAE,CAAC;IAEzB,0DAA0D;IAC1D,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB,2DAA2D;IAC3D,SAAS,CAAC,EAAE,MAAM,CAAC;IAEnB,gDAAgD;IAChD,cAAc,CAAC,EAAE,MAAM,CAAC;IAExB,wDAAwD;IACxD,QAAQ,CAAC,EAAE,MAAM,CAAC;IAElB,2CAA2C;IAC3C,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,MAAM,qBAAqB,GAAG,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;AA6B5E,wBAAgB,cAAc,CAAC,MAAM,EAAE,oBAAoB,GAAG,qBAAqB,CAwHlF"}
@@ -0,0 +1,123 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Types
3
+ // ---------------------------------------------------------------------------
4
+ // ---------------------------------------------------------------------------
5
+ // Helpers
6
+ // ---------------------------------------------------------------------------
7
+ function jsonError(error, status) {
8
+ return new Response(JSON.stringify({ error, status }), {
9
+ status,
10
+ headers: { 'Content-Type': 'application/json' },
11
+ });
12
+ }
13
+ const IP_REGEX = /^(\d{1,3}\.){3}\d{1,3}$|^\[?[\da-fA-F:]+\]?$|^0x[\da-fA-F]+$|^\d{10,}$/;
14
+ function isIPAddress(hostname) {
15
+ // Strip brackets from IPv6
16
+ const clean = hostname.replace(/^\[|\]$/g, '');
17
+ return IP_REGEX.test(clean);
18
+ }
19
+ function isRedirect(status) {
20
+ return status >= 300 && status < 400;
21
+ }
22
+ // ---------------------------------------------------------------------------
23
+ // Factory
24
+ // ---------------------------------------------------------------------------
25
+ export function imageOptimizer(config) {
26
+ const { allowedDomains, maxWidth = 3840, maxHeight = 2160, defaultQuality = 80, cacheTtl = 31536000, fetchTimeout = 10000, } = config;
27
+ const allowedSet = new Set(allowedDomains);
28
+ return async (request) => {
29
+ const requestUrl = new URL(request.url);
30
+ const sourceUrl = requestUrl.searchParams.get('url');
31
+ // --- Validate params ---
32
+ if (!sourceUrl) {
33
+ return jsonError('Missing required "url" parameter', 400);
34
+ }
35
+ const w = Number(requestUrl.searchParams.get('w'));
36
+ const h = Number(requestUrl.searchParams.get('h'));
37
+ if (!w || !h || Number.isNaN(w) || Number.isNaN(h)) {
38
+ return jsonError('Missing or invalid "w" and "h" parameters', 400);
39
+ }
40
+ // --- Validate source URL ---
41
+ let parsed;
42
+ try {
43
+ parsed = new URL(sourceUrl);
44
+ }
45
+ catch {
46
+ return jsonError('Invalid "url" parameter', 400);
47
+ }
48
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
49
+ return jsonError('Only HTTP(S) URLs are allowed', 400);
50
+ }
51
+ if (parsed.username || parsed.password) {
52
+ return jsonError('URLs with credentials are not allowed', 400);
53
+ }
54
+ if (isIPAddress(parsed.hostname)) {
55
+ return jsonError('IP addresses are not allowed', 400);
56
+ }
57
+ // --- Domain validation (exact hostname match) ---
58
+ if (!allowedSet.has(parsed.hostname)) {
59
+ return jsonError(`Domain '${parsed.hostname}' is not in allowedDomains`, 403);
60
+ }
61
+ // --- Parse optional params ---
62
+ const quality = Number(requestUrl.searchParams.get('q')) || defaultQuality;
63
+ const fit = requestUrl.searchParams.get('fit') ?? 'cover';
64
+ const clampedWidth = Math.min(w, maxWidth);
65
+ const clampedHeight = Math.min(h, maxHeight);
66
+ // --- Fetch with cf.image ---
67
+ let response;
68
+ try {
69
+ // The cf.image property is a Cloudflare-specific extension to RequestInit.
70
+ // We cast to RequestInit because the standard type doesn't include `cf`,
71
+ // but Cloudflare Workers' global `fetch` accepts it at runtime.
72
+ const fetchOptions = {
73
+ redirect: 'manual',
74
+ signal: AbortSignal.timeout(fetchTimeout),
75
+ };
76
+ fetchOptions.cf = {
77
+ image: {
78
+ width: clampedWidth,
79
+ height: clampedHeight,
80
+ quality,
81
+ fit,
82
+ format: 'auto',
83
+ },
84
+ };
85
+ response = await fetch(sourceUrl, fetchOptions);
86
+ }
87
+ catch (error) {
88
+ if (error instanceof DOMException && error.name === 'TimeoutError') {
89
+ return jsonError('Source image fetch timed out', 504);
90
+ }
91
+ return jsonError('Failed to fetch source image', 502);
92
+ }
93
+ // --- Handle redirects ---
94
+ if (isRedirect(response.status)) {
95
+ return jsonError('Source image returned redirect (not followed for security)', 502);
96
+ }
97
+ // --- Handle non-200 responses ---
98
+ if (response.status === 404) {
99
+ return jsonError('Source image not found', 404);
100
+ }
101
+ if (!response.ok) {
102
+ return jsonError(`Source image returned status ${response.status}`, 502);
103
+ }
104
+ // --- Validate content type ---
105
+ const contentType = response.headers.get('Content-Type') ?? '';
106
+ if (!contentType.startsWith('image/')) {
107
+ return jsonError('Source URL did not return an image', 502);
108
+ }
109
+ // --- Determine optimization status ---
110
+ const cfResized = response.headers.get('cf-resized');
111
+ const optimized = cfResized ? 'cf' : 'passthrough';
112
+ // --- Return optimized response ---
113
+ return new Response(response.body, {
114
+ status: 200,
115
+ headers: {
116
+ 'Content-Type': contentType,
117
+ 'Cache-Control': `public, max-age=${cacheTtl}, immutable`,
118
+ 'X-Vertz-Image-Optimized': optimized,
119
+ },
120
+ });
121
+ };
122
+ }
123
+ //# sourceMappingURL=image-optimizer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"image-optimizer.js","sourceRoot":"","sources":["../src/image-optimizer.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vertz/cloudflare",
3
- "version": "0.2.14",
3
+ "version": "0.2.16",
4
4
  "description": "Cloudflare Workers adapter for vertz",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -14,6 +14,10 @@
14
14
  ".": {
15
15
  "types": "./dist/index.d.ts",
16
16
  "import": "./dist/index.js"
17
+ },
18
+ "./image": {
19
+ "types": "./dist/image-optimizer.d.ts",
20
+ "import": "./dist/image-optimizer.js"
17
21
  }
18
22
  },
19
23
  "scripts": {
@@ -22,15 +26,15 @@
22
26
  "test": "bun test"
23
27
  },
24
28
  "dependencies": {
25
- "@vertz/core": "^0.2.13"
29
+ "@vertz/core": "^0.2.15"
26
30
  },
27
31
  "devDependencies": {
28
32
  "@cloudflare/workers-types": "^4.20260305.0",
29
- "@vertz/ui-server": "^0.2.13",
33
+ "@vertz/ui-server": "^0.2.15",
30
34
  "typescript": "^5.7.3"
31
35
  },
32
36
  "peerDependencies": {
33
- "@vertz/ui-server": "^0.2.13"
37
+ "@vertz/ui-server": "^0.2.15"
34
38
  },
35
39
  "peerDependenciesMeta": {
36
40
  "@vertz/ui-server": {
package/src/handler.ts CHANGED
@@ -54,6 +54,13 @@ export interface CloudflareHandlerConfig {
54
54
 
55
55
  /** When true, adds standard security headers to all responses. */
56
56
  securityHeaders?: boolean;
57
+
58
+ /**
59
+ * Image optimizer handler for runtime image optimization at the edge.
60
+ * Created via `imageOptimizer()` from `@vertz/cloudflare/image`.
61
+ * Routes `/_vertz/image` requests to the optimizer.
62
+ */
63
+ imageOptimizer?: (request: Request) => Promise<Response>;
57
64
  }
58
65
 
59
66
  // ---------------------------------------------------------------------------
@@ -206,7 +213,7 @@ function isSSRModuleConfig(
206
213
  }
207
214
 
208
215
  function createFullStackHandler(config: CloudflareHandlerConfig): CloudflareWorkerModule {
209
- const { basePath, ssr, securityHeaders } = config;
216
+ const { basePath, ssr, securityHeaders, imageOptimizer: imageOptimizerHandler } = config;
210
217
  // Install per-request fetch proxy once (idempotent)
211
218
  installFetchProxy();
212
219
  let cachedApp: AppBuilder | null = null;
@@ -262,6 +269,12 @@ function createFullStackHandler(config: CloudflareHandlerConfig): CloudflareWork
262
269
  const url = new URL(request.url);
263
270
  const nonce = generateNonce();
264
271
 
272
+ // Image optimizer route — highest priority, before API and SSR
273
+ if (url.pathname === '/_vertz/image' && imageOptimizerHandler) {
274
+ const response = await imageOptimizerHandler(request);
275
+ return applyHeaders(response, nonce);
276
+ }
277
+
265
278
  // Route splitting: basePath/* → API handler (no URL rewriting — the
266
279
  // app's own basePath/apiPrefix handles prefix matching internally)
267
280
  if (url.pathname.startsWith(basePath)) {
@@ -0,0 +1,174 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Types
3
+ // ---------------------------------------------------------------------------
4
+
5
+ export interface ImageOptimizerConfig {
6
+ /** Required: exact hostnames allowed as image sources. SSRF protection. */
7
+ allowedDomains: string[];
8
+
9
+ /** Max output width in CSS pixels. Default: 3840 (4K). */
10
+ maxWidth?: number;
11
+
12
+ /** Max output height in CSS pixels. Default: 2160 (4K). */
13
+ maxHeight?: number;
14
+
15
+ /** Default image quality 1-100. Default: 80. */
16
+ defaultQuality?: number;
17
+
18
+ /** Cache TTL in seconds. Default: 31536000 (1 year). */
19
+ cacheTtl?: number;
20
+
21
+ /** Fetch timeout in ms. Default: 10000. */
22
+ fetchTimeout?: number;
23
+ }
24
+
25
+ export type ImageOptimizerHandler = (request: Request) => Promise<Response>;
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function jsonError(error: string, status: number): Response {
32
+ return new Response(JSON.stringify({ error, status }), {
33
+ status,
34
+ headers: { 'Content-Type': 'application/json' },
35
+ });
36
+ }
37
+
38
+ const IP_REGEX = /^(\d{1,3}\.){3}\d{1,3}$|^\[?[\da-fA-F:]+\]?$|^0x[\da-fA-F]+$|^\d{10,}$/;
39
+
40
+ function isIPAddress(hostname: string): boolean {
41
+ // Strip brackets from IPv6
42
+ const clean = hostname.replace(/^\[|\]$/g, '');
43
+ return IP_REGEX.test(clean);
44
+ }
45
+
46
+ function isRedirect(status: number): boolean {
47
+ return status >= 300 && status < 400;
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // Factory
52
+ // ---------------------------------------------------------------------------
53
+
54
+ export function imageOptimizer(config: ImageOptimizerConfig): ImageOptimizerHandler {
55
+ const {
56
+ allowedDomains,
57
+ maxWidth = 3840,
58
+ maxHeight = 2160,
59
+ defaultQuality = 80,
60
+ cacheTtl = 31536000,
61
+ fetchTimeout = 10000,
62
+ } = config;
63
+
64
+ const allowedSet = new Set(allowedDomains);
65
+
66
+ return async (request: Request): Promise<Response> => {
67
+ const requestUrl = new URL(request.url);
68
+ const sourceUrl = requestUrl.searchParams.get('url');
69
+
70
+ // --- Validate params ---
71
+ if (!sourceUrl) {
72
+ return jsonError('Missing required "url" parameter', 400);
73
+ }
74
+
75
+ const w = Number(requestUrl.searchParams.get('w'));
76
+ const h = Number(requestUrl.searchParams.get('h'));
77
+
78
+ if (!w || !h || Number.isNaN(w) || Number.isNaN(h)) {
79
+ return jsonError('Missing or invalid "w" and "h" parameters', 400);
80
+ }
81
+
82
+ // --- Validate source URL ---
83
+ let parsed: URL;
84
+ try {
85
+ parsed = new URL(sourceUrl);
86
+ } catch {
87
+ return jsonError('Invalid "url" parameter', 400);
88
+ }
89
+
90
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
91
+ return jsonError('Only HTTP(S) URLs are allowed', 400);
92
+ }
93
+
94
+ if (parsed.username || parsed.password) {
95
+ return jsonError('URLs with credentials are not allowed', 400);
96
+ }
97
+
98
+ if (isIPAddress(parsed.hostname)) {
99
+ return jsonError('IP addresses are not allowed', 400);
100
+ }
101
+
102
+ // --- Domain validation (exact hostname match) ---
103
+ if (!allowedSet.has(parsed.hostname)) {
104
+ return jsonError(`Domain '${parsed.hostname}' is not in allowedDomains`, 403);
105
+ }
106
+
107
+ // --- Parse optional params ---
108
+ const quality = Number(requestUrl.searchParams.get('q')) || defaultQuality;
109
+ const fit = requestUrl.searchParams.get('fit') ?? 'cover';
110
+ const clampedWidth = Math.min(w, maxWidth);
111
+ const clampedHeight = Math.min(h, maxHeight);
112
+
113
+ // --- Fetch with cf.image ---
114
+ let response: Response;
115
+ try {
116
+ // The cf.image property is a Cloudflare-specific extension to RequestInit.
117
+ // We cast to RequestInit because the standard type doesn't include `cf`,
118
+ // but Cloudflare Workers' global `fetch` accepts it at runtime.
119
+ const fetchOptions: RequestInit = {
120
+ redirect: 'manual',
121
+ signal: AbortSignal.timeout(fetchTimeout),
122
+ };
123
+ (fetchOptions as Record<string, unknown>).cf = {
124
+ image: {
125
+ width: clampedWidth,
126
+ height: clampedHeight,
127
+ quality,
128
+ fit,
129
+ format: 'auto',
130
+ },
131
+ };
132
+ response = await fetch(sourceUrl, fetchOptions);
133
+ } catch (error) {
134
+ if (error instanceof DOMException && error.name === 'TimeoutError') {
135
+ return jsonError('Source image fetch timed out', 504);
136
+ }
137
+ return jsonError('Failed to fetch source image', 502);
138
+ }
139
+
140
+ // --- Handle redirects ---
141
+ if (isRedirect(response.status)) {
142
+ return jsonError('Source image returned redirect (not followed for security)', 502);
143
+ }
144
+
145
+ // --- Handle non-200 responses ---
146
+ if (response.status === 404) {
147
+ return jsonError('Source image not found', 404);
148
+ }
149
+
150
+ if (!response.ok) {
151
+ return jsonError(`Source image returned status ${response.status}`, 502);
152
+ }
153
+
154
+ // --- Validate content type ---
155
+ const contentType = response.headers.get('Content-Type') ?? '';
156
+ if (!contentType.startsWith('image/')) {
157
+ return jsonError('Source URL did not return an image', 502);
158
+ }
159
+
160
+ // --- Determine optimization status ---
161
+ const cfResized = response.headers.get('cf-resized');
162
+ const optimized = cfResized ? 'cf' : 'passthrough';
163
+
164
+ // --- Return optimized response ---
165
+ return new Response(response.body, {
166
+ status: 200,
167
+ headers: {
168
+ 'Content-Type': contentType,
169
+ 'Cache-Control': `public, max-age=${cacheTtl}, immutable`,
170
+ 'X-Vertz-Image-Optimized': optimized,
171
+ },
172
+ });
173
+ };
174
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Type-level tests for createHandler config — image optimizer integration.
3
+ * Checked by `tsc --noEmit`, not by runtime tests.
4
+ */
5
+
6
+ import { createHandler } from '../src/handler.js';
7
+ import { imageOptimizer } from '../src/image-optimizer.js';
8
+
9
+ declare const app: (env: unknown) => import('@vertz/core').AppBuilder;
10
+
11
+ // ─── Valid usage ─────────────────────────────────────────────
12
+
13
+ // Config with imageOptimizer
14
+ createHandler({
15
+ app,
16
+ basePath: '/api',
17
+ imageOptimizer: imageOptimizer({ allowedDomains: ['cdn.example.com'] }),
18
+ });
19
+
20
+ // Config without imageOptimizer (optional)
21
+ createHandler({
22
+ app,
23
+ basePath: '/api',
24
+ });
25
+
26
+ // ─── Invalid usage ───────────────────────────────────────────
27
+
28
+ // @ts-expect-error — imageOptimizer must be a handler function, not a string
29
+ createHandler({ app, basePath: '/api', imageOptimizer: 'invalid' });
@@ -1,5 +1,5 @@
1
- import type { AppBuilder } from '@vertz/core';
2
1
  import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from 'bun:test';
2
+ import type { AppBuilder } from '@vertz/core';
3
3
  import { createHandler, generateHTMLTemplate, generateNonce } from '../src/handler.js';
4
4
 
5
5
  function mockApp(handler?: (...args: unknown[]) => Promise<Response>): AppBuilder {
@@ -245,7 +245,7 @@ describe('createHandler (config object)', () => {
245
245
 
246
246
  it('adds security headers when securityHeaders is true', async () => {
247
247
  const worker = createHandler({
248
- app: () => mockApp(mock().mockResolvedValue(new Response('OK'))),
248
+ app: () => mockApp(mock().mockImplementation(() => new Response('OK'))),
249
249
  basePath: '/api',
250
250
  securityHeaders: true,
251
251
  });
@@ -536,7 +536,7 @@ describe('nonce-based CSP headers', () => {
536
536
 
537
537
  it('CSP header contains nonce (not unsafe-inline) for script-src', async () => {
538
538
  const worker = createHandler({
539
- app: () => mockApp(mock().mockResolvedValue(new Response('OK'))),
539
+ app: () => mockApp(mock().mockImplementation(() => new Response('OK'))),
540
540
  basePath: '/api',
541
541
  securityHeaders: true,
542
542
  });
@@ -559,7 +559,7 @@ describe('nonce-based CSP headers', () => {
559
559
 
560
560
  it('CSP header keeps unsafe-inline for style-src', async () => {
561
561
  const worker = createHandler({
562
- app: () => mockApp(mock().mockResolvedValue(new Response('OK'))),
562
+ app: () => mockApp(mock().mockImplementation(() => new Response('OK'))),
563
563
  basePath: '/api',
564
564
  securityHeaders: true,
565
565
  });
@@ -576,7 +576,7 @@ describe('nonce-based CSP headers', () => {
576
576
 
577
577
  it('each request gets a different nonce in the CSP header', async () => {
578
578
  const worker = createHandler({
579
- app: () => mockApp(mock().mockResolvedValue(new Response('OK'))),
579
+ app: () => mockApp(mock().mockImplementation(() => new Response('OK'))),
580
580
  basePath: '/api',
581
581
  securityHeaders: true,
582
582
  });
@@ -659,7 +659,9 @@ describe('generateHTMLTemplate with nonce', () => {
659
659
  it('adds nonce attribute to script tag when nonce is provided', () => {
660
660
  const html = generateHTMLTemplate('/assets/client.js', 'My App', 'abc123');
661
661
 
662
- expect(html).toContain('<script type="module" src="/assets/client.js" nonce="abc123"></script>');
662
+ expect(html).toContain(
663
+ '<script type="module" src="/assets/client.js" nonce="abc123"></script>',
664
+ );
663
665
  });
664
666
 
665
667
  it('omits nonce attribute when nonce is not provided', () => {
@@ -669,3 +671,154 @@ describe('generateHTMLTemplate with nonce', () => {
669
671
  expect(html).not.toContain('nonce');
670
672
  });
671
673
  });
674
+
675
+ // ---------------------------------------------------------------------------
676
+ // Image optimizer integration
677
+ // ---------------------------------------------------------------------------
678
+
679
+ describe('createHandler (image optimizer integration)', () => {
680
+ const mockEnv = { DB: {} };
681
+ const mockCtx = {} as ExecutionContext;
682
+
683
+ function fakeImageOptimizerHandler(): (request: Request) => Promise<Response> {
684
+ return async () => {
685
+ return new Response('optimized-image-bytes', {
686
+ status: 200,
687
+ headers: {
688
+ 'Content-Type': 'image/webp',
689
+ 'Cache-Control': 'public, max-age=31536000, immutable',
690
+ 'X-Vertz-Image-Optimized': 'cf',
691
+ },
692
+ });
693
+ };
694
+ }
695
+
696
+ it('routes /_vertz/image requests to the image optimizer handler', async () => {
697
+ const apiHandler = mock().mockResolvedValue(new Response('API'));
698
+ const optimizerHandler = mock(fakeImageOptimizerHandler());
699
+
700
+ const worker = createHandler({
701
+ app: () => mockApp(apiHandler),
702
+ basePath: '/api',
703
+ imageOptimizer: optimizerHandler,
704
+ });
705
+
706
+ const response = await worker.fetch(
707
+ new Request(
708
+ 'https://example.com/_vertz/image?url=https%3A%2F%2Fcdn.example.com%2Fphoto.jpg&w=800&h=600',
709
+ ),
710
+ mockEnv,
711
+ mockCtx,
712
+ );
713
+
714
+ expect(optimizerHandler).toHaveBeenCalled();
715
+ expect(apiHandler).not.toHaveBeenCalled();
716
+ expect(response.headers.get('Content-Type')).toBe('image/webp');
717
+ });
718
+
719
+ it('applies security headers to optimizer responses', async () => {
720
+ const worker = createHandler({
721
+ app: () => mockApp(),
722
+ basePath: '/api',
723
+ imageOptimizer: fakeImageOptimizerHandler(),
724
+ securityHeaders: true,
725
+ });
726
+
727
+ const response = await worker.fetch(
728
+ new Request(
729
+ 'https://example.com/_vertz/image?url=https%3A%2F%2Fcdn.example.com%2Fphoto.jpg&w=800&h=600',
730
+ ),
731
+ mockEnv,
732
+ mockCtx,
733
+ );
734
+
735
+ expect(response.headers.get('X-Content-Type-Options')).toBe('nosniff');
736
+ expect(response.headers.get('X-Frame-Options')).toBe('DENY');
737
+ });
738
+
739
+ it('routes API requests to app handler (not optimizer)', async () => {
740
+ const apiHandler = mock().mockResolvedValue(
741
+ new Response('{"items":[]}', {
742
+ headers: { 'Content-Type': 'application/json' },
743
+ }),
744
+ );
745
+ const optimizerHandler = mock(fakeImageOptimizerHandler());
746
+
747
+ const worker = createHandler({
748
+ app: () => mockApp(apiHandler),
749
+ basePath: '/api',
750
+ imageOptimizer: optimizerHandler,
751
+ });
752
+
753
+ const response = await worker.fetch(
754
+ new Request('https://example.com/api/todos'),
755
+ mockEnv,
756
+ mockCtx,
757
+ );
758
+
759
+ expect(apiHandler).toHaveBeenCalled();
760
+ expect(optimizerHandler).not.toHaveBeenCalled();
761
+ expect(await response.text()).toBe('{"items":[]}');
762
+ });
763
+
764
+ it('routes non-image non-API requests to SSR handler', async () => {
765
+ const ssrHandler = mock().mockResolvedValue(
766
+ new Response('<html>SSR</html>', {
767
+ headers: { 'Content-Type': 'text/html' },
768
+ }),
769
+ );
770
+ const optimizerHandler = mock(fakeImageOptimizerHandler());
771
+
772
+ const worker = createHandler({
773
+ app: () => mockApp(),
774
+ basePath: '/api',
775
+ ssr: ssrHandler,
776
+ imageOptimizer: optimizerHandler,
777
+ });
778
+
779
+ const response = await worker.fetch(new Request('https://example.com/'), mockEnv, mockCtx);
780
+
781
+ expect(ssrHandler).toHaveBeenCalled();
782
+ expect(optimizerHandler).not.toHaveBeenCalled();
783
+ expect(await response.text()).toBe('<html>SSR</html>');
784
+ });
785
+
786
+ it('falls through to SSR or 404 when no imageOptimizer configured', async () => {
787
+ const worker = createHandler({
788
+ app: () => mockApp(),
789
+ basePath: '/api',
790
+ });
791
+
792
+ const response = await worker.fetch(
793
+ new Request('https://example.com/_vertz/image?url=https%3A%2F%2Fcdn.example.com%2Fx.jpg'),
794
+ mockEnv,
795
+ mockCtx,
796
+ );
797
+
798
+ // No optimizer → falls through to 404 (no SSR configured either)
799
+ expect(response.status).toBe(404);
800
+ });
801
+
802
+ it('image optimizer route takes priority over basePath when both could match', async () => {
803
+ const apiHandler = mock().mockResolvedValue(new Response('API'));
804
+ const optimizerHandler = mock(fakeImageOptimizerHandler());
805
+
806
+ // Edge case: basePath is /_vertz — optimizer route should still win
807
+ const worker = createHandler({
808
+ app: () => mockApp(apiHandler),
809
+ basePath: '/_vertz',
810
+ imageOptimizer: optimizerHandler,
811
+ });
812
+
813
+ await worker.fetch(
814
+ new Request(
815
+ 'https://example.com/_vertz/image?url=https%3A%2F%2Fcdn.example.com%2Fphoto.jpg&w=800&h=600',
816
+ ),
817
+ mockEnv,
818
+ mockCtx,
819
+ );
820
+
821
+ expect(optimizerHandler).toHaveBeenCalled();
822
+ expect(apiHandler).not.toHaveBeenCalled();
823
+ });
824
+ });
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Type-level tests for image optimizer config.
3
+ * Checked by `tsc --noEmit`, not by runtime tests.
4
+ */
5
+
6
+ import { imageOptimizer } from '../src/image-optimizer.js';
7
+
8
+ // ─── Valid usage ─────────────────────────────────────────────
9
+
10
+ // Minimal config
11
+ imageOptimizer({ allowedDomains: ['cdn.example.com'] });
12
+
13
+ // Full config
14
+ imageOptimizer({
15
+ allowedDomains: ['cdn.example.com', 'uploads.example.com'],
16
+ maxWidth: 3840,
17
+ maxHeight: 2160,
18
+ defaultQuality: 80,
19
+ cacheTtl: 86400,
20
+ fetchTimeout: 5000,
21
+ });
22
+
23
+ // ─── Invalid usage ───────────────────────────────────────────
24
+
25
+ // @ts-expect-error — allowedDomains must be string[], not string
26
+ imageOptimizer({ allowedDomains: 'cdn.example.com' });
27
+
28
+ // @ts-expect-error — maxWidth must be number
29
+ imageOptimizer({ allowedDomains: ['cdn.example.com'], maxWidth: '3840' });
30
+
31
+ // @ts-expect-error — missing required allowedDomains
32
+ imageOptimizer({});
@@ -0,0 +1,372 @@
1
+ import { afterEach, describe, expect, it, mock } from 'bun:test';
2
+ import { imageOptimizer } from '../src/image-optimizer.js';
3
+
4
+ // Mock global fetch for testing
5
+ const originalFetch = globalThis.fetch;
6
+
7
+ function mockFetchResponse(options: {
8
+ status?: number;
9
+ contentType?: string;
10
+ body?: string;
11
+ headers?: Record<string, string>;
12
+ }) {
13
+ const { status = 200, contentType = 'image/jpeg', body = 'image-data', headers = {} } = options;
14
+ return mock((_url: string | Request | URL, _init?: RequestInit) => {
15
+ const resHeaders = new Headers({ 'Content-Type': contentType, ...headers });
16
+ return Promise.resolve(new Response(body, { status, headers: resHeaders }));
17
+ });
18
+ }
19
+
20
+ function makeRequest(
21
+ params: Record<string, string>,
22
+ accept = 'image/avif,image/webp,*/*',
23
+ ): Request {
24
+ const url = new URL('https://example.com/_vertz/image');
25
+ for (const [k, v] of Object.entries(params)) {
26
+ url.searchParams.set(k, v);
27
+ }
28
+ return new Request(url.toString(), {
29
+ headers: { Accept: accept },
30
+ });
31
+ }
32
+
33
+ afterEach(() => {
34
+ globalThis.fetch = originalFetch;
35
+ });
36
+
37
+ describe('Feature: Edge image optimizer', () => {
38
+ describe('Given an optimizer configured with allowedDomains: ["cdn.example.com"]', () => {
39
+ const handler = imageOptimizer({ allowedDomains: ['cdn.example.com'] });
40
+
41
+ describe('When request has valid url, w, and h params', () => {
42
+ it('Then fetches the image with cf.image options', async () => {
43
+ const fetchMock = mockFetchResponse({ headers: { 'cf-resized': 'true' } });
44
+ globalThis.fetch = fetchMock;
45
+
46
+ await handler(
47
+ makeRequest({
48
+ url: 'https://cdn.example.com/photo.jpg',
49
+ w: '400',
50
+ h: '300',
51
+ }),
52
+ );
53
+
54
+ expect(fetchMock).toHaveBeenCalledTimes(1);
55
+ const [fetchUrl, fetchInit] = fetchMock.mock.calls[0] as [string, RequestInit];
56
+ expect(fetchUrl).toBe('https://cdn.example.com/photo.jpg');
57
+ const cf = (fetchInit as Record<string, unknown>).cf as {
58
+ image: Record<string, unknown>;
59
+ };
60
+ expect(cf.image.width).toBe(400);
61
+ expect(cf.image.height).toBe(300);
62
+ expect(cf.image.format).toBe('auto');
63
+ });
64
+
65
+ it('Then returns Cache-Control: public, max-age=31536000, immutable', async () => {
66
+ globalThis.fetch = mockFetchResponse({ headers: { 'cf-resized': 'true' } });
67
+
68
+ const response = await handler(
69
+ makeRequest({
70
+ url: 'https://cdn.example.com/photo.jpg',
71
+ w: '400',
72
+ h: '300',
73
+ }),
74
+ );
75
+
76
+ expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
77
+ });
78
+
79
+ it('Then returns X-Vertz-Image-Optimized: cf when cf-resized header present', async () => {
80
+ globalThis.fetch = mockFetchResponse({ headers: { 'cf-resized': 'true' } });
81
+
82
+ const response = await handler(
83
+ makeRequest({
84
+ url: 'https://cdn.example.com/photo.jpg',
85
+ w: '400',
86
+ h: '300',
87
+ }),
88
+ );
89
+
90
+ expect(response.headers.get('X-Vertz-Image-Optimized')).toBe('cf');
91
+ });
92
+
93
+ it('Then returns Content-Type from the actual response', async () => {
94
+ globalThis.fetch = mockFetchResponse({
95
+ contentType: 'image/webp',
96
+ headers: { 'cf-resized': 'true' },
97
+ });
98
+
99
+ const response = await handler(
100
+ makeRequest({
101
+ url: 'https://cdn.example.com/photo.jpg',
102
+ w: '400',
103
+ h: '300',
104
+ }),
105
+ );
106
+
107
+ expect(response.headers.get('Content-Type')).toBe('image/webp');
108
+ });
109
+ });
110
+
111
+ describe('When cf.image is not available (no cf-resized header)', () => {
112
+ it('Then returns the original image (passthrough)', async () => {
113
+ globalThis.fetch = mockFetchResponse({ contentType: 'image/jpeg' });
114
+
115
+ const response = await handler(
116
+ makeRequest({
117
+ url: 'https://cdn.example.com/photo.jpg',
118
+ w: '400',
119
+ h: '300',
120
+ }),
121
+ );
122
+
123
+ expect(response.status).toBe(200);
124
+ });
125
+
126
+ it('Then returns X-Vertz-Image-Optimized: passthrough', async () => {
127
+ globalThis.fetch = mockFetchResponse({});
128
+
129
+ const response = await handler(
130
+ makeRequest({
131
+ url: 'https://cdn.example.com/photo.jpg',
132
+ w: '400',
133
+ h: '300',
134
+ }),
135
+ );
136
+
137
+ expect(response.headers.get('X-Vertz-Image-Optimized')).toBe('passthrough');
138
+ });
139
+
140
+ it('Then still returns Cache-Control headers', async () => {
141
+ globalThis.fetch = mockFetchResponse({});
142
+
143
+ const response = await handler(
144
+ makeRequest({
145
+ url: 'https://cdn.example.com/photo.jpg',
146
+ w: '400',
147
+ h: '300',
148
+ }),
149
+ );
150
+
151
+ expect(response.headers.get('Cache-Control')).toBe('public, max-age=31536000, immutable');
152
+ });
153
+ });
154
+
155
+ describe('When request has url not in allowedDomains', () => {
156
+ it('Then returns 403 with JSON error body', async () => {
157
+ const fetchMock = mockFetchResponse({});
158
+ globalThis.fetch = fetchMock;
159
+
160
+ const response = await handler(
161
+ makeRequest({
162
+ url: 'https://evil.com/photo.jpg',
163
+ w: '400',
164
+ h: '300',
165
+ }),
166
+ );
167
+
168
+ expect(response.status).toBe(403);
169
+ const body = (await response.json()) as { error: string };
170
+ expect(body.error).toContain('not in allowedDomains');
171
+ expect(fetchMock).not.toHaveBeenCalled();
172
+ });
173
+ });
174
+
175
+ describe('When request has subdomain of allowed domain', () => {
176
+ it('Then returns 403 (exact hostname match, not subdomain match)', async () => {
177
+ const response = await handler(
178
+ makeRequest({
179
+ url: 'https://evil-cdn.example.com/photo.jpg',
180
+ w: '400',
181
+ h: '300',
182
+ }),
183
+ );
184
+
185
+ expect(response.status).toBe(403);
186
+ });
187
+ });
188
+
189
+ describe('When request is missing the url parameter', () => {
190
+ it('Then returns 400 with JSON error body', async () => {
191
+ const response = await handler(makeRequest({ w: '400', h: '300' }));
192
+
193
+ expect(response.status).toBe(400);
194
+ const body = (await response.json()) as { error: string };
195
+ expect(body.error).toContain('url');
196
+ });
197
+ });
198
+
199
+ describe('When request has a non-HTTP url', () => {
200
+ it('Then returns 400 with JSON error body', async () => {
201
+ const response = await handler(
202
+ makeRequest({
203
+ url: 'ftp://example.com/photo.jpg',
204
+ w: '400',
205
+ h: '300',
206
+ }),
207
+ );
208
+
209
+ expect(response.status).toBe(400);
210
+ const body = (await response.json()) as { error: string };
211
+ expect(body.error).toContain('HTTP');
212
+ });
213
+ });
214
+
215
+ describe('When request has an IP address URL', () => {
216
+ it('Then returns 400 (IP addresses rejected)', async () => {
217
+ const response = await handler(
218
+ makeRequest({
219
+ url: 'http://169.254.169.254/latest/meta-data/',
220
+ w: '400',
221
+ h: '300',
222
+ }),
223
+ );
224
+
225
+ expect(response.status).toBe(400);
226
+ });
227
+ });
228
+
229
+ describe('When request has a URL with credentials', () => {
230
+ it('Then returns 400 (credentials in URL rejected)', async () => {
231
+ const response = await handler(
232
+ makeRequest({
233
+ url: 'https://user:pass@cdn.example.com/photo.jpg',
234
+ w: '400',
235
+ h: '300',
236
+ }),
237
+ );
238
+
239
+ expect(response.status).toBe(400);
240
+ });
241
+ });
242
+
243
+ describe('When the source image returns a redirect', () => {
244
+ it('Then returns 502 with redirect error', async () => {
245
+ globalThis.fetch = mock(() =>
246
+ Promise.resolve(
247
+ new Response(null, {
248
+ status: 301,
249
+ headers: { Location: 'https://evil.com/hack.jpg' },
250
+ }),
251
+ ),
252
+ );
253
+
254
+ const response = await handler(
255
+ makeRequest({
256
+ url: 'https://cdn.example.com/photo.jpg',
257
+ w: '400',
258
+ h: '300',
259
+ }),
260
+ );
261
+
262
+ expect(response.status).toBe(502);
263
+ const body = (await response.json()) as { error: string };
264
+ expect(body.error).toContain('redirect');
265
+ });
266
+ });
267
+
268
+ describe('When the source image returns 404', () => {
269
+ it('Then returns 404 with JSON error body', async () => {
270
+ globalThis.fetch = mockFetchResponse({ status: 404, contentType: 'text/html' });
271
+
272
+ const response = await handler(
273
+ makeRequest({
274
+ url: 'https://cdn.example.com/missing.jpg',
275
+ w: '400',
276
+ h: '300',
277
+ }),
278
+ );
279
+
280
+ expect(response.status).toBe(404);
281
+ });
282
+ });
283
+
284
+ describe('When the source URL returns non-image Content-Type', () => {
285
+ it('Then returns 502 with not an image error', async () => {
286
+ globalThis.fetch = mockFetchResponse({ contentType: 'text/html' });
287
+
288
+ const response = await handler(
289
+ makeRequest({
290
+ url: 'https://cdn.example.com/page.html',
291
+ w: '400',
292
+ h: '300',
293
+ }),
294
+ );
295
+
296
+ expect(response.status).toBe(502);
297
+ const body = (await response.json()) as { error: string };
298
+ expect(body.error).toContain('image');
299
+ });
300
+ });
301
+
302
+ describe('When width exceeds maxWidth', () => {
303
+ const handlerWithLimits = imageOptimizer({
304
+ allowedDomains: ['cdn.example.com'],
305
+ maxWidth: 1920,
306
+ });
307
+
308
+ it('Then clamps width to maxWidth', async () => {
309
+ const fetchMock = mockFetchResponse({ headers: { 'cf-resized': 'true' } });
310
+ globalThis.fetch = fetchMock;
311
+
312
+ await handlerWithLimits(
313
+ makeRequest({
314
+ url: 'https://cdn.example.com/photo.jpg',
315
+ w: '5000',
316
+ h: '300',
317
+ }),
318
+ );
319
+
320
+ const [, fetchInit] = fetchMock.mock.calls[0] as [string, RequestInit];
321
+ const cf = (fetchInit as Record<string, unknown>).cf as {
322
+ image: Record<string, unknown>;
323
+ };
324
+ expect(cf.image.width).toBe(1920);
325
+ });
326
+ });
327
+
328
+ describe('When height exceeds maxHeight', () => {
329
+ const handlerWithLimits = imageOptimizer({
330
+ allowedDomains: ['cdn.example.com'],
331
+ maxHeight: 1080,
332
+ });
333
+
334
+ it('Then clamps height to maxHeight', async () => {
335
+ const fetchMock = mockFetchResponse({ headers: { 'cf-resized': 'true' } });
336
+ globalThis.fetch = fetchMock;
337
+
338
+ await handlerWithLimits(
339
+ makeRequest({
340
+ url: 'https://cdn.example.com/photo.jpg',
341
+ w: '400',
342
+ h: '5000',
343
+ }),
344
+ );
345
+
346
+ const [, fetchInit] = fetchMock.mock.calls[0] as [string, RequestInit];
347
+ const cf = (fetchInit as Record<string, unknown>).cf as {
348
+ image: Record<string, unknown>;
349
+ };
350
+ expect(cf.image.height).toBe(1080);
351
+ });
352
+ });
353
+
354
+ describe('When request uses redirect: manual', () => {
355
+ it('Then the fetch is called with redirect: manual', async () => {
356
+ const fetchMock = mockFetchResponse({ headers: { 'cf-resized': 'true' } });
357
+ globalThis.fetch = fetchMock;
358
+
359
+ await handler(
360
+ makeRequest({
361
+ url: 'https://cdn.example.com/photo.jpg',
362
+ w: '400',
363
+ h: '300',
364
+ }),
365
+ );
366
+
367
+ const [, fetchInit] = fetchMock.mock.calls[0] as [string, RequestInit];
368
+ expect(fetchInit.redirect).toBe('manual');
369
+ });
370
+ });
371
+ });
372
+ });