vinext 1.4.2 → 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 (88) hide show
  1. package/README.md +4 -1
  2. package/dist/build/prerender-paths.d.ts +11 -1
  3. package/dist/build/prerender-paths.js +36 -10
  4. package/dist/cli.js +1 -1
  5. package/dist/client/dev-error-overlay.d.ts +2 -1
  6. package/dist/client/dev-error-overlay.js +8 -3
  7. package/dist/config/config-matchers.js +6 -2
  8. package/dist/config/next-config.d.ts +9 -0
  9. package/dist/entries/app-rsc-entry.js +24 -12
  10. package/dist/entries/pages-server-entry.js +17 -4
  11. package/dist/index.js +22 -25
  12. package/dist/init-platform.js +2 -2
  13. package/dist/init.js +42 -5
  14. package/dist/server/api-handler.js +7 -3
  15. package/dist/server/app-browser-entry.js +16 -37
  16. package/dist/server/app-browser-error.d.ts +1 -1
  17. package/dist/server/app-browser-error.js +13 -4
  18. package/dist/server/app-browser-supplemental-refresh.d.ts +13 -0
  19. package/dist/server/app-browser-supplemental-refresh.js +36 -4
  20. package/dist/server/app-middleware.d.ts +2 -0
  21. package/dist/server/app-middleware.js +1 -0
  22. package/dist/server/app-page-boundary-render.js +2 -0
  23. package/dist/server/app-page-dispatch.d.ts +1 -0
  24. package/dist/server/app-page-dispatch.js +5 -1
  25. package/dist/server/app-page-element-builder.d.ts +2 -0
  26. package/dist/server/app-page-element-builder.js +2 -1
  27. package/dist/server/app-page-route-wiring.d.ts +7 -5
  28. package/dist/server/app-page-route-wiring.js +3 -1
  29. package/dist/server/app-pages-bridge.d.ts +6 -0
  30. package/dist/server/app-pages-bridge.js +8 -3
  31. package/dist/server/app-request-stage-independent-entry.js +2 -3
  32. package/dist/server/app-router-entry.js +2 -3
  33. package/dist/server/app-rsc-cache-busting.d.ts +8 -0
  34. package/dist/server/app-rsc-cache-busting.js +13 -1
  35. package/dist/server/app-rsc-combined-handler.js +2 -1
  36. package/dist/server/app-rsc-handler.d.ts +4 -0
  37. package/dist/server/app-rsc-handler.js +39 -22
  38. package/dist/server/app-rsc-response-finalizer.d.ts +15 -3
  39. package/dist/server/app-rsc-response-finalizer.js +51 -7
  40. package/dist/server/app-ssr-entry.js +8 -0
  41. package/dist/server/config-headers.d.ts +7 -0
  42. package/dist/server/config-headers.js +20 -1
  43. package/dist/server/image-animation.d.ts +9 -0
  44. package/dist/server/image-animation.js +118 -0
  45. package/dist/server/image-optimization.d.ts +25 -5
  46. package/dist/server/image-optimization.js +305 -21
  47. package/dist/server/image-source-reader.d.ts +34 -0
  48. package/dist/server/image-source-reader.js +126 -0
  49. package/dist/server/metadata-route-response.d.ts +6 -1
  50. package/dist/server/metadata-route-response.js +22 -4
  51. package/dist/server/middleware-matcher.d.ts +1 -0
  52. package/dist/server/middleware-matcher.js +1 -1
  53. package/dist/server/middleware-runtime.d.ts +5 -0
  54. package/dist/server/middleware-runtime.js +48 -15
  55. package/dist/server/middleware.d.ts +1 -1
  56. package/dist/server/middleware.js +2 -1
  57. package/dist/server/nitro-request-target-plugin.d.ts +17 -0
  58. package/dist/server/nitro-request-target-plugin.js +22 -0
  59. package/dist/server/open-redirect.d.ts +2 -1
  60. package/dist/server/open-redirect.js +2 -1
  61. package/dist/server/pages-client-assets.d.ts +1 -0
  62. package/dist/server/pages-i18n.d.ts +7 -0
  63. package/dist/server/pages-i18n.js +14 -1
  64. package/dist/server/pages-node-compat.js +7 -3
  65. package/dist/server/pages-page-handler.js +4 -4
  66. package/dist/server/pages-request-pipeline.d.ts +9 -0
  67. package/dist/server/pages-request-pipeline.js +29 -10
  68. package/dist/server/pages-request-stage-entry.js +7 -2
  69. package/dist/server/pages-router-entry.js +3 -0
  70. package/dist/server/prod-server.d.ts +1 -1
  71. package/dist/server/prod-server.js +48 -54
  72. package/dist/server/srvx-middleware-origin.d.ts +13 -0
  73. package/dist/server/srvx-middleware-origin.js +19 -0
  74. package/dist/server/srvx-request-target.d.ts +12 -0
  75. package/dist/server/srvx-request-target.js +20 -0
  76. package/dist/server/static-file-cache.js +10 -22
  77. package/dist/server/worker-image-optimization.d.ts +8 -1
  78. package/dist/server/worker-image-optimization.js +25 -6
  79. package/dist/server/worker-revalidation-context.js +1 -0
  80. package/dist/shims/error-boundary.d.ts +6 -2
  81. package/dist/shims/error-boundary.js +4 -2
  82. package/dist/shims/request-context.d.ts +3 -1
  83. package/dist/shims/url-utils.js +6 -1
  84. package/dist/utils/client-entry-manifest.d.ts +5 -0
  85. package/dist/utils/client-entry-manifest.js +1 -0
  86. package/dist/utils/client-runtime-metadata.d.ts +5 -0
  87. package/dist/utils/client-runtime-metadata.js +33 -7
  88. package/package.json +1 -1
@@ -64,6 +64,13 @@ export type ImageConfig = {
64
64
  contentDispositionType?: "inline" | "attachment";
65
65
  /** Content-Security-Policy header value. Default: "script-src 'none'; frame-src 'none'; sandbox;" */
66
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;
67
74
  };
68
75
  /**
69
76
  * Next.js default device sizes and image sizes.
@@ -144,11 +151,6 @@ export declare function negotiateImageFormat(acceptHeader: string | null): strin
144
151
  * Optimized images are immutable because the URL encodes the transform params.
145
152
  */
146
153
  export declare const IMAGE_CACHE_CONTROL = "public, max-age=31536000, immutable";
147
- /**
148
- * Next.js's Cache-Control for an image whose source can change at its URL
149
- * (anything but a hashed build asset), at the default `images.minimumCacheTTL`.
150
- */
151
- export declare const MUTABLE_IMAGE_SOURCE_CACHE_CONTROL = "public, max-age=14400, must-revalidate";
152
154
  /**
153
155
  * Content-Security-Policy for image optimization responses.
154
156
  * Blocks script execution and framing to prevent XSS via SVG or other
@@ -156,6 +158,10 @@ export declare const MUTABLE_IMAGE_SOURCE_CACHE_CONTROL = "public, max-age=14400
156
158
  * Matches Next.js default: script-src 'none'; frame-src 'none'; sandbox;
157
159
  */
158
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;
159
165
  /**
160
166
  * Check if a Content-Type header value is a safe image type.
161
167
  * Returns false for SVG (unless dangerouslyAllowSVG is true), HTML, or any non-image type.
@@ -177,6 +183,20 @@ export declare function isSafeImageContentType(contentType: string | null, dange
177
183
  * go out without it. Only full image responses carry either header here too.
178
184
  */
179
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;
180
200
  /**
181
201
  * Handlers for image optimization I/O operations.
182
202
  * Workers provide these callbacks to adapt their specific bindings.
@@ -1,5 +1,7 @@
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";
3
5
  import { hasLocalMatch } from "./image-local-patterns.js";
4
6
  //#region src/server/image-optimization.ts
5
7
  /**
@@ -19,6 +21,9 @@ import { hasLocalMatch } from "./image-local-patterns.js";
19
21
  * spoofing. SVG content is blocked by default (following Next.js behavior).
20
22
  * When `dangerouslyAllowSVG` is enabled in next.config.js, SVGs are served
21
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.
22
27
  */
23
28
  /** The pathname that triggers image optimization (matches Next.js). */
24
29
  const IMAGE_OPTIMIZATION_PATH = "/_next/image";
@@ -238,11 +243,13 @@ function negotiateImageFormat(acceptHeader) {
238
243
  * Optimized images are immutable because the URL encodes the transform params.
239
244
  */
240
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;
241
248
  /**
242
- * Next.js's Cache-Control for an image whose source can change at its URL
243
- * (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.
244
251
  */
245
- const MUTABLE_IMAGE_SOURCE_CACHE_CONTROL = "public, max-age=14400, must-revalidate";
252
+ const DEFAULT_MAXIMUM_RESPONSE_BODY = 5e7;
246
253
  /**
247
254
  * Content-Security-Policy for image optimization responses.
248
255
  * Blocks script execution and framing to prevent XSS via SVG or other
@@ -263,16 +270,207 @@ const SAFE_IMAGE_CONTENT_TYPES = /* @__PURE__ */ new Set([
263
270
  "image/avif",
264
271
  "image/x-icon",
265
272
  "image/vnd.microsoft.icon",
273
+ "image/x-icns",
266
274
  "image/bmp",
267
- "image/tiff"
275
+ "image/tiff",
276
+ "image/jxl",
277
+ "image/heic"
268
278
  ]);
269
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",
288
+ "image/bmp",
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"
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
+ }
468
+ /**
270
469
  * Check if a Content-Type header value is a safe image type.
271
470
  * Returns false for SVG (unless dangerouslyAllowSVG is true), HTML, or any non-image type.
272
471
  */
273
472
  function isSafeImageContentType(contentType, dangerouslyAllowSVG = false) {
274
- if (!contentType) return false;
275
- const mediaType = contentType.split(";")[0].trim().toLowerCase();
473
+ const mediaType = imageMediaType(contentType);
276
474
  if (SAFE_IMAGE_CONTENT_TYPES.has(mediaType)) return true;
277
475
  if (dangerouslyAllowSVG && mediaType === "image/svg+xml") return true;
278
476
  return false;
@@ -305,20 +503,65 @@ function setImageSecurityHeaders(headers, config) {
305
503
  */
306
504
  const IMAGE_RESPONSE_CACHE_STATE = "MISS";
307
505
  /** Headers shared by every successful image response, transformed or not. */
308
- function setImageResponseHeaders(headers, config) {
309
- headers.set("Cache-Control", IMAGE_CACHE_CONTROL);
506
+ function setImageResponseHeaders(headers, cacheControl, config) {
507
+ headers.set("Cache-Control", cacheControl);
310
508
  headers.set("Vary", "Accept");
311
509
  setImageSecurityHeaders(headers, config);
312
510
  setCacheStateHeaders(headers, IMAGE_RESPONSE_CACHE_STATE);
313
511
  }
314
- 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) {
315
549
  const headers = new Headers(source.headers);
316
- setImageResponseHeaders(headers, config);
317
- 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, {
318
553
  status: 200,
319
554
  headers
320
555
  });
321
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
+ }
322
565
  /**
323
566
  * Handle image optimization requests.
324
567
  *
@@ -330,36 +573,77 @@ async function handleImageOptimization(request, handlers, allowedWidths, imageCo
330
573
  const params = parseImageParams(new URL(request.url), allowedWidths, imageConfig?.qualities, { localPatterns: imageConfig?.localPatterns });
331
574
  if (!params) return badRequestResponse();
332
575
  const { imageUrl, width, quality } = params;
333
- 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);
334
579
  if (!source.ok || !source.body) return invalidImageSourceResponse();
335
580
  const format = negotiateImageFormat(request.headers.get("Accept"));
336
581
  const sourceContentType = source.headers.get("Content-Type");
337
- if (!isSafeImageContentType(sourceContentType, imageConfig?.dangerouslyAllowSVG)) return new Response("The requested resource is not an allowed image type", { status: 400 });
338
- 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;
339
592
  if (handlers.transformImage) try {
340
- 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, {
341
613
  width,
342
614
  format,
343
615
  quality
344
616
  });
345
617
  const headers = new Headers(transformed.headers);
346
- setImageResponseHeaders(headers, imageConfig);
618
+ setImageResponseHeaders(headers, IMAGE_CACHE_CONTROL, imageConfig);
347
619
  if (!isSafeImageContentType(headers.get("Content-Type"), imageConfig?.dangerouslyAllowSVG)) headers.set("Content-Type", format);
348
620
  return new Response(transformed.body, {
349
621
  status: 200,
350
622
  headers
351
623
  });
352
624
  } catch (e) {
625
+ if (e instanceof ImageSourceTooLargeError || reader?.tooLarge) return sourceTooLargeResponse();
353
626
  console.error("[vinext] Image optimization error:", e);
354
627
  }
628
+ const fallbackCacheControl = handlers.transformImage ? sourceImageCacheControl(isBuildAsset, null, imageConfig) : sourceCacheControl;
629
+ const readLength = reader?.completeLength ?? null;
355
630
  try {
356
- return createPassthroughImageResponse(source, imageConfig);
631
+ return createPassthroughImageResponse(source, readLength !== null && reader?.body() || source.body, fallbackCacheControl, imageConfig, readLength);
357
632
  } catch (e) {
358
633
  console.error("[vinext] Image fallback error, refetching source image:", e);
359
- const refetchedSource = await handlers.fetchAsset(imageUrl, request);
634
+ reader?.cancel();
635
+ const refetchedSource = await withSniffedImageContentType(await handlers.fetchAsset(imageUrl, request));
360
636
  if (!refetchedSource.ok || !refetchedSource.body) return invalidImageSourceResponse();
361
- if (!isSafeImageContentType(refetchedSource.headers.get("Content-Type"), imageConfig?.dangerouslyAllowSVG)) return new Response("The requested resource is not an allowed image type", { status: 400 });
362
- 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);
363
647
  }
364
648
  }
365
649
  const _IMAGE_OPTIMIZER_KEY = Symbol.for("vinext.imageOptimizer");
@@ -412,4 +696,4 @@ function handleConfiguredImageOptimization(request, fetchAsset, allowedWidths, i
412
696
  }, allowedWidths, imageConfig);
413
697
  }
414
698
  //#endregion
415
- export { DEFAULT_DEVICE_SIZES, DEFAULT_IMAGE_SIZES, DEFAULT_LOCAL_PATTERNS, 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
@@ -0,0 +1,126 @@
1
+ //#region src/server/image-source-reader.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
+ /** Most bytes of a source kept to replay it after animation detection. */
10
+ const HELD_SOURCE_LIMIT = 1048576;
11
+ /** Thrown once a source grows past `images.maximumResponseBody`. */
12
+ var ImageSourceTooLargeError = class extends Error {
13
+ constructor() {
14
+ super("Image source exceeds images.maximumResponseBody");
15
+ }
16
+ };
17
+ var ImageSourceReader = class {
18
+ #reader;
19
+ #limit;
20
+ #read = 0;
21
+ #done = false;
22
+ #tooLarge = false;
23
+ #window = [];
24
+ #windowStart = 0;
25
+ #windowEnd = 0;
26
+ #held = [];
27
+ #replayed = false;
28
+ constructor(body, limit) {
29
+ this.#reader = body.getReader();
30
+ this.#limit = limit;
31
+ }
32
+ /** Whether the source has grown past the limit. */
33
+ get tooLarge() {
34
+ return this.#tooLarge;
35
+ }
36
+ /** The source's length once it has all been read and held, else null. */
37
+ get completeLength() {
38
+ return this.#done && this.#held ? this.#read : null;
39
+ }
40
+ /** Stop reading the source without waiting on its cancellation. */
41
+ cancel() {
42
+ this.#reader.cancel().catch(() => {});
43
+ }
44
+ /** Up to `length` bytes at `offset`, fewer where the source ends. */
45
+ async bytes(offset, length) {
46
+ const end = offset + length;
47
+ await this.#reach(offset, end);
48
+ const available = Math.min(end, this.#windowEnd);
49
+ const bytes = new Uint8Array(Math.max(0, available - offset));
50
+ let start = this.#windowStart;
51
+ for (const chunk of this.#window) {
52
+ if (start >= available) break;
53
+ const from = Math.max(offset - start, 0);
54
+ const to = Math.min(available - start, chunk.byteLength);
55
+ if (from < to) bytes.set(chunk.subarray(from, to), start + from - offset);
56
+ start += chunk.byteLength;
57
+ }
58
+ return bytes;
59
+ }
60
+ /** The byte at `offset`, or undefined past the source's end. */
61
+ async byte(offset) {
62
+ return (await this.bytes(offset, 1))[0];
63
+ }
64
+ /**
65
+ * The whole source as a stream, replaying the bytes already read, or null
66
+ * once they are no longer all held. An incompletely read source replays
67
+ * once; the rest streams from the source, still within the limit, and is
68
+ * held too while the whole source fits.
69
+ */
70
+ body() {
71
+ const held = this.#held;
72
+ if (held === null || this.#replayed && !this.#done) return null;
73
+ this.#replayed = true;
74
+ this.#window = [];
75
+ let index = 0;
76
+ return new ReadableStream({
77
+ pull: async (controller) => {
78
+ if (index < held.length) {
79
+ controller.enqueue(held[index++]);
80
+ return;
81
+ }
82
+ const chunk = await this.#next();
83
+ if (!chunk) return controller.close();
84
+ this.#hold(chunk);
85
+ index = held.length;
86
+ controller.enqueue(chunk);
87
+ },
88
+ cancel: (reason) => this.#reader.cancel(reason)
89
+ });
90
+ }
91
+ async #next() {
92
+ if (this.#done) return void 0;
93
+ const { done, value } = await this.#reader.read();
94
+ if (done) {
95
+ this.#done = true;
96
+ return;
97
+ }
98
+ this.#read += value.byteLength;
99
+ if (this.#read > this.#limit) {
100
+ this.#tooLarge = true;
101
+ this.cancel();
102
+ throw new ImageSourceTooLargeError();
103
+ }
104
+ return value;
105
+ }
106
+ #hold(chunk) {
107
+ if (!this.#held) return;
108
+ if (this.#read <= HELD_SOURCE_LIMIT) this.#held.push(chunk);
109
+ else this.#held = null;
110
+ }
111
+ /** Read until `end` is in the window, dropping chunks wholly before `offset`. */
112
+ async #reach(offset, end) {
113
+ if (offset < this.#windowStart) throw new Error("Image source reads must not move backwards");
114
+ for (;;) {
115
+ while (this.#window.length > 0 && this.#windowStart + this.#window[0].byteLength <= offset) this.#windowStart += this.#window.shift().byteLength;
116
+ if (this.#windowEnd >= end) return;
117
+ const chunk = await this.#next();
118
+ if (!chunk) return;
119
+ this.#hold(chunk);
120
+ this.#window.push(chunk);
121
+ this.#windowEnd += chunk.byteLength;
122
+ }
123
+ }
124
+ };
125
+ //#endregion
126
+ export { ImageSourceReader, ImageSourceTooLargeError };
@@ -21,6 +21,11 @@ type MetadataRouteRequestOptions = {
21
21
  isrRouteKey?: (pathname: string) => string;
22
22
  isrSet?: MetadataRouteCacheSetter;
23
23
  makeThenableParams: MetadataRouteMakeThenableParams;
24
+ /**
25
+ * The pathname params are read from. A direct request passes its raw path
26
+ * here and its decoded path as `cleanPathname`; a rewrite passes its
27
+ * destination, still percent-encoded, as both.
28
+ */
24
29
  routePathname?: string;
25
30
  scheduleBackgroundRegeneration?: MetadataRouteBackgroundRegenerator;
26
31
  };
@@ -36,6 +41,6 @@ export type PrerenderableMetadataRoute = {
36
41
  */
37
42
  export declare function getPrerenderableMetadataRoutePaths(metadataRoutes: readonly MetadataRuntimeRoute[]): Promise<PrerenderableMetadataRoute[]>;
38
43
  export declare function isMetadataRouteRequestPath(metadataRoutes: readonly MetadataRuntimeRoute[], cleanPathname: string): boolean;
39
- export declare function handleMetadataRouteRequest(options: MetadataRouteRequestOptions): Promise<Response | null>;
44
+ export declare function handleMetadataRouteRequest(requestOptions: MetadataRouteRequestOptions): Promise<Response | null>;
40
45
  //#endregion
41
46
  export {};