@opennextjs/cloudflare 1.17.0 → 1.17.2

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.
@@ -50,6 +50,23 @@ function loadManifest($PATH, $$$ARGS) {
50
50
  return process.env.NEXT_BUILD_ID;
51
51
  }
52
52
  ${returnManifests}
53
+ // Known optional manifests \u2014 Next.js loads these with handleMissing: true
54
+ // (see vercel/next.js packages/next/src/server/route-modules/route-module.ts).
55
+ // Return {} to match Next.js behaviour instead of crashing the worker.
56
+ // Note: Some manifest constants in Next.js omit the .json extension
57
+ // (e.g. SUBRESOURCE_INTEGRITY_MANIFEST, DYNAMIC_CSS_MANIFEST), so we
58
+ // strip .json before matching to handle both forms.
59
+ {
60
+ const p = $PATH.replace(/\\.json$/, "");
61
+ if (p.endsWith("react-loadable-manifest") ||
62
+ p.endsWith("subresource-integrity-manifest") ||
63
+ p.endsWith("server-reference-manifest") ||
64
+ p.endsWith("dynamic-css-manifest") ||
65
+ p.endsWith("fallback-build-manifest") ||
66
+ p.endsWith("prefetch-hints")) {
67
+ return {};
68
+ }
69
+ }
53
70
  throw new Error(\`Unexpected loadManifest(\${$PATH}) call!\`);
54
71
  }`,
55
72
  };
@@ -87,6 +104,11 @@ function evalManifest($PATH, $$$ARGS) {
87
104
  function evalManifest($PATH, $$$ARGS) {
88
105
  $PATH = $PATH.replaceAll(${JSON.stringify(sep)}, ${JSON.stringify(posix.sep)});
89
106
  ${returnManifests}
107
+ // client-reference-manifest is optional for static metadata routes
108
+ // (see vercel/next.js route-module.ts, loaded with handleMissing: true)
109
+ if ($PATH.endsWith("_client-reference-manifest.js")) {
110
+ return { __RSC_MANIFEST: {} };
111
+ }
90
112
  throw new Error(\`Unexpected evalManifest(\${$PATH}) call!\`);
91
113
  }`,
92
114
  };
@@ -1,4 +1,17 @@
1
1
  import type yargs from "yargs";
2
+ import type { WithWranglerArgs } from "./utils/utils.js";
3
+ /**
4
+ * Implementation of the `opennextjs-cloudflare build` command.
5
+ *
6
+ * @param args
7
+ */
8
+ export declare function buildCommand(args: WithWranglerArgs<{
9
+ skipNextBuild: boolean;
10
+ noMinify: boolean;
11
+ skipWranglerConfigCheck: boolean;
12
+ openNextConfigPath: string | undefined;
13
+ dangerouslyUseUnsupportedNextVersion: boolean;
14
+ }>): Promise<void>;
2
15
  /**
3
16
  * Add the `build` command to yargs configuration.
4
17
  *
@@ -1,12 +1,14 @@
1
+ import logger from "@opennextjs/aws/logger.js";
1
2
  import { build as buildImpl } from "../build/build.js";
2
- import { createWranglerConfigIfNonExistent } from "../utils/create-config-files.js";
3
+ import { askConfirmation } from "../utils/ask-confirmation.js";
4
+ import { createWranglerConfigFile, findWranglerConfig } from "../utils/create-wrangler-config.js";
3
5
  import { compileConfig, getNormalizedOptions, nextAppDir, printHeaders, readWranglerConfig, withWranglerOptions, withWranglerPassthroughArgs, } from "./utils/utils.js";
4
6
  /**
5
7
  * Implementation of the `opennextjs-cloudflare build` command.
6
8
  *
7
9
  * @param args
8
10
  */
9
- async function buildCommand(args) {
11
+ export async function buildCommand(args) {
10
12
  printHeaders("build");
11
13
  const { config, buildDir } = await compileConfig(args.openNextConfigPath);
12
14
  const options = getNormalizedOptions(config, buildDir);
@@ -15,7 +17,17 @@ async function buildCommand(args) {
15
17
  // Note: We don't ask when a custom config file is specified via `--config`
16
18
  // nor when `--skipWranglerConfigCheck` is used.
17
19
  if (!projectOpts.wranglerConfigPath && !args.skipWranglerConfigCheck) {
18
- await createWranglerConfigIfNonExistent(projectOpts);
20
+ if (!findWranglerConfig(projectOpts.sourceDir)) {
21
+ const confirmCreate = "No `wrangler.(toml|json|jsonc)` config file found, do you want to create one?";
22
+ if (await askConfirmation(confirmCreate)) {
23
+ await createWranglerConfigFile(projectOpts.sourceDir);
24
+ }
25
+ else {
26
+ logger.warn(`No Wrangler config file created
27
+
28
+ (to avoid this check use the \`--skipWranglerConfigCheck\` flag or set a \`SKIP_WRANGLER_CONFIG_CHECK\` environment variable to \`yes\`)`);
29
+ }
30
+ }
19
31
  }
20
32
  const wranglerConfig = await readWranglerConfig(args);
21
33
  await buildImpl(options, config, projectOpts, wranglerConfig, args.dangerouslyUseUnsupportedNextVersion);
@@ -20,8 +20,12 @@ export declare function printHeaders(command: string): void;
20
20
  *
21
21
  * When users specify a custom config file but it doesn't exist, we throw an Error.
22
22
  *
23
+ * @throws If a custom config path is provided but the file does not exist.
24
+ * @throws If no config file is found and the user declines to create one.
25
+ *
23
26
  * @param configPath Optional path to the config file. Absolute or relative to cwd.
24
- * @returns OpenNext config.
27
+ * @returns The compiled OpenNext config and the build directory.
28
+ *
25
29
  */
26
30
  export declare function compileConfig(configPath: string | undefined): Promise<{
27
31
  config: import("@opennextjs/aws/types/open-next.js").OpenNextConfig;
@@ -8,7 +8,8 @@ import { printHeader, showWarningOnWindows } from "@opennextjs/aws/build/utils.j
8
8
  import logger from "@opennextjs/aws/logger.js";
9
9
  import { unstable_readConfig } from "wrangler";
10
10
  import { ensureCloudflareConfig } from "../../build/utils/ensure-cf-config.js";
11
- import { createOpenNextConfigIfNotExistent } from "../../utils/create-config-files.js";
11
+ import { askConfirmation } from "../../utils/ask-confirmation.js";
12
+ import { createOpenNextConfigFile, findOpenNextConfig } from "../../utils/create-open-next-config.js";
12
13
  export const nextAppDir = process.cwd();
13
14
  /**
14
15
  * Print headers and warnings for the CLI.
@@ -27,15 +28,24 @@ export function printHeaders(command) {
27
28
  *
28
29
  * When users specify a custom config file but it doesn't exist, we throw an Error.
29
30
  *
31
+ * @throws If a custom config path is provided but the file does not exist.
32
+ * @throws If no config file is found and the user declines to create one.
33
+ *
30
34
  * @param configPath Optional path to the config file. Absolute or relative to cwd.
31
- * @returns OpenNext config.
35
+ * @returns The compiled OpenNext config and the build directory.
36
+ *
32
37
  */
33
38
  export async function compileConfig(configPath) {
34
39
  if (configPath && !existsSync(configPath)) {
35
40
  throw new Error(`Custom config file not found at ${configPath}`);
36
41
  }
42
+ configPath ??= findOpenNextConfig(nextAppDir);
37
43
  if (!configPath) {
38
- configPath = await createOpenNextConfigIfNotExistent(nextAppDir);
44
+ const answer = await askConfirmation("Missing required `open-next.config.ts` file, do you want to create one?");
45
+ if (!answer) {
46
+ throw new Error("The `open-next.config.ts` file is required, aborting!");
47
+ }
48
+ configPath = createOpenNextConfigFile(nextAppDir, { cache: false });
39
49
  }
40
50
  const { config, buildDir } = await compileOpenNextConfig(configPath, { compileEdge: true });
41
51
  ensureCloudflareConfig(config);
@@ -22,7 +22,37 @@ export type LocalPattern = {
22
22
  * @returns A promise that resolves to the resolved request.
23
23
  */
24
24
  export declare function handleImageRequest(requestURL: URL, requestHeaders: Headers, env: CloudflareEnv): Promise<Response>;
25
+ /**
26
+ * Handles requests to /cdn-cgi/image/ in development.
27
+ *
28
+ * Extracts the image URL, fetches the image, and checks the content type against
29
+ * Cloudflare's supported input formats.
30
+ *
31
+ * @param requestURL The full request URL.
32
+ * @param env The Cloudflare environment bindings.
33
+ * @returns A promise that resolves to the image response.
34
+ */
35
+ export declare function handleCdnCgiImageRequest(requestURL: URL, env: CloudflareEnv): Promise<Response>;
36
+ /**
37
+ * Parses a /cdn-cgi/image/ request URL.
38
+ *
39
+ * Extracts the image URL from the `/cdn-cgi/image/<options>/<image-url>` path format.
40
+ * Rejects protocol-relative URLs (`//...`). The cdn-cgi options are not parsed or
41
+ * validated as they are Cloudflare's concern.
42
+ *
43
+ * @param pathname The URL pathname (e.g. `/cdn-cgi/image/width=640,quality=75,format=auto/path/to/image.png`).
44
+ * @returns the parsed URL result or an error.
45
+ */
46
+ export declare function parseCdnCgiImageRequest(pathname: string): {
47
+ ok: true;
48
+ url: string;
49
+ static: boolean;
50
+ } | ErrorResult;
25
51
  export type OptimizedImageFormat = "image/avif" | "image/webp";
52
+ type ErrorResult = {
53
+ ok: false;
54
+ message: string;
55
+ };
26
56
  export declare function matchLocalPattern(pattern: LocalPattern, url: {
27
57
  pathname: string;
28
58
  search: string;
@@ -68,19 +68,11 @@ export async function handleImageRequest(requestURL, requestHeaders, env) {
68
68
  immutable = cacheControlHeader.includes("immutable");
69
69
  }
70
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
- });
71
+ const readHeaderResult = await readImageHeader(imageResponse);
72
+ if (readHeaderResult instanceof Response) {
73
+ return readHeaderResult;
82
74
  }
83
- const contentType = detectImageContentType(readImageHeaderBytesResult.value);
75
+ const { contentType, imageStream } = readHeaderResult;
84
76
  if (contentType === null) {
85
77
  warn(`Failed to detect content type of "${parseResult.url}"`);
86
78
  return new Response('"url" parameter is valid but image type is not allowed', {
@@ -153,6 +145,123 @@ export async function handleImageRequest(requestURL, requestHeaders, env) {
153
145
  });
154
146
  return response;
155
147
  }
148
+ /**
149
+ * Handles requests to /cdn-cgi/image/ in development.
150
+ *
151
+ * Extracts the image URL, fetches the image, and checks the content type against
152
+ * Cloudflare's supported input formats.
153
+ *
154
+ * @param requestURL The full request URL.
155
+ * @param env The Cloudflare environment bindings.
156
+ * @returns A promise that resolves to the image response.
157
+ */
158
+ export async function handleCdnCgiImageRequest(requestURL, env) {
159
+ const parseResult = parseCdnCgiImageRequest(requestURL.pathname);
160
+ if (!parseResult.ok) {
161
+ return new Response(parseResult.message, {
162
+ status: 400,
163
+ });
164
+ }
165
+ let imageResponse;
166
+ if (parseResult.url.startsWith("/")) {
167
+ if (env.ASSETS === undefined) {
168
+ return new Response("env.ASSETS binding is not defined", {
169
+ status: 404,
170
+ });
171
+ }
172
+ const absoluteURL = new URL(parseResult.url, requestURL);
173
+ imageResponse = await env.ASSETS.fetch(absoluteURL);
174
+ }
175
+ else {
176
+ imageResponse = await fetch(parseResult.url);
177
+ }
178
+ if (!imageResponse.ok || imageResponse.body === null) {
179
+ return new Response('"url" parameter is valid but upstream response is invalid', {
180
+ status: imageResponse.status,
181
+ });
182
+ }
183
+ const readHeaderResult = await readImageHeader(imageResponse);
184
+ if (readHeaderResult instanceof Response) {
185
+ return readHeaderResult;
186
+ }
187
+ const { contentType, imageStream } = readHeaderResult;
188
+ if (contentType === null || !SUPPORTED_CDN_CGI_INPUT_TYPES.has(contentType)) {
189
+ return new Response('"url" parameter is valid but image type is not allowed', {
190
+ status: 400,
191
+ });
192
+ }
193
+ if (contentType === SVG && !__IMAGES_ALLOW_SVG__) {
194
+ return new Response('"url" parameter is valid but image type is not allowed', {
195
+ status: 400,
196
+ });
197
+ }
198
+ return new Response(imageStream, {
199
+ headers: { "Content-Type": contentType },
200
+ });
201
+ }
202
+ /**
203
+ * Parses a /cdn-cgi/image/ request URL.
204
+ *
205
+ * Extracts the image URL from the `/cdn-cgi/image/<options>/<image-url>` path format.
206
+ * Rejects protocol-relative URLs (`//...`). The cdn-cgi options are not parsed or
207
+ * validated as they are Cloudflare's concern.
208
+ *
209
+ * @param pathname The URL pathname (e.g. `/cdn-cgi/image/width=640,quality=75,format=auto/path/to/image.png`).
210
+ * @returns the parsed URL result or an error.
211
+ */
212
+ export function parseCdnCgiImageRequest(pathname) {
213
+ const match = pathname.match(/^\/cdn-cgi\/image\/(?<options>[^/]+)\/(?<url>.+)$/);
214
+ if (match === null ||
215
+ // Valid URLs have at least one option
216
+ !match.groups?.options ||
217
+ !match.groups?.url) {
218
+ return { ok: false, message: "Invalid /cdn-cgi/image/ URL format" };
219
+ }
220
+ const imageUrl = match.groups.url;
221
+ // The regex separator consumes one `/`, so if imageUrl starts with `/`
222
+ // the original URL segment was protocol-relative (`//...`).
223
+ if (imageUrl.startsWith("/")) {
224
+ return { ok: false, message: '"url" parameter cannot be a protocol-relative URL (//)' };
225
+ }
226
+ // Resolve the image URL: it may be absolute (https://...) or relative.
227
+ let resolvedUrl;
228
+ if (imageUrl.match(/^https?:\/\//)) {
229
+ resolvedUrl = imageUrl;
230
+ }
231
+ else {
232
+ // Relative URLs need a leading slash.
233
+ resolvedUrl = `/${imageUrl}`;
234
+ }
235
+ return {
236
+ ok: true,
237
+ url: resolvedUrl,
238
+ static: false,
239
+ };
240
+ }
241
+ /**
242
+ * Reads the first 32 bytes of an image response to detect its content type.
243
+ *
244
+ * Tees the response body so the image stream can still be consumed after detection.
245
+ *
246
+ * @param imageResponse The image response whose body to read.
247
+ * @returns The detected content type and image stream, or an error Response if the header bytes
248
+ * could not be read.
249
+ */
250
+ async function readImageHeader(imageResponse) {
251
+ // Note: imageResponse.body is non-null — callers check before calling.
252
+ const [contentTypeStream, imageStream] = imageResponse.body.tee();
253
+ const headerBytes = new Uint8Array(32);
254
+ const reader = contentTypeStream.getReader({ mode: "byob" });
255
+ const readResult = await reader.readAtLeast(32, headerBytes);
256
+ if (readResult.value === undefined) {
257
+ await imageResponse.body.cancel();
258
+ return new Response('"url" parameter is valid but upstream response is invalid', {
259
+ status: 400,
260
+ });
261
+ }
262
+ const contentType = detectImageContentType(readResult.value);
263
+ return { contentType, imageStream };
264
+ }
156
265
  /**
157
266
  * Fetch call with max redirects and timeouts.
158
267
  *
@@ -268,6 +377,9 @@ function parseImageRequest(requestURL, requestHeaders) {
268
377
  /**
269
378
  * Validates that there is exactly one "url" query parameter.
270
379
  *
380
+ * Checks length, protocol-relative URLs, local/remote pattern matching, recursion, and protocol.
381
+ *
382
+ * @param requestURL The request URL containing the "url" query parameter.
271
383
  * @returns the validated URL or an error result.
272
384
  */
273
385
  function validateUrlQueryParameter(requestURL) {
@@ -287,7 +399,6 @@ function validateUrlQueryParameter(requestURL) {
287
399
  };
288
400
  return result;
289
401
  }
290
- // The url parameter value should be a valid URL or a valid relative URL.
291
402
  const url = urls[0];
292
403
  if (url.length > 3072) {
293
404
  const result = {
@@ -515,6 +626,12 @@ const ICO = "image/x-icon";
515
626
  const ICNS = "image/x-icns";
516
627
  const TIFF = "image/tiff";
517
628
  const BMP = "image/bmp";
629
+ /**
630
+ * Image content types supported as input by Cloudflare's cdn-cgi image transformation.
631
+ *
632
+ * @see https://developers.cloudflare.com/images/transform-images/#supported-input-formats
633
+ */
634
+ const SUPPORTED_CDN_CGI_INPUT_TYPES = new Set([JPEG, PNG, GIF, WEBP, SVG, HEIC]);
518
635
  /**
519
636
  * Detects the content type by looking at the first few bytes of a file
520
637
  *
@@ -1,5 +1,5 @@
1
1
  //@ts-expect-error: Will be resolved by wrangler build
2
- import { handleImageRequest } from "./cloudflare/images.js";
2
+ import { handleCdnCgiImageRequest, 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
@@ -23,14 +23,7 @@ export default {
23
23
  // Serve images in development.
24
24
  // Note: "/cdn-cgi/image/..." requests do not reach production workers.
25
25
  if (url.pathname.startsWith("/cdn-cgi/image/")) {
26
- const m = url.pathname.match(/\/cdn-cgi\/image\/.+?\/(?<url>.+)$/);
27
- if (m === null) {
28
- return new Response("Not Found!", { status: 404 });
29
- }
30
- const imageUrl = m.groups.url;
31
- return imageUrl.match(/^https?:\/\//)
32
- ? fetch(imageUrl, { cf: { cacheEverything: true } })
33
- : env.ASSETS?.fetch(new URL(`/${imageUrl}`, url));
26
+ return handleCdnCgiImageRequest(url, env);
34
27
  }
35
28
  // Fallback for the Next default image loader.
36
29
  if (url.pathname ===
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.17.0",
4
+ "version": "1.17.2",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "opennextjs-cloudflare": "dist/cli/index.js"
@@ -1,19 +0,0 @@
1
- import type { ProjectOptions } from "../project-options.js";
2
- /**
3
- * Creates a `wrangler.jsonc` file for the user if a wrangler config file doesn't already exist,
4
- * but only after asking for the user's confirmation.
5
- *
6
- * If the user refuses a warning is shown (which offers ways to opt out of this check to the user).
7
- *
8
- * @param projectOpts The options for the project
9
- */
10
- export declare function createWranglerConfigIfNonExistent(projectOpts: ProjectOptions): Promise<void>;
11
- /**
12
- * Creates a `open-next.config.ts` file for the user if it doesn't exist, but only after asking for the user's confirmation.
13
- *
14
- * If the user refuses an error is thrown (since the file is mandatory).
15
- *
16
- * @param sourceDir The source directory for the project
17
- * @return The path to the created source file
18
- */
19
- export declare function createOpenNextConfigIfNotExistent(sourceDir: string): Promise<string>;
@@ -1,44 +0,0 @@
1
- import { askConfirmation } from "./ask-confirmation.js";
2
- import { createOpenNextConfigFile, findOpenNextConfig } from "./create-open-next-config.js";
3
- import { createWranglerConfigFile, findWranglerConfig } from "./create-wrangler-config.js";
4
- /**
5
- * Creates a `wrangler.jsonc` file for the user if a wrangler config file doesn't already exist,
6
- * but only after asking for the user's confirmation.
7
- *
8
- * If the user refuses a warning is shown (which offers ways to opt out of this check to the user).
9
- *
10
- * @param projectOpts The options for the project
11
- */
12
- export async function createWranglerConfigIfNonExistent(projectOpts) {
13
- const wranglerConfigFileExists = Boolean(findWranglerConfig(projectOpts.sourceDir));
14
- if (wranglerConfigFileExists) {
15
- return;
16
- }
17
- const answer = await askConfirmation("No `wrangler.(toml|json|jsonc)` config file found, do you want to create one?");
18
- if (!answer) {
19
- console.warn("No Wrangler config file created" +
20
- "\n" +
21
- "(to avoid this check use the `--skipWranglerConfigCheck` flag or set a `SKIP_WRANGLER_CONFIG_CHECK` environment variable to `yes`)");
22
- return;
23
- }
24
- await createWranglerConfigFile(projectOpts.sourceDir);
25
- }
26
- /**
27
- * Creates a `open-next.config.ts` file for the user if it doesn't exist, but only after asking for the user's confirmation.
28
- *
29
- * If the user refuses an error is thrown (since the file is mandatory).
30
- *
31
- * @param sourceDir The source directory for the project
32
- * @return The path to the created source file
33
- */
34
- export async function createOpenNextConfigIfNotExistent(sourceDir) {
35
- const openNextConfigPath = findOpenNextConfig(sourceDir);
36
- if (!openNextConfigPath) {
37
- const answer = await askConfirmation("Missing required `open-next.config.ts` file, do you want to create one?");
38
- if (!answer) {
39
- throw new Error("The `open-next.config.ts` file is required, aborting!");
40
- }
41
- return createOpenNextConfigFile(sourceDir, { cache: false });
42
- }
43
- return openNextConfigPath;
44
- }