@opennextjs/cloudflare 1.13.0 → 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;
@@ -77,4 +77,7 @@ function ensureNextjsVersionSupported(options) {
77
77
  logger.error("Next.js version unsupported, please upgrade to version 14.2 or greater.");
78
78
  process.exit(1);
79
79
  }
80
+ if (buildHelper.compareSemver(options.nextVersion, ">=", "16")) {
81
+ logger.warn("Next.js 16 is not fully supported yet! Some features may not work as expected.");
82
+ }
80
83
  }
@@ -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: false,
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
- * Fetches an images.
13
+ * Handles requests to /_next/image(/), including image optimizations.
14
14
  *
15
- * Local images (starting with a '/' as fetched using the passed fetcher).
16
- * Remote images should match the configured remote patterns or a 404 response is returned.
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 fetchImage(fetcher: Fetcher | undefined, imageUrl: string, ctx: ExecutionContext): Promise<Response | undefined>;
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
- export declare function matchLocalPattern(pattern: LocalPattern, url: URL): boolean;
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 detectContentType(buffer: Uint8Array): "image/svg+xml" | "image/jpeg" | "image/png" | "image/gif" | "image/webp" | "image/avif" | "image/x-icon" | "image/x-icns" | "image/tiff" | "image/bmp" | "image/jxl" | "image/heic" | "application/pdf" | "image/jp2" | undefined;
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
- let NEXT_IMAGE_REGEXP;
1
+ import { error, warn } from "@opennextjs/aws/adapters/logger.js";
2
2
  /**
3
- * Fetches an images.
3
+ * Handles requests to /_next/image(/), including image optimizations.
4
4
  *
5
- * Local images (starting with a '/' as fetched using the passed fetcher).
6
- * Remote images should match the configured remote patterns or a 404 response is returned.
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 fetchImage(fetcher, imageUrl, ctx) {
9
- // https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/server/image-optimizer.ts#L208
10
- if (!imageUrl || imageUrl.length > 3072 || imageUrl.startsWith("//")) {
11
- return getUrlErrorResponse();
12
- }
13
- // Local
14
- if (imageUrl.startsWith("/")) {
15
- // @ts-expect-error TS2339 Missing types for URL.parse
16
- const url = URL.parse(imageUrl, "http://n");
17
- if (url == null) {
18
- return getUrlErrorResponse();
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
- // This method will never throw because URL parser will handle invalid input.
21
- const pathname = decodeURIComponent(url.pathname);
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
- // If localPatterns are not defined all local images are allowed.
27
- if (__IMAGES_LOCAL_PATTERNS__.length > 0 &&
28
- !__IMAGES_LOCAL_PATTERNS__.some((p) => matchLocalPattern(p, url))) {
29
- return getUrlErrorResponse();
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
- return fetcher?.fetch(`http://assets.local${imageUrl}`);
53
+ imageResponse = fetchImageResult.response;
32
54
  }
33
- // Remote
34
- let url;
35
- try {
36
- url = new URL(imageUrl);
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
- catch {
39
- return getUrlErrorResponse();
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
- if (url.protocol !== "http:" && url.protocol !== "https:") {
42
- return getUrlErrorResponse();
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
- // The remotePatterns is used to allow images from specific remote external paths and block all others.
45
- if (!__IMAGES_REMOTE_PATTERNS__.some((p) => matchRemotePattern(p, url))) {
46
- return getUrlErrorResponse();
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
- const imgResponse = await fetch(imageUrl, { cf: { cacheEverything: true } });
49
- if (!imgResponse.body) {
50
- return imgResponse;
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
- const buffer = new ArrayBuffer(32);
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
- let contentType;
55
- // respBody is eventually used for the response
56
- // contentBody is used to detect the content type
57
- const [respBody, contentBody] = imgResponse.body.tee();
58
- const reader = contentBody.getReader({ mode: "byob" });
59
- const { value } = await reader.read(new Uint8Array(buffer));
60
- // Release resources by calling `reader.cancel()`
61
- // `ctx.waitUntil` keeps the runtime running until the promise settles without having to wait here.
62
- ctx.waitUntil(reader.cancel());
63
- if (value) {
64
- contentType = detectContentType(value);
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
- if (!contentType) {
67
- // Fallback to upstream header when the type can not be detected
68
- // https://github.com/vercel/next.js/blob/d76f0b1/packages/next/src/server/image-optimizer.ts#L748
69
- contentType = imgResponse.headers.get("content-type") ?? "";
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
- // Sanitize the content type:
72
- // - Accept images only
73
- // - Reject multiple content types
74
- if (!contentType.startsWith("image/") || contentType.includes(",")) {
75
- contentType = undefined;
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 (contentType && !(contentType === SVG && !__IMAGES_ALLOW_SVG__)) {
78
- const headers = new Headers(imgResponse.headers);
79
- headers.set("content-type", contentType);
80
- headers.set("content-disposition", __IMAGES_CONTENT_DISPOSITION__);
81
- headers.set("content-security-policy", __IMAGES_CONTENT_SECURITY_POLICY__);
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
- // Cancel the unused stream
85
- ctx.waitUntil(respBody.cancel());
86
- return new Response('"url" parameter is valid but image type is not allowed', {
87
- status: 400,
88
- });
322
+ return { url, static: staticAsset };
323
+ }
324
+ let parsedURL;
325
+ try {
326
+ parsedURL = new URL(url);
89
327
  }
90
328
  catch {
91
- return new Response('"url" parameter is valid but upstream response is invalid', {
92
- status: 400,
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 detectContentType(buffer) {
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 { fetchImage } from "./cloudflare/images.js";
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
- const imageUrl = url.searchParams.get("url") ?? "";
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);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@opennextjs/cloudflare",
3
3
  "description": "Cloudflare builder for next apps",
4
- "version": "1.13.0",
4
+ "version": "1.14.0",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "opennextjs-cloudflare": "dist/cli/index.js"
@@ -75,7 +75,7 @@
75
75
  "vitest": "^2.1.1"
76
76
  },
77
77
  "peerDependencies": {
78
- "wrangler": "^4.49.0"
78
+ "wrangler": "^4.49.1"
79
79
  },
80
80
  "scripts": {
81
81
  "clean": "rimraf dist",