@opennextjs/cloudflare 1.13.1 → 1.14.0
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.
|
@@ -7,6 +7,7 @@ import type { PREFIX_ENV_NAME as R2_CACHE_PREFIX_ENV_NAME } from "./overrides/in
|
|
|
7
7
|
declare global {
|
|
8
8
|
interface CloudflareEnv {
|
|
9
9
|
ASSETS?: Fetcher;
|
|
10
|
+
IMAGES?: ImagesBinding;
|
|
10
11
|
NEXTJS_ENV?: string;
|
|
11
12
|
WORKER_SELF_REFERENCE?: Service;
|
|
12
13
|
NEXT_INC_CACHE_KV?: KVNamespace;
|
|
@@ -14,14 +14,20 @@ export async function compileImages(options) {
|
|
|
14
14
|
? JSON.parse(fs.readFileSync(imagesManifestPath, { encoding: "utf-8" }))
|
|
15
15
|
: {};
|
|
16
16
|
const __IMAGES_REMOTE_PATTERNS__ = JSON.stringify(imagesManifest?.images?.remotePatterns ?? []);
|
|
17
|
-
const __IMAGES_LOCAL_PATTERNS__ = JSON.stringify(imagesManifest?.images?.localPatterns ??
|
|
17
|
+
const __IMAGES_LOCAL_PATTERNS__ = JSON.stringify(imagesManifest?.images?.localPatterns ?? defaultLocalPatterns);
|
|
18
|
+
const __IMAGES_DEVICE_SIZES__ = JSON.stringify(imagesManifest?.images?.deviceSizes ?? defaultDeviceSizes);
|
|
19
|
+
const __IMAGES_IMAGE_SIZES__ = JSON.stringify(imagesManifest?.images?.imageSizes ?? defaultImageSizes);
|
|
20
|
+
const __IMAGES_QUALITIES__ = JSON.stringify(imagesManifest?.images?.qualities ?? defaultQualities);
|
|
21
|
+
const __IMAGES_FORMATS__ = JSON.stringify(imagesManifest?.images?.formats ?? defaultFormats);
|
|
22
|
+
const __IMAGES_MINIMUM_CACHE_TTL_SEC__ = JSON.stringify(imagesManifest?.images?.minimumCacheTTL ?? defaultMinimumCacheTTLSec);
|
|
18
23
|
const __IMAGES_ALLOW_SVG__ = JSON.stringify(Boolean(imagesManifest?.images?.dangerouslyAllowSVG));
|
|
19
24
|
const __IMAGES_CONTENT_SECURITY_POLICY__ = JSON.stringify(imagesManifest?.images?.contentSecurityPolicy ?? "script-src 'none'; frame-src 'none'; sandbox;");
|
|
20
25
|
const __IMAGES_CONTENT_DISPOSITION__ = JSON.stringify(imagesManifest?.images?.contentDispositionType ?? "attachment");
|
|
26
|
+
const __IMAGES_MAX_REDIRECTS__ = JSON.stringify(imagesManifest?.images?.maximumRedirects ?? defaultMaxRedirects);
|
|
21
27
|
await build({
|
|
22
28
|
entryPoints: [imagesPath],
|
|
23
29
|
outdir: path.join(options.outputDir, "cloudflare"),
|
|
24
|
-
bundle:
|
|
30
|
+
bundle: true,
|
|
25
31
|
minify: false,
|
|
26
32
|
format: "esm",
|
|
27
33
|
target: "esnext",
|
|
@@ -29,9 +35,25 @@ export async function compileImages(options) {
|
|
|
29
35
|
define: {
|
|
30
36
|
__IMAGES_REMOTE_PATTERNS__,
|
|
31
37
|
__IMAGES_LOCAL_PATTERNS__,
|
|
38
|
+
__IMAGES_DEVICE_SIZES__,
|
|
39
|
+
__IMAGES_IMAGE_SIZES__,
|
|
40
|
+
__IMAGES_QUALITIES__,
|
|
41
|
+
__IMAGES_FORMATS__,
|
|
42
|
+
__IMAGES_MINIMUM_CACHE_TTL_SEC__,
|
|
32
43
|
__IMAGES_ALLOW_SVG__,
|
|
33
44
|
__IMAGES_CONTENT_SECURITY_POLICY__,
|
|
34
45
|
__IMAGES_CONTENT_DISPOSITION__,
|
|
46
|
+
__IMAGES_MAX_REDIRECTS__,
|
|
35
47
|
},
|
|
36
48
|
});
|
|
37
49
|
}
|
|
50
|
+
const defaultDeviceSizes = [640, 750, 828, 1080, 1200, 1920, 2048, 3840];
|
|
51
|
+
// 16 was included in Next.js 15
|
|
52
|
+
const defaultImageSizes = [32, 48, 64, 96, 128, 256, 384];
|
|
53
|
+
// All values between 1-100 were allowed in Next.js 15
|
|
54
|
+
const defaultQualities = [75];
|
|
55
|
+
// Was unlimited in Next.js 15
|
|
56
|
+
const defaultMaxRedirects = 3;
|
|
57
|
+
const defaultFormats = ["image/webp"];
|
|
58
|
+
const defaultMinimumCacheTTLSec = 14400;
|
|
59
|
+
const defaultLocalPatterns = { pathname: "/**" };
|
|
@@ -10,14 +10,25 @@ export type LocalPattern = {
|
|
|
10
10
|
search?: string;
|
|
11
11
|
};
|
|
12
12
|
/**
|
|
13
|
-
*
|
|
13
|
+
* Handles requests to /_next/image(/), including image optimizations.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* Image optimization is disabled and the original image is returned if `env.IMAGES` is undefined.
|
|
16
|
+
*
|
|
17
|
+
* Throws an exception on unexpected errors.
|
|
18
|
+
*
|
|
19
|
+
* @param requestURL
|
|
20
|
+
* @param requestHeaders
|
|
21
|
+
* @param env
|
|
22
|
+
* @returns A promise that resolves to the resolved request.
|
|
17
23
|
*/
|
|
18
|
-
export declare function
|
|
24
|
+
export declare function handleImageRequest(requestURL: URL, requestHeaders: Headers, env: CloudflareEnv): Promise<Response>;
|
|
25
|
+
export type OptimizedImageFormat = "image/avif" | "image/webp";
|
|
26
|
+
export declare function matchLocalPattern(pattern: LocalPattern, url: {
|
|
27
|
+
pathname: string;
|
|
28
|
+
search: string;
|
|
29
|
+
}): boolean;
|
|
19
30
|
export declare function matchRemotePattern(pattern: RemotePattern, url: URL): boolean;
|
|
20
|
-
|
|
31
|
+
type ImageContentType = "image/avif" | "image/webp" | "image/png" | "image/jpeg" | "image/jxl" | "image/jp2" | "image/heic" | "image/gif" | "image/svg+xml" | "image/x-icon" | "image/x-icns" | "image/tiff" | "image/bmp";
|
|
21
32
|
/**
|
|
22
33
|
* Detects the content type by looking at the first few bytes of a file
|
|
23
34
|
*
|
|
@@ -26,11 +37,19 @@ export declare function matchLocalPattern(pattern: LocalPattern, url: URL): bool
|
|
|
26
37
|
* @param buffer The image bytes
|
|
27
38
|
* @returns a content type of undefined for unsupported content
|
|
28
39
|
*/
|
|
29
|
-
export declare function
|
|
40
|
+
export declare function detectImageContentType(buffer: Uint8Array): ImageContentType | null;
|
|
30
41
|
declare global {
|
|
31
42
|
var __IMAGES_REMOTE_PATTERNS__: RemotePattern[];
|
|
32
43
|
var __IMAGES_LOCAL_PATTERNS__: LocalPattern[];
|
|
44
|
+
var __IMAGES_DEVICE_SIZES__: number[];
|
|
45
|
+
var __IMAGES_IMAGE_SIZES__: number[];
|
|
46
|
+
var __IMAGES_QUALITIES__: number[];
|
|
47
|
+
var __IMAGES_FORMATS__: NextConfigImageFormat[];
|
|
48
|
+
var __IMAGES_MINIMUM_CACHE_TTL_SEC__: number;
|
|
33
49
|
var __IMAGES_ALLOW_SVG__: boolean;
|
|
34
50
|
var __IMAGES_CONTENT_SECURITY_POLICY__: string;
|
|
35
51
|
var __IMAGES_CONTENT_DISPOSITION__: string;
|
|
52
|
+
var __IMAGES_MAX_REDIRECTS__: number;
|
|
53
|
+
type NextConfigImageFormat = "image/avif" | "image/webp";
|
|
36
54
|
}
|
|
55
|
+
export {};
|
|
@@ -1,97 +1,488 @@
|
|
|
1
|
-
|
|
1
|
+
import { error, warn } from "@opennextjs/aws/adapters/logger.js";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
3
|
+
* Handles requests to /_next/image(/), including image optimizations.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
5
|
+
* Image optimization is disabled and the original image is returned if `env.IMAGES` is undefined.
|
|
6
|
+
*
|
|
7
|
+
* Throws an exception on unexpected errors.
|
|
8
|
+
*
|
|
9
|
+
* @param requestURL
|
|
10
|
+
* @param requestHeaders
|
|
11
|
+
* @param env
|
|
12
|
+
* @returns A promise that resolves to the resolved request.
|
|
7
13
|
*/
|
|
8
|
-
export async function
|
|
9
|
-
|
|
10
|
-
if (!
|
|
11
|
-
return
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
|
|
14
|
+
export async function handleImageRequest(requestURL, requestHeaders, env) {
|
|
15
|
+
const parseResult = parseImageRequest(requestURL, requestHeaders);
|
|
16
|
+
if (!parseResult.ok) {
|
|
17
|
+
return new Response(parseResult.message, {
|
|
18
|
+
status: 400,
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
let imageResponse;
|
|
22
|
+
if (parseResult.url.startsWith("/")) {
|
|
23
|
+
if (env.ASSETS === undefined) {
|
|
24
|
+
error("env.ASSETS binding is not defined");
|
|
25
|
+
return new Response('"url" parameter is valid but upstream response is invalid', {
|
|
26
|
+
status: 404,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
const absoluteURL = new URL(parseResult.url, requestURL);
|
|
30
|
+
imageResponse = await env.ASSETS.fetch(absoluteURL);
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
let fetchImageResult;
|
|
34
|
+
try {
|
|
35
|
+
fetchImageResult = await fetchWithRedirects(parseResult.url, 7_000, __IMAGES_MAX_REDIRECTS__);
|
|
19
36
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
NEXT_IMAGE_REGEXP ??= /\/_next\/image($|\/)/;
|
|
23
|
-
if (NEXT_IMAGE_REGEXP.test(pathname)) {
|
|
24
|
-
return getUrlErrorResponse();
|
|
37
|
+
catch (e) {
|
|
38
|
+
throw new Error("Failed to fetch image", { cause: e });
|
|
25
39
|
}
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
40
|
+
if (!fetchImageResult.ok) {
|
|
41
|
+
if (fetchImageResult.error === "timed_out") {
|
|
42
|
+
return new Response('"url" parameter is valid but upstream response timed out', {
|
|
43
|
+
status: 504,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (fetchImageResult.error === "too_many_redirects") {
|
|
47
|
+
return new Response('"url" parameter is valid but upstream response is invalid', {
|
|
48
|
+
status: 508,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
throw new Error("Failed to fetch image");
|
|
30
52
|
}
|
|
31
|
-
|
|
53
|
+
imageResponse = fetchImageResult.response;
|
|
32
54
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
55
|
+
if (!imageResponse.ok || imageResponse.body === null) {
|
|
56
|
+
return new Response('"url" parameter is valid but upstream response is invalid', {
|
|
57
|
+
status: imageResponse.status,
|
|
58
|
+
});
|
|
37
59
|
}
|
|
38
|
-
|
|
39
|
-
|
|
60
|
+
let immutable = false;
|
|
61
|
+
if (parseResult.static) {
|
|
62
|
+
immutable = true;
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const cacheControlHeader = imageResponse.headers.get("Cache-Control");
|
|
66
|
+
if (cacheControlHeader !== null) {
|
|
67
|
+
// TODO: Properly parse header
|
|
68
|
+
immutable = cacheControlHeader.includes("immutable");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const [contentTypeImageStream, imageStream] = imageResponse.body.tee();
|
|
72
|
+
const imageHeaderBytes = new Uint8Array(32);
|
|
73
|
+
const contentTypeImageReader = contentTypeImageStream.getReader({
|
|
74
|
+
mode: "byob",
|
|
75
|
+
});
|
|
76
|
+
const readImageHeaderBytesResult = await contentTypeImageReader.readAtLeast(32, imageHeaderBytes);
|
|
77
|
+
if (readImageHeaderBytesResult.value === undefined) {
|
|
78
|
+
await imageResponse.body.cancel();
|
|
79
|
+
return new Response('"url" parameter is valid but upstream response is invalid', {
|
|
80
|
+
status: 400,
|
|
81
|
+
});
|
|
40
82
|
}
|
|
41
|
-
|
|
42
|
-
|
|
83
|
+
const contentType = detectImageContentType(readImageHeaderBytesResult.value);
|
|
84
|
+
if (contentType === null) {
|
|
85
|
+
warn(`Failed to detect content type of "${parseResult.url}"`);
|
|
86
|
+
return new Response('"url" parameter is valid but image type is not allowed', {
|
|
87
|
+
status: 400,
|
|
88
|
+
});
|
|
43
89
|
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
90
|
+
if (contentType === SVG) {
|
|
91
|
+
if (!__IMAGES_ALLOW_SVG__) {
|
|
92
|
+
return new Response('"url" parameter is valid but image type is not allowed', {
|
|
93
|
+
status: 400,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
const response = createImageResponse(imageStream, contentType, {
|
|
97
|
+
immutable,
|
|
98
|
+
});
|
|
99
|
+
return response;
|
|
47
100
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
101
|
+
if (contentType === GIF) {
|
|
102
|
+
if (env.IMAGES === undefined) {
|
|
103
|
+
warn("env.IMAGES binding is not defined");
|
|
104
|
+
const response = createImageResponse(imageStream, contentType, {
|
|
105
|
+
immutable,
|
|
106
|
+
});
|
|
107
|
+
return response;
|
|
108
|
+
}
|
|
109
|
+
const imageSource = env.IMAGES.input(imageStream);
|
|
110
|
+
const imageTransformationResult = await imageSource
|
|
111
|
+
.transform({
|
|
112
|
+
width: parseResult.width,
|
|
113
|
+
fit: "scale-down",
|
|
114
|
+
})
|
|
115
|
+
.output({
|
|
116
|
+
quality: parseResult.quality,
|
|
117
|
+
format: GIF,
|
|
118
|
+
});
|
|
119
|
+
const outputImageStream = imageTransformationResult.image();
|
|
120
|
+
const response = createImageResponse(outputImageStream, GIF, {
|
|
121
|
+
immutable,
|
|
122
|
+
});
|
|
123
|
+
return response;
|
|
51
124
|
}
|
|
52
|
-
|
|
125
|
+
if (contentType === AVIF || contentType === WEBP || contentType === JPEG || contentType === PNG) {
|
|
126
|
+
if (env.IMAGES === undefined) {
|
|
127
|
+
warn("env.IMAGES binding is not defined");
|
|
128
|
+
const response = createImageResponse(imageStream, contentType, {
|
|
129
|
+
immutable,
|
|
130
|
+
});
|
|
131
|
+
return response;
|
|
132
|
+
}
|
|
133
|
+
const outputFormat = parseResult.format ?? contentType;
|
|
134
|
+
const imageSource = env.IMAGES.input(imageStream);
|
|
135
|
+
const imageTransformationResult = await imageSource
|
|
136
|
+
.transform({
|
|
137
|
+
width: parseResult.width,
|
|
138
|
+
fit: "scale-down",
|
|
139
|
+
})
|
|
140
|
+
.output({
|
|
141
|
+
quality: parseResult.quality,
|
|
142
|
+
format: outputFormat,
|
|
143
|
+
});
|
|
144
|
+
const outputImageStream = imageTransformationResult.image();
|
|
145
|
+
const response = createImageResponse(outputImageStream, outputFormat, {
|
|
146
|
+
immutable,
|
|
147
|
+
});
|
|
148
|
+
return response;
|
|
149
|
+
}
|
|
150
|
+
warn(`Image content type ${contentType} not supported`);
|
|
151
|
+
const response = createImageResponse(imageStream, contentType, {
|
|
152
|
+
immutable,
|
|
153
|
+
});
|
|
154
|
+
return response;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Fetch call with max redirects and timeouts.
|
|
158
|
+
*
|
|
159
|
+
* Re-throws the exception thrown by a fetch call.
|
|
160
|
+
* @param url
|
|
161
|
+
* @param timeoutMS Timeout for a single fetch call.
|
|
162
|
+
* @param maxRedirectCount
|
|
163
|
+
* @returns
|
|
164
|
+
*/
|
|
165
|
+
async function fetchWithRedirects(url, timeoutMS, maxRedirectCount) {
|
|
166
|
+
// TODO: Add dangerouslyAllowLocalIP support
|
|
167
|
+
let response;
|
|
53
168
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
169
|
+
response = await fetch(url, {
|
|
170
|
+
signal: AbortSignal.timeout(timeoutMS),
|
|
171
|
+
redirect: "manual",
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
if (e instanceof Error && e.name === "TimeoutError") {
|
|
176
|
+
const result = {
|
|
177
|
+
ok: false,
|
|
178
|
+
error: "timed_out",
|
|
179
|
+
};
|
|
180
|
+
return result;
|
|
65
181
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
182
|
+
throw e;
|
|
183
|
+
}
|
|
184
|
+
if (redirectResponseStatuses.includes(response.status)) {
|
|
185
|
+
const locationHeader = response.headers.get("Location");
|
|
186
|
+
if (locationHeader !== null) {
|
|
187
|
+
if (maxRedirectCount < 1) {
|
|
188
|
+
const result = {
|
|
189
|
+
ok: false,
|
|
190
|
+
error: "too_many_redirects",
|
|
191
|
+
};
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
let redirectTarget;
|
|
195
|
+
if (locationHeader.startsWith("/")) {
|
|
196
|
+
redirectTarget = new URL(locationHeader, url).href;
|
|
197
|
+
}
|
|
198
|
+
else {
|
|
199
|
+
redirectTarget = locationHeader;
|
|
200
|
+
}
|
|
201
|
+
const result = await fetchWithRedirects(redirectTarget, timeoutMS, maxRedirectCount - 1);
|
|
202
|
+
return result;
|
|
70
203
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
204
|
+
}
|
|
205
|
+
const result = {
|
|
206
|
+
ok: true,
|
|
207
|
+
response: response,
|
|
208
|
+
};
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
const redirectResponseStatuses = [301, 302, 303, 307, 308];
|
|
212
|
+
function createImageResponse(image, contentType, imageResponseFlags) {
|
|
213
|
+
const response = new Response(image, {
|
|
214
|
+
headers: {
|
|
215
|
+
Vary: "Accept",
|
|
216
|
+
"Content-Type": contentType,
|
|
217
|
+
"Content-Disposition": __IMAGES_CONTENT_DISPOSITION__,
|
|
218
|
+
"Content-Security-Policy": __IMAGES_CONTENT_SECURITY_POLICY__,
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
if (imageResponseFlags.immutable) {
|
|
222
|
+
response.headers.set("Cache-Control", "public, max-age=315360000, immutable");
|
|
223
|
+
}
|
|
224
|
+
return response;
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Parses the image request URL and headers.
|
|
228
|
+
*
|
|
229
|
+
* This function validates the parameters and returns either the parsed result or an error message.
|
|
230
|
+
*
|
|
231
|
+
* @param requestURL request URL
|
|
232
|
+
* @param requestHeaders request headers
|
|
233
|
+
* @returns an instance of `ParseImageRequestURLSuccessResult` when successful, or an instance of `ErrorResult` when failed.
|
|
234
|
+
*/
|
|
235
|
+
function parseImageRequest(requestURL, requestHeaders) {
|
|
236
|
+
const formats = __IMAGES_FORMATS__;
|
|
237
|
+
const parsedUrlOrError = validateUrlQueryParameter(requestURL);
|
|
238
|
+
if (!("url" in parsedUrlOrError)) {
|
|
239
|
+
return parsedUrlOrError;
|
|
240
|
+
}
|
|
241
|
+
const widthOrError = validateWidthQueryParameter(requestURL);
|
|
242
|
+
if (typeof widthOrError !== "number") {
|
|
243
|
+
return widthOrError;
|
|
244
|
+
}
|
|
245
|
+
const qualityOrError = validateQualityQueryParameter(requestURL);
|
|
246
|
+
if (typeof qualityOrError !== "number") {
|
|
247
|
+
return qualityOrError;
|
|
248
|
+
}
|
|
249
|
+
const acceptHeader = requestHeaders.get("Accept") ?? "";
|
|
250
|
+
let format = null;
|
|
251
|
+
// Find a more specific format that the client accepts.
|
|
252
|
+
for (const allowedFormat of formats) {
|
|
253
|
+
if (acceptHeader.includes(allowedFormat)) {
|
|
254
|
+
format = allowedFormat;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const result = {
|
|
259
|
+
ok: true,
|
|
260
|
+
url: parsedUrlOrError.url,
|
|
261
|
+
width: widthOrError,
|
|
262
|
+
quality: qualityOrError,
|
|
263
|
+
format,
|
|
264
|
+
static: parsedUrlOrError.static,
|
|
265
|
+
};
|
|
266
|
+
return result;
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
269
|
+
* Validates that there is exactly one "url" query parameter.
|
|
270
|
+
*
|
|
271
|
+
* @returns the validated URL or an error result.
|
|
272
|
+
*/
|
|
273
|
+
function validateUrlQueryParameter(requestURL) {
|
|
274
|
+
// There should be a single "url" parameter.
|
|
275
|
+
const urls = requestURL.searchParams.getAll("url");
|
|
276
|
+
if (urls.length < 1) {
|
|
277
|
+
const result = {
|
|
278
|
+
ok: false,
|
|
279
|
+
message: '"url" parameter is required',
|
|
280
|
+
};
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
if (urls.length > 1) {
|
|
284
|
+
const result = {
|
|
285
|
+
ok: false,
|
|
286
|
+
message: '"url" parameter cannot be an array',
|
|
287
|
+
};
|
|
288
|
+
return result;
|
|
289
|
+
}
|
|
290
|
+
// The url parameter value should be a valid URL or a valid relative URL.
|
|
291
|
+
const url = urls[0];
|
|
292
|
+
if (url.length > 3072) {
|
|
293
|
+
const result = {
|
|
294
|
+
ok: false,
|
|
295
|
+
message: '"url" parameter is too long',
|
|
296
|
+
};
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
if (url.startsWith("//")) {
|
|
300
|
+
const result = {
|
|
301
|
+
ok: false,
|
|
302
|
+
message: '"url" parameter cannot be a protocol-relative URL (//)',
|
|
303
|
+
};
|
|
304
|
+
return result;
|
|
305
|
+
}
|
|
306
|
+
if (url.startsWith("/")) {
|
|
307
|
+
const staticAsset = url.startsWith(`${__NEXT_BASE_PATH__ || ""}/_next/static/media`);
|
|
308
|
+
const pathname = getPathnameFromRelativeURL(url);
|
|
309
|
+
if (/\/_next\/image($|\/)/.test(decodeURIComponent(pathname))) {
|
|
310
|
+
const result = {
|
|
311
|
+
ok: false,
|
|
312
|
+
message: '"url" parameter cannot be recursive',
|
|
313
|
+
};
|
|
314
|
+
return result;
|
|
76
315
|
}
|
|
77
|
-
if (
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
return new Response(respBody, { ...imgResponse, headers });
|
|
316
|
+
if (!staticAsset) {
|
|
317
|
+
if (!hasLocalMatch(__IMAGES_LOCAL_PATTERNS__, url)) {
|
|
318
|
+
const result = { ok: false, message: '"url" parameter is not allowed' };
|
|
319
|
+
return result;
|
|
320
|
+
}
|
|
83
321
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
322
|
+
return { url, static: staticAsset };
|
|
323
|
+
}
|
|
324
|
+
let parsedURL;
|
|
325
|
+
try {
|
|
326
|
+
parsedURL = new URL(url);
|
|
89
327
|
}
|
|
90
328
|
catch {
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
329
|
+
const result = { ok: false, message: '"url" parameter is invalid' };
|
|
330
|
+
return result;
|
|
331
|
+
}
|
|
332
|
+
const validProtocols = ["http:", "https:"];
|
|
333
|
+
if (!validProtocols.includes(parsedURL.protocol)) {
|
|
334
|
+
const result = {
|
|
335
|
+
ok: false,
|
|
336
|
+
message: '"url" parameter is invalid',
|
|
337
|
+
};
|
|
338
|
+
return result;
|
|
339
|
+
}
|
|
340
|
+
if (!hasRemoteMatch(__IMAGES_REMOTE_PATTERNS__, parsedURL)) {
|
|
341
|
+
const result = {
|
|
342
|
+
ok: false,
|
|
343
|
+
message: '"url" parameter is not allowed',
|
|
344
|
+
};
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
return { url: parsedURL.href, static: false };
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Validates the "w" (width) query parameter.
|
|
351
|
+
*
|
|
352
|
+
* @returns the validated width number or an error result.
|
|
353
|
+
*/
|
|
354
|
+
function validateWidthQueryParameter(requestURL) {
|
|
355
|
+
const widthQueryValues = requestURL.searchParams.getAll("w");
|
|
356
|
+
if (widthQueryValues.length < 1) {
|
|
357
|
+
const result = {
|
|
358
|
+
ok: false,
|
|
359
|
+
message: '"w" parameter (width) is required',
|
|
360
|
+
};
|
|
361
|
+
return result;
|
|
362
|
+
}
|
|
363
|
+
if (widthQueryValues.length > 1) {
|
|
364
|
+
const result = {
|
|
365
|
+
ok: false,
|
|
366
|
+
message: '"w" parameter (width) cannot be an array',
|
|
367
|
+
};
|
|
368
|
+
return result;
|
|
369
|
+
}
|
|
370
|
+
const widthQueryValue = widthQueryValues[0];
|
|
371
|
+
if (!/^[0-9]+$/.test(widthQueryValue)) {
|
|
372
|
+
const result = {
|
|
373
|
+
ok: false,
|
|
374
|
+
message: '"w" parameter (width) must be an integer greater than 0',
|
|
375
|
+
};
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
const width = parseInt(widthQueryValue, 10);
|
|
379
|
+
if (width <= 0 || isNaN(width)) {
|
|
380
|
+
const result = {
|
|
381
|
+
ok: false,
|
|
382
|
+
message: '"w" parameter (width) must be an integer greater than 0',
|
|
383
|
+
};
|
|
384
|
+
return result;
|
|
385
|
+
}
|
|
386
|
+
const sizeValid = __IMAGES_DEVICE_SIZES__.includes(width) || __IMAGES_IMAGE_SIZES__.includes(width);
|
|
387
|
+
if (!sizeValid) {
|
|
388
|
+
const result = {
|
|
389
|
+
ok: false,
|
|
390
|
+
message: `"w" parameter (width) of ${width} is not allowed`,
|
|
391
|
+
};
|
|
392
|
+
return result;
|
|
94
393
|
}
|
|
394
|
+
return width;
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Validates the "q" (quality) query parameter.
|
|
398
|
+
*
|
|
399
|
+
* @returns the validated quality number or an error result.
|
|
400
|
+
*/
|
|
401
|
+
function validateQualityQueryParameter(requestURL) {
|
|
402
|
+
const qualityQueryValues = requestURL.searchParams.getAll("q");
|
|
403
|
+
if (qualityQueryValues.length < 1) {
|
|
404
|
+
const result = {
|
|
405
|
+
ok: false,
|
|
406
|
+
message: '"q" parameter (quality) is required',
|
|
407
|
+
};
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
if (qualityQueryValues.length > 1) {
|
|
411
|
+
const result = {
|
|
412
|
+
ok: false,
|
|
413
|
+
message: '"q" parameter (quality) cannot be an array',
|
|
414
|
+
};
|
|
415
|
+
return result;
|
|
416
|
+
}
|
|
417
|
+
const qualityQueryValue = qualityQueryValues[0];
|
|
418
|
+
if (!/^[0-9]+$/.test(qualityQueryValue)) {
|
|
419
|
+
const result = {
|
|
420
|
+
ok: false,
|
|
421
|
+
message: '"q" parameter (quality) must be an integer between 1 and 100',
|
|
422
|
+
};
|
|
423
|
+
return result;
|
|
424
|
+
}
|
|
425
|
+
const quality = parseInt(qualityQueryValue, 10);
|
|
426
|
+
if (isNaN(quality) || quality < 1 || quality > 100) {
|
|
427
|
+
const result = {
|
|
428
|
+
ok: false,
|
|
429
|
+
message: '"q" parameter (quality) must be an integer between 1 and 100',
|
|
430
|
+
};
|
|
431
|
+
return result;
|
|
432
|
+
}
|
|
433
|
+
if (!__IMAGES_QUALITIES__.includes(quality)) {
|
|
434
|
+
const result = {
|
|
435
|
+
ok: false,
|
|
436
|
+
message: `"q" parameter (quality) of ${quality} is not allowed`,
|
|
437
|
+
};
|
|
438
|
+
return result;
|
|
439
|
+
}
|
|
440
|
+
return quality;
|
|
441
|
+
}
|
|
442
|
+
function getPathnameFromRelativeURL(relativeURL) {
|
|
443
|
+
return relativeURL.split("?")[0];
|
|
444
|
+
}
|
|
445
|
+
function hasLocalMatch(localPatterns, relativeURL) {
|
|
446
|
+
const parseRelativeURLResult = parseRelativeURL(relativeURL);
|
|
447
|
+
for (const localPattern of localPatterns) {
|
|
448
|
+
const matched = matchLocalPattern(localPattern, parseRelativeURLResult);
|
|
449
|
+
if (matched) {
|
|
450
|
+
return true;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
return false;
|
|
454
|
+
}
|
|
455
|
+
function parseRelativeURL(relativeURL) {
|
|
456
|
+
if (!relativeURL.includes("?")) {
|
|
457
|
+
const result = {
|
|
458
|
+
pathname: relativeURL,
|
|
459
|
+
search: "",
|
|
460
|
+
};
|
|
461
|
+
return result;
|
|
462
|
+
}
|
|
463
|
+
const parts = relativeURL.split("?");
|
|
464
|
+
const pathname = parts[0];
|
|
465
|
+
const search = "?" + parts.slice(1).join("?");
|
|
466
|
+
const result = {
|
|
467
|
+
pathname,
|
|
468
|
+
search,
|
|
469
|
+
};
|
|
470
|
+
return result;
|
|
471
|
+
}
|
|
472
|
+
export function matchLocalPattern(pattern, url) {
|
|
473
|
+
if (pattern.search !== undefined && pattern.search !== url.search) {
|
|
474
|
+
return false;
|
|
475
|
+
}
|
|
476
|
+
return new RegExp(pattern.pathname).test(url.pathname);
|
|
477
|
+
}
|
|
478
|
+
function hasRemoteMatch(remotePatterns, url) {
|
|
479
|
+
for (const remotePattern of remotePatterns) {
|
|
480
|
+
const matched = matchRemotePattern(remotePattern, url);
|
|
481
|
+
if (matched) {
|
|
482
|
+
return true;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return false;
|
|
95
486
|
}
|
|
96
487
|
export function matchRemotePattern(pattern, url) {
|
|
97
488
|
// https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/shared/lib/match-remote-pattern.ts
|
|
@@ -111,19 +502,6 @@ export function matchRemotePattern(pattern, url) {
|
|
|
111
502
|
// Should be the same as writeImagesManifest()
|
|
112
503
|
return new RegExp(pattern.pathname).test(url.pathname);
|
|
113
504
|
}
|
|
114
|
-
export function matchLocalPattern(pattern, url) {
|
|
115
|
-
// https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/shared/lib/match-local-pattern.ts
|
|
116
|
-
if (pattern.search !== undefined && pattern.search !== url.search) {
|
|
117
|
-
return false;
|
|
118
|
-
}
|
|
119
|
-
return new RegExp(pattern.pathname).test(url.pathname);
|
|
120
|
-
}
|
|
121
|
-
/**
|
|
122
|
-
* @returns same error as Next.js when the url query parameter is not accepted.
|
|
123
|
-
*/
|
|
124
|
-
function getUrlErrorResponse() {
|
|
125
|
-
return new Response(`"url" parameter is not allowed`, { status: 400 });
|
|
126
|
-
}
|
|
127
505
|
const AVIF = "image/avif";
|
|
128
506
|
const WEBP = "image/webp";
|
|
129
507
|
const PNG = "image/png";
|
|
@@ -137,8 +515,6 @@ const ICO = "image/x-icon";
|
|
|
137
515
|
const ICNS = "image/x-icns";
|
|
138
516
|
const TIFF = "image/tiff";
|
|
139
517
|
const BMP = "image/bmp";
|
|
140
|
-
// pdf will be rejected (not an `image/...` type)
|
|
141
|
-
const PDF = "application/pdf";
|
|
142
518
|
/**
|
|
143
519
|
* Detects the content type by looking at the first few bytes of a file
|
|
144
520
|
*
|
|
@@ -147,7 +523,7 @@ const PDF = "application/pdf";
|
|
|
147
523
|
* @param buffer The image bytes
|
|
148
524
|
* @returns a content type of undefined for unsupported content
|
|
149
525
|
*/
|
|
150
|
-
export function
|
|
526
|
+
export function detectImageContentType(buffer) {
|
|
151
527
|
if ([0xff, 0xd8, 0xff].every((b, i) => buffer[i] === b)) {
|
|
152
528
|
return JPEG;
|
|
153
529
|
}
|
|
@@ -190,10 +566,8 @@ export function detectContentType(buffer) {
|
|
|
190
566
|
if ([0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63].every((b, i) => !b || buffer[i] === b)) {
|
|
191
567
|
return HEIC;
|
|
192
568
|
}
|
|
193
|
-
if ([0x25, 0x50, 0x44, 0x46, 0x2d].every((b, i) => buffer[i] === b)) {
|
|
194
|
-
return PDF;
|
|
195
|
-
}
|
|
196
569
|
if ([0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a].every((b, i) => buffer[i] === b)) {
|
|
197
570
|
return JP2;
|
|
198
571
|
}
|
|
572
|
+
return null;
|
|
199
573
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
//@ts-expect-error: Will be resolved by wrangler build
|
|
2
|
-
import {
|
|
2
|
+
import { handleImageRequest } from "./cloudflare/images.js";
|
|
3
3
|
//@ts-expect-error: Will be resolved by wrangler build
|
|
4
4
|
import { runWithCloudflareRequestContext } from "./cloudflare/init.js";
|
|
5
5
|
//@ts-expect-error: Will be resolved by wrangler build
|
|
@@ -35,8 +35,7 @@ export default {
|
|
|
35
35
|
// Fallback for the Next default image loader.
|
|
36
36
|
if (url.pathname ===
|
|
37
37
|
`${globalThis.__NEXT_BASE_PATH__}/_next/image${globalThis.__TRAILING_SLASH__ ? "/" : ""}`) {
|
|
38
|
-
|
|
39
|
-
return await fetchImage(env.ASSETS, imageUrl, ctx);
|
|
38
|
+
return await handleImageRequest(url, request.headers, env);
|
|
40
39
|
}
|
|
41
40
|
// - `Request`s are handled by the Next server
|
|
42
41
|
const reqOrResp = await middlewareHandler(request, env, ctx);
|