@vertz/cloudflare 0.2.15 → 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 +10 -0
- package/dist/handler.d.ts +6 -0
- package/dist/handler.d.ts.map +1 -1
- package/dist/handler.js +6 -1
- package/dist/handler.js.map +1 -1
- package/dist/image-optimizer.d.ts +17 -0
- package/dist/image-optimizer.d.ts.map +1 -0
- package/dist/image-optimizer.js +123 -0
- package/dist/image-optimizer.js.map +1 -0
- package/package.json +8 -4
- package/src/handler.ts +14 -1
- package/src/image-optimizer.ts +174 -0
- package/tests/handler.test-d.ts +29 -0
- package/tests/handler.test.ts +159 -6
- package/tests/image-optimizer.test-d.ts +32 -0
- package/tests/image-optimizer.test.ts +372 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
3
13
|
## 0.2.15
|
|
4
14
|
|
|
5
15
|
### 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;
|
package/dist/handler.d.ts.map
CHANGED
|
@@ -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;
|
|
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)) {
|
package/dist/handler.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handler.js","sourceRoot":"","sources":["../src/handler.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,kBAAkB,EAAE,MAAM,8BAA8B,CAAC;
|
|
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.
|
|
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.
|
|
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.
|
|
33
|
+
"@vertz/ui-server": "^0.2.15",
|
|
30
34
|
"typescript": "^5.7.3"
|
|
31
35
|
},
|
|
32
36
|
"peerDependencies": {
|
|
33
|
-
"@vertz/ui-server": "^0.2.
|
|
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' });
|
package/tests/handler.test.ts
CHANGED
|
@@ -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().
|
|
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().
|
|
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().
|
|
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().
|
|
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(
|
|
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
|
+
});
|