vinext 1.4.1 → 1.4.3

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.
Files changed (143) hide show
  1. package/README.md +5 -2
  2. package/dist/build/action-owner-manifest.js +1 -0
  3. package/dist/build/prerender-paths.d.ts +11 -1
  4. package/dist/build/prerender-paths.js +36 -10
  5. package/dist/check.js +1 -1
  6. package/dist/cli.js +1 -1
  7. package/dist/client/dev-error-overlay.d.ts +2 -1
  8. package/dist/client/dev-error-overlay.js +8 -3
  9. package/dist/config/config-matchers.js +8 -4
  10. package/dist/config/destination-params.js +5 -2
  11. package/dist/config/next-config.d.ts +25 -1
  12. package/dist/config/next-config.js +31 -1
  13. package/dist/entries/app-rsc-entry.js +65 -30
  14. package/dist/entries/app-rsc-manifest.js +4 -0
  15. package/dist/entries/pages-server-entry.js +19 -4
  16. package/dist/index.js +37 -37
  17. package/dist/init-cloudflare.js +18 -2
  18. package/dist/init-platform.js +2 -2
  19. package/dist/init.js +42 -5
  20. package/dist/plugins/css-target.d.ts +18 -0
  21. package/dist/plugins/css-target.js +93 -0
  22. package/dist/routing/app-route-graph.d.ts +10 -0
  23. package/dist/routing/app-route-graph.js +15 -1
  24. package/dist/server/api-handler.d.ts +2 -0
  25. package/dist/server/api-handler.js +10 -8
  26. package/dist/server/app-action-forwarding.js +3 -1
  27. package/dist/server/app-browser-entry.js +37 -41
  28. package/dist/server/app-browser-error.d.ts +1 -1
  29. package/dist/server/app-browser-error.js +13 -4
  30. package/dist/server/app-browser-supplemental-refresh.d.ts +13 -0
  31. package/dist/server/app-browser-supplemental-refresh.js +36 -4
  32. package/dist/server/app-elements.d.ts +3 -0
  33. package/dist/server/app-elements.js +14 -1
  34. package/dist/server/app-fallback-renderer.js +2 -2
  35. package/dist/server/app-middleware.d.ts +2 -0
  36. package/dist/server/app-middleware.js +1 -0
  37. package/dist/server/app-optimistic-routing.d.ts +12 -1
  38. package/dist/server/app-optimistic-routing.js +38 -16
  39. package/dist/server/app-page-boundary-render.js +4 -2
  40. package/dist/server/app-page-cache-render.js +1 -1
  41. package/dist/server/app-page-dispatch.d.ts +11 -1
  42. package/dist/server/app-page-dispatch.js +78 -31
  43. package/dist/server/app-page-element-builder.d.ts +11 -20
  44. package/dist/server/app-page-element-builder.js +39 -50
  45. package/dist/server/app-page-intercept-segment-config.d.ts +56 -0
  46. package/dist/server/app-page-intercept-segment-config.js +90 -0
  47. package/dist/server/app-page-navigation-params.d.ts +23 -0
  48. package/dist/server/app-page-navigation-params.js +49 -0
  49. package/dist/server/app-page-render.d.ts +5 -2
  50. package/dist/server/app-page-render.js +17 -3
  51. package/dist/server/app-page-request.d.ts +24 -2
  52. package/dist/server/app-page-request.js +10 -10
  53. package/dist/server/app-page-route-wiring.d.ts +17 -5
  54. package/dist/server/app-page-route-wiring.js +19 -13
  55. package/dist/server/app-page-segment-plan.js +9 -1
  56. package/dist/server/app-page-segment-state.d.ts +9 -0
  57. package/dist/server/app-page-segment-state.js +54 -1
  58. package/dist/server/app-pages-bridge.d.ts +6 -0
  59. package/dist/server/app-pages-bridge.js +8 -3
  60. package/dist/server/app-request-stage-independent-entry.js +2 -3
  61. package/dist/server/app-response-stage-entry.js +1 -0
  62. package/dist/server/app-route-module-loader.d.ts +2 -0
  63. package/dist/server/app-route-module-loader.js +1 -0
  64. package/dist/server/app-router-entry.js +2 -3
  65. package/dist/server/app-rsc-cache-busting.d.ts +8 -0
  66. package/dist/server/app-rsc-cache-busting.js +13 -1
  67. package/dist/server/app-rsc-combined-handler.js +2 -1
  68. package/dist/server/app-rsc-handler.d.ts +14 -1
  69. package/dist/server/app-rsc-handler.js +111 -62
  70. package/dist/server/app-rsc-response-finalizer.d.ts +18 -3
  71. package/dist/server/app-rsc-response-finalizer.js +61 -7
  72. package/dist/server/app-rsc-route-matching.d.ts +14 -0
  73. package/dist/server/app-rsc-route-matching.js +42 -18
  74. package/dist/server/app-server-action-execution.js +16 -10
  75. package/dist/server/app-ssr-entry.js +8 -0
  76. package/dist/server/cache-control.d.ts +2 -0
  77. package/dist/server/cache-control.js +3 -1
  78. package/dist/server/config-headers.d.ts +7 -0
  79. package/dist/server/config-headers.js +20 -1
  80. package/dist/server/image-animation.d.ts +9 -0
  81. package/dist/server/image-animation.js +118 -0
  82. package/dist/server/image-local-patterns.d.ts +21 -0
  83. package/dist/server/image-local-patterns.js +59 -0
  84. package/dist/server/image-optimization.d.ts +48 -27
  85. package/dist/server/image-optimization.js +316 -22
  86. package/dist/server/image-source-reader.d.ts +34 -0
  87. package/dist/server/image-source-reader.js +126 -0
  88. package/dist/server/metadata-route-response.d.ts +6 -1
  89. package/dist/server/metadata-route-response.js +46 -10
  90. package/dist/server/middleware-matcher.d.ts +1 -0
  91. package/dist/server/middleware-matcher.js +1 -1
  92. package/dist/server/middleware-runtime.d.ts +5 -0
  93. package/dist/server/middleware-runtime.js +48 -15
  94. package/dist/server/middleware.d.ts +1 -1
  95. package/dist/server/middleware.js +2 -1
  96. package/dist/server/nitro-public-files-plugin.js +20 -8
  97. package/dist/server/nitro-request-target-plugin.d.ts +17 -0
  98. package/dist/server/nitro-request-target-plugin.js +22 -0
  99. package/dist/server/open-redirect.d.ts +2 -1
  100. package/dist/server/open-redirect.js +2 -1
  101. package/dist/server/pages-client-assets.d.ts +1 -0
  102. package/dist/server/pages-data-route.d.ts +5 -1
  103. package/dist/server/pages-data-route.js +14 -6
  104. package/dist/server/pages-i18n.d.ts +7 -0
  105. package/dist/server/pages-i18n.js +14 -1
  106. package/dist/server/pages-node-compat.js +7 -3
  107. package/dist/server/pages-page-handler.js +4 -4
  108. package/dist/server/pages-request-pipeline.d.ts +9 -0
  109. package/dist/server/pages-request-pipeline.js +35 -15
  110. package/dist/server/pages-request-stage-entry.js +8 -2
  111. package/dist/server/pages-router-entry.js +3 -0
  112. package/dist/server/prod-server.d.ts +1 -1
  113. package/dist/server/prod-server.js +64 -62
  114. package/dist/server/request-pipeline.d.ts +5 -0
  115. package/dist/server/request-pipeline.js +11 -1
  116. package/dist/server/response-stage-cacheability.d.ts +7 -0
  117. package/dist/server/response-stage-cacheability.js +6 -5
  118. package/dist/server/srvx-middleware-origin.d.ts +13 -0
  119. package/dist/server/srvx-middleware-origin.js +19 -0
  120. package/dist/server/srvx-request-target.d.ts +12 -0
  121. package/dist/server/srvx-request-target.js +20 -0
  122. package/dist/server/static-file-cache.js +10 -22
  123. package/dist/server/worker-image-optimization.d.ts +10 -3
  124. package/dist/server/worker-image-optimization.js +43 -9
  125. package/dist/server/worker-revalidation-context.js +1 -0
  126. package/dist/server/worker-utils.d.ts +8 -0
  127. package/dist/server/worker-utils.js +19 -2
  128. package/dist/shims/error-boundary.d.ts +6 -2
  129. package/dist/shims/error-boundary.js +4 -2
  130. package/dist/shims/image-props.d.ts +6 -0
  131. package/dist/shims/image-props.js +39 -3
  132. package/dist/shims/image.js +3 -3
  133. package/dist/shims/request-context.d.ts +3 -1
  134. package/dist/shims/slot.d.ts +3 -0
  135. package/dist/shims/slot.js +67 -9
  136. package/dist/shims/url-utils.js +6 -1
  137. package/dist/utils/base-path.d.ts +7 -0
  138. package/dist/utils/base-path.js +11 -1
  139. package/dist/utils/client-entry-manifest.d.ts +5 -0
  140. package/dist/utils/client-entry-manifest.js +1 -0
  141. package/dist/utils/client-runtime-metadata.d.ts +5 -0
  142. package/dist/utils/client-runtime-metadata.js +33 -7
  143. package/package.json +1 -1
@@ -0,0 +1,21 @@
1
+ //#region src/server/image-local-patterns.d.ts
2
+ /**
3
+ * `images.localPatterns` matching for the image optimization endpoint, kept
4
+ * apart from the remote-pattern shim so the request path doesn't load its IP
5
+ * parsing.
6
+ */
7
+ export type LocalPattern = {
8
+ pathname?: string;
9
+ search?: string;
10
+ };
11
+ /**
12
+ * Check whether a local URL matches a single local pattern.
13
+ * Follows the same semantics as Next.js's matchLocalPattern().
14
+ */
15
+ export declare function matchLocalPattern(pattern: LocalPattern, url: URL): boolean;
16
+ /**
17
+ * Check whether a local URL matches any configured local pattern. Like
18
+ * Next.js's hasLocalMatch(), an unset list allows every local URL.
19
+ */
20
+ export declare function hasLocalMatch(localPatterns: LocalPattern[] | undefined, url: URL): boolean;
21
+ //#endregion
@@ -0,0 +1,59 @@
1
+ //#region src/server/image-local-patterns.ts
2
+ const localPathnameRegexCache = /* @__PURE__ */ new Map();
3
+ /**
4
+ * Convert a local pathname glob to a RegExp, reading `*` and `**` like Next.js's
5
+ * picomatch (`makeRe(pathname, { dot: true })`):
6
+ * - a `**` segment matches zero or more whole segments, but needs its own
7
+ * separator right after a leading `/`, and at the end after a segment
8
+ * that ends in `*`;
9
+ * - any other `*` run matches characters within one segment;
10
+ * - a last segment ending in `*` also matches one trailing slash, and a
11
+ * whole last `*` matches one character or more (that slash included).
12
+ * Other picomatch syntax (braces, `?`, classes) is matched literally.
13
+ */
14
+ function localPathnameGlobToRegex(pattern) {
15
+ const cached = localPathnameRegexCache.get(pattern);
16
+ if (cached !== void 0) return cached;
17
+ const segments = pattern.split("/").filter((segment, i, all) => !(segment === "**" && all[i - 1] === "**"));
18
+ let regexStr = "^";
19
+ for (let i = 0; i < segments.length; i++) {
20
+ const segment = segments[i];
21
+ const previous = segments[i - 1];
22
+ const isLast = i === segments.length - 1;
23
+ if (segment === "**") {
24
+ if (i === 0) regexStr += isLast ? ".*" : "(?:.*/)?";
25
+ else if (previous === "" || isLast && previous.endsWith("*")) regexStr += "/.*";
26
+ else regexStr += "(?:/.*)?";
27
+ continue;
28
+ }
29
+ if (i > 0 && !(i === 1 && previous === "**")) regexStr += "/";
30
+ if (segment === "*" && isLast) {
31
+ regexStr += "(?:[^/]+/?|/)";
32
+ continue;
33
+ }
34
+ regexStr += segment.split(/\*+/).map((literal) => literal.replace(/[.+?^${}()|[\]\\]/g, "\\$&")).join("[^/]*");
35
+ if (isLast && segment.endsWith("*")) regexStr += "/?";
36
+ }
37
+ regexStr += "$";
38
+ const re = new RegExp(regexStr);
39
+ localPathnameRegexCache.set(pattern, re);
40
+ return re;
41
+ }
42
+ /**
43
+ * Check whether a local URL matches a single local pattern.
44
+ * Follows the same semantics as Next.js's matchLocalPattern().
45
+ */
46
+ function matchLocalPattern(pattern, url) {
47
+ if (pattern.search !== void 0 && pattern.search !== url.search) return false;
48
+ return localPathnameGlobToRegex(pattern.pathname ?? "**").test(url.pathname);
49
+ }
50
+ /**
51
+ * Check whether a local URL matches any configured local pattern. Like
52
+ * Next.js's hasLocalMatch(), an unset list allows every local URL.
53
+ */
54
+ function hasLocalMatch(localPatterns, url) {
55
+ if (!localPatterns) return true;
56
+ return localPatterns.some((p) => matchLocalPattern(p, url));
57
+ }
58
+ //#endregion
59
+ export { hasLocalMatch, matchLocalPattern };
@@ -1,22 +1,5 @@
1
+ import { LocalPattern } from "./image-local-patterns.js";
1
2
  //#region src/server/image-optimization.d.ts
2
- /**
3
- * Image optimization request handler.
4
- *
5
- * Handles `/_next/image?url=...&w=...&q=...` requests. In production
6
- * on Cloudflare Workers, uses the Images binding (`env.IMAGES`) to
7
- * resize and transcode on the fly. On other runtimes (Node.js dev/prod
8
- * server), serves the original file as a passthrough with appropriate
9
- * Cache-Control headers.
10
- *
11
- * Format negotiation: inspects the `Accept` header and serves AVIF, WebP,
12
- * or JPEG depending on client support.
13
- *
14
- * Security: All image responses include Content-Security-Policy and
15
- * X-Content-Type-Options headers to prevent XSS via SVG or Content-Type
16
- * spoofing. SVG content is blocked by default (following Next.js behavior).
17
- * When `dangerouslyAllowSVG` is enabled in next.config.js, SVGs are served
18
- * as-is (no transformation) with security headers applied.
19
- */
20
3
  /** The pathname that triggers image optimization (matches Next.js). */
21
4
  export declare const IMAGE_OPTIMIZATION_PATH = "/_next/image";
22
5
  /**
@@ -49,11 +32,22 @@ export type ImageConfig = {
49
32
  /** Allowed fixed-image widths. Defaults to Next.js image sizes. */
50
33
  imageSizes?: number[];
51
34
  /**
52
- * Allowed output qualities. When unset, any quality from 1-100 is permitted
53
- * (matches Next.js: an unset `images.qualities` is not restricted to a single
54
- * value). When set, only the listed qualities are accepted.
35
+ * Allowed output qualities. When set, only the listed qualities are
36
+ * accepted, as in Next.js. When unset, any quality from 1-100 is accepted.
37
+ *
38
+ * Intentional divergence from Next.js 16, which defaults this to `[75]`:
39
+ * vinext keeps the Next.js 14/15 behaviour for an unset list so apps that
40
+ * adopt vinext from those versions and pass `quality={N}` keep working
41
+ * instead of getting 400s. Apps opt into Next.js 16's strict list by
42
+ * configuring `images.qualities`.
55
43
  */
56
44
  qualities?: number[];
45
+ /**
46
+ * Local sources the endpoint may serve, resolved like Next.js: an app list
47
+ * gains the static-import pattern, and an unset list becomes
48
+ * DEFAULT_LOCAL_PATTERNS when checked.
49
+ */
50
+ localPatterns?: LocalPattern[];
57
51
  /** Allow SVG through the image optimization endpoint. Default: false. */
58
52
  dangerouslyAllowSVG?: boolean;
59
53
  /**
@@ -70,6 +64,13 @@ export type ImageConfig = {
70
64
  contentDispositionType?: "inline" | "attachment";
71
65
  /** Content-Security-Policy header value. Default: "script-src 'none'; frame-src 'none'; sandbox;" */
72
66
  contentSecurityPolicy?: string;
67
+ /** Least max-age, in seconds, of an image served as-is. Default: 14400. */
68
+ minimumCacheTTL?: number;
69
+ /**
70
+ * Most bytes of a GIF, PNG or WebP source read for optimization before
71
+ * answering 413. Default: 50000000.
72
+ */
73
+ maximumResponseBody?: number;
73
74
  };
74
75
  /**
75
76
  * Next.js default device sizes and image sizes.
@@ -80,10 +81,12 @@ export declare const DEFAULT_DEVICE_SIZES: number[];
80
81
  export declare const DEFAULT_IMAGE_SIZES: number[];
81
82
  export type ParseImageParamsOptions = {
82
83
  isDev?: boolean;
84
+ /** `images.localPatterns`; DEFAULT_LOCAL_PATTERNS when unset. */
85
+ localPatterns?: LocalPattern[];
83
86
  };
84
87
  export declare function resolveDevImageRedirect(requestUrl: URL, allowedWidths?: number[], allowedQualities?: number[], options?: ParseImageParamsOptions): string | null;
85
88
  export type ImageOptimizationSignal = {
86
- /** Middleware response headers to send with the image. */
89
+ /** next.config and middleware response headers to send with the image. */
87
90
  middlewareHeaders: Headers;
88
91
  /** The request headers after middleware's overrides, when it set any. */
89
92
  requestHeaders: Headers | null;
@@ -120,6 +123,11 @@ export declare function isImageSourceBodyStatus(status: number): boolean;
120
123
  */
121
124
  export declare const INVALID_IMAGE_SOURCE_MESSAGE = "The requested resource isn't a valid image.";
122
125
  export declare function invalidImageSourceResponse(): Response;
126
+ /**
127
+ * Next.js applies this when `images.localPatterns` is unset: every local path,
128
+ * but no query string.
129
+ */
130
+ export declare const DEFAULT_LOCAL_PATTERNS: LocalPattern[];
123
131
  /**
124
132
  * Parse and validate image optimization query parameters.
125
133
  * Returns null if the request is malformed.
@@ -143,11 +151,6 @@ export declare function negotiateImageFormat(acceptHeader: string | null): strin
143
151
  * Optimized images are immutable because the URL encodes the transform params.
144
152
  */
145
153
  export declare const IMAGE_CACHE_CONTROL = "public, max-age=31536000, immutable";
146
- /**
147
- * Next.js's Cache-Control for an image whose source can change at its URL
148
- * (anything but a hashed build asset), at the default `images.minimumCacheTTL`.
149
- */
150
- export declare const MUTABLE_IMAGE_SOURCE_CACHE_CONTROL = "public, max-age=14400, must-revalidate";
151
154
  /**
152
155
  * Content-Security-Policy for image optimization responses.
153
156
  * Blocks script execution and framing to prevent XSS via SVG or other
@@ -155,6 +158,10 @@ export declare const MUTABLE_IMAGE_SOURCE_CACHE_CONTROL = "public, max-age=14400
155
158
  * Matches Next.js default: script-src 'none'; frame-src 'none'; sandbox;
156
159
  */
157
160
  export declare const IMAGE_CONTENT_SECURITY_POLICY = "script-src 'none'; frame-src 'none'; sandbox;";
161
+ /** Next.js's 400 body for a source whose type it does not serve, such as SVG. */
162
+ export declare const IMAGE_TYPE_NOT_ALLOWED_MESSAGE = "\"url\" parameter is valid but image type is not allowed";
163
+ /** The image type Next.js detects from a source's first bytes, or null. */
164
+ export declare function sniffImageContentType(bytes: Uint8Array): string | null;
158
165
  /**
159
166
  * Check if a Content-Type header value is a safe image type.
160
167
  * Returns false for SVG (unless dangerouslyAllowSVG is true), HTML, or any non-image type.
@@ -176,6 +183,20 @@ export declare function isSafeImageContentType(contentType: string | null, dange
176
183
  * go out without it. Only full image responses carry either header here too.
177
184
  */
178
185
  export declare const IMAGE_RESPONSE_CACHE_STATE = "MISS";
186
+ /**
187
+ * Mark a source the host fetched from its `_next/static` build output, which
188
+ * is served immutably like Next.js' `isStatic` sources. A public file is never
189
+ * marked, whatever its path.
190
+ */
191
+ export declare function markImageBuildAsset(response: Response): Response;
192
+ /**
193
+ * Next.js's Cache-Control for a source it serves as-is: a build asset stays
194
+ * immutable, and any other source is revalidated after the larger of
195
+ * `images.minimumCacheTTL` and the source's own max-age. Next.js ignores the
196
+ * source's max-age when it falls back after a failed transform, so that
197
+ * fallback passes null.
198
+ */
199
+ export declare function sourceImageCacheControl(isBuildAsset: boolean, upstreamCacheControl: string | null, config?: ImageConfig): string;
179
200
  /**
180
201
  * Handlers for image optimization I/O operations.
181
202
  * Workers provide these callbacks to adapt their specific bindings.
@@ -1,5 +1,8 @@
1
1
  import { badRequestResponse } from "./http-error-responses.js";
2
2
  import { setCacheStateHeaders } from "./cache-headers.js";
3
+ import { isAnimatedImage } from "./image-animation.js";
4
+ import { ImageSourceReader, ImageSourceTooLargeError } from "./image-source-reader.js";
5
+ import { hasLocalMatch } from "./image-local-patterns.js";
3
6
  //#region src/server/image-optimization.ts
4
7
  /**
5
8
  * Image optimization request handler.
@@ -18,6 +21,9 @@ import { setCacheStateHeaders } from "./cache-headers.js";
18
21
  * spoofing. SVG content is blocked by default (following Next.js behavior).
19
22
  * When `dangerouslyAllowSVG` is enabled in next.config.js, SVGs are served
20
23
  * as-is (no transformation) with security headers applied.
24
+ *
25
+ * Like Next.js, formats it does not optimize (SVG, ICO, ICNS, BMP, JPEG XL,
26
+ * HEIC) and animated images are served as-is too.
21
27
  */
22
28
  /** The pathname that triggers image optimization (matches Next.js). */
23
29
  const IMAGE_OPTIMIZATION_PATH = "/_next/image";
@@ -166,6 +172,14 @@ function invalidImageSourceResponse() {
166
172
  }
167
173
  const RECURSIVE_IMAGE_URL_RE = /\/_(?:next|vinext)\/image(?:$|\/)/;
168
174
  /**
175
+ * Next.js applies this when `images.localPatterns` is unset: every local path,
176
+ * but no query string.
177
+ */
178
+ const DEFAULT_LOCAL_PATTERNS = [{
179
+ pathname: "**",
180
+ search: ""
181
+ }];
182
+ /**
169
183
  * Parse and validate image optimization query parameters.
170
184
  * Returns null if the request is malformed.
171
185
  *
@@ -204,6 +218,7 @@ function parseImageParams(url, allowedWidths = [...DEFAULT_DEVICE_SIZES, ...DEFA
204
218
  if (resolved.origin !== base) return null;
205
219
  if (resolved.pathname.startsWith("//")) return null;
206
220
  if (RECURSIVE_IMAGE_URL_RE.test(decodeURIComponent(resolved.pathname))) return null;
221
+ if (!options.isDev && !hasLocalMatch(options.localPatterns ?? DEFAULT_LOCAL_PATTERNS, resolved)) return null;
207
222
  } catch {
208
223
  return null;
209
224
  }
@@ -228,11 +243,13 @@ function negotiateImageFormat(acceptHeader) {
228
243
  * Optimized images are immutable because the URL encodes the transform params.
229
244
  */
230
245
  const IMAGE_CACHE_CONTROL = "public, max-age=31536000, immutable";
246
+ /** Next.js's default `images.minimumCacheTTL`, in seconds. */
247
+ const DEFAULT_MINIMUM_CACHE_TTL = 14400;
231
248
  /**
232
- * Next.js's Cache-Control for an image whose source can change at its URL
233
- * (anything but a hashed build asset), at the default `images.minimumCacheTTL`.
249
+ * Next.js's default `images.maximumResponseBody`: the most it reads of a
250
+ * source before answering 413.
234
251
  */
235
- const MUTABLE_IMAGE_SOURCE_CACHE_CONTROL = "public, max-age=14400, must-revalidate";
252
+ const DEFAULT_MAXIMUM_RESPONSE_BODY = 5e7;
236
253
  /**
237
254
  * Content-Security-Policy for image optimization responses.
238
255
  * Blocks script execution and framing to prevent XSS via SVG or other
@@ -253,16 +270,207 @@ const SAFE_IMAGE_CONTENT_TYPES = /* @__PURE__ */ new Set([
253
270
  "image/avif",
254
271
  "image/x-icon",
255
272
  "image/vnd.microsoft.icon",
273
+ "image/x-icns",
274
+ "image/bmp",
275
+ "image/tiff",
276
+ "image/jxl",
277
+ "image/heic"
278
+ ]);
279
+ /**
280
+ * Formats Next.js serves as-is instead of optimizing (`BYPASS_TYPES` in
281
+ * packages/next/src/server/image-optimizer.ts), with ICO's registered name.
282
+ */
283
+ const BYPASS_IMAGE_CONTENT_TYPES = /* @__PURE__ */ new Set([
284
+ "image/svg+xml",
285
+ "image/x-icon",
286
+ "image/vnd.microsoft.icon",
287
+ "image/x-icns",
256
288
  "image/bmp",
257
- "image/tiff"
289
+ "image/jxl",
290
+ "image/heic"
291
+ ]);
292
+ /** Formats Next.js serves as-is when animated (`ANIMATABLE_TYPES`). */
293
+ const ANIMATABLE_IMAGE_CONTENT_TYPES = /* @__PURE__ */ new Set([
294
+ "image/webp",
295
+ "image/png",
296
+ "image/gif"
258
297
  ]);
298
+ /** Next.js's 400 body for a source whose type it does not serve, such as SVG. */
299
+ const IMAGE_TYPE_NOT_ALLOWED_MESSAGE = "\"url\" parameter is valid but image type is not allowed";
300
+ function imageTypeNotAllowedResponse() {
301
+ return new Response(IMAGE_TYPE_NOT_ALLOWED_MESSAGE, { status: 400 });
302
+ }
303
+ function imageMediaType(contentType) {
304
+ return contentType?.split(";")[0].trim().toLowerCase() ?? "";
305
+ }
306
+ /**
307
+ * Next.js's image signatures (`detectContentType` in
308
+ * packages/next/src/server/image-optimizer.ts); a null byte matches any value.
309
+ */
310
+ const IMAGE_SIGNATURES = [
311
+ ["image/jpeg", [
312
+ 255,
313
+ 216,
314
+ 255
315
+ ]],
316
+ ["image/png", [
317
+ 137,
318
+ 80,
319
+ 78,
320
+ 71,
321
+ 13,
322
+ 10,
323
+ 26,
324
+ 10
325
+ ]],
326
+ ["image/gif", [
327
+ 71,
328
+ 73,
329
+ 70,
330
+ 56
331
+ ]],
332
+ ["image/webp", [
333
+ 82,
334
+ 73,
335
+ 70,
336
+ 70,
337
+ null,
338
+ null,
339
+ null,
340
+ null,
341
+ 87,
342
+ 69,
343
+ 66,
344
+ 80
345
+ ]],
346
+ ["image/svg+xml", [
347
+ 60,
348
+ 63,
349
+ 120,
350
+ 109,
351
+ 108
352
+ ]],
353
+ ["image/svg+xml", [
354
+ 60,
355
+ 115,
356
+ 118,
357
+ 103
358
+ ]],
359
+ ["image/avif", [
360
+ null,
361
+ null,
362
+ null,
363
+ null,
364
+ 102,
365
+ 116,
366
+ 121,
367
+ 112,
368
+ 97,
369
+ 118,
370
+ 105,
371
+ 102
372
+ ]],
373
+ ["image/x-icon", [
374
+ 0,
375
+ 0,
376
+ 1,
377
+ 0
378
+ ]],
379
+ ["image/x-icns", [
380
+ 105,
381
+ 99,
382
+ 110,
383
+ 115
384
+ ]],
385
+ ["image/tiff", [
386
+ 73,
387
+ 73,
388
+ 42,
389
+ 0
390
+ ]],
391
+ ["image/bmp", [66, 77]],
392
+ ["image/jxl", [255, 10]],
393
+ ["image/jxl", [
394
+ 0,
395
+ 0,
396
+ 0,
397
+ 12,
398
+ 74,
399
+ 88,
400
+ 76,
401
+ 32,
402
+ 13,
403
+ 10,
404
+ 135,
405
+ 10
406
+ ]],
407
+ ["image/heic", [
408
+ null,
409
+ null,
410
+ null,
411
+ null,
412
+ 102,
413
+ 116,
414
+ 121,
415
+ 112,
416
+ 104,
417
+ 101,
418
+ 105,
419
+ 99
420
+ ]]
421
+ ];
422
+ const IMAGE_SIGNATURE_LENGTH = 12;
423
+ /** The image type Next.js detects from a source's first bytes, or null. */
424
+ function sniffImageContentType(bytes) {
425
+ for (const [type, signature] of IMAGE_SIGNATURES) if (signature.every((byte, i) => byte === null || bytes[i] === byte)) return type;
426
+ return null;
427
+ }
428
+ /**
429
+ * Label a source that arrives without an image Content-Type by its first
430
+ * bytes, as Next.js types every source. Workers static assets have no type for
431
+ * some formats Next.js serves, such as ICNS.
432
+ */
433
+ async function withSniffedImageContentType(source) {
434
+ if (!source.ok || !source.body || imageMediaType(source.headers.get("Content-Type")).startsWith("image/")) return source;
435
+ const reader = source.body.getReader();
436
+ const head = [];
437
+ const signature = new Uint8Array(IMAGE_SIGNATURE_LENGTH);
438
+ let length = 0;
439
+ while (length < IMAGE_SIGNATURE_LENGTH) {
440
+ const { done, value } = await reader.read();
441
+ if (done) break;
442
+ head.push(value);
443
+ signature.set(value.subarray(0, IMAGE_SIGNATURE_LENGTH - length), length);
444
+ length += Math.min(value.byteLength, IMAGE_SIGNATURE_LENGTH - length);
445
+ }
446
+ const body = new ReadableStream({
447
+ start(controller) {
448
+ for (const chunk of head) controller.enqueue(chunk);
449
+ },
450
+ async pull(controller) {
451
+ const { done, value } = await reader.read();
452
+ if (done) controller.close();
453
+ else controller.enqueue(value);
454
+ },
455
+ cancel(reason) {
456
+ return reader.cancel(reason);
457
+ }
458
+ });
459
+ const headers = new Headers(source.headers);
460
+ const type = sniffImageContentType(signature.subarray(0, length));
461
+ if (type !== null) headers.set("Content-Type", type);
462
+ return new Response(body, {
463
+ status: source.status,
464
+ statusText: source.statusText,
465
+ headers
466
+ });
467
+ }
259
468
  /**
260
469
  * Check if a Content-Type header value is a safe image type.
261
470
  * Returns false for SVG (unless dangerouslyAllowSVG is true), HTML, or any non-image type.
262
471
  */
263
472
  function isSafeImageContentType(contentType, dangerouslyAllowSVG = false) {
264
- if (!contentType) return false;
265
- const mediaType = contentType.split(";")[0].trim().toLowerCase();
473
+ const mediaType = imageMediaType(contentType);
266
474
  if (SAFE_IMAGE_CONTENT_TYPES.has(mediaType)) return true;
267
475
  if (dangerouslyAllowSVG && mediaType === "image/svg+xml") return true;
268
476
  return false;
@@ -295,20 +503,65 @@ function setImageSecurityHeaders(headers, config) {
295
503
  */
296
504
  const IMAGE_RESPONSE_CACHE_STATE = "MISS";
297
505
  /** Headers shared by every successful image response, transformed or not. */
298
- function setImageResponseHeaders(headers, config) {
299
- headers.set("Cache-Control", IMAGE_CACHE_CONTROL);
506
+ function setImageResponseHeaders(headers, cacheControl, config) {
507
+ headers.set("Cache-Control", cacheControl);
300
508
  headers.set("Vary", "Accept");
301
509
  setImageSecurityHeaders(headers, config);
302
510
  setCacheStateHeaders(headers, IMAGE_RESPONSE_CACHE_STATE);
303
511
  }
304
- function createPassthroughImageResponse(source, config) {
512
+ /** The `s-maxage` or `max-age` of a Cache-Control value, like Next.js' getMaxAge. */
513
+ function cacheControlMaxAge(cacheControl) {
514
+ const directives = /* @__PURE__ */ new Map();
515
+ for (const directive of cacheControl?.split(",") ?? []) {
516
+ const [key, value] = directive.trim().split("=", 2);
517
+ directives.set(key.toLowerCase(), value?.toLowerCase());
518
+ }
519
+ let age = directives.get("s-maxage") || directives.get("max-age") || "";
520
+ if (age.startsWith("\"") && age.endsWith("\"")) age = age.slice(1, -1);
521
+ const seconds = Number.parseInt(age, 10);
522
+ return Number.isNaN(seconds) ? 0 : seconds;
523
+ }
524
+ const IMAGE_BUILD_ASSET = Symbol.for("vinext.imageBuildAsset");
525
+ /**
526
+ * Mark a source the host fetched from its `_next/static` build output, which
527
+ * is served immutably like Next.js' `isStatic` sources. A public file is never
528
+ * marked, whatever its path.
529
+ */
530
+ function markImageBuildAsset(response) {
531
+ Reflect.set(response, IMAGE_BUILD_ASSET, true);
532
+ return response;
533
+ }
534
+ function isImageBuildAsset(response) {
535
+ return Reflect.get(response, IMAGE_BUILD_ASSET) === true;
536
+ }
537
+ /**
538
+ * Next.js's Cache-Control for a source it serves as-is: a build asset stays
539
+ * immutable, and any other source is revalidated after the larger of
540
+ * `images.minimumCacheTTL` and the source's own max-age. Next.js ignores the
541
+ * source's max-age when it falls back after a failed transform, so that
542
+ * fallback passes null.
543
+ */
544
+ function sourceImageCacheControl(isBuildAsset, upstreamCacheControl, config) {
545
+ if (isBuildAsset) return IMAGE_CACHE_CONTROL;
546
+ return `public, max-age=${Math.max(config?.minimumCacheTTL ?? DEFAULT_MINIMUM_CACHE_TTL, cacheControlMaxAge(upstreamCacheControl))}, must-revalidate`;
547
+ }
548
+ function createPassthroughImageResponse(source, body, cacheControl, config, readLength = null) {
305
549
  const headers = new Headers(source.headers);
306
- setImageResponseHeaders(headers, config);
307
- return new Response(source.body, {
550
+ setImageResponseHeaders(headers, cacheControl, config);
551
+ if (readLength !== null) headers.set("Content-Length", String(readLength));
552
+ return new Response(body, {
308
553
  status: 200,
309
554
  headers
310
555
  });
311
556
  }
557
+ /** Whether a source's Content-Length is more than `limit` bytes. */
558
+ function declaresMoreThan(source, limit) {
559
+ return Number(source.headers.get("Content-Length") ?? NaN) > limit;
560
+ }
561
+ /** Next.js's 413 for a source larger than `images.maximumResponseBody`. */
562
+ function sourceTooLargeResponse() {
563
+ return new Response("\"url\" parameter is valid but internal response is invalid", { status: 413 });
564
+ }
312
565
  /**
313
566
  * Handle image optimization requests.
314
567
  *
@@ -317,39 +570,80 @@ function createPassthroughImageResponse(source, config) {
317
570
  * cache headers.
318
571
  */
319
572
  async function handleImageOptimization(request, handlers, allowedWidths, imageConfig) {
320
- const params = parseImageParams(new URL(request.url), allowedWidths, imageConfig?.qualities);
573
+ const params = parseImageParams(new URL(request.url), allowedWidths, imageConfig?.qualities, { localPatterns: imageConfig?.localPatterns });
321
574
  if (!params) return badRequestResponse();
322
575
  const { imageUrl, width, quality } = params;
323
- const source = await handlers.fetchAsset(imageUrl, request);
576
+ const fetchedSource = await handlers.fetchAsset(imageUrl, request);
577
+ const isBuildAsset = isImageBuildAsset(fetchedSource);
578
+ const source = await withSniffedImageContentType(fetchedSource);
324
579
  if (!source.ok || !source.body) return invalidImageSourceResponse();
325
580
  const format = negotiateImageFormat(request.headers.get("Accept"));
326
581
  const sourceContentType = source.headers.get("Content-Type");
327
- if (!isSafeImageContentType(sourceContentType, imageConfig?.dangerouslyAllowSVG)) return new Response("The requested resource is not an allowed image type", { status: 400 });
328
- if (sourceContentType?.split(";")[0].trim().toLowerCase() === "image/svg+xml") return createPassthroughImageResponse(source, imageConfig);
582
+ if (!isSafeImageContentType(sourceContentType, imageConfig?.dangerouslyAllowSVG)) return imageTypeNotAllowedResponse();
583
+ const sourceMediaType = imageMediaType(sourceContentType);
584
+ const sourceCacheControl = sourceImageCacheControl(isBuildAsset, source.headers.get("Cache-Control"), imageConfig);
585
+ if (BYPASS_IMAGE_CONTENT_TYPES.has(sourceMediaType)) {
586
+ const response = createPassthroughImageResponse(source, source.body, sourceCacheControl, imageConfig);
587
+ if (sourceMediaType === "image/vnd.microsoft.icon") response.headers.set("Content-Type", "image/x-icon");
588
+ return response;
589
+ }
590
+ const limit = imageConfig?.maximumResponseBody ?? DEFAULT_MAXIMUM_RESPONSE_BODY;
591
+ let reader = null;
329
592
  if (handlers.transformImage) try {
330
- const transformed = await handlers.transformImage(source.body, {
593
+ let body = source.body;
594
+ if (ANIMATABLE_IMAGE_CONTENT_TYPES.has(sourceMediaType)) {
595
+ if (declaresMoreThan(source, limit)) {
596
+ source.body.cancel().catch(() => {});
597
+ return sourceTooLargeResponse();
598
+ }
599
+ reader = new ImageSourceReader(source.body, limit);
600
+ const animated = await isAnimatedImage(reader);
601
+ let sourceBody = reader.body();
602
+ if (!sourceBody) {
603
+ reader.cancel();
604
+ const refetched = await handlers.fetchAsset(imageUrl, request);
605
+ if (!refetched.ok || !refetched.body) return invalidImageSourceResponse();
606
+ reader = new ImageSourceReader(refetched.body, limit);
607
+ sourceBody = reader.body();
608
+ }
609
+ if (animated) return createPassthroughImageResponse(source, sourceBody, sourceCacheControl, imageConfig, reader.completeLength);
610
+ body = sourceBody;
611
+ }
612
+ const transformed = await handlers.transformImage(body, {
331
613
  width,
332
614
  format,
333
615
  quality
334
616
  });
335
617
  const headers = new Headers(transformed.headers);
336
- setImageResponseHeaders(headers, imageConfig);
618
+ setImageResponseHeaders(headers, IMAGE_CACHE_CONTROL, imageConfig);
337
619
  if (!isSafeImageContentType(headers.get("Content-Type"), imageConfig?.dangerouslyAllowSVG)) headers.set("Content-Type", format);
338
620
  return new Response(transformed.body, {
339
621
  status: 200,
340
622
  headers
341
623
  });
342
624
  } catch (e) {
625
+ if (e instanceof ImageSourceTooLargeError || reader?.tooLarge) return sourceTooLargeResponse();
343
626
  console.error("[vinext] Image optimization error:", e);
344
627
  }
628
+ const fallbackCacheControl = handlers.transformImage ? sourceImageCacheControl(isBuildAsset, null, imageConfig) : sourceCacheControl;
629
+ const readLength = reader?.completeLength ?? null;
345
630
  try {
346
- return createPassthroughImageResponse(source, imageConfig);
631
+ return createPassthroughImageResponse(source, readLength !== null && reader?.body() || source.body, fallbackCacheControl, imageConfig, readLength);
347
632
  } catch (e) {
348
633
  console.error("[vinext] Image fallback error, refetching source image:", e);
349
- const refetchedSource = await handlers.fetchAsset(imageUrl, request);
634
+ reader?.cancel();
635
+ const refetchedSource = await withSniffedImageContentType(await handlers.fetchAsset(imageUrl, request));
350
636
  if (!refetchedSource.ok || !refetchedSource.body) return invalidImageSourceResponse();
351
- if (!isSafeImageContentType(refetchedSource.headers.get("Content-Type"), imageConfig?.dangerouslyAllowSVG)) return new Response("The requested resource is not an allowed image type", { status: 400 });
352
- return createPassthroughImageResponse(refetchedSource, imageConfig);
637
+ if (!isSafeImageContentType(refetchedSource.headers.get("Content-Type"), imageConfig?.dangerouslyAllowSVG)) return imageTypeNotAllowedResponse();
638
+ let body = refetchedSource.body;
639
+ if (reader) {
640
+ if (declaresMoreThan(refetchedSource, limit)) {
641
+ body.cancel().catch(() => {});
642
+ return sourceTooLargeResponse();
643
+ }
644
+ body = new ImageSourceReader(body, limit).body();
645
+ }
646
+ return createPassthroughImageResponse(refetchedSource, body, fallbackCacheControl, imageConfig);
353
647
  }
354
648
  }
355
649
  const _IMAGE_OPTIMIZER_KEY = Symbol.for("vinext.imageOptimizer");
@@ -402,4 +696,4 @@ function handleConfiguredImageOptimization(request, fetchAsset, allowedWidths, i
402
696
  }, allowedWidths, imageConfig);
403
697
  }
404
698
  //#endregion
405
- export { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES, IMAGE_CACHE_CONTROL, IMAGE_CONTENT_SECURITY_POLICY, IMAGE_OPTIMIZATION_PATH, IMAGE_RESPONSE_CACHE_STATE, INVALID_IMAGE_SOURCE_MESSAGE, MUTABLE_IMAGE_SOURCE_CACHE_CONTROL, VINEXT_IMAGE_OPTIMIZATION_PATH, getImageOptimizer, handleConfiguredImageOptimization, handleImageOptimization, invalidImageSourceResponse, isImageOptimizationPath, isImageSourceBodyStatus, isSafeImageContentType, markImageOptimizationSignal, negotiateImageFormat, parseImageParams, readImageOptimizationSignal, resolveDevImageRedirect, restoreImageOptimizationSignalFromTransport, serializeImageOptimizationSignalForTransport, setImageOptimizer };
699
+ export { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES, DEFAULT_LOCAL_PATTERNS, IMAGE_CACHE_CONTROL, IMAGE_CONTENT_SECURITY_POLICY, IMAGE_OPTIMIZATION_PATH, IMAGE_RESPONSE_CACHE_STATE, IMAGE_TYPE_NOT_ALLOWED_MESSAGE, INVALID_IMAGE_SOURCE_MESSAGE, VINEXT_IMAGE_OPTIMIZATION_PATH, getImageOptimizer, handleConfiguredImageOptimization, handleImageOptimization, invalidImageSourceResponse, isImageOptimizationPath, isImageSourceBodyStatus, isSafeImageContentType, markImageBuildAsset, markImageOptimizationSignal, negotiateImageFormat, parseImageParams, readImageOptimizationSignal, resolveDevImageRedirect, restoreImageOptimizationSignalFromTransport, serializeImageOptimizationSignalForTransport, setImageOptimizer, sniffImageContentType, sourceImageCacheControl };
@@ -0,0 +1,34 @@
1
+ //#region src/server/image-source-reader.d.ts
2
+ /**
3
+ * Streamed reads of an image source for the image optimization endpoint.
4
+ *
5
+ * Animation detection parses a GIF, PNG or WebP source as it streams in rather
6
+ * than buffering it, so a large source cannot exhaust a Worker isolate's
7
+ * memory. Only the source's first bytes are kept, to replay without a refetch.
8
+ */
9
+ /** Thrown once a source grows past `images.maximumResponseBody`. */
10
+ export declare class ImageSourceTooLargeError extends Error {
11
+ constructor();
12
+ }
13
+ export declare class ImageSourceReader {
14
+ #private;
15
+ constructor(body: ReadableStream<Uint8Array>, limit: number);
16
+ /** Whether the source has grown past the limit. */
17
+ get tooLarge(): boolean;
18
+ /** The source's length once it has all been read and held, else null. */
19
+ get completeLength(): number | null;
20
+ /** Stop reading the source without waiting on its cancellation. */
21
+ cancel(): void;
22
+ /** Up to `length` bytes at `offset`, fewer where the source ends. */
23
+ bytes(offset: number, length: number): Promise<Uint8Array>;
24
+ /** The byte at `offset`, or undefined past the source's end. */
25
+ byte(offset: number): Promise<number | undefined>;
26
+ /**
27
+ * The whole source as a stream, replaying the bytes already read, or null
28
+ * once they are no longer all held. An incompletely read source replays
29
+ * once; the rest streams from the source, still within the limit, and is
30
+ * held too while the whole source fits.
31
+ */
32
+ body(): ReadableStream<Uint8Array> | null;
33
+ }
34
+ //#endregion