From 4fcecbd5d1f558f3a5923de65aa2c414533e9e7e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 03:56:48 +0000 Subject: [PATCH 01/12] feat(image): add shared image config and prop type contract Add the Next.js 16-compatible images config types and defaults, the framework-neutral Image prop types, and picomatch for pattern parity. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6 --- packages/void/package.json | 2 + packages/void/src/image/config.ts | 253 ++++++++++++++++++++++++++++++ packages/void/src/image/types.ts | 126 +++++++++++++++ pnpm-lock.yaml | 11 ++ 4 files changed, 392 insertions(+) create mode 100644 packages/void/src/image/config.ts create mode 100644 packages/void/src/image/types.ts diff --git a/packages/void/package.json b/packages/void/package.json index 8b76fa47c..c04c757b6 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -376,6 +376,7 @@ "minimatch": "^10.2.6", "mysql2": "^3.24.6", "pg": "^8.23.1", + "picomatch": "^4.0.7", "postal-mime": "catalog:", "proper-lockfile": "^4.1.2", "terminal-link": "^5.0.0", @@ -386,6 +387,7 @@ "@types/better-sqlite3": "^9.6.0", "@types/node": "catalog:", "@types/pg": "^8.23.1", + "@types/picomatch": "^4.0.3", "@types/proper-lockfile": "^4.1.4", "@voidzero-dev/vite-plus-prompts": "catalog:", "arktype": "^2.2.8", diff --git a/packages/void/src/image/config.ts b/packages/void/src/image/config.ts new file mode 100644 index 000000000..b31c7e9db --- /dev/null +++ b/packages/void/src/image/config.ts @@ -0,0 +1,253 @@ +/** + * Image configuration shared by `void.config.ts`, the `/_void/image` endpoint and + * every adapter's `Image` component. Keys, types and defaults follow `images` in + * Next.js 16 `next.config.js`; the endpoint path is `/_void/image`. + * + * Worker- and browser-safe: no Node.js imports. + */ + +/** Output formats the endpoint may negotiate from `Accept`. */ +export type ImageFormat = 'image/avif' | 'image/webp'; + +/** + * Built-in loader names. `cloudflare` is a Void addition that emits Cloudflare + * zone transformation URLs (`/cdn-cgi/image/...`). + */ +export type ImageLoaderName = + | 'default' + | 'imgix' + | 'cloudinary' + | 'akamai' + | 'custom' + | 'cloudflare'; + +/** A remote source allowed by the endpoint. Fields other than `hostname` are optional. */ +export type RemotePattern = { + protocol?: 'http' | 'https'; + hostname: string; + port?: string; + pathname?: string; + search?: string; +}; + +/** A local source allowed by the endpoint. */ +export type LocalPattern = { + pathname?: string; + search?: string; +}; + +/** `images` in `void.config.ts`. Every key is optional. */ +export type ImageConfig = { + /** Widths used for `w` descriptors when `sizes` or `fill` is set. */ + deviceSizes?: Array; + /** Widths combined with `deviceSizes` for fixed-width and `sizes` images. */ + imageSizes?: Array; + /** Allowed `q` values. */ + qualities?: Array; + /** Output formats to negotiate from `Accept`, in order of preference. */ + formats?: Array; + /** Minimum `Cache-Control` max-age, in seconds, for optimized images. */ + minimumCacheTTL?: number; + /** Remote sources the endpoint may fetch. Accepts `URL` instances. */ + remotePatterns?: Array; + /** Local sources the endpoint may load. All paths without a query when unset. */ + localPatterns?: Array; + /** Serve SVG sources (unchanged) instead of rejecting them. */ + dangerouslyAllowSVG?: boolean; + /** Allow remote sources that resolve to private or loopback IP literals. */ + dangerouslyAllowLocalIP?: boolean; + /** `Content-Security-Policy` sent with every endpoint response. */ + contentSecurityPolicy?: string; + /** `Content-Disposition` type sent with every endpoint response. */ + contentDispositionType?: 'inline' | 'attachment'; + /** Redirects followed for remote sources. */ + maximumRedirects?: number; + /** Largest remote source body, in bytes. Capped at 20 MB. */ + maximumResponseBody?: number; + /** Loader used to build image URLs. */ + loader?: ImageLoaderName; + /** Module whose default export is the loader for every `Image`, relative to the project root. */ + loaderFile?: string; + /** URL prefix of the optimizer (default loader) or image CDN (other loaders). */ + path?: string; + /** Render every image with its original `src`. */ + unoptimized?: boolean; + /** @deprecated Use `remotePatterns`. */ + domains?: Array; + /** Accepted for Next.js compatibility. Workers have no disk cache, so it has no effect. */ + maximumDiskCacheSize?: number; + /** Accepted for Next.js compatibility. Has no effect. */ + disableStaticImages?: boolean; + /** Accepted for Next.js compatibility. Has no effect. */ + customCacheHandler?: boolean; +}; + +/** `ImageConfig` with every key resolved to its default. */ +export type ImageConfigComplete = Required< + Omit< + ImageConfig, + | 'localPatterns' + | 'maximumDiskCacheSize' + | 'remotePatterns' + | 'disableStaticImages' + | 'customCacheHandler' + > +> & { + remotePatterns: Array; + localPatterns: Array | undefined; + maximumDiskCacheSize: number | undefined; + disableStaticImages: boolean; + customCacheHandler: boolean; +}; + +/** Default `images.path` for the default loader. */ +export const DEFAULT_IMAGE_PATH = '/_void/image'; + +/** Default `images.path` for `loader: 'cloudflare'`. */ +export const CLOUDFLARE_IMAGE_PATH = '/cdn-cgi/image'; + +/** Largest stream the Cloudflare Images binding accepts in `.input()`. */ +export const IMAGES_BINDING_MAX_INPUT_BYTES = 20_000_000; + +/** Cloudflare Images encodes AVIF only up to this width. */ +export const AVIF_MAX_WIDTH = 1200; + +/** Next.js 16 `imageConfigDefault`, with Void's endpoint path. */ +export const imageConfigDefault: ImageConfigComplete = { + deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840], + imageSizes: [32, 48, 64, 96, 128, 256, 384], + path: DEFAULT_IMAGE_PATH, + loader: 'default', + loaderFile: '', + domains: [], + disableStaticImages: false, + minimumCacheTTL: 14400, + formats: ['image/webp'], + maximumDiskCacheSize: undefined, + maximumRedirects: 3, + maximumResponseBody: 50_000_000, + dangerouslyAllowLocalIP: false, + dangerouslyAllowSVG: false, + contentSecurityPolicy: `script-src 'none'; frame-src 'none'; sandbox;`, + contentDispositionType: 'attachment', + localPatterns: undefined, + remotePatterns: [], + qualities: [75], + unoptimized: false, + customCacheHandler: false, +}; + +export const VALID_IMAGE_LOADERS: ReadonlyArray = [ + 'default', + 'imgix', + 'cloudinary', + 'akamai', + 'custom', + 'cloudflare', +]; + +export const VALID_IMAGE_FORMATS: ReadonlyArray = ['image/avif', 'image/webp']; + +/** Every key accepted under `images`, in documentation order. */ +export const IMAGE_CONFIG_KEYS: ReadonlyArray = [ + 'deviceSizes', + 'imageSizes', + 'qualities', + 'formats', + 'minimumCacheTTL', + 'remotePatterns', + 'localPatterns', + 'dangerouslyAllowSVG', + 'dangerouslyAllowLocalIP', + 'contentSecurityPolicy', + 'contentDispositionType', + 'maximumRedirects', + 'maximumResponseBody', + 'loader', + 'loaderFile', + 'path', + 'unoptimized', + 'domains', + 'maximumDiskCacheSize', + 'disableStaticImages', + 'customCacheHandler', +]; + +/** + * A remote pattern with its globs compiled at build time (picomatch v4 + * semantics, as Next.js uses). `protocol` has no trailing colon. + */ +export type CompiledRemotePattern = RemotePattern & { + /** `RegExp` source for `hostname` (picomatch, default options). */ + hostnameRe: string; + /** `RegExp` source for `pathname ?? '**'` (picomatch, `{ dot: true }`). */ + pathnameRe: string; +}; + +/** A local pattern with its glob compiled at build time. */ +export type CompiledLocalPattern = LocalPattern & { + /** `RegExp` source for `pathname ?? '**'` (picomatch, `{ dot: true }`). */ + pathnameRe: string; +}; + +/** + * The full configuration the server uses: defaults applied, sizes and + * qualities sorted, patterns compiled, Next.js normalization applied, and + * `unoptimized` forced where no optimizer exists (non-Cloudflare targets, + * static output, Void Platform builds) unless a custom loader is set. + * JSON-serializable, so it can be baked into generated code. + */ +export type ResolvedImageConfig = { + deviceSizes: Array; + imageSizes: Array; + /** `[...deviceSizes, ...imageSizes]` sorted ascending. */ + allSizes: Array; + qualities: Array; + formats: Array; + minimumCacheTTL: number; + remotePatterns: Array; + /** Defaults to `[{ pathname: '**', search: '' }]` (any path, no query). */ + localPatterns: Array; + domains: Array; + dangerouslyAllowSVG: boolean; + dangerouslyAllowLocalIP: boolean; + contentSecurityPolicy: string; + contentDispositionType: 'inline' | 'attachment'; + maximumRedirects: number; + /** `min(images.maximumResponseBody, IMAGES_BINDING_MAX_INPUT_BYTES)`. */ + maximumResponseBody: number; + loader: ImageLoaderName; + /** Absolute, forward-slash path of `images.loaderFile`, or `''`. */ + loaderFile: string; + path: string; + unoptimized: boolean; + /** True for the Vite dev server (`vp dev`), false for builds and preview. */ + dev: boolean; +}; + +/** + * The configuration an `Image` component reads at render time, registered by + * the Void Vite plugin in every environment. Client bundles receive patterns + * only in development (for the same dev-time checks Next.js runs); server + * environments always receive them. + */ +export type ClientImageConfig = { + deviceSizes: Array; + imageSizes: Array; + allSizes: Array; + qualities: Array; + path: string; + loader: ImageLoaderName; + dangerouslyAllowSVG: boolean; + unoptimized: boolean; + dev: boolean; + localPatterns?: Array; + remotePatterns?: Array; + domains?: Array; +}; + +/** + * `globalThis` key of the registered `{ config: ClientImageConfig; loader?: ImageLoader }`. + * A versioned `Symbol.for` key keeps duplicate module copies in sync. + */ +export const IMAGE_REGISTRY_KEY: unique symbol = Symbol.for('void.image.config.v1') as never; diff --git a/packages/void/src/image/types.ts b/packages/void/src/image/types.ts new file mode 100644 index 000000000..5753d48ed --- /dev/null +++ b/packages/void/src/image/types.ts @@ -0,0 +1,126 @@ +/** + * Framework-neutral `Image` prop and result types, matching `next/image` in + * Next.js 16. Adapters intersect `ImageCoreProps` with their own `` + * attribute types. + */ +import type { ClientImageConfig } from './config.ts'; + +/** What `import img from './a.png?image'` returns. */ +export interface StaticImageData { + src: string; + height: number; + width: number; + blurDataURL?: string; + blurWidth?: number; + blurHeight?: number; +} + +export interface StaticRequire { + default: StaticImageData; +} + +export type StaticImport = StaticRequire | StaticImageData; + +export type ImageLoaderProps = { + src: string; + width: number; + quality?: number; +}; + +export type ImageLoaderPropsWithConfig = ImageLoaderProps & { + config: Readonly; +}; + +export type ImageLoader = (props: ImageLoaderProps) => string; + +export type ImageLoaderWithConfig = (props: ImageLoaderPropsWithConfig) => string; + +export type PlaceholderValue = 'blur' | 'empty' | `data:image/${string}`; + +/** Style object accepted and returned by the core (React `CSSProperties` shape). */ +export type ImageStyle = Record; + +/** Props every adapter's `Image` accepts, besides ordinary `` attributes. */ +export type ImageCoreProps = { + src: string | StaticImport; + alt: string; + width?: number | `${number}`; + height?: number | `${number}`; + fill?: boolean; + loader?: ImageLoader; + quality?: number | `${number}`; + preload?: boolean; + /** @deprecated Use `preload`. */ + priority?: boolean; + loading?: 'lazy' | 'eager' | undefined; + placeholder?: PlaceholderValue; + blurDataURL?: string; + unoptimized?: boolean; + overrideSrc?: string; + sizes?: string; + decoding?: 'async' | 'auto' | 'sync'; + fetchPriority?: 'high' | 'low' | 'auto'; + className?: string; + style?: ImageStyle; + /** @deprecated Use `onLoad`. */ + onLoadingComplete?: (img: unknown) => void; + /** @deprecated Legacy `next/image` prop. */ + layout?: string; + /** @deprecated Legacy `next/image` prop. */ + objectFit?: string; + /** @deprecated Legacy `next/image` prop. */ + objectPosition?: string; + /** @deprecated Legacy `next/image` prop. */ + lazyBoundary?: string; + /** @deprecated Legacy `next/image` prop. */ + lazyRoot?: string; +}; + +/** + * Attributes for the rendered ``, in `next/image`'s order (`src` last). + * Names use React's camelCase; `toHtmlImgAttributes` converts them for other adapters. + */ +export type ImgProps = { + [key: string]: unknown; + loading: 'lazy' | 'eager' | undefined; + fetchPriority?: 'high' | 'low' | 'auto'; + width: number | undefined; + height: number | undefined; + decoding?: 'async' | 'auto' | 'sync'; + className?: string; + style: ImageStyle; + sizes: string | undefined; + srcSet: string | undefined; + src: string; +}; + +export type ImageMeta = { + unoptimized: boolean; + /** `preload || priority`. */ + preload: boolean; + placeholder: PlaceholderValue; + fill: boolean; +}; + +export type ImgPropsResult = { + props: ImgProps; + meta: ImageMeta; +}; + +/** Component state that changes the result after load or error. */ +export type ImageRenderState = { + blurComplete?: boolean; + showAltText?: boolean; +}; + +/** `` attributes for a preloaded image, HTML names. */ +export type ImagePreloadLink = { + /** Deduplication key: `imagesrcset + '\n' + imagesizes`, or `href`. */ + key: string; + href?: string; + imagesrcset?: string; + imagesizes?: string; + crossorigin?: string; + referrerpolicy?: string; + fetchpriority?: string; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a2795df76..8b610f4f0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -708,6 +708,9 @@ importers: pg: specifier: ^8.23.1 version: 8.23.1 + picomatch: + specifier: ^4.0.7 + version: 4.0.7 postal-mime: specifier: 'catalog:' version: 4.0.5 @@ -736,6 +739,9 @@ importers: '@types/pg': specifier: ^8.23.1 version: 8.23.1 + '@types/picomatch': + specifier: ^4.0.3 + version: 4.0.3 '@types/proper-lockfile': specifier: ^4.1.4 version: 4.1.4 @@ -5593,6 +5599,9 @@ packages: '@types/pg@8.23.1': resolution: {integrity: sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A==} + '@types/picomatch@4.0.3': + resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} + '@types/proper-lockfile@4.1.4': resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} @@ -14397,6 +14406,8 @@ snapshots: pg-protocol: 1.16.1 pg-types: 2.2.0 + '@types/picomatch@4.0.3': {} + '@types/proper-lockfile@4.1.4': dependencies: '@types/retry': 0.12.5 From 4705c05ffe274f95830ddff40528cbeb8402d0d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 04:23:03 +0000 Subject: [PATCH 02/12] feat(image): add void/image core library and /_void/image endpoint Port next/image 16 prop resolution, loaders, pattern matching and config resolution into the Worker-safe void/image subpath, and add the /_void/image optimizer on the Cloudflare Images binding. Binding inference is in progress. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6 --- packages/void/package.json | 8 +- packages/void/schema.json | 168 + packages/void/src/application-analysis.ts | 37 +- packages/void/src/application-features.ts | 33 +- packages/void/src/client-asset-manifest.ts | 40 + packages/void/src/config.ts | 20 +- packages/void/src/image-config.ts | 533 +++ packages/void/src/image/blur-svg.ts | 35 + packages/void/src/image/client-config.ts | 52 + packages/void/src/image/get-img-props.ts | 602 ++++ packages/void/src/image/html.ts | 212 ++ packages/void/src/image/index.ts | 42 + packages/void/src/image/loader.ts | 130 + packages/void/src/image/loading.ts | 137 + packages/void/src/image/optimizer/accept.ts | 55 + .../void/src/image/optimizer/content-type.ts | 351 ++ packages/void/src/image/optimizer/handler.ts | 364 ++ packages/void/src/image/optimizer/headers.ts | 121 + .../void/src/image/optimizer/image-error.ts | 20 + packages/void/src/image/optimizer/index.ts | 41 + .../void/src/image/optimizer/private-ip.ts | 145 + packages/void/src/image/optimizer/upstream.ts | 216 ++ packages/void/src/image/optimizer/validate.ts | 127 + packages/void/src/image/patterns.ts | 99 + packages/void/src/image/registry.ts | 122 + packages/void/src/image/warn-once.ts | 10 + packages/void/src/inference/vinext-images.ts | 137 + packages/void/src/inference/walk.ts | 102 +- packages/void/src/plugin-inference.ts | 35 +- packages/void/src/runtime/routing-image.ts | 135 + packages/void/src/runtime/routing.ts | 1 + .../void/test/fixtures/images/animated.gif | Bin 0 -> 101 bytes .../void/test/fixtures/images/animated.png | Bin 0 -> 200 bytes .../void/test/fixtures/images/animated.webp | Bin 0 -> 140 bytes packages/void/test/fixtures/images/test.avif | Bin 0 -> 289 bytes packages/void/test/fixtures/images/test.gif | Bin 0 -> 118 bytes packages/void/test/fixtures/images/test.jpg | Bin 0 -> 310 bytes packages/void/test/fixtures/images/test.png | Bin 0 -> 121 bytes packages/void/test/fixtures/images/test.tiff | Bin 0 -> 256 bytes packages/void/test/fixtures/images/test.webp | Bin 0 -> 96 bytes .../test/integration/image-endpoint.test.ts | 316 ++ .../test/unit/client-subpath-exports.test.ts | 13 + packages/void/test/unit/config.test.ts | 115 + packages/void/test/unit/image-config.test.ts | 421 +++ packages/void/test/unit/image-html.test.ts | 238 ++ packages/void/test/unit/image-loader.test.ts | 154 + .../test/unit/image-optimizer-handler.test.ts | 718 ++++ .../unit/image-optimizer-manifest.test.ts | 108 + .../unit/image-optimizer-upstream.test.ts | 356 ++ .../void/test/unit/image-optimizer.test.ts | 608 ++++ .../void/test/unit/image-patterns.test.ts | 131 + packages/void/test/unit/image-props.golden.ts | 3166 +++++++++++++++++ packages/void/test/unit/image-props.test.ts | 236 ++ packages/void/vite.config.ts | 1 + 54 files changed, 10694 insertions(+), 17 deletions(-) create mode 100644 packages/void/src/image-config.ts create mode 100644 packages/void/src/image/blur-svg.ts create mode 100644 packages/void/src/image/client-config.ts create mode 100644 packages/void/src/image/get-img-props.ts create mode 100644 packages/void/src/image/html.ts create mode 100644 packages/void/src/image/index.ts create mode 100644 packages/void/src/image/loader.ts create mode 100644 packages/void/src/image/loading.ts create mode 100644 packages/void/src/image/optimizer/accept.ts create mode 100644 packages/void/src/image/optimizer/content-type.ts create mode 100644 packages/void/src/image/optimizer/handler.ts create mode 100644 packages/void/src/image/optimizer/headers.ts create mode 100644 packages/void/src/image/optimizer/image-error.ts create mode 100644 packages/void/src/image/optimizer/index.ts create mode 100644 packages/void/src/image/optimizer/private-ip.ts create mode 100644 packages/void/src/image/optimizer/upstream.ts create mode 100644 packages/void/src/image/optimizer/validate.ts create mode 100644 packages/void/src/image/patterns.ts create mode 100644 packages/void/src/image/registry.ts create mode 100644 packages/void/src/image/warn-once.ts create mode 100644 packages/void/src/inference/vinext-images.ts create mode 100644 packages/void/src/runtime/routing-image.ts create mode 100644 packages/void/test/fixtures/images/animated.gif create mode 100644 packages/void/test/fixtures/images/animated.png create mode 100644 packages/void/test/fixtures/images/animated.webp create mode 100644 packages/void/test/fixtures/images/test.avif create mode 100644 packages/void/test/fixtures/images/test.gif create mode 100644 packages/void/test/fixtures/images/test.jpg create mode 100644 packages/void/test/fixtures/images/test.png create mode 100644 packages/void/test/fixtures/images/test.tiff create mode 100644 packages/void/test/fixtures/images/test.webp create mode 100644 packages/void/test/integration/image-endpoint.test.ts create mode 100644 packages/void/test/unit/image-config.test.ts create mode 100644 packages/void/test/unit/image-html.test.ts create mode 100644 packages/void/test/unit/image-loader.test.ts create mode 100644 packages/void/test/unit/image-optimizer-handler.test.ts create mode 100644 packages/void/test/unit/image-optimizer-manifest.test.ts create mode 100644 packages/void/test/unit/image-optimizer-upstream.test.ts create mode 100644 packages/void/test/unit/image-optimizer.test.ts create mode 100644 packages/void/test/unit/image-patterns.test.ts create mode 100644 packages/void/test/unit/image-props.golden.ts create mode 100644 packages/void/test/unit/image-props.test.ts diff --git a/packages/void/package.json b/packages/void/package.json index c04c757b6..8db0c4a36 100644 --- a/packages/void/package.json +++ b/packages/void/package.json @@ -43,7 +43,8 @@ "#self/runtime/migration-handler-pg": "./dist/runtime/migration-handler-pg.mjs", "#self/runtime/migration-handler-mysql": "./dist/runtime/migration-handler-mysql.mjs", "#self/runtime/ws-server": "./dist/runtime/ws-server.mjs", - "#self/runtime/routing": "./dist/runtime/routing.mjs" + "#self/runtime/routing": "./dist/runtime/routing.mjs", + "#self/image": "./dist/image/index.mjs" }, "exports": { ".": { @@ -328,6 +329,11 @@ "types": "./dist/runtime/sandbox-container.d.mts", "import": "./dist/runtime/sandbox-container.mjs" }, + "./image": { + "types": "./dist/image/index.d.mts", + "import": "./dist/image/index.mjs", + "require": "./dist/image/index.mjs" + }, "./limits": { "types": "./dist/runtime/limits.d.mts", "import": "./dist/runtime/limits.mjs", diff --git a/packages/void/schema.json b/packages/void/schema.json index 3921f17e3..af7e3cc5c 100644 --- a/packages/void/schema.json +++ b/packages/void/schema.json @@ -267,6 +267,10 @@ "email": { "type": "boolean", "description": "Enable email support (used by void/email)." + }, + "images": { + "oneOf": [{ "type": "boolean" }, { "type": "string" }], + "description": "Enable the Cloudflare Images binding used by Image components. `true` uses default name \"IMAGES\", a string sets a custom binding name, `false` disables it." } }, "additionalProperties": false @@ -417,6 +421,170 @@ } }, "additionalProperties": false + }, + "images": { + "type": "object", + "description": "Image optimization for Image components and the /_void/image endpoint. Keys, types and defaults follow images in Next.js 16 next.config.js.", + "properties": { + "deviceSizes": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "maxItems": 25, + "description": "Widths used for w descriptors when sizes or fill is set. Default: [640, 750, 828, 1080, 1200, 1920, 2048, 3840]." + }, + "imageSizes": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "maxItems": 25, + "description": "Widths combined with deviceSizes for fixed-width and sizes images. Default: [32, 48, 64, 96, 128, 256, 384]." + }, + "qualities": { + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "minItems": 1, + "maxItems": 20, + "description": "Allowed quality values. Default: [75]." + }, + "formats": { + "type": "array", + "items": { + "type": "string", + "enum": ["image/avif", "image/webp"] + }, + "maxItems": 4, + "description": "Output formats negotiated from the Accept header, in order of preference. Default: [\"image/webp\"]." + }, + "minimumCacheTTL": { + "type": "integer", + "minimum": 0, + "description": "Minimum Cache-Control max-age, in seconds, for optimized images. Default: 14400." + }, + "remotePatterns": { + "type": "array", + "maxItems": 50, + "items": { + "type": "object", + "properties": { + "protocol": { + "type": "string", + "enum": ["http", "https"] + }, + "hostname": { + "type": "string" + }, + "port": { + "type": "string", + "maxLength": 5 + }, + "pathname": { + "type": "string" + }, + "search": { + "type": "string" + } + }, + "required": ["hostname"], + "additionalProperties": false + }, + "description": "Remote sources the endpoint may fetch. void.config.ts also accepts URL objects." + }, + "localPatterns": { + "type": "array", + "maxItems": 25, + "items": { + "type": "object", + "properties": { + "pathname": { + "type": "string" + }, + "search": { + "type": "string" + } + }, + "additionalProperties": false + }, + "description": "Local sources the endpoint may load. Default: any path without a query string." + }, + "dangerouslyAllowSVG": { + "type": "boolean", + "description": "Serve SVG sources (unchanged) instead of rejecting them. Default: false." + }, + "dangerouslyAllowLocalIP": { + "type": "boolean", + "description": "Allow remote sources on private or loopback IP addresses. Default: false." + }, + "contentSecurityPolicy": { + "type": "string", + "description": "Content-Security-Policy sent with every endpoint response. Default: \"script-src 'none'; frame-src 'none'; sandbox;\"." + }, + "contentDispositionType": { + "type": "string", + "enum": ["inline", "attachment"], + "description": "Content-Disposition type sent with every endpoint response. Default: \"attachment\"." + }, + "maximumRedirects": { + "type": "integer", + "minimum": 0, + "maximum": 20, + "description": "Redirects followed for remote sources. Default: 3." + }, + "maximumResponseBody": { + "type": "integer", + "minimum": 1, + "description": "Largest remote source body, in bytes. Capped at 20000000. Default: 50000000." + }, + "loader": { + "type": "string", + "enum": ["default", "imgix", "cloudinary", "akamai", "custom", "cloudflare"], + "description": "Loader used to build image URLs. \"cloudflare\" emits Cloudflare zone transformation URLs (/cdn-cgi/image/...). Default: \"default\"." + }, + "loaderFile": { + "type": "string", + "description": "Module, relative to the project root, whose default export is the loader for every Image." + }, + "path": { + "type": "string", + "description": "URL prefix of the optimizer (default loader) or image CDN (other loaders). Default: \"/_void/image\"." + }, + "unoptimized": { + "type": "boolean", + "description": "Render every image with its original src. Default: false." + }, + "domains": { + "type": "array", + "items": { + "type": "string" + }, + "maxItems": 50, + "description": "Deprecated: use remotePatterns." + }, + "maximumDiskCacheSize": { + "type": "integer", + "minimum": 0, + "description": "Accepted for Next.js compatibility. Has no effect." + }, + "disableStaticImages": { + "type": "boolean", + "description": "Accepted for Next.js compatibility. Has no effect." + }, + "customCacheHandler": { + "type": "boolean", + "description": "Accepted for Next.js compatibility. Has no effect." + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/packages/void/src/application-analysis.ts b/packages/void/src/application-analysis.ts index 6d3a4008d..5a2d7177f 100644 --- a/packages/void/src/application-analysis.ts +++ b/packages/void/src/application-analysis.ts @@ -1,10 +1,12 @@ -import { needsApplicationDatabase } from './application-features.ts'; +import { isImageOptimizerEnabled, needsApplicationDatabase } from './application-features.ts'; import { findVoidAuthConfig } from './auth/config.ts'; import { getDatabaseDialect, resolveBindingNames, type VoidConfig } from './config.ts'; import { scanJobsSync } from './crons/scan.ts'; import { scanDurableObjectModules } from './durable/scan.ts'; +import { detectVinextImagesOptimizer } from './inference/vinext-images.ts'; import type { InferredBindings } from './inference/walk.ts'; import { collectMigrations } from './migrations/collect.ts'; +import { DEFAULT_IMAGES_BINDING } from './plugin-binding-utils.ts'; import { detectFramework, FRAMEWORK_SCAN_DIRS, inferProjectBindings } from './plugin-inference.ts'; import { resolveProjectPaths } from './project-paths.ts'; import { scanQueuesSync } from './queues/scan.ts'; @@ -44,6 +46,22 @@ export type ApplicationAnalysis = { reasons: ApplicationReasons; }; +/** Custom binding names, plus the binding vinext's optimizer reads unless configured. */ +function resolveApplicationBindingNames( + overrides: NonNullable['bindings'], + vinextImages: { binding: string } | undefined, +): ReturnType { + const names = resolveBindingNames(overrides); + if ( + vinextImages && + vinextImages.binding !== DEFAULT_IMAGES_BINDING && + typeof overrides?.images !== 'string' + ) { + return { ...names, images: vinextImages.binding }; + } + return names; +} + /** * Discover application source features once for a consumer's environment. * Resource ids, generated provider configuration and migration history remain @@ -57,17 +75,29 @@ export function analyzeApplicationSync( const paths = resolveProjectPaths(root, config); const framework = detectFramework(root); const scanDirs = config.inference?.scanDirs ?? (framework ? FRAMEWORK_SCAN_DIRS : undefined); + const overrides = config.inference?.bindings; const inferred = inferProjectBindings( root, - { paths, environment, bindings: config.inference?.bindings }, + { + paths, + environment, + bindings: overrides, + imageOptimizer: isImageOptimizerEnabled(config, framework), + }, scanDirs, ); + // vinext serves `/_next/image` itself; its Cloudflare optimizer reads the Images binding. + const vinextImages = + framework?.name === 'vinext-app' || framework?.name === 'vinext-pages' + ? detectVinextImagesOptimizer(root) + : undefined; const authConfigPath = findVoidAuthConfig(root, paths); const authEnabled = inferred.needsAuth || authConfigPath !== null || config.auth !== undefined; const bindings = { ...inferred, needsAuth: authEnabled, needsSandbox: inferred.needsSandbox || isSandboxEnabled(config), + ...(vinextImages && overrides?.images === undefined && { needsImages: true }), }; const dialect = getDatabaseDialect(config); const migrations = collectMigrations(paths.migrationsDir); @@ -80,7 +110,6 @@ export function analyzeApplicationSync( // Typed durable modules currently use the root convention on every target. const durableObjects = scanDurableObjectModules(root); const email = scanEmailHandlersSync(paths.sourceRoot); - const overrides = config.inference?.bindings; const bindingReason = ( key: 'db' | 'kv' | 'storage', label: string, @@ -117,7 +146,7 @@ export function analyzeApplicationSync( authConfigPath, authEnabled, dialect, - bindingNames: resolveBindingNames(config.inference?.bindings), + bindingNames: resolveApplicationBindingNames(overrides, vinextImages), needsDatabase: needsApplicationDatabase(bindings, migrations.length), migrations, jobs, diff --git a/packages/void/src/application-features.ts b/packages/void/src/application-features.ts index 3fd8ac0f5..d4f1e6bcd 100644 --- a/packages/void/src/application-features.ts +++ b/packages/void/src/application-features.ts @@ -1,5 +1,7 @@ -import type { InferenceConfig } from './config.ts'; +import type { InferenceConfig, VoidConfig } from './config.ts'; +import { imageOptimizerConfigured } from './image-config.ts'; import type { InferredBindings } from './inference/walk.ts'; +import type { DetectedFramework } from './plugin-inference.ts'; /** Apply explicitly named overrides while preserving unrelated source features. */ export function applyBindingOverrides( @@ -14,6 +16,7 @@ export function applyBindingOverrides( storage: 'needsR2', ai: 'needsAI', email: 'needsEmail', + images: 'needsImages', } as const; for (const [key, inferredKey] of Object.entries(keys)) { const value = overrides[key as keyof typeof keys]; @@ -25,6 +28,34 @@ export function applyBindingOverrides( return bindings; } +/** + * Whether the default `/_void/image` optimizer serves this app: a Void app + * (frameworks own their routes) on Cloudflare with server output and the + * default loader. + */ +export function isImageOptimizerEnabled( + config: Pick, + framework: DetectedFramework | undefined, +): boolean { + return ( + framework === undefined && + imageOptimizerConfigured(config.images, { target: config.target, output: config.output }) + ); +} + +/** + * `Image` and `getImageProps` need the Images binding only while the default + * `/_void/image` optimizer serves the app. An `env.IMAGES` read always does. + */ +export function applyImageOptimizerRequirement( + inferred: InferredBindings, + imageOptimizer: boolean, +): InferredBindings { + return imageOptimizer && inferred.usesImageOptimizer && !inferred.needsImages + ? { ...inferred, needsImages: true } + : inferred; +} + /** Shared database requirement for development and both deployment targets. */ export function needsApplicationDatabase( bindings: Pick, diff --git a/packages/void/src/client-asset-manifest.ts b/packages/void/src/client-asset-manifest.ts index 9a10add2a..c54a3b666 100644 --- a/packages/void/src/client-asset-manifest.ts +++ b/packages/void/src/client-asset-manifest.ts @@ -3,11 +3,36 @@ import type { Rolldown } from 'vite'; import type { ClientManifest } from './runtime/routing-assets.ts'; import type { RoutingHeaderRule } from './runtime/routing.ts'; +/** Build facts that prove statically imported images are fingerprinted. */ +export type ClientImageCacheProof = { + /** Source files imported with `?image`, relative to the project root (client manifest keys). */ + imageSources: Iterable; + /** + * The client build names every emitted asset from a string `assetFileNames` + * template containing `[hash]` (see `hasHashedAssetFileNames`). + */ + hashedAssetFileNames: boolean; +}; + +/** + * Whether Rollup output options name every emitted asset with `[hash]`: Vite's + * default template, or an authored string template that contains it. A + * function template cannot be proven. + */ +export function hasHashedAssetFileNames(output: unknown): boolean { + const outputs = Array.isArray(output) ? output : [output]; + return outputs.every((options) => { + const template = (options as { assetFileNames?: unknown } | undefined)?.assetFileNames; + return template === undefined || (typeof template === 'string' && template.includes('[hash]')); + }); +} + /** Attach cache provenance to the existing manifest without changing its entry inventory. */ export function stampClientAssetCachePolicy( bundle: Rolldown.OutputBundle, manifestPath: string, headerRules: Array = [], + images?: ClientImageCacheProof, ): void { const asset = bundle[manifestPath]; if (asset?.type !== 'asset') { @@ -36,6 +61,21 @@ export function stampClientAssetCachePolicy( } } const manifest = JSON.parse(String(asset.source)) as ClientManifest; + if (images?.hashedAssetFileNames) { + // Images imported with `?image` are emitted by Vite's asset pipeline under + // the proven [hash] template, so their manifest files are fingerprinted. + // Other output assets stay unproven, as above. + for (const source of images.imageSources) { + const file = manifest[source]?.file; + if ( + file && + bundle[file]?.type === 'asset' && + !authoredCachePatterns.some((pattern) => matchPattern(pattern, '/' + file)) + ) { + fingerprinted.add(file); + } + } + } for (const entry of Object.values(manifest)) { if (entry.file && fingerprinted.has(entry.file)) { entry.immutableFiles = [entry.file]; diff --git a/packages/void/src/config.ts b/packages/void/src/config.ts index 3490cfc42..976a139fa 100644 --- a/packages/void/src/config.ts +++ b/packages/void/src/config.ts @@ -7,6 +7,8 @@ import { } from './auth/providers.ts'; import type { DevRedirectRule, HeaderRule } from './cli/types.ts'; import { loadVoidConfig } from './config-loader.ts'; +import { validateImagesConfig } from './image-config.ts'; +import type { ImageConfig } from './image/config.ts'; import type { HeadConfig } from './pages/head.ts'; import { validateSourceDir } from './project-paths.ts'; import { MimeError, parseAddress } from './runtime/email/mime.ts'; @@ -67,6 +69,7 @@ export type InferenceConfig = { storage?: boolean | string; ai?: boolean | string; email?: boolean; + images?: boolean | string; }; scanDirs?: ReadonlyArray; build?: string; @@ -176,6 +179,8 @@ export type VoidConfig = { database?: 'pg' | 'mysql'; ai?: AiConfig; email?: EmailConfig; + /** Image optimization for `Image` components and the `/_void/image` endpoint (Next.js `images`). */ + images?: ImageConfig; }; const CONFIG_FILE = 'void.json'; @@ -716,9 +721,9 @@ function validateConfigObject(obj: unknown): VoidConfig { } const b = inf.bindings; for (const key of Object.keys(b)) { - if (!['db', 'kv', 'storage', 'ai', 'email'].includes(key)) { + if (!['db', 'kv', 'storage', 'ai', 'email', 'images'].includes(key)) { throw new Error( - `config: Unknown binding '${key}' in ${CONFIG_FILE}. Valid: db, kv, storage, ai, email.`, + `config: Unknown binding '${key}' in ${CONFIG_FILE}. Valid: db, kv, storage, ai, email, images.`, ); } if (key === 'email') { @@ -959,6 +964,9 @@ function validateConfigObject(obj: unknown): VoidConfig { } } + // Validate images + validateImagesConfig(obj.images, CONFIG_FILE); + // Legacy void.json files may carry an editor hint. It is not part of the // typed config and should never reach consumers of the resolved config. if ('$schema' in obj) { @@ -1000,11 +1008,11 @@ export function isNodeTarget(target: VoidConfig['target']): boolean { */ export function resolveBindingNames( bindings: InferenceConfig['bindings'], -): { d1?: string; kv?: string; r2?: string; ai?: string } | undefined { +): { d1?: string; kv?: string; r2?: string; ai?: string; images?: string } | undefined { if (!bindings) { return undefined; } - const names: { d1?: string; kv?: string; r2?: string; ai?: string } = {}; + const names: { d1?: string; kv?: string; r2?: string; ai?: string; images?: string } = {}; let hasCustom = false; if (typeof bindings.db === 'string') { names.d1 = bindings.db; @@ -1022,6 +1030,10 @@ export function resolveBindingNames( names.ai = bindings.ai; hasCustom = true; } + if (typeof bindings.images === 'string') { + names.images = bindings.images; + hasCustom = true; + } return hasCustom ? names : undefined; } diff --git a/packages/void/src/image-config.ts b/packages/void/src/image-config.ts new file mode 100644 index 000000000..547b535a6 --- /dev/null +++ b/packages/void/src/image-config.ts @@ -0,0 +1,533 @@ +/** + * Node-side `images` config handling: validation for `void.config.ts`, + * Next.js 16 normalization, glob compilation and the subsets sent to the + * endpoint and to `Image` components. + */ +import { existsSync } from 'node:fs'; +import { isRecord } from '@void/deploy-core'; +import { resolve } from 'pathslash'; +import picomatch from 'picomatch'; +import { + CLOUDFLARE_IMAGE_PATH, + DEFAULT_IMAGE_PATH, + IMAGE_CONFIG_KEYS, + IMAGES_BINDING_MAX_INPUT_BYTES, + VALID_IMAGE_FORMATS, + VALID_IMAGE_LOADERS, + imageConfigDefault, + type CompiledLocalPattern, + type CompiledRemotePattern, + type ImageConfig, + type LocalPattern, + type RemotePattern, + type ResolvedImageConfig, +} from './image/config.ts'; +import { IMAGE_DOCS_URL } from './image/loader.ts'; + +export { toClientImageConfig } from './image/client-config.ts'; + +/** Next.js 16 `images` schema limits. */ +export const IMAGE_CONFIG_LIMITS = { + sizeMin: 1, + sizeMax: 10000, + deviceSizesMaxItems: 25, + imageSizesMaxItems: 25, + qualityMin: 1, + qualityMax: 100, + qualitiesMinItems: 1, + qualitiesMaxItems: 20, + formatsMaxItems: 4, + domainsMaxItems: 50, + remotePatternsMaxItems: 50, + localPatternsMaxItems: 25, + portMaxLength: 5, + maximumRedirectsMax: 20, +} as const; + +const BOOLEAN_KEYS = [ + 'unoptimized', + 'dangerouslyAllowSVG', + 'dangerouslyAllowLocalIP', + 'disableStaticImages', + 'customCacheHandler', +] as const; + +const STRING_KEYS = ['contentSecurityPolicy', 'loaderFile', 'path'] as const; + +const REMOTE_PATTERN_KEYS = ['protocol', 'hostname', 'port', 'pathname', 'search']; +const LOCAL_PATTERN_KEYS = ['pathname', 'search']; + +/** Static `?image` imports are emitted under Vite's default `build.assetsDir`. */ +const STATIC_IMPORT_PATTERN: LocalPattern = { pathname: '/assets/**', search: '' }; + +const NODE_TARGETS = new Set(['node', 'bun', 'deno']); + +function isInteger(value: unknown, min: number, max: number): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= min && value <= max; +} + +function validateSizes( + value: unknown, + key: 'deviceSizes' | 'imageSizes', + maxItems: number, + configFile: string, +): void { + const { sizeMin, sizeMax } = IMAGE_CONFIG_LIMITS; + if ( + !Array.isArray(value) || + value.length > maxItems || + !value.every((size) => isInteger(size, sizeMin, sizeMax)) + ) { + throw new Error( + `config: "images.${key}" in ${configFile} must be an array of at most ${maxItems} integers between ${sizeMin} and ${sizeMax}.`, + ); + } +} + +function validatePatternString( + pattern: Record, + key: string, + label: string, + configFile: string, +): void { + if (pattern[key] !== undefined && typeof pattern[key] !== 'string') { + throw new Error(`config: "${label}.${key}" in ${configFile} must be a string.`); + } +} + +function validateRemotePatterns(value: unknown, configFile: string): void { + const { remotePatternsMaxItems, portMaxLength } = IMAGE_CONFIG_LIMITS; + if (!Array.isArray(value) || value.length > remotePatternsMaxItems) { + throw new Error( + `config: "images.remotePatterns" in ${configFile} must be an array of at most ${remotePatternsMaxItems} URL objects or patterns.`, + ); + } + value.forEach((pattern: unknown, index) => { + // `isRecord` accepts URL instances, so check them first. + if (pattern instanceof URL) { + return; + } + const label = `images.remotePatterns[${index}]`; + if (!isRecord(pattern)) { + throw new Error( + `config: "${label}" in ${configFile} must be a URL or an object with a "hostname".`, + ); + } + for (const key of Object.keys(pattern)) { + if (!REMOTE_PATTERN_KEYS.includes(key)) { + throw new Error( + `config: Unknown key '${label}.${key}' in ${configFile}. Valid: ${REMOTE_PATTERN_KEYS.join(', ')}.`, + ); + } + } + if (typeof pattern.hostname !== 'string') { + throw new Error(`config: "${label}.hostname" in ${configFile} must be a string.`); + } + if ( + pattern.protocol !== undefined && + pattern.protocol !== 'http' && + pattern.protocol !== 'https' + ) { + throw new Error(`config: "${label}.protocol" in ${configFile} must be "http" or "https".`); + } + if ( + pattern.port !== undefined && + (typeof pattern.port !== 'string' || pattern.port.length > portMaxLength) + ) { + throw new Error( + `config: "${label}.port" in ${configFile} must be a string of at most ${portMaxLength} characters.`, + ); + } + validatePatternString(pattern, 'pathname', label, configFile); + validatePatternString(pattern, 'search', label, configFile); + }); +} + +function validateLocalPatterns(value: unknown, configFile: string): void { + const { localPatternsMaxItems } = IMAGE_CONFIG_LIMITS; + if (!Array.isArray(value) || value.length > localPatternsMaxItems) { + throw new Error( + `config: "images.localPatterns" in ${configFile} must be an array of at most ${localPatternsMaxItems} patterns.`, + ); + } + value.forEach((pattern: unknown, index) => { + const label = `images.localPatterns[${index}]`; + if (!isRecord(pattern) || pattern instanceof URL) { + throw new Error(`config: "${label}" in ${configFile} must be an object.`); + } + for (const key of Object.keys(pattern)) { + if (!LOCAL_PATTERN_KEYS.includes(key)) { + throw new Error( + `config: Unknown key '${label}.${key}' in ${configFile}. Valid: ${LOCAL_PATTERN_KEYS.join(', ')}.`, + ); + } + } + validatePatternString(pattern, 'pathname', label, configFile); + validatePatternString(pattern, 'search', label, configFile); + }); +} + +/** + * Validate `images` with the limits of the Next.js 16 schema. Unknown keys are + * rejected. `loader`/`path`/`loaderFile` combinations are checked when the + * config is resolved. + */ +export function validateImagesConfig(value: unknown, configFile: string): void { + if (value === undefined) { + return; + } + // `isRecord` accepts URL instances, which have no own keys. + if (!isRecord(value) || value instanceof URL) { + throw new Error(`config: "images" in ${configFile} must be an object.`); + } + for (const key of Object.keys(value)) { + if (!(IMAGE_CONFIG_KEYS as ReadonlyArray).includes(key)) { + throw new Error( + `config: Unknown key 'images.${key}' in ${configFile}. Valid: ${IMAGE_CONFIG_KEYS.join(', ')}.`, + ); + } + } + + const images = value; + const limits = IMAGE_CONFIG_LIMITS; + + if (images.deviceSizes !== undefined) { + validateSizes(images.deviceSizes, 'deviceSizes', limits.deviceSizesMaxItems, configFile); + } + if (images.imageSizes !== undefined) { + validateSizes(images.imageSizes, 'imageSizes', limits.imageSizesMaxItems, configFile); + } + if (images.qualities !== undefined) { + const qualities = images.qualities; + if ( + !Array.isArray(qualities) || + qualities.length < limits.qualitiesMinItems || + qualities.length > limits.qualitiesMaxItems || + !qualities.every((q) => isInteger(q, limits.qualityMin, limits.qualityMax)) + ) { + throw new Error( + `config: "images.qualities" in ${configFile} must be an array of ${limits.qualitiesMinItems} to ${limits.qualitiesMaxItems} integers between ${limits.qualityMin} and ${limits.qualityMax}.`, + ); + } + } + if (images.formats !== undefined) { + const formats = images.formats; + if ( + !Array.isArray(formats) || + formats.length > limits.formatsMaxItems || + !formats.every((format) => (VALID_IMAGE_FORMATS as ReadonlyArray).includes(format)) + ) { + throw new Error( + `config: "images.formats" in ${configFile} must be an array of at most ${limits.formatsMaxItems} of: ${VALID_IMAGE_FORMATS.join(', ')}.`, + ); + } + } + if (images.minimumCacheTTL !== undefined) { + if (!isInteger(images.minimumCacheTTL, 0, Number.MAX_SAFE_INTEGER)) { + throw new Error( + `config: "images.minimumCacheTTL" in ${configFile} must be a non-negative integer.`, + ); + } + } + if (images.remotePatterns !== undefined) { + validateRemotePatterns(images.remotePatterns, configFile); + } + if (images.localPatterns !== undefined) { + validateLocalPatterns(images.localPatterns, configFile); + } + for (const key of BOOLEAN_KEYS) { + if (images[key] !== undefined && typeof images[key] !== 'boolean') { + throw new Error(`config: "images.${key}" in ${configFile} must be a boolean.`); + } + } + for (const key of STRING_KEYS) { + if (images[key] !== undefined && typeof images[key] !== 'string') { + throw new Error(`config: "images.${key}" in ${configFile} must be a string.`); + } + } + if ( + images.contentDispositionType !== undefined && + images.contentDispositionType !== 'inline' && + images.contentDispositionType !== 'attachment' + ) { + throw new Error( + `config: "images.contentDispositionType" in ${configFile} must be "inline" or "attachment".`, + ); + } + if (images.maximumRedirects !== undefined) { + if (!isInteger(images.maximumRedirects, 0, limits.maximumRedirectsMax)) { + throw new Error( + `config: "images.maximumRedirects" in ${configFile} must be an integer between 0 and ${limits.maximumRedirectsMax}.`, + ); + } + } + if (images.maximumResponseBody !== undefined) { + if (!isInteger(images.maximumResponseBody, 1, Number.MAX_SAFE_INTEGER)) { + throw new Error( + `config: "images.maximumResponseBody" in ${configFile} must be a positive integer.`, + ); + } + } + if (images.maximumDiskCacheSize !== undefined) { + if (!isInteger(images.maximumDiskCacheSize, 0, Number.MAX_SAFE_INTEGER)) { + throw new Error( + `config: "images.maximumDiskCacheSize" in ${configFile} must be a non-negative integer.`, + ); + } + } + if ( + images.loader !== undefined && + !(VALID_IMAGE_LOADERS as ReadonlyArray).includes(images.loader) + ) { + throw new Error( + `config: "images.loader" in ${configFile} must be one of: ${VALID_IMAGE_LOADERS.join(', ')}.`, + ); + } + if (images.domains !== undefined) { + if ( + !Array.isArray(images.domains) || + images.domains.length > limits.domainsMaxItems || + !images.domains.every((domain) => typeof domain === 'string') + ) { + throw new Error( + `config: "images.domains" in ${configFile} must be an array of at most ${limits.domainsMaxItems} strings.`, + ); + } + } +} + +/** + * Warnings for `images` keys that are deprecated or have no effect in Void. + * Emitted once by the plugin after the config is read. + */ +export function getImageConfigWarnings( + images: ImageConfig | undefined, + configFile: string, +): Array { + const warnings: Array = []; + if (!images) { + return warnings; + } + if (images.domains?.length) { + warnings.push( + `\`images.domains\` is deprecated in favor of \`images.remotePatterns\`. Please update ${configFile} to protect your application from malicious users.`, + ); + } + if (images.maximumDiskCacheSize !== undefined) { + warnings.push( + `"images.maximumDiskCacheSize" in ${configFile} is ignored: optimized images are cached in the Cloudflare cache, not on disk.`, + ); + } + if (images.disableStaticImages) { + warnings.push( + `"images.disableStaticImages" in ${configFile} is ignored: image imports return URLs unless they end with "?image".`, + ); + } + if (images.customCacheHandler) { + warnings.push( + `"images.customCacheHandler" in ${configFile} is ignored: optimized images are cached in the Cloudflare cache.`, + ); + } + return warnings; +} + +/** Options for `resolveImageConfig`. */ +export type ResolveImageConfigOptions = { + /** Project root. `images.loaderFile` resolves from here and must exist. */ + root: string; + /** True for the Vite dev server (`vp dev`), false for builds and preview. */ + dev: boolean; + /** `target` from the Void config. `node`, `bun` and `deno` have no optimizer. */ + target?: string; + /** `output` from the Void config. Static output has no optimizer. */ + output?: 'server' | 'static'; + /** A Void Platform build, which has no Images binding yet. */ + managed?: boolean; +}; + +/** Normalize one remote pattern as Next.js does and compile its globs (picomatch v4). */ +export function compileRemotePattern(p: URL | RemotePattern): CompiledRemotePattern { + // Read the fields through getters so `URL` instances work. + const { protocol, hostname, port, pathname, search } = p; + const proto = protocol?.replace(/:$/, ''); + if (proto !== undefined && proto !== 'http' && proto !== 'https') { + throw new Error( + `Specified images.remotePatterns must have protocol "http" or "https" received "${proto}".`, + ); + } + const compiled: CompiledRemotePattern = { + hostname, + hostnameRe: picomatch.makeRe(hostname).source, + pathnameRe: picomatch.makeRe(pathname ?? '**', { dot: true }).source, + }; + if (proto !== undefined) { + compiled.protocol = proto; + } + if (port !== undefined) { + compiled.port = port; + } + if (pathname !== undefined) { + compiled.pathname = pathname; + } + if (search !== undefined) { + compiled.search = search; + } + return compiled; +} + +/** Compile one local pattern's glob (picomatch v4). */ +export function compileLocalPattern(p: LocalPattern): CompiledLocalPattern { + const compiled: CompiledLocalPattern = { + pathnameRe: picomatch.makeRe(p.pathname ?? '**', { dot: true }).source, + }; + if (p.pathname !== undefined) { + compiled.pathname = p.pathname; + } + if (p.search !== undefined) { + compiled.search = p.search; + } + return compiled; +} + +const ascending = (a: number, b: number): number => a - b; + +/** + * Apply defaults and Next.js 16 normalization to `images`, compile the + * patterns, resolve `loaderFile`, and force `unoptimized` where no optimizer + * exists (`node`/`bun`/`deno` targets, static output, Void Platform builds) + * unless a custom loader or `loaderFile` is set. + */ +export function resolveImageConfig( + images: ImageConfig | undefined, + options: ResolveImageConfigOptions, +): ResolvedImageConfig { + const user: Partial = {}; + for (const [key, value] of Object.entries(images ?? {})) { + if (value !== undefined && value !== null) { + (user as Record)[key] = value; + } + } + const merged = { ...imageConfigDefault, ...user }; + + const localPatterns: Array = merged.localPatterns + ? merged.localPatterns.map((p) => ({ ...p })) + : [{ pathname: '**', search: '' }]; + if ( + merged.localPatterns && + !localPatterns.some( + (p) => + p.pathname === STATIC_IMPORT_PATTERN.pathname && p.search === STATIC_IMPORT_PATTERN.search, + ) + ) { + // Static `?image` imports are always allowed. + localPatterns.push({ ...STATIC_IMPORT_PATTERN }); + } + + const loader = merged.loader || 'default'; + let path = merged.path; + if (loader === 'cloudflare' && path === DEFAULT_IMAGE_PATH) { + path = CLOUDFLARE_IMAGE_PATH; + } + if ( + loader !== 'default' && + loader !== 'custom' && + loader !== 'cloudflare' && + path === DEFAULT_IMAGE_PATH + ) { + throw new Error( + `Specified images.loader property (${loader}) also requires images.path property to be assigned to a URL prefix.\nSee more info here: ${IMAGE_DOCS_URL}`, + ); + } + // Next.js appends a trailing slash for non-default loaders. Cloudflare URLs + // add their own `/` separator. + if (path && !path.endsWith('/') && loader !== 'default' && loader !== 'cloudflare') { + path += '/'; + } + + let loaderFile = ''; + if (merged.loaderFile) { + if (loader !== 'default' && loader !== 'custom') { + throw new Error( + `Specified images.loader property (${loader}) cannot be used with images.loaderFile property. Please set images.loader to "custom".`, + ); + } + const absolutePath = resolve(options.root, merged.loaderFile); + if (!existsSync(absolutePath)) { + throw new Error(`Specified images.loaderFile does not exist at "${absolutePath}".`); + } + loaderFile = absolutePath; + } + + const noOptimizer = + (options.target !== undefined && NODE_TARGETS.has(options.target)) || + options.output === 'static' || + options.managed === true; + const customLoader = loader !== 'default' || loaderFile !== ''; + + const deviceSizes = [...merged.deviceSizes].sort(ascending); + const imageSizes = [...merged.imageSizes].sort(ascending); + + return { + deviceSizes, + imageSizes, + allSizes: [...deviceSizes, ...imageSizes].sort(ascending), + qualities: [...merged.qualities].sort(ascending), + formats: [...merged.formats], + minimumCacheTTL: merged.minimumCacheTTL, + remotePatterns: merged.remotePatterns.map(compileRemotePattern), + localPatterns: localPatterns.map(compileLocalPattern), + domains: [...merged.domains], + dangerouslyAllowSVG: merged.dangerouslyAllowSVG, + dangerouslyAllowLocalIP: merged.dangerouslyAllowLocalIP, + contentSecurityPolicy: merged.contentSecurityPolicy, + contentDispositionType: merged.contentDispositionType, + maximumRedirects: merged.maximumRedirects, + maximumResponseBody: Math.min(merged.maximumResponseBody, IMAGES_BINDING_MAX_INPUT_BYTES), + loader, + loaderFile, + path, + unoptimized: merged.unoptimized || (noOptimizer && !customLoader), + dev: options.dev, + }; +} + +function startsWithSinglePathSlash(path: string): boolean { + return path.startsWith('/') && !path.startsWith('//'); +} + +/** + * Whether `/_void/image` should be served: the default loader, no + * `loaderFile`, not `unoptimized`, and a same-origin `path`. + */ +export function isImageOptimizerActive(resolved: ResolvedImageConfig): boolean { + return ( + !resolved.unoptimized && + resolved.loader === 'default' && + !resolved.loaderFile && + startsWithSinglePathSlash(resolved.path) + ); +} + +/** + * `isImageOptimizerActive` from the authored config alone (no file system), + * for binding inference: `Image` usage needs the Images binding only when + * this is true. + */ +export function imageOptimizerConfigured( + images: ImageConfig | undefined, + project: { target?: string; output?: string; managed?: boolean }, +): boolean { + if (project.target !== undefined && NODE_TARGETS.has(project.target)) { + return false; + } + if (project.output === 'static' || project.managed === true) { + return false; + } + if (images?.unoptimized || images?.loaderFile) { + return false; + } + if ((images?.loader || 'default') !== 'default') { + return false; + } + return startsWithSinglePathSlash(images?.path ?? DEFAULT_IMAGE_PATH); +} diff --git a/packages/void/src/image/blur-svg.ts b/packages/void/src/image/blur-svg.ts new file mode 100644 index 000000000..104887fe9 --- /dev/null +++ b/packages/void/src/image/blur-svg.ts @@ -0,0 +1,35 @@ +/** Inputs of `getImageBlurSvg`, as `getImgProps` computes them. */ +export type ImageBlurSvgOptions = { + widthInt?: number; + heightInt?: number; + blurWidth?: number; + blurHeight?: number; + blurDataURL: string; + objectFit?: string | number; +}; + +/** + * The URL-encoded SVG that blurs `blurDataURL` for `placeholder="blur"`, + * ported from Next.js 16 `image-blur-svg.js`. + */ +export function getImageBlurSvg({ + widthInt, + heightInt, + blurWidth, + blurHeight, + blurDataURL, + objectFit, +}: ImageBlurSvgOptions): string { + const std = 20; + const svgWidth = blurWidth ? blurWidth * 40 : widthInt; + const svgHeight = blurHeight ? blurHeight * 40 : heightInt; + const viewBox = svgWidth && svgHeight ? `viewBox='0 0 ${svgWidth} ${svgHeight}'` : ''; + const preserveAspectRatio = viewBox + ? 'none' + : objectFit === 'contain' + ? 'xMidYMid' + : objectFit === 'cover' + ? 'xMidYMid slice' + : 'none'; + return `%3Csvg xmlns='http://www.w3.org/2000/svg' ${viewBox}%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='${std}'/%3E%3CfeColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/%3E%3CfeFlood x='0' y='0' width='100%25' height='100%25'/%3E%3CfeComposite operator='out' in='s'/%3E%3CfeComposite in2='SourceGraphic'/%3E%3CfeGaussianBlur stdDeviation='${std}'/%3E%3C/filter%3E%3Cimage width='100%25' height='100%25' x='0' y='0' preserveAspectRatio='${preserveAspectRatio}' style='filter: url(%23b);' href='${blurDataURL}'/%3E%3C/svg%3E`; +} diff --git a/packages/void/src/image/client-config.ts b/packages/void/src/image/client-config.ts new file mode 100644 index 000000000..5d52a404f --- /dev/null +++ b/packages/void/src/image/client-config.ts @@ -0,0 +1,52 @@ +import { imageConfigDefault, type ClientImageConfig, type ResolvedImageConfig } from './config.ts'; + +/** + * The subset of the resolved config an `Image` reads at render time (Next.js + * `__NEXT_IMAGE_OPTS`). Server environments always receive the patterns, for + * the loader's query-string check; client bundles receive them only in + * development, for the dev-time `src` checks. + */ +export function toClientImageConfig( + resolved: ResolvedImageConfig, + consumer: 'client' | 'server', +): ClientImageConfig { + const config: ClientImageConfig = { + deviceSizes: [...resolved.deviceSizes], + imageSizes: [...resolved.imageSizes], + allSizes: [...resolved.allSizes], + qualities: [...resolved.qualities], + path: resolved.path, + loader: resolved.loader, + dangerouslyAllowSVG: resolved.dangerouslyAllowSVG, + unoptimized: resolved.unoptimized, + dev: resolved.dev, + }; + if (consumer === 'server' || resolved.dev) { + config.localPatterns = resolved.localPatterns.map((p) => ({ ...p })); + config.remotePatterns = resolved.remotePatterns.map((p) => ({ ...p })); + config.domains = [...resolved.domains]; + } + return config; +} + +const ascending = (a: number, b: number): number => a - b; + +/** + * Next.js defaults in client form, without patterns (as `next/image` sees them + * when no config is supplied). Used when no config is registered. + */ +export function defaultClientImageConfig(): ClientImageConfig { + const deviceSizes = [...imageConfigDefault.deviceSizes].sort(ascending); + const imageSizes = [...imageConfigDefault.imageSizes].sort(ascending); + return { + deviceSizes, + imageSizes, + allSizes: [...deviceSizes, ...imageSizes].sort(ascending), + qualities: [...imageConfigDefault.qualities].sort(ascending), + path: imageConfigDefault.path, + loader: imageConfigDefault.loader, + dangerouslyAllowSVG: imageConfigDefault.dangerouslyAllowSVG, + unoptimized: imageConfigDefault.unoptimized, + dev: false, + }; +} diff --git a/packages/void/src/image/get-img-props.ts b/packages/void/src/image/get-img-props.ts new file mode 100644 index 000000000..c9d96739b --- /dev/null +++ b/packages/void/src/image/get-img-props.ts @@ -0,0 +1,602 @@ +/** + * `getImgProps`, ported from Next.js 16 `get-img-props.js`: the `` + * attributes every adapter's `Image` renders. Development checks are gated by + * `imgConf.dev` instead of `NODE_ENV`, and messages name `Image` and + * `void.config.ts`. Not ported: the `output: 'export'` error (Void resolves + * static output as `unoptimized`), the `dpl` deployment-id query, and the + * development blur URL (Void has no development blur endpoint). + */ +import { getImageBlurSvg } from './blur-svg.ts'; +import type { ClientImageConfig } from './config.ts'; +import { IMAGE_DOCS_URL } from './loader.ts'; +import type { + ImageCoreProps, + ImageLoader, + ImageLoaderPropsWithConfig, + ImageLoaderWithConfig, + ImageRenderState, + ImageStyle, + ImgPropsResult, + PlaceholderValue, + StaticImageData, + StaticRequire, +} from './types.ts'; +import { warnOnce } from './warn-once.ts'; + +/** Second argument of `getImgProps`. */ +export type GetImgPropsState = ImageRenderState & { + /** The loader used when the `loader` prop is not set. `undefined` means a `loaderFile` without a default export. */ + defaultLoader: ImageLoaderWithConfig | undefined; + imgConf: Readonly; +}; + +const VALID_LOADING_VALUES = ['lazy', 'eager', undefined] as const; + +// Object-fit values that are not valid background-size values +const INVALID_BACKGROUND_SIZE_VALUES: ReadonlyArray = [ + '-moz-initial', + 'fill', + 'none', + 'scale-down', + undefined, +]; + +function isStaticRequire(src: object): src is StaticRequire { + return (src as StaticRequire).default !== undefined; +} + +function isStaticImageData(src: object): src is StaticImageData { + return (src as StaticImageData).src !== undefined; +} + +function isStaticImport(src: unknown): src is StaticRequire | StaticImageData { + return !!src && typeof src === 'object' && (isStaticRequire(src) || isStaticImageData(src)); +} + +type LcpImage = { src: string; loading: string | undefined; placeholder: PlaceholderValue }; + +const allImgs = new Map(); +let perfObserver: { observe(options: object): void } | undefined; + +/** Browser globals read by the development LCP check (no DOM lib in this package). */ +type BrowserGlobals = { + window?: { location: { href: string } }; + PerformanceObserver?: new ( + callback: (list: { getEntries(): Array<{ element?: { src?: string } }> }) => void, + ) => { observe(options: object): void }; +}; + +function getInt(x: unknown): number | undefined { + if (x === undefined) { + return x; + } + if (typeof x === 'number') { + return Number.isFinite(x) ? x : NaN; + } + if (typeof x === 'string' && /^[0-9]+$/.test(x)) { + return parseInt(x, 10); + } + return NaN; +} + +function getWidths( + { deviceSizes, allSizes }: Readonly, + width: number | undefined, + sizes: string | undefined, +): { widths: Array; kind: 'w' | 'x' } { + if (sizes) { + // Find all the "vw" percent sizes used in the sizes prop + const viewportWidthRe = /(^|\s)(1?\d?\d)vw/g; + const percentSizes: Array = []; + for (let match; (match = viewportWidthRe.exec(sizes)); match) { + percentSizes.push(parseInt(match[2])); + } + if (percentSizes.length) { + const smallestRatio = Math.min(...percentSizes) * 0.01; + return { + widths: allSizes.filter((s) => s >= deviceSizes[0] * smallestRatio), + kind: 'w', + }; + } + return { widths: allSizes, kind: 'w' }; + } + if (typeof width !== 'number') { + return { widths: deviceSizes, kind: 'w' }; + } + // 1x and 2x only: 3x images look the same on most screens and cost far more bytes. + const widths = [ + ...new Set( + [width, width * 2].map((w) => allSizes.find((p) => p >= w) || allSizes[allSizes.length - 1]), + ), + ]; + return { widths, kind: 'x' }; +} + +function generateImgAttrs({ + config, + src, + unoptimized, + width, + quality, + sizes, + loader, +}: { + config: Readonly; + src: string; + unoptimized: boolean; + width: number | undefined; + quality: number | undefined; + sizes: string | undefined; + loader: ImageLoaderWithConfig; +}): { src: string; srcSet: string | undefined; sizes: string | undefined } { + if (unoptimized) { + return { src, srcSet: undefined, sizes: undefined }; + } + + const { widths, kind } = getWidths(config, width, sizes); + const last = widths.length - 1; + + return { + sizes: !sizes && kind === 'w' ? '100vw' : sizes, + srcSet: widths + .map( + (w, i) => + `${loader({ config, src, quality, width: w })} ${kind === 'w' ? w : i + 1}${kind}`, + ) + .join(', '), + // `src` stays the last attribute: React applies attributes in order, and + // Safari starts fetching `src` before `sizes` and `srcSet` are set otherwise. + src: loader({ config, src, quality, width: widths[last] }), + }; +} + +/** + * The `` props and render metadata for `Image` props. Shared by server and + * client; `state` carries the loader, the config and the component's + * `blurComplete` / `showAltText` state. + */ +export function getImgProps( + { + src, + sizes, + unoptimized = false, + priority = false, + preload = false, + loading, + className, + quality, + width, + height, + fill = false, + style, + overrideSrc, + onLoad, + onLoadingComplete, + placeholder = 'empty', + blurDataURL, + fetchPriority, + decoding = 'async', + layout, + objectFit, + objectPosition, + lazyBoundary, + lazyRoot, + ...rest + }: ImageCoreProps & Record, + _state: GetImgPropsState, +): ImgPropsResult { + const { imgConf: config, showAltText, blurComplete, defaultLoader } = _state; + if (defaultLoader === undefined) { + throw new Error( + `images.loaderFile detected but the file is missing default export.\nRead more: ${IMAGE_DOCS_URL}`, + ); + } + const restProps = rest as Record; + let loader: ImageLoaderWithConfig = + (restProps.loader as ImageLoaderWithConfig | undefined) || defaultLoader; + // Remove property so it's not spread on element + delete restProps.loader; + delete restProps.srcSet; + + // This marker means the user set neither a "loader" prop nor a loader file. + const isDefaultLoader = '__void_img_default' in loader; + + if (isDefaultLoader) { + if (config.loader === 'custom') { + throw new Error( + `Image with src "${String(src)}" is missing "loader" prop.` + + `\nRead more: ${IMAGE_DOCS_URL}`, + ); + } + } else { + // A user-defined loader never receives the internal config. + const customImageLoader = loader as ImageLoader; + loader = (obj: ImageLoaderPropsWithConfig) => { + const { config: _, ...opts } = obj; + return customImageLoader(opts); + }; + } + + if (layout) { + if (layout === 'fill') { + fill = true; + } + const layoutToStyle: Record = { + intrinsic: { maxWidth: '100%', height: 'auto' }, + responsive: { width: '100%', height: 'auto' }, + }; + const layoutToSizes: Record = { + responsive: '100vw', + fill: '100vw', + }; + const layoutStyle = layoutToStyle[layout]; + if (layoutStyle) { + style = { ...style, ...layoutStyle }; + } + const layoutSizes = layoutToSizes[layout]; + if (layoutSizes && !sizes) { + sizes = layoutSizes; + } + } + + let staticSrc = ''; + let widthInt = getInt(width); + let heightInt = getInt(height); + let blurWidth: number | undefined; + let blurHeight: number | undefined; + if (isStaticImport(src)) { + const staticImageData = isStaticRequire(src) ? src.default : src; + + if (!staticImageData.src) { + throw new Error( + `An object should only be passed to the image component src parameter if it comes from a static image import. It must include src. Received ${JSON.stringify( + staticImageData, + )}`, + ); + } + if (!staticImageData.height || !staticImageData.width) { + throw new Error( + `An object should only be passed to the image component src parameter if it comes from a static image import. It must include height and width. Received ${JSON.stringify( + staticImageData, + )}`, + ); + } + + blurWidth = staticImageData.blurWidth; + blurHeight = staticImageData.blurHeight; + blurDataURL = blurDataURL || staticImageData.blurDataURL; + staticSrc = staticImageData.src; + + if (!fill) { + if (!widthInt && !heightInt) { + widthInt = staticImageData.width; + heightInt = staticImageData.height; + } else if (widthInt && !heightInt) { + const ratio = widthInt / staticImageData.width; + heightInt = Math.round(staticImageData.height * ratio); + } else if (!widthInt && heightInt) { + const ratio = heightInt / staticImageData.height; + widthInt = Math.round(staticImageData.width * ratio); + } + } + } + const srcString = typeof src === 'string' ? src : staticSrc; + + let isLazy = !priority && !preload && (loading === 'lazy' || loading === undefined); + if (!srcString || srcString.startsWith('data:') || srcString.startsWith('blob:')) { + // https://developer.mozilla.org/docs/Web/HTTP/Basics_of_HTTP/Data_URIs + unoptimized = true; + isLazy = false; + } + if (config.unoptimized) { + unoptimized = true; + } + if ( + isDefaultLoader && + !config.dangerouslyAllowSVG && + srcString.split('?', 1)[0].endsWith('.svg') + ) { + // Serve SVG as-is instead of proxying it through the optimizer. + unoptimized = true; + } + + const qualityInt = getInt(quality); + + if (config.dev) { + if (!srcString) { + // There is no `src` to identify the image here, so the component logs + // the element on mount instead. + unoptimized = true; + } else { + if (fill) { + if (width) { + throw new Error( + `Image with src "${srcString}" has both "width" and "fill" properties. Only one should be used.`, + ); + } + if (height) { + throw new Error( + `Image with src "${srcString}" has both "height" and "fill" properties. Only one should be used.`, + ); + } + if (style?.position && style.position !== 'absolute') { + throw new Error( + `Image with src "${srcString}" has both "fill" and "style.position" properties. Images with "fill" always use position absolute - it cannot be modified.`, + ); + } + if (style?.width && style.width !== '100%') { + throw new Error( + `Image with src "${srcString}" has both "fill" and "style.width" properties. Images with "fill" always use width 100% - it cannot be modified.`, + ); + } + if (style?.height && style.height !== '100%') { + throw new Error( + `Image with src "${srcString}" has both "fill" and "style.height" properties. Images with "fill" always use height 100% - it cannot be modified.`, + ); + } + } else { + if (widthInt === undefined) { + throw new Error(`Image with src "${srcString}" is missing required "width" property.`); + } else if (isNaN(widthInt)) { + throw new Error( + `Image with src "${srcString}" has invalid "width" property. Expected a numeric value in pixels but received "${String(width)}".`, + ); + } + if (heightInt === undefined) { + throw new Error(`Image with src "${srcString}" is missing required "height" property.`); + } else if (isNaN(heightInt)) { + throw new Error( + `Image with src "${srcString}" has invalid "height" property. Expected a numeric value in pixels but received "${String(height)}".`, + ); + } + // oxlint-disable-next-line no-control-regex + if (/^[\x00-\x20]/.test(srcString)) { + throw new Error( + `Image with src "${srcString}" cannot start with a space or control character. Use src.trimStart() to remove it or encodeURIComponent(src) to keep it.`, + ); + } + // oxlint-disable-next-line no-control-regex + if (/[\x00-\x20]$/.test(srcString)) { + throw new Error( + `Image with src "${srcString}" cannot end with a space or control character. Use src.trimEnd() to remove it or encodeURIComponent(src) to keep it.`, + ); + } + } + } + if (!(VALID_LOADING_VALUES as ReadonlyArray).includes(loading)) { + throw new Error( + `Image with src "${srcString}" has invalid "loading" property. Provided "${String(loading)}" should be one of ${VALID_LOADING_VALUES.map( + String, + ).join(',')}.`, + ); + } + if (priority && loading === 'lazy') { + throw new Error( + `Image with src "${srcString}" has both "priority" and "loading='lazy'" properties. Only one should be used.`, + ); + } + if (preload && loading === 'lazy') { + throw new Error( + `Image with src "${srcString}" has both "preload" and "loading='lazy'" properties. Only one should be used.`, + ); + } + if (preload && priority) { + throw new Error( + `Image with src "${srcString}" has both "preload" and "priority" properties. Only "preload" should be used.`, + ); + } + if ( + placeholder !== 'empty' && + placeholder !== 'blur' && + !String(placeholder).startsWith('data:image/') + ) { + throw new Error( + `Image with src "${srcString}" has invalid "placeholder" property "${String(placeholder)}".`, + ); + } + if (placeholder !== 'empty') { + if (widthInt && heightInt && widthInt * heightInt < 1600) { + warnOnce( + `Image with src "${srcString}" is smaller than 40x40. Consider removing the "placeholder" property to improve performance.`, + ); + } + } + if (qualityInt && config.qualities && !config.qualities.includes(qualityInt)) { + warnOnce( + `Image with src "${srcString}" is using quality "${qualityInt}" which is not configured in images.qualities [${config.qualities.join( + ', ', + )}]. Please update your config to [${[...config.qualities, qualityInt].sort().join(', ')}].` + + `\nRead more: ${IMAGE_DOCS_URL}`, + ); + } + if (placeholder === 'blur' && !blurDataURL) { + const VALID_BLUR_EXT = ['jpeg', 'png', 'webp', 'avif']; // should match next-image-loader + + throw new Error( + `Image with src "${srcString}" has "placeholder='blur'" property but is missing the "blurDataURL" property. + Possible solutions: + - Add a "blurDataURL" property, the contents should be a small Data URL to represent the image + - Change the "src" property to a static import with one of the supported file types: ${VALID_BLUR_EXT.join( + ',', + )} (animated images not supported) + - Remove the "placeholder" property, effectively no blur effect + Read more: ${IMAGE_DOCS_URL}`, + ); + } + if ('ref' in restProps) { + warnOnce( + `Image with src "${srcString}" is using unsupported "ref" property. Consider using the "onLoad" property instead.`, + ); + } + + if (!unoptimized && !isDefaultLoader) { + const urlStr = loader({ + config, + src: srcString, + width: widthInt || 400, + quality: qualityInt || 75, + }); + let url: URL | undefined; + try { + url = new URL(urlStr); + } catch { + // Not an absolute URL. + } + if (urlStr === srcString || (url && url.pathname === srcString && !url.search)) { + warnOnce( + `Image with src "${srcString}" has a "loader" property that does not implement width. Please implement it or use the "unoptimized" property instead.` + + `\nRead more: ${IMAGE_DOCS_URL}`, + ); + } + } + + if (onLoadingComplete) { + warnOnce( + `Image with src "${srcString}" is using deprecated "onLoadingComplete" property. Please use the "onLoad" property instead.`, + ); + } + + for (const [legacyKey, legacyValue] of Object.entries({ + layout, + objectFit, + objectPosition, + lazyBoundary, + lazyRoot, + })) { + if (legacyValue) { + warnOnce( + `Image with src "${srcString}" has legacy prop "${legacyKey}". Did you forget to run the codemod?` + + `\nRead more: ${IMAGE_DOCS_URL}`, + ); + } + } + + observeLargestContentfulPaint(); + } + + const imgStyle: ImageStyle = Object.assign( + fill + ? { + position: 'absolute', + height: '100%', + width: '100%', + left: 0, + top: 0, + right: 0, + bottom: 0, + objectFit, + objectPosition, + } + : {}, + showAltText ? {} : { color: 'transparent' }, + style, + ); + + const backgroundImage = + !blurComplete && placeholder !== 'empty' + ? placeholder === 'blur' + ? `url("data:image/svg+xml;charset=utf-8,${getImageBlurSvg({ + widthInt, + heightInt, + blurWidth, + blurHeight, + blurDataURL: blurDataURL || '', // assume not undefined + objectFit: imgStyle.objectFit, + })}")` + : `url("${placeholder}")` // assume `data:image/` + : null; + + const backgroundSize = !INVALID_BACKGROUND_SIZE_VALUES.includes(imgStyle.objectFit) + ? imgStyle.objectFit + : imgStyle.objectFit === 'fill' + ? '100% 100%' // the background-size equivalent of `fill` + : 'cover'; + + const placeholderStyle: ImageStyle = backgroundImage + ? { + backgroundSize, + backgroundPosition: imgStyle.objectPosition || '50% 50%', + backgroundRepeat: 'no-repeat', + backgroundImage, + } + : {}; + + const imgAttributes = generateImgAttrs({ + config, + src: srcString, + unoptimized, + width: widthInt, + quality: qualityInt, + sizes, + loader, + }); + + const loadingFinal = isLazy ? 'lazy' : loading; + + if (config.dev) { + const browser = globalThis as BrowserGlobals; + if (browser.window !== undefined) { + let fullUrl: URL; + try { + fullUrl = new URL(imgAttributes.src); + } catch { + fullUrl = new URL(imgAttributes.src, browser.window.location.href); + } + allImgs.set(fullUrl.href, { src: srcString, loading: loadingFinal, placeholder }); + } + } + + const props = { + ...restProps, + loading: loadingFinal, + fetchPriority, + width: widthInt, + height: heightInt, + decoding, + className, + style: { ...imgStyle, ...placeholderStyle }, + sizes: imgAttributes.sizes, + srcSet: imgAttributes.srcSet, + src: overrideSrc || imgAttributes.src, + }; + const meta = { + unoptimized, + preload: preload || priority, + placeholder, + fill, + }; + return { props, meta }; +} + +/** Development only: warn when a lazy image is the Largest Contentful Paint. Set up once per page. */ +function observeLargestContentfulPaint(): void { + const browser = globalThis as BrowserGlobals; + if (browser.window === undefined || perfObserver || !browser.PerformanceObserver) { + return; + } + perfObserver = new browser.PerformanceObserver((entryList) => { + for (const entry of entryList.getEntries()) { + const imgSrc = entry?.element?.src || ''; + const lcpImage = allImgs.get(imgSrc); + if ( + lcpImage && + lcpImage.loading === 'lazy' && + lcpImage.placeholder === 'empty' && + !lcpImage.src.startsWith('data:') && + !lcpImage.src.startsWith('blob:') + ) { + // https://web.dev/lcp/#measure-lcp-in-javascript + warnOnce( + `Image with src "${lcpImage.src}" was detected as the Largest Contentful Paint (LCP). Please add the \`loading="eager"\` property if this image is above the fold.` + + `\nRead more: ${IMAGE_DOCS_URL}`, + ); + } + } + }); + try { + perfObserver.observe({ type: 'largest-contentful-paint', buffered: true }); + } catch (err) { + // Log error but don't crash the app + console.error(err); + } +} diff --git a/packages/void/src/image/html.ts b/packages/void/src/image/html.ts new file mode 100644 index 000000000..7774a4119 --- /dev/null +++ b/packages/void/src/image/html.ts @@ -0,0 +1,212 @@ +/** + * Helpers for adapters that render `getImgProps` results as HTML attributes + * (Vue, Svelte, Solid, islands) and for ``. + */ +import type { ImageMeta, ImagePreloadLink, ImageStyle, ImgProps } from './types.ts'; + +// React DOM's unitless CSS properties: numbers other than 0 get `px` otherwise. +const UNITLESS_NUMBERS = new Set( + 'animationIterationCount aspectRatio borderImageOutset borderImageSlice borderImageWidth boxFlex boxFlexGroup boxOrdinalGroup columnCount columns flex flexGrow flexPositive flexShrink flexNegative flexOrder gridArea gridRow gridRowEnd gridRowSpan gridRowStart gridColumn gridColumnEnd gridColumnSpan gridColumnStart fontWeight lineClamp lineHeight opacity order orphans scale tabSize widows zIndex zoom fillOpacity floodOpacity stopOpacity strokeDasharray strokeDashoffset strokeMiterlimit strokeOpacity strokeWidth MozAnimationIterationCount MozBoxFlex MozBoxFlexGroup MozLineClamp msAnimationIterationCount msFlex msZoom msFlexGrow msFlexNegative msFlexOrder msFlexPositive msFlexShrink msGridColumn msGridColumnSpan msGridRow msGridRowSpan WebkitAnimationIterationCount WebkitBoxFlex WebKitBoxFlexGroup WebkitBoxOrdinalGroup WebkitColumnCount WebkitColumns WebkitFlex WebkitFlexGrow WebkitFlexPositive WebkitFlexShrink WebkitLineClamp'.split( + ' ', + ), +); + +/** React prop names `getImgProps` may return, mapped to HTML attribute names. */ +const HTML_ATTRIBUTE_NAMES: Record = { + className: 'class', + srcSet: 'srcset', + fetchPriority: 'fetchpriority', + crossOrigin: 'crossorigin', + referrerPolicy: 'referrerpolicy', + useMap: 'usemap', + isMap: 'ismap', + tabIndex: 'tabindex', +}; + +/** + * Serialize a style object as React DOM does: kebab-case names (`ms` prefix + * becomes `-ms-`), `px` for non-zero numbers of unit properties, trimmed + * strings, `;`-separated, with `undefined`, `null`, booleans and `''` dropped. + * The result is not HTML-escaped. + */ +export function styleToString(style: ImageStyle): string { + const declarations: Array = []; + for (const name of Object.keys(style)) { + const value: unknown = style[name]; + if (value == null || typeof value === 'boolean' || value === '') { + continue; + } + if (name.startsWith('--')) { + declarations.push(`${name}:${String(value).trim()}`); + continue; + } + const cssName = name + .replace(/([A-Z])/g, '-$1') + .toLowerCase() + .replace(/^ms-/, '-ms-'); + const cssValue = + typeof value === 'number' + ? value === 0 || UNITLESS_NUMBERS.has(name) + ? String(value) + : `${value}px` + : String(value).trim(); + declarations.push(`${cssName}:${cssValue}`); + } + return declarations.join(';'); +} + +/** + * `getImgProps` props as HTML attributes, in the same order: React names + * become HTML names (`srcSet` → `srcset`, `className` → `class`, + * `fetchPriority` → `fetchpriority`, `crossOrigin` → `crossorigin`, + * `referrerPolicy` → `referrerpolicy`), `style` becomes a CSS string (omitted + * when empty), and `undefined`, `null`, functions and other objects are dropped. + */ +export function toHtmlImgAttributes( + props: Record, +): Record { + const attributes: Record = {}; + for (const [key, value] of Object.entries(props)) { + if (value == null) { + continue; + } + if (key === 'style') { + if (typeof value === 'string') { + if (value) { + attributes.style = value; + } + continue; + } + if (typeof value === 'object') { + const css = styleToString(value as ImageStyle); + if (css) { + attributes.style = css; + } + } + continue; + } + if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean') { + continue; + } + attributes[HTML_ATTRIBUTE_NAMES[key] ?? key] = value; + } + return attributes; +} + +function stringAttribute( + props: Record, + ...names: Array +): string | undefined { + for (const name of names) { + const value = props[name]; + if (typeof value === 'string' && value) { + return value; + } + } + return undefined; +} + +/** + * The `` for a preloaded image (`preload` or + * `priority`), or `undefined`. As in Next.js, `href` is omitted when the image + * has a `srcset`, so browsers pick the same candidate as the ``. + */ +export function getImagePreloadLink( + props: ImgProps | Record, + meta: ImageMeta, +): ImagePreloadLink | undefined { + if (!meta.preload) { + return undefined; + } + const record = props as Record; + const imagesrcset = stringAttribute(record, 'srcSet', 'srcset'); + const imagesizes = stringAttribute(record, 'sizes'); + const href = imagesrcset ? undefined : stringAttribute(record, 'src'); + if (!imagesrcset && !href) { + return undefined; + } + const link: ImagePreloadLink = { + key: imagesrcset ? `${imagesrcset}\n${imagesizes ?? ''}` : (href as string), + }; + if (href) { + link.href = href; + } + if (imagesrcset) { + link.imagesrcset = imagesrcset; + } + if (imagesizes) { + link.imagesizes = imagesizes; + } + const crossorigin = stringAttribute(record, 'crossOrigin', 'crossorigin'); + if (crossorigin) { + link.crossorigin = crossorigin; + } + const referrerpolicy = stringAttribute(record, 'referrerPolicy', 'referrerpolicy'); + if (referrerpolicy) { + link.referrerpolicy = referrerpolicy; + } + const fetchpriority = stringAttribute(record, 'fetchPriority', 'fetchpriority'); + if (fetchpriority) { + link.fetchpriority = fetchpriority; + } + return link; +} + +function escapeAttribute(value: string): string { + return value + .replaceAll('&', '&') + .replaceAll('"', '"') + .replaceAll("'", ''') + .replaceAll('<', '<') + .replaceAll('>', '>'); +} + +const PRELOAD_ATTRIBUTES = [ + 'href', + 'imagesrcset', + 'imagesizes', + 'crossorigin', + 'referrerpolicy', + 'fetchpriority', +] as const; + +/** `` tags for `links`, attribute values HTML-escaped. */ +export function renderImagePreloadLinks(links: Iterable): string { + let html = ''; + for (const link of links) { + html += '; + render(): string; +}; + +/** A preload collector for one server render. */ +export function createImagePreloadCollector(): ImagePreloadCollector { + const byKey = new Map(); + return { + add(link) { + if (!byKey.has(link.key)) { + byKey.set(link.key, link); + } + }, + links() { + return [...byKey.values()]; + }, + render() { + return renderImagePreloadLinks(byKey.values()); + }, + }; +} diff --git a/packages/void/src/image/index.ts b/packages/void/src/image/index.ts new file mode 100644 index 000000000..a8750a483 --- /dev/null +++ b/packages/void/src/image/index.ts @@ -0,0 +1,42 @@ +/** + * `void/image`: the framework-neutral core of every adapter's `Image` + * component and `getImageProps()`, matching `next/image` in Next.js 16. + * Worker- and browser-safe. + */ +export * from './config.ts'; +export * from './types.ts'; +export { getImageBlurSvg, type ImageBlurSvgOptions } from './blur-svg.ts'; +export { getImgProps, type GetImgPropsState } from './get-img-props.ts'; +export { + createImagePreloadCollector, + getImagePreloadLink, + renderImagePreloadLinks, + styleToString, + toHtmlImgAttributes, + type ImagePreloadCollector, +} from './html.ts'; +export { + cloudflareLoader, + defaultLoader, + findClosestQuality, + IMAGE_DOCS_URL, + type BuiltinImageLoader, +} from './loader.ts'; +export { + handleImageLoading, + type HandleImageLoadingOptions, + type ImageElementLike, +} from './loading.ts'; +export { + hasLocalMatch, + hasRemoteMatch, + matchLocalPattern, + matchRemotePattern, +} from './patterns.ts'; +export { + getDefaultImageLoader, + getImageConfig, + getImageProps, + resolveImageProps, + type ImageConfigRegistration, +} from './registry.ts'; diff --git a/packages/void/src/image/loader.ts b/packages/void/src/image/loader.ts new file mode 100644 index 000000000..83e46a352 --- /dev/null +++ b/packages/void/src/image/loader.ts @@ -0,0 +1,130 @@ +/** + * Built-in image loaders and `findClosestQuality`, ported from Next.js 16 + * (`image-loader.js`, `find-closest-quality.js`). Development checks are gated + * by `config.dev` instead of `NODE_ENV`; Next.js's deployment-id (`dpl`) query + * is not used. + */ +import type { ClientImageConfig } from './config.ts'; +import { hasLocalMatch, hasRemoteMatch } from './patterns.ts'; +import type { ImageLoaderPropsWithConfig, ImageLoaderWithConfig } from './types.ts'; + +/** Linked from error messages in place of the Next.js documentation. */ +export const IMAGE_DOCS_URL = 'https://void.cloud/guide/images'; + +/** + * A loader built into Void. It receives `config`; `getImgProps` passes any + * other loader only `{ src, width, quality }`. + */ +export type BuiltinImageLoader = ImageLoaderWithConfig & { readonly __void_img_default: true }; + +/** + * The configured quality closest to `quality` (default 75). Ties keep the + * lower value. Without `qualities` the requested quality is returned. + */ +export function findClosestQuality( + quality: number | undefined, + config: { qualities?: ReadonlyArray | undefined } | undefined, +): number { + const q = quality || 75; + if (!config?.qualities?.length) { + return q; + } + return config.qualities.reduce( + (prev, cur) => (Math.abs(cur - q) < Math.abs(prev - q) ? cur : prev), + config.qualities[0], + ); +} + +function isDefaultLocalPatterns(config: Readonly): boolean { + const patterns = config.localPatterns; + return patterns?.length === 1 && patterns[0].pathname === '**' && patterns[0].search === ''; +} + +function defaultLoaderImpl({ config, src, width, quality }: ImageLoaderPropsWithConfig): string { + if (config.dev) { + const missingValues: Array = []; + // these should always be provided but make sure they are + if (!src) { + missingValues.push('src'); + } + if (!width) { + missingValues.push('width'); + } + if (missingValues.length > 0) { + throw new Error( + `Void Image Optimization requires ${missingValues.join(', ')} to be provided. Make sure you pass them as props to the \`Image\` component. Received: ${JSON.stringify( + { src, width, quality }, + )}`, + ); + } + } + + if (src.startsWith('/') && src.includes('?') && isDefaultLocalPatterns(config)) { + throw new Error( + `Image with src "${src}" is using a query string which is not configured in images.localPatterns.` + + `\nRead more: ${IMAGE_DOCS_URL}`, + ); + } + + if (config.dev) { + if (src.startsWith('//')) { + throw new Error( + `Failed to parse src "${src}" on \`Image\`, protocol-relative URL (//) must be changed to an absolute URL (http:// or https://)`, + ); + } + if (src.startsWith('/') && config.localPatterns) { + if (!hasLocalMatch(config.localPatterns, src)) { + throw new Error( + `Invalid src prop (${src}) on \`Image\` does not match \`images.localPatterns\` configured in your \`void.config.ts\`\n` + + `See more info: ${IMAGE_DOCS_URL}`, + ); + } + } + if (!src.startsWith('/') && (config.domains || config.remotePatterns)) { + let parsedSrc: URL; + try { + parsedSrc = new URL(src); + } catch (err) { + console.error(err); + throw new Error( + `Failed to parse src "${src}" on \`Image\`, if using relative image it must start with a leading slash "/" or be an absolute URL (http:// or https://)`, + ); + } + if (!hasRemoteMatch(config.domains ?? [], config.remotePatterns ?? [], parsedSrc)) { + throw new Error( + `Invalid src prop (${src}) on \`Image\`, hostname "${parsedSrc.hostname}" is not configured under images in your \`void.config.ts\`\n` + + `See more info: ${IMAGE_DOCS_URL}`, + ); + } + } + } + + const q = findClosestQuality(quality, config); + return `${config.path}?url=${encodeURIComponent(src)}&w=${width}&q=${q}`; +} + +/** + * The default loader: `?url=&w=&q=` for the + * `/_void/image` endpoint. + */ +export const defaultLoader: BuiltinImageLoader = Object.assign(defaultLoaderImpl, { + __void_img_default: true as const, +}); + +function cloudflareLoaderImpl({ config, src, width, quality }: ImageLoaderPropsWithConfig): string { + // Zone transformations do not exist on the dev server. + if (config.dev) { + return src; + } + const params = `width=${width},quality=${findClosestQuality(quality, config)},format=auto`; + return `${config.path.replace(/\/+$/, '')}/${params}/${src.startsWith('/') ? src.slice(1) : src}`; +} + +/** + * `images.loader: 'cloudflare'`: Cloudflare zone transformation URLs, + * `/width=,quality=,format=auto/` (`path` defaults to + * `/cdn-cgi/image`). Returns `src` unchanged in development. + */ +export const cloudflareLoader: BuiltinImageLoader = Object.assign(cloudflareLoaderImpl, { + __void_img_default: true as const, +}); diff --git a/packages/void/src/image/loading.ts b/packages/void/src/image/loading.ts new file mode 100644 index 000000000..a5cedb225 --- /dev/null +++ b/packages/void/src/image/loading.ts @@ -0,0 +1,137 @@ +/** + * `handleImageLoading`, ported from `handleLoading` in Next.js 16 + * `client/image-component.js`: what every adapter's `Image` runs when the + * `` loads, or on mount when the server-rendered image already loaded. + */ +import { IMAGE_DOCS_URL } from './loader.ts'; +import type { PlaceholderValue } from './types.ts'; +import { warnOnce } from './warn-once.ts'; + +/** The parts of an `HTMLImageElement` `handleImageLoading` reads (this package has no DOM types). */ +export type ImageElementLike = { + readonly src: string; + readonly isConnected: boolean; + readonly parentElement: unknown; + readonly width: number; + readonly height: number; + decode?: () => Promise; + getAttribute(name: string): string | null; + getBoundingClientRect?: () => { width: number }; +}; + +export type HandleImageLoadingOptions = { + placeholder: PlaceholderValue; + /** Called once per loaded `src`, after decoding, while the image is still connected. Adapters build their load event here and call `onLoadingComplete`. */ + onLoad?: (img: T) => void; + /** Called before `onLoad` when the placeholder is not `empty`, to drop the placeholder background. */ + onBlurComplete?: () => void; + /** Run the development warnings (`ClientImageConfig.dev`). */ + dev?: boolean; + /** The `sizes` prop as passed to `Image`. */ + sizesInput?: string; + unoptimized?: boolean; +}; + +/** Browser globals read by the development warnings. */ +type BrowserGlobals = { + window?: { + innerWidth: number; + getComputedStyle(element: unknown): { position: string }; + }; +}; + +const LOADED_SRC = 'data-loaded-src'; + +/** + * Finish loading an ``: once per `src` (tracked with an expando + * property, not an attribute), wait for `decode()`, then, unless the image was + * removed meanwhile, end the blur placeholder and call `onLoad`. + */ +export function handleImageLoading( + img: T | null | undefined, + options: HandleImageLoadingOptions, +): void { + const src = img?.src; + if (!img) { + return; + } + const expando = img as unknown as Record; + if (expando[LOADED_SRC] === src) { + return; + } + expando[LOADED_SRC] = src; + const { placeholder, onLoad, onBlurComplete, dev, sizesInput, unoptimized } = options; + const p = 'decode' in img && typeof img.decode === 'function' ? img.decode() : Promise.resolve(); + p.catch(() => {}).then(() => { + if (!img.parentElement || !img.isConnected) { + // Exit early in case of race condition: + // - onload() is called + // - decode() is called but incomplete + // - unmount is called + // - decode() completes + return; + } + if (placeholder !== 'empty') { + onBlurComplete?.(); + } + onLoad?.(img); + if (dev) { + warnAfterLoad(img, src ?? '', sizesInput, unoptimized); + } + }); +} + +function warnAfterLoad( + img: ImageElementLike, + src: string, + sizesInput: string | undefined, + unoptimized: boolean | undefined, +): void { + const browser = (globalThis as BrowserGlobals).window; + let origSrc = src; + try { + origSrc = new URL(src, 'http://n').searchParams.get('url') || src; + } catch { + // Keep the raw src. + } + if (img.getAttribute('data-nimg') === 'fill') { + if (!unoptimized && (!sizesInput || sizesInput === '100vw') && browser) { + const widthViewportRatio = (img.getBoundingClientRect?.().width ?? 0) / browser.innerWidth; + if (widthViewportRatio < 0.6) { + if (sizesInput === '100vw') { + warnOnce( + `Image with src "${origSrc}" has "fill" prop and "sizes" prop of "100vw", but image is not rendered at full viewport width. Please adjust "sizes" to improve page performance. Read more: ${IMAGE_DOCS_URL}`, + ); + } else { + warnOnce( + `Image with src "${origSrc}" has "fill" but is missing "sizes" prop. Please add it to improve page performance. Read more: ${IMAGE_DOCS_URL}`, + ); + } + } + } + if (img.parentElement && browser) { + const { position } = browser.getComputedStyle(img.parentElement); + const valid = ['absolute', 'fixed', 'relative']; + if (!valid.includes(position)) { + warnOnce( + `Image with src "${origSrc}" has "fill" and parent element with invalid "position". Provided "${position}" should be one of ${valid + .map(String) + .join(',')}.`, + ); + } + } + if (img.height === 0) { + warnOnce( + `Image with src "${origSrc}" has "fill" and a height value of 0. This is likely because the parent element of the image has not been styled to have a set height.`, + ); + } + } + + const heightModified = img.height.toString() !== img.getAttribute('height'); + const widthModified = img.width.toString() !== img.getAttribute('width'); + if ((heightModified && !widthModified) || (!heightModified && widthModified)) { + warnOnce( + `Image with src "${origSrc}" has either width or height modified, but not the other. If you use CSS to change the size of your image, also include the styles 'width: "auto"' or 'height: "auto"' to maintain the aspect ratio.`, + ); + } +} diff --git a/packages/void/src/image/optimizer/accept.ts b/packages/void/src/image/optimizer/accept.ts new file mode 100644 index 000000000..782d0b712 --- /dev/null +++ b/packages/void/src/image/optimizer/accept.ts @@ -0,0 +1,55 @@ +import { AVIF_MAX_WIDTH, type ImageFormat } from '../config.ts'; + +/** Media types an `Accept` header lists explicitly with `q > 0`, lowercased. Wildcards never count. */ +function acceptedTypes(accept: string): Set { + const types = new Set(); + for (const entry of accept.split(',')) { + const [type = '', ...parameters] = entry.split(';').map((part) => part.trim()); + let quality = 1; + for (const parameter of parameters) { + const separator = parameter.indexOf('='); + const name = (separator === -1 ? parameter : parameter.slice(0, separator)).trim(); + if (name.toLowerCase() !== 'q') { + continue; + } + const value = separator === -1 ? '' : parameter.slice(separator + 1).trim(); + // A malformed weight disqualifies only its own entry. Next.js answers 500 here. + quality = /^\d+(\.\d*)?$|^\.\d+$/.test(value) ? Number(value) : 0; + } + if (quality > 0 && !type.includes('*')) { + types.add(type.toLowerCase()); + } + } + return types; +} + +/** + * The output format for a request: the first of `formats` that `accept` lists + * explicitly (exact media type, case-insensitive, `q > 0`), or `''` when none + * does. `image/avif` is skipped above Cloudflare's AVIF width limit so the + * next preferred format is used. + * + * Next.js also needs an explicit listing (`accept.includes(type)`), but it + * ranks AVIF over WebP regardless of the `formats` order; Void follows the + * documented order. Media-type parameters (`image/webp;level=1`) are ignored + * instead of leaking into the response type. + */ +export function getSupportedImageFormat( + formats: ReadonlyArray, + accept: string | null | undefined, + width: number, +): ImageFormat | '' { + if (!accept) { + return ''; + } + const accepted = acceptedTypes(accept); + for (const format of formats) { + if (format === 'image/avif' && width > AVIF_MAX_WIDTH) { + continue; + } + if (accepted.has(format)) { + return format; + } + } + return ''; +} diff --git a/packages/void/src/image/optimizer/content-type.ts b/packages/void/src/image/optimizer/content-type.ts new file mode 100644 index 000000000..aaceda6c4 --- /dev/null +++ b/packages/void/src/image/optimizer/content-type.ts @@ -0,0 +1,351 @@ +/** + * Image content sniffing for `/_void/image`, ported from Next.js 16 + * (`detect-content-type.js`, the `image-detector` fallback it bundles, and + * `is-animated`). The endpoint never trusts an upstream `Content-Type`. + */ + +export const AVIF = 'image/avif'; +export const WEBP = 'image/webp'; +export const PNG = 'image/png'; +export const JPEG = 'image/jpeg'; +export const JXL = 'image/jxl'; +export const JP2 = 'image/jp2'; +export const HEIC = 'image/heic'; +export const GIF = 'image/gif'; +export const SVG = 'image/svg+xml'; +export const ICO = 'image/x-icon'; +export const ICNS = 'image/x-icns'; +export const TIFF = 'image/tiff'; +export const BMP = 'image/bmp'; +export const PDF = 'application/pdf'; + +/** Types that may be animated; animated sources are served unchanged. */ +export const ANIMATABLE_TYPES: ReadonlyArray = [WEBP, PNG, GIF]; + +/** Types Next.js never transforms. */ +export const BYPASS_TYPES: ReadonlyArray = [SVG, ICO, ICNS, BMP, JXL, HEIC]; + +/** File extensions Next.js derives from an output type (`mime` 1.x plus its additions). */ +const EXTENSIONS: Readonly> = { + [AVIF]: 'avif', + [WEBP]: 'webp', + [PNG]: 'png', + [JPEG]: 'jpeg', + [JXL]: 'jxl', + [JP2]: 'jp2', + [HEIC]: 'heic', + [GIF]: 'gif', + [SVG]: 'svg', + [ICO]: 'ico', + [ICNS]: 'icns', + [TIFF]: 'tiff', + [BMP]: 'bmp', + [PDF]: 'pdf', +}; + +/** The file extension for a served image type, or `undefined` for an unknown type. */ +export function getImageExtension(contentType: string): string | undefined { + return EXTENSIONS[contentType]; +} + +const startsWith = (bytes: Uint8Array, signature: ReadonlyArray): boolean => + signature.every((byte, index) => bytes[index] === byte); + +/** Signature where `0` bytes are wildcards (box sizes and RIFF lengths). */ +const startsWithMasked = (bytes: Uint8Array, signature: ReadonlyArray): boolean => + signature.every((byte, index) => !byte || bytes[index] === byte); + +/** + * The image type of `bytes` from its magic bytes, or `null` when it is empty + * or not an image Next.js recognizes. Every match Next.js makes is kept, + * including non-image types (`application/pdf`) that the endpoint rejects. + */ +export function detectContentType(bytes: Uint8Array): string | null { + if (bytes.byteLength === 0) { + return null; + } + if (startsWith(bytes, [0xff, 0xd8, 0xff])) { + return JPEG; + } + if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { + return PNG; + } + if (startsWith(bytes, [0x47, 0x49, 0x46, 0x38])) { + return GIF; + } + if (startsWithMasked(bytes, [0x52, 0x49, 0x46, 0x46, 0, 0, 0, 0, 0x57, 0x45, 0x42, 0x50])) { + return WEBP; + } + if (startsWith(bytes, [0x3c, 0x3f, 0x78, 0x6d, 0x6c])) { + return SVG; + } + if (startsWith(bytes, [0x3c, 0x73, 0x76, 0x67])) { + return SVG; + } + if (startsWithMasked(bytes, [0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x61, 0x76, 0x69, 0x66])) { + return AVIF; + } + if (startsWith(bytes, [0x00, 0x00, 0x01, 0x00])) { + return ICO; + } + if (startsWith(bytes, [0x69, 0x63, 0x6e, 0x73])) { + return ICNS; + } + if (startsWith(bytes, [0x49, 0x49, 0x2a, 0x00])) { + return TIFF; + } + if (startsWith(bytes, [0x42, 0x4d])) { + return BMP; + } + if (startsWith(bytes, [0xff, 0x0a])) { + return JXL; + } + if (startsWith(bytes, [0x00, 0x00, 0x00, 0x0c, 0x4a, 0x58, 0x4c, 0x20, 0x0d, 0x0a, 0x87, 0x0a])) { + return JXL; + } + if (startsWithMasked(bytes, [0, 0, 0, 0, 0x66, 0x74, 0x79, 0x70, 0x68, 0x65, 0x69, 0x63])) { + return HEIC; + } + if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) { + return PDF; + } + if (startsWith(bytes, [0x00, 0x00, 0x00, 0x0c, 0x6a, 0x50, 0x20, 0x20, 0x0d, 0x0a, 0x87, 0x0a])) { + return JP2; + } + switch (detectFormat(bytes.subarray(0, 1024))) { + case 'webp': + return WEBP; + case 'png': + return PNG; + case 'jpg': + return JPEG; + case 'gif': + return GIF; + case 'svg': + return SVG; + case 'jxl': + case 'jxl-stream': + return JXL; + case 'jp2': + return JP2; + case 'tiff': + return TIFF; + case 'bmp': + return BMP; + case 'ico': + return ICO; + case 'icns': + return ICNS; + default: + // heif, cur, dds, j2c, ktx, pnm, psd, tga and unknown input. + return null; + } +} + +// The `image-detector` fallback (image-size's validators). Out-of-range reads +// produce `NaN`, which fails every comparison, exactly as in the original. +const text = new TextDecoder(); +const utf8 = (bytes: Uint8Array, start = 0, end = bytes.length): string => + text.decode(bytes.slice(start, end)); +const hex = (bytes: Uint8Array, start: number, end: number): string => + Array.from(bytes.slice(start, end), (byte) => byte.toString(16).padStart(2, '0')).join(''); +const readUInt16LE = (bytes: Uint8Array, offset: number): number => + bytes[offset] + bytes[offset + 1] * 2 ** 8; +const readUInt32BE = (bytes: Uint8Array, offset: number): number => + bytes[offset] * 2 ** 24 + + bytes[offset + 1] * 2 ** 16 + + bytes[offset + 2] * 2 ** 8 + + bytes[offset + 3]; +const readUInt32LE = (bytes: Uint8Array, offset: number): number => + bytes[offset] + + bytes[offset + 1] * 2 ** 8 + + bytes[offset + 2] * 2 ** 16 + + bytes[offset + 3] * 2 ** 24; + +function findBox(bytes: Uint8Array, name: string, offset: number): { offset: number } | undefined { + while (offset < bytes.length) { + if (bytes.length - offset < 4) { + return undefined; + } + const size = readUInt32BE(bytes, offset); + if (bytes.length - offset < size) { + return undefined; + } + if (utf8(bytes, 4 + offset, 8 + offset) === name) { + return { offset }; + } + offset += size > 0 ? size : 8; + } + return undefined; +} + +const ftypBrand = (bytes: Uint8Array): string | undefined => { + const box = findBox(bytes, 'ftyp', 0); + return box ? utf8(bytes, box.offset + 8, box.offset + 12) : undefined; +}; + +const HEIF_BRANDS = new Set(['avif', 'mif1', 'msf1', 'heic', 'heix', 'hevc', 'hevx']); +const PNM_TYPES = new Set(['P1', 'P2', 'P3', 'P4', 'P5', 'P6', 'P7', 'PF']); +const SVG_ROOT = /"']|"[^"]*"|'[^']*')*>/; + +const VALIDATORS: ReadonlyArray<[string, (bytes: Uint8Array) => boolean]> = [ + ['bmp', (b) => utf8(b, 0, 2) === 'BM'], + ['cur', (b) => readUInt16LE(b, 0) === 0 && readUInt16LE(b, 4) !== 0 && readUInt16LE(b, 2) === 2], + ['dds', (b) => readUInt32LE(b, 0) === 542327876], + ['gif', (b) => /^GIF8[79]a/.test(utf8(b, 0, 6))], + ['heif', (b) => utf8(b, 4, 8) === 'ftyp' && HEIF_BRANDS.has(ftypBrand(b) ?? '')], + ['icns', (b) => utf8(b, 0, 4) === 'icns'], + ['ico', (b) => readUInt16LE(b, 0) === 0 && readUInt16LE(b, 4) !== 0 && readUInt16LE(b, 2) === 1], + ['j2c', (b) => readUInt32BE(b, 0) === 4283432785], + ['jp2', (b) => utf8(b, 4, 8) === 'jP ' && ftypBrand(b) === 'jp2 '], + ['jpg', (b) => hex(b, 0, 2) === 'ffd8'], + ['jxl', (b) => utf8(b, 4, 8) === 'JXL ' && ftypBrand(b) === 'jxl '], + ['jxl-stream', (b) => hex(b, 0, 2) === 'ff0a'], + ['ktx', (b) => ['KTX 11', 'KTX 20'].includes(utf8(b, 1, 7))], + [ + 'png', + (b) => { + if (utf8(b, 1, 8) !== 'PNG\r\n\x1a\n') { + return false; + } + // image-size throws "Invalid PNG" here, which fails the request in + // Next.js. Treating it as unrecognized rejects it as not an image. + const chunk = utf8(b, 12, 16) === 'CgBI' ? utf8(b, 28, 32) : utf8(b, 12, 16); + return chunk === 'IHDR'; + }, + ], + ['pnm', (b) => PNM_TYPES.has(utf8(b, 0, 2))], + ['psd', (b) => utf8(b, 0, 4) === '8BPS'], + ['svg', (b) => SVG_ROOT.test(utf8(b, 0, 1000))], + ['tga', (b) => readUInt16LE(b, 0) === 0 && readUInt16LE(b, 4) === 0], + ['tiff', (b) => ['49492a00', '4d4d002a'].includes(hex(b, 0, 4))], + [ + 'webp', + (b) => utf8(b, 0, 4) === 'RIFF' && utf8(b, 8, 12) === 'WEBP' && utf8(b, 12, 15) === 'VP8', + ], +]; + +const BY_FIRST_BYTE: Readonly> = { + 56: 'psd', + 66: 'bmp', + 68: 'dds', + 71: 'gif', + 73: 'tiff', + 77: 'tiff', + 82: 'webp', + 105: 'icns', + 137: 'png', + 255: 'jpg', +}; + +function detectFormat(bytes: Uint8Array): string | undefined { + const validate = (name: string): boolean => + VALIDATORS.find(([candidate]) => candidate === name)![1](bytes); + const likely = BY_FIRST_BYTE[bytes[0]]; + if (likely && validate(likely)) { + return likely; + } + return VALIDATORS.find(([, matches]) => matches(bytes))?.[0]; +} + +const ascii = (bytes: Uint8Array, start: number, end: number): string => + String.fromCharCode(...bytes.subarray(start, end)); + +/** GIF data sub-blocks end with a zero-length block. */ +function gifBlocksLength(bytes: Uint8Array, offset: number): number { + let length = 0; + while (bytes[offset + length]) { + length += bytes[offset + length] + 1; + } + return length + 1; +} + +function isAnimatedGif(bytes: Uint8Array): boolean { + let hasColorTable = bytes[10] & 0x80; + let colorTableSize = bytes[10] & 0x07; + let offset = 6 + 7 + (hasColorTable ? 3 * 2 ** (colorTableSize + 1) : 0); + let images = 0; + while (images < 2 && offset < bytes.length) { + switch (bytes[offset]) { + case 0x2c: + images += 1; + hasColorTable = bytes[offset + 9] & 0x80; + colorTableSize = bytes[offset + 9] & 0x07; + offset += 10 + (hasColorTable ? 3 * 2 ** (colorTableSize + 1) : 0); + offset += gifBlocksLength(bytes, offset + 1) + 1; + break; + case 0x21: + offset += 2; + offset += gifBlocksLength(bytes, offset); + break; + default: + // The trailer (0x3b) or anything unexpected ends the scan. + offset = bytes.length; + } + } + return images > 1; +} + +function isAnimatedPng(bytes: Uint8Array): boolean { + let hasAnimationControl = false; + let hasDefaultImage = false; + let hasFrameData = false; + let previous: string | null = null; + let offset = 8; + // A truncated chunk header ends the scan (is-animated throws a RangeError there). + while (offset + 8 <= bytes.length) { + const length = readUInt32BE(bytes, offset); + const type = ascii(bytes, offset + 4, offset + 8); + switch (type) { + case 'acTL': + hasAnimationControl = true; + break; + case 'IDAT': + if (!hasAnimationControl || (previous !== 'fcTL' && previous !== 'IDAT')) { + return false; + } + hasDefaultImage = true; + break; + case 'fdAT': + if (!hasDefaultImage || (previous !== 'fcTL' && previous !== 'fdAT')) { + return false; + } + hasFrameData = true; + break; + } + previous = type; + offset += 4 + 4 + length + 4; + } + return hasAnimationControl && hasDefaultImage && hasFrameData; +} + +function isAnimatedWebp(bytes: Uint8Array): boolean { + for (let index = 0; index + 4 <= bytes.length; index++) { + if ( + bytes[index] === 0x41 && + bytes[index + 1] === 0x4e && + bytes[index + 2] === 0x49 && + bytes[index + 3] === 0x4d + ) { + return true; + } + } + return false; +} + +/** + * Whether a GIF has more than one image, a PNG is an APNG with frame data, + * or a WebP contains an `ANIM` chunk (`is-animated` rules). + */ +export function isAnimated(bytes: Uint8Array): boolean { + if (ascii(bytes, 0, 3) === 'GIF') { + return isAnimatedGif(bytes); + } + if (hex(bytes, 0, 8) === '89504e470d0a1a0a') { + return isAnimatedPng(bytes); + } + if (ascii(bytes, 8, 12) === 'WEBP') { + return isAnimatedWebp(bytes); + } + return false; +} diff --git a/packages/void/src/image/optimizer/handler.ts b/packages/void/src/image/optimizer/handler.ts new file mode 100644 index 000000000..a7e022e9c --- /dev/null +++ b/packages/void/src/image/optimizer/handler.ts @@ -0,0 +1,364 @@ +import type { ImageFormat, ResolvedImageConfig } from '../config.ts'; +import { getSupportedImageFormat } from './accept.ts'; +import { + ANIMATABLE_TYPES, + AVIF, + BYPASS_TYPES, + detectContentType, + isAnimated, + JPEG, + PNG, + WEBP, +} from './content-type.ts'; +import { + getContentDisposition, + getFileNameWithExtension, + getImageEtag, + getMaxAge, + isFreshRequest, +} from './headers.ts'; +import { ImageError, NOT_AN_IMAGE, TYPE_NOT_ALLOWED } from './image-error.ts'; +import { + fetchRemoteImage, + loadLocalImage, + type AssetFetcher, + type ImageFetch, + type ImageUpstream, +} from './upstream.ts'; +import { validateImageParams, type ImageParams } from './validate.ts'; + +/** Formats the Images binding can encode. Other output types serve the original. */ +type OutputFormat = 'image/jpeg' | 'image/png' | 'image/webp' | 'image/avif'; +const OUTPUT_FORMATS: ReadonlyArray = [JPEG, PNG, WEBP, AVIF]; + +/** The part of the Cloudflare Images binding the endpoint uses. */ +export type ImageTransformerLike = { + transform(transform: { width: number; fit: 'scale-down' }): ImageTransformerLike; + output(options: { + format: OutputFormat; + quality: number; + }): Promise<{ image(): ReadableStream }>; +}; +export type ImagesBindingLike = { + input(stream: ReadableStream): ImageTransformerLike; +}; + +/** The part of the Workers Cache API the endpoint uses. */ +export type ImageCacheLike = { + match(request: Request): Promise; + put(request: Request, response: Response): Promise; +}; + +export type ImageRequestOptions = { + config: ResolvedImageConfig; + /** Compile-time deployment id; scopes cached local sources to one deployment. */ + deploymentId: string; + /** Static assets binding local sources load from. */ + assets?: AssetFetcher; + /** Images binding; without it, sources that need a transform are served unchanged. */ + binding?: ImagesBindingLike; + /** Binding name for the missing-binding warning. */ + bindingName?: string; + /** Response cache (`caches.default`). Ignored in development. */ + cache?: ImageCacheLike; + /** Keeps cache writes alive after the response; nothing is cached without it. */ + waitUntil?: (promise: Promise) => void; + /** Whether a local source path is a fingerprinted build asset. */ + isImmutable?: (pathname: string) => boolean | Promise; + /** `fetch` for remote sources. */ + fetch?: ImageFetch; + /** Remote source timeout in milliseconds (default 7000). */ + timeoutMs?: number; +}; + +type ImageResult = { + bytes: Uint8Array; + contentType: string; + /** Opaque validator, sent quoted. */ + etag: string; + maxAge: number; +}; + +/** Next.js's `Cache-Control` for statically imported (fingerprinted) images. */ +const IMMUTABLE_CACHE_CONTROL = 'public, max-age=315360000, immutable'; +/** Bump when the stored entry format changes. */ +const CACHE_VERSION = '1'; +const CACHE_MAX_AGE_HEADER = 'X-Void-Image-Max-Age'; +const IMMUTABLE_CACHE_TTL = 31536000; + +const warned = new Set(); +/** One warning per key per isolate; bounded, because animated-image keys include the source. */ +function warnOnce(key: string, message: string): void { + if (warned.has(key) || warned.size >= 256) { + return; + } + warned.add(key); + console.warn(message); +} + +function warnTransformFailure(error: unknown, path: string): void { + const code = (error as { code?: unknown } | null)?.code; + if (code === 9422) { + warnOnce( + 'code:9422', + `[void] The Cloudflare Images Free allowance is used up, so ${path} serves the original image for new sizes until next month (error 9422).`, + ); + return; + } + const message = (error as { message?: unknown } | null)?.message; + const detail = typeof message === 'string' ? message : String(error); + warnOnce( + `code:${typeof code === 'number' ? code : 'unknown'}`, + typeof code === 'number' + ? `[void] Cloudflare Images could not transform an image (error ${code}: ${detail}), so ${path} serves the original image.` + : `[void] Image transformation failed (${detail}), so ${path} serves the original image.`, + ); +} + +const text = (status: number, message: string): Response => new Response(message, { status }); + +function cacheKey( + requestUrl: URL, + params: ImageParams, + format: ImageFormat | '', + options: ImageRequestOptions, +): Request { + // Built only from validated values. The Cache API ignores `Vary`, so the + // negotiated format is part of the key; local sources change with each + // deployment, remote ones do not. + const key = new URL(options.config.path, requestUrl.origin); + key.searchParams.set('url', params.href); + key.searchParams.set('w', String(params.width)); + key.searchParams.set('q', String(params.quality)); + key.searchParams.set('__void_img_fmt', format || 'auto'); + key.searchParams.set('__void_img_v', CACHE_VERSION); + if (!params.isAbsolute) { + key.searchParams.set('__void_img_dpl', options.deploymentId); + } + return new Request(key); +} + +async function readCache(cache: ImageCacheLike, key: Request): Promise { + // Fail open: any Cache API problem is a miss. + try { + const response = await cache.match(key); + if (!response) { + return undefined; + } + const contentType = response.headers.get('content-type'); + const etag = response.headers.get('etag'); + const maxAge = response.headers.get(CACHE_MAX_AGE_HEADER); + if (!response.ok || !contentType || !etag || !maxAge || !/^\d+$/.test(maxAge)) { + await response.body?.cancel(); + return undefined; + } + const bytes = new Uint8Array(await response.arrayBuffer()); + if (bytes.byteLength === 0) { + return undefined; + } + return { bytes, contentType, etag: etag.replace(/^"|"$/g, ''), maxAge: Number(maxAge) }; + } catch { + return undefined; + } +} + +function writeCache( + options: ImageRequestOptions, + key: Request, + result: ImageResult, + isStatic: boolean, +): void { + const ttl = isStatic ? IMMUTABLE_CACHE_TTL : result.maxAge; + if (!options.cache || !options.waitUntil || ttl <= 0) { + return; + } + const entry = new Response(result.bytes, { + headers: { + 'Content-Type': result.contentType, + 'Cache-Control': `public, max-age=${ttl}`, + ETag: `"${result.etag}"`, + [CACHE_MAX_AGE_HEADER]: String(result.maxAge), + }, + }); + let write: Promise; + try { + write = options.cache.put(key, entry).catch(() => {}); + } catch { + return; + } + options.waitUntil(write); +} + +async function optimize( + upstream: ImageUpstream, + params: ImageParams, + format: ImageFormat | '', + options: ImageRequestOptions, +): Promise { + const { config } = options; + const maxAge = Math.max(config.minimumCacheTTL, getMaxAge(upstream.cacheControl)); + const type = detectContentType(upstream.bytes); + if (!type || !type.startsWith('image/') || type.includes(',')) { + throw new ImageError(400, NOT_AN_IMAGE); + } + if (type.startsWith('image/svg') && !config.dangerouslyAllowSVG) { + throw new ImageError(400, TYPE_NOT_ALLOWED); + } + const original: ImageResult = { + bytes: upstream.bytes, + contentType: type, + etag: upstream.etag, + maxAge, + }; + if (ANIMATABLE_TYPES.includes(type) && isAnimated(upstream.bytes)) { + warnOnce( + `animated:${params.href}`, + `[void] The requested resource "${params.href}" is an animated image so it will not be optimized. Consider adding the "unoptimized" property to the .`, + ); + return original; + } + if (BYPASS_TYPES.includes(type)) { + return original; + } + // Without a negotiated format, keep the source type, except that WebP and + // AVIF fall back to JPEG for clients that did not ask for them. + const output = format || (type === WEBP || type === AVIF ? JPEG : type); + if (!OUTPUT_FORMATS.includes(output)) { + // The binding cannot encode GIF (locally), TIFF or JPEG 2000. + return original; + } + // Next.js serves the original with `minimumCacheTTL` when its encoder fails. + const fallback: ImageResult = { ...original, maxAge: config.minimumCacheTTL }; + if (!options.binding) { + warnOnce( + 'binding', + `[void] Image optimization is unavailable because the Worker has no Images binding (env.${options.bindingName ?? 'IMAGES'}), so ${config.path} serves original images.`, + ); + return fallback; + } + try { + // Quality is passed through unchanged: Next.js's AVIF quality scaling is + // tuned for sharp's encoder, not Cloudflare's. `scale-down` never enlarges. + const result = await options.binding + .input(new Response(upstream.bytes).body as ReadableStream) + .transform({ width: params.width, fit: 'scale-down' }) + .output({ format: output as OutputFormat, quality: params.quality }); + const bytes = new Uint8Array(await new Response(result.image()).arrayBuffer()); + if (bytes.byteLength === 0) { + throw new Error('empty output'); + } + return { bytes, contentType: output, etag: await getImageEtag(bytes), maxAge }; + } catch (error) { + // Binding errors carry a numeric `code` (9422 when the Free allowance is + // used up) but no global class to test against. The source stream is + // spent, so the buffered bytes are served instead. + warnTransformFailure(error, config.path); + return fallback; + } +} + +function respond( + request: Request, + href: string, + result: ImageResult, + isStatic: boolean, + cacheStatus: 'HIT' | 'MISS', + config: ResolvedImageConfig, +): Response { + const headers = new Headers(); + headers.set('Vary', 'Accept'); + headers.set( + 'Cache-Control', + isStatic + ? IMMUTABLE_CACHE_CONTROL + : `public, max-age=${config.dev ? 0 : result.maxAge}, must-revalidate`, + ); + // Next.js sends the tag unquoted; Void quotes it as RFC 9110 requires. + headers.set('ETag', `"${result.etag}"`); + if (isFreshRequest(request.headers, result.etag)) { + return new Response(null, { status: 304, headers }); + } + headers.set('Content-Type', result.contentType); + headers.set( + 'Content-Disposition', + getContentDisposition( + getFileNameWithExtension(href, result.contentType), + config.contentDispositionType, + ), + ); + headers.set('Content-Security-Policy', config.contentSecurityPolicy); + headers.set('X-Content-Type-Options', 'nosniff'); + headers.set('X-Void-Image-Cache', cacheStatus); + headers.set('Content-Length', String(result.bytes.byteLength)); + return new Response(request.method === 'HEAD' ? null : result.bytes, { status: 200, headers }); +} + +/** + * Serve one `/_void/image` request with Next.js `/_next/image` semantics: + * validate `url`/`w`/`q` (400 with Next's messages), negotiate the output + * format from `Accept`, answer from the cache, load the source (static + * assets or an allowed remote URL), sniff it, resize it with the Images + * binding and respond with Next's caching headers. + * + * Void differences: `GET`/`HEAD` only (405 otherwise), a quoted `ETag`, + * `X-Content-Type-Options: nosniff`, `X-Void-Image-Cache` instead of + * `X-Nextjs-Cache`, no stale-while-revalidate (the Cache API has none), and + * any binding failure serves the original image. + */ +export async function handleImageRequest( + request: Request, + options: ImageRequestOptions, +): Promise { + const { config } = options; + if (request.method !== 'GET' && request.method !== 'HEAD') { + return new Response('Method Not Allowed', { status: 405, headers: { Allow: 'GET, HEAD' } }); + } + if (config.unoptimized || config.loader !== 'default' || config.loaderFile) { + return text(404, 'Not Found'); + } + const requestUrl = new URL(request.url); + const params = validateImageParams(requestUrl.searchParams, config); + if ('errorMessage' in params) { + return text(400, params.errorMessage); + } + const format = getSupportedImageFormat( + config.formats, + request.headers.get('accept'), + params.width, + ); + try { + let isStatic = false; + if (!params.isAbsolute && options.isImmutable) { + try { + isStatic = await options.isImmutable(new URL(params.href, 'http://n').pathname); + } catch { + isStatic = false; + } + } + const key = + options.cache && !config.dev ? cacheKey(requestUrl, params, format, options) : undefined; + const cached = key ? await readCache(options.cache!, key) : undefined; + if (cached) { + return respond(request, params.href, cached, isStatic, 'HIT', config); + } + const upstream = params.isAbsolute + ? await fetchRemoteImage(params.href, { + dangerouslyAllowLocalIP: config.dangerouslyAllowLocalIP, + maximumResponseBody: config.maximumResponseBody, + maximumRedirects: config.maximumRedirects, + fetch: options.fetch, + timeoutMs: options.timeoutMs, + }) + : await loadLocalImage(options.assets, params.href, request.url, config.maximumResponseBody); + const result = await optimize(upstream, params, format, options); + if (key) { + writeCache(options, key, result, isStatic); + } + return respond(request, params.href, result, isStatic, 'MISS', config); + } catch (error) { + if (error instanceof ImageError) { + return text(error.statusCode, error.message); + } + throw error; + } +} diff --git a/packages/void/src/image/optimizer/headers.ts b/packages/void/src/image/optimizer/headers.ts new file mode 100644 index 000000000..f63198557 --- /dev/null +++ b/packages/void/src/image/optimizer/headers.ts @@ -0,0 +1,121 @@ +import { bytesToBase64 } from '../../runtime/base64.ts'; +import { getImageExtension } from './content-type.ts'; + +/** + * Seconds from an upstream `Cache-Control`: `s-maxage`, else `max-age`, else + * `0` (Next.js `getMaxAge`). `no-store`, `private` and `no-cache` are ignored, + * so the endpoint's `minimumCacheTTL` still applies. + */ +export function getMaxAge(cacheControl: string | null | undefined): number { + const directives = new Map(); + for (const directive of cacheControl ? cacheControl.split(',') : []) { + const [key = '', value] = directive.trim().split('=', 2); + directives.set(key.toLowerCase(), value?.toLowerCase()); + } + let age = directives.get('s-maxage') || directives.get('max-age') || ''; + if (age.startsWith('"') && age.endsWith('"')) { + age = age.slice(1, -1); + } + const seconds = parseInt(age, 10); + return Number.isNaN(seconds) ? 0 : seconds; +} + +const toBase64Url = (bytes: Uint8Array): string => + bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); + +/** SHA-256 of `bytes`, base64url without padding (Next.js `getImageEtag`). */ +export async function getImageEtag(bytes: Uint8Array): Promise { + return toBase64Url(new Uint8Array(await crypto.subtle.digest('SHA-256', bytes))); +} + +/** + * The upstream `ETag` header encoded as base64url (so weak validators stay + * opaque), or the SHA-256 of the body when the upstream sent none. + */ +export async function extractEtag( + etag: string | null | undefined, + bytes: Uint8Array, +): Promise { + return etag ? toBase64Url(new TextEncoder().encode(etag)) : getImageEtag(bytes); +} + +/** + * Whether a conditional request can be answered with `304` for the opaque + * entity tag `etag` (the `fresh` rules Next.js uses). Void sends the tag + * quoted, so a quoted, unquoted or weak `If-None-Match` member matches; + * `*` matches anything and `Cache-Control: no-cache` never does. The endpoint + * sends no `Last-Modified`, so `If-Modified-Since` never makes a request fresh. + */ +export function isFreshRequest(headers: Headers, etag: string): boolean { + const modifiedSince = headers.get('if-modified-since'); + const noneMatch = headers.get('if-none-match'); + if (!modifiedSince && !noneMatch) { + return false; + } + const cacheControl = headers.get('cache-control'); + if (cacheControl && /(?:^|,)\s*?no-cache\s*?(?:,|$)/.test(cacheControl)) { + return false; + } + if (noneMatch && noneMatch.trim() !== '*') { + const matches = noneMatch.split(',').some((member) => { + let tag = member.trim(); + if (tag.startsWith('W/')) { + tag = tag.slice(2); + } + if (tag.length >= 2 && tag.startsWith('"') && tag.endsWith('"')) { + tag = tag.slice(1, -1); + } + return tag === etag; + }); + if (!matches) { + return false; + } + } + return !modifiedSince; +} + +/** + * The download name for a response: the last path segment of the source URL + * up to its first dot, plus the served type's extension (`my.photo.png` as + * WebP is `my.webp`). `image.bin` when the URL ends in `/`. + */ +export function getFileNameWithExtension(url: string, contentType: string): string { + const [path = ''] = url.split('?', 1); + const name = path.split('/').pop(); + if (!contentType || !name) { + return 'image.bin'; + } + const [base] = name.split('.', 1); + return `${base}.${getImageExtension(contentType)}`; +} + +const NON_LATIN1 = /[^\x20-\x7e\xa0-\xff]/g; +const QUOTED_TEXT = /^[\x20-\x7e\x80-\xff]+$/; +const HEX_ESCAPE = /%[0-9A-Fa-f]{2}/; +// oxlint-disable-next-line no-control-regex -- RFC 8187 percent-encodes control characters +const ATTRIBUTE_ESCAPES = /[\x00-\x20"'()*,/:;<=>?@[\\\]{}\x7f]/g; + +/** + * `Content-Disposition` for `fileName`, formatted like the `content-disposition` + * package Next.js uses: a quoted `filename` with non-Latin-1 characters + * replaced by `?`, plus an RFC 8187 `filename*` when that fallback differs, + * the name is not plain text, or it contains `%XX` sequences. + */ +export function getContentDisposition(fileName: string, type: 'inline' | 'attachment'): string { + const fallback = fileName.replace(NON_LATIN1, '?'); + const hasFallback = fallback !== fileName; + const quotable = QUOTED_TEXT.test(fileName); + let header = type; + if (quotable || hasFallback) { + header += `; filename="${(hasFallback ? fallback : fileName).replace(/([\\"])/g, '\\$1')}"`; + } + if (hasFallback || !quotable || HEX_ESCAPE.test(fileName)) { + // A lone surrogate cannot be percent-encoded; its fallback still can. + const encoded = encodeURIComponent(fileName.replace(/[\ud800-\udfff]/gu, '?')).replace( + ATTRIBUTE_ESCAPES, + (character) => '%' + character.charCodeAt(0).toString(16).toUpperCase(), + ); + header += `; filename*=UTF-8''${encoded}`; + } + return header; +} diff --git a/packages/void/src/image/optimizer/image-error.ts b/packages/void/src/image/optimizer/image-error.ts new file mode 100644 index 000000000..833559e2a --- /dev/null +++ b/packages/void/src/image/optimizer/image-error.ts @@ -0,0 +1,20 @@ +/** + * A failure the endpoint answers with `statusCode` and `message` as a plain + * text body (Next.js `ImageError`). Statuses below 400 become 500. + */ +export class ImageError extends Error { + readonly statusCode: number; + + constructor(statusCode: number, message: string) { + super(message); + this.name = 'ImageError'; + this.statusCode = statusCode >= 400 ? statusCode : 500; + } +} + +export const NOT_ALLOWED = '"url" parameter is not allowed'; +export const UPSTREAM_INVALID = '"url" parameter is valid but upstream response is invalid'; +export const UPSTREAM_TIMED_OUT = '"url" parameter is valid but upstream response timed out'; +export const INTERNAL_INVALID = '"url" parameter is valid but internal response is invalid'; +export const NOT_AN_IMAGE = "The requested resource isn't a valid image."; +export const TYPE_NOT_ALLOWED = '"url" parameter is valid but image type is not allowed'; diff --git a/packages/void/src/image/optimizer/index.ts b/packages/void/src/image/optimizer/index.ts new file mode 100644 index 000000000..65b495b68 --- /dev/null +++ b/packages/void/src/image/optimizer/index.ts @@ -0,0 +1,41 @@ +/** + * The `/_void/image` optimizer: a Worker-safe port of Next.js 16's + * `/_next/image` endpoint backed by the Cloudflare Images binding. Each + * module mirrors one part of Next.js's `image-optimizer` so it can be tested + * on its own; `runtime/routing-image.ts` mounts `handleImageRequest`. + */ +export { getSupportedImageFormat } from './accept.ts'; +export { + ANIMATABLE_TYPES, + BYPASS_TYPES, + detectContentType, + getImageExtension, + isAnimated, +} from './content-type.ts'; +export { + handleImageRequest, + type ImageCacheLike, + type ImageRequestOptions, + type ImagesBindingLike, + type ImageTransformerLike, +} from './handler.ts'; +export { + extractEtag, + getContentDisposition, + getFileNameWithExtension, + getImageEtag, + getMaxAge, + isFreshRequest, +} from './headers.ts'; +export { ImageError } from './image-error.ts'; +export { isPrivateIpLiteral } from './private-ip.ts'; +export { + fetchRemoteImage, + loadLocalImage, + UPSTREAM_TIMEOUT_MS, + type AssetFetcher, + type ImageFetch, + type ImageUpstream, + type RemoteImageOptions, +} from './upstream.ts'; +export { validateImageParams, type ImageParams, type ImageParamsResult } from './validate.ts'; diff --git a/packages/void/src/image/optimizer/private-ip.ts b/packages/void/src/image/optimizer/private-ip.ts new file mode 100644 index 000000000..0ee68f4db --- /dev/null +++ b/packages/void/src/image/optimizer/private-ip.ts @@ -0,0 +1,145 @@ +/** + * Private-address checks for remote image hosts. Next.js resolves hostnames + * and rejects any address `ipaddr.js` does not classify as `unicast`. Workers + * cannot resolve DNS, so Void checks the literal host after WHATWG URL + * normalization (which already turns `0x7f.1` and `2130706433` into + * `127.0.0.1`), plus `localhost` and `*.localhost`, which a dev server reaches. + */ + +type Range = readonly [prefix: ReadonlyArray, bits: number]; + +// ipaddr.js 2.x IPv4 special ranges: unspecified, broadcast, multicast, +// linkLocal, loopback, carrierGradeNat, private, reserved, as112 and amt. +const IPV4_RANGES: ReadonlyArray = [ + [[0, 0, 0, 0], 8], + [[255, 255, 255, 255], 32], + [[224, 0, 0, 0], 4], + [[169, 254, 0, 0], 16], + [[127, 0, 0, 0], 8], + [[100, 64, 0, 0], 10], + [[10, 0, 0, 0], 8], + [[172, 16, 0, 0], 12], + [[192, 168, 0, 0], 16], + [[192, 0, 0, 0], 24], + [[192, 0, 2, 0], 24], + [[192, 88, 99, 0], 24], + [[198, 18, 0, 0], 15], + [[198, 51, 100, 0], 24], + [[203, 0, 113, 0], 24], + [[240, 0, 0, 0], 4], + [[192, 175, 48, 0], 24], + [[192, 31, 196, 0], 24], + [[192, 52, 193, 0], 24], +]; + +// ipaddr.js 2.x IPv6 special ranges. `::/96` (IPv4-compatible, deprecated) is +// a Void addition: those hosts are classified like the IPv4 address they embed. +const IPV6_RANGES: ReadonlyArray = [ + [[0, 0, 0, 0, 0, 0, 0, 0], 128], + [[0xfe80, 0, 0, 0, 0, 0, 0, 0], 10], + [[0xff00, 0, 0, 0, 0, 0, 0, 0], 8], + [[0, 0, 0, 0, 0, 0, 0, 1], 128], + [[0xfc00, 0, 0, 0, 0, 0, 0, 0], 7], + [[0x100, 0, 0, 0, 0, 0, 0, 0], 64], + [[0, 0, 0, 0, 0xffff, 0, 0, 0], 96], + [[0x64, 0xff9b, 0, 0, 0, 0, 0, 0], 96], + [[0x2002, 0, 0, 0, 0, 0, 0, 0], 16], + [[0x2001, 0, 0, 0, 0, 0, 0, 0], 32], + [[0x2001, 2, 0, 0, 0, 0, 0, 0], 48], + [[0x2001, 3, 0, 0, 0, 0, 0, 0], 32], + [[0x2001, 4, 0x112, 0, 0, 0, 0, 0], 48], + [[0x2620, 0x4f, 0x8000, 0, 0, 0, 0, 0], 48], + [[0x2001, 0x10, 0, 0, 0, 0, 0, 0], 28], + [[0x2001, 0x20, 0, 0, 0, 0, 0, 0], 28], + [[0x2001, 0x30, 0, 0, 0, 0, 0, 0], 28], + [[0x2001, 0, 0, 0, 0, 0, 0, 0], 23], + [[0x2001, 0xdb8, 0, 0, 0, 0, 0, 0], 32], +]; + +function inRange(parts: ReadonlyArray, [prefix, bits]: Range, partBits: number): boolean { + let remaining = bits; + for (let index = 0; remaining > 0; index++, remaining -= partBits) { + const shift = partBits - Math.min(remaining, partBits); + if (parts[index] >> shift !== prefix[index] >> shift) { + return false; + } + } + return true; +} + +function parseIpv4(value: string): Array | undefined { + const parts = value.split('.'); + if (parts.length !== 4 || !parts.every((part) => /^\d{1,3}$/.test(part) && Number(part) < 256)) { + return undefined; + } + return parts.map(Number); +} + +function parseIpv6(value: string): Array | undefined { + let address = value; + // URL hosts are already in hex form; a dotted IPv4 tail is accepted for direct callers. + const dotted = /^(.*:)([^:]+\.[^:]+)$/.exec(address); + if (dotted) { + const ipv4 = parseIpv4(dotted[2]); + if (!ipv4) { + return undefined; + } + const high = ((ipv4[0] << 8) | ipv4[1]).toString(16); + const low = ((ipv4[2] << 8) | ipv4[3]).toString(16); + address = `${dotted[1]}${high}:${low}`; + } + const halves = address.split('::'); + if (halves.length > 2) { + return undefined; + } + const parse = (half: string): Array | undefined => { + if (half === '') { + return []; + } + const groups = half.split(':'); + return groups.every((group) => /^[0-9a-f]{1,4}$/i.test(group)) + ? groups.map((group) => parseInt(group, 16)) + : undefined; + }; + const head = parse(halves[0]); + const tail = halves.length === 2 ? parse(halves[1]) : []; + if (!head || !tail) { + return undefined; + } + if (halves.length === 1) { + return head.length === 8 ? head : undefined; + } + const fill = 8 - head.length - tail.length; + return fill >= 1 ? [...head, ...Array(fill).fill(0), ...tail] : undefined; +} + +/** + * Whether a URL hostname is `localhost`, `*.localhost`, or a literal IPv4 or + * IPv6 address outside the public unicast space. IPv4-mapped IPv6 addresses + * are checked as the IPv4 address they carry. Other hostnames return `false`. + */ +export function isPrivateIpLiteral(hostname: string): boolean { + const host = hostname.toLowerCase().replace(/\.$/, ''); + if (host === 'localhost' || host.endsWith('.localhost')) { + return true; + } + const ipv4 = parseIpv4(host); + if (ipv4) { + return IPV4_RANGES.some((range) => inRange(ipv4, range, 8)); + } + if (!host.startsWith('[') || !host.endsWith(']')) { + return false; + } + const ipv6 = parseIpv6(host.slice(1, -1)); + if (!ipv6) { + return false; + } + if (ipv6.slice(0, 5).every((part) => part === 0) && (ipv6[5] === 0xffff || ipv6[5] === 0)) { + // IPv4-mapped (`::ffff:a.b.c.d`) or IPv4-compatible (`::a.b.c.d`). + if (ipv6[5] === 0xffff || ipv6[6] !== 0 || ipv6[7] > 1) { + const embedded = [ipv6[6] >> 8, ipv6[6] & 0xff, ipv6[7] >> 8, ipv6[7] & 0xff]; + return IPV4_RANGES.some((range) => inRange(embedded, range, 8)); + } + } + return IPV6_RANGES.some((range) => inRange(ipv6, range, 16)); +} diff --git a/packages/void/src/image/optimizer/upstream.ts b/packages/void/src/image/optimizer/upstream.ts new file mode 100644 index 000000000..d046d1b21 --- /dev/null +++ b/packages/void/src/image/optimizer/upstream.ts @@ -0,0 +1,216 @@ +import { extractEtag } from './headers.ts'; +import { + ImageError, + INTERNAL_INVALID, + NOT_ALLOWED, + UPSTREAM_INVALID, + UPSTREAM_TIMED_OUT, +} from './image-error.ts'; +import { isPrivateIpLiteral } from './private-ip.ts'; + +/** A source image, fully buffered. */ +export type ImageUpstream = { + bytes: Uint8Array; + /** Upstream `Content-Type`; informational only, the bytes are sniffed. */ + contentType: string | null; + cacheControl: string | null; + /** Opaque validator: the base64url upstream `ETag`, or the SHA-256 of the bytes. */ + etag: string; +}; + +export type ImageFetch = (input: string, init: RequestInit) => Promise; + +export type AssetFetcher = { fetch: (request: Request) => Promise }; + +/** Next.js's upstream request timeout, covering headers and body. */ +export const UPSTREAM_TIMEOUT_MS = 7000; + +const REDIRECT_STATUSES = [301, 302, 303, 307, 308]; + +const canParse = (location: string, base: string): boolean => { + try { + new URL(location, base); + return true; + } catch { + return false; + } +}; + +const discard = (response: Response): void => { + response.body?.cancel().catch(() => {}); +}; + +/** + * Read `body` into one array, failing with `413` once more than `limit` + * bytes arrive. `signal` cancels the read (Next.js's timeout also covers the body). + */ +async function readBody( + body: ReadableStream | null, + limit: number, + message: string, + signal?: AbortSignal, +): Promise> { + if (!body) { + return new Uint8Array(0); + } + const reader = body.getReader(); + const chunks: Array = []; + let total = 0; + const abort = (): void => { + reader.cancel(signal?.reason).catch(() => {}); + }; + signal?.addEventListener('abort', abort); + if (signal?.aborted) { + abort(); + } + try { + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + total += value.byteLength; + if (total > limit) { + reader.cancel().catch(() => {}); + throw new ImageError(413, message); + } + chunks.push(value); + } + } finally { + signal?.removeEventListener('abort', abort); + } + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return bytes; +} + +export type RemoteImageOptions = { + dangerouslyAllowLocalIP: boolean; + maximumResponseBody: number; + /** Redirects still allowed; each followed redirect lowers it by one. */ + maximumRedirects: number; + fetch?: ImageFetch; + timeoutMs?: number; +}; + +/** + * Fetch an allowed remote source like Next.js's `fetchExternalImage`: no + * client headers or cookies, manual redirects (each hop checked again for a + * private host, `508` past `maximumRedirects`), a 7-second timeout (`504`), a + * streamed body limit (`413`), and the upstream status for any non-2xx answer. + * Workers decode `Content-Encoding` themselves, so the limit applies to the + * decoded bytes as in Next.js. + */ +export async function fetchRemoteImage( + href: string, + options: RemoteImageOptions, +): Promise { + const url = new URL(href); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new ImageError(500, UPSTREAM_INVALID); + } + if (!options.dangerouslyAllowLocalIP && isPrivateIpLiteral(url.hostname)) { + throw new ImageError(400, NOT_ALLOWED); + } + const signal = AbortSignal.timeout(options.timeoutMs ?? UPSTREAM_TIMEOUT_MS); + const request = options.fetch ?? ((input, init) => fetch(input, init)); + let response: Response; + try { + response = await request(url.href, { + method: 'GET', + redirect: 'manual', + signal, + headers: { accept: '*/*', 'accept-language': '*', 'sec-fetch-mode': 'cors' }, + }); + } catch { + if (signal.aborted) { + throw new ImageError(504, UPSTREAM_TIMED_OUT); + } + throw new ImageError(500, UPSTREAM_INVALID); + } + const location = response.headers.get('location'); + if (REDIRECT_STATUSES.includes(response.status) && location && canParse(location, url.href)) { + // The body of a redirect is never read: only the final response is size-limited. + discard(response); + if (options.maximumRedirects <= 0) { + throw new ImageError(508, UPSTREAM_INVALID); + } + return fetchRemoteImage(new URL(location, url.href).href, { + ...options, + maximumRedirects: options.maximumRedirects - 1, + }); + } + if (response.status < 200 || response.status > 299) { + discard(response); + throw new ImageError(response.status, UPSTREAM_INVALID); + } + let bytes: Uint8Array; + try { + bytes = await readBody(response.body, options.maximumResponseBody, UPSTREAM_INVALID, signal); + } catch (error) { + if (error instanceof ImageError) { + throw error; + } + if (signal.aborted) { + throw new ImageError(504, UPSTREAM_TIMED_OUT); + } + throw new ImageError(500, UPSTREAM_INVALID); + } + if (signal.aborted) { + throw new ImageError(504, UPSTREAM_TIMED_OUT); + } + if (bytes.byteLength === 0) { + throw new ImageError(400, UPSTREAM_INVALID); + } + return { + bytes, + contentType: response.headers.get('content-type'), + cacheControl: response.headers.get('cache-control'), + etag: await extractEtag(response.headers.get('etag'), bytes), + }; +} + +/** + * Load a local source from the static assets binding with an empty header + * set (`GET`, no cookies or conditional headers), like Next.js's internal + * image request. Application routes are not consulted. + */ +export async function loadLocalImage( + assets: AssetFetcher | undefined, + href: string, + requestUrl: string, + maximumResponseBody: number, +): Promise { + if (!assets || typeof assets.fetch !== 'function') { + throw new ImageError(500, UPSTREAM_INVALID); + } + let response: Response; + try { + response = await assets.fetch(new Request(new URL(href, requestUrl))); + } catch { + throw new ImageError(500, UPSTREAM_INVALID); + } + if (response.status < 200 || response.status > 299) { + discard(response); + throw new ImageError(response.status, INTERNAL_INVALID); + } + let bytes: Uint8Array; + try { + bytes = await readBody(response.body, maximumResponseBody, INTERNAL_INVALID); + } catch (error) { + throw error instanceof ImageError ? error : new ImageError(500, UPSTREAM_INVALID); + } + if (bytes.byteLength === 0) { + throw new ImageError(400, INTERNAL_INVALID); + } + return { + bytes, + contentType: response.headers.get('content-type'), + cacheControl: response.headers.get('cache-control'), + etag: await extractEtag(response.headers.get('etag'), bytes), + }; +} diff --git a/packages/void/src/image/optimizer/validate.ts b/packages/void/src/image/optimizer/validate.ts new file mode 100644 index 000000000..db0a63a3b --- /dev/null +++ b/packages/void/src/image/optimizer/validate.ts @@ -0,0 +1,127 @@ +import type { ResolvedImageConfig } from '../config.ts'; +import { hasLocalMatch, hasRemoteMatch } from '../patterns.ts'; +import { NOT_ALLOWED } from './image-error.ts'; + +/** Validated `/_void/image` query parameters. */ +export type ImageParams = { + /** The local path (as requested) or the normalized absolute URL. */ + href: string; + isAbsolute: boolean; + width: number; + quality: number; +}; + +export type ImageParamsResult = ImageParams | { errorMessage: string }; + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +/** `/\/_void\/image($|\/)/` for the default path: a local source may not point back at the endpoint. */ +function recursionPattern(path: string): RegExp { + return new RegExp(`${escapeRegExp(path.replace(/\/+$/, ''))}($|/)`); +} + +/** Single value of a query key; `string[]` when it repeats (as Node's query parser reports it). */ +function queryValue(query: URLSearchParams, key: string): string | Array { + const values = query.getAll(key); + return values.length > 1 ? values : (values[0] ?? ''); +} + +/** + * Validate `url`, `w` and `q` in Next.js's order with its exact messages + * (`ImageOptimizerCache.validateParams`). Every `q` format check runs before + * the width allow-list; the quality range and allow-list run last. + * + * Differences from Next.js: a malformed `%` sequence in a local path is + * `"url" parameter is invalid` instead of a 500; a local path whose origin + * changes when resolved (`/\host`) is treated as protocol-relative; and there + * is no development allowance for `w <= 8` / `q=70`, which only Next.js's blur + * placeholder requests. + */ +export function validateImageParams( + query: URLSearchParams, + config: Pick< + ResolvedImageConfig, + 'allSizes' | 'qualities' | 'localPatterns' | 'remotePatterns' | 'domains' | 'path' + >, +): ImageParamsResult { + const url = queryValue(query, 'url'); + const w = queryValue(query, 'w'); + const q = queryValue(query, 'q'); + if (!url) { + return { errorMessage: '"url" parameter is required' }; + } else if (Array.isArray(url)) { + return { errorMessage: '"url" parameter cannot be an array' }; + } + if (url.length > 3072) { + return { errorMessage: '"url" parameter is too long' }; + } + if (url.startsWith('//')) { + return { errorMessage: '"url" parameter cannot be a protocol-relative URL (//)' }; + } + let href: string; + let isAbsolute: boolean; + if (url.startsWith('/')) { + href = url; + isAbsolute = false; + let pathname: string; + try { + const parsed = new URL(url, 'http://n'); + if (parsed.origin !== 'http://n') { + return { errorMessage: '"url" parameter cannot be a protocol-relative URL (//)' }; + } + pathname = decodeURIComponent(parsed.pathname); + } catch { + return { errorMessage: '"url" parameter is invalid' }; + } + if (recursionPattern(config.path).test(pathname)) { + return { errorMessage: '"url" parameter cannot be recursive' }; + } + if (!hasLocalMatch(config.localPatterns, url)) { + return { errorMessage: NOT_ALLOWED }; + } + } else { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return { errorMessage: '"url" parameter is invalid' }; + } + href = parsed.toString(); + isAbsolute = true; + if (!['http:', 'https:'].includes(parsed.protocol)) { + return { errorMessage: '"url" parameter is invalid' }; + } + if (!hasRemoteMatch(config.domains, config.remotePatterns, parsed)) { + return { errorMessage: NOT_ALLOWED }; + } + } + if (!w) { + return { errorMessage: '"w" parameter (width) is required' }; + } else if (Array.isArray(w)) { + return { errorMessage: '"w" parameter (width) cannot be an array' }; + } else if (!/^[0-9]+$/.test(w)) { + return { errorMessage: '"w" parameter (width) must be an integer greater than 0' }; + } + if (!q) { + return { errorMessage: '"q" parameter (quality) is required' }; + } else if (Array.isArray(q)) { + return { errorMessage: '"q" parameter (quality) cannot be an array' }; + } else if (!/^[0-9]+$/.test(q)) { + return { errorMessage: '"q" parameter (quality) must be an integer between 1 and 100' }; + } + const width = parseInt(w, 10); + if (width <= 0 || Number.isNaN(width)) { + return { errorMessage: '"w" parameter (width) must be an integer greater than 0' }; + } + if (!config.allSizes.includes(width)) { + return { errorMessage: `"w" parameter (width) of ${width} is not allowed` }; + } + const quality = parseInt(q, 10); + if (Number.isNaN(quality) || quality < 1 || quality > 100) { + return { errorMessage: '"q" parameter (quality) must be an integer between 1 and 100' }; + } + if (!config.qualities.includes(quality)) { + return { errorMessage: `"q" parameter (quality) of ${q} is not allowed` }; + } + return { href, isAbsolute, width, quality }; +} diff --git a/packages/void/src/image/patterns.ts b/packages/void/src/image/patterns.ts new file mode 100644 index 000000000..5e5e95c52 --- /dev/null +++ b/packages/void/src/image/patterns.ts @@ -0,0 +1,99 @@ +/** + * `images.remotePatterns` / `images.localPatterns` matching, ported from + * Next.js 16 (`match-remote-pattern.js`, `match-local-pattern.js`). + * + * Next.js runs picomatch at request time. Void compiles every glob to a + * `RegExp` source at config time (`compileRemotePattern` / + * `compileLocalPattern` in `image-config.ts`, as Next's `writeImagesManifest` + * does), so this module stays Worker- and browser-safe. + */ +import type { CompiledLocalPattern, CompiledRemotePattern } from './config.ts'; + +const compiled = new Map(); + +/** One `RegExp` per compiled source. The sources are anchored and have no `g` flag, so `test` is stateless. */ +function toRegExp(source: string): RegExp { + let re = compiled.get(source); + if (!re) { + re = new RegExp(source); + compiled.set(source, re); + } + return re; +} + +/** Whether `url` (a parsed absolute URL) matches one remote pattern. */ +export function matchRemotePattern(pattern: CompiledRemotePattern, url: URL): boolean { + if (pattern.protocol !== undefined) { + if (pattern.protocol.replace(/:$/, '') !== url.protocol.replace(/:$/, '')) { + return false; + } + } + if (pattern.port !== undefined) { + if (pattern.port !== url.port) { + return false; + } + } + if (pattern.hostname === undefined) { + throw new Error(`Pattern should define hostname but found\n${JSON.stringify(pattern)}`); + } else { + if (!toRegExp(pattern.hostnameRe).test(url.hostname)) { + return false; + } + } + if (pattern.search !== undefined) { + if (pattern.search !== url.search) { + return false; + } + } + if (!toRegExp(pattern.pathnameRe).test(url.pathname)) { + return false; + } + return true; +} + +/** Whether `url` matches `images.domains` (exact hostname) or any remote pattern. */ +export function hasRemoteMatch( + domains: ReadonlyArray, + remotePatterns: ReadonlyArray, + url: URL, +): boolean { + return ( + domains.some((domain) => url.hostname === domain) || + remotePatterns.some((p) => matchRemotePattern(p, url)) + ); +} + +/** Whether `url` (parsed against any base) matches one local pattern. */ +export function matchLocalPattern(pattern: CompiledLocalPattern, url: URL): boolean { + if (pattern.search !== undefined) { + if (pattern.search !== url.search) { + return false; + } + } + if (!toRegExp(pattern.pathnameRe).test(url.pathname)) { + return false; + } + return true; +} + +/** + * Whether a local `path?query` matches any local pattern. Without patterns + * every local path is allowed (Next.js semantics; resolved configs always + * carry the default `[{ pathname: '**', search: '' }]`). + */ +export function hasLocalMatch( + localPatterns: ReadonlyArray | undefined, + urlPathAndQuery: string, +): boolean { + if (!localPatterns) { + return true; + } + let url: URL; + try { + url = new URL(urlPathAndQuery, 'http://n'); + } catch { + // Next.js lets this throw. A value that is not a URL path matches nothing. + return false; + } + return localPatterns.some((p) => matchLocalPattern(p, url)); +} diff --git a/packages/void/src/image/registry.ts b/packages/void/src/image/registry.ts new file mode 100644 index 000000000..d763f5c70 --- /dev/null +++ b/packages/void/src/image/registry.ts @@ -0,0 +1,122 @@ +/** + * The config and loader registered by the Void Vite plugin + * (`virtual:void/image-config`), and the registry-aware entry points adapters + * and apps call. The registry lives on `globalThis` under a versioned + * `Symbol.for` key so duplicate copies of this module read the same values. + */ +import { defaultClientImageConfig } from './client-config.ts'; +import { IMAGE_REGISTRY_KEY, type ClientImageConfig } from './config.ts'; +import { getImgProps } from './get-img-props.ts'; +import { cloudflareLoader, defaultLoader } from './loader.ts'; +import type { + ImageCoreProps, + ImageLoader, + ImageLoaderWithConfig, + ImageRenderState, + ImgProps, + ImgPropsResult, +} from './types.ts'; + +/** + * What the plugin stores under `IMAGE_REGISTRY_KEY`. `loader` is the default + * export of `images.loaderFile`, or `undefined` without one. + */ +export type ImageConfigRegistration = { + config: ClientImageConfig; + loader?: ImageLoader | undefined; +}; + +let fallbackConfig: ClientImageConfig | undefined; +const inlinedConfigs = new WeakMap(); + +function getRegistration(): ImageConfigRegistration | undefined { + const registration = (globalThis as Record)[IMAGE_REGISTRY_KEY]; + return registration && typeof registration === 'object' + ? (registration as ImageConfigRegistration) + : undefined; +} + +/** + * The registered client config, or Next.js defaults (with `/_void/image`) + * when nothing is registered, e.g. in unit tests or outside Vite. + */ +export function getImageConfig(): ClientImageConfig { + const registered = getRegistration()?.config; + if (registered) { + return registered; + } + fallbackConfig ??= defaultClientImageConfig(); + return fallbackConfig; +} + +/** + * The loader an `Image` without a `loader` prop uses: the registered + * `images.loaderFile` export, else the Cloudflare loader for + * `loader: 'cloudflare'`, else the default loader. + */ +export function getDefaultImageLoader(config?: ClientImageConfig): ImageLoaderWithConfig { + const loader = getRegistration()?.loader; + if (typeof loader === 'function') { + return loader; + } + return (config ?? getImageConfig()).loader === 'cloudflare' ? cloudflareLoader : defaultLoader; +} + +/** + * `getImgProps` with the registered config and loader, for adapters. `state` + * is the component's `blurComplete` / `showAltText` state. + */ +export function resolveImageProps( + props: ImageCoreProps & Record, + state?: ImageRenderState, +): ImgPropsResult { + const imgConf = getImageConfig(); + return getImgProps(props, { + defaultLoader: getDefaultImageLoader(imgConf), + imgConf, + blurComplete: state?.blurComplete, + showAltText: state?.showAltText, + }); +} + +/** + * The options Next.js inlines into bundles for `getImageProps()`: patterns + * only in development, so production calls skip the loader's query-string + * check on the server too. + */ +function getInlinedImageConfig(config: ClientImageConfig): ClientImageConfig { + if (config.dev || (!config.localPatterns && !config.remotePatterns && !config.domains)) { + return config; + } + let inlined = inlinedConfigs.get(config); + if (!inlined) { + const { localPatterns: _local, remotePatterns: _remote, domains: _domains, ...rest } = config; + inlined = rest; + inlinedConfigs.set(config, inlined); + } + return inlined; +} + +/** + * The props `Image` would pass to its ``, for use with another element, + * a `` source or CSS (`next/image` `getImageProps`). Keys whose + * value is `undefined` are removed. + */ +export function getImageProps(props: ImageCoreProps & Record): { + props: ImgProps; +} { + const imgConf = getInlinedImageConfig(getImageConfig()); + const { props: imgProps } = getImgProps(props, { + defaultLoader: getDefaultImageLoader(imgConf), + imgConf, + }); + // Normally we don't care about undefined props because we pass to JSX, + // but this exported function could be used by the end user for anything + // so we delete undefined props to clean it up a little. + for (const [key, value] of Object.entries(imgProps)) { + if (value === undefined) { + delete imgProps[key]; + } + } + return { props: imgProps }; +} diff --git a/packages/void/src/image/warn-once.ts b/packages/void/src/image/warn-once.ts new file mode 100644 index 000000000..984ec4f50 --- /dev/null +++ b/packages/void/src/image/warn-once.ts @@ -0,0 +1,10 @@ +/** Messages already logged by `warnOnce` in this module instance. */ +const warnings = new Set(); + +/** `console.warn` a message once per module instance (Next.js `warnOnce`). Callers gate it on `config.dev`. */ +export function warnOnce(message: string): void { + if (!warnings.has(message)) { + console.warn(message); + } + warnings.add(message); +} diff --git a/packages/void/src/inference/vinext-images.ts b/packages/void/src/inference/vinext-images.ts new file mode 100644 index 000000000..8a0769de0 --- /dev/null +++ b/packages/void/src/inference/vinext-images.ts @@ -0,0 +1,137 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { walk } from 'estree-walker'; +import { join } from 'pathslash'; +import { parseSync } from 'vite'; +import { + isCallExpression, + isIdentifier, + isMemberExpression, + isObjectExpression, + type AstNode, +} from '../shared/ast.ts'; +import { staticLiteral } from '../shared/static-exports.ts'; + +/** The module that exports vinext's Cloudflare Images optimizer factory. */ +export const VINEXT_IMAGES_OPTIMIZER_MODULE = '@vinext/cloudflare/images/images-optimizer'; + +/** vinext's default `imagesOptimizer({ binding })`. */ +const VINEXT_DEFAULT_IMAGES_BINDING = 'IMAGES'; + +const VITE_CONFIG_FILES = [ + 'vite.config.ts', + 'vite.config.mts', + 'vite.config.js', + 'vite.config.mjs', + 'vite.config.cts', + 'vite.config.cjs', +]; + +/** The string-literal `binding` of an `imagesOptimizer({ binding })` options object. */ +function literalBinding(options: AstNode | undefined): string | undefined { + if (!isObjectExpression(options)) { + return undefined; + } + for (const property of options.properties ?? []) { + if (property.type !== 'Property' || property.computed === true) { + continue; + } + const key = property.key as AstNode | undefined; + const name = isIdentifier(key) ? key.name : staticLiteral(key); + if (name !== 'binding') { + continue; + } + const value = staticLiteral(property.value as AstNode | undefined); + return typeof value === 'string' && value !== '' ? value : undefined; + } + return undefined; +} + +/** + * Find vinext's Cloudflare Images optimizer in a Vite config's source: + * a call to `imagesOptimizer` imported from `@vinext/cloudflare/images/images-optimizer` + * (aliases and namespace imports included). Returns the Images binding it reads, + * from a string-literal `binding` option or vinext's default `IMAGES`. + */ +export function detectVinextImagesOptimizerInSource( + code: string, + filename: string, +): { binding: string } | undefined { + if (!code.includes(VINEXT_IMAGES_OPTIMIZER_MODULE)) { + return undefined; + } + let parsed: ReturnType; + try { + parsed = parseSync(filename, code); + } catch { + return undefined; + } + const factories = new Set(); + const namespaces = new Set(); + for (const item of parsed.module.staticImports) { + if (item.moduleRequest.value !== VINEXT_IMAGES_OPTIMIZER_MODULE) { + continue; + } + for (const entry of item.entries) { + if (entry.isType) { + continue; + } + if (entry.importName.kind === 'Name' && entry.importName.name === 'imagesOptimizer') { + factories.add(entry.localName.value); + } else if (entry.importName.kind === 'NamespaceObject') { + namespaces.add(entry.localName.value); + } + } + } + if (factories.size === 0 && namespaces.size === 0) { + return undefined; + } + + let found: { binding: string } | undefined; + walk(parsed.program as unknown as Parameters[0], { + enter(node) { + const current = node as AstNode; + if (found || !isCallExpression(current)) { + return; + } + const callee = current.callee; + const isFactory = + (isIdentifier(callee) && factories.has(callee.name)) || + (isMemberExpression(callee) && + !callee.computed && + isIdentifier(callee.object) && + namespaces.has(callee.object.name) && + isIdentifier(callee.property) && + callee.property.name === 'imagesOptimizer'); + if (isFactory) { + found = { + binding: literalBinding(current.arguments?.[0]) ?? VINEXT_DEFAULT_IMAGES_BINDING, + }; + this.skip(); + } + }, + }); + return found; +} + +/** + * Detect vinext's Cloudflare Images optimizer in the project's Vite config. + * vinext keeps its options private, so the config source is read statically. + * A non-literal `binding` falls back to `IMAGES`; set `inference.bindings.images` + * to the name in that case. + */ +export function detectVinextImagesOptimizer(root: string): { binding: string } | undefined { + for (const file of VITE_CONFIG_FILES) { + const path = join(root, file); + if (!existsSync(path)) { + continue; + } + let code: string; + try { + code = readFileSync(path, 'utf-8'); + } catch { + return undefined; + } + return detectVinextImagesOptimizerInSource(code, file); + } + return undefined; +} diff --git a/packages/void/src/inference/walk.ts b/packages/void/src/inference/walk.ts index 11b0920ef..0b4ffe2d5 100644 --- a/packages/void/src/inference/walk.ts +++ b/packages/void/src/inference/walk.ts @@ -14,13 +14,90 @@ export type InferredBindings = { needsSandbox: boolean; needsLive?: boolean; needsEmail?: boolean; + /** + * The Cloudflare Images binding is required. Source inference sets it for an + * `env.IMAGES` read; project inference also sets it for `usesImageOptimizer` + * while the default `/_void/image` optimizer is configured. + */ + needsImages?: boolean; + /** `Image` or `getImageProps` from a Void adapter or an image subpath. */ + usesImageOptimizer?: boolean; }; // SEND_EMAIL is intentionally absent from this regex: Void owns that binding // (managed sends go through __VOID_PROXY/email/send; native Cloudflare deploys // add SEND_EMAIL themselves), so the `void/email` import (below) is the // sole driver of `needsEmail`. -const ENV_BINDING_RE = /\benv\s*\.\s*(DB|KV|STORAGE|QUEUE_\w+|AI|SANDBOX)\b/; +const ENV_BINDING_RE = /\benv\s*\.\s*(DB|KV|STORAGE|QUEUE_\w+|AI|SANDBOX|IMAGES)\b/; + +/** Adapter entries whose `Image` and `getImageProps` exports render through the optimizer. */ +const IMAGE_ADAPTER_SOURCES = new Set(['@void/react', '@void/vue', '@void/svelte', '@void/solid']); +const IMAGE_EXPORT_NAMES = new Set(['Image', 'getImageProps']); +/** Exports that render through the optimizer, per image subpath. */ +const IMAGE_SUBPATH_EXPORTS: Record> = { + '@void/react/image': new Set(['default', 'Image', 'getImageProps']), + // The other `void/image` exports are loader and config helpers. + 'void/image': new Set(['getImageProps']), +}; + +function imageExportNames(source: string): ReadonlySet | undefined { + return IMAGE_ADAPTER_SOURCES.has(source) ? IMAGE_EXPORT_NAMES : IMAGE_SUBPATH_EXPORTS[source]; +} + +/** + * Whether the module imports or re-exports an image export by name, or reads + * one from a namespace import. Type-only specifiers never count, and neither + * does a bare side-effect import or a star re-export of a whole adapter. + */ +function usesImageExports(parsed: ReturnType, code: string): boolean { + for (const item of parsed.module.staticImports) { + const names = imageExportNames(item.moduleRequest.value); + if (!names) { + continue; + } + for (const entry of item.entries) { + if (entry.isType) { + continue; + } + const { kind, name } = entry.importName; + if (kind === 'Name' && name !== null && names.has(name)) { + return true; + } + if (kind === 'Default' && names.has('default')) { + return true; + } + if (kind === 'NamespaceObject') { + // `ns.Image`, ``, `ns.getImageProps(...)`. + // Identifiers need only `$` escaped. + const members = [...names].join('|'); + const local = entry.localName.value.replaceAll('$', '\\$'); + if (new RegExp(`(?; @@ -50,7 +129,7 @@ export function inferBindingsFromSource(code: string, filename: string): Inferre return result; } - function recordModuleReference(source: string, statement: string): void { + function recordModuleReference(source: string, statement: string, dynamic = false): void { switch (source) { case 'void/db': result.needsD1 = true; @@ -79,6 +158,14 @@ export function inferBindingsFromSource(code: string, filename: string): Inferre case 'void/live': result.needsLive = true; break; + case 'void/image': + case '@void/react/image': + // Static references are checked per specifier in `usesImageExports`; + // a literal dynamic import may read any export. + if (dynamic) { + result.usesImageOptimizer = true; + } + break; case 'void/client': case 'void/client/react': case 'void/client/vue': @@ -106,10 +193,14 @@ export function inferBindingsFromSource(code: string, filename: string): Inferre ? staticLiteral(expression.expression as AstNode) : undefined; if (typeof source === 'string') { - recordModuleReference(source, code.slice(reference.start, reference.end)); + recordModuleReference(source, code.slice(reference.start, reference.end), true); } } + if (usesImageExports(parsed, code)) { + result.usesImageOptimizer = true; + } + if (!ENV_BINDING_RE.test(code)) { return result; } @@ -154,6 +245,9 @@ export function inferBindingsFromSource(code: string, filename: string): Inferre case 'SANDBOX': result.needsSandbox = true; break; + case 'IMAGES': + result.needsImages = true; + break; default: if (property.name.startsWith('QUEUE_')) { result.needsQueues = true; @@ -180,5 +274,7 @@ export function mergeBindings(bindings: Array): InferredBindin needsSandbox: bindings.some((b) => b.needsSandbox), needsLive: bindings.some((b) => b.needsLive), needsEmail: bindings.some((b) => b.needsEmail), + needsImages: bindings.some((b) => b.needsImages), + usesImageOptimizer: bindings.some((b) => b.usesImageOptimizer), }; } diff --git a/packages/void/src/plugin-inference.ts b/packages/void/src/plugin-inference.ts index c85a9b1fb..3ad14e268 100644 --- a/packages/void/src/plugin-inference.ts +++ b/packages/void/src/plugin-inference.ts @@ -1,8 +1,9 @@ import { existsSync, readFileSync } from 'node:fs'; import { join, relative, toSlash } from 'pathslash'; import { globSync } from 'tinyglobby'; -import { applyBindingOverrides } from './application-features.ts'; +import { applyBindingOverrides, applyImageOptimizerRequirement } from './application-features.ts'; import { inferBindingsFromSource, mergeBindings, type InferredBindings } from './inference/walk.ts'; +import { scriptBlocks } from './pages/island-imports.ts'; import { readProjectPaths, type ProjectPaths } from './project-paths.ts'; import { isRouteEnabledForEnvironment, @@ -13,7 +14,7 @@ import { import { isWebSocketPatternFile } from './router/websocket.ts'; import { getSsrServerEntryPath } from './ssr/entry.ts'; -const SOURCE_GLOB = '**/*.{ts,tsx,mts,js,jsx,mjs,astro}'; +const SOURCE_GLOB = '**/*.{ts,tsx,mts,js,jsx,mjs,astro,vue,svelte}'; /** Framework class determines integration depth. */ export type FrameworkClass = 'a' | 'b' | 'c'; @@ -107,7 +108,13 @@ export type InferenceOptions = { storage?: boolean | string; ai?: boolean | string; email?: boolean; + images?: boolean | string; }; + /** + * The default `/_void/image` optimizer serves this app, so `Image` and + * `getImageProps` usage requires the Images binding. + */ + imageOptimizer?: boolean; paths?: ProjectPaths; /** Filter route files for a Vite serve/build environment. Other source files are always scanned. */ environment?: RouteEnvironment; @@ -118,8 +125,24 @@ function extractAstroFrontmatter(code: string): string { return match?.[1] ?? ''; } -function inferBindingsFromFile(file: string, code: string): InferredBindings { +/** + * Infer one source file. `.astro` files are read from their frontmatter. + * Vue and Svelte ` + + +`, + ); + const svelteRoot = createTempRoot(); + writePage( + svelteRoot, + 'index.svelte', + ` + + + + +`, + ); + + for (const root of [vueRoot, svelteRoot]) { + expect(inferProjectBindings(root, { imageOptimizer: true })).toMatchObject({ + needsImages: true, + usesImageOptimizer: true, + }); + } + }); + + it('infers no other bindings from Vue and Svelte files', () => { + const root = createTempRoot(); + writePage( + root, + 'index.vue', + ` +`, + ); + writePage( + root, + 'about.svelte', + ` +`, + ); + + expect(inferProjectBindings(root)).toEqual({ + needsD1: false, + needsKV: false, + needsR2: false, + needsAuth: false, + needsQueues: false, + needsAI: false, + needsSandbox: false, + needsEmail: false, + needsLive: false, + needsImages: true, + usesImageOptimizer: true, + }); + }); + + it('ignores Vue and Svelte markup outside script blocks', () => { + const root = createTempRoot(); + writePage( + root, + 'index.vue', + ` +`, + ); + + expect(inferProjectBindings(root, { imageOptimizer: true })).toMatchObject({ + needsImages: false, + usesImageOptimizer: false, + }); + }); +}); + describe('detectFramework', () => { afterEach(() => { while (tempDirs.length > 0) { diff --git a/packages/void/test/unit/vinext-images.test.ts b/packages/void/test/unit/vinext-images.test.ts new file mode 100644 index 000000000..b90a78838 --- /dev/null +++ b/packages/void/test/unit/vinext-images.test.ts @@ -0,0 +1,92 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it } from 'vite-plus/test'; +import { + detectVinextImagesOptimizer, + detectVinextImagesOptimizerInSource, +} from '../../src/inference/vinext-images.ts'; + +const MODULE = '@vinext/cloudflare/images/images-optimizer'; + +const roots: Array = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +describe('detectVinextImagesOptimizerInSource', () => { + it.each<[string, string]>([ + [ + `import { imagesOptimizer } from '${MODULE}';\nvinext({ images: { optimizer: imagesOptimizer() } });`, + 'IMAGES', + ], + [`import { imagesOptimizer } from '${MODULE}';\nimagesOptimizer({});`, 'IMAGES'], + [ + `import { imagesOptimizer } from '${MODULE}';\nimagesOptimizer({ binding: 'PHOTOS' });`, + 'PHOTOS', + ], + [ + `import { imagesOptimizer } from '${MODULE}';\nimagesOptimizer({ 'binding': "PHOTOS" });`, + 'PHOTOS', + ], + [ + `import { imagesOptimizer } from '${MODULE}';\nimagesOptimizer({ binding: \`PHOTOS\` });`, + 'PHOTOS', + ], + [`import { imagesOptimizer as cf } from '${MODULE}';\ncf({ binding: 'MEDIA' });`, 'MEDIA'], + [ + `import * as images from '${MODULE}';\nimages.imagesOptimizer({ binding: 'MEDIA' });`, + 'MEDIA', + ], + // A non-literal name cannot be read statically; vinext's default applies. + [`import { imagesOptimizer } from '${MODULE}';\nimagesOptimizer({ binding: name });`, 'IMAGES'], + [`import { imagesOptimizer } from '${MODULE}';\nimagesOptimizer(options);`, 'IMAGES'], + ])('detects %s', (code, binding) => { + expect(detectVinextImagesOptimizerInSource(code, 'vite.config.ts')).toEqual({ binding }); + }); + + it.each([ + 'import vinext from "vinext";\nvinext();', + `import { imagesOptimizer } from '${MODULE}';`, + `import type { imagesOptimizer } from '${MODULE}';\nimagesOptimizer();`, + `import { imagesOptimizer } from './local-optimizer';\nimagesOptimizer();`, + `// import { imagesOptimizer } from '${MODULE}';\nimagesOptimizer();`, + `import * as images from '${MODULE}';\nimages.other({ binding: 'MEDIA' });`, + `import { imagesOptimizer } from '${MODULE}';\nimagesOptimizer();\nnot { valid`, + ])('ignores %s', (code) => { + expect(detectVinextImagesOptimizerInSource(code, 'vite.config.ts')).toBeUndefined(); + }); +}); + +describe('detectVinextImagesOptimizer', () => { + function project(files: Record): string { + const root = mkdtempSync(join(tmpdir(), 'void-vinext-images-')); + roots.push(root); + for (const [file, code] of Object.entries(files)) { + writeFileSync(join(root, file), code); + } + return root; + } + + it('reads the project Vite config', () => { + const root = project({ + 'vite.config.mts': `import { imagesOptimizer } from '${MODULE}';\nimagesOptimizer({ binding: 'PHOTOS' });`, + }); + expect(detectVinextImagesOptimizer(root)).toEqual({ binding: 'PHOTOS' }); + }); + + it('reads only the Vite config Vite would load first', () => { + const root = project({ + 'vite.config.ts': 'export default {};', + 'vite.config.js': `import { imagesOptimizer } from '${MODULE}';\nimagesOptimizer();`, + }); + expect(detectVinextImagesOptimizer(root)).toBeUndefined(); + }); + + it('returns undefined without a Vite config', () => { + expect(detectVinextImagesOptimizer(project({}))).toBeUndefined(); + }); +}); From e880ff62608f3c5c46a000d59e95ccbca997b578 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 04:59:32 +0000 Subject: [PATCH 04/12] feat(image): add Image and getImageProps to the Pages adapters React, Vue, Svelte and Solid render next/image 16 markup through void/image and preload images through each adapter's head handling. @void/react/image has the next/image module shape for migrations. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6 --- packages/react/package.json | 4 + packages/react/src/image.ts | 12 + packages/react/src/runtime/image.tsx | 404 +++++++++++++ packages/react/src/runtime/index.ts | 2 + packages/react/test/unit/image.golden.ts | 560 ++++++++++++++++++ packages/react/test/unit/image.test.ts | 330 +++++++++++ packages/react/vite.config.ts | 1 + packages/solid/src/plugin-islands.ts | 44 +- packages/solid/src/plugin-pages.ts | 81 ++- packages/solid/src/runtime/context.ts | 7 + packages/solid/src/runtime/image-props.ts | 202 +++++++ packages/solid/src/runtime/image.tsx | 174 ++++++ packages/solid/src/runtime/index.ts | 12 + packages/solid/test/unit/image-props.test.ts | 256 ++++++++ packages/solid/test/unit/image.test.ts | 279 +++++++++ .../solid/test/unit/plugin-islands.test.ts | 30 + packages/solid/test/unit/plugin-pages.test.ts | 111 ++++ packages/svelte/src/runtime/Image.svelte | 92 +++ .../svelte/src/runtime/ImagePreload.svelte | 27 + packages/svelte/src/runtime/image.ts | 209 +++++++ packages/svelte/src/runtime/index.ts | 3 + packages/svelte/test/unit/image.test.ts | 485 +++++++++++++++ packages/vue/src/plugin-islands.ts | 7 +- packages/vue/src/plugin-pages.ts | 7 +- packages/vue/src/runtime/image.ts | 357 +++++++++++ packages/vue/src/runtime/index.ts | 10 + packages/vue/test/unit/image-page-fixture.ts | 52 ++ packages/vue/test/unit/image.golden.ts | 427 +++++++++++++ packages/vue/test/unit/image.test.ts | 499 ++++++++++++++++ packages/vue/test/unit/plugin-islands.test.ts | 39 ++ packages/vue/test/unit/plugin-pages.test.ts | 53 ++ 31 files changed, 4723 insertions(+), 53 deletions(-) create mode 100644 packages/react/src/image.ts create mode 100644 packages/react/src/runtime/image.tsx create mode 100644 packages/react/test/unit/image.golden.ts create mode 100644 packages/react/test/unit/image.test.ts create mode 100644 packages/solid/src/runtime/image-props.ts create mode 100644 packages/solid/src/runtime/image.tsx create mode 100644 packages/solid/test/unit/image-props.test.ts create mode 100644 packages/solid/test/unit/image.test.ts create mode 100644 packages/svelte/src/runtime/Image.svelte create mode 100644 packages/svelte/src/runtime/ImagePreload.svelte create mode 100644 packages/svelte/src/runtime/image.ts create mode 100644 packages/svelte/test/unit/image.test.ts create mode 100644 packages/vue/src/runtime/image.ts create mode 100644 packages/vue/test/unit/image-page-fixture.ts create mode 100644 packages/vue/test/unit/image.golden.ts create mode 100644 packages/vue/test/unit/image.test.ts diff --git a/packages/react/package.json b/packages/react/package.json index 010076149..8996089a6 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -21,6 +21,10 @@ "types": "./dist/index.d.mts", "import": "./dist/index.mjs" }, + "./image": { + "types": "./dist/image.d.mts", + "import": "./dist/image.mjs" + }, "./plugin": { "types": "./dist/plugin.d.mts", "import": "./dist/plugin.mjs" diff --git a/packages/react/src/image.ts b/packages/react/src/image.ts new file mode 100644 index 000000000..796e7c05c --- /dev/null +++ b/packages/react/src/image.ts @@ -0,0 +1,12 @@ +/** + * `@void/react/image`: shaped like the `next/image` module, so + * `import Image, { getImageProps } from 'next/image'` becomes + * `import Image, { getImageProps } from '@void/react/image'`. + */ +export { Image as default, Image, getImageProps } from './runtime/image.tsx'; +export type { + ImageLoader, + ImageLoaderProps, + ImageProps, + StaticImageData, +} from './runtime/image.tsx'; diff --git a/packages/react/src/runtime/image.tsx b/packages/react/src/runtime/image.tsx new file mode 100644 index 000000000..c09ecf532 --- /dev/null +++ b/packages/react/src/runtime/image.tsx @@ -0,0 +1,404 @@ +/** + * `Image` and `getImageProps`, matching `next/image` in Next.js 16. Ported from + * Next.js `client/image-component.js` (MIT) for React 19: `fetchPriority` is + * camelCase, `ref` is an ordinary prop, and preloads always use + * `ReactDOM.preload` (Next.js App Router behavior). The props themselves come + * from `void/image`, which reads the images config registered by the Void + * plugin, so server and client compute the same `srcSet`. + */ +import { + useCallback, + useEffect, + useLayoutEffect, + useRef, + useState, + type CSSProperties, + type Dispatch, + type JSX, + type ReactEventHandler, + type Ref, + type RefAttributes, + type RefObject, + type SetStateAction, + type SyntheticEvent, +} from 'react'; +import { preload, type PreloadOptions } from 'react-dom'; +import { + getImageConfig, + getImageProps as getCoreImageProps, + handleImageLoading, + resolveImageProps, + type ImageCoreProps, + type ImageLoader, + type ImageLoaderProps, + type PlaceholderValue, + type StaticImageData, + type StaticImport, +} from 'void/image'; + +export type { ImageLoader, ImageLoaderProps, StaticImageData }; + +type LoadingValue = 'lazy' | 'eager' | undefined; + +type OnLoadingComplete = (img: HTMLImageElement) => void; + +/** Props of `Image`: `` attributes plus the `next/image` props. */ +export type ImageProps = Omit< + JSX.IntrinsicElements['img'], + 'src' | 'srcSet' | 'ref' | 'alt' | 'width' | 'height' | 'loading' +> & { + src: string | StaticImport; + alt: string; + width?: number | `${number}`; + height?: number | `${number}`; + fill?: boolean; + loader?: ImageLoader; + quality?: number | `${number}`; + preload?: boolean; + /** @deprecated Use `preload`. */ + priority?: boolean; + loading?: LoadingValue; + placeholder?: PlaceholderValue; + blurDataURL?: string; + unoptimized?: boolean; + overrideSrc?: string; + /** @deprecated Use `onLoad`. */ + onLoadingComplete?: OnLoadingComplete; + /** @deprecated Use `fill`, or `style` for the other `next/legacy/image` layouts. */ + layout?: string; + /** @deprecated Use `style`. */ + objectFit?: string; + /** @deprecated Use `style`. */ + objectPosition?: string; + /** @deprecated This prop does nothing. */ + lazyBoundary?: string; + /** @deprecated This prop does nothing. */ + lazyRoot?: string; +}; + +/** The `` props `getImageProps` returns (React attribute names). */ +type ImgProps = Omit & { + loading: LoadingValue; + width: number | undefined; + height: number | undefined; + style: CSSProperties; + sizes: string | undefined; + srcSet: string | undefined; + src: string; +}; + +type OnLoad = ReactEventHandler | undefined; + +// The core types `style` as a plain record and has no React event types. +function toCoreProps(props: ImageProps): ImageCoreProps & Record { + return props as unknown as ImageCoreProps & Record; +} + +// See https://stackoverflow.com/q/39777833/266535 for why we use this ref +// handler instead of the img's onLoad attribute. +function handleLoading( + img: HTMLImageElement, + placeholder: PlaceholderValue, + onLoadRef: RefObject, + onLoadingCompleteRef: RefObject, + setBlurComplete: (b: boolean) => void, + unoptimized: boolean, + sizesInput: string | undefined, +): void { + handleImageLoading(img, { + placeholder, + onBlurComplete: () => setBlurComplete(true), + onLoad: (loaded) => { + if (onLoadRef.current) { + // Since we don't have the SyntheticEvent here, + // we must create one with the same shape. + // See https://reactjs.org/docs/events.html + const event = new Event('load'); + Object.defineProperty(event, 'target', { writable: false, value: loaded }); + let prevented = false; + let stopped = false; + onLoadRef.current({ + ...event, + nativeEvent: event, + currentTarget: loaded, + target: loaded, + isDefaultPrevented: () => prevented, + isPropagationStopped: () => stopped, + persist: () => {}, + preventDefault: () => { + prevented = true; + event.preventDefault(); + }, + stopPropagation: () => { + stopped = true; + event.stopPropagation(); + }, + } as unknown as SyntheticEvent); + } + onLoadingCompleteRef.current?.(loaded); + }, + dev: getImageConfig().dev, + sizesInput, + unoptimized, + }); +} + +function applyRef(ref: Ref, current: T): () => void { + if (typeof ref === 'function') { + const cleanup = ref(current); + return typeof cleanup === 'function' ? cleanup : () => ref(null); + } + if (ref) { + ref.current = current; + } + return () => { + if (ref) { + ref.current = null; + } + }; +} + +// Always wrap user refs: a ref that returns a cleanup function must not leak +// into ref-merging code written before React 19. +function useMergedRef( + refA: Ref | undefined, + refB: Ref | undefined, +): (current: T | null) => void { + const cleanupA = useRef<(() => void) | null>(null); + const cleanupB = useRef<(() => void) | null>(null); + return useCallback( + (current: T | null) => { + if (current === null) { + const cleanupFnA = cleanupA.current; + if (cleanupFnA) { + cleanupA.current = null; + cleanupFnA(); + } + const cleanupFnB = cleanupB.current; + if (cleanupFnB) { + cleanupB.current = null; + cleanupFnB(); + } + } else { + if (refA) { + cleanupA.current = applyRef(refA, current); + } + if (refB) { + cleanupB.current = applyRef(refB, current); + } + } + }, + [refA, refB], + ); +} + +type ImageElementProps = ImgProps & { + unoptimized: boolean; + placeholder: PlaceholderValue; + fill: boolean; + onLoadRef: RefObject; + onLoadingCompleteRef: RefObject; + setBlurComplete: Dispatch>; + setShowAltText: Dispatch>; + sizesInput: string | undefined; + forwardedRef: Ref | undefined; +}; + +function ImageElement({ + src, + srcSet, + sizes, + height, + width, + decoding, + className, + style, + fetchPriority, + placeholder, + loading, + unoptimized, + fill, + onLoadRef, + onLoadingCompleteRef, + setBlurComplete, + setShowAltText, + sizesInput, + onLoad: _onLoad, + onError, + forwardedRef, + ...rest +}: ImageElementProps): JSX.Element { + const didInsertRef = useRef(false); + const insertedImgRef = useRef(null); + + useLayoutEffect(() => { + const { current: didInsert } = didInsertRef; + const { current: img } = insertedImgRef; + if (!didInsert && img !== null) { + // Replay events from during hydration that React doesn't replay. + if (onError) { + // If the image has an error before react hydrates, then the error is lost. + // The workaround is to wait until the image is mounted which is after hydration, + // then we set the src again to trigger the error handler (if there was an error). + // This doesn't just trigger the error handler but retries the whole request. + // oxlint-disable-next-line no-self-assign + img.src = img.src; + } + if (getImageConfig().dev) { + if (!src) { + console.error(`Image is missing required "src" property:`, img); + } + if (img.getAttribute('alt') === null) { + console.error( + `Image is missing required "alt" property. Please add Alternative Text to describe the image for screen readers and search engines.`, + ); + } + } + if (img.complete) { + handleLoading( + img, + placeholder, + onLoadRef, + onLoadingCompleteRef, + setBlurComplete, + unoptimized, + sizesInput, + ); + } + didInsertRef.current = true; + } + }, [ + src, + placeholder, + onLoadRef, + onLoadingCompleteRef, + setBlurComplete, + onError, + unoptimized, + sizesInput, + ]); + + const ref = useMergedRef(forwardedRef, insertedImgRef); + + return ( + // If you move this element creation, also move the Layout Effect above + // reading from the ref. Otherwise we might run the Layout Effect when + // the current value isn't set to the HTMLImageElement instance. + { + handleLoading( + event.currentTarget, + placeholder, + onLoadRef, + onLoadingCompleteRef, + setBlurComplete, + unoptimized, + sizesInput, + ); + }} + onError={(event) => { + // if the real image fails to load, this will ensure "alt" is visible + setShowAltText(true); + if (placeholder !== 'empty') { + // If the real image fails to load, this will still remove the placeholder. + setBlurComplete(true); + } + onError?.(event); + }} + /> + ); +} + +function ImagePreload({ imgAttributes }: { imgAttributes: ImgProps }): null { + const options: PreloadOptions = { + as: 'image', + imageSrcSet: imgAttributes.srcSet, + imageSizes: imgAttributes.sizes, + crossOrigin: imgAttributes.crossOrigin, + referrerPolicy: imgAttributes.referrerPolicy, + fetchPriority: imgAttributes.fetchPriority, + }; + // React emits the `` while server rendering + // (omitting `href` when `imageSrcSet` is set) and adds it to `document.head` + // on the client unless an equal link exists. + preload(imgAttributes.src, options); + return null; +} + +/** + * Renders an `` with a responsive `srcSet` served by the Void image + * endpoint (or the configured loader), lazy loading by default, and an + * optional blur placeholder. Accepts every `next/image` prop. + */ +export function Image({ + ref, + ...props +}: ImageProps & RefAttributes): JSX.Element { + const { onLoad, onLoadingComplete } = props; + const onLoadRef = useRef(onLoad); + useEffect(() => { + onLoadRef.current = onLoad; + }, [onLoad]); + const onLoadingCompleteRef = useRef(onLoadingComplete); + useEffect(() => { + onLoadingCompleteRef.current = onLoadingComplete; + }, [onLoadingComplete]); + + const [blurComplete, setBlurComplete] = useState(false); + const [showAltText, setShowAltText] = useState(false); + // `ref` stays out of the core props: it would trigger the "unsupported ref" warning. + const { props: coreAttributes, meta: imgMeta } = resolveImageProps(toCoreProps(props), { + blurComplete, + showAltText, + }); + const imgAttributes = coreAttributes as unknown as ImgProps; + + return ( + <> + + {imgMeta.preload ? : null} + + ); +} + +/** + * The props `Image` would pass to its ``, for another element, a + * `` source, CSS or canvas. Keys whose value is `undefined` are + * removed. Does not preload. + */ +export function getImageProps(imgProps: ImageProps): { props: ImgProps } { + return getCoreImageProps(toCoreProps(imgProps)) as unknown as { props: ImgProps }; +} diff --git a/packages/react/src/runtime/index.ts b/packages/react/src/runtime/index.ts index 9e114623e..afa2cbe14 100644 --- a/packages/react/src/runtime/index.ts +++ b/packages/react/src/runtime/index.ts @@ -1,5 +1,7 @@ export { Form } from './form.tsx'; export { Link } from './link.tsx'; +export { Image, getImageProps } from './image.tsx'; +export type { ImageProps, ImageLoader, ImageLoaderProps, StaticImageData } from './image.tsx'; export { useRouter } from './use-router.ts'; export { useParams } from './use-params.ts'; export { useShared } from './use-shared.ts'; diff --git a/packages/react/test/unit/image.golden.ts b/packages/react/test/unit/image.golden.ts new file mode 100644 index 000000000..8547ef7cf --- /dev/null +++ b/packages/react/test/unit/image.golden.ts @@ -0,0 +1,560 @@ +/** + * Golden output of next@16.4.2: its real `Image` (`next/dist/client/image-component.js`) + * and `getImageProps` (`next/dist/shared/lib/image-external-get-image-props.js`), + * run in Node with react@19.3.0 and react-dom@19.3.0, `NODE_ENV=production`, and + * `process.env.__NEXT_IMAGE_OPTS` set to the object `next build` inlines for + * Next.js's default `images` config with `path: '/_void/image'`. + * `staticMarkup` is `renderToStaticMarkup()`; `tree` is + * `renderToString(
)`, which includes the + * `ReactDOM.preload` link of preloaded images. Function props were written with + * `Function#toString`. Regenerate the same way after changing a case; do not + * edit the expected strings by hand. + */ +import type { ImageProps } from '../../src/runtime/image.tsx'; + +export type ImageGoldenCase = { + name: string; + /** `Image` props; JSX types `data-*` attributes separately. */ + props: ImageProps & { [key: `data-${string}`]: string }; + staticMarkup: string; + tree: string; +}; + +export const imageGoldenCases: Array = [ + { + name: 'fixed width (1x/2x)', + props: { + src: '/avatars/ada.png', + alt: 'Ada', + width: 64, + height: 64, + }, + staticMarkup: + 'Ada', + tree: '
Ada
', + }, + { + name: 'numeric string dimensions', + props: { + src: '/photos/a.jpg', + alt: 'A', + width: '500', + height: '300', + }, + staticMarkup: + 'A', + tree: '
A
', + }, + { + name: 'sizes with vw', + props: { + src: '/hero.jpg', + alt: 'Hero', + width: 1200, + height: 600, + sizes: '(max-width: 768px) 100vw, 50vw', + }, + staticMarkup: + 'Hero', + tree: '
Hero
', + }, + { + name: 'sizes without vw', + props: { + src: '/hero.jpg', + alt: 'Hero', + width: 300, + height: 200, + sizes: '300px', + }, + staticMarkup: + 'Hero', + tree: '
Hero
', + }, + { + name: 'fill', + props: { + src: '/cover.jpg', + alt: 'Cover', + fill: true, + }, + staticMarkup: + 'Cover', + tree: '
Cover
', + }, + { + name: 'fill with sizes and objectFit', + props: { + src: '/cover.jpg', + alt: 'Cover', + fill: true, + sizes: '33vw', + style: { + objectFit: 'cover', + }, + }, + staticMarkup: + 'Cover', + tree: '
Cover
', + }, + { + name: 'preload', + props: { + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + preload: true, + }, + staticMarkup: + 'LCP', + tree: '
LCP
', + }, + { + name: 'priority', + props: { + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + priority: true, + }, + staticMarkup: + 'LCP', + tree: '
LCP
', + }, + { + name: 'preload with fetchPriority high', + props: { + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + preload: true, + fetchPriority: 'high', + }, + staticMarkup: + 'LCP', + tree: '
LCP
', + }, + { + name: 'preload fill with crossOrigin and referrerPolicy', + props: { + src: 'https://images.example.com/lcp.jpg', + alt: 'LCP', + fill: true, + sizes: '100vw', + preload: true, + crossOrigin: 'anonymous', + referrerPolicy: 'no-referrer', + }, + staticMarkup: + 'LCP', + tree: '
LCP
', + }, + { + name: 'preload unoptimized', + props: { + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + preload: true, + unoptimized: true, + }, + staticMarkup: + 'LCP', + tree: '
LCP
', + }, + { + name: 'loading eager', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + loading: 'eager', + }, + staticMarkup: + 'A', + tree: '
A
', + }, + { + name: 'fetchPriority low', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + fetchPriority: 'low', + }, + staticMarkup: + 'A', + tree: '
A
', + }, + { + name: 'unoptimized', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + unoptimized: true, + }, + staticMarkup: + 'A', + tree: '
A
', + }, + { + name: 'custom loader prop', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + quality: 60, + loader: ({ src, width, quality }) => + `https://cdn.example.com${src}?w=${width}&q=${quality || 75}`, + }, + staticMarkup: + 'A', + tree: '
A
', + }, + { + name: 'overrideSrc', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + overrideSrc: '/seo/original.png', + }, + staticMarkup: + 'A', + tree: '
A
', + }, + { + name: 'placeholder blur', + props: { + src: '/photo.jpg', + alt: 'Photo', + width: 400, + height: 300, + placeholder: 'blur', + blurDataURL: 'data:image/png;base64,iVBORw0KGgo=', + }, + staticMarkup: + 'Photo', + tree: '
Photo
', + }, + { + name: 'placeholder blur with objectFit contain', + props: { + src: '/photo.jpg', + alt: 'Photo', + width: 400, + height: 300, + placeholder: 'blur', + blurDataURL: 'data:image/png;base64,iVBORw0KGgo=', + style: { + objectFit: 'contain', + }, + }, + staticMarkup: + 'Photo', + tree: '
Photo
', + }, + { + name: 'data:image placeholder', + props: { + src: '/photo.jpg', + alt: 'Photo', + width: 400, + height: 300, + placeholder: 'data:image/png;base64,iVBORw0KGgo=', + }, + staticMarkup: + 'Photo', + tree: '
Photo
', + }, + { + name: 'placeholder empty', + props: { + src: '/photo.jpg', + alt: 'Photo', + width: 400, + height: 300, + placeholder: 'empty', + }, + staticMarkup: + 'Photo', + tree: '
Photo
', + }, + { + name: 'className and style', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + className: 'rounded shadow', + style: { + borderRadius: 8, + maxWidth: '100%', + height: 'auto', + }, + }, + staticMarkup: + 'A', + tree: '
A
', + }, + { + name: 'svg src', + props: { + src: '/logo.svg', + alt: 'Logo', + width: 120, + height: 40, + }, + staticMarkup: + 'Logo', + tree: '
Logo
', + }, + { + name: 'remote absolute src', + props: { + src: 'https://images.example.com/photos/a.jpg?id=1', + alt: 'Remote', + width: 640, + height: 480, + }, + staticMarkup: + 'Remote', + tree: '
Remote
', + }, + { + name: 'quality', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + quality: 50, + }, + staticMarkup: + 'A', + tree: '
A
', + }, + { + name: 'width above every configured size', + props: { + src: '/a.png', + alt: 'A', + width: 5000, + height: 100, + }, + staticMarkup: + 'A', + tree: '
A
', + }, + { + name: 'static import', + props: { + src: { + src: '/assets/hero-abc123.jpg', + width: 1600, + height: 900, + }, + alt: 'Hero', + }, + staticMarkup: + 'Hero', + tree: '
Hero
', + }, + { + name: 'static import with width and blur', + props: { + src: { + src: '/assets/hero-abc123.jpg', + width: 1600, + height: 900, + blurDataURL: 'data:image/jpeg;base64,/9j/', + blurWidth: 8, + blurHeight: 5, + }, + alt: 'Hero', + width: 800, + placeholder: 'blur', + }, + staticMarkup: + 'Hero', + tree: '
Hero
', + }, + { + name: 'data: src', + props: { + src: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=', + alt: 'Pixel', + width: 1, + height: 1, + }, + staticMarkup: + 'Pixel', + tree: '
Pixel
', + }, + { + name: 'empty alt', + props: { + src: '/a.png', + alt: '', + width: 100, + height: 100, + }, + staticMarkup: + '', + tree: '
', + }, + { + name: 'extra img attributes and handlers', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + id: 'avatar', + title: 'Ada', + decoding: 'sync', + draggable: false, + 'data-testid': 'avatar', + onLoad: () => {}, + onError: () => {}, + }, + staticMarkup: + 'A', + tree: '
A
', + }, + { + name: 'legacy layout responsive', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + layout: 'responsive', + }, + staticMarkup: + 'A', + tree: '
A
', + }, +]; + +/** `renderToString` of two preloaded `Image`s with the same `src` (alt "LCP" and "LCP again"). */ +export const imageGoldenDedupeTree: string = + '
LCPLCP again
'; + +/** `getImageProps(props).props` from next@16.4.2, in key order. */ +export const imageGoldenGetImageProps: Array<{ + name: string; + props: ImageProps; + result: Record; +}> = [ + { + name: 'fixed width', + props: { + src: '/avatars/ada.png', + alt: 'Ada', + width: 64, + height: 64, + }, + result: { + alt: 'Ada', + loading: 'lazy', + width: 64, + height: 64, + decoding: 'async', + style: { + color: 'transparent', + }, + srcSet: + '/_void/image?url=%2Favatars%2Fada.png&w=64&q=75 1x, /_void/image?url=%2Favatars%2Fada.png&w=128&q=75 2x', + src: '/_void/image?url=%2Favatars%2Fada.png&w=128&q=75', + }, + }, + { + name: 'fill with sizes', + props: { + src: '/cover.jpg', + alt: 'Cover', + fill: true, + sizes: '50vw', + }, + result: { + alt: 'Cover', + loading: 'lazy', + decoding: 'async', + style: { + position: 'absolute', + height: '100%', + width: '100%', + left: 0, + top: 0, + right: 0, + bottom: 0, + objectFit: undefined, + objectPosition: undefined, + color: 'transparent', + }, + sizes: '50vw', + srcSet: + '/_void/image?url=%2Fcover.jpg&w=384&q=75 384w, /_void/image?url=%2Fcover.jpg&w=640&q=75 640w, /_void/image?url=%2Fcover.jpg&w=750&q=75 750w, /_void/image?url=%2Fcover.jpg&w=828&q=75 828w, /_void/image?url=%2Fcover.jpg&w=1080&q=75 1080w, /_void/image?url=%2Fcover.jpg&w=1200&q=75 1200w, /_void/image?url=%2Fcover.jpg&w=1920&q=75 1920w, /_void/image?url=%2Fcover.jpg&w=2048&q=75 2048w, /_void/image?url=%2Fcover.jpg&w=3840&q=75 3840w', + src: '/_void/image?url=%2Fcover.jpg&w=3840&q=75', + }, + }, + { + name: 'preload with className and fetchPriority', + props: { + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + preload: true, + fetchPriority: 'high', + className: 'hero', + }, + result: { + alt: 'LCP', + fetchPriority: 'high', + width: 800, + height: 400, + decoding: 'async', + className: 'hero', + style: { + color: 'transparent', + }, + srcSet: + '/_void/image?url=%2Flcp.jpg&w=828&q=75 1x, /_void/image?url=%2Flcp.jpg&w=1920&q=75 2x', + src: '/_void/image?url=%2Flcp.jpg&w=1920&q=75', + }, + }, + { + name: 'unoptimized', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + unoptimized: true, + }, + result: { + alt: 'A', + loading: 'lazy', + width: 100, + height: 100, + decoding: 'async', + style: { + color: 'transparent', + }, + src: '/a.png', + }, + }, +]; diff --git a/packages/react/test/unit/image.test.ts b/packages/react/test/unit/image.test.ts new file mode 100644 index 000000000..0a329f391 --- /dev/null +++ b/packages/react/test/unit/image.test.ts @@ -0,0 +1,330 @@ +import { createElement, createRef, type ReactElement } from 'react'; +import { renderToStaticMarkup, renderToString } from 'react-dom/server'; +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { getImageConfig, IMAGE_REGISTRY_KEY, type ClientImageConfig } from 'void/image'; +import packageJson from '../../package.json' with { type: 'json' }; +import * as imageModule from '../../src/image.ts'; +import { getImageProps, Image, type ImageProps } from '../../src/runtime/image.tsx'; +import * as runtime from '../../src/runtime/index.ts'; +import { renderReactPage } from '../../src/runtime/pages-server.ts'; +import { + imageGoldenCases, + imageGoldenDedupeTree, + imageGoldenGetImageProps, +} from './image.golden.ts'; + +const registry = globalThis as Record; + +function registerImageConfig(config: Partial, loader?: unknown): void { + registry[IMAGE_REGISTRY_KEY] = { config: { ...getImageConfig(), ...config }, loader }; +} + +afterEach(() => { + delete registry[IMAGE_REGISTRY_KEY]; + vi.restoreAllMocks(); +}); + +type ImgElementProps = { + onLoad: (event: { currentTarget: unknown }) => void; + onError: (event: unknown) => void; +}; + +/** + * The `` element `Image` renders, with its React event handlers, and + * spies for the component's `blurComplete` / `showAltText` setters. Renders + * on the server, since this package's unit tests have no DOM. + */ +function renderImgElement(props: ImageProps): { + img: ReactElement; + setBlurComplete: ReturnType; + setShowAltText: ReturnType; +} { + let fragment: ReactElement<{ children: Array }> | undefined; + renderToStaticMarkup( + createElement(function ImageProbe() { + fragment = Image(props) as ReactElement<{ children: Array }>; + return null; + }), + ); + const imageElement = fragment!.props.children[0] as ReactElement>; + const setBlurComplete = vi.fn(); + const setShowAltText = vi.fn(); + let img: ReactElement | undefined; + renderToStaticMarkup( + createElement(function ImageElementProbe() { + const render = imageElement.type as (props: Record) => ReactElement; + img = render({ + ...imageElement.props, + setBlurComplete, + setShowAltText, + }) as ReactElement; + return null; + }), + ); + return { img: img!, setBlurComplete, setShowAltText }; +} + +function fakeImg(overrides: Record = {}): Record { + return { + src: 'http://localhost/_void/image?url=%2Fa.png&w=256&q=75', + isConnected: true, + parentElement: {}, + width: 100, + height: 100, + complete: true, + decode: () => Promise.resolve(), + getAttribute: (name: string) => (name === 'data-nimg' ? '1' : '100'), + ...overrides, + }; +} + +const flush = (): Promise => new Promise((resolve) => setTimeout(resolve, 0)); + +describe('Image matches next/image 16.4.2 markup', () => { + it.each(imageGoldenCases)('$name', ({ props, staticMarkup, tree }) => { + expect(renderToStaticMarkup(createElement(Image, props))).toBe(staticMarkup); + expect(renderToString(createElement('main', null, createElement(Image, props)))).toBe(tree); + }); + + it('emits one preload link for two preloaded images with the same source', () => { + const props: ImageProps = { + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + preload: true, + }; + expect( + renderToString( + createElement( + 'main', + null, + createElement(Image, props), + createElement(Image, { ...props, alt: 'LCP again' }), + ), + ), + ).toBe(imageGoldenDedupeTree); + }); +}); + +describe('getImageProps', () => { + it.each(imageGoldenGetImageProps)('matches next/image 16.4.2: $name', ({ props, result }) => { + const { props: imgProps } = getImageProps(props); + expect(imgProps).toStrictEqual(result); + expect(Object.keys(imgProps)).toEqual(Object.keys(result)); + }); + + it('removes keys whose value is undefined', () => { + const { props } = getImageProps({ src: '/a.png', alt: 'A', width: 100, height: 100 }); + expect(Object.entries(props).filter(([, value]) => value === undefined)).toEqual([]); + expect(props).not.toHaveProperty('fetchPriority'); + expect(props).not.toHaveProperty('className'); + expect(props).not.toHaveProperty('sizes'); + }); + + it('does not preload', () => { + const markup = renderToString( + createElement(function Page() { + getImageProps({ src: '/lcp.jpg', alt: 'LCP', width: 800, height: 400, preload: true }); + return null; + }), + ); + expect(markup).toBe(''); + }); +}); + +describe('Image config', () => { + it('uses the registered images config and loader file', () => { + registerImageConfig( + { deviceSizes: [400, 800], imageSizes: [100], allSizes: [100, 400, 800] }, + ({ src, width, quality }: { src: string; width: number; quality?: number }) => + `https://cdn.example.com${src}?w=${width}&q=${quality ?? 'auto'}`, + ); + expect( + renderToStaticMarkup( + createElement(Image, { src: '/a.png', alt: 'A', width: 90, height: 90 }), + ), + ).toBe( + 'A', + ); + }); + + it('does not pass ref to the shared props (no unsupported ref warning)', () => { + registerImageConfig({ dev: true }); + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + const markup = renderToStaticMarkup( + createElement(Image, { + src: '/ref.png', + alt: 'Ref', + width: 100, + height: 100, + ref: createRef(), + }), + ); + expect(markup).not.toContain(' ref='); + expect(warn).not.toHaveBeenCalled(); + expect(error).not.toHaveBeenCalled(); + }); + + it('throws the development errors of next/image', () => { + registerImageConfig({ dev: true }); + expect(() => + renderToStaticMarkup(createElement(Image, { src: '/a.png', alt: 'A', width: 100 })), + ).toThrow('Image with src "/a.png" is missing required "height" property.'); + }); +}); + +describe('Image events', () => { + it('calls onLoad with a load event and onLoadingComplete once per src, then ends the blur', async () => { + const onLoad = vi.fn(); + const onLoadingComplete = vi.fn(); + const { img, setBlurComplete } = renderImgElement({ + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + placeholder: 'blur', + blurDataURL: 'data:image/png;base64,iVBORw0KGgo=', + onLoad, + onLoadingComplete, + }); + const element = fakeImg(); + + img.props.onLoad({ currentTarget: element }); + img.props.onLoad({ currentTarget: element }); + await flush(); + + expect(setBlurComplete).toHaveBeenCalledExactlyOnceWith(true); + expect(onLoad).toHaveBeenCalledOnce(); + const event = onLoad.mock.calls[0][0]; + expect(event.target).toBe(element); + expect(event.currentTarget).toBe(element); + expect(event.nativeEvent).toBeInstanceOf(Event); + expect(event.nativeEvent.type).toBe('load'); + expect(event.isDefaultPrevented()).toBe(false); + event.preventDefault(); + expect(event.isDefaultPrevented()).toBe(true); + expect(onLoadingComplete).toHaveBeenCalledExactlyOnceWith(element); + expect(onLoad.mock.invocationCallOrder[0]).toBeLessThan( + onLoadingComplete.mock.invocationCallOrder[0], + ); + }); + + it('keeps the placeholder state for placeholder="empty"', async () => { + const onLoad = vi.fn(); + const { img, setBlurComplete } = renderImgElement({ + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + onLoad, + }); + img.props.onLoad({ currentTarget: fakeImg() }); + await flush(); + expect(setBlurComplete).not.toHaveBeenCalled(); + expect(onLoad).toHaveBeenCalledOnce(); + }); + + it('skips callbacks when the image was removed before decoding finished', async () => { + const onLoad = vi.fn(); + const onLoadingComplete = vi.fn(); + const { img } = renderImgElement({ + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + onLoad, + onLoadingComplete, + }); + img.props.onLoad({ currentTarget: fakeImg({ isConnected: false }) }); + await flush(); + expect(onLoad).not.toHaveBeenCalled(); + expect(onLoadingComplete).not.toHaveBeenCalled(); + }); + + it('shows the alt text, ends the blur and calls onError when loading fails', () => { + const onError = vi.fn(); + const { img, setBlurComplete, setShowAltText } = renderImgElement({ + src: '/missing.png', + alt: 'Missing', + width: 100, + height: 100, + placeholder: 'data:image/png;base64,iVBORw0KGgo=', + onError, + }); + const event = { currentTarget: fakeImg() }; + img.props.onError(event); + expect(setShowAltText).toHaveBeenCalledExactlyOnceWith(true); + expect(setBlurComplete).toHaveBeenCalledExactlyOnceWith(true); + expect(onError).toHaveBeenCalledExactlyOnceWith(event); + }); + + it('only shows the alt text on error without a placeholder', () => { + const { img, setBlurComplete, setShowAltText } = renderImgElement({ + src: '/missing.png', + alt: 'Missing', + width: 100, + height: 100, + }); + img.props.onError({ currentTarget: fakeImg() }); + expect(setShowAltText).toHaveBeenCalledExactlyOnceWith(true); + expect(setBlurComplete).not.toHaveBeenCalled(); + }); +}); + +describe('page rendering', () => { + it('puts one preload link at the top of the app root', async () => { + function Page() { + return createElement( + 'main', + null, + createElement(Image, { + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + preload: true, + }), + createElement(Image, { + src: '/lcp.jpg', + alt: 'LCP again', + width: 800, + height: 400, + preload: true, + }), + createElement(Image, { src: '/below.jpg', alt: 'Below', width: 800, height: 400 }), + ); + } + const stream = await renderReactPage({ + pageObj: { component: 'page', props: {}, url: '/', params: {}, shared: {}, errors: null }, + assetTags: { css: '', preloads: '', body: '' }, + components: { page: async () => ({ default: Page }) }, + layoutTree: {}, + }); + const html = await new Response(stream).text(); + const app = html.slice(html.indexOf('
')); + + expect(app.startsWith('
{ + it('has the next/image module shape', () => { + expect(Object.keys(imageModule).sort()).toEqual(['Image', 'default', 'getImageProps']); + expect(imageModule.default).toBe(Image); + expect(imageModule.Image).toBe(Image); + expect(imageModule.getImageProps).toBe(getImageProps); + }); + + it('is exported from @void/react and the ./image subpath', () => { + expect(runtime.Image).toBe(Image); + expect(runtime.getImageProps).toBe(getImageProps); + expect(packageJson.exports['./image']).toEqual({ + types: './dist/image.d.mts', + import: './dist/image.mjs', + }); + }); +}); diff --git a/packages/react/vite.config.ts b/packages/react/vite.config.ts index bc9da4932..262191de2 100644 --- a/packages/react/vite.config.ts +++ b/packages/react/vite.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ pack: { entry: [ 'src/index.ts', + 'src/image.ts', 'src/plugin.ts', 'src/runtime/pages-client.ts', 'src/runtime/pages-server.ts', diff --git a/packages/solid/src/plugin-islands.ts b/packages/solid/src/plugin-islands.ts index 06e5c42d7..b029ec7b6 100644 --- a/packages/solid/src/plugin-islands.ts +++ b/packages/solid/src/plugin-islands.ts @@ -256,7 +256,8 @@ function generateIslandRenderer( return ` import { renderToStringAsync, generateHydrationScript } from "solid-js/web"; import { createComponent } from "solid-js"; -import { SharedContext, ErrorsContext, ParamsContext } from "@void/solid"; +import { SharedContext, ErrorsContext, ParamsContext, ImagePreloadContext } from "@void/solid"; +import { createImagePreloadCollector } from "void/image"; import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from "void/pages-head"; ${generateComponentManifest(scan, pagesDir)} @@ -274,25 +275,34 @@ export async function renderIslandPage(pageObj, assetTags) { layoutIds.map(async (id) => (await components[id]()).default) ); + const imagePreloads = createImagePreloadCollector(); + + // Image preloads go into . Islands mount with render(), not + // hydrate(), so the client needs no matching provider. function ServerApp() { - return createComponent(SharedContext.Provider, { - value: pageObj.shared || {}, + return createComponent(ImagePreloadContext.Provider, { + value: imagePreloads, get children() { - return createComponent(ErrorsContext.Provider, { - value: pageObj.errors || {}, + return createComponent(SharedContext.Provider, { + value: pageObj.shared || {}, get children() { - return createComponent(ParamsContext.Provider, { - value: pageObj.params || {}, + return createComponent(ErrorsContext.Provider, { + value: pageObj.errors || {}, get children() { - // Build layout chain inside providers so contexts are available. - // No RouterContext — islands have no Void Router. - let element = createComponent(PageComponent, pageObj.props); - for (let i = layoutComponents.length - 1; i >= 0; i--) { - const Layout = layoutComponents[i]; - const child = element; - element = createComponent(Layout, { get children() { return child; } }); - } - return element; + return createComponent(ParamsContext.Provider, { + value: pageObj.params || {}, + get children() { + // Build layout chain inside providers so contexts are available. + // No RouterContext — islands have no Void Router. + let element = createComponent(PageComponent, pageObj.props); + for (let i = layoutComponents.length - 1; i >= 0; i--) { + const Layout = layoutComponents[i]; + const child = element; + element = createComponent(Layout, { get children() { return child; } }); + } + return element; + } + }); } }); } @@ -310,7 +320,7 @@ export async function renderIslandPage(pageObj, assetTags) { return \` -\${headHtml}\${assetTags.css}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.preloads : ""}\${__pagesNeedingClientJS.has(pageObj.component) ? generateHydrationScript() : ""} +\${headHtml}\${imagePreloads.render()}\${assetTags.css}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.preloads : ""}\${__pagesNeedingClientJS.has(pageObj.component) ? generateHydrationScript() : ""}
\${html}
\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""} diff --git a/packages/solid/src/plugin-pages.ts b/packages/solid/src/plugin-pages.ts index 5ec15f82a..9901d9819 100644 --- a/packages/solid/src/plugin-pages.ts +++ b/packages/solid/src/plugin-pages.ts @@ -177,7 +177,8 @@ function generateRendererModule(scan: PageScanResult, pagesDir: string): string return ` import { renderToStringAsync, generateHydrationScript } from "solid-js/web"; import { createComponent } from "solid-js"; -import { SharedContext, ErrorsContext, ParamsContext, RouterContext, NavigationContext } from "@void/solid"; +import { SharedContext, ErrorsContext, ParamsContext, RouterContext, NavigationContext, ImagePreloadContext } from "@void/solid"; +import { createImagePreloadCollector } from "void/image"; import { createSsrRouter, idleNavigationState } from "void/pages-client"; import { renderHeadToString, renderHtmlAttrs, renderBodyAttrs } from "void/pages-head"; import { serializePageData } from "void/pages-server"; @@ -191,30 +192,39 @@ export async function renderPage(pageObj, assetTags) { layoutIds.map(async (id) => (await components[id]()).default) ); + const imagePreloads = createImagePreloadCollector(); + + // Image preloads go into . The client entry wraps its tree in the same + // provider: every component consumes a hydration key, so both trees need it. function ServerApp() { - return createComponent(SharedContext.Provider, { - value: pageObj.shared || {}, + return createComponent(ImagePreloadContext.Provider, { + value: imagePreloads, get children() { - return createComponent(ErrorsContext.Provider, { - value: pageObj.errors || {}, + return createComponent(SharedContext.Provider, { + value: pageObj.shared || {}, get children() { - return createComponent(RouterContext.Provider, { - value: createSsrRouter(pageObj.url || "/", pageObj.params || {}), + return createComponent(ErrorsContext.Provider, { + value: pageObj.errors || {}, get children() { - return createComponent(NavigationContext.Provider, { - value: () => idleNavigationState(), + return createComponent(RouterContext.Provider, { + value: createSsrRouter(pageObj.url || "/", pageObj.params || {}), get children() { - return createComponent(ParamsContext.Provider, { - value: pageObj.params || {}, + return createComponent(NavigationContext.Provider, { + value: () => idleNavigationState(), get children() { - // Build layout chain inside providers so contexts are available - let element = createComponent(PageComponent, pageObj.props); - for (let i = layoutComponents.length - 1; i >= 0; i--) { - const Layout = layoutComponents[i]; - const child = element; - element = createComponent(Layout, { get children() { return child; } }); - } - return element; + return createComponent(ParamsContext.Provider, { + value: pageObj.params || {}, + get children() { + // Build layout chain inside providers so contexts are available + let element = createComponent(PageComponent, pageObj.props); + for (let i = layoutComponents.length - 1; i >= 0; i--) { + const Layout = layoutComponents[i]; + const child = element; + element = createComponent(Layout, { get children() { return child; } }); + } + return element; + } + }); } }); } @@ -237,7 +247,7 @@ export async function renderPage(pageObj, assetTags) { return \` -\${headHtml}\${assetTags.css}\${assetTags.preloads}\${generateHydrationScript()} +\${headHtml}\${imagePreloads.render()}\${assetTags.css}\${assetTags.preloads}\${generateHydrationScript()}
\${html}
@@ -259,7 +269,7 @@ function generateClientEntry( import { createSignal, createComponent, createMemo, mergeProps, untrack, batch } from "solid-js"; import { createMutable } from "solid-js/store"; import { hydrate, render } from "solid-js/web"; -import { SharedContext, ErrorsContext, ParamsContext, RouterContext, NavigationContext, setActionRouter } from "@void/solid"; +import { SharedContext, ErrorsContext, ParamsContext, RouterContext, NavigationContext, ImagePreloadContext, setActionRouter } from "@void/solid"; import { createRouterFacade, createVoidRouter, idleNavigationState } from "void/pages-client"; import { parseCacheFor } from "void/pages-prefetch"; @@ -359,23 +369,30 @@ function renderSlot(depth) { }); } +// The server renderer provides an image preload collector here; this +// provider (null in the browser) keeps the hydration keys of both trees equal. function App() { - return createComponent(SharedContext.Provider, { - value: shared, + return createComponent(ImagePreloadContext.Provider, { + value: null, get children() { - return createComponent(ErrorsContext.Provider, { - value: errors, + return createComponent(SharedContext.Provider, { + value: shared, get children() { - return createComponent(RouterContext.Provider, { - value: reactiveRouter, + return createComponent(ErrorsContext.Provider, { + value: errors, get children() { - return createComponent(ParamsContext.Provider, { - get value() { return routeParams(); }, + return createComponent(RouterContext.Provider, { + value: reactiveRouter, get children() { - return createComponent(NavigationContext.Provider, { - value: navigation, + return createComponent(ParamsContext.Provider, { + get value() { return routeParams(); }, get children() { - return renderSlot(0); + return createComponent(NavigationContext.Provider, { + value: navigation, + get children() { + return renderSlot(0); + } + }); } }); } diff --git a/packages/solid/src/runtime/context.ts b/packages/solid/src/runtime/context.ts index f168918aa..0d3470ccd 100644 --- a/packages/solid/src/runtime/context.ts +++ b/packages/solid/src/runtime/context.ts @@ -1,4 +1,5 @@ import { createContext, type Context } from 'solid-js'; +import type { ImagePreloadCollector } from 'void/image'; import type { NavigationState, VoidRouter } from 'void/pages-client'; export const SharedContext: Context | null> = createContext = createContext NavigationState) | null> = createContext< (() => NavigationState) | null >(null); +/** + * Collects the `` tags of one server render + * for the page ``. `null` in the browser. + */ +export const ImagePreloadContext: Context = + createContext(null); diff --git a/packages/solid/src/runtime/image-props.ts b/packages/solid/src/runtime/image-props.ts new file mode 100644 index 000000000..51dffd071 --- /dev/null +++ b/packages/solid/src/runtime/image-props.ts @@ -0,0 +1,202 @@ +/** + * The JSX-free half of the Solid `Image`: its prop types, the mapping between + * Solid props and `void/image`, and `getImageProps`. `void/image` computes the + * attributes, so server and client render the same `srcset`. + */ +import type { JSX } from 'solid-js'; +import { + getImageProps as getCoreImageProps, + toHtmlImgAttributes, + type ImageCoreProps, + type ImageStyle, + type ImgProps, + type ImgPropsResult, +} from 'void/image'; + +export type { + ImageLoader, + ImageLoaderProps, + PlaceholderValue, + StaticImageData, + StaticImport, +} from 'void/image'; + +type ImgAttributes = JSX.ImgHTMLAttributes; + +/** + * `Image` props: `next/image`'s props plus ordinary Solid `` attributes. + * `class`, `fetchpriority` and `style` (string or object) work as on ``; + * `next/image`'s `className` and `fetchPriority` are accepted too. + */ +export type ImageProps = Omit & + Omit & { + style?: JSX.CSSProperties | string; + /** @deprecated Use `onLoad`. */ + onLoadingComplete?: (img: HTMLImageElement) => void; + }; + +/** + * The `` attributes `Image` renders, with HTML names (`srcset`, + * `fetchpriority`, `class`) and `style` as a CSS string. Spread them on an + * ``, or use `srcset` on a `` ``. + */ +export type ImageAttributes = Omit< + ImgAttributes, + 'src' | 'srcSet' | 'width' | 'height' | 'style' | 'loading' +> & { + loading?: 'lazy' | 'eager'; + width?: number; + height?: number; + style?: string; + src: string; +}; + +/** What `Image` spreads on its ``: `ImageAttributes` plus `data-nimg`. */ +export type ImageElementAttributes = ImageAttributes & { 'data-nimg': '1' | 'fill' }; + +// Vue's `parseStringStyle`: `;` outside parentheses ends a declaration. +const STYLE_DECLARATION_DELIMITER = /;(?![^(]*\))/g; +const STYLE_PROPERTY_DELIMITER = /:([\s\S]+)/; +const STYLE_COMMENT = /\/\*[\s\S]*?\*\//g; + +/** A CSS property name in the camelCase form `void/image` reads (`object-fit` → `objectFit`). */ +function toStyleKey(name: string): string { + if (name.startsWith('--') || !name.includes('-')) { + return name; + } + const camel = name.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); + // React spells the `-ms-` prefix in lowercase (`msTransform`), others capitalized (`WebkitMask`). + return name.startsWith('-ms-') ? `m${camel.slice(1)}` : camel; +} + +/** + * A Solid `style` value (a CSS string, or an object with kebab-case or + * camelCase names) as the camelCase object `void/image` merges with its own + * styles. Returns `undefined` for an empty value. + */ +export function toImageStyle(style: unknown): ImageStyle | undefined { + const result: ImageStyle = {}; + if (typeof style === 'string') { + for (const declaration of style.replace(STYLE_COMMENT, '').split(STYLE_DECLARATION_DELIMITER)) { + const [name, value] = declaration.split(STYLE_PROPERTY_DELIMITER); + if (value !== undefined && name.trim() && value.trim()) { + result[toStyleKey(name.trim())] = value.trim(); + } + } + } else if (style && typeof style === 'object') { + for (const [name, value] of Object.entries(style)) { + if (typeof value === 'string' || typeof value === 'number') { + result[toStyleKey(name)] = value; + } + } + } + return Object.keys(result).length ? result : undefined; +} + +/** + * Solid `Image` props as `void/image` input: `class` and `className` join + * into `className`, `style` becomes a camelCase object, `fetchpriority` fills + * in `fetchPriority`, and `srcset`, `ref` and `onload` are dropped (as + * `next/image` drops `srcSet` and `onLoad`). Other props keep their order and + * stay ahead of `Image`'s own attributes. + */ +export function toImageCoreProps( + props: Record, +): ImageCoreProps & Record { + const core: Record = {}; + const classes: Array = []; + let fetchpriority: unknown; + for (const key of Object.keys(props)) { + const value = props[key]; + if (value === undefined) { + continue; + } + switch (key) { + case 'class': + case 'className': + if (typeof value === 'string' && value) { + classes.push(value); + } + break; + case 'style': + core.style = toImageStyle(value); + break; + case 'fetchpriority': + fetchpriority = value; + break; + case 'srcset': + case 'srcSet': + case 'ref': + case 'onload': + break; + default: + core[key] = value; + } + } + if (classes.length) { + core.className = classes.join(' '); + } + if (core.fetchPriority === undefined && fetchpriority !== undefined) { + core.fetchPriority = fetchpriority; + } + return core as ImageCoreProps & Record; +} + +/** + * `void/image` props for Solid: the attributes `Image` computes get HTML names + * and a CSS string `style`; the other props pass through unchanged, first. + */ +function toImageAttributes(props: ImgProps, nimg?: '1' | 'fill'): ImageAttributes { + const { + loading, + fetchPriority, + width, + height, + decoding, + className, + style, + sizes, + srcSet, + src, + ...rest + } = props; + // `next/image` renders `fetchPriority` before `loading` and `data-nimg` + // after `decoding`; `getImageProps` keeps `getImgProps`'s order. + const own = nimg + ? { + fetchPriority, + loading, + width, + height, + decoding, + 'data-nimg': nimg, + className, + style, + sizes, + srcSet, + src, + } + : { loading, fetchPriority, width, height, decoding, className, style, sizes, srcSet, src }; + return { ...rest, ...toHtmlImgAttributes(own) } as ImageAttributes; +} + +/** + * The attributes `Image` spreads on its ``, in `next/image`'s order: + * other props first, then `fetchpriority`, `loading`, `width`, `height`, + * `decoding`, `data-nimg`, `class`, `style`, `sizes`, `srcset` and `src` + * last (Safari starts fetching `src` before `srcset` is set otherwise). + */ +export function toImageElementAttributes(result: ImgPropsResult): ImageElementAttributes { + return toImageAttributes(result.props, result.meta.fill ? 'fill' : '1') as ImageElementAttributes; +} + +/** + * The attributes `Image` would render, for another element, a `` + * `` or CSS (`next/image` `getImageProps`), with Solid's HTML + * attribute names: `srcset`, `fetchpriority`, `class`, and `style` as a CSS + * string. Attributes without a value are omitted. + */ +export function getImageProps(props: ImageProps): { props: ImageAttributes } { + const { props: imgProps } = getCoreImageProps(toImageCoreProps(props as Record)); + return { props: toImageAttributes(imgProps) }; +} diff --git a/packages/solid/src/runtime/image.tsx b/packages/solid/src/runtime/image.tsx new file mode 100644 index 000000000..d590db5c2 --- /dev/null +++ b/packages/solid/src/runtime/image.tsx @@ -0,0 +1,174 @@ +/** + * `Image` for Solid: `next/image` from Next.js 16. `image-props.ts` maps the + * props onto `void/image`; this file ports the component's load, error and + * preload behavior (`client/image-component.js`). + */ +import { + batch, + createMemo, + createSignal, + onMount, + splitProps, + useContext, + type JSX, +} from 'solid-js'; +import { isServer } from 'solid-js/web'; +import { + getImageConfig, + getImagePreloadLink, + handleImageLoading, + resolveImageProps, + type ImageCoreProps, +} from 'void/image'; +import { ImagePreloadContext } from './context.ts'; +import { toImageCoreProps, toImageElementAttributes, type ImageProps } from './image-props.ts'; + +export { + getImageProps, + type ImageAttributes, + type ImageLoader, + type ImageLoaderProps, + type ImageProps, + type PlaceholderValue, + type StaticImageData, + type StaticImport, +} from './image-props.ts'; + +/** Call a Solid event handler: a function, or a `[handler, data]` pair. */ +function callHandler(handler: unknown, event: Event): void { + if (Array.isArray(handler)) { + const [fn, data] = handler; + if (typeof fn === 'function') { + fn(data, event); + } + } else if (typeof handler === 'function') { + handler(event); + } +} + +/** A `load` event targeting `img`, for `onLoad` handlers called after decoding. */ +function createLoadEvent(img: HTMLImageElement): Event { + const event = new Event('load'); + Object.defineProperty(event, 'target', { writable: false, value: img }); + Object.defineProperty(event, 'currentTarget', { writable: false, value: img }); + return event; +} + +/** + * An `` with a responsive `srcset` from the image optimizer, lazy + * loading, an optional blur placeholder and an optional preload link. + * `onLoad` runs once the image is decoded, also for an image that finished + * loading before hydration. + */ +export function Image(props: ImageProps): JSX.Element { + const [local, others] = splitProps(props, [ + 'ref', + 'onLoad', + 'onload', + 'onError', + 'onerror', + 'onLoadingComplete', + ]); + const [blurComplete, setBlurComplete] = createSignal(false); + const [showAltText, setShowAltText] = createSignal(false); + const resolved = createMemo(() => + resolveImageProps( + // `onLoadingComplete` only reaches the core for its deprecation warning. + { + ...toImageCoreProps(others), + onLoadingComplete: local.onLoadingComplete as ImageCoreProps['onLoadingComplete'], + }, + { blurComplete: blurComplete(), showAltText: showAltText() }, + ), + ); + const attributes = createMemo(() => toImageElementAttributes(resolved())); + + if (isServer) { + // The page renderer puts collected links into ``. Islands render + // on the server too, so their preloads land there as well. + const preloads = useContext(ImagePreloadContext); + if (preloads) { + const { props: imgProps, meta } = resolved(); + const link = getImagePreloadLink(imgProps, meta); + if (link) { + preloads.add(link); + } + } + } + + let img: HTMLImageElement | undefined; + + function finishLoading(element: HTMLImageElement): void { + const { meta } = resolved(); + handleImageLoading(element, { + placeholder: meta.placeholder, + unoptimized: meta.unoptimized, + sizesInput: others.sizes, + dev: getImageConfig().dev, + onBlurComplete: () => setBlurComplete(true), + onLoad: (target) => { + if (local.onLoad || local.onload) { + const event = createLoadEvent(target); + callHandler(local.onLoad, event); + callHandler(local.onload, event); + } + local.onLoadingComplete?.(target); + }, + }); + } + + function onError(event: Event): void { + batch(() => { + // If the real image fails to load, this will ensure "alt" is visible + setShowAltText(true); + if (resolved().meta.placeholder !== 'empty') { + // If the real image fails to load, this will still remove the placeholder. + setBlurComplete(true); + } + }); + callHandler(local.onError, event); + callHandler(local.onerror, event); + } + + onMount(() => { + const element = img; + if (!element) { + return; + } + // Replay events from before hydration, which Solid does not replay. + if (local.onError || local.onerror) { + // If the image has an error before hydration, the error is lost. + // Setting the src again retries the request and triggers the error + // handler if it fails again. + // oxlint-disable-next-line no-self-assign + element.src = element.src; + } + if (getImageConfig().dev) { + if (!resolved().props.src) { + console.error(`Image is missing required "src" property:`, element); + } + if (element.getAttribute('alt') === null) { + console.error( + `Image is missing required "alt" property. Please add Alternative Text to describe the image for screen readers and search engines.`, + ); + } + } + if (element.complete) { + finishLoading(element); + } + }); + + return ( + { + img = element; + if (typeof local.ref === 'function') { + local.ref(element); + } + }} + onLoad={(event) => finishLoading(event.currentTarget)} + onError={onError} + /> + ); +} diff --git a/packages/solid/src/runtime/index.ts b/packages/solid/src/runtime/index.ts index cbe927720..3cbf9e39d 100644 --- a/packages/solid/src/runtime/index.ts +++ b/packages/solid/src/runtime/index.ts @@ -1,4 +1,15 @@ export { Form } from './form.tsx'; +export { + Image, + getImageProps, + type ImageAttributes, + type ImageLoader, + type ImageLoaderProps, + type ImageProps, + type PlaceholderValue, + type StaticImageData, + type StaticImport, +} from './image.tsx'; export { Link, type LinkProps } from './link.tsx'; export { useRouter } from './use-router.ts'; export { useParams } from './use-params.ts'; @@ -13,5 +24,6 @@ export { ParamsContext, RouterContext, NavigationContext, + ImagePreloadContext, } from './context.ts'; export type { DeferredState as Deferred, InferProps } from 'void'; diff --git a/packages/solid/test/unit/image-props.test.ts b/packages/solid/test/unit/image-props.test.ts new file mode 100644 index 000000000..af2443f0c --- /dev/null +++ b/packages/solid/test/unit/image-props.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { resolveImageProps, styleToString } from 'void/image'; +import { + getImageProps, + toImageCoreProps, + toImageElementAttributes, + toImageStyle, + type ImageProps, +} from '../../src/runtime/image-props.ts'; + +const A_SRCSET = '/_void/image?url=%2Fa.png&w=128&q=75 1x, /_void/image?url=%2Fa.png&w=256&q=75 2x'; +const A_SRC = '/_void/image?url=%2Fa.png&w=256&q=75'; + +describe('toImageStyle', () => { + it('reads kebab-case and camelCase objects as camelCase', () => { + expect( + toImageStyle({ + 'object-fit': 'cover', + 'object-position': 'top', + '-webkit-line-clamp': 2, + '-ms-transform': 'none', + '--accent': 'red', + zIndex: 2, + opacity: undefined, + }), + ).toStrictEqual({ + objectFit: 'cover', + objectPosition: 'top', + WebkitLineClamp: 2, + msTransform: 'none', + '--accent': 'red', + zIndex: 2, + }); + }); + + it('parses CSS strings', () => { + const style = toImageStyle( + 'object-fit: cover; background: url("a;b.png") no-repeat; /* note */ --accent: red;', + ); + + expect(style).toStrictEqual({ + objectFit: 'cover', + background: 'url("a;b.png") no-repeat', + '--accent': 'red', + }); + expect(styleToString(style ?? {})).toBe( + 'object-fit:cover;background:url("a;b.png") no-repeat;--accent:red', + ); + }); + + it.each(['', ' ; ', undefined, null, false])('returns undefined for %j', (value) => { + expect(toImageStyle(value)).toBeUndefined(); + }); +}); + +describe('toImageCoreProps', () => { + it('maps Solid attribute names onto next/image props', () => { + const onClick = () => {}; + expect( + toImageCoreProps({ + src: '/a.png', + alt: 'A', + class: 'one', + className: 'two', + fetchpriority: 'high', + style: 'object-fit: contain', + srcset: '/ignored.png 1x', + srcSet: '/ignored.png 2x', + ref: () => {}, + onload: () => {}, + onClick, + id: undefined, + }), + ).toStrictEqual({ + src: '/a.png', + alt: 'A', + style: { objectFit: 'contain' }, + onClick, + className: 'one two', + fetchPriority: 'high', + }); + }); + + it('prefers fetchPriority over fetchpriority', () => { + expect(toImageCoreProps({ fetchpriority: 'high', fetchPriority: 'low' }).fetchPriority).toBe( + 'low', + ); + }); +}); + +describe('toImageElementAttributes', () => { + it('renders next/image attribute order with HTML names and src last', () => { + const onClick = () => {}; + const classList = { active: true }; + const attributes = toImageElementAttributes( + resolveImageProps( + toImageCoreProps({ + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + id: 'avatar', + classList, + onClick, + class: 'rounded', + fetchpriority: 'low', + crossorigin: 'anonymous', + }), + ), + ); + + expect(Object.keys(attributes)).toEqual([ + 'alt', + 'id', + 'classList', + 'onClick', + 'crossorigin', + 'fetchpriority', + 'loading', + 'width', + 'height', + 'decoding', + 'data-nimg', + 'class', + 'style', + 'srcset', + 'src', + ]); + expect(attributes).toMatchObject({ + classList, + onClick, + fetchpriority: 'low', + loading: 'lazy', + 'data-nimg': '1', + class: 'rounded', + style: 'color:transparent', + srcset: A_SRCSET, + src: A_SRC, + }); + }); + + it('marks fill images and turns the placeholder into a CSS string', () => { + const attributes = toImageElementAttributes( + resolveImageProps( + toImageCoreProps({ + src: '/a.png', + alt: 'A', + fill: true, + placeholder: 'data:image/png;base64,AA', + style: { 'object-fit': 'contain' }, + }), + ), + ); + + expect(attributes['data-nimg']).toBe('fill'); + expect(attributes.style).toBe( + 'position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:contain;color:transparent;background-size:contain;background-position:50% 50%;background-repeat:no-repeat;background-image:url("data:image/png;base64,AA")', + ); + }); + + it('drops the placeholder after loading and the transparent color after an error', () => { + const props = toImageCoreProps({ + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + placeholder: 'data:image/png;base64,AA', + }); + + expect(toImageElementAttributes(resolveImageProps(props, { blurComplete: true })).style).toBe( + 'color:transparent', + ); + expect( + toImageElementAttributes(resolveImageProps(props, { blurComplete: true, showAltText: true })) + .style, + ).toBeUndefined(); + }); +}); + +// Expected values: next@16.4.2 `getImageProps()` (packages/void/test/unit/image-props.golden.ts) +// with Solid's attribute names and a CSS string style. +describe('getImageProps', () => { + it('returns HTML attributes in getImgProps order without undefined values', () => { + const { props } = getImageProps({ src: '/a.png', alt: 'A', width: 100, height: 100 }); + + expect(Object.entries(props)).toEqual([ + ['alt', 'A'], + ['loading', 'lazy'], + ['width', 100], + ['height', 100], + ['decoding', 'async'], + ['style', 'color:transparent'], + ['srcset', A_SRCSET], + ['src', A_SRC], + ]); + }); + + it('maps class, fill styles and sizes', () => { + const { props } = getImageProps({ + src: '/a.png', + alt: 'A', + fill: true, + sizes: '50vw', + class: 'c', + }); + + expect(props).toStrictEqual({ + alt: 'A', + loading: 'lazy', + decoding: 'async', + class: 'c', + style: + 'position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent', + sizes: '50vw', + srcset: + '/_void/image?url=%2Fa.png&w=384&q=75 384w, /_void/image?url=%2Fa.png&w=640&q=75 640w, /_void/image?url=%2Fa.png&w=750&q=75 750w, /_void/image?url=%2Fa.png&w=828&q=75 828w, /_void/image?url=%2Fa.png&w=1080&q=75 1080w, /_void/image?url=%2Fa.png&w=1200&q=75 1200w, /_void/image?url=%2Fa.png&w=1920&q=75 1920w, /_void/image?url=%2Fa.png&w=2048&q=75 2048w, /_void/image?url=%2Fa.png&w=3840&q=75 3840w', + src: '/_void/image?url=%2Fa.png&w=3840&q=75', + }); + }); + + it('keeps fetchpriority for preloaded images and drops lazy loading', () => { + const props: ImageProps = { + src: '/a.png', + alt: 'A', + width: 100, + height: 50, + preload: true, + fetchpriority: 'high', + }; + + expect(getImageProps(props).props).toStrictEqual({ + alt: 'A', + fetchpriority: 'high', + width: 100, + height: 50, + decoding: 'async', + style: 'color:transparent', + srcset: A_SRCSET, + src: A_SRC, + }); + }); + + it('serves unoptimized images unchanged', () => { + expect( + getImageProps({ src: '/a.png', alt: 'A', width: 100, height: 50, unoptimized: true }).props, + ).toStrictEqual({ + alt: 'A', + loading: 'lazy', + width: 100, + height: 50, + decoding: 'async', + style: 'color:transparent', + src: '/a.png', + }); + }); +}); diff --git a/packages/solid/test/unit/image.test.ts b/packages/solid/test/unit/image.test.ts new file mode 100644 index 000000000..48526adfa --- /dev/null +++ b/packages/solid/test/unit/image.test.ts @@ -0,0 +1,279 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { build, type Rolldown } from 'vite'; +import solid from 'vite-plugin-solid'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import type { ImageProps } from '../../src/runtime/image-props.ts'; + +const PACKAGE_ROOT = join(import.meta.dirname, '..', '..'); +const TMP = join(import.meta.dirname, '__tmp_image_ssr__'); + +type ServerRenderer = { + /** Render `Image`s inside an `ImagePreloadContext` provider, as the page renderer does. */ + render(images: Array): { html: string; head: string }; + /** Render an `Image` without a preload collector, as the browser does. */ + renderWithoutCollector(props: ImageProps): string; +}; + +let renderer: ServerRenderer; + +/** + * Compile the Solid `Image` as `vite-plugin-solid` compiles it for the + * server (hydratable SSR output) and load it with Solid's server renderer. + */ +async function compileServerRenderer(): Promise { + const runtime = join(PACKAGE_ROOT, 'src', 'runtime'); + // Inside the package, so `solid-js` and `void` resolve as for an app. + mkdirSync(TMP, { recursive: true }); + const entry = join(TMP, 'entry.ts'); + writeFileSync( + entry, + ` +import { createComponent } from "solid-js"; +import { renderToString } from "solid-js/web"; +import { createImagePreloadCollector } from "void/image"; +import { ImagePreloadContext } from ${JSON.stringify(join(runtime, 'context.ts'))}; +import { Image } from ${JSON.stringify(join(runtime, 'image.tsx'))}; + +export function render(images) { + const preloads = createImagePreloadCollector(); + const html = renderToString(() => + createComponent(ImagePreloadContext.Provider, { + value: preloads, + get children() { + return images.map((props) => createComponent(Image, props)); + }, + }), + ); + return { html, head: preloads.render() }; +} + +export function renderWithoutCollector(props) { + return renderToString(() => createComponent(Image, props)); +} +`, + ); + const output = (await build({ + root: PACKAGE_ROOT, + configFile: false, + logLevel: 'silent', + plugins: [solid({ ssr: true })], + build: { + ssr: entry, + write: false, + minify: false, + rollupOptions: { external: [/^solid-js(\/|$)/, /^void(\/|$)/] }, + }, + })) as Rolldown.RolldownOutput; + const chunk = output.output.find((item) => item.type === 'chunk' && item.isEntry); + if (!chunk || chunk.type !== 'chunk') { + throw new Error('Expected an SSR entry chunk.'); + } + const file = join(TMP, 'entry.mjs'); + writeFileSync(file, chunk.code); + return (await import(pathToFileURL(file).href)) as ServerRenderer; +} + +beforeAll(async () => { + renderer = await compileServerRenderer(); +}, 60_000); + +afterAll(() => { + rmSync(TMP, { recursive: true, force: true }); +}); + +/** Server markup without Solid's hydration keys. */ +function withoutHydrationKeys(html: string): string { + return html.replace(/ data-hk="[^"]*"/g, ''); +} + +type MarkupCase = { name: string; props: ImageProps; html: string; head?: string }; + +/** + * `html` is next@16.4.2's server markup for the same `next/image` props + * (`images.path` set to `/_void/image`), spelled as Solid renders it: HTML + * attribute names (`srcset`, `fetchpriority`, `referrerpolicy`) and `'` + * left unescaped. `head` holds the preload links the page renderer puts into + * ``; Next.js renders the same links with React's attribute names. + */ +const markupCases: Array = [ + { + name: 'fixed width (1x/2x)', + props: { src: '/avatars/ada.png', alt: 'Ada', width: 64, height: 64 }, + html: 'Ada', + }, + { + name: 'sizes with vw', + props: { + src: '/hero.jpg', + alt: 'Hero', + width: 1200, + height: 600, + sizes: '(max-width: 768px) 100vw, 50vw', + }, + html: 'Hero', + }, + { + name: 'fill with sizes and object-fit', + props: { + src: '/cover.jpg', + alt: 'Cover', + fill: true, + sizes: '33vw', + style: { 'object-fit': 'cover' }, + }, + html: 'Cover', + }, + { + name: 'preload with fetchpriority high', + props: { + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + preload: true, + fetchpriority: 'high', + }, + html: 'LCP', + head: '', + }, + { + name: 'priority (deprecated preload)', + props: { src: '/lcp.jpg', alt: 'LCP', width: 800, height: 400, priority: true }, + html: 'LCP', + head: '', + }, + { + name: 'preload fill with crossorigin and referrerpolicy', + props: { + src: 'https://images.example.com/lcp.jpg', + alt: 'LCP', + fill: true, + sizes: '100vw', + preload: true, + crossorigin: 'anonymous', + referrerpolicy: 'no-referrer', + }, + html: 'LCP', + head: '', + }, + { + name: 'preload unoptimized', + props: { + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + preload: true, + unoptimized: true, + }, + html: 'LCP', + head: '', + }, + { + name: 'loading eager (no preload)', + props: { src: '/a.png', alt: 'A', width: 100, height: 100, loading: 'eager' }, + html: 'A', + }, + { + name: 'fetchPriority low', + props: { src: '/a.png', alt: 'A', width: 100, height: 100, fetchPriority: 'low' }, + html: 'A', + }, + { + name: 'unoptimized', + props: { src: '/a.png', alt: 'A', width: 100, height: 100, unoptimized: true }, + html: 'A', + }, + { + name: 'custom loader prop', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + quality: 60, + loader: ({ src, width, quality }) => + `https://cdn.example.com${src}?w=${width}&q=${quality || 75}`, + }, + html: 'A', + }, + { + name: 'placeholder blur', + props: { + src: '/photo.jpg', + alt: 'Photo', + width: 400, + height: 300, + placeholder: 'blur', + blurDataURL: 'data:image/png;base64,iVBORw0KGgo=', + }, + html: "\"Photo\"", + }, + { + // Solid's server renderer ends a spread `class` with a space. + name: 'class and string style', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + class: 'rounded shadow', + style: 'border-radius: 8px; max-width: 100%; height: auto', + }, + html: 'A', + }, + { + name: 'static import', + props: { src: { src: '/assets/hero-abc123.jpg', width: 1600, height: 900 }, alt: 'Hero' }, + html: 'Hero', + }, + { + name: 'extra img attributes and handlers', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + id: 'avatar', + title: 'Ada', + decoding: 'sync', + draggable: false, + 'data-testid': 'avatar', + onLoad: () => {}, + onError: () => {}, + } as ImageProps, + html: 'A', + }, +]; + +describe('Image server rendering', () => { + it.each(markupCases)('$name', ({ props, html, head }) => { + const result = renderer.render([props]); + expect(withoutHydrationKeys(result.html)).toBe(html); + expect(result.head).toBe(head ?? ''); + }); + + it('collects one preload link per srcset', () => { + const lcp: ImageProps = { src: '/lcp.jpg', alt: 'LCP', width: 800, height: 400, preload: true }; + const result = renderer.render([lcp, { ...lcp, alt: 'Again' }, { ...lcp, sizes: '50vw' }]); + + expect(result.head.match(/ { + const html = renderer.renderWithoutCollector({ + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + preload: true, + }); + + expect(withoutHydrationKeys(html)).toBe( + markupCases.find((item) => item.name === 'priority (deprecated preload)')?.html, + ); + }); +}); diff --git a/packages/solid/test/unit/plugin-islands.test.ts b/packages/solid/test/unit/plugin-islands.test.ts index 08fc22072..19c350c86 100644 --- a/packages/solid/test/unit/plugin-islands.test.ts +++ b/packages/solid/test/unit/plugin-islands.test.ts @@ -34,6 +34,9 @@ function setupPlugin() { loadClientEntry(): string | undefined { return plugin.load.handler('\0virtual:void-islands-client'); }, + loadRenderer(): string | undefined { + return plugin.load.handler('\0virtual:void-islands-renderer'); + }, }; } @@ -125,3 +128,30 @@ export default function Layout(props) { return
{props.children}
; }`, expect(clientEntry).not.toContain('Sidebar'); }); }); + +describe('islandsPlugin — image preloads', () => { + beforeEach(() => { + mkdirSync(PAGES_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('collects preloads per request and puts them into ', async () => { + writeFile('index.island.tsx', `export default function Home() { return
Home
; }`); + + const { buildStart, loadRenderer } = setupPlugin(); + await buildStart(); + const renderer = loadRenderer() ?? ''; + const serverApp = renderer.slice(renderer.indexOf('function ServerApp() {')); + + expect(renderer).toContain('import { createImagePreloadCollector } from "void/image";'); + expect(renderer).toMatch(/import \{[^}]*\bImagePreloadContext\b[^}]*\} from "@void\/solid";/); + expect( + renderer.indexOf('const imagePreloads = createImagePreloadCollector();'), + ).toBeGreaterThan(renderer.indexOf('export async function renderIslandPage(')); + expect(/createComponent\(([\w.]+)/.exec(serverApp)?.[1]).toBe('ImagePreloadContext.Provider'); + expect(renderer).toContain('${headHtml}${imagePreloads.render()}${assetTags.css}'); + }); +}); diff --git a/packages/solid/test/unit/plugin-pages.test.ts b/packages/solid/test/unit/plugin-pages.test.ts index f65a59e0f..a78207060 100644 --- a/packages/solid/test/unit/plugin-pages.test.ts +++ b/packages/solid/test/unit/plugin-pages.test.ts @@ -1,5 +1,8 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { build, type Plugin, type Rolldown } from 'vite'; +import solid from 'vite-plugin-solid'; import { afterEach, describe, expect, it } from 'vite-plus/test'; import { pagesPlugin } from '../../src/plugin-pages.ts'; @@ -75,3 +78,111 @@ describe('pagesPlugin dev HMR', () => { expect(renderer).toContain('"test": () => import'); }); }); + +describe('pagesPlugin image preloads', () => { + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + async function loadModules() { + writePage('index.tsx', 'export default function Index() { return

Home

; }\n'); + const plugin = pagesPlugin(PAGES_DIR) as PagesPluginHooks; + getHookHandler(plugin.config)({}, { command: 'build' }); + await getHookHandler(plugin.buildStart).call({}); + const load = getHookHandler(plugin.load); + return { + renderer: (await load('\0virtual:void-pages-renderer')) ?? '', + client: (await load('\0virtual:void-pages-client')) ?? '', + }; + } + + /** The first `createComponent` call inside a generated root function. */ + function outermostComponent(code: string, root: string): string | undefined { + const body = code.slice(code.indexOf(`function ${root}() {`)); + return /createComponent\(([\w.]+)/.exec(body)?.[1]; + } + + it('collects preloads per request and puts them into ', async () => { + const { renderer } = await loadModules(); + + expect(renderer).toContain('import { createImagePreloadCollector } from "void/image";'); + expect( + renderer.indexOf('const imagePreloads = createImagePreloadCollector();'), + ).toBeGreaterThan(renderer.indexOf('export async function renderPage(')); + expect(renderer).toContain('${headHtml}${imagePreloads.render()}${assetTags.css}'); + }); + + it('wraps the server and client trees in the same outermost provider', async () => { + const { renderer, client } = await loadModules(); + + expect(outermostComponent(renderer, 'ServerApp')).toBe('ImagePreloadContext.Provider'); + expect(renderer).toContain('value: imagePreloads,'); + expect(outermostComponent(client, 'App')).toBe('ImagePreloadContext.Provider'); + expect(client).toMatch(/createComponent\(ImagePreloadContext\.Provider, \{\s+value: null,/); + for (const code of [renderer, client]) { + expect(code).toMatch(/import \{[^}]*\bImagePreloadContext\b[^}]*\} from "@void\/solid";/); + expect(code.match(/createComponent\(\w+Context\.Provider/g)).toHaveLength(6); + } + }); +}); + +describe('pagesPlugin renderer', () => { + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('renders the preload links of a page into ', async () => { + writePage( + 'index.tsx', + `import { Image } from '@void/solid'; +export default function Index() { + return
LCP
; +} +`, + ); + // The renderer's own input only: the plugin's config hook adds the client entry. + const { config: _config, ...plugin } = pagesPlugin(PAGES_DIR); + const output = (await build({ + root: TMP, + configFile: false, + logLevel: 'silent', + plugins: [solid({ ssr: true }), plugin as Plugin], + resolve: { + alias: { '@void/solid': join(import.meta.dirname, '../../src/runtime/index.ts') }, + }, + build: { + ssr: true, + write: false, + minify: false, + rollupOptions: { + input: 'virtual:void-pages-renderer', + external: [/^solid-js(\/|$)/, /^void(\/|$)/], + output: { codeSplitting: false }, + }, + }, + })) as Rolldown.RolldownOutput; + const chunk = output.output.find((item) => item.type === 'chunk' && item.isEntry); + if (!chunk || chunk.type !== 'chunk') { + throw new Error('Expected a renderer chunk.'); + } + const file = join(TMP, 'renderer.mjs'); + writeFileSync(file, chunk.code); + const { renderPage } = (await import(pathToFileURL(file).href)) as { + renderPage( + pageObj: Record, + assetTags: Record, + ): Promise; + }; + const assetTags = { css: '', preloads: '', body: '' }; + const page = { component: 'index', props: {}, url: '/', params: {} }; + + const html = await renderPage(page, assetTags); + const link = + ''; + + expect(html).toContain(`${link}`); + expect(html).toMatch(/
LCP + import { onMount } from "svelte"; + import { + getImageConfig, + getImagePreloadLink, + handleImageLoading, + resolveImageProps, + type HandleImageLoadingOptions, + } from "void/image"; + import { + createImageLoadEvent, + getImgAttributes, + toCoreImageProps, + type ImageProps, + } from "./image.ts"; + import ImagePreload from "./ImagePreload.svelte"; + + type ImageEvent = Event & { currentTarget: EventTarget & HTMLImageElement }; + + let { onload = undefined, onerror = undefined, ...props }: ImageProps = $props(); + + let img = $state(null); + let blurComplete = $state(false); + let showAltText = $state(false); + + const resolved = $derived( + resolveImageProps(toCoreImageProps(props), { blurComplete, showAltText }), + ); + const attributes = $derived(getImgAttributes(resolved)); + const preloadLink = $derived(getImagePreloadLink(resolved.props, resolved.meta)); + + function loadingOptions(): HandleImageLoadingOptions { + return { + placeholder: resolved.meta.placeholder, + unoptimized: resolved.meta.unoptimized, + sizesInput: props.sizes, + dev: getImageConfig().dev, + onBlurComplete() { + blurComplete = true; + }, + onLoad(target) { + onload?.(createImageLoadEvent(target)); + props.onLoadingComplete?.(target); + }, + }; + } + + function handleLoad(event: Event) { + handleImageLoading((event as ImageEvent).currentTarget, loadingOptions()); + } + + function handleError(event: Event) { + // If the image fails to load, show the alt text and drop the placeholder. + showAltText = true; + if (resolved.meta.placeholder !== "empty") { + blurComplete = true; + } + onerror?.(event as ImageEvent); + } + + // On hydration, Svelte replays a load or error event the server-rendered + // fired earlier (through inline capture handlers), so unlike + // `next/image` this does not reset `img.src` to retry an error. As in + // `next/image`, an image that already finished loading is handled here, + // which also covers pages whose CSP blocks those inline handlers. + onMount(() => { + if (!img) { + return; + } + if (getImageConfig().dev) { + if (!attributes.src) { + console.error(`Image is missing required "src" property:`, img); + } + if (img.getAttribute("alt") === null) { + console.error( + `Image is missing required "alt" property. Please add Alternative Text to describe the image for screen readers and search engines.`, + ); + } + } + if (img.complete) { + handleImageLoading(img, loadingOptions()); + } + }); + + + +{#if preloadLink}{/if} diff --git a/packages/svelte/src/runtime/ImagePreload.svelte b/packages/svelte/src/runtime/ImagePreload.svelte new file mode 100644 index 000000000..236d22a64 --- /dev/null +++ b/packages/svelte/src/runtime/ImagePreload.svelte @@ -0,0 +1,27 @@ + + + + + diff --git a/packages/svelte/src/runtime/image.ts b/packages/svelte/src/runtime/image.ts new file mode 100644 index 000000000..49ddacddc --- /dev/null +++ b/packages/svelte/src/runtime/image.ts @@ -0,0 +1,209 @@ +import type { ClassValue, HTMLImgAttributes } from 'svelte/elements'; +import { + getImageProps as getCoreImageProps, + toHtmlImgAttributes, + type ImageCoreProps, + type ImageStyle, + type ImgPropsResult, +} from 'void/image'; + +type ImageEventHandler = ( + event: Event & { currentTarget: EventTarget & HTMLImageElement }, +) => unknown; + +/** + * `Image` props: the `next/image` props, with Svelte's names for `class`, + * `style` (a string or a style object) and the `onload` / `onerror` events, + * plus any other `` attribute. + */ +export type ImageProps = Omit< + HTMLImgAttributes, + keyof ImageCoreProps | 'children' | 'class' | 'style' | 'srcset' | 'onload' | 'onerror' +> & + Omit & { + class?: ClassValue | null; + style?: string | ImageStyle | null; + /** Runs once the image has loaded and decoded, as in `next/image`. */ + onload?: ImageEventHandler | null; + onerror?: ImageEventHandler | null; + /** @deprecated Use `onload`. */ + onLoadingComplete?: (img: HTMLImageElement) => void; + }; + +/** `` attributes returned by `getImageProps`, with HTML names, for ``. */ +export type ImgAttributes = { + [attribute: string]: unknown; + loading?: 'lazy' | 'eager'; + fetchpriority?: 'high' | 'low' | 'auto'; + width?: number; + height?: number; + decoding?: 'async' | 'auto' | 'sync'; + class?: ClassValue; + style?: string; + sizes?: string; + srcset?: string; + src: string; +}; + +function toStyleKey(name: string): string { + if (name.startsWith('--')) { + return name; + } + // React's names: `-webkit-line-clamp` is `WebkitLineClamp`, `-ms-transform` is `msTransform`. + const key = name.toLowerCase().replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); + return key.startsWith('Ms') ? `ms${key.slice(2)}` : key; +} + +/** + * A `style` attribute value as a style object (`object-fit` becomes + * `objectFit`), so `Image` merges it with its own styles and reads + * `object-fit` / `object-position` as `next/image` reads `style`. + */ +export function parseStyle(style: string): ImageStyle { + const result: ImageStyle = {}; + let declaration = ''; + let quote = ''; + let depth = 0; + const add = () => { + const colon = declaration.indexOf(':'); + const name = declaration.slice(0, colon).trim(); + const value = declaration.slice(colon + 1).trim(); + if (colon > 0 && name && value) { + result[toStyleKey(name)] = value; + } + declaration = ''; + }; + for (const char of style) { + if (quote) { + if (char === quote) { + quote = ''; + } + } else if (char === '"' || char === "'") { + quote = char; + } else if (char === '(') { + depth++; + } else if (char === ')') { + depth = Math.max(0, depth - 1); + } else if (char === ';' && depth === 0) { + add(); + continue; + } + declaration += char; + } + add(); + return result; +} + +/** + * `Image` props as `void/image` props: `class` becomes `className`, a + * `style` string becomes a style object, `fetchpriority` becomes + * `fetchPriority`. `srcset` is dropped (`Image` computes it) and so is + * `onload`, which `Image` calls after decoding (as `next/image` drops `onLoad`). + */ +export function toCoreImageProps( + props: Record, +): ImageCoreProps & Record { + const { + class: className, + style, + fetchpriority, + srcset: _srcset, + onload: _onload, + ...rest + } = props; + const core: Record = rest; + if (className != null) { + core.className = className; + } + if (typeof style === 'string') { + core.style = parseStyle(style); + } else if (style != null) { + core.style = style; + } + if (core.fetchPriority === undefined && fetchpriority != null) { + core.fetchPriority = fetchpriority; + } + return core as ImageCoreProps & Record; +} + +/** + * `void/image` `` props as Svelte element attributes: HTML names, a + * `style` string, the `class` value as given (Svelte resolves arrays and + * objects), in the same order. Event handlers and symbol-keyed props + * (attachments) pass through; `undefined` values are dropped. + */ +export function toSvelteImgAttributes(props: Record): ImgAttributes { + const attributes: Record = {}; + for (const [key, value] of Object.entries(props)) { + if (key === 'className') { + if (value != null) { + attributes.class = value; + } + } else if (typeof value === 'function') { + attributes[key] = value; + } else { + Object.assign(attributes, toHtmlImgAttributes({ [key]: value })); + } + } + for (const symbol of Object.getOwnPropertySymbols(props)) { + attributes[symbol] = props[symbol]; + } + return attributes as ImgAttributes; +} + +/** + * The attributes `Image` renders on its ``, in `next/image`'s order: + * other attributes, `fetchpriority`, `loading`, `width`, `height`, + * `decoding`, `data-nimg`, `class`, `style`, `sizes`, `srcset`, and `src` + * last, so browsers do not fetch `src` before `srcset` and `sizes` are set. + */ +export function getImgAttributes({ props, meta }: ImgPropsResult): ImgAttributes { + const { + fetchPriority, + loading, + width, + height, + decoding, + className, + style, + sizes, + srcSet, + src, + ...rest + } = props; + return toSvelteImgAttributes({ + ...rest, + fetchPriority, + loading, + width, + height, + decoding, + 'data-nimg': meta.fill ? 'fill' : '1', + className, + style, + sizes, + srcSet, + src, + }); +} + +/** The `load` event `Image` passes to `onload` after decoding, like `next/image`. */ +export function createImageLoadEvent( + img: HTMLImageElement, +): Event & { currentTarget: EventTarget & HTMLImageElement } { + const event = new Event('load'); + Object.defineProperty(event, 'target', { value: img }); + Object.defineProperty(event, 'currentTarget', { value: img }); + return event as Event & { currentTarget: EventTarget & HTMLImageElement }; +} + +/** + * The attributes `Image` would render on its `` (without `data-nimg`), + * for another element, a `` source or CSS. Names are HTML attribute + * names (`srcset`, `fetchpriority`, `class`) and `style` is a string, so the + * result spreads onto ``. `onload` is not included. + */ +export function getImageProps(props: ImageProps): { props: ImgAttributes } { + const { props: imgProps } = getCoreImageProps(toCoreImageProps(props)); + return { props: toSvelteImgAttributes(imgProps) }; +} diff --git a/packages/svelte/src/runtime/index.ts b/packages/svelte/src/runtime/index.ts index 4e5240d3f..492db2a98 100644 --- a/packages/svelte/src/runtime/index.ts +++ b/packages/svelte/src/runtime/index.ts @@ -1,4 +1,5 @@ export { default as Form } from './Form.svelte'; +export { default as Image } from './Image.svelte'; export { default as Link } from './Link.svelte'; export { useRouter } from './use-router.ts'; export { useParams } from './use-params.ts'; @@ -7,4 +8,6 @@ export { useForm } from './use-form.svelte.ts'; export { useNavigation } from './use-navigation.ts'; export { useIslandForm } from './use-island-form.svelte.ts'; export { action, setActionRouter } from './action.ts'; +export { getImageProps } from './image.ts'; +export type { ImageProps } from './image.ts'; export type { DeferredState as Deferred, InferProps } from 'void'; diff --git a/packages/svelte/test/unit/image.test.ts b/packages/svelte/test/unit/image.test.ts new file mode 100644 index 000000000..d6ae31786 --- /dev/null +++ b/packages/svelte/test/unit/image.test.ts @@ -0,0 +1,485 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { svelte } from '@sveltejs/vite-plugin-svelte'; +import type { Component } from 'svelte'; +import { render } from 'svelte/server'; +import { build, type Rolldown } from 'vite'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; +import { getImageConfig, IMAGE_REGISTRY_KEY } from 'void/image'; +import { + createImageLoadEvent, + getImageProps, + parseStyle, + type ImageProps, +} from '../../src/runtime/image.ts'; + +// Expected values come from Next.js 16 `getImgProps` results recorded in +// packages/void/test/unit/image-props.golden.ts (case names in comments), +// written with HTML attribute names in `next/image`'s `` order. + +const PACKAGE_DIR = join(import.meta.dirname, '../..'); +const TMP = join(import.meta.dirname, '__tmp_image__'); + +/** Svelte's server output captures `load` / `error` before hydration on every `` with a spread. */ +const SVELTE_EVENT_CAPTURE: Array<[string, string]> = [ + ['onload', 'this.__e=event'], + ['onerror', 'this.__e=event'], +]; + +const BLUR_SVG = + "data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 50'%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3CfeColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/%3E%3CfeFlood x='0' y='0' width='100%25' height='100%25'/%3E%3CfeComposite operator='out' in='s'/%3E%3CfeComposite in2='SourceGraphic'/%3E%3CfeGaussianBlur stdDeviation='20'/%3E%3C/filter%3E%3Cimage width='100%25' height='100%25' x='0' y='0' preserveAspectRatio='none' style='filter: url(%23b);' href='data:image/png;base64,YY'/%3E%3C/svg%3E"; + +const FILL_STYLE = + 'position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;object-fit:cover;color:transparent'; + +function fillSrcSet(src: string, widths: Array): string { + return widths.map((w) => `/_void/image?url=${src}&w=${w}&q=75 ${w}w`).join(', '); +} + +let Image: Component; + +beforeAll(async () => { + // The unit project has no Svelte plugin: compile the shipped component for + // the server with Vite, keeping `svelte` and `void` external so the render + // below shares their instances (and the image config registry). + const output = (await build({ + root: PACKAGE_DIR, + configFile: false, + logLevel: 'silent', + plugins: [svelte()], + build: { + ssr: join(PACKAGE_DIR, 'src/runtime/Image.svelte'), + write: false, + rollupOptions: { external: [/^svelte($|\/)/, /^void($|\/)/] }, + }, + })) as Rolldown.RolldownOutput; + const entry = output.output.find( + (item): item is Rolldown.OutputChunk => item.type === 'chunk' && item.isEntry, + )!; + mkdirSync(TMP, { recursive: true }); + const file = join(TMP, 'Image.mjs'); + writeFileSync(file, entry.code); + Image = ((await import(pathToFileURL(file).href)) as { default: Component }).default; +}, 60_000); + +afterAll(() => { + rmSync(TMP, { recursive: true, force: true }); +}); + +function decode(value: string): string { + return value + .replaceAll('"', '"') + .replaceAll(''', "'") + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('&', '&'); +} + +/** The attributes of every `` in `html`, in source order, decoded. */ +function tags(html: string, tag: string): Array> { + const pattern = new RegExp(`<${tag}((?:\\s+[^\\s=/>]+(?:="[^"]*")?)*)\\s*/?>`, 'g'); + return [...html.matchAll(pattern)].map(([, attributes]) => + [...attributes.matchAll(/\s+([^\s=/>]+)(?:="([^"]*)")?/g)].map( + ([, name, value = '']): [string, string] => [name, decode(value)], + ), + ); +} + +function renderImage(props: ImageProps): { + img: Array<[string, string]>; + links: Array>; +} { + const { body, head } = render(Image, { props }); + const images = tags(body, 'img'); + expect(images).toHaveLength(1); + // Only the (and Svelte's hydration comments): no text nodes around it. + expect(body.replaceAll(//g, '')).toMatch(/^$/); + return { img: images[0], links: tags(head, 'link') }; +} + +describe('Image server rendering', () => { + it('renders the default markup (issue example avatar)', () => { + const { img, links } = renderImage({ + src: '/avatars/ada.png', + alt: 'Ada', + width: 64, + height: 64, + }); + expect(img).toEqual([ + ['alt', 'Ada'], + ['loading', 'lazy'], + ['width', '64'], + ['height', '64'], + ['decoding', 'async'], + ['data-nimg', '1'], + ['style', 'color:transparent'], + [ + 'srcset', + '/_void/image?url=%2Favatars%2Fada.png&w=64&q=75 1x, /_void/image?url=%2Favatars%2Fada.png&w=128&q=75 2x', + ], + ['src', '/_void/image?url=%2Favatars%2Fada.png&w=128&q=75'], + ...SVELTE_EVENT_CAPTURE, + ]); + expect(links).toEqual([]); + }); + + it('renders fill images with class and a style string (fill with sizes, className and objectFit)', () => { + const { img } = renderImage({ + src: 'https://example.com/a.png', + alt: 'A', + fill: true, + sizes: '50vw', + class: 'c', + style: 'object-fit: cover', + }); + expect(img).toEqual([ + ['alt', 'A'], + ['loading', 'lazy'], + ['decoding', 'async'], + ['data-nimg', 'fill'], + ['class', 'c'], + ['style', FILL_STYLE], + ['sizes', '50vw'], + [ + 'srcset', + fillSrcSet( + 'https%3A%2F%2Fexample.com%2Fa.png', + [384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840], + ), + ], + ['src', '/_void/image?url=https%3A%2F%2Fexample.com%2Fa.png&w=3840&q=75'], + ...SVELTE_EVENT_CAPTURE, + ]); + }); + + it('accepts a style object like next/image', () => { + const { img } = renderImage({ + src: 'https://example.com/a.png', + alt: 'A', + fill: true, + sizes: '50vw', + style: { objectFit: 'cover' }, + }); + expect(img.find(([name]) => name === 'style')).toEqual(['style', FILL_STYLE]); + }); + + it.each([ + ['fetchPriority', { fetchPriority: 'high' }], + ['fetchpriority', { fetchpriority: 'high' }], + ] as const)( + 'preloads through svelte:head (preload with fetchPriority high, %s)', + (_, priority) => { + const { img, links } = renderImage({ + src: '/a.png', + alt: 'A', + width: 10, + height: 10, + preload: true, + ...priority, + }); + expect(img).toEqual([ + ['alt', 'A'], + ['fetchpriority', 'high'], + ['width', '10'], + ['height', '10'], + ['decoding', 'async'], + ['data-nimg', '1'], + ['style', 'color:transparent'], + ['srcset', '/_void/image?url=%2Fa.png&w=32&q=75 1x'], + ['src', '/_void/image?url=%2Fa.png&w=32&q=75'], + ...SVELTE_EVENT_CAPTURE, + ]); + // As in Next.js, `href` is omitted when the image has a `srcset`. + expect(links).toEqual([ + [ + ['rel', 'preload'], + ['as', 'image'], + ['imagesrcset', '/_void/image?url=%2Fa.png&w=32&q=75 1x'], + ['fetchpriority', 'high'], + ], + ]); + }, + ); + + it('preloads unoptimized images by href with CORS attributes (deprecated priority)', () => { + const { img, links } = renderImage({ + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + priority: true, + unoptimized: true, + crossorigin: 'anonymous', + referrerpolicy: 'no-referrer', + }); + expect(img).toEqual([ + ['alt', 'LCP'], + ['crossorigin', 'anonymous'], + ['referrerpolicy', 'no-referrer'], + ['width', '800'], + ['height', '400'], + ['decoding', 'async'], + ['data-nimg', '1'], + ['style', 'color:transparent'], + ['src', '/lcp.jpg'], + ...SVELTE_EVENT_CAPTURE, + ]); + expect(links).toEqual([ + [ + ['rel', 'preload'], + ['as', 'image'], + ['href', '/lcp.jpg'], + ['crossorigin', 'anonymous'], + ['referrerpolicy', 'no-referrer'], + ], + ]); + }); + + it('renders the blur placeholder from a style string (blur with objectFit contain)', () => { + const { img } = renderImage({ + src: '/a.png', + alt: 'A', + width: 100, + height: 50, + placeholder: 'blur', + blurDataURL: 'data:image/png;base64,YY', + style: 'object-fit: contain', + }); + expect(img).toEqual([ + ['alt', 'A'], + ['loading', 'lazy'], + ['width', '100'], + ['height', '50'], + ['decoding', 'async'], + ['data-nimg', '1'], + [ + 'style', + `color:transparent;object-fit:contain;background-size:contain;background-position:50% 50%;background-repeat:no-repeat;background-image:url("${BLUR_SVG}")`, + ], + [ + 'srcset', + '/_void/image?url=%2Fa.png&w=128&q=75 1x, /_void/image?url=%2Fa.png&w=256&q=75 2x', + ], + ['src', '/_void/image?url=%2Fa.png&w=256&q=75'], + ...SVELTE_EVENT_CAPTURE, + ]); + }); + + it('keeps other attributes first and ignores srcset and handlers (className and extra attributes)', () => { + const { img } = renderImage({ + id: 'hero', + src: '/a.png', + alt: 'A', + 'data-x': '1', + width: 100, + height: 50, + class: 'img', + crossorigin: 'anonymous', + referrerpolicy: 'no-referrer', + srcset: '/user.png 1x', + onload: () => {}, + onclick: () => {}, + onLoadingComplete: () => {}, + } as ImageProps); + expect(img).toEqual([ + ['id', 'hero'], + ['alt', 'A'], + ['data-x', '1'], + ['crossorigin', 'anonymous'], + ['referrerpolicy', 'no-referrer'], + ['loading', 'lazy'], + ['width', '100'], + ['height', '50'], + ['decoding', 'async'], + ['data-nimg', '1'], + ['class', 'img'], + ['style', 'color:transparent'], + [ + 'srcset', + '/_void/image?url=%2Fa.png&w=128&q=75 1x, /_void/image?url=%2Fa.png&w=256&q=75 2x', + ], + ['src', '/_void/image?url=%2Fa.png&w=256&q=75'], + ...SVELTE_EVENT_CAPTURE, + ]); + }); + + it('reads dimensions from static imports (static import)', () => { + const { img } = renderImage({ + src: { src: '/assets/a.png', width: 800, height: 600 }, + alt: 'A', + }); + expect(img.filter(([name]) => ['width', 'height', 'srcset', 'src'].includes(name))).toEqual([ + ['width', '800'], + ['height', '600'], + [ + 'srcset', + '/_void/image?url=%2Fassets%2Fa.png&w=828&q=75 1x, /_void/image?url=%2Fassets%2Fa.png&w=1920&q=75 2x', + ], + ['src', '/_void/image?url=%2Fassets%2Fa.png&w=1920&q=75'], + ]); + }); + + it('runs the development checks of the registered config (missing width)', () => { + const registry = globalThis as Record; + registry[IMAGE_REGISTRY_KEY] = { config: { ...getImageConfig(), dev: true } }; + try { + // `render` runs when its output is read. + expect(() => render(Image, { props: { src: '/a.png', alt: 'A', height: 50 } }).body).toThrow( + 'Image with src "/a.png" is missing required "width" property.', + ); + } finally { + delete registry[IMAGE_REGISTRY_KEY]; + } + }); + + it('uses the registered images config (config unoptimized)', () => { + const registry = globalThis as Record; + registry[IMAGE_REGISTRY_KEY] = { config: { ...getImageConfig(), unoptimized: true } }; + try { + const { img } = renderImage({ src: '/a.png', alt: 'A', width: 100, height: 50 }); + expect(img.filter(([name]) => name === 'srcset' || name === 'src')).toEqual([ + ['src', '/a.png'], + ]); + } finally { + delete registry[IMAGE_REGISTRY_KEY]; + } + }); +}); + +describe('getImageProps', () => { + it('returns HTML attribute names (getImageProps defaults)', () => { + const { props } = getImageProps({ src: '/a.png', alt: 'A', width: 100, height: 100 }); + expect(Object.entries(props)).toEqual([ + ['alt', 'A'], + ['loading', 'lazy'], + ['width', 100], + ['height', 100], + ['decoding', 'async'], + ['style', 'color:transparent'], + [ + 'srcset', + '/_void/image?url=%2Fa.png&w=128&q=75 1x, /_void/image?url=%2Fa.png&w=256&q=75 2x', + ], + ['src', '/_void/image?url=%2Fa.png&w=256&q=75'], + ]); + }); + + it('maps class, sizes and fill styles (getImageProps fill)', () => { + const { props } = getImageProps({ + src: '/a.png', + alt: 'A', + fill: true, + sizes: '50vw', + class: 'c', + }); + expect(Object.entries(props)).toEqual([ + ['alt', 'A'], + ['loading', 'lazy'], + ['decoding', 'async'], + ['class', 'c'], + [ + 'style', + 'position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent', + ], + ['sizes', '50vw'], + ['srcset', fillSrcSet('%2Fa.png', [384, 640, 750, 828, 1080, 1200, 1920, 2048, 3840])], + ['src', '/_void/image?url=%2Fa.png&w=3840&q=75'], + ]); + }); + + it('keeps fetchpriority and drops loading for preloaded images (getImageProps preload)', () => { + const { props } = getImageProps({ + src: '/a.png', + alt: 'A', + width: 100, + height: 50, + preload: true, + fetchpriority: 'high', + }); + expect(Object.entries(props)).toEqual([ + ['alt', 'A'], + ['fetchpriority', 'high'], + ['width', 100], + ['height', 50], + ['decoding', 'async'], + ['style', 'color:transparent'], + [ + 'srcset', + '/_void/image?url=%2Fa.png&w=128&q=75 1x, /_void/image?url=%2Fa.png&w=256&q=75 2x', + ], + ['src', '/_void/image?url=%2Fa.png&w=256&q=75'], + ]); + }); + + it('keeps symbol-keyed props such as Svelte attachments', () => { + const attachment = Symbol('attachment'); + const attach = () => {}; + const { props } = getImageProps({ + src: '/a.png', + alt: 'A', + width: 100, + height: 50, + [attachment]: attach, + } as ImageProps); + expect((props as Record)[attachment]).toBe(attach); + }); + + it('keeps event handlers other than onload, as next/image keeps onError', () => { + const onerror = () => {}; + const { props } = getImageProps({ + src: '/a.png', + alt: 'A', + width: 100, + height: 50, + onload: () => {}, + onerror, + }); + expect(props.onerror).toBe(onerror); + expect('onload' in props).toBe(false); + }); +}); + +describe('parseStyle', () => { + it('converts declarations to style object keys', () => { + expect( + parseStyle( + 'object-fit: cover; Object-Position: 10% 20%;-webkit-line-clamp:2;-ms-transform: none; --gap: 4px;;', + ), + ).toEqual({ + objectFit: 'cover', + objectPosition: '10% 20%', + WebkitLineClamp: '2', + msTransform: 'none', + '--gap': '4px', + }); + }); + + it('keeps semicolons and colons inside url() and strings', () => { + expect( + parseStyle( + `background-image: url("data:image/png;base64,AA"); content: 'a;b'; mask: url(data:image/svg+xml;utf8,x)`, + ), + ).toEqual({ + backgroundImage: 'url("data:image/png;base64,AA")', + content: "'a;b'", + mask: 'url(data:image/svg+xml;utf8,x)', + }); + }); + + it('ignores declarations without a name or value', () => { + expect(parseStyle(': red; color:; color: red !important')).toEqual({ + color: 'red !important', + }); + }); +}); + +describe('createImageLoadEvent', () => { + it('creates a load event targeting the image', () => { + const img = {} as HTMLImageElement; + const event = createImageLoadEvent(img); + expect(event.type).toBe('load'); + expect(event.target).toBe(img); + expect(event.currentTarget).toBe(img); + }); +}); diff --git a/packages/vue/src/plugin-islands.ts b/packages/vue/src/plugin-islands.ts index e204adbca..006134e8d 100644 --- a/packages/vue/src/plugin-islands.ts +++ b/packages/vue/src/plugin-islands.ts @@ -298,7 +298,10 @@ export async function renderIslandPage(pageObj, assetTags) { } }); - const html = await renderToString(app); + // \`Image\` collects its tags in the SSR context. + const ssrContext = {}; + const html = await renderToString(app, ssrContext); + const imagePreloads = ssrContext.__voidImagePreloads ? ssrContext.__voidImagePreloads.render() : ""; const __mdScriptAttr = __mdClientScriptIds.has(pageObj.component) ? \` data-md-script="\${escapeAttr(pageObj.component)}"\` : ""; const headHtml = pageObj.head ? renderHeadToString(pageObj.head) : ""; const htmlAttrs = pageObj.head ? renderHtmlAttrs(pageObj.head) : ""; @@ -306,7 +309,7 @@ export async function renderIslandPage(pageObj, assetTags) { return \` -\${headHtml}\${assetTags.css}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.preloads : ""} +\${headHtml}\${imagePreloads}\${assetTags.css}\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.preloads : ""}
\${html}
\${__pagesNeedingClientJS.has(pageObj.component) ? assetTags.body : ""} diff --git a/packages/vue/src/plugin-pages.ts b/packages/vue/src/plugin-pages.ts index a1629774c..e6c12c950 100644 --- a/packages/vue/src/plugin-pages.ts +++ b/packages/vue/src/plugin-pages.ts @@ -301,7 +301,10 @@ export async function renderPage(pageObj, assetTags) { } }); - const html = await renderToString(app); + // \`Image\` collects its tags in the SSR context. + const ssrContext = {}; + const html = await renderToString(app, ssrContext); + const imagePreloads = ssrContext.__voidImagePreloads ? ssrContext.__voidImagePreloads.render() : ""; const pageData = serializePageData(pageObj); const headHtml = pageObj.head ? renderHeadToString(pageObj.head) : ""; const htmlAttrs = pageObj.head ? renderHtmlAttrs(pageObj.head) : ""; @@ -309,7 +312,7 @@ export async function renderPage(pageObj, assetTags) { return \` -\${headHtml}\${assetTags.css}\${assetTags.preloads} +\${headHtml}\${imagePreloads}\${assetTags.css}\${assetTags.preloads}
\${html}
diff --git a/packages/vue/src/runtime/image.ts b/packages/vue/src/runtime/image.ts new file mode 100644 index 000000000..a489d1e70 --- /dev/null +++ b/packages/vue/src/runtime/image.ts @@ -0,0 +1,357 @@ +/** + * `Image` for Vue: `next/image` from Next.js 16 as a render-function + * component. `void/image` computes the attributes, so server and client + * render the same `srcset`; this file only maps Vue props, attrs and + * listeners onto it and ports the component's load, error and preload + * behavior (`client/image-component.js`). + */ +import { + createImagePreloadCollector, + getImageConfig, + getImagePreloadLink, + getImageProps as getCoreImageProps, + handleImageLoading, + resolveImageProps, + toHtmlImgAttributes, + type ImageCoreProps, + type ImageLoader, + type ImagePreloadCollector, + type ImageStyle, + type ImgPropsResult, + type PlaceholderValue, + type StaticImport, +} from 'void/image'; +import { + camelize, + defineComponent, + h, + inject, + normalizeClass, + normalizeStyle, + onMounted, + ref, + ssrContextKey, + type Component, + type ImgHTMLAttributes, + type PropType, + type StyleValue, +} from 'vue'; + +export type { + ImageLoader, + ImageLoaderProps, + PlaceholderValue, + StaticImageData, + StaticImport, +} from 'void/image'; + +/** + * `Image` props: `next/image`'s props plus ordinary `` attributes. Vue's + * `class` and `style` (string, object or array) work as on ``; React's + * `className` and `fetchPriority` names are accepted too. + */ +export type ImageProps = Omit< + ImgHTMLAttributes, + 'src' | 'srcset' | 'alt' | 'width' | 'height' | 'loading' | 'decoding' | 'sizes' | 'style' +> & + Omit & { + style?: StyleValue; + /** @deprecated Use `@load`. */ + onLoadingComplete?: (img: HTMLImageElement) => void; + }; + +/** + * The `` attributes `Image` renders, with HTML names (`srcset`, + * `fetchpriority`, `class`) and `style` as a CSS string, ready for + * `v-bind` on an `` or a `` ``. + */ +export type ImageAttributes = { + [attribute: string]: string | number | boolean | undefined; + src: string; + srcset?: string; + sizes?: string; + loading?: 'lazy' | 'eager'; + fetchpriority?: 'high' | 'low' | 'auto'; + width?: number; + height?: number; + decoding?: 'async' | 'auto' | 'sync'; + class?: string; + style?: string; +}; + +/** + * Property of Vue's SSR context (`renderToString(app, context)`) that holds + * the render's image preload collector. The generated page and island + * renderers read the same key to put the links into ``. + */ +const SSR_PRELOADS_KEY = '__voidImagePreloads'; + +const LISTENER_RE = /^on[^a-z]/; + +function callHandlers(handler: unknown, ...args: Array): void { + const handlers = Array.isArray(handler) ? handler : [handler]; + for (const fn of handlers) { + if (typeof fn === 'function') { + (fn as (...values: Array) => unknown)(...args); + } + } +} + +function hasHandler(handler: unknown): boolean { + return Array.isArray(handler) + ? handler.some((fn) => typeof fn === 'function') + : typeof handler === 'function'; +} + +/** Vue `style` in any form as the camelCase style object `void/image` expects. */ +function toImageStyle(value: unknown): ImageStyle | undefined { + if (value == null || value === false || value === '') { + return undefined; + } + // An array always normalizes to an object; strings become declarations. + const normalized = normalizeStyle([value]) as Record; + const style: ImageStyle = {}; + for (const [name, item] of Object.entries(normalized)) { + if (typeof item === 'string' || typeof item === 'number') { + style[name.startsWith('--') ? name : camelize(name)] = item; + } + } + return style; +} + +/** + * Vue props and attrs as `void/image` input: `class` and `className` become + * `className`, `style` becomes an object, `fetchpriority` fills in + * `fetchPriority`, a user `srcset` is dropped (as `next/image` drops + * `srcSet`), and event listeners other than `onLoadingComplete` stay with the + * component. Other attributes keep their order ahead of `Image`'s own. + */ +function toCoreProps(input: Record): ImageCoreProps & Record { + const core: Record = {}; + const classes: Array = []; + let style: ImageStyle | undefined; + let fetchpriority: unknown; + for (const [key, value] of Object.entries(input)) { + if (value === undefined) { + continue; + } + switch (key) { + case 'class': + case 'className': + classes.push(value); + continue; + case 'style': + style = toImageStyle(value); + continue; + case 'srcset': + case 'srcSet': + continue; + case 'fetchpriority': + fetchpriority = value; + continue; + case 'onLoadingComplete': + break; + default: + if (LISTENER_RE.test(key)) { + continue; + } + } + core[key] = value; + } + if (core.fetchPriority === undefined && fetchpriority !== undefined) { + core.fetchPriority = fetchpriority; + } + const className = normalizeClass(classes); + if (className) { + core.className = className; + } + if (style) { + core.style = style; + } + return core as ImageCoreProps & Record; +} + +function preloadCollector(ssr: Record): ImagePreloadCollector { + let collector = ssr[SSR_PRELOADS_KEY] as ImagePreloadCollector | undefined; + if (!collector) { + collector = createImagePreloadCollector(); + ssr[SSR_PRELOADS_KEY] = collector; + } + return collector; +} + +/** Next.js `ImageElement`'s attribute order after the passthrough attributes; `src` stays last. */ +const ORDERED_ATTRIBUTES = [ + 'fetchpriority', + 'loading', + 'width', + 'height', + 'decoding', + 'data-nimg', + 'class', + 'style', + 'sizes', + 'srcset', + 'src', +] as const; + +/** + * An optimized `` with `next/image`'s props, defaults and markup + * (`srcset`, `sizes`, lazy loading, `data-nimg`, placeholders). Listen with + * `@load` (after the image is decoded) and `@error`. With `preload`, server + * rendering adds a `` to ``. + */ +export const Image: Component = defineComponent({ + name: 'VoidImage', + inheritAttrs: false, + props: { + src: { type: [String, Object] as PropType, required: true }, + alt: { type: String, required: true }, + width: { type: [Number, String] as PropType, default: undefined }, + height: { type: [Number, String] as PropType, default: undefined }, + quality: { type: [Number, String] as PropType, default: undefined }, + // `default: undefined` keeps absent booleans unset instead of Vue's `false`. + fill: { type: Boolean, default: undefined }, + preload: { type: Boolean, default: undefined }, + /** @deprecated Use `preload`. */ + priority: { type: Boolean, default: undefined }, + unoptimized: { type: Boolean, default: undefined }, + loader: { type: Function as PropType, default: undefined }, + sizes: { type: String, default: undefined }, + loading: { type: String as PropType<'lazy' | 'eager'>, default: undefined }, + placeholder: { type: String as PropType, default: undefined }, + blurDataURL: { type: String, default: undefined }, + overrideSrc: { type: String, default: undefined }, + decoding: { type: String as PropType<'async' | 'auto' | 'sync'>, default: undefined }, + fetchPriority: { type: String as PropType<'high' | 'low' | 'auto'>, default: undefined }, + }, + setup(props, { attrs }) { + const ssr = inject | null>(ssrContextKey, null); + const imgRef = ref(null); + const blurComplete = ref(false); + const showAltText = ref(false); + let current: ImgPropsResult | undefined; + + function handleLoad(img: HTMLImageElement): void { + const meta = current?.meta; + if (!meta) { + return; + } + handleImageLoading(img, { + placeholder: meta.placeholder, + unoptimized: meta.unoptimized, + sizesInput: props.sizes, + dev: getImageConfig().dev, + onBlurComplete() { + blurComplete.value = true; + }, + onLoad(loaded) { + if (hasHandler(attrs.onLoad)) { + // The native event is gone by now (`decode()` resolved), so build one. + const event = new Event('load'); + Object.defineProperty(event, 'target', { value: loaded }); + Object.defineProperty(event, 'currentTarget', { value: loaded }); + callHandlers(attrs.onLoad, event); + } + callHandlers(attrs.onLoadingComplete, loaded); + }, + }); + } + + function onLoad(event: Event): void { + handleLoad(event.currentTarget as HTMLImageElement); + } + + function onError(event: Event): void { + // If the image fails to load, show the alt text and drop the placeholder. + showAltText.value = true; + if (current && current.meta.placeholder !== 'empty') { + blurComplete.value = true; + } + callHandlers(attrs.onError, event); + } + + onMounted(() => { + const img = imgRef.value; + if (!img) { + return; + } + // Vue does not replay `load` or `error` that fired before hydration. + if (hasHandler(attrs.onError)) { + // Setting `src` again retries the request, so a lost error fires again. + // oxlint-disable-next-line no-self-assign + img.src = img.src; + } + if (getImageConfig().dev) { + if (!current?.props.src) { + console.error(`Image is missing required "src" property:`, img); + } + if (img.getAttribute('alt') === null) { + console.error( + `Image is missing required "alt" property. Please add Alternative Text to describe the image for screen readers and search engines.`, + ); + } + } + if (img.complete) { + handleLoad(img); + } + }); + + return () => { + // `alt` leads the passthrough attributes, as in `next/image` markup. + const input: Record = { alt: props.alt }; + Object.assign(input, attrs, props); + const result = resolveImageProps(toCoreProps(input), { + blurComplete: blurComplete.value, + showAltText: showAltText.value, + }); + current = result; + const { props: imgProps, meta } = result; + if (ssr && meta.preload) { + const link = getImagePreloadLink(imgProps, meta); + if (link) { + preloadCollector(ssr).add(link); + } + } + + const html: Record = toHtmlImgAttributes(imgProps); + html['data-nimg'] = meta.fill ? 'fill' : '1'; + const element: Record = {}; + for (const [key, value] of Object.entries(html)) { + if (!(ORDERED_ATTRIBUTES as ReadonlyArray).includes(key)) { + element[key] = value; + } + } + for (const [key, value] of Object.entries(attrs)) { + if ( + LISTENER_RE.test(key) && + key !== 'onLoad' && + key !== 'onError' && + key !== 'onLoadingComplete' + ) { + element[key] = value; + } + } + for (const key of ORDERED_ATTRIBUTES) { + if (html[key] !== undefined) { + element[key] = html[key]; + } + } + element.ref = imgRef; + element.onLoad = onLoad; + element.onError = onError; + return h('img', element); + }; + }, +}); + +/** + * The attributes `Image` would render for `props`, for another ``, a + * `` `` or CSS (`next/image` `getImageProps`). Names are + * HTML attribute names (`srcset`, `fetchpriority`, `class`) and `style` is a + * CSS string, as Vue's `` takes them; `data-nimg` is not included. + */ +export function getImageProps(props: ImageProps): { props: ImageAttributes } { + const { props: imgProps } = getCoreImageProps(toCoreProps(props as Record)); + return { props: toHtmlImgAttributes(imgProps) as ImageAttributes }; +} diff --git a/packages/vue/src/runtime/index.ts b/packages/vue/src/runtime/index.ts index 295bbd6ca..ee225d805 100644 --- a/packages/vue/src/runtime/index.ts +++ b/packages/vue/src/runtime/index.ts @@ -1,4 +1,14 @@ export { Form } from './form.ts'; +export { Image, getImageProps } from './image.ts'; +export type { + ImageAttributes, + ImageLoader, + ImageLoaderProps, + ImageProps, + PlaceholderValue, + StaticImageData, + StaticImport, +} from './image.ts'; export { Link } from './link.ts'; export { useRouter } from './use-router.ts'; export { useParams } from './use-params.ts'; diff --git a/packages/vue/test/unit/image-page-fixture.ts b/packages/vue/test/unit/image-page-fixture.ts new file mode 100644 index 000000000..0244d18df --- /dev/null +++ b/packages/vue/test/unit/image-page-fixture.ts @@ -0,0 +1,52 @@ +import { writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +const IMAGE_MODULE = join(import.meta.dirname, '../../src/runtime/image.ts'); + +let counter = 0; + +/** The `` tags the fixture page's images preload, deduplicated, in render order. */ +export const IMAGE_PAGE_PRELOADS: string = + '' + + ''; + +/** + * Import a generated page or island renderer with its `pageFile` import + * pointed at a page that renders preloaded `Image`s (two with the same source), + * so tests run the renderer's real SSR context and `` assembly. Files are + * written to `dir`, inside this package, so `vue` and `void` resolve. + */ +export async function importRendererWithImagePage( + renderer: string, + pageImportPath: string, + dir: string, +): Promise> { + const id = counter++; + const pagePath = join(dir, `image-page-${id}.ts`); + writeFileSync( + pagePath, + `import { defineComponent, h } from "vue"; +import { Image } from ${JSON.stringify(IMAGE_MODULE)}; +const lcp = { src: "/lcp.jpg", alt: "LCP", width: 800, height: 400, preload: true }; +export default defineComponent({ + setup: () => () => + h("main", [ + h(Image, lcp), + h(Image, { ...lcp, alt: "LCP again" }), + h(Image, { src: "/b.png", alt: "B", width: 10, height: 10, priority: true, unoptimized: true }), + h(Image, { src: "/c.png", alt: "C", width: 10, height: 10 }), + ]), +}); +`, + ); + const quoted = JSON.stringify(pageImportPath); + if (!renderer.includes(`import(${quoted})`)) { + throw new Error(`Renderer does not import ${quoted}.`); + } + const rendererPath = join(dir, `renderer-${id}.mjs`); + writeFileSync( + rendererPath, + renderer.replace(`import(${quoted})`, `import(${JSON.stringify(pagePath)})`), + ); + return (await import(rendererPath)) as Record; +} diff --git a/packages/vue/test/unit/image.golden.ts b/packages/vue/test/unit/image.golden.ts new file mode 100644 index 000000000..c8febd252 --- /dev/null +++ b/packages/vue/test/unit/image.golden.ts @@ -0,0 +1,427 @@ +/** + * Expected Vue server markup for `Image`, translated from the markup + * next@16.4.2's real `Image` renders with react-dom 19.3.0 + * (`renderToStaticMarkup`, `NODE_ENV=production`, Next.js's default image + * options with `path: '/_void/image'`). The translation only changes how the + * same markup is spelled: + * - React's `srcSet`, `fetchPriority`, `referrerPolicy`, `imageSrcSet` and + * `imageSizes` become HTML names, `/>` becomes `>`, `'` becomes + * `'`, and empty values print as bare attributes (`alt`), as Vue does; + * - the preload `` React prints in the tree moves to `preload`, with + * React's `crossorigin=""` spelled `"anonymous"`. React DOM's own preloads + * of non-lazy images (not from `preload` / `priority`) are dropped; + * - props use `class` for `className`. + * Regenerate with the scratch script that translates the React golden module; + * do not edit by hand. + */ +import type { ImageProps } from '../../src/runtime/image.ts'; + +export type ImageVueGoldenCase = { + name: string; + props: ImageProps & Record; + /** The rendered ``. */ + html: string; + /** The `` collected for ``, if any. */ + preload: string | null; +}; + +export const imageVueGoldenCases: Array = [ + { + name: 'fixed width (1x/2x)', + props: { + src: '/avatars/ada.png', + alt: 'Ada', + width: 64, + height: 64, + }, + html: 'Ada', + preload: null, + }, + { + name: 'numeric string dimensions', + props: { + src: '/photos/a.jpg', + alt: 'A', + width: '500', + height: '300', + }, + html: 'A', + preload: null, + }, + { + name: 'sizes with vw', + props: { + src: '/hero.jpg', + alt: 'Hero', + width: 1200, + height: 600, + sizes: '(max-width: 768px) 100vw, 50vw', + }, + html: 'Hero', + preload: null, + }, + { + name: 'sizes without vw', + props: { + src: '/hero.jpg', + alt: 'Hero', + width: 300, + height: 200, + sizes: '300px', + }, + html: 'Hero', + preload: null, + }, + { + name: 'fill', + props: { + src: '/cover.jpg', + alt: 'Cover', + fill: true, + }, + html: 'Cover', + preload: null, + }, + { + name: 'fill with sizes and objectFit', + props: { + src: '/cover.jpg', + alt: 'Cover', + fill: true, + sizes: '33vw', + style: { + objectFit: 'cover', + }, + }, + html: 'Cover', + preload: null, + }, + { + name: 'preload', + props: { + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + preload: true, + }, + html: 'LCP', + preload: + '', + }, + { + name: 'priority', + props: { + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + priority: true, + }, + html: 'LCP', + preload: + '', + }, + { + name: 'preload with fetchPriority high', + props: { + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + preload: true, + fetchPriority: 'high', + }, + html: 'LCP', + preload: + '', + }, + { + name: 'preload fill with crossOrigin and referrerPolicy', + props: { + src: 'https://images.example.com/lcp.jpg', + alt: 'LCP', + fill: true, + sizes: '100vw', + preload: true, + crossOrigin: 'anonymous', + referrerPolicy: 'no-referrer', + }, + html: 'LCP', + preload: + '', + }, + { + name: 'preload unoptimized', + props: { + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + preload: true, + unoptimized: true, + }, + html: 'LCP', + preload: '', + }, + { + name: 'loading eager', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + loading: 'eager', + }, + html: 'A', + preload: null, + }, + { + name: 'fetchPriority low', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + fetchPriority: 'low', + }, + html: 'A', + preload: null, + }, + { + name: 'unoptimized', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + unoptimized: true, + }, + html: 'A', + preload: null, + }, + { + name: 'custom loader prop', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + quality: 60, + loader: ({ src, width, quality }) => + `https://cdn.example.com${src}?w=${width}&q=${quality || 75}`, + }, + html: 'A', + preload: null, + }, + { + name: 'overrideSrc', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + overrideSrc: '/seo/original.png', + }, + html: 'A', + preload: null, + }, + { + name: 'placeholder blur', + props: { + src: '/photo.jpg', + alt: 'Photo', + width: 400, + height: 300, + placeholder: 'blur', + blurDataURL: 'data:image/png;base64,iVBORw0KGgo=', + }, + html: 'Photo', + preload: null, + }, + { + name: 'placeholder blur with objectFit contain', + props: { + src: '/photo.jpg', + alt: 'Photo', + width: 400, + height: 300, + placeholder: 'blur', + blurDataURL: 'data:image/png;base64,iVBORw0KGgo=', + style: { + objectFit: 'contain', + }, + }, + html: 'Photo', + preload: null, + }, + { + name: 'data:image placeholder', + props: { + src: '/photo.jpg', + alt: 'Photo', + width: 400, + height: 300, + placeholder: 'data:image/png;base64,iVBORw0KGgo=', + }, + html: 'Photo', + preload: null, + }, + { + name: 'placeholder empty', + props: { + src: '/photo.jpg', + alt: 'Photo', + width: 400, + height: 300, + placeholder: 'empty', + }, + html: 'Photo', + preload: null, + }, + { + name: 'className and style', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + class: 'rounded shadow', + style: { + borderRadius: 8, + maxWidth: '100%', + height: 'auto', + }, + }, + html: 'A', + preload: null, + }, + { + name: 'svg src', + props: { + src: '/logo.svg', + alt: 'Logo', + width: 120, + height: 40, + }, + html: 'Logo', + preload: null, + }, + { + name: 'remote absolute src', + props: { + src: 'https://images.example.com/photos/a.jpg?id=1', + alt: 'Remote', + width: 640, + height: 480, + }, + html: 'Remote', + preload: null, + }, + { + name: 'quality', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + quality: 50, + }, + html: 'A', + preload: null, + }, + { + name: 'width above every configured size', + props: { + src: '/a.png', + alt: 'A', + width: 5000, + height: 100, + }, + html: 'A', + preload: null, + }, + { + name: 'static import', + props: { + src: { + src: '/assets/hero-abc123.jpg', + width: 1600, + height: 900, + }, + alt: 'Hero', + }, + html: 'Hero', + preload: null, + }, + { + name: 'static import with width and blur', + props: { + src: { + src: '/assets/hero-abc123.jpg', + width: 1600, + height: 900, + blurDataURL: 'data:image/jpeg;base64,/9j/', + blurWidth: 8, + blurHeight: 5, + }, + alt: 'Hero', + width: 800, + placeholder: 'blur', + }, + html: 'Hero', + preload: null, + }, + { + name: 'data: src', + props: { + src: 'data:image/gif;base64,R0lGODlhAQABAAAAACw=', + alt: 'Pixel', + width: 1, + height: 1, + }, + html: 'Pixel', + preload: null, + }, + { + name: 'empty alt', + props: { + src: '/a.png', + alt: '', + width: 100, + height: 100, + }, + html: '', + preload: null, + }, + { + name: 'extra img attributes and handlers', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + id: 'avatar', + title: 'Ada', + decoding: 'sync', + draggable: false, + 'data-testid': 'avatar', + onLoad: () => {}, + onError: () => {}, + }, + html: 'A', + preload: null, + }, + { + name: 'legacy layout responsive', + props: { + src: '/a.png', + alt: 'A', + width: 100, + height: 100, + layout: 'responsive', + }, + html: 'A', + preload: null, + }, +]; diff --git a/packages/vue/test/unit/image.test.ts b/packages/vue/test/unit/image.test.ts new file mode 100644 index 000000000..a389640fe --- /dev/null +++ b/packages/vue/test/unit/image.test.ts @@ -0,0 +1,499 @@ +import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; +import { + IMAGE_REGISTRY_KEY, + type ClientImageConfig, + type ImageConfigRegistration, + type ImagePreloadCollector, +} from 'void/image'; +import { createRenderer, createSSRApp, defineComponent, h, nextTick, type VNodeChild } from 'vue'; +import { renderToString } from 'vue/server-renderer'; +import { Image, getImageProps } from '../../src/runtime/image.ts'; +import { imageVueGoldenCases } from './image.golden.ts'; + +type Props = Record; + +async function renderSsr( + render: () => VNodeChild, +): Promise<{ html: string; preloads: string | undefined }> { + const context: Record = {}; + const html = await renderToString(createSSRApp({ render }), context); + const collector = context.__voidImagePreloads as ImagePreloadCollector | undefined; + return { html, preloads: collector?.render() }; +} + +function golden(name: string): (typeof imageVueGoldenCases)[number] { + const found = imageVueGoldenCases.find((c) => c.name === name); + if (!found) { + throw new Error(`Missing golden case "${name}".`); + } + return found; +} + +function setRegistration(registration: ImageConfigRegistration | undefined): void { + const store = globalThis as Record; + if (registration) { + store[IMAGE_REGISTRY_KEY] = registration; + } else { + delete store[IMAGE_REGISTRY_KEY]; + } +} + +function clientConfig(overrides: Partial): ClientImageConfig { + const deviceSizes = overrides.deviceSizes ?? [640, 750, 828, 1080, 1200, 1920, 2048, 3840]; + const imageSizes = overrides.imageSizes ?? [32, 48, 64, 96, 128, 256, 384]; + return { + deviceSizes, + imageSizes, + allSizes: [...deviceSizes, ...imageSizes].sort((a, b) => a - b), + qualities: [75], + path: '/_void/image', + loader: 'default', + dangerouslyAllowSVG: false, + unoptimized: false, + dev: false, + ...overrides, + }; +} + +afterEach(() => { + setRegistration(undefined); + vi.restoreAllMocks(); +}); + +describe('Image server markup parity with next@16.4.2', () => { + it.each(imageVueGoldenCases.map((c) => [c.name, c] as const))('%s', async (_name, c) => { + const { html, preloads } = await renderSsr(() => h(Image, c.props)); + expect(html).toBe(c.html); + expect(preloads ?? null).toBe(c.preload); + }); +}); + +describe('Image with Vue attribute forms', () => { + it('takes class in any Vue form and style as a CSS string', async () => { + const expected = golden('className and style').html; + const props = { src: '/a.png', alt: 'A', width: 100, height: 100 }; + const fromString = await renderSsr(() => + h(Image, { + ...props, + class: ['rounded', { shadow: true, hidden: false }], + style: 'border-radius: 8px; max-width: 100%; height: auto', + }), + ); + const fromArray = await renderSsr(() => + h(Image, { + ...props, + class: 'rounded shadow', + style: [{ borderRadius: '8px' }, 'max-width: 100%', { height: 'auto' }], + }), + ); + const fromClassName = await renderSsr(() => + h(Image, { + ...props, + className: 'rounded shadow', + style: { borderRadius: 8, maxWidth: '100%', height: 'auto' }, + }), + ); + expect(fromString.html).toBe(expected); + expect(fromArray.html).toBe(expected); + expect(fromClassName.html).toBe(expected); + }); + + it('reads object-fit from a style string for the blur placeholder', async () => { + const c = golden('placeholder blur with objectFit contain'); + const { html } = await renderSsr(() => + h(Image, { ...c.props, style: 'object-fit: contain' } as Props), + ); + expect(html).toBe(c.html); + }); + + it('accepts HTML attribute names for fetchpriority, crossorigin and referrerpolicy', async () => { + const high = golden('preload with fetchPriority high'); + const { fetchPriority: _fetchPriority, ...highProps } = high.props; + const highResult = await renderSsr(() => + h(Image, { ...highProps, fetchpriority: 'high' } as Props), + ); + expect(highResult.html).toBe(high.html); + expect(highResult.preloads).toBe(high.preload); + + const cors = golden('preload fill with crossOrigin and referrerPolicy'); + const { crossOrigin: _crossOrigin, referrerPolicy: _referrerPolicy, ...corsProps } = cors.props; + const corsResult = await renderSsr(() => + h(Image, { + ...corsProps, + crossorigin: 'anonymous', + referrerpolicy: 'no-referrer', + } as Props), + ); + expect(corsResult.html).toBe(cors.html); + expect(corsResult.preloads).toBe(cors.preload); + }); + + it('ignores a user srcset and data-nimg, as next/image does', async () => { + const c = golden('fixed width (1x/2x)'); + const { html } = await renderSsr(() => + h(Image, { ...c.props, srcset: '/user.png 1x', 'data-nimg': 'x' } as Props), + ); + expect(html).toBe(c.html); + }); + + it('keeps passthrough attributes after alt, ahead of the image attributes', async () => { + const { html } = await renderSsr(() => + h(Image, { + 'data-testid': 'hero', + src: '/a.png', + id: 'hero', + alt: 'A', + width: 10, + height: 10, + unoptimized: true, + onClick: () => {}, + }), + ); + expect(html).toBe( + 'A', + ); + }); + + it('treats the Vue boolean shorthand as true', async () => { + const c = golden('fill'); + const { html } = await renderSsr(() => h(Image, { ...c.props, fill: '' } as Props)); + expect(html).toBe(c.html); + }); +}); + +describe('Image preload links', () => { + const lcp = { src: '/lcp.jpg', alt: 'LCP', width: 800, height: 400, preload: true }; + const lcpLink = + ''; + + it('collects one link per image source and leaves the markup unchanged', async () => { + const { html, preloads } = await renderSsr(() => + h('main', [h(Image, lcp), h(Image, { ...lcp, alt: 'LCP again' })]), + ); + expect(preloads).toBe(lcpLink); + expect(html).toBe( + `
${golden('preload').html}${golden('preload').html.replace('alt="LCP"', 'alt="LCP again"')}
`, + ); + }); + + it('collects links in render order from nested components', async () => { + const Nested = defineComponent({ + setup: () => () => + h('section', [h(Image, { ...lcp, src: '/second.jpg', unoptimized: true })]), + }); + const { preloads } = await renderSsr(() => + h('main', [ + h(Image, lcp), + h(Nested), + h(Image, { ...lcp, priority: true, preload: undefined }), + ]), + ); + expect(preloads).toBe(`${lcpLink}`); + }); + + it('collects nothing without preload or priority', async () => { + const { preloads } = await renderSsr(() => + h(Image, { ...lcp, preload: false, loading: 'eager', fetchPriority: 'high' }), + ); + expect(preloads).toBeUndefined(); + }); +}); + +describe('Image reads the registered config and loader', () => { + it('uses the registered sizes and loaderFile export', async () => { + setRegistration({ + config: clientConfig({ deviceSizes: [400, 800], imageSizes: [100], path: '/cdn/' }), + loader: ({ src, width, quality }) => `https://cdn.example.com${src}?w=${width}&q=${quality}`, + }); + const { html } = await renderSsr(() => + h(Image, { src: '/a.png', alt: 'A', width: 90, height: 90, quality: 50 }), + ); + expect(html).toBe( + 'A', + ); + }); + + it('throws next/image development errors in development', async () => { + setRegistration({ config: clientConfig({ dev: true }) }); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + await expect( + renderSsr(() => h(Image, { src: '/a.png', alt: 'A', height: 10 })), + ).rejects.toThrow('Image with src "/a.png" is missing required "width" property.'); + }); +}); + +describe('getImageProps', () => { + it('returns the attributes Image renders, with HTML names and a CSS string', () => { + expect( + Object.entries( + getImageProps({ src: '/avatars/ada.png', alt: 'Ada', width: 64, height: 64 }).props, + ), + ).toEqual( + Object.entries({ + alt: 'Ada', + loading: 'lazy', + width: 64, + height: 64, + decoding: 'async', + style: 'color:transparent', + srcset: + '/_void/image?url=%2Favatars%2Fada.png&w=64&q=75 1x, /_void/image?url=%2Favatars%2Fada.png&w=128&q=75 2x', + src: '/_void/image?url=%2Favatars%2Fada.png&w=128&q=75', + }), + ); + }); + + it('handles fill, sizes, preload, class and unoptimized like next/image', () => { + expect( + Object.entries( + getImageProps({ src: '/cover.jpg', alt: 'Cover', fill: true, sizes: '50vw' }).props, + ), + ).toEqual( + Object.entries({ + alt: 'Cover', + loading: 'lazy', + decoding: 'async', + style: + 'position:absolute;height:100%;width:100%;left:0;top:0;right:0;bottom:0;color:transparent', + sizes: '50vw', + srcset: + '/_void/image?url=%2Fcover.jpg&w=384&q=75 384w, /_void/image?url=%2Fcover.jpg&w=640&q=75 640w, /_void/image?url=%2Fcover.jpg&w=750&q=75 750w, /_void/image?url=%2Fcover.jpg&w=828&q=75 828w, /_void/image?url=%2Fcover.jpg&w=1080&q=75 1080w, /_void/image?url=%2Fcover.jpg&w=1200&q=75 1200w, /_void/image?url=%2Fcover.jpg&w=1920&q=75 1920w, /_void/image?url=%2Fcover.jpg&w=2048&q=75 2048w, /_void/image?url=%2Fcover.jpg&w=3840&q=75 3840w', + src: '/_void/image?url=%2Fcover.jpg&w=3840&q=75', + }), + ); + const preloaded = { + src: '/lcp.jpg', + alt: 'LCP', + width: 800, + height: 400, + preload: true, + } as const; + const expected = Object.entries({ + alt: 'LCP', + fetchpriority: 'high', + width: 800, + height: 400, + decoding: 'async', + class: 'hero', + style: 'color:transparent', + srcset: + '/_void/image?url=%2Flcp.jpg&w=828&q=75 1x, /_void/image?url=%2Flcp.jpg&w=1920&q=75 2x', + src: '/_void/image?url=%2Flcp.jpg&w=1920&q=75', + }); + expect( + Object.entries( + getImageProps({ ...preloaded, fetchPriority: 'high', className: 'hero' }).props, + ), + ).toEqual(expected); + expect( + Object.entries( + getImageProps({ ...preloaded, fetchpriority: 'high', class: { hero: true } }).props, + ), + ).toEqual(expected); + expect( + Object.entries( + getImageProps({ src: '/a.png', alt: 'A', width: 100, height: 100, unoptimized: true }) + .props, + ), + ).toEqual( + Object.entries({ + alt: 'A', + loading: 'lazy', + width: 100, + height: 100, + decoding: 'async', + style: 'color:transparent', + src: '/a.png', + }), + ); + }); +}); + +// A minimal host for Vue's custom renderer, so the browser behavior runs in +// Node: `` elements record attributes, listeners and `src` writes. +type FakeNode = { + tag: string; + children: Array; + parentElement: FakeNode | null; + isConnected: boolean; + attributes: Map; + listeners: Map void>; + complete: boolean; + srcWrites: number; + src: string; + width: number; + height: number; + decode(): Promise; + getAttribute(name: string): string | null; +}; + +function createNode(tag: string, complete = false): FakeNode { + let src = ''; + const node: FakeNode = { + tag, + children: [], + parentElement: null, + isConnected: tag === 'root', + attributes: new Map(), + listeners: new Map(), + complete, + srcWrites: 0, + get src() { + return src; + }, + set src(value: string) { + src = value; + node.srcWrites++; + }, + width: 0, + height: 0, + decode: () => Promise.resolve(), + getAttribute(name) { + return node.attributes.get(name) ?? null; + }, + }; + return node; +} + +function setConnected(node: FakeNode, connected: boolean): void { + node.isConnected = connected; + for (const child of node.children) { + setConnected(child, connected); + } +} + +function mountImage(props: Props, options: { complete?: boolean } = {}) { + const root = createNode('root'); + const { createApp } = createRenderer({ + createElement: (tag) => createNode(tag, options.complete), + createText: () => createNode('#text'), + createComment: () => createNode('#comment'), + setText: () => {}, + setElementText: () => {}, + insert(child, parent) { + parent.children.push(child); + child.parentElement = parent; + setConnected(child, parent.isConnected); + }, + remove(child) { + const parent = child.parentElement; + if (parent) { + parent.children.splice(parent.children.indexOf(child), 1); + } + child.parentElement = null; + setConnected(child, false); + }, + parentNode: (node) => node.parentElement, + nextSibling: () => null, + patchProp(el, key, _prev, next) { + if (/^on[A-Z]/.test(key)) { + if (typeof next === 'function') { + el.listeners.set(key, next as (event: unknown) => void); + } else { + el.listeners.delete(key); + } + return; + } + if (next == null) { + el.attributes.delete(key); + return; + } + el.attributes.set(key, String(next)); + if (key === 'src') { + el.src = String(next); + } + }, + }); + const app = createApp({ render: () => h(Image, props) }); + app.mount(root); + const img = root.children[0]; + return { + img, + fire(name: 'onLoad' | 'onError', event: Record = {}) { + img.listeners.get(name)?.({ currentTarget: img, target: img, ...event }); + }, + unmount: () => app.unmount(), + }; +} + +async function flush(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); + await nextTick(); +} + +describe('Image in the browser', () => { + const blurred = { + src: '/a.png', + alt: 'A', + width: 100, + height: 50, + placeholder: 'blur', + blurDataURL: 'data:image/png;base64,YY', + }; + + it('calls @load with a load event once per src after decoding and drops the placeholder', async () => { + const onLoad = vi.fn(); + const onLoadingComplete = vi.fn(); + const { img, fire } = mountImage({ ...blurred, onLoad, onLoadingComplete }); + expect(img.getAttribute('style')).toContain('background-image:'); + + fire('onLoad'); + expect(onLoad).not.toHaveBeenCalled(); + await flush(); + expect(onLoad).toHaveBeenCalledTimes(1); + const event = onLoad.mock.calls[0][0] as Event; + expect(event).toBeInstanceOf(Event); + expect(event.type).toBe('load'); + expect(event.target).toBe(img); + expect(event.currentTarget).toBe(img); + expect(onLoadingComplete).toHaveBeenCalledWith(img); + expect(img.getAttribute('style')).toBe('color:transparent'); + + fire('onLoad'); + await flush(); + expect(onLoad).toHaveBeenCalledTimes(1); + }); + + it('replays a load that finished before mount', async () => { + const onLoad = vi.fn(); + const { img } = mountImage({ ...blurred, onLoad }, { complete: true }); + await flush(); + expect(onLoad).toHaveBeenCalledTimes(1); + expect(img.getAttribute('style')).toBe('color:transparent'); + }); + + it('skips @load when the image is removed before decoding finishes', async () => { + const onLoad = vi.fn(); + const { fire, unmount } = mountImage({ ...blurred, onLoad }); + fire('onLoad'); + unmount(); + await flush(); + expect(onLoad).not.toHaveBeenCalled(); + }); + + it('shows the alt text and drops the placeholder on error, then calls @error', async () => { + const onError = vi.fn(); + const { img, fire } = mountImage({ ...blurred, onError }); + fire('onError', { type: 'error' }); + expect(onError).toHaveBeenCalledWith(expect.objectContaining({ type: 'error' })); + await nextTick(); + expect(img.getAttribute('style')).toBeNull(); + }); + + it('retries the request on mount only when @error is set, so an early error is not lost', () => { + expect(mountImage(blurred).img.srcWrites).toBe(1); + const { img } = mountImage({ ...blurred, onError: () => {} }); + expect(img.srcWrites).toBe(2); + expect(img.src).toBe('/_void/image?url=%2Fa.png&w=256&q=75'); + }); + + it('passes other listeners to the img and renders without an SSR context', () => { + const onClick = vi.fn(); + const { img } = mountImage({ ...blurred, preload: true, onClick }); + expect(img.listeners.has('onClick')).toBe(true); + img.listeners.get('onClick')?.({}); + expect(onClick).toHaveBeenCalledTimes(1); + expect(img.getAttribute('data-nimg')).toBe('1'); + expect([...img.attributes.keys()].at(-1)).toBe('src'); + }); +}); diff --git a/packages/vue/test/unit/plugin-islands.test.ts b/packages/vue/test/unit/plugin-islands.test.ts index 19f32ebf1..f84d402ce 100644 --- a/packages/vue/test/unit/plugin-islands.test.ts +++ b/packages/vue/test/unit/plugin-islands.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it, beforeEach, afterEach, vi } from 'vite-plus/test'; import { islandsPlugin } from '../../src/plugin-islands.ts'; +import { IMAGE_PAGE_PRELOADS, importRendererWithImagePage } from './image-page-fixture.ts'; const TMP = join(import.meta.dirname, '__tmp_islands_plugin__'); const PAGES_DIR = join(TMP, 'pages'); @@ -271,3 +272,41 @@ import Sidebar from "./Sidebar.vue" with { island: "load" } expect(clientEntry).not.toContain('Sidebar'); }); }); + +describe('islandsPlugin renderer image preloads', () => { + beforeEach(() => { + mkdirSync(PAGES_DIR, { recursive: true }); + }); + + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('renders with an SSR context and puts Image preloads in before CSS', async () => { + writeFile('index.island.vue', ``); + const { buildStart, loadRenderer } = setupPlugin(); + await buildStart(); + const renderer = loadRenderer(); + if (!renderer) { + throw new Error('Expected the islands renderer module.'); + } + expect(renderer).toContain('const html = await renderToString(app, ssrContext);'); + expect(renderer).toContain('${headHtml}${imagePreloads}${assetTags.css}'); + + const { renderIslandPage } = (await importRendererWithImagePage( + renderer, + join(PAGES_DIR, 'index.island.vue'), + TMP, + )) as { + renderIslandPage: (pageObj: unknown, assetTags: Record) => Promise; + }; + const html = await renderIslandPage( + { component: 'index', props: {}, head: { title: 'Home' } }, + { css: '', preloads: '', body: '' }, + ); + expect(html).toContain( + `Home${IMAGE_PAGE_PRELOADS}`, + ); + expect(html).toContain('
LCP { expect(renderer).toContain('"test": () => import'); }); }); + +describe('pagesPlugin renderer image preloads', () => { + afterEach(() => { + rmSync(TMP, { recursive: true, force: true }); + }); + + it('renders with an SSR context and puts Image preloads in before CSS', async () => { + writePage('index.vue', '\n'); + const plugin = pagesPlugin(PAGES_DIR).find((p) => p.name === 'void-vue:pages') as + | PagesPluginHooks + | undefined; + if (!plugin) { + throw new Error('Expected void-vue:pages plugin.'); + } + await getHookHandler(plugin.buildStart).call({}); + const renderer = await getHookHandler(plugin.load)('\0virtual:void-pages-renderer'); + if (!renderer) { + throw new Error('Expected the pages renderer module.'); + } + expect(renderer).toContain('const html = await renderToString(app, ssrContext);'); + expect(renderer).toContain( + '${headHtml}${imagePreloads}${assetTags.css}${assetTags.preloads}', + ); + + const { renderPage } = (await importRendererWithImagePage( + renderer, + join(PAGES_DIR, 'index.vue'), + TMP, + )) as { + renderPage: (pageObj: unknown, assetTags: Record) => Promise; + }; + const assetTags = { + css: '', + preloads: '', + body: '', + }; + const html = await renderPage( + { component: 'index', props: {}, url: '/', params: {}, shared: {}, head: { title: 'Home' } }, + assetTags, + ); + expect(html).toContain( + `Home${IMAGE_PAGE_PRELOADS}${assetTags.css}${assetTags.preloads}`, + ); + expect(html.match(/rel="preload" as="image"/g)).toHaveLength(2); + + const plain = await renderPage( + { component: 'index', props: {}, url: '/', params: {}, shared: {} }, + assetTags, + ); + expect(plain).toContain(`${IMAGE_PAGE_PRELOADS}${assetTags.css}`); + }); +}); From 3cf137686c4da7405dafe7261f40889cc32038a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 05:21:46 +0000 Subject: [PATCH 05/12] docs(image): document Image, void/image and the images config Add the images guide and update the config, inference, API, Cloudflare and Node target references, the void skill and maintainer notes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6 --- docs/.vitepress/config.ts | 2 + docs/guide/images.md | 616 +++++++++++++++++++++++++++ docs/integrations/cloudflare.md | 20 +- docs/integrations/nodejs-bun-deno.md | 4 + docs/reference/api.md | 1 + docs/reference/api/env.md | 2 +- docs/reference/api/pages.md | 14 + docs/reference/config.md | 75 +++- docs/reference/resource-inference.md | 27 +- docs/reference/structure.md | 2 +- packages/void/AGENTS.md | 11 + packages/void/skills/void/SKILL.md | 87 ++-- 12 files changed, 786 insertions(+), 75 deletions(-) create mode 100644 docs/guide/images.md diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index fdb5d1c82..74d1b7dad 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -20,6 +20,7 @@ const referenceItems = [ { text: 'target', link: '/reference/config#target' }, { text: 'routing', link: '/reference/config#routing' }, { text: 'inference', link: '/reference/config#inference' }, + { text: 'images', link: '/reference/config#images' }, { text: 'output', link: '/reference/config#output' }, { text: 'remote', link: '/reference/config#remote' }, { text: 'sandbox', link: '/reference/config#sandbox' }, @@ -259,6 +260,7 @@ export default extendConfig( { text: 'Rewrites', link: '/guide/edge/rewrites' }, ], }, + { text: 'Images', link: '/guide/images' }, { text: 'Custom SSR', link: '/guide/ssr' }, { text: 'SSG', link: '/guide/ssg' }, ], diff --git a/docs/guide/images.md b/docs/guide/images.md new file mode 100644 index 000000000..db39a4a5f --- /dev/null +++ b/docs/guide/images.md @@ -0,0 +1,616 @@ +--- +outline: deep +--- + +# Images + +`Image` renders an `` that loads lazily, reserves its space while it loads, and picks a size that suits the screen from a responsive `srcset`. Void resizes the image and converts it to a modern format at `/_void/image`, using [Cloudflare Images](https://developers.cloudflare.com/images/transform-images/bindings/). + +`Image`, `getImageProps()`, loaders, and the `images` config match `next/image` in Next.js 16. Code written for `next/image` works in Void with the changes listed in [Migrating from `next/image`](#migrating-from-next-image). + +## Rendering an Image + +Each Pages adapter exports `Image`: + +::: code-group + +```tsx [React] +// pages/team.tsx +import { Image } from '@void/react'; + +export default function Team() { + return Ada Lovelace; +} +``` + +```vue [Vue] + + + + +``` + +```svelte [Svelte] + + + +Ada Lovelace +``` + +```tsx [Solid] +// pages/team.tsx +import { Image } from '@void/solid'; + +export default function Team() { + return Ada Lovelace; +} +``` + +::: + +The page renders this element, shown here formatted for readability: + +```html +Ada Lovelace +``` + +`width` and `height` set the intrinsic size in pixels and reserve the image's space, so the layout doesn't shift when it loads. Style the displayed size with CSS as usual. + +### Props + +| Prop | Default | Description | +| ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `src` | Required | A path such as `/hero.jpg`, an absolute URL allowed by [`remotePatterns`](#remote-images), or a [static import](#imported-files). | +| `alt` | Required | Text alternative. Use `""` for decorative images. | +| `width`, `height` | — | Intrinsic size in pixels. Required unless `src` is a static import or `fill` is set. | +| `fill` | `false` | Fill the parent element instead of using `width` and `height`. See [Filling a container](#filling-a-container). | +| `sizes` | — | Media conditions for the displayed width, such as `(max-width: 768px) 100vw, 50vw`. See [Responsive images](#responsive-images). | +| `quality` | `75` | Quality from 1 to 100. Void uses the closest value in [`images.qualities`](../reference/config.md#images). | +| `preload` | `false` | Load the image early. See [Preloading](#preloading). `priority` is a deprecated alias. | +| `loading` | `lazy` | `lazy` or `eager`. | +| `fetchPriority` | — | `high`, `low`, or `auto`. | +| `decoding` | `async` | `async`, `sync`, or `auto`. | +| `placeholder` | `empty` | `empty`, `blur`, or a `data:image/...` URL. See [Placeholders](#placeholders). | +| `blurDataURL` | — | Small data URL shown blurred while the image loads, with `placeholder="blur"`. | +| `loader` | — | Function that builds image URLs. See [Loaders](#loaders). | +| `unoptimized` | `false` | Serve `src` as is, without a `srcset`. | +| `overrideSrc` | — | Use this value as the `src` attribute while keeping the generated `srcset`. | +| `onLoad`, `onError` | — | Run after the image loads or fails to load. Vue uses `@load` and `@error`; Svelte uses `onload` and `onerror`. `onLoadingComplete` is deprecated. | + +Other attributes, such as `class`, `style`, `id`, `title`, and `crossOrigin`, pass through to the ``. Vue, Svelte, and Solid accept `class` and `style` in their usual forms, including style strings. + +Images without a `width` and `height`, or with invalid prop combinations, throw an error during development that explains the fix. + +## Sizing + +### Fixed size + +With `width` and `height` only, the `srcset` offers the image at 1x and 2x density. The browser picks one for the screen's pixel density. + +### Responsive images + +Add `sizes` when the displayed width depends on the viewport. The `srcset` then lists widths from [`images.deviceSizes` and `images.imageSizes`](../reference/config.md#images), and the browser downloads the smallest one that covers the displayed size: + +```tsx +Cover +``` + +### Filling a container + +Set `fill` when the image's size comes from its parent, such as a banner of unknown aspect ratio. The image is absolutely positioned to cover the parent, so the parent needs `position: relative`, `absolute`, or `fixed`. `sizes` defaults to `100vw`; set it when the container is narrower than the viewport. + +::: code-group + +```tsx [React] +
+ Mountains at dawn +
+``` + +```vue [Vue] +
+ Mountains at dawn +
+``` + +```svelte [Svelte] +
+ Mountains at dawn +
+``` + +```tsx [Solid] +
+ Mountains at dawn +
+``` + +::: + +## Loading Behavior + +Images load lazily (`loading="lazy"`) and decode asynchronously (`decoding="async"`) by default, so offscreen images don't compete with the content the visitor sees first. The `` style includes `color: transparent`, which hides the alt text while the image loads. + +### Preloading + +Set `preload` on the image that is likely to be the [Largest Contentful Paint](https://web.dev/articles/lcp), such as a hero image above the fold. It loads the image eagerly and adds a `` to the server-rendered page: + +```tsx +Mountains at dawn +``` + +Use `preload` for one or two images per page. To load an image early without a preload link, set `loading="eager"` or `fetchPriority="high"` instead. `priority` still works as a deprecated alias of `preload`; don't combine it with `preload` or `loading="lazy"`. + +### Placeholders + +A placeholder fills the image's space until the image loads: + +- `placeholder="blur"` shows `blurDataURL` blurred. Void doesn't generate `blurDataURL`, so pass a small data URL yourself, for example one produced when you upload the image. +- `placeholder="data:image/..."` shows that data URL as is, for example a solid color or a small SVG. +- `placeholder="empty"`, the default, shows nothing. + +```tsx +Lake +``` + +The placeholder is removed once the image loads. Images smaller than 40×40 pixels don't benefit from a placeholder. + +## Local Images + +### Files in `public/` + +Reference files in [`public/`](../reference/structure.md#public) by their root path, with `width` and `height`: + +```tsx +Lake +``` + +Local images must be static files: files from `public/` or imported into your code. Paths served by a route or page can't be optimized; render those with `unoptimized`. + +A local `src` with a query string, such as `/photo.jpg?v=2`, is rejected unless [`images.localPatterns`](#allowed-sources) allows it. + +### Imported files + +Import an image with the `?image` query to get its URL and intrinsic size, read from the file at build time: + +::: code-group + +```tsx [React] +import { Image } from '@void/react'; +import hero from './hero.jpg?image'; + +export default function Home() { + return Mountains at dawn; +} +``` + +```vue [Vue] + + + +``` + +```svelte [Svelte] + + +Mountains at dawn +``` + +```tsx [Solid] +import { Image } from '@void/solid'; +import hero from './hero.jpg?image'; + +export default function Home() { + return Mountains at dawn; +} +``` + +::: + +The import is a `StaticImageData` object, `{ src, width, height }`. `Image` takes `width` and `height` from it; pass one of them to scale the other proportionally. Supported files are PNG, JPEG, GIF, WebP, AVIF, HEIC, SVG, BMP, and ICO. + +Imported images get a content hash in their file name, so optimized versions are cached with `Cache-Control: public, max-age=315360000, immutable`. An import without `?image` keeps Vite's default behavior and returns the URL string. Vite inlines very small files as data URLs, and `Image` serves those as is. + +## Remote Images + +Allow each remote source in [`images.remotePatterns`](../reference/config.md#images). Then pass the absolute URL with `width` and `height`: + +```ts +// void.config.ts +import { defineConfig } from 'void/config'; + +export default defineConfig({ + images: { + remotePatterns: [ + new URL('https://images.example.com/photos/**'), + { protocol: 'https', hostname: '**.cdn.example.com', pathname: '/avatars/**' }, + ], + }, +}); +``` + +```tsx +Lake +``` + +### Allowed sources + +`/_void/image` fetches only sources that match your patterns: + +- **`remotePatterns`** lists allowed remote sources. A pattern matches `protocol`, `hostname`, `port`, `pathname`, and `search`; omitted fields match anything. In `pathname`, `*` matches one path segment and `**` matches any number. In `hostname`, `*.example.com` and `**.example.com` both match any subdomain of `example.com`, but not `example.com` itself. A `URL` object is shorthand for a pattern with its protocol, hostname, port, and path, and matches only URLs without a query string. No remote source is allowed by default. +- **`localPatterns`** limits local sources. By default, any local path without a query string is allowed. When you set it, list every allowed `pathname` and `search`; static `?image` imports stay allowed. + +```ts +images: { + localPatterns: [{ pathname: '/photos/**', search: '' }], +}, +``` + +During development, an `Image` whose `src` doesn't match these patterns throws an error that names the setting to change. + +Remote sources must use `http` or `https`. Void follows up to `images.maximumRedirects` redirects and stops after 7 seconds. Unless `images.dangerouslyAllowLocalIP` is set, it refuses URLs whose host is `localhost` or a private or loopback IP address. Hostnames aren't resolved first, so allow only hosts you trust in `remotePatterns`. + +## `getImageProps` + +`getImageProps(props)` returns the attributes `Image` would render, without rendering anything. Use it for `` elements, background images, or markup that `Image` doesn't cover. It accepts the same props as `Image` and returns `{ props }`. + +### Art direction + +Serve a different image per breakpoint with ``: + +::: code-group + +```tsx [React] +import { getImageProps } from '@void/react'; + +export default function Hero() { + const common = { alt: 'Mountains at dawn', sizes: '100vw' }; + const { + props: { srcSet: desktop }, + } = getImageProps({ ...common, src: '/hero-desktop.jpg', width: 1440, height: 875 }); + const { + props: { srcSet: mobile, ...rest }, + } = getImageProps({ ...common, src: '/hero-mobile.jpg', width: 750, height: 1334 }); + + return ( + + + + + + ); +} +``` + +```vue [Vue] + + + +``` + +```svelte [Svelte] + + + + + + + +``` + +```tsx [Solid] +import { getImageProps } from '@void/solid'; + +export default function Hero() { + const common = { alt: 'Mountains at dawn', sizes: '100vw' }; + const { + props: { srcset: desktop }, + } = getImageProps({ ...common, src: '/hero-desktop.jpg', width: 1440, height: 875 }); + const { + props: { srcset: mobile, ...rest }, + } = getImageProps({ ...common, src: '/hero-mobile.jpg', width: 750, height: 1334 }); + + return ( + + + + + + ); +} +``` + +::: + +React's `getImageProps` returns React attribute names such as `srcSet` and a style object. The Vue, Svelte, and Solid versions return HTML attribute names, such as `srcset`, `class`, and `fetchpriority`, with `style` as a string, ready to bind to an element. Keys without a value are left out. + +`void/image` also exports a framework-neutral `getImageProps` that returns React attribute names. Use it in server code or outside a Pages adapter, for example to build an `image-set()` background. + +`getImageProps` doesn't add a preload link, even with `preload`. Avoid `placeholder` with it, because nothing removes the placeholder after the image loads. + +## Loaders + +A loader turns `src`, `width`, and `quality` into an image URL. The default loader builds `/_void/image` URLs. Any loader is a function with this signature: + +```ts +import type { ImageLoaderProps } from 'void/image'; + +type ImageLoader = (props: ImageLoaderProps) => string; +// ImageLoaderProps: { src: string; width: number; quality?: number } +``` + +`Image` calls the loader once for each width in the `srcset`. Use `width` in the URL; a loader that ignores it serves the same file at every size, and Void warns about it during development. + +### `loader` prop + +Pass a loader to a single image: + +```tsx +import { Image, type ImageLoaderProps } from '@void/react'; + +function imageServiceLoader({ src, width, quality }: ImageLoaderProps) { + return `https://images.example.com${src}?w=${width}&q=${quality ?? 75}`; +} + +Lake; +``` + +The prop works the same way in every adapter. + +### Loader file + +To use a loader for every `Image`, export it as the default export of a module and set `images.loaderFile`. The path is relative to the project root: + +```ts +// image-loader.ts +import type { ImageLoaderProps } from 'void/image'; + +export default function imageServiceLoader({ src, width, quality }: ImageLoaderProps): string { + const url = new URL(src, 'https://images.example.com'); + url.searchParams.set('w', String(width)); + url.searchParams.set('q', String(quality ?? 75)); + return url.href; +} +``` + +```ts +// void.config.ts +import { defineConfig } from 'void/config'; + +export default defineConfig({ + images: { + loader: 'custom', + loaderFile: './image-loader.ts', + }, +}); +``` + +The loader runs on the server and in the browser. With `loader: 'custom'` and no `loaderFile`, every `Image` must pass a `loader` prop. + +### Cloudflare zone transformations + +If your app is served from a domain in a Cloudflare zone with [image transformations](https://developers.cloudflare.com/images/transform-images/) enabled, the `cloudflare` loader builds transformation URLs that the zone serves directly: + +```ts +// void.config.ts +import { defineConfig } from 'void/config'; + +export default defineConfig({ + images: { loader: 'cloudflare' }, +}); +``` + +An image with `src="/hero.jpg"` then gets URLs such as `/cdn-cgi/image/width=640,quality=75,format=auto/hero.jpg`. Cloudflare picks the output format for each browser. Set `images.path` to use another zone, such as `https://images.example.com/cdn-cgi/image`. + +The `cloudflare` loader needs no Images binding and doesn't use `/_void/image`. During development it returns the original `src`, because zone transformations only exist on Cloudflare's network. Transformations count toward the same [Cloudflare Images allowance](#free-allowance). + +### Unoptimized images + +Set `unoptimized` on an image, or `images.unoptimized: true` for every image, to serve `src` as is without a `srcset`. `data:` and `blob:` URLs are always served as is. With the default and `cloudflare` loaders, SVG files are too, unless `images.dangerouslyAllowSVG` is set. + +## Optimization Endpoint + +With the default loader, `Image` points at `/_void/image`: + +``` +GET /_void/image?url=%2Fphotos%2Flake.jpg&w=828&q=75 +``` + +| Parameter | Value | +| --------- | --------------------------------------------------------------------------------- | +| `url` | A local path starting with `/`, or an absolute `http` or `https` URL, URL-encoded | +| `w` | Output width. Must be one of `images.deviceSizes` or `images.imageSizes` | +| `q` | Output quality. Must be one of `images.qualities` | + +A request with a missing or disallowed parameter returns `400` with a plain-text reason, such as `"w" parameter (width) of 500 is not allowed` or `"url" parameter is not allowed`. A source that isn't an image also returns `400`. Sources larger than `images.maximumResponseBody` return `413`, remote sources that time out return `504`, and more than `images.maximumRedirects` redirects return `508`. An error status from the source, such as `404`, is passed through. `/_void/image` answers `GET` and `HEAD` requests; other methods return `405`. + +The endpoint runs after your middleware, so middleware that guards every path also applies to image requests. + +### Output formats + +The endpoint picks the first type in [`images.formats`](../reference/config.md#images) that the request's `Accept` header lists explicitly. The default is `['image/webp']`. To prefer AVIF: + +```ts +images: { + formats: ['image/avif', 'image/webp'], +}, +``` + +AVIF output is limited to 1,200 pixels wide, so wider images use the next listed format. When the browser accepts none of the formats, the response keeps the source format, except that WebP and AVIF sources are converted to JPEG. Images are only scaled down, never enlarged. + +### Caching + +Optimized images are cached in the Cloudflare data center that served them. Responses include: + +- `Cache-Control: public, max-age=, must-revalidate`, where `` is the larger of `images.minimumCacheTTL` (default four hours) and the source's own `max-age`. [Imported files](#imported-files) use `public, max-age=315360000, immutable`. +- `ETag`, and `304 Not Modified` for a matching `If-None-Match`. +- `Vary: Accept`. +- `X-Void-Image-Cache: HIT` or `MISS`. +- `Content-Disposition` (`attachment` by default; see `images.contentDispositionType`), `Content-Security-Policy` from `images.contentSecurityPolicy`, and `X-Content-Type-Options: nosniff`. + +Cached local images belong to the deployment that served them, so a new deployment optimizes them again. Cached remote images stay until their cache time expires. + +### Images served unchanged + +Some sources are returned exactly as stored: + +- Animated GIF, PNG, and WebP files. +- SVG files, which are rejected unless `images.dangerouslyAllowSVG` is `true`. Keep the default `contentSecurityPolicy` when you allow SVG. +- ICO, BMP, HEIC, and JPEG XL files. +- GIF, TIFF, and JPEG 2000 files when the browser accepts none of the configured formats. + +If a transformation fails, including when the [Free allowance](#free-allowance) is used up, the endpoint serves the original file with `images.minimumCacheTTL` as its cache time and logs a warning. + +## Development + +Images are optimized during development without a Cloudflare connection. Local transformations apply the requested width and output format; the `q` parameter only changes the output on Cloudflare. Development responses use `Cache-Control: public, max-age=0, must-revalidate` and skip the cache. If local transformations are unavailable, the endpoint serves original files and logs a warning. + +Remote sources are fetched from the network, so they need to be reachable from your machine. + +## Deployment + +### Your Cloudflare account + +When your app imports `Image` or `getImageProps` and uses the default loader, Void adds a Cloudflare Images binding named `IMAGES` to your Worker and serves `/_void/image` with it. + +Reading `env.IMAGES` or `c.env.IMAGES` in server code also adds the binding, so you can call the [Images binding](https://developers.cloudflare.com/images/transform-images/bindings/) directly. + +Rename or remove the binding with [`inference.bindings.images`](../reference/config.md#inference-bindings). With `false`, Void deploys without the binding and `/_void/image` serves original files. To turn optimization off entirely, set `images.unoptimized: true`. + +#### Free allowance + +Image optimization works on Workers Free. The [Cloudflare Images Free plan](https://developers.cloudflare.com/images/pricing/) includes 5,000 unique transformations per month at no charge. A unique transformation is one source image at one set of parameters, such as width and quality, and Cloudflare counts it once per calendar month however often it is requested. A responsive image can use several, one for each width that browsers request. + +When the month's allowance is used up, Cloudflare doesn't charge you. Optimized images already in the cache keep being served, and requests for transformations Cloudflare refuses serve the original file until the next month. To use fewer transformations, shorten `images.deviceSizes` and `images.imageSizes`. See [Cloudflare Images pricing](https://developers.cloudflare.com/images/pricing/) for paid limits. + +### Void Platform + +Void Platform doesn't provide image optimization yet. `Image` renders original files there, as if `images.unoptimized` were set, and `void deploy` prints a warning. A custom loader or the `cloudflare` loader still applies. + +### Other targets and static output + +With [`target`](../reference/config.md#target) set to `node`, `bun`, or `deno`, or with [`output: "static"`](../reference/config.md#output), there is no `/_void/image` endpoint. `Image` renders original files, as if `images.unoptimized` were set, unless you configure a [custom loader](#loaders) that points at an image service. + +## Islands + +In [island pages](./pages-routing/islands.md), an `Image` outside an island is static HTML. The `srcset` and lazy loading work as on other pages, but `onLoad` and `onError` don't run and a blur placeholder isn't removed after the image loads, so it stays visible behind transparent images. Render the image inside an island when you need those behaviors. + +## Migrating from `next/image` + +`Image` accepts the same props as `next/image` and renders the same `` attributes. In React, `@void/react/image` has the same exports as the `next/image` module: + +```tsx +import Image, { getImageProps } from '@void/react/image'; +``` + +Replace `next/image` imports with `@void/react/image`. To keep `next/image` imports in code you don't want to change, alias the module in your Vite config: + +```ts +// vite.config.ts +import { voidReact } from '@void/react/plugin'; +import { defineConfig } from 'vite'; +import { voidPlugin } from 'void'; + +export default defineConfig({ + plugins: [voidPlugin(), voidReact()], + resolve: { + alias: { 'next/image': '@void/react/image' }, + }, +}); +``` + +Void detects image usage from `@void/*` imports, so with the alias also set `inference.bindings.images` to `true`. The alias only affects bundling; TypeScript still has to resolve `next/image` on its own. + +Move the `images` object from `next.config.js` to `void.config.ts`. The keys, types, and defaults are the same, and loader functions and `loaderFile` modules work unchanged. + +These differences remain: + +- The endpoint path is `/_void/image` instead of `/_next/image`. Its cache status header is `X-Void-Image-Cache`. +- Static image imports need `?image`. An import without it returns the URL string. +- `blurDataURL` isn't generated for imported images. Pass it yourself with `placeholder="blur"`. +- Local sources must be static files. Next.js also optimizes images served by routes. +- AVIF output is limited to 1,200 pixels wide; wider images use the next format in `images.formats`. +- `images.maximumResponseBody` is capped at 20 MB. +- Remote hostnames aren't resolved before fetching, so `images.dangerouslyAllowLocalIP` only governs `localhost` and IP addresses written in the URL. +- `images.maximumDiskCacheSize`, `images.disableStaticImages`, and `images.customCacheHandler` are accepted but have no effect, and Void prints a warning. `images.domains` is deprecated as in Next.js; use `remotePatterns`. + +See [`images`](../reference/config.md#images) for every configuration key. diff --git a/docs/integrations/cloudflare.md b/docs/integrations/cloudflare.md index 5b77b7dc8..8afaadb94 100644 --- a/docs/integrations/cloudflare.md +++ b/docs/integrations/cloudflare.md @@ -46,14 +46,15 @@ This provides binding types and Workers runtime types. ### Available bindings -| Binding | Resource | Use in your app | -| ---------------- | --------------- | --------------------------------- | -| `DB` | D1 | `c.env.DB` or `void/db` | -| `KV` | KV | `c.env.KV` or `void/kv` | -| `STORAGE` | R2 | `c.env.STORAGE` or `void/storage` | -| `AI` | Workers AI | `c.env.AI` or `void/ai` | -| `QUEUE_*` | Queues | `defineQueue()` or `void/queues` | -| Filename-derived | Durable Objects | A module in `durable-objects/` | +| Binding | Resource | Use in your app | +| ---------------- | --------------- | --------------------------------------------- | +| `DB` | D1 | `c.env.DB` or `void/db` | +| `KV` | KV | `c.env.KV` or `void/kv` | +| `STORAGE` | R2 | `c.env.STORAGE` or `void/storage` | +| `AI` | Workers AI | `c.env.AI` or `void/ai` | +| `IMAGES` | Images | `Image`, `getImageProps()`, or `c.env.IMAGES` | +| `QUEUE_*` | Queues | `defineQueue()` or `void/queues` | +| Filename-derived | Durable Objects | A module in `durable-objects/` | Use [binding configuration](../reference/resource-inference.md#explicit-binding-overrides) when you need to enable, disable, or rename an inferred binding. @@ -194,6 +195,7 @@ Void deploys native apps, static sites, SPAs, supported SSGs, and Cloudflare bui - Named Cloudflare environments aren't supported. Use a separate Void project config for each target. - Sandbox requires Workers Paid and Docker. See [Sandbox requirements](../guide/sandboxes.md#deployment) for plan and token permissions. - Email to arbitrary recipients requires Workers Paid; verified recipients and inbound mail work on Workers Free. See [Email](../guide/email/domains.md#your-own-cloudflare-account). +- Image optimization works on Workers Free with the Cloudflare Images Free plan: 5,000 unique transformations per month. After that, new image sizes serve the original file until the next month, at no charge. See [Images](../guide/images.md#free-allowance). - Node.js, Bun, and Deno use their [own deployment path](./nodejs-bun-deno.md). Commit Durable Object migration history in `void.lock.json`. Do not delete or reorder deployed steps. @@ -210,6 +212,8 @@ Custom domain commands update routes immediately. If a first deployment times ou Local D1, KV, and R2 bindings use local data. Your production resource IDs are used during deployment. +Images are resized and converted locally, without a Cloudflare connection, and don't count toward your Images allowance. Quality settings only apply on Cloudflare. + Workers AI runs remotely after you connect to Cloudflare, so development calls consume your account's allowance. Remote bindings in development and preview use your `void cloudflare login` session, or `CLOUDFLARE_API_TOKEN` when it is set. ### AI on your Cloudflare account {#ai-self-host} diff --git a/docs/integrations/nodejs-bun-deno.md b/docs/integrations/nodejs-bun-deno.md index 0d992023f..5c8f5f73c 100644 --- a/docs/integrations/nodejs-bun-deno.md +++ b/docs/integrations/nodejs-bun-deno.md @@ -120,6 +120,10 @@ If you need a database or storage, use an external provider and connect via stan WebSocket route files (`*.ws.ts`) require Cloudflare. You can still use the `void/ws` browser client to connect to a separately hosted WebSocket server. +### No image optimization + +There is no `/_void/image` endpoint on these targets. `Image` from the Pages adapters renders original files unless you set a [custom loader](../guide/images.md#loaders) that points at an image service. + ### No cron job runtime You can still define [cron jobs](../guide/jobs.md) in `crons/` and they will compile into the bundle, but there is no built-in scheduler to invoke them. On Cloudflare, Workers Cron Triggers call the `scheduled` handler automatically. On Node.js, you'll need an external scheduler (e.g. `node-cron`, systemd timers, or your hosting platform's cron) to trigger the exported handler. diff --git a/docs/reference/api.md b/docs/reference/api.md index 3347e2cd6..f0beba3c9 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -61,6 +61,7 @@ Import Void's app-facing APIs from these paths: | `void/kv` | `kv`, `createKV`, typed KV client/map types | | `void/storage` | `storage`, `createStorage` for the inferred R2 binding or a specific `R2Bucket` | | `void/ai` | `ai`, `VoidAi`, model input and response types | +| `void/image` | `getImageProps` with React attribute names, `defaultLoader`, `cloudflareLoader`, `findClosestQuality`, `imageConfigDefault`, and image types such as `ImageConfig`, `ImageLoader`, `ImageLoaderProps`, `StaticImageData`, `RemotePattern`, and `LocalPattern`. The `Image` component comes from each Pages adapter. See [Images](../guide/images.md). | | `void/limits` | `UsageLimit`, `UsageLimitData`, and `readUsageLimit`. AI and Sandbox limits reject with `UsageLimit`; uncaught limits in Void routes and Pages actions return structured HTTP 429 responses. | | `void/isr` | `revalidate(options)` for on-demand ISR cache invalidation | | `void/log` | `logger` structured logging helper | diff --git a/docs/reference/api/env.md b/docs/reference/api/env.md index a329354eb..9bf8d523a 100644 --- a/docs/reference/api/env.md +++ b/docs/reference/api/env.md @@ -45,6 +45,6 @@ Non-client keys are server values and use remote secret storage in production. ` /// ``` -Declares the `Cloudflare.Env` namespace with `DB`, `KV`, `STORAGE`, `AI`, and `SANDBOX` binding types. +Declares the `Cloudflare.Env` namespace with `DB`, `KV`, `STORAGE`, `AI`, `IMAGES`, and `SANDBOX` binding types. For handler context variables such as `c.set()` and `c.get()`, augment [`CloudContextVariables`](./types.md#cloudcontextvariables). This is separate from `Cloudflare.Env`, which types worker bindings. Framework adapters such as `@void/vue`, `@void/react`, and `@void/svelte` also augment `CloudContextVariables` to add the `shared` key used by `useShared()`. diff --git a/docs/reference/api/pages.md b/docs/reference/api/pages.md index 8f5fb8e1e..ed14f429d 100644 --- a/docs/reference/api/pages.md +++ b/docs/reference/api/pages.md @@ -46,6 +46,8 @@ Imported from `"@void/vue"`. | Export | Description | | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `Link` | Vue component for SPA navigation. Renders `` for GET, `Next page`, +

{message}

PackedNext page`, solid: `import { createSignal } from 'solid-js'; -import { Link } from '@void/solid'; +import { Image, Link } from '@void/solid'; export default function Page(props: { message: string }) { const [count, setCount] = createSignal(0); - return <>

{props.message}

Next page; + return <>

{props.message}

PackedNext page; }`, }; @@ -322,6 +323,14 @@ try { ); mkdirSync(join(fixture, 'public'), { recursive: true }); writeFileSync(join(fixture, 'public/packed.txt'), label); + // A 1x1 PNG for `Image`, which renders through the installed void/image and /_void/image. + writeFileSync( + join(fixture, 'public/packed.png'), + Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64', + ), + ); const vite = join( fixture, useVitePlus ? 'node_modules/vite-plus/bin/vp' : 'node_modules/vite/bin/vite.js', @@ -380,7 +389,12 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E await new Promise((resolve) => setTimeout(resolve, 200)); } assert(response?.ok, task.failure()); - assert((await response.text()).includes(`Packed ${label}`)); + const html = await response.text(); + assert(html.includes(`Packed ${label}`)); + assert(html.includes('/_void/image?url=%2Fpacked.png&w=32&q=75 1x'), html); + const image = await fetch(`${base}/_void/image?url=%2Fpacked.png&w=32&q=75`); + assert.equal(image.status, 200, task.failure()); + assert.match(image.headers.get('content-type') ?? '', /^image\//); assert.equal(await (await fetch(`${base}/packed.txt`)).text(), label); const apiResponse = await fetch(`${base}/api/packed`); const apiBody = await apiResponse.text(); @@ -487,7 +501,7 @@ if (runtime.manifest.platformVersion !== ${JSON.stringify(version)}) throw new E } } console.log( - `Passed packed ${label} (${suite}): init, frozen install, ${useVitePlus ? 'Vite+' : 'public Vite'} build/typecheck, assets, KV, email, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}${framework === 'react' ? ', Node target' : ''}`, + `Passed packed ${label} (${suite}): init, frozen install, ${useVitePlus ? 'Vite+' : 'public Vite'} build/typecheck, assets, KV, email, Image, runtime integrity${full ? ', dev/preview, hydration/navigation' : ', dev HTTP'}${framework === 'react' ? ', Node target' : ''}`, ); } catch (error) { failures.push({ fixture: label, error: String(error.stack ?? error) }); diff --git a/docs/guide/images.md b/docs/guide/images.md index db39a4a5f..cc3dd81cf 100644 --- a/docs/guide/images.md +++ b/docs/guide/images.md @@ -598,7 +598,7 @@ export default defineConfig({ }); ``` -Void detects image usage from `@void/*` imports, so with the alias also set `inference.bindings.images` to `true`. The alias only affects bundling; TypeScript still has to resolve `next/image` on its own. +Void detects image usage from `next/image` imports too, so the alias needs no other configuration. The alias only affects bundling; TypeScript still has to resolve `next/image` on its own. Move the `images` object from `next.config.js` to `void.config.ts`. The keys, types, and defaults are the same, and loader functions and `loaderFile` modules work unchanged. diff --git a/docs/reference/resource-inference.md b/docs/reference/resource-inference.md index c2149dca6..dbddc9b3d 100644 --- a/docs/reference/resource-inference.md +++ b/docs/reference/resource-inference.md @@ -21,7 +21,7 @@ Void automatically detects which Cloudflare resources your project uses by scann | `VOID_LIVE` | `DurableObjectNamespace` | `import { defineLiveStream } from "void/live"` | n/a | | filename-derived | `DurableObjectNamespace` | A default-exported `defineDurableState()` module in `durable-objects/` | n/a | -`Image` and `getImageProps` add `IMAGES` in native Void apps only while the default `/_void/image` endpoint serves images. They don't with `images.unoptimized`, a custom or `cloudflare` loader, `output: "static"`, or a non-Cloudflare `target`. Type-only imports never add a binding. See [Images](../guide/images.md#deployment). +`Image` and `getImageProps` add `IMAGES` in native Void apps only while the default `/_void/image` endpoint serves images. They don't with `images.unoptimized`, a custom or `cloudflare` loader, `output: "static"`, or a non-Cloudflare `target`. Type-only imports never add a binding. `next/image` imports count as `@void/react/image` imports, for apps that [alias `next/image`](../guide/images.md#migrating-from-next-image). See [Images](../guide/images.md#deployment). Auth detection also triggers when importing the `auth` specifier from `void/client` or a framework-specific client subpath such as `void/client/react` (but not when importing only `fetch`). diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 1f2fc086a..31f6beed9 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -139,7 +139,7 @@ Two features need extra wiring on a self-hosted `vite build && wrangler deploy` - **Config transport**: `imagePlugin` (`src/plugin-image.ts`) prepends `import "virtual:void/image-config"` to the resolved `void/image` entry in every environment (matched by real path, query stripped) and excludes `void/image` from client `optimizeDeps` so the import is never prebundled away. The virtual module registers `{ config: ClientImageConfig, loader }` under `Symbol.for('void.image.config.v1')`; `void/image` reads it lazily and falls back to `imageConfigDefault`. Client bundles receive patterns only in development. Patterns are compiled with picomatch in Node-only `image-config.ts` and shipped as RegExp sources; keep picomatch and `node:` imports out of the `void/image` entry. - **Endpoint mounting**: generated route tables mount `createImageHandler` with `app.all(images.path)` after auth and user middleware and before WebSocket hooks, routes, and pages, only when `shouldMountImageOptimizer()` holds (Cloudflare target, `isImageOptimizerActive()`, and `Image`/`getImageProps` usage or `inference.bindings.images`). The handler gets the full `ResolvedImageConfig` as a JSON literal and shares the `__VOID_ISR_DEPLOYMENT_ID` literal. The path skips the Void auth session lookup (`skipPaths`), self-host ISR (`bypassPaths`), and is added to SPA `run_worker_first`. Apps without images must generate byte-identical code. -- **Images binding emission**: like Workers AI, `mergeWorkerBindings` adds `images: { binding }` only for `needsImages` and only when no authored `images` binding exists, but for serve and build alike (Miniflare transforms locally, no `remote`). Name precedence: authored `cloudflare.images` > `inference.bindings.images` > `IMAGES`. `vars.__VOID_IMAGES_BINDING` records a non-default name for `runtime/routing-image.ts` and is on the kitchen-sink leak-check allowlist. `Image` usage needs the binding only while `isImageOptimizerEnabled()`; apps without `Image`, `getImageProps`, or `env.IMAGES` emit no binding, var, or route, and nothing probes Images entitlement (Free plan error 9422 serves the original). +- **Images binding emission**: like Workers AI, `mergeWorkerBindings` adds `images: { binding }` only for `needsImages` and only when no authored `images` binding exists, but for serve and build alike (Miniflare transforms locally, no `remote`). Name precedence: authored `cloudflare.images` > `inference.bindings.images` > `IMAGES`. `vars.__VOID_IMAGES_BINDING` records a non-default name for `runtime/routing-image.ts` and is on the kitchen-sink leak-check allowlist. `Image` usage needs the binding only while `isImageOptimizerEnabled()`, which is false in framework apps, so `inference/walk.ts` can count `next/image` imports as `@void/react/image` (the documented `resolve.alias` migration) without affecting vinext; apps without `Image`, `getImageProps`, or `env.IMAGES` emit no binding, var, or route, and nothing probes Images entitlement (Free plan error 9422 serves the original). - **Static imports**: `?image` loads in `imagePlugin` with dimensions from `image-size.ts` (file headers, no sharp) and returns `{ src, width, height }`; plain image imports keep Vite's URL. User `localPatterns` get `{ pathname: '/assets/**', search: '' }` appended, which assumes Vite's default `build.assetsDir` and covers only built URLs. The dev server serves imports from their source paths, so with user patterns dev `?image` imports return the file's `/@fs/` URL and the dev config appends `/@fs/**` (`usesFsStaticImageUrls`). The endpoint sends the immutable `Cache-Control` only for local sources under `assetsDir` listed in the client manifest's `immutableFiles`. - **Void Platform**: the platform has no Images binding. Managed builds (`VOID_DEPLOY_PROJECT_ID`, Cloudflare target) resolve `images` as unoptimized unless a non-default loader or `loaderFile` is set, and `void deploy` warns once (`managedImageOptimizerWarning`). Without a binding the endpoint serves validated originals. - **Native deploy verification**: `deploy-artifact-validation.ts` accepts a generated `images` binding only when it equals the pre-build root binding, or Void's own `{ binding }` named by `imagesBindingName ?? 'IMAGES'` when pre-build inference found `needsImages`. `needsImages` also blocks linking an existing Worker in `deploy-cloudflare-preflight.ts`. @@ -182,6 +182,8 @@ Column types for `gen model`: `string`, `text`, `datetime` → `text()`, `intege `gen model` creates Drizzle table definitions in `db/schema/` and updates the `db/schema.ts` barrel export. Auto-detects your validation library (valibot, zod, or arktype) from `package.json`. If none is found, prompts to choose one or skip validation. +`gen ssr` entries put `Image` preload links in ``: the Vue entry passes an SSR context and renders `ssrContext.__voidImagePreloads` (the key in `packages/vue/src/runtime/image.ts`, now part of scaffolded user code), and the Svelte entry renders `render()`'s `head`. React emits them through `ReactDOM.preload`. The Solid entry has no `ImagePreloadContext` provider, which would add an `@void/solid` dependency and a matching client provider, so Solid preloads are dropped there. + Source: `src/cli/gen-args.ts` (arg parsing), `src/cli/gen.ts` (command runner). Tests: `test/unit/gen-args.test.ts`, `test/unit/gen.test.ts`. ## Coding Agent Integration diff --git a/packages/void/src/cli/gen.ts b/packages/void/src/cli/gen.ts index 8378db16a..336711e78 100644 --- a/packages/void/src/cli/gen.ts +++ b/packages/void/src/cli/gen.ts @@ -808,20 +808,23 @@ function writeVueSsr(root: string): void { writeIfNotExists( join(ssrDir, 'main.ssr.ts'), `import { createSSRApp } from "vue"; -import { renderToString } from "vue/server-renderer"; +import { renderToString, type SSRContext } from "vue/server-renderer"; import { defineRender } from "void"; import App from "./App.vue"; export default defineRender(async (c, assetTags) => { const app = createSSRApp(App); - const appHtml = await renderToString(app); + // \`Image\` from @void/vue collects its tags here. + const ssrContext: SSRContext = {}; + const appHtml = await renderToString(app, ssrContext); + const imagePreloads = ssrContext.__voidImagePreloads?.render() ?? ""; const html = \` App - \${assetTags.css}\${assetTags.preloads} + \${imagePreloads}\${assetTags.css}\${assetTags.preloads}
\${appHtml}
@@ -873,14 +876,14 @@ import { defineRender } from "void"; import App from "./App.svelte"; export default defineRender(async (c, assetTags) => { - const { html: appHtml } = render(App); + const { head, body: appHtml } = render(App); const html = \` App - \${assetTags.css}\${assetTags.preloads} + \${head}\${assetTags.css}\${assetTags.preloads}
\${appHtml}
diff --git a/packages/void/src/inference/walk.ts b/packages/void/src/inference/walk.ts index 0b4ffe2d5..986640c4a 100644 --- a/packages/void/src/inference/walk.ts +++ b/packages/void/src/inference/walk.ts @@ -20,7 +20,11 @@ export type InferredBindings = { * while the default `/_void/image` optimizer is configured. */ needsImages?: boolean; - /** `Image` or `getImageProps` from a Void adapter or an image subpath. */ + /** + * `Image` or `getImageProps` from a Void adapter, an image subpath, or + * `next/image`. Only Void apps act on it (`isImageOptimizerEnabled`), so + * framework apps such as vinext, which own `next/image`, are unaffected. + */ usesImageOptimizer?: boolean; }; @@ -33,9 +37,12 @@ const ENV_BINDING_RE = /\benv\s*\.\s*(DB|KV|STORAGE|QUEUE_\w+|AI|SANDBOX|IMAGES) /** Adapter entries whose `Image` and `getImageProps` exports render through the optimizer. */ const IMAGE_ADAPTER_SOURCES = new Set(['@void/react', '@void/vue', '@void/svelte', '@void/solid']); const IMAGE_EXPORT_NAMES = new Set(['Image', 'getImageProps']); +const REACT_IMAGE_EXPORTS: ReadonlySet = new Set(['default', 'Image', 'getImageProps']); /** Exports that render through the optimizer, per image subpath. */ const IMAGE_SUBPATH_EXPORTS: Record> = { - '@void/react/image': new Set(['default', 'Image', 'getImageProps']), + '@void/react/image': REACT_IMAGE_EXPORTS, + // Void apps migrating from Next.js alias it to `@void/react/image`. + 'next/image': REACT_IMAGE_EXPORTS, // The other `void/image` exports are loader and config helpers. 'void/image': new Set(['getImageProps']), }; @@ -160,6 +167,7 @@ export function inferBindingsFromSource(code: string, filename: string): Inferre break; case 'void/image': case '@void/react/image': + case 'next/image': // Static references are checked per specifier in `usesImageExports`; // a literal dynamic import may read any export. if (dynamic) { diff --git a/packages/void/test/unit/application-analysis.test.ts b/packages/void/test/unit/application-analysis.test.ts index 2ca30b7ee..eb0e12b2a 100644 --- a/packages/void/test/unit/application-analysis.test.ts +++ b/packages/void/test/unit/application-analysis.test.ts @@ -244,6 +244,17 @@ describe('image optimizer analysis', () => { expect(analysis.bindingNames).toBeUndefined(); }); + it('requires the Images binding for next/image aliased to @void/react/image', () => { + const applicationRoot = root(); + source( + applicationRoot, + 'pages/index.tsx', + 'import Image from "next/image"; export default () => ;', + ); + const analysis = analyzeApplicationSync(applicationRoot, { config: {}, environment: 'prod' }); + expect(analysis.bindings).toMatchObject({ needsImages: true, usesImageOptimizer: true }); + }); + it.each<[string, VoidConfig]>([ ['unoptimized', { images: { unoptimized: true } }], ['a custom loader', { images: { loader: 'custom' } }], @@ -354,6 +365,25 @@ describe('vinext image optimizer analysis', () => { expect(analysis.bindings.needsImages).toBe(false); }); + it.each([false, true])( + 'leaves next/image to vinext without the Cloudflare optimizer (App Router: %s)', + (appRouter) => { + const applicationRoot = vinextApp( + 'import vinext from "vinext"; import { voidPlugin } from "void"; export default { plugins: [voidPlugin(), vinext()] };', + appRouter, + ); + source( + applicationRoot, + appRouter ? 'app/hero.tsx' : 'pages/index.tsx', + 'import Image from "next/image"; export default () => ;', + ); + const analysis = analyzeApplicationSync(applicationRoot, { config: {} }); + expect(analysis.framework?.name).toBe(appRouter ? 'vinext-app' : 'vinext-pages'); + expect(analysis.bindings.needsImages).toBe(false); + expect(analysis.bindingNames).toBeUndefined(); + }, + ); + it('ignores the optimizer import outside vinext apps', () => { const applicationRoot = root(); source(applicationRoot, 'vite.config.ts', optimizerConfig()); diff --git a/packages/void/test/unit/gen.test.ts b/packages/void/test/unit/gen.test.ts index 74360dccd..65aea0e5b 100644 --- a/packages/void/test/unit/gen.test.ts +++ b/packages/void/test/unit/gen.test.ts @@ -1112,7 +1112,11 @@ describe('gen ssr', () => { const ssr = read('src/main.ssr.ts'); expect(ssr).toContain('import { createSSRApp } from "vue"'); - expect(ssr).toContain('import { renderToString } from "vue/server-renderer"'); + expect(ssr).toContain('import { renderToString, type SSRContext } from "vue/server-renderer"'); + // `Image` from @void/vue collects preload links in the SSR context. + expect(ssr).toContain('const appHtml = await renderToString(app, ssrContext);'); + expect(ssr).toContain('const imagePreloads = ssrContext.__voidImagePreloads?.render() ?? "";'); + expect(ssr).toContain('${imagePreloads}${assetTags.css}${assetTags.preloads}\n '); const client = read('src/main.client.ts'); expect(client).toContain('import { createSSRApp } from "vue"'); @@ -1128,6 +1132,9 @@ describe('gen ssr', () => { const ssr = read('src/main.ssr.ts'); expect(ssr).toContain('import { render } from "svelte/server"'); + // `` output, including `Image` preload links, goes into . + expect(ssr).toContain('const { head, body: appHtml } = render(App);'); + expect(ssr).toContain('${head}${assetTags.css}${assetTags.preloads}\n '); const client = read('src/main.client.ts'); expect(client).toContain('import { hydrate } from "svelte"'); diff --git a/packages/void/test/unit/inference.test.ts b/packages/void/test/unit/inference.test.ts index 950cabffa..72b6d7162 100644 --- a/packages/void/test/unit/inference.test.ts +++ b/packages/void/test/unit/inference.test.ts @@ -270,6 +270,14 @@ describe('inferBindingsFromSource image usage', () => { "export { getImageProps } from 'void/image';", "const load = () => import('void/image');", "const load = () => import('@void/react/image');", + // `next/image` aliased to `@void/react/image` when migrating from Next.js. + "import Image from 'next/image';", + "import NextImage, { getImageProps } from 'next/image';", + "import { getImageProps } from 'next/image';", + "import { Image } from 'next/image';", + "import * as Next from 'next/image';\nexport const View = () => ;", + "export { default as Image } from 'next/image';", + "const load = () => import('next/image');", ])('detects %s', (code) => { expect(usesImage(code)).toBe(true); }); @@ -292,7 +300,11 @@ describe('inferBindingsFromSource image usage', () => { "import * as Void from '@void/react';\nexport const View = () => ;", "import * as Void from '@void/react';\nexport const value = other.Void.Image;", "import { Image } from './components/image';", - "import { Image } from 'next/image';", + "import type Image from 'next/image';", + "import type { ImageProps } from 'next/image';", + "import 'next/image';", + "import * as Next from 'next/image';\nexport type Props = Next.ImageProps;", + "import Image from 'next/legacy/image';", "import Image from '@void/vue/image';", ])('ignores %s', (code) => { expect(usesImage(code)).toBe(false); From 1c74ef48d62b3b92e2b87a26a441a8daf8aa88f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 06:13:27 +0000 Subject: [PATCH 09/12] test(image): cover Image in the Pages playgrounds Add an image page to the React, Vue, Svelte and Solid playgrounds and check markup, preload links, endpoint resizing and format negotiation, hydration and cache headers in dev and preview. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6 --- .../integration/react-pages-client.test.ts | 140 ++++++++++++++ .../solid-pages-playground.test.ts | 164 ++++++++++++++++ .../integration/svelte-pages-client.test.ts | 182 +++++++++++++++++- .../test/integration/vue-pages-client.test.ts | 168 ++++++++++++++++ playground/react-pages/pages/image.tsx | 38 ++++ .../react-pages/public/void-image-test.png | Bin 0 -> 813 bytes .../react-pages/src/void-image-static.png | Bin 0 -> 4729 bytes .../solid-pages/assets/void-image-import.png | Bin 0 -> 6265 bytes playground/solid-pages/pages/image.tsx | 39 ++++ .../solid-pages/public/void-image-test.png | Bin 0 -> 813 bytes .../svelte-pages/assets/void-image-import.png | Bin 0 -> 6265 bytes playground/svelte-pages/pages/image.svelte | 34 ++++ .../svelte-pages/public/void-image-test.png | Bin 0 -> 3030 bytes playground/vue-pages/README.md | 6 + .../vue-pages/assets/void-image-import.png | Bin 0 -> 6265 bytes playground/vue-pages/pages/image.vue | 36 ++++ .../vue-pages/public/void-image-test.png | Bin 0 -> 3034 bytes 17 files changed, 806 insertions(+), 1 deletion(-) create mode 100644 playground/react-pages/pages/image.tsx create mode 100644 playground/react-pages/public/void-image-test.png create mode 100644 playground/react-pages/src/void-image-static.png create mode 100644 playground/solid-pages/assets/void-image-import.png create mode 100644 playground/solid-pages/pages/image.tsx create mode 100644 playground/solid-pages/public/void-image-test.png create mode 100644 playground/svelte-pages/assets/void-image-import.png create mode 100644 playground/svelte-pages/pages/image.svelte create mode 100644 playground/svelte-pages/public/void-image-test.png create mode 100644 playground/vue-pages/assets/void-image-import.png create mode 100644 playground/vue-pages/pages/image.vue create mode 100644 playground/vue-pages/public/void-image-test.png diff --git a/packages/react/test/integration/react-pages-client.test.ts b/packages/react/test/integration/react-pages-client.test.ts index f40d9baf4..794424b24 100644 --- a/packages/react/test/integration/react-pages-client.test.ts +++ b/packages/react/test/integration/react-pages-client.test.ts @@ -39,6 +39,16 @@ type PageHead = { const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const REACT_PAGES_ROOT = join(ROOT, 'playground/react-pages'); +// `pages/image.tsx`: srcset widths follow Next.js (the smallest configured size +// at least 1x and 2x the width), and React renders `srcSet` with that casing. +const IMAGE_ENDPOINT = '/_void/image?url=%2Fvoid-image-test.png'; +const FIXED_IMAGE_HTML = + 'Void image test`; +// `ReactDOM.preload` puts the link at the start of the rendered page, inside `#app`. +const PRELOAD_IMAGE_LINK = `
`; +const IMAGE_TEST_IDS = ['image-fixed', 'image-preload', 'image-fill', 'image-static']; const REACT_PAGES_LAYOUT = join(REACT_PAGES_ROOT, 'pages/layout.tsx'); const LATE_WORKER_DEPS_ROOT = join(REACT_PAGES_ROOT, 'node_modules/@void-repro'); let DEV_BASE: string; @@ -170,6 +180,60 @@ beforeAll(async () => { browser = await launchPagesBrowser(); }); +/** Width of a PNG from its IHDR chunk. */ +async function pngWidth(response: Response): Promise { + return Buffer.from(await response.arrayBuffer()).readUInt32BE(16); +} + +/** Every `Image` on `/image` loads through the endpoint and hydrates cleanly. */ +async function expectImagePageHydrates(base: string) { + const context = await browser.newContext(); + const errors: Array = []; + try { + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + page.on('console', (msg) => { + if (msg.type() === 'error' || /hydrat/i.test(msg.text())) { + errors.push(msg.text()); + } + }); + page.on('pageerror', (error) => { + errors.push(error.message); + }); + + await page.goto(`${base}/image`); + await page.waitForSelector('#app[data-hydrated="true"]'); + await page.waitForFunction( + (ids) => + ids.every((id) => { + const img = document.querySelector(`[data-testid="${id}"]`); + return img?.complete && img.naturalWidth > 0; + }), + IMAGE_TEST_IDS, + ); + + const fixed = await page.evaluate(() => { + const img = document.querySelector('[data-testid="image-fixed"]')!; + return { + naturalWidth: img.naturalWidth, + srcset: img.getAttribute('srcset'), + currentSrc: new URL(img.currentSrc).pathname + new URL(img.currentSrc).search, + }; + }); + expect(fixed).toEqual({ + naturalWidth: 640, + srcset: `${IMAGE_ENDPOINT}&w=640&q=75 1x, ${IMAGE_ENDPOINT}&w=1920&q=75 2x`, + currentSrc: `${IMAGE_ENDPOINT}&w=640&q=75`, + }); + expect(await page.getAttribute('[data-testid="image-static"]', 'src')).toMatch( + /^\/_void\/image\?url=%2F/, + ); + expect(errors).toEqual([]); + } finally { + await context.close(); + } +} + afterAll(async () => { await browser?.close(); }); @@ -689,6 +753,48 @@ describe('react-pages client (dev)', () => { registerSharedDevSsrTest({ getBase: () => DEV_BASE, framework: 'React' }); + it('dev: Image renders Next.js markup and one preload link', async () => { + const response = await fetch(`${DEV_BASE}/image`); + expect(response.status).toBe(200); + const html = await response.text(); + expect(html).toContain(FIXED_IMAGE_HTML); + expect(html).toContain(PRELOAD_IMAGE_LINK); + expect(html.match(/ { + const webp = await fetch(`${DEV_BASE}${IMAGE_ENDPOINT}&w=640&q=75`, { + headers: { Accept: 'image/webp,*/*' }, + }); + expect(webp.status).toBe(200); + expect(webp.headers.get('content-type')).toBe('image/webp'); + expect(webp.headers.get('vary')).toBe('Accept'); + expect(webp.headers.get('cache-control')).toBe('public, max-age=0, must-revalidate'); + await webp.arrayBuffer(); + + const png = await fetch(`${DEV_BASE}${IMAGE_ENDPOINT}&w=640&q=75`, { + headers: { Accept: '*/*' }, + }); + expect(png.status).toBe(200); + expect(png.headers.get('content-type')).toBe('image/png'); + expect(await pngWidth(png)).toBe(640); + + const invalid = await fetch(`${DEV_BASE}${IMAGE_ENDPOINT}&w=641&q=75`); + expect(invalid.status).toBe(400); + expect(await invalid.text()).toBe('"w" parameter (width) of 641 is not allowed'); + }); + + it('dev: Image hydrates and loads through /_void/image', async () => { + await expectImagePageHydrates(DEV_BASE); + }); + it('dev: head tags, pages protocol, form handling, API coexistence', async () => { const rootHtml = await (await fetch(`${DEV_BASE}/`)).text(); expect(rootHtml).toContain('Home | React Pages Demo'); @@ -926,4 +1032,38 @@ describe('react-pages client (preview)', { retry: 2 }, () => { () => PREVIEW_BASE, () => browser, ); + + it('preview: Image uses the Images binding and caches fingerprinted imports', async () => { + const wranglerConfig = JSON.parse( + readFileSync(join(REACT_PAGES_ROOT, 'dist/ssr/wrangler.json'), 'utf-8'), + ) as { images?: unknown }; + expect(wranglerConfig.images).toEqual({ binding: 'IMAGES' }); + + const html = await (await fetch(`${PREVIEW_BASE}/image`)).text(); + expect(html).toContain(FIXED_IMAGE_HTML); + expect(html).toContain(PRELOAD_IMAGE_LINK); + + const publicImage = await fetch(`${PREVIEW_BASE}${IMAGE_ENDPOINT}&w=640&q=75`, { + headers: { Accept: 'image/webp,*/*' }, + }); + expect(publicImage.status).toBe(200); + expect(publicImage.headers.get('content-type')).toBe('image/webp'); + expect(publicImage.headers.get('cache-control')).toBe('public, max-age=14400, must-revalidate'); + await publicImage.arrayBuffer(); + + // Above the 4 KiB inline limit, so the build emits a fingerprinted asset. + const staticSrc = html.match( + /data-testid="image-static"[^>]* src="(\/_void\/image\?url=%2Fassets%2Fvoid-image-static-[\w-]+\.png&w=96&q=75)"/, + )?.[1]; + expect(staticSrc).toBeDefined(); + const staticImage = await fetch(`${PREVIEW_BASE}${staticSrc!.replaceAll('&', '&')}`); + expect(staticImage.status).toBe(200); + expect(staticImage.headers.get('content-type')).toBe('image/png'); + expect(staticImage.headers.get('cache-control')).toBe('public, max-age=315360000, immutable'); + await staticImage.arrayBuffer(); + }); + + it('preview: Image hydrates and loads through /_void/image', async () => { + await expectImagePageHydrates(PREVIEW_BASE); + }); }); diff --git a/packages/solid/test/integration/solid-pages-playground.test.ts b/packages/solid/test/integration/solid-pages-playground.test.ts index 8377423c2..3656ceb03 100644 --- a/packages/solid/test/integration/solid-pages-playground.test.ts +++ b/packages/solid/test/integration/solid-pages-playground.test.ts @@ -204,6 +204,150 @@ function registerClientTests(getBase: () => string, coverage: 'smoke' | 'full') }); } +// `pages/image.tsx`. Srcset widths follow `next/image`: the smallest configured +// size at least 1x and 2x the rendered width. +const IMAGE_ENDPOINT = '/_void/image?url=%2Fvoid-image-test.png'; +const IMAGE_FIXED_TAG = + 'Void image test`; +const IMAGE_PRELOAD_LINK = + '`; + +/** The SSR `` with `data-testid`, without Solid's hydration key. */ +function imageTag(html: string, testId: string): string { + const tag = html.match(new RegExp(`]*data-testid="${testId}"[^>]*>`))?.[0]; + expect(tag, ``).toBeDefined(); + return tag!.replace(/ data-hk="[^"]*"/, ''); +} + +/** The `/_void/image` URL of the `?image` import's 1x candidate, decoded from the SSR markup. */ +function importedImageUrl(html: string): string { + const srcset = imageTag(html, 'image-import').match(/ srcset="([^"]+)"/)?.[1] ?? ''; + return srcset.split(', ')[0].replace(/ 1x$/, '').replaceAll('&', '&'); +} + +/** + * `pages/image.tsx`: SSR markup, the `` preload link, the + * `/_void/image` endpoint (resized through the local Images binding) and + * hydration in the browser. + */ +function registerImageTests(getBase: () => string, mode: 'dev' | 'preview') { + it(`${mode}: Image renders next/image markup and a preload link`, async () => { + const res = await fetch(`${getBase()}/image`); + expect(res.status).toBe(200); + const html = await res.text(); + + expect(imageTag(html, 'image-fixed')).toBe(IMAGE_FIXED_TAG); + // `preload` drops `loading` and adds exactly one preload link to . + expect(imageTag(html, 'image-preload')).not.toContain('loading='); + const head = html.match(/([\s\S]*?)<\/head>/)?.[1] ?? ''; + expect(head).toContain(IMAGE_PRELOAD_LINK); + expect(html.match(/ { + const base = getBase(); + const cacheControl = + mode === 'dev' + ? 'public, max-age=0, must-revalidate' + : 'public, max-age=14400, must-revalidate'; + + const webp = await fetch(`${base}${IMAGE_ENDPOINT}&w=640&q=75`, { + headers: { Accept: 'image/webp,*/*' }, + }); + expect(webp.status).toBe(200); + expect(webp.headers.get('content-type')).toBe('image/webp'); + expect(webp.headers.get('vary')).toBe('Accept'); + expect(webp.headers.get('cache-control')).toBe(cacheControl); + const webpBytes = Buffer.from(await webp.arrayBuffer()); + expect(webpBytes.subarray(8, 12).toString('latin1')).toBe('WEBP'); + + // Without WebP in Accept the source format is kept; the PNG IHDR width is at byte 16. + const png = await fetch(`${base}${IMAGE_ENDPOINT}&w=640&q=75`, { + headers: { Accept: '*/*' }, + }); + expect(png.status).toBe(200); + expect(png.headers.get('content-type')).toBe('image/png'); + expect(png.headers.get('cache-control')).toBe(cacheControl); + expect(Buffer.from(await png.arrayBuffer()).readUInt32BE(16)).toBe(640); + + const rejected = await fetch(`${base}${IMAGE_ENDPOINT}&w=641&q=75`); + expect(rejected.status).toBe(400); + expect(await rejected.text()).toBe('"w" parameter (width) of 641 is not allowed'); + }); + + it(`${mode}: Image hydrates and loads through /_void/image`, async () => { + const context = await browser.newContext(); + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + const problems: Array = []; + page.on('pageerror', (error) => problems.push(`pageerror: ${error.message}`)); + page.on('console', (msg) => { + const text = msg.text(); + if (msg.type() === 'error' || /hydrat/i.test(text) || text.includes('Image with src')) { + problems.push(`console.${msg.type()}: ${text}`); + } + }); + + try { + await page.goto(`${getBase()}/image`); + await page.waitForSelector('#app[data-hydrated="true"]'); + // `complete` is also true after an error, which leaves `naturalWidth` at 0. + await page.waitForFunction(() => + ['image-fixed', 'image-import'].every( + (id) => document.querySelector(`[data-testid="${id}"]`)?.complete, + ), + ); + const images = await page.evaluate(() => + Object.fromEntries( + ['image-fixed', 'image-import'].map((id) => { + const img = document.querySelector(`[data-testid="${id}"]`)!; + const current = new URL(img.currentSrc); + return [ + id, + { + naturalWidth: img.naturalWidth, + currentSrc: current.pathname + current.search, + srcset: img.getAttribute('srcset'), + }, + ]; + }), + ), + ); + // A 1x screen picks the 640w candidate, so the 1200px source was resized. + expect(images['image-fixed']).toEqual({ + naturalWidth: 640, + currentSrc: `${IMAGE_ENDPOINT}&w=640&q=75`, + srcset: `${IMAGE_ENDPOINT}&w=640&q=75 1x, ${IMAGE_ENDPOINT}&w=1920&q=75 2x`, + }); + expect(images['image-import'].naturalWidth).toBe(64); + expect(images['image-import'].currentSrc).toMatch(/^\/_void\/image\?url=%2Fassets%2F/); + expect(problems).toEqual([]); + } finally { + await context.close(); + } + }); +} + describe('solid-pages client (dev)', () => { let child: ChildProcess | undefined; @@ -236,6 +380,7 @@ describe('solid-pages client (dev)', () => { }); registerSharedDevSsrTest({ getBase: () => DEV_BASE, framework: 'Solid' }); + registerImageTests(() => DEV_BASE, 'dev'); defineClientTests(() => DEV_BASE, 'smoke'); defineSharedIslandTests( @@ -288,4 +433,23 @@ describe('solid-pages client (preview)', { retry: 2 }, () => { waitForValidation: (page) => page.getByText('Title is required').waitFor({ timeout: 20_000 }), }, ); + + registerImageTests(() => PREVIEW_BASE, 'preview'); + + it('preview: Image uses the Images binding and caches fingerprinted imports', async () => { + const wranglerConfig = JSON.parse( + readFileSync(join(ROOT, 'playground/solid-pages/dist/ssr/wrangler.json'), 'utf-8'), + ) as { images?: unknown }; + expect(wranglerConfig.images).toEqual({ binding: 'IMAGES' }); + + // The import is above Vite's 4 KiB inline limit, so the build emits a fingerprinted asset. + const html = await (await fetch(`${PREVIEW_BASE}/image`)).text(); + const imported = await fetch(`${PREVIEW_BASE}${importedImageUrl(html)}`, { + headers: { Accept: '*/*' }, + }); + expect(imported.status).toBe(200); + expect(imported.headers.get('content-type')).toBe('image/png'); + expect(imported.headers.get('cache-control')).toBe('public, max-age=315360000, immutable'); + expect(Buffer.from(await imported.arrayBuffer()).readUInt32BE(16)).toBe(64); + }); }); diff --git a/packages/svelte/test/integration/svelte-pages-client.test.ts b/packages/svelte/test/integration/svelte-pages-client.test.ts index 1b7662b24..ec533d38f 100644 --- a/packages/svelte/test/integration/svelte-pages-client.test.ts +++ b/packages/svelte/test/integration/svelte-pages-client.test.ts @@ -1,5 +1,5 @@ import { execSync, type ChildProcess } from 'node:child_process'; -import { existsSync } from 'node:fs'; +import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { type Browser, type Page } from 'playwright'; import { describe, expect, it, beforeAll, afterAll } from 'vite-plus/test'; @@ -111,6 +111,166 @@ function registerClientTests(getBase: () => string, coverage: 'smoke' | 'full') }); } +const IMAGE_FIXED_TAG = + 'Void image test'; +const IMAGE_PRELOAD_LINK = + ''; +const IMAGE_ENDPOINT = '/_void/image?url=%2Fvoid-image-test.png&q=75'; + +function imageTag(html: string, testId: string): string { + const tag = html.match(new RegExp(`]*data-testid="${testId}"[^>]*>`))?.[0]; + expect(tag, ``).toBeDefined(); + return tag!; +} + +/** The `/_void/image` URL of the `?image` import's 1x candidate, decoded from the SSR markup. */ +function importedImageUrl(html: string): string { + const srcset = imageTag(html, 'image-import').match(/ srcset="([^"]+)"/)?.[1] ?? ''; + return srcset.split(', ')[0].replace(/ 1x$/, '').replaceAll('&', '&'); +} + +/** + * `pages/image.svelte`: SSR markup, the `` preload link and the + * `/_void/image` endpoint, which resizes through the local Images binding. + */ +function registerImageTests(getBase: () => string, mode: 'dev' | 'preview') { + it(`${mode}: Image renders next/image markup and a preload link`, async () => { + const res = await fetch(`${getBase()}/image`); + expect(res.status).toBe(200); + const html = await res.text(); + + expect(imageTag(html, 'image-fixed')).toBe(IMAGE_FIXED_TAG); + // `preload` drops `loading` and adds exactly one preload link to . + expect(imageTag(html, 'image-preload')).not.toContain('loading='); + const head = html.match(/([\s\S]*?)<\/head>/)?.[1] ?? ''; + expect(head).toContain(IMAGE_PRELOAD_LINK); + expect(html.match(/ { + const base = getBase(); + const cacheControl = + mode === 'dev' + ? 'public, max-age=0, must-revalidate' + : 'public, max-age=14400, must-revalidate'; + + const webp = await fetch(`${base}${IMAGE_ENDPOINT}&w=640`, { + headers: { Accept: 'image/webp,*/*' }, + }); + expect(webp.status).toBe(200); + expect(webp.headers.get('content-type')).toBe('image/webp'); + expect(webp.headers.get('vary')).toBe('Accept'); + expect(webp.headers.get('cache-control')).toBe(cacheControl); + const webpBytes = Buffer.from(await webp.arrayBuffer()); + expect(webpBytes.subarray(8, 12).toString('latin1')).toBe('WEBP'); + + // Without WebP in Accept the source format is kept; the PNG IHDR width is at byte 16. + const png = await fetch(`${base}${IMAGE_ENDPOINT}&w=640`, { headers: { Accept: '*/*' } }); + expect(png.status).toBe(200); + expect(png.headers.get('content-type')).toBe('image/png'); + expect(png.headers.get('cache-control')).toBe(cacheControl); + expect(Buffer.from(await png.arrayBuffer()).readUInt32BE(16)).toBe(640); + + const rejected = await fetch(`${base}${IMAGE_ENDPOINT}&w=641`); + expect(rejected.status).toBe(400); + expect(await rejected.text()).toBe('"w" parameter (width) of 641 is not allowed'); + }); + + it(`${mode}: Image hydrates and loads through /_void/image`, async () => { + const context = await browser.newContext(); + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + const problems: Array = []; + page.on('pageerror', (error) => problems.push(`pageerror: ${error.message}`)); + page.on('console', (msg) => { + const text = msg.text(); + if ( + msg.type() === 'error' || + text.includes('[svelte]') || + /hydrat/i.test(text) || + text.includes('Image with src') + ) { + problems.push(`console.${msg.type()}: ${text}`); + } + }); + + try { + await page.goto(`${getBase()}/image`); + await page.waitForSelector('#app[data-hydrated="true"]'); + // `complete` is also true after an error, which leaves `naturalWidth` at 0. + await page.waitForFunction(() => + ['image-fixed', 'image-import'].every( + (id) => document.querySelector(`[data-testid="${id}"]`)?.complete, + ), + ); + const images = await page.evaluate(() => + Object.fromEntries( + ['image-fixed', 'image-import'].map((id) => { + const img = document.querySelector(`[data-testid="${id}"]`)!; + const current = new URL(img.currentSrc); + return [ + id, + { + naturalWidth: img.naturalWidth, + currentSrc: current.pathname + current.search, + srcset: img.getAttribute('srcset'), + }, + ]; + }), + ), + ); + // A 1x screen picks the 640w candidate, so the 1200px source was resized. + expect(images['image-fixed']).toEqual({ + naturalWidth: 640, + currentSrc: '/_void/image?url=%2Fvoid-image-test.png&w=640&q=75', + srcset: + '/_void/image?url=%2Fvoid-image-test.png&w=640&q=75 1x, ' + + '/_void/image?url=%2Fvoid-image-test.png&w=1920&q=75 2x', + }); + expect(images['image-import'].naturalWidth).toBe(64); + expect(images['image-import'].currentSrc).toMatch(/^\/_void\/image\?url=%2Fassets%2F/); + // Hydration keeps the single preload link. + expect( + await page + .locator('head link[rel="preload"][as="image"]') + .evaluateAll((links) => links.map((link) => link.getAttribute('imagesrcset'))), + ).toEqual([ + '/_void/image?url=%2Fvoid-image-test.png&w=640&q=75 1x, ' + + '/_void/image?url=%2Fvoid-image-test.png&w=1080&q=75 2x', + ]); + expect(problems).toEqual([]); + } finally { + await context.close(); + } + }); +} + describe('svelte-pages client (dev)', () => { let child: ChildProcess | undefined; @@ -129,6 +289,7 @@ describe('svelte-pages client (dev)', () => { }); registerSharedDevSsrTest({ getBase: () => DEV_BASE, framework: 'Svelte' }); + registerImageTests(() => DEV_BASE, 'dev'); defineClientTests(() => DEV_BASE, 'smoke'); defineSharedIslandTests( @@ -175,4 +336,23 @@ describe('svelte-pages client (preview)', { retry: 2 }, () => { () => PREVIEW_BASE, () => browser, ); + registerImageTests(() => PREVIEW_BASE, 'preview'); + + it('preview: the build binds IMAGES and serves imported images as immutable', async () => { + const wrangler = JSON.parse( + readFileSync(join(ROOT, 'playground/svelte-pages/dist/ssr/wrangler.json'), 'utf-8'), + ) as { images?: unknown }; + expect(wrangler.images).toEqual({ binding: 'IMAGES' }); + + // The imported file is above Vite's inline limit, so it is a fingerprinted asset. + const html = await (await fetch(`${PREVIEW_BASE}/image`)).text(); + const imported = await fetch(`${PREVIEW_BASE}${importedImageUrl(html)}`, { + headers: { Accept: 'image/webp,*/*' }, + }); + expect(imported.status).toBe(200); + expect(imported.headers.get('content-type')).toBe('image/webp'); + expect(imported.headers.get('cache-control')).toBe('public, max-age=315360000, immutable'); + const importedBytes = Buffer.from(await imported.arrayBuffer()); + expect(importedBytes.subarray(8, 12).toString('latin1')).toBe('WEBP'); + }); }); diff --git a/packages/vue/test/integration/vue-pages-client.test.ts b/packages/vue/test/integration/vue-pages-client.test.ts index 62f75e4d8..3cd3f76e4 100644 --- a/packages/vue/test/integration/vue-pages-client.test.ts +++ b/packages/vue/test/integration/vue-pages-client.test.ts @@ -194,6 +194,154 @@ function registerClientTests(getBase: () => string, coverage: 'smoke' | 'full') }); } +const IMAGE_FIXED_TAG = + 'Void image test'; +const IMAGE_PRELOAD_LINK = + ''; +const IMAGE_ENDPOINT = '/_void/image?url=%2Fvoid-image-test.png&q=75'; + +function imageTag(html: string, testId: string): string { + const tag = html.match(new RegExp(`]*data-testid="${testId}"[^>]*>`))?.[0]; + expect(tag, ``).toBeDefined(); + return tag!; +} + +/** The `/_void/image` URL of the `?image` import's 1x candidate, decoded from the SSR markup. */ +function importedImageUrl(html: string): string { + const srcset = imageTag(html, 'image-import').match(/ srcset="([^"]+)"/)?.[1] ?? ''; + return srcset.split(', ')[0].replace(/ 1x$/, '').replaceAll('&', '&'); +} + +/** + * `pages/image.vue`: SSR markup, the `` preload link and the + * `/_void/image` endpoint, which resizes through the local Images binding. + */ +function registerImageTests(getBase: () => string, mode: 'dev' | 'preview') { + it(`${mode}: Image renders next/image markup and a preload link`, async () => { + const res = await fetch(`${getBase()}/image`); + expect(res.status).toBe(200); + const html = await res.text(); + + expect(imageTag(html, 'image-fixed')).toBe(IMAGE_FIXED_TAG); + // `preload` drops `loading` and adds exactly one preload link to . + expect(imageTag(html, 'image-preload')).not.toContain('loading='); + const head = html.match(/([\s\S]*?)<\/head>/)?.[1] ?? ''; + expect(head).toContain(IMAGE_PRELOAD_LINK); + expect(html.match(/ { + const base = getBase(); + const cacheControl = + mode === 'dev' + ? 'public, max-age=0, must-revalidate' + : 'public, max-age=14400, must-revalidate'; + + const webp = await fetch(`${base}${IMAGE_ENDPOINT}&w=640`, { + headers: { Accept: 'image/webp,*/*' }, + }); + expect(webp.status).toBe(200); + expect(webp.headers.get('content-type')).toBe('image/webp'); + expect(webp.headers.get('vary')).toBe('Accept'); + expect(webp.headers.get('cache-control')).toBe(cacheControl); + const webpBytes = Buffer.from(await webp.arrayBuffer()); + expect(webpBytes.subarray(8, 12).toString('latin1')).toBe('WEBP'); + + // Without WebP in Accept the source format is kept; the PNG IHDR width is at byte 16. + const png = await fetch(`${base}${IMAGE_ENDPOINT}&w=640`, { headers: { Accept: '*/*' } }); + expect(png.status).toBe(200); + expect(png.headers.get('content-type')).toBe('image/png'); + expect(png.headers.get('cache-control')).toBe(cacheControl); + expect(Buffer.from(await png.arrayBuffer()).readUInt32BE(16)).toBe(640); + + const rejected = await fetch(`${base}${IMAGE_ENDPOINT}&w=641`); + expect(rejected.status).toBe(400); + expect(await rejected.text()).toBe('"w" parameter (width) of 641 is not allowed'); + }); + + it(`${mode}: Image hydrates and loads through /_void/image`, async () => { + const context = await browser.newContext(); + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + const problems: Array = []; + page.on('pageerror', (error) => problems.push(`pageerror: ${error.message}`)); + page.on('console', (msg) => { + const text = msg.text(); + if ( + msg.type() === 'error' || + text.includes('[Vue warn]') || + text.includes('Hydration') || + text.includes('Image with src') + ) { + problems.push(`console.${msg.type()}: ${text}`); + } + }); + + try { + await page.goto(`${getBase()}/image`); + await page.waitForSelector('#app[data-hydrated="true"]'); + // `complete` is also true after an error, which leaves `naturalWidth` at 0. + await page.waitForFunction(() => + ['image-fixed', 'image-import'].every( + (id) => document.querySelector(`[data-testid="${id}"]`)?.complete, + ), + ); + const images = await page.evaluate(() => + Object.fromEntries( + ['image-fixed', 'image-import'].map((id) => { + const img = document.querySelector(`[data-testid="${id}"]`)!; + const current = new URL(img.currentSrc); + return [ + id, + { + naturalWidth: img.naturalWidth, + currentSrc: current.pathname + current.search, + srcset: img.getAttribute('srcset'), + }, + ]; + }), + ), + ); + // A 1x screen picks the 640w candidate, so the 1200px source was resized. + expect(images['image-fixed']).toEqual({ + naturalWidth: 640, + currentSrc: '/_void/image?url=%2Fvoid-image-test.png&w=640&q=75', + srcset: + '/_void/image?url=%2Fvoid-image-test.png&w=640&q=75 1x, ' + + '/_void/image?url=%2Fvoid-image-test.png&w=1920&q=75 2x', + }); + expect(images['image-import'].naturalWidth).toBe(64); + expect(images['image-import'].currentSrc).toMatch(/^\/_void\/image\?url=%2Fassets%2F/); + expect(problems).toEqual([]); + } finally { + await context.close(); + } + }); +} + describe('vue-pages client (dev)', () => { let child: ChildProcess | undefined; @@ -216,6 +364,7 @@ describe('vue-pages client (dev)', () => { }); registerSharedDevSsrTest({ getBase: () => DEV_BASE, framework: 'Vue' }); + registerImageTests(() => DEV_BASE, 'dev'); defineClientTests(() => DEV_BASE, 'smoke'); defineSharedIslandTests( @@ -343,4 +492,23 @@ describe('vue-pages client (preview)', { retry: 2 }, () => { () => PREVIEW_BASE, () => browser, ); + registerImageTests(() => PREVIEW_BASE, 'preview'); + + it('preview: the build binds IMAGES and serves imported images as immutable', async () => { + const wrangler = JSON.parse( + readFileSync(join(ROOT, 'playground/vue-pages/dist/ssr/wrangler.json'), 'utf-8'), + ) as { images?: unknown }; + expect(wrangler.images).toEqual({ binding: 'IMAGES' }); + + // The imported file is above Vite's inline limit, so it is a fingerprinted asset. + const html = await (await fetch(`${PREVIEW_BASE}/image`)).text(); + const imported = await fetch(`${PREVIEW_BASE}${importedImageUrl(html)}`, { + headers: { Accept: 'image/webp,*/*' }, + }); + expect(imported.status).toBe(200); + expect(imported.headers.get('content-type')).toBe('image/webp'); + expect(imported.headers.get('cache-control')).toBe('public, max-age=315360000, immutable'); + const importedBytes = Buffer.from(await imported.arrayBuffer()); + expect(importedBytes.subarray(8, 12).toString('latin1')).toBe('WEBP'); + }); }); diff --git a/playground/react-pages/pages/image.tsx b/playground/react-pages/pages/image.tsx new file mode 100644 index 000000000..1003b99f9 --- /dev/null +++ b/playground/react-pages/pages/image.tsx @@ -0,0 +1,38 @@ +import { Image } from '@void/react'; +import staticImage from '../src/void-image-static.png?image'; + +export default function ImagePage() { + return ( +
+

Image

+ Void image test + Void image preload +
+ Void image fill +
+ Void image static import +
+ ); +} diff --git a/playground/react-pages/public/void-image-test.png b/playground/react-pages/public/void-image-test.png new file mode 100644 index 0000000000000000000000000000000000000000..002caf3a0bfe2202007386a82ed0f7f18e30015e GIT binary patch literal 813 zcmeAS@N?(olHy`uVBq!ia0y~yVA;UHz!bs33>0y-c&`DZm;-!5Tyr&Ux~eZM268zI zJR*TK^9vAW+*%!!4b*VK)5S5Q;?~=PjJ!Z4hZcPIfBS|}1jrl(qaiQ^L%{rh9s{FB S)4eQ^+dWfTewaa*wSEE5LH!OR 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áNo&TWDmbipbRjGgf`J6MGXJp4>{)?XE;8TE1a_i6N=^LsLOsd2 zZCL-oBOwdiiOMXo8e(vf^V0OSS?1eUJh#%$80jvVWZ~jkLGp=H_h2QO|EB?jNuM*z*(J~NdhCb1WShc1jn&Cko${<+s~5b#*C12)TyQ*Ysw^s8|> zke{mRciWy;ZiZXr>afFII>cht*d>m7xy&JXkHNOSxF=kMHD8u;eS-;G$L6sO`TOSt zP4Sy|(^#311d86OvzM|NYnjuy3hWwf83Sjk+`5TZU%kc}{&3*9(8996kG(Q4Yg}nv zbpgJ^3>w`Fz%Mp&yG*<9Y3LxI`HalDhg_x6KMEr{na(&7XCQ|*xlS|(#esHgxX zfdz3#u4n5U_30Z+cHiKot-##F=MXs#4Sn$biNkXoIv2XJF1;iSjW-z6EtFb|j^y9Z zC={O^|6}ktHcv89LRI4Q^7yNn;?RsF(>`nUlMs}x zic;+X*!+KRRl^R+(1LF0bmQ9672+#+55>mIegz_-Fe;{gixvt*^&sE`^ZI#hyfiY} zyHJhB_T#j{j-5k(hbK_7rumx}J~VP%E_>qnhbdfLbX8jbWk?T=G)L}^c}89!deHkk zIFO;vI4o&X`i9|1i%$;($g`N}7A{i=#hf|!>I_=Po8nMUQ^_(ohr4s@n)cZmAo}DF z!ik)MqA({IQG8eYdJy(@5iHqbIE?JcSFptk}MD`a1|fmZ>EBp1sfN+MbyO zSG4l_XQrnCirNkJrKJ;p4a>`c|2y1hlN)OH($c+i>R z&>~9S*CFD#yYQRt=4-`Sqf#D9wi%?B{b`K=^`TNJyA&okG41(AEJL)m__bikgX+edM)eSs<(7*QAE81)q6q(jaX?U9 j#shNJ3JK}6uE(<-{Otg(1%#K!00000NkvXXu0mjfYAzTX literal 0 HcmV?d00001 diff --git a/playground/solid-pages/pages/image.tsx b/playground/solid-pages/pages/image.tsx new file mode 100644 index 000000000..86e3b5022 --- /dev/null +++ b/playground/solid-pages/pages/image.tsx @@ -0,0 +1,39 @@ +import { Image } from '@void/solid'; +import importedPhoto from '../assets/void-image-import.png?image'; + +export default function ImagePage() { + return ( +
+

Images

+ Void image test + Void image preload +
+ Void image fill +
+ Void image import +
+ ); +} diff --git a/playground/solid-pages/public/void-image-test.png b/playground/solid-pages/public/void-image-test.png new file mode 100644 index 0000000000000000000000000000000000000000..7fd915b114d760e1ed79c4d07aad058c1426f21a GIT binary patch literal 813 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 literal 0 HcmV?d00001 diff --git a/playground/svelte-pages/pages/image.svelte b/playground/svelte-pages/pages/image.svelte new file mode 100644 index 000000000..9d20bb800 --- /dev/null +++ b/playground/svelte-pages/pages/image.svelte @@ -0,0 +1,34 @@ + + +
+

Images

+ Void image test + Void image preload +
+ Void image fill +
+ Void image import +
diff --git a/playground/svelte-pages/public/void-image-test.png b/playground/svelte-pages/public/void-image-test.png new file mode 100644 index 0000000000000000000000000000000000000000..9acf61c730ec2c5c6991e92e16156e191ea814b3 GIT binary patch literal 3030 zcmeAS@N?(olHy`uVBq!ia0y~yVA;UHz!bs31Qhw((%}oFI14-?iy0W0Uw|;<*6N^a z1_tg9PZ!6Kid%258}c$R@EqQtBwZh--6Oj>h2`g@xj;=HCa?g>HUS{v%)!9Wpa|q1 z5CEE*z`@ACV9){-mv90SJc`s*Mn|ha4sTGL0(3p?y@eV`Gz~#itH9A_(4s|~*ur8L zA}sMr(jfiNJE?;FN2go?4@yMn(jWkFWoJ5C1@<2)~ z(4}S^EdyxY0-<*eLg$3aFfxh&P{WtbAxl9sKt^+>McFi;yN;lei^0>?&t;ucLK6T? Ct4k07 literal 0 HcmV?d00001 diff --git a/playground/vue-pages/README.md b/playground/vue-pages/README.md index b4c25986a..4b904d713 100644 --- a/playground/vue-pages/README.md +++ b/playground/vue-pages/README.md @@ -14,6 +14,11 @@ pages/ index.server.ts # Server handler with loader + action [id].vue # Dynamic user detail page [id].server.ts # Server handler for [id] page + image.vue # Image: fixed, preload, fill and ?image import +assets/ + void-image-import.png # Imported with ?image (fingerprinted in builds) +public/ + void-image-test.png # Served from /, optimized through /_void/image routes/api/ health.ts # Health check endpoint (GET /api/health) middleware/ @@ -36,3 +41,4 @@ db/ - **`Link` component** — SPA navigation (renders `
` for GET, ``); + + const { buildStart, loadClientEntry } = setupPlugin(); + await buildStart(); + const clientEntry = loadClientEntry()!; + + expect(clientEntry).toContain( + `const context = new Map([[${JSON.stringify(IMAGE_PRELOADS_KEY)}, new Map()]]);`, + ); + expect(clientEntry).toContain('hydrate(Component, { target: el, props, context });'); + }); }); describe('islandsPlugin — island style loader', () => { diff --git a/packages/void/AGENTS.md b/packages/void/AGENTS.md index 31f6beed9..56843c858 100644 --- a/packages/void/AGENTS.md +++ b/packages/void/AGENTS.md @@ -141,7 +141,8 @@ Two features need extra wiring on a self-hosted `vite build && wrangler deploy` - **Endpoint mounting**: generated route tables mount `createImageHandler` with `app.all(images.path)` after auth and user middleware and before WebSocket hooks, routes, and pages, only when `shouldMountImageOptimizer()` holds (Cloudflare target, `isImageOptimizerActive()`, and `Image`/`getImageProps` usage or `inference.bindings.images`). The handler gets the full `ResolvedImageConfig` as a JSON literal and shares the `__VOID_ISR_DEPLOYMENT_ID` literal. The path skips the Void auth session lookup (`skipPaths`), self-host ISR (`bypassPaths`), and is added to SPA `run_worker_first`. Apps without images must generate byte-identical code. - **Images binding emission**: like Workers AI, `mergeWorkerBindings` adds `images: { binding }` only for `needsImages` and only when no authored `images` binding exists, but for serve and build alike (Miniflare transforms locally, no `remote`). Name precedence: authored `cloudflare.images` > `inference.bindings.images` > `IMAGES`. `vars.__VOID_IMAGES_BINDING` records a non-default name for `runtime/routing-image.ts` and is on the kitchen-sink leak-check allowlist. `Image` usage needs the binding only while `isImageOptimizerEnabled()`, which is false in framework apps, so `inference/walk.ts` can count `next/image` imports as `@void/react/image` (the documented `resolve.alias` migration) without affecting vinext; apps without `Image`, `getImageProps`, or `env.IMAGES` emit no binding, var, or route, and nothing probes Images entitlement (Free plan error 9422 serves the original). - **Static imports**: `?image` loads in `imagePlugin` with dimensions from `image-size.ts` (file headers, no sharp) and returns `{ src, width, height }`; plain image imports keep Vite's URL. User `localPatterns` get `{ pathname: '/assets/**', search: '' }` appended, which assumes Vite's default `build.assetsDir` and covers only built URLs. The dev server serves imports from their source paths, so with user patterns dev `?image` imports return the file's `/@fs/` URL and the dev config appends `/@fs/**` (`usesFsStaticImageUrls`). The endpoint sends the immutable `Cache-Control` only for local sources under `assetsDir` listed in the client manifest's `immutableFiles`. -- **Void Platform**: the platform has no Images binding. Managed builds (`VOID_DEPLOY_PROJECT_ID`, Cloudflare target) resolve `images` as unoptimized unless a non-default loader or `loaderFile` is set, and `void deploy` warns once (`managedImageOptimizerWarning`). Without a binding the endpoint serves validated originals. +- **Endpoint sources**: generated route tables pass `app` to `createImageHandler`, which requests local sources through it as a header-less `GET` (Next.js's `routerServerHandler` path), so path-scoped middleware applies; an app `404` falls back to `env.ASSETS`, and any other non-2xx fails with its status. An `AsyncLocalStorage` flag answers `400` when that request reaches the endpoint again (a rewrite), instead of recursing. Remote redirect targets must match `remotePatterns`: Workers cannot resolve DNS, and Miniflare reaches private networks. A transform-failure or missing-binding original (`fallback`) never gets the immutable policy. +- **Void Platform**: the platform has no Images binding. Managed builds (`VOID_DEPLOY_PROJECT_ID`, Cloudflare target) resolve `images` as unoptimized unless a non-default loader or `loaderFile` is set, and `void deploy` warns once (`managedImageOptimizerWarning`): the `Image` message for optimizer usage, otherwise an `env.IMAGES`-is-undefined message for any other `needsImages` (env read, `inference.bindings.images`, vinext). Without a binding the endpoint serves validated originals. - **Native deploy verification**: `deploy-artifact-validation.ts` accepts a generated `images` binding only when it equals the pre-build root binding, or Void's own `{ binding }` named by `imagesBindingName ?? 'IMAGES'` when pre-build inference found `needsImages`. `needsImages` also blocks linking an existing Worker in `deploy-cloudflare-preflight.ts`. ### Package Exports @@ -182,7 +183,7 @@ Column types for `gen model`: `string`, `text`, `datetime` → `text()`, `intege `gen model` creates Drizzle table definitions in `db/schema/` and updates the `db/schema.ts` barrel export. Auto-detects your validation library (valibot, zod, or arktype) from `package.json`. If none is found, prompts to choose one or skip validation. -`gen ssr` entries put `Image` preload links in ``: the Vue entry passes an SSR context and renders `ssrContext.__voidImagePreloads` (the key in `packages/vue/src/runtime/image.ts`, now part of scaffolded user code), and the Svelte entry renders `render()`'s `head`. React emits them through `ReactDOM.preload`. The Solid entry has no `ImagePreloadContext` provider, which would add an `@void/solid` dependency and a matching client provider, so Solid preloads are dropped there. +`gen ssr` entries put `Image` preload links in ``: the Vue entry passes an SSR context and renders `ssrContext.__voidImagePreloads` (the key in `packages/vue/src/runtime/image.ts`, now part of scaffolded user code), and the Svelte entry renders `render()`'s `head`. React emits them through `ReactDOM.preload`. The Solid entry has no `ImagePreloadContext` provider, which would add an `@void/solid` dependency and a matching client provider, so Solid preloads are dropped there. `docs/guide/images.md#custom-ssr` documents this wiring (including the Vue key) for users; keep them in sync. Source: `src/cli/gen-args.ts` (arg parsing), `src/cli/gen.ts` (command runner). Tests: `test/unit/gen-args.test.ts`, `test/unit/gen.test.ts`. diff --git a/packages/void/src/cli/deploy-cloudflare-build.ts b/packages/void/src/cli/deploy-cloudflare-build.ts index e5a6d4cba..442fa6319 100644 --- a/packages/void/src/cli/deploy-cloudflare-build.ts +++ b/packages/void/src/cli/deploy-cloudflare-build.ts @@ -58,8 +58,17 @@ export async function buildCloudflareWorker(input: { prepareBuildOutput: () => ReturnType; }> { const { root, voidConfig, deps, exit, atomic, frameworkPreset, detectedFramework } = input; - const { configPath, needsAI, aiBindingName, migrationsDir, dialect, authEnabled, desired } = - input.source; + const { + configPath, + needsAI, + aiBindingName, + needsImages, + imagesBindingName, + migrationsDir, + dialect, + authEnabled, + desired, + } = input.source; const { accountId, apiBaseUrl, @@ -118,6 +127,7 @@ export async function buildCloudflareWorker(input: { preset: frameworkPreset, requiredSecretNames: schemaFacts.requiredKeys, ai: needsAI ? { binding: aiBindingName } : undefined, + images: needsImages ? { binding: imagesBindingName } : undefined, preserveBindings: existingWorker !== null, }); } catch (error) { diff --git a/packages/void/src/cli/deploy-cloudflare-preflight.ts b/packages/void/src/cli/deploy-cloudflare-preflight.ts index 2bf0b26e5..d44e2a54f 100644 --- a/packages/void/src/cli/deploy-cloudflare-preflight.ts +++ b/packages/void/src/cli/deploy-cloudflare-preflight.ts @@ -66,7 +66,7 @@ import { provisionCloudflareResources, type ProvisionDriftEntry, } from './provision.ts'; -import { writeProvisionedWranglerConfig } from './wrangler.ts'; +import { addsImagesBinding, writeProvisionedWranglerConfig } from './wrangler.ts'; /** Validate source-level native deploy inputs before account operations or project code. */ export async function prepareCloudflareDeploySource( @@ -196,6 +196,7 @@ export async function preflightCloudflareWorker(input: { authEnabled, needsAI, needsImages, + imagesBindingName, expectedCrons, desired, migrations: preBuildMigrations, @@ -347,7 +348,7 @@ export async function preflightCloudflareWorker(input: { authEnabled || needsSandbox || needsAI || - needsImages || + addsImagesBinding(rootConfig, needsImages, imagesBindingName) || expectedCrons.length || desired.queues.length || preBuildMigrations.length diff --git a/packages/void/src/cli/deploy-cloudflare-static.ts b/packages/void/src/cli/deploy-cloudflare-static.ts index d6bd48da1..d2f7d055a 100644 --- a/packages/void/src/cli/deploy-cloudflare-static.ts +++ b/packages/void/src/cli/deploy-cloudflare-static.ts @@ -62,7 +62,7 @@ import { getErrorMessage, note } from './output.ts'; import { buildStaticManifest } from './package.ts'; import { detectPreset, type StaticPreset, type FrameworkPreset } from './preset.ts'; import { deleteEnvVarCaseAware } from './provision.ts'; -import { readWranglerManagedQueueCleanupCandidates } from './wrangler.ts'; +import { addsImagesBinding, readWranglerManagedQueueCleanupCandidates } from './wrangler.ts'; /** Default runtime date for static and framework Workers that set none. */ const DEFAULT_COMPATIBILITY_DATE = '2026-09-11'; @@ -78,6 +78,8 @@ export async function prepareCloudflareFrameworkArtifact(options: { preset: FrameworkPreset; requiredSecretNames?: ReadonlyArray; ai?: { binding: string }; + /** `env.IMAGES` reads; added unless the root config already owns the name. */ + images?: { binding: string }; preserveBindings?: boolean; }): Promise { const { root, configPath, accountId, workerName, voidConfig, framework, preset, ai } = options; @@ -142,6 +144,9 @@ export async function prepareCloudflareFrameworkArtifact(options: { requiredSecrets: requiredSecretNames, versionMetadataBinding, ...(ai ? { ai: { ...ai, gateway: voidConfig.ai?.gateway } } : {}), + ...(options.images && addsImagesBinding(rootConfig, true, options.images.binding) + ? { images: options.images } + : {}), ...(preset.workerDir === preset.assetsDir ? { assetsIgnore: [ diff --git a/packages/void/src/cli/deploy-managed-full.ts b/packages/void/src/cli/deploy-managed-full.ts index abd877499..fd1c593ab 100644 --- a/packages/void/src/cli/deploy-managed-full.ts +++ b/packages/void/src/cli/deploy-managed-full.ts @@ -66,17 +66,22 @@ import type { WrapperFeatures } from './wrapper.ts'; import { needsWrapper } from './wrapper.ts'; /** - * The warning a Void Platform deploy prints when the app renders `Image` through the default - * `/_void/image` optimizer. The platform has no Images binding yet, so managed builds resolve - * images as unoptimized. + * The warning a Void Platform deploy prints when the app needs the Images binding, which the + * platform doesn't provide yet. `Image` through the default `/_void/image` optimizer gets the + * optimizer message (managed builds resolve images as unoptimized). Otherwise `needsImages` + * comes from an `env.IMAGES` read, `inference.bindings.images`, or vinext's optimizer, and the + * binding is simply undefined at runtime. */ export function managedImageOptimizerWarning( - bindings: Pick, + bindings: Pick, voidConfig: Pick, detected: DetectedFramework | undefined, ): string | undefined { - return bindings.usesImageOptimizer && isImageOptimizerEnabled(voidConfig, detected) - ? "Image optimization isn't available on Void Platform yet, so Image serves original files." + if (bindings.usesImageOptimizer && isImageOptimizerEnabled(voidConfig, detected)) { + return "Image optimization isn't available on Void Platform yet, so Image serves original files."; + } + return bindings.needsImages + ? "The Cloudflare Images binding (env.IMAGES) isn't available on Void Platform yet, so reading it returns undefined. Use `void deploy --platform cloudflare` to deploy with it." : undefined; } diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 22e4fcfec..15939cd52 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -530,7 +530,7 @@ export function syncWranglerBindings( root: string, bindings: InferredBindings, options?: { - bindingNames?: { d1?: string; kv?: string; r2?: string; ai?: string }; + bindingNames?: { d1?: string; kv?: string; r2?: string; ai?: string; images?: string }; directCloudflareAi?: { gateway?: string }; websockets?: Array; live?: boolean | LiveBindingConfig; @@ -596,6 +596,13 @@ export function syncWranglerBindings( } } + // Miniflare transforms images locally, so the binding needs no `remote` flag. + const imagesName = options?.bindingNames?.images ?? 'IMAGES'; + if (addsImagesBinding(parsed, bindings.needsImages, imagesName)) { + parsed.images = { binding: imagesName }; + modified = true; + } + if (options?.websockets) { // Never remove a binding or emit `deleted_classes` for a removed route: that can break // the next upload or destroy the class's SQLite storage. The compiler exports a tombstone. @@ -2264,6 +2271,28 @@ export function findBindingNameOwner( return owners.get(name); } +/** + * Whether Void adds the Cloudflare Images binding `name` to a Wrangler config: + * the app needs it, the config declares no `images` entry (an authored one + * stays authoritative), and no other binding uses the name. With such a + * binding, an `env.IMAGES` read reads the user's own binding. + */ +export function addsImagesBinding( + config: Record, + needsImages: boolean | undefined, + name: string, +): boolean { + if (needsImages !== true || config.images !== undefined) { + return false; + } + try { + return findBindingNameOwner(config, 'images', name) === undefined; + } catch { + // A malformed field is Wrangler's to report. + return true; + } +} + /** * The one entry in `field` (`d1_databases`, `kv_namespaces`, …) with this binding, or * undefined. Throws on a malformed field, a duplicate entry, or a name another binding diff --git a/packages/void/src/image-config.ts b/packages/void/src/image-config.ts index 547b535a6..910d712e8 100644 --- a/packages/void/src/image-config.ts +++ b/packages/void/src/image-config.ts @@ -5,7 +5,7 @@ */ import { existsSync } from 'node:fs'; import { isRecord } from '@void/deploy-core'; -import { resolve } from 'pathslash'; +import { isAbsolute, join, resolve } from 'pathslash'; import picomatch from 'picomatch'; import { CLOUDFLARE_IMAGE_PATH, @@ -57,8 +57,8 @@ const STRING_KEYS = ['contentSecurityPolicy', 'loaderFile', 'path'] as const; const REMOTE_PATTERN_KEYS = ['protocol', 'hostname', 'port', 'pathname', 'search']; const LOCAL_PATTERN_KEYS = ['pathname', 'search']; -/** Static `?image` imports are emitted under Vite's default `build.assetsDir`. */ -const STATIC_IMPORT_PATTERN: LocalPattern = { pathname: '/assets/**', search: '' }; +/** Vite's default `build.assetsDir`, where static `?image` imports are emitted. */ +const DEFAULT_ASSETS_DIR = 'assets'; const NODE_TARGETS = new Set(['node', 'bun', 'deno']); @@ -343,6 +343,12 @@ export type ResolveImageConfigOptions = { output?: 'server' | 'static'; /** A Void Platform build, which has no Images binding yet. */ managed?: boolean; + /** + * Vite's `build.assetsDir` (default `assets`). User `localPatterns` get a + * pattern for it so static `?image` imports stay allowed. The plugin + * corrects it with `setImageAssetsDir` once Vite's config is resolved. + */ + assetsDir?: string; }; /** Normalize one remote pattern as Next.js does and compile its globs (picomatch v4). */ @@ -391,9 +397,76 @@ export function compileLocalPattern(p: LocalPattern): CompiledLocalPattern { const ascending = (a: number, b: number): number => a - b; +/** The local pattern for static `?image` imports emitted under `assetsDir`. */ +function staticImportPattern(assetsDir: string): LocalPattern { + const dir = assetsDir.replace(/^\/+|\/+$/g, ''); + return { pathname: dir ? `/${dir}/**` : '/*', search: '' }; +} + +/** + * The user's `localPatterns` and the static import pattern, or the default + * `[{ pathname: '**', search: '' }]` when the user sets none. + */ +function resolveLocalPatterns( + user: ReadonlyArray | undefined, + assetsDir: string, +): Array { + if (!user) { + return [{ pathname: '**', search: '' }]; + } + const patterns = user.map((p) => ({ ...p })); + const staticImports = staticImportPattern(assetsDir); + if ( + !patterns.some( + (p) => p.pathname === staticImports.pathname && p.search === staticImports.search, + ) + ) { + // Static `?image` imports are always allowed. + patterns.push(staticImports); + } + return patterns; +} + +/** The user's `localPatterns` and how many leading entries `resolveImageConfig` compiled from them. */ +const localPatternSources = new WeakMap< + ResolvedImageConfig, + { user: ReadonlyArray | undefined; count: number } +>(); + +/** + * Allow static `?image` imports under Vite's resolved `build.assetsDir` in + * place of the directory `resolved` was resolved with. Patterns appended to + * `resolved.localPatterns` after resolution are kept. + */ +export function setImageAssetsDir(resolved: ResolvedImageConfig, assetsDir: string): void { + const source = localPatternSources.get(resolved); + if (!source?.user) { + return; + } + const compiled = resolveLocalPatterns(source.user, assetsDir).map(compileLocalPattern); + resolved.localPatterns.splice(0, source.count, ...compiled); + source.count = compiled.length; +} + +/** + * Next.js joins `loaderFile` to the project directory, so `/lib/loader.ts` + * is `/lib/loader.ts`. An absolute path to an existing file outside it + * also works. + */ +function resolveLoaderFile(root: string, loaderFile: string): string { + const joined = join(root, loaderFile); + if (!existsSync(joined) && isAbsolute(loaderFile) && existsSync(loaderFile)) { + return resolve(loaderFile); + } + if (!existsSync(joined)) { + throw new Error(`Specified images.loaderFile does not exist at "${joined}".`); + } + return joined; +} + /** * Apply defaults and Next.js 16 normalization to `images`, compile the - * patterns, resolve `loaderFile`, and force `unoptimized` where no optimizer + * patterns, resolve `loaderFile`, and set `noOptimizer` where no optimizer * exists (`node`/`bun`/`deno` targets, static output, Void Platform builds) * unless a custom loader or `loaderFile` is set. */ @@ -409,19 +482,10 @@ export function resolveImageConfig( } const merged = { ...imageConfigDefault, ...user }; - const localPatterns: Array = merged.localPatterns - ? merged.localPatterns.map((p) => ({ ...p })) - : [{ pathname: '**', search: '' }]; - if ( - merged.localPatterns && - !localPatterns.some( - (p) => - p.pathname === STATIC_IMPORT_PATTERN.pathname && p.search === STATIC_IMPORT_PATTERN.search, - ) - ) { - // Static `?image` imports are always allowed. - localPatterns.push({ ...STATIC_IMPORT_PATTERN }); - } + const localPatterns = resolveLocalPatterns( + merged.localPatterns, + options.assetsDir ?? DEFAULT_ASSETS_DIR, + ); const loader = merged.loader || 'default'; let path = merged.path; @@ -451,14 +515,10 @@ export function resolveImageConfig( `Specified images.loader property (${loader}) cannot be used with images.loaderFile property. Please set images.loader to "custom".`, ); } - const absolutePath = resolve(options.root, merged.loaderFile); - if (!existsSync(absolutePath)) { - throw new Error(`Specified images.loaderFile does not exist at "${absolutePath}".`); - } - loaderFile = absolutePath; + loaderFile = resolveLoaderFile(options.root, merged.loaderFile); } - const noOptimizer = + const optimizerUnavailable = (options.target !== undefined && NODE_TARGETS.has(options.target)) || options.output === 'static' || options.managed === true; @@ -467,7 +527,7 @@ export function resolveImageConfig( const deviceSizes = [...merged.deviceSizes].sort(ascending); const imageSizes = [...merged.imageSizes].sort(ascending); - return { + const resolved: ResolvedImageConfig = { deviceSizes, imageSizes, allSizes: [...deviceSizes, ...imageSizes].sort(ascending), @@ -486,28 +546,64 @@ export function resolveImageConfig( loader, loaderFile, path, - unoptimized: merged.unoptimized || (noOptimizer && !customLoader), + unoptimized: merged.unoptimized, + noOptimizer: optimizerUnavailable && !customLoader, dev: options.dev, }; + localPatternSources.set(resolved, { + user: merged.localPatterns, + count: resolved.localPatterns.length, + }); + return resolved; } -function startsWithSinglePathSlash(path: string): boolean { - return path.startsWith('/') && !path.startsWith('//'); +/** `http(s)://host/...` or `//host/...`. */ +const ABSOLUTE_URL = /^(?:https?:)?\/\//i; + +/** + * The request path the app serves the optimizer at for `images.path`: the + * path itself, or the pathname of an absolute URL (an image CDN in front of + * the app, which forwards requests to that path). `undefined` for other + * values and for a bare origin, whose root path the app's pages own. + */ +function getImageOptimizerPath(path: string): string | undefined { + if (!ABSOLUTE_URL.test(path)) { + return path.startsWith('/') ? path : undefined; + } + let pathname: string; + try { + pathname = new URL(path, 'https://localhost').pathname; + } catch { + return undefined; + } + return pathname === '/' ? undefined : pathname; } /** - * Whether `/_void/image` should be served: the default loader, no - * `loaderFile`, not `unoptimized`, and a same-origin `path`. + * Whether the app should serve the optimizer endpoint: the default loader, + * no `loaderFile`, not `unoptimized`, an optimizer for this target, and a + * `path` with a mountable request path. */ export function isImageOptimizerActive(resolved: ResolvedImageConfig): boolean { return ( !resolved.unoptimized && + !resolved.noOptimizer && resolved.loader === 'default' && !resolved.loaderFile && - startsWithSinglePathSlash(resolved.path) + getImageOptimizerPath(resolved.path) !== undefined ); } +/** + * The config the endpoint and the generated routes use: `path` is the request + * path the endpoint is mounted at (the pathname of an absolute + * `images.path`). `Image` keeps the configured `path`. + */ +export function toMountedImageConfig(resolved: ResolvedImageConfig): ResolvedImageConfig { + const path = getImageOptimizerPath(resolved.path); + return path === undefined || path === resolved.path ? resolved : { ...resolved, path }; +} + /** * `isImageOptimizerActive` from the authored config alone (no file system), * for binding inference: `Image` usage needs the Images binding only when @@ -529,5 +625,5 @@ export function imageOptimizerConfigured( if ((images?.loader || 'default') !== 'default') { return false; } - return startsWithSinglePathSlash(images?.path ?? DEFAULT_IMAGE_PATH); + return getImageOptimizerPath(images?.path ?? DEFAULT_IMAGE_PATH) !== undefined; } diff --git a/packages/void/src/image/client-config.ts b/packages/void/src/image/client-config.ts index 5d52a404f..9312aa89a 100644 --- a/packages/void/src/image/client-config.ts +++ b/packages/void/src/image/client-config.ts @@ -21,6 +21,9 @@ export function toClientImageConfig( unoptimized: resolved.unoptimized, dev: resolved.dev, }; + if (resolved.noOptimizer) { + config.noOptimizer = true; + } if (consumer === 'server' || resolved.dev) { config.localPatterns = resolved.localPatterns.map((p) => ({ ...p })); config.remotePatterns = resolved.remotePatterns.map((p) => ({ ...p })); diff --git a/packages/void/src/image/config.ts b/packages/void/src/image/config.ts index b31c7e9db..c5550251a 100644 --- a/packages/void/src/image/config.ts +++ b/packages/void/src/image/config.ts @@ -193,8 +193,8 @@ export type CompiledLocalPattern = LocalPattern & { /** * The full configuration the server uses: defaults applied, sizes and * qualities sorted, patterns compiled, Next.js normalization applied, and - * `unoptimized` forced where no optimizer exists (non-Cloudflare targets, - * static output, Void Platform builds) unless a custom loader is set. + * `noOptimizer` set where no optimizer exists (non-Cloudflare targets, static + * output, Void Platform builds) unless a custom loader is set. * JSON-serializable, so it can be baked into generated code. */ export type ResolvedImageConfig = { @@ -220,7 +220,15 @@ export type ResolvedImageConfig = { /** Absolute, forward-slash path of `images.loaderFile`, or `''`. */ loaderFile: string; path: string; + /** `images.unoptimized`: every image renders with its original `src`. */ unoptimized: boolean; + /** + * Nothing serves the default loader's URLs in this build (`node`, `bun` + * and `deno` targets, static output, Void Platform builds) and no custom + * loader or `loaderFile` is configured. Images that use the default loader + * render with their original `src`; a `loader` prop still applies. + */ + noOptimizer: boolean; /** True for the Vite dev server (`vp dev`), false for builds and preview. */ dev: boolean; }; @@ -241,6 +249,8 @@ export type ClientImageConfig = { dangerouslyAllowSVG: boolean; unoptimized: boolean; dev: boolean; + /** `ResolvedImageConfig.noOptimizer`, present only when true. */ + noOptimizer?: boolean; localPatterns?: Array; remotePatterns?: Array; domains?: Array; diff --git a/packages/void/src/image/get-img-props.ts b/packages/void/src/image/get-img-props.ts index c9d96739b..3e6dce245 100644 --- a/packages/void/src/image/get-img-props.ts +++ b/packages/void/src/image/get-img-props.ts @@ -2,8 +2,9 @@ * `getImgProps`, ported from Next.js 16 `get-img-props.js`: the `` * attributes every adapter's `Image` renders. Development checks are gated by * `imgConf.dev` instead of `NODE_ENV`, and messages name `Image` and - * `void.config.ts`. Not ported: the `output: 'export'` error (Void resolves - * static output as `unoptimized`), the `dpl` deployment-id query, and the + * `void.config.ts`. Not ported: the `output: 'export'` error (where no + * optimizer exists, `imgConf.noOptimizer` renders default-loader images + * unoptimized instead), the `dpl` deployment-id query, and the * development blur URL (Void has no development blur endpoint). */ import { getImageBlurSvg } from './blur-svg.ts'; @@ -291,6 +292,10 @@ export function getImgProps( if (config.unoptimized) { unoptimized = true; } + if (config.noOptimizer && isDefaultLoader) { + // Nothing serves the default loader's URLs in this build; a `loader` prop still applies. + unoptimized = true; + } if ( isDefaultLoader && !config.dangerouslyAllowSVG && diff --git a/packages/void/src/image/html.ts b/packages/void/src/image/html.ts index 7774a4119..dabe5f7a9 100644 --- a/packages/void/src/image/html.ts +++ b/packages/void/src/image/html.ts @@ -106,6 +106,24 @@ function stringAttribute( return undefined; } +/** + * `crossorigin` as the `` renders it: `''` (anonymous) and a bare + * attribute (`true`) both become `''`, so the preload's request mode matches + * the image's and the browser reuses the preloaded response. + */ +function crossOriginAttribute(props: Record): string | undefined { + for (const name of ['crossOrigin', 'crossorigin']) { + const value = props[name]; + if (value === true) { + return ''; + } + if (typeof value === 'string') { + return value; + } + } + return undefined; +} + /** * The `` for a preloaded image (`preload` or * `priority`), or `undefined`. As in Next.js, `href` is omitted when the image @@ -137,8 +155,8 @@ export function getImagePreloadLink( if (imagesizes) { link.imagesizes = imagesizes; } - const crossorigin = stringAttribute(record, 'crossOrigin', 'crossorigin'); - if (crossorigin) { + const crossorigin = crossOriginAttribute(record); + if (crossorigin !== undefined) { link.crossorigin = crossorigin; } const referrerpolicy = stringAttribute(record, 'referrerPolicy', 'referrerpolicy'); diff --git a/packages/void/src/image/loader.ts b/packages/void/src/image/loader.ts index 83e46a352..a4f25b348 100644 --- a/packages/void/src/image/loader.ts +++ b/packages/void/src/image/loader.ts @@ -111,19 +111,29 @@ export const defaultLoader: BuiltinImageLoader = Object.assign(defaultLoaderImpl __void_img_default: true as const, }); +/** + * `src` with whitespace percent-encoded: a `srcset` candidate URL ends at the + * first whitespace. Existing escapes, `/`, `:`, `?` and `,` are kept. + */ +function encodeWhitespace(src: string): string { + return src.replace(/\s/g, encodeURIComponent); +} + function cloudflareLoaderImpl({ config, src, width, quality }: ImageLoaderPropsWithConfig): string { // Zone transformations do not exist on the dev server. if (config.dev) { - return src; + return encodeWhitespace(src); } const params = `width=${width},quality=${findClosestQuality(quality, config)},format=auto`; - return `${config.path.replace(/\/+$/, '')}/${params}/${src.startsWith('/') ? src.slice(1) : src}`; + const source = encodeWhitespace(src.startsWith('/') ? src.slice(1) : src); + return `${config.path.replace(/\/+$/, '')}/${params}/${source}`; } /** * `images.loader: 'cloudflare'`: Cloudflare zone transformation URLs, * `/width=,quality=,format=auto/` (`path` defaults to - * `/cdn-cgi/image`). Returns `src` unchanged in development. + * `/cdn-cgi/image`). Returns `src` itself in development. Whitespace in + * `src` is percent-encoded so `srcset` candidates stay valid. */ export const cloudflareLoader: BuiltinImageLoader = Object.assign(cloudflareLoaderImpl, { __void_img_default: true as const, diff --git a/packages/void/src/image/optimizer/handler.ts b/packages/void/src/image/optimizer/handler.ts index a7e022e9c..cc2174f9e 100644 --- a/packages/void/src/image/optimizer/handler.ts +++ b/packages/void/src/image/optimizer/handler.ts @@ -1,4 +1,5 @@ import type { ImageFormat, ResolvedImageConfig } from '../config.ts'; +import { hasRemoteMatch } from '../patterns.ts'; import { getSupportedImageFormat } from './accept.ts'; import { ANIMATABLE_TYPES, @@ -24,6 +25,7 @@ import { type AssetFetcher, type ImageFetch, type ImageUpstream, + type LocalImageDispatch, } from './upstream.ts'; import { validateImageParams, type ImageParams } from './validate.ts'; @@ -55,6 +57,12 @@ export type ImageRequestOptions = { deploymentId: string; /** Static assets binding local sources load from. */ assets?: AssetFetcher; + /** + * Runs a local source request through the application first, so middleware + * sees the source path; its `404` falls back to `assets`. Without it, + * local sources are read from `assets` directly. + */ + fetchLocal?: LocalImageDispatch; /** Images binding; without it, sources that need a transform are served unchanged. */ binding?: ImagesBindingLike; /** Binding name for the missing-binding warning. */ @@ -77,13 +85,20 @@ type ImageResult = { /** Opaque validator, sent quoted. */ etag: string; maxAge: number; + /** + * The original served because the transform failed or the binding is + * missing. It never gets the immutable policy, so the next request after + * `maxAge` (`minimumCacheTTL`) tries the transform again. + */ + fallback?: boolean; }; /** Next.js's `Cache-Control` for statically imported (fingerprinted) images. */ const IMMUTABLE_CACHE_CONTROL = 'public, max-age=315360000, immutable'; /** Bump when the stored entry format changes. */ -const CACHE_VERSION = '1'; +const CACHE_VERSION = '2'; const CACHE_MAX_AGE_HEADER = 'X-Void-Image-Max-Age'; +const CACHE_FALLBACK_HEADER = 'X-Void-Image-Fallback'; const IMMUTABLE_CACHE_TTL = 31536000; const warned = new Set(); @@ -156,7 +171,13 @@ async function readCache(cache: ImageCacheLike, key: Request): Promise = { + 'Content-Type': result.contentType, + 'Cache-Control': `public, max-age=${ttl}`, + ETag: `"${result.etag}"`, + [CACHE_MAX_AGE_HEADER]: String(result.maxAge), + }; + if (result.fallback) { + headers[CACHE_FALLBACK_HEADER] = '1'; + } + const entry = new Response(result.bytes, { headers }); let write: Promise; try { write = options.cache.put(key, entry).catch(() => {}); @@ -228,7 +251,7 @@ async function optimize( return original; } // Next.js serves the original with `minimumCacheTTL` when its encoder fails. - const fallback: ImageResult = { ...original, maxAge: config.minimumCacheTTL }; + const fallback: ImageResult = { ...original, maxAge: config.minimumCacheTTL, fallback: true }; if (!options.binding) { warnOnce( 'binding', @@ -267,9 +290,13 @@ function respond( ): Response { const headers = new Headers(); headers.set('Vary', 'Accept'); + // Next.js also marks an original served after a failed transform + // immutable. Void keeps `minimumCacheTTL` for it, so browsers pick up the + // optimized image once the transform works again (the Free allowance resets + // monthly, while a static image's URL survives deployments). headers.set( 'Cache-Control', - isStatic + isStatic && !result.fallback ? IMMUTABLE_CACHE_CONTROL : `public, max-age=${config.dev ? 0 : result.maxAge}, must-revalidate`, ); @@ -302,8 +329,9 @@ function respond( * * Void differences: `GET`/`HEAD` only (405 otherwise), a quoted `ETag`, * `X-Content-Type-Options: nosniff`, `X-Void-Image-Cache` instead of - * `X-Nextjs-Cache`, no stale-while-revalidate (the Cache API has none), and - * any binding failure serves the original image. + * `X-Nextjs-Cache`, no stale-while-revalidate (the Cache API has none), any + * binding failure serves the original image (never marked immutable), and + * remote redirect targets must match `images.remotePatterns`. */ export async function handleImageRequest( request: Request, @@ -346,10 +374,26 @@ export async function handleImageRequest( dangerouslyAllowLocalIP: config.dangerouslyAllowLocalIP, maximumResponseBody: config.maximumResponseBody, maximumRedirects: config.maximumRedirects, + isAllowedRedirect: (target) => { + if (hasRemoteMatch(config.domains, config.remotePatterns, target)) { + return true; + } + warnOnce( + `redirect:${target.origin}`, + `[void] An image source redirected to ${target.origin}, which images.remotePatterns doesn't allow, so ${config.path} refused it. Add the redirect target to images.remotePatterns to optimize it.`, + ); + return false; + }, fetch: options.fetch, timeoutMs: options.timeoutMs, }) - : await loadLocalImage(options.assets, params.href, request.url, config.maximumResponseBody); + : await loadLocalImage( + options.assets, + params.href, + request.url, + config.maximumResponseBody, + options.fetchLocal, + ); const result = await optimize(upstream, params, format, options); if (key) { writeCache(options, key, result, isStatic); diff --git a/packages/void/src/image/optimizer/index.ts b/packages/void/src/image/optimizer/index.ts index 65b495b68..5623fdbaf 100644 --- a/packages/void/src/image/optimizer/index.ts +++ b/packages/void/src/image/optimizer/index.ts @@ -36,6 +36,7 @@ export { type AssetFetcher, type ImageFetch, type ImageUpstream, + type LocalImageDispatch, type RemoteImageOptions, } from './upstream.ts'; export { validateImageParams, type ImageParams, type ImageParamsResult } from './validate.ts'; diff --git a/packages/void/src/image/optimizer/upstream.ts b/packages/void/src/image/optimizer/upstream.ts index d046d1b21..a0f28a26a 100644 --- a/packages/void/src/image/optimizer/upstream.ts +++ b/packages/void/src/image/optimizer/upstream.ts @@ -93,6 +93,11 @@ export type RemoteImageOptions = { maximumResponseBody: number; /** Redirects still allowed; each followed redirect lowers it by one. */ maximumRedirects: number; + /** + * Whether an `http`/`https` redirect target may be fetched (the endpoint + * checks `images.remotePatterns`); a refused target is `400`. + */ + isAllowedRedirect?: (url: URL) => boolean; fetch?: ImageFetch; timeoutMs?: number; }; @@ -104,6 +109,12 @@ export type RemoteImageOptions = { * streamed body limit (`413`), and the upstream status for any non-2xx answer. * Workers decode `Content-Encoding` themselves, so the limit applies to the * decoded bytes as in Next.js. + * + * Unlike Next.js, redirect targets must also pass `isAllowedRedirect`. + * Next.js resolves every hop's hostname and refuses private addresses; a + * Worker cannot resolve DNS, and the local runtime behind `vp dev` and + * preview may reach private networks, so a redirect from an allowed host + * to a name such as `127.0.0.1.nip.io` must not be followed. */ export async function fetchRemoteImage( href: string, @@ -139,7 +150,15 @@ export async function fetchRemoteImage( if (options.maximumRedirects <= 0) { throw new ImageError(508, UPSTREAM_INVALID); } - return fetchRemoteImage(new URL(location, url.href).href, { + const target = new URL(location, url.href); + if ( + options.isAllowedRedirect && + (target.protocol === 'http:' || target.protocol === 'https:') && + !options.isAllowedRedirect(target) + ) { + throw new ImageError(400, NOT_ALLOWED); + } + return fetchRemoteImage(target.href, { ...options, maximumRedirects: options.maximumRedirects - 1, }); @@ -174,23 +193,41 @@ export async function fetchRemoteImage( }; } +/** Runs a request through the application (middleware, routes and page fallbacks). */ +export type LocalImageDispatch = (request: Request) => Promise; + /** - * Load a local source from the static assets binding with an empty header - * set (`GET`, no cookies or conditional headers), like Next.js's internal - * image request. Application routes are not consulted. + * Load a local source with an empty header set (`GET`, no cookies or + * conditional headers), like Next.js's internal image request. + * + * With `dispatch`, the request takes the Worker's own path: the application + * answers first, so middleware that guards the source path applies, and a + * `404` falls back to the static assets. Any other non-2xx answer (a + * middleware `401`, a redirect) fails with its status. Without `dispatch`, + * the static assets binding is read directly. */ export async function loadLocalImage( assets: AssetFetcher | undefined, href: string, requestUrl: string, maximumResponseBody: number, + dispatch?: LocalImageDispatch, ): Promise { - if (!assets || typeof assets.fetch !== 'function') { + const hasAssets = !!assets && typeof assets.fetch === 'function'; + if (!hasAssets && !dispatch) { throw new ImageError(500, UPSTREAM_INVALID); } - let response: Response; + const url = new URL(href, requestUrl); + let response: Response | undefined; try { - response = await assets.fetch(new Request(new URL(href, requestUrl))); + if (dispatch) { + response = await dispatch(new Request(url)); + if (response.status === 404 && hasAssets) { + discard(response); + response = undefined; + } + } + response ??= await assets!.fetch(new Request(url)); } catch { throw new ImageError(500, UPSTREAM_INVALID); } diff --git a/packages/void/src/image/registry.ts b/packages/void/src/image/registry.ts index d763f5c70..bb7a455dd 100644 --- a/packages/void/src/image/registry.ts +++ b/packages/void/src/image/registry.ts @@ -19,11 +19,13 @@ import type { /** * What the plugin stores under `IMAGE_REGISTRY_KEY`. `loader` is the default - * export of `images.loaderFile`, or `undefined` without one. + * export of `images.loaderFile`, or `undefined` without one; + * `hasLoaderFile` is set whenever `images.loaderFile` is. */ export type ImageConfigRegistration = { config: ClientImageConfig; loader?: ImageLoader | undefined; + hasLoaderFile?: boolean; }; let fallbackConfig: ClientImageConfig | undefined; @@ -52,10 +54,18 @@ export function getImageConfig(): ClientImageConfig { /** * The loader an `Image` without a `loader` prop uses: the registered * `images.loaderFile` export, else the Cloudflare loader for - * `loader: 'cloudflare'`, else the default loader. + * `loader: 'cloudflare'`, else the default loader. With `images.loaderFile` + * it is that file's default export as is, so a missing export is `undefined` + * and `getImgProps` reports it as Next.js does. */ -export function getDefaultImageLoader(config?: ClientImageConfig): ImageLoaderWithConfig { - const loader = getRegistration()?.loader; +export function getDefaultImageLoader( + config?: ClientImageConfig, +): ImageLoaderWithConfig | undefined { + const registration = getRegistration(); + if (registration?.hasLoaderFile) { + return registration.loader; + } + const loader = registration?.loader; if (typeof loader === 'function') { return loader; } diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts index 9282e77ef..d21a41422 100644 --- a/packages/void/src/index.ts +++ b/packages/void/src/index.ts @@ -364,8 +364,8 @@ export function voidPlugin(options?: { let viteCommand: 'serve' | 'build' = 'build'; let versionMetadataBinding: string | undefined; // Registered for Void apps (Cloudflare and Node targets) only. `images` is - // resolved per command in its config hook; Node targets resolve it as - // unoptimized (unless a custom loader is set) and mount nothing. + // resolved per command in its config hook; Node targets resolve it with + // `noOptimizer` (unless a custom loader is set) and mount nothing. const images = imagePlugin(root, { images: config.images, target: config.target, diff --git a/packages/void/src/plugin-image.ts b/packages/void/src/plugin-image.ts index 269a0a270..acb91e6ec 100644 --- a/packages/void/src/plugin-image.ts +++ b/packages/void/src/plugin-image.ts @@ -2,13 +2,21 @@ import { readFileSync, realpathSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import MagicString from 'magic-string'; import { basename, relative } from 'pathslash'; -import { normalizePath, type ConfigEnv, type Plugin } from 'vite'; +import { + isFileLoadingAllowed, + normalizePath, + type ConfigEnv, + type Plugin, + type ResolvedConfig, +} from 'vite'; import { isNodeTarget, type VoidConfig } from './config.ts'; import { compileLocalPattern, isImageOptimizerActive, resolveImageConfig, + setImageAssetsDir, toClientImageConfig, + toMountedImageConfig, } from './image-config.ts'; import { readImageSize, type ImageSize } from './image-size.ts'; import { IMAGE_REGISTRY_KEY, type ImageConfig, type ResolvedImageConfig } from './image/config.ts'; @@ -65,7 +73,8 @@ export type ImagePluginApi = { getConfig(): ResolvedImageConfig | undefined; /** * The resolved config when the generated routes mount the optimizer - * endpoint (see `shouldMountImageOptimizer`), else `undefined`. + * endpoint (see `shouldMountImageOptimizer`), else `undefined`. Its `path` + * is the mount path (`toMountedImageConfig`). */ getMountedConfig(usesImageOptimizer: boolean | undefined): ResolvedImageConfig | undefined; /** Root-relative POSIX paths of the files imported with `?image` (client manifest keys). */ @@ -134,7 +143,9 @@ const REGISTRY_KEY_SOURCE = `Symbol.for(${JSON.stringify(Symbol.keyFor(IMAGE_REG /** * The `virtual:void/image-config` module for one environment: it stores the * client config subset and the `images.loaderFile` default export where - * `void/image` reads them. + * `void/image` reads them. The loader file is imported as a namespace, as + * Next.js does, so a file without a default export reaches `getImgProps`'s + * "missing default export" error instead of failing to link. */ export function generateImageConfigModule( resolved: ResolvedImageConfig, @@ -143,11 +154,13 @@ export function generateImageConfigModule( const config = JSON.stringify(toClientImageConfig(resolved, consumer)); const lines: Array = []; if (resolved.loaderFile) { - lines.push(`import __voidImageLoader from ${JSON.stringify(resolved.loaderFile)};`); + lines.push(`import * as __voidImageLoaderModule from ${JSON.stringify(resolved.loaderFile)};`); + lines.push( + `globalThis[${REGISTRY_KEY_SOURCE}] = { config: ${config}, loader: Reflect.get(__voidImageLoaderModule, "default"), hasLoaderFile: true };`, + ); + } else { + lines.push(`globalThis[${REGISTRY_KEY_SOURCE}] = { config: ${config}, loader: undefined };`); } - lines.push( - `globalThis[${REGISTRY_KEY_SOURCE}] = { config: ${config}, loader: ${resolved.loaderFile ? '__voidImageLoader' : 'undefined'} };`, - ); return lines.join('\n') + '\n'; } @@ -198,6 +211,7 @@ export function imagePlugin( let imageEntryRealPath: string | undefined; const realPaths = new Map(); let viteRoot = root; + let viteConfig: ResolvedConfig | undefined; const imageSources = new Set(); const resolvedByCommand = new Map(); let current: ResolvedImageConfig | undefined; @@ -215,6 +229,17 @@ export function imagePlugin( return real; } + /** + * Static `?image` imports are emitted under Vite's `build.assetsDir`, which + * is known only once Vite's config is resolved (after the `config` hook). + */ + function applyAssetsDir(resolved: ResolvedImageConfig): void { + const assetsDir = viteConfig?.build?.assetsDir; + if (assetsDir !== undefined) { + setImageAssetsDir(resolved, assetsDir); + } + } + function isImageEntry(id: string): boolean { const file = cleanId(id); if (file === imageEntry) { @@ -230,6 +255,7 @@ export function imagePlugin( let resolved = resolvedByCommand.get(key); if (!resolved) { resolved = resolveImageConfigForCommand(root, options, env); + applyAssetsDir(resolved); resolvedByCommand.set(key, resolved); } current = resolved; @@ -243,7 +269,7 @@ export function imagePlugin( usesImageOptimizer, inferenceBinding: options.inferenceBinding, }) - ? current + ? toMountedImageConfig(current) : undefined; }, imageSources, @@ -258,7 +284,12 @@ export function imagePlugin( api.resolve(env); }, configResolved(config) { + viteConfig = config; viteRoot = config.root; + // The config module and the routes read the resolved config after this. + for (const resolved of resolvedByCommand.values()) { + applyAssetsDir(resolved); + } }, configEnvironment(name, environment) { const consumer = environment.consumer ?? (name === 'client' ? 'client' : 'server'); @@ -288,6 +319,18 @@ export function imagePlugin( return; } const file = cleanId(id); + // Vite checks `server.fs` only for ids no plugin loads, and the module + // below makes the file servable from `/@fs/` (`safeModulePaths`). A + // request for `/@fs/?image` must not expose files outside + // `server.fs.allow`; real imports pass because the importer's import + // analysis already marked the file safe. Vite answers this error with 403. + if ( + this.environment?.mode === 'dev' && + viteConfig?.server?.fs && + !isFileLoadingAllowed(viteConfig, normalizePath(file)) + ) { + throw Object.assign(new Error(`Denied ID ${id}`), { code: 'ERR_DENIED_ID', id }); + } const source = normalizePath(relative(viteRoot, file)); let bytes: Uint8Array; try { diff --git a/packages/void/src/plugin-routing.ts b/packages/void/src/plugin-routing.ts index 0451eea74..81b7adffd 100644 --- a/packages/void/src/plugin-routing.ts +++ b/packages/void/src/plugin-routing.ts @@ -419,6 +419,9 @@ export function routingPlugin(root: string, options: RoutingPluginOptions): Plug dev: compileOptions.dev, isr, notFound: policy.assetConfig.not_found_handling ?? 'none', + assetFallbackSkipPaths: compileOptions.images + ? [compileOptions.images.config.path] + : undefined, durableObjects: options.durableObjects, }), ); diff --git a/packages/void/src/router/compile.ts b/packages/void/src/router/compile.ts index ced646033..aeab032ef 100644 --- a/packages/void/src/router/compile.ts +++ b/packages/void/src/router/compile.ts @@ -297,9 +297,10 @@ export function compileRouteTable( // The image optimizer runs after auth and user middleware (as `/_next/image` // does after Next.js middleware), and before every route and page so a // catch-all cannot shadow it. It answers 405 for methods other than GET/HEAD. + // It requests local sources through `app`, so middleware sees their paths. if (imageConfig) { lines.push( - `app.all(${JSON.stringify(imageConfig.path)}, __createImageHandler(${JSON.stringify({ config: imageConfig, deploymentId: getDeploymentId(), assetsDir: options.assetsDir })}));`, + `app.all(${JSON.stringify(imageConfig.path)}, __createImageHandler(${JSON.stringify({ config: imageConfig, deploymentId: getDeploymentId(), assetsDir: options.assetsDir })}, app));`, ); lines.push(''); } @@ -1032,9 +1033,16 @@ export function compileAssetFallbackHelper( assetsDir = 'assets', serveSpaFallback = false, notFound: NotFoundHandling = 'none', + skipPaths?: ReadonlyArray, ): string { + const options = { + assetsDir, + serveSpaFallback, + notFound, + ...(skipPaths?.length ? { skipPaths } : {}), + }; return `import { createAssetFallback as __createAssetFallback } from ${JSON.stringify(ROUTING_RUNTIME_PATH)}; -const serveWithAssets = __createAssetFallback(${JSON.stringify({ assetsDir, serveSpaFallback, notFound })}); +const serveWithAssets = __createAssetFallback(${JSON.stringify(options)}); `; } @@ -1089,6 +1097,11 @@ export interface WorkerEntryOptions { * on the worker-first path. Defaults to `'none'`. */ notFound?: NotFoundHandling; + /** + * Request paths whose application 404 never falls through to static assets + * (the mounted image optimizer endpoint, which passes source errors through). + */ + assetFallbackSkipPaths?: Array; /** * User-defined Durable Object classes (`durable/` directory). Each entry is * imported from `filePath`, bound to `bindingName`, and re-exported as @@ -1133,6 +1146,7 @@ export function compileWorkerEntry(options: WorkerEntryOptions = {}): string { dev = false, isr, notFound = 'none', + assetFallbackSkipPaths, durableObjects = [], } = options; const remotePath = remote?.remotePath; @@ -1204,7 +1218,12 @@ export function compileWorkerEntry(options: WorkerEntryOptions = {}): string { const __filterInternalBindings = (env) => __filterBindings(env, ${JSON.stringify(INTERNAL_BINDING_PREFIXES)}, ${JSON.stringify(exactNames)}); `; - const assetHelper = compileAssetFallbackHelper(assetsDir, serveSpaFallback, notFound); + const assetHelper = compileAssetFallbackHelper( + assetsDir, + serveSpaFallback, + notFound, + assetFallbackSkipPaths, + ); // ISR wiring (self-host only, non-managed builds). When `isr` is absent // every fragment below is an empty string. diff --git a/packages/void/src/runtime/better-auth-shared.ts b/packages/void/src/runtime/better-auth-shared.ts index 7c35d389b..1c6de714e 100644 --- a/packages/void/src/runtime/better-auth-shared.ts +++ b/packages/void/src/runtime/better-auth-shared.ts @@ -20,9 +20,10 @@ export type VoidAuthRuntimeOptions = { userConfig?: VoidAuthConfig; dev?: boolean; /** - * Exact request paths the session middleware leaves alone, like Void's - * internal `/__*` routes: the image optimizer endpoint, whose responses are - * public and cached. + * Exact request paths whose responses are public and cached (the image + * optimizer endpoint). Anonymous requests skip the session lookup; + * credentialed requests still resolve the user for guard middleware, but the + * response gets no auth headers (Set-Cookie, `private, no-store`). */ skipPaths?: ReadonlyArray; }; @@ -538,11 +539,17 @@ export function createVoidAuthRuntime( runtime: VoidAuthRuntimeOptions, database: AuthDatabase, ): Promise { + const credentialed = ['cookie', 'authorization', 'cf-access-jwt-assertion'].some((header) => + c.req.raw.headers.has(header), + ); + // Public cached responses (the image optimizer) keep the session for guard + // middleware but never carry auth headers. + const publicPath = runtime.skipPaths?.includes(c.req.path) === true; // Skip auth for internal routes (cron, queue, migration, binding proxy) and - // the image optimizer: no session lookup and no Set-Cookie on public images. - // Still set user/session to null so downstream middleware that checks - // these context variables gets the expected sentinel (not undefined). - if (c.req.path.startsWith('/__') || runtime.skipPaths?.includes(c.req.path)) { + // anonymous requests for public paths. Still set user/session to null so + // downstream middleware that checks these context variables gets the + // expected sentinel (not undefined). + if (c.req.path.startsWith('/__') || (publicPath && !credentialed)) { c.set('user', null); c.set('session', null); return next(); @@ -553,6 +560,8 @@ export function createVoidAuthRuntime( const result = await auth.api.getSession({ headers: await sessionHeaders(c.req.raw, auth), + // The refreshed cookie would be dropped below, so leave the refresh to pages. + ...(publicPath ? { query: { disableRefresh: true } } : {}), asResponse: false, returnHeaders: true, }); @@ -565,9 +574,9 @@ export function createVoidAuthRuntime( await next(); }); - const credentialed = ['cookie', 'authorization', 'cf-access-jwt-assertion'].some((header) => - c.req.raw.headers.has(header), - ); + if (publicPath) { + return; + } appendAuthHeaders( c, result.headers, diff --git a/packages/void/src/runtime/routing-image.ts b/packages/void/src/runtime/routing-image.ts index d49cf08fe..81000e4ee 100644 --- a/packages/void/src/runtime/routing-image.ts +++ b/packages/void/src/runtime/routing-image.ts @@ -1,4 +1,5 @@ -import type { Context } from 'hono'; +import { AsyncLocalStorage } from 'node:async_hooks'; +import type { Context, ExecutionContext } from 'hono'; import type { ResolvedImageConfig } from '../image/config.ts'; import { handleImageRequest, @@ -25,7 +26,23 @@ export type ImageHandlerOptions = { loadManifest?: ManifestLoader; }; +/** The generated Hono app, which local source requests run through. */ +export type ImageSourceApp = { + fetch: ( + request: Request, + env: RuntimeEnvironment, + context?: ExecutionContext, + ) => Response | Promise; +}; + const DEFAULT_IMAGES_BINDING = 'IMAGES'; +const RECURSIVE = '"url" parameter cannot be recursive'; + +/** + * Set while a local source request runs through the app. A rewrite of that + * request back to the endpoint answers `400` instead of recursing. + */ +const loadingSource = new AsyncLocalStorage(); function rawEnv(): RuntimeEnv | undefined { try { @@ -53,13 +70,11 @@ function defaultCache(): ImageCacheLike | undefined { } } -function executionContext(c: Context): { waitUntil(promise: Promise): void } | undefined { +function executionContext(c: Context): ExecutionContext | undefined { try { // Hono throws when the runtime passed no execution context (Node targets). const ctx = c.executionCtx as { waitUntil?: unknown } | undefined; - return ctx && typeof ctx.waitUntil === 'function' - ? (ctx as { waitUntil(promise: Promise): void }) - : undefined; + return ctx && typeof ctx.waitUntil === 'function' ? (ctx as ExecutionContext) : undefined; } catch { return undefined; } @@ -71,9 +86,16 @@ function executionContext(c: Context): { waitUntil(promise: Promise): v * `env.IMAGES`, or the custom name the build stamps into * `__VOID_IMAGES_BINDING`; without a binding, images are served unchanged. * Development skips the response cache and fingerprint lookups. + * + * With `app`, a local source is requested from the app with no headers, as + * the Worker would serve it to a client without cookies: path-scoped + * middleware applies to it (as Next.js runs `/_next/image` sources through + * its router), and an app `404` falls back to `env.ASSETS`. Without `app`, + * local sources are read from `env.ASSETS` directly. */ export function createImageHandler( options: ImageHandlerOptions, + app?: ImageSourceApp, ): (c: Context) => Promise { const { config } = options; const assetsPrefix = '/' + (options.assetsDir ?? 'assets').replace(/^\/+|\/+$/g, '') + '/'; @@ -82,6 +104,9 @@ export function createImageHandler( ? Promise.resolve(new Set(options.immutableFiles)) : undefined; return async (c) => { + if (loadingSource.getStore()) { + return new Response(RECURSIVE, { status: 400 }); + } const env = (c.env ?? {}) as RuntimeEnvironment; const raw = rawEnv(); const bindingName = @@ -100,6 +125,12 @@ export function createImageHandler( binding: binding && typeof binding.input === 'function' ? (binding as ImagesBindingLike) : undefined, bindingName, + fetchLocal: app + ? (source) => + loadingSource.run(true, async () => + ctx ? app.fetch(source, env, ctx) : app.fetch(source, env), + ) + : undefined, cache: config.dev ? undefined : defaultCache(), waitUntil: ctx ? (promise) => ctx.waitUntil(promise) : undefined, isImmutable: config.dev diff --git a/packages/void/src/runtime/routing-worker.ts b/packages/void/src/runtime/routing-worker.ts index ebe641a82..af904d067 100644 --- a/packages/void/src/runtime/routing-worker.ts +++ b/packages/void/src/runtime/routing-worker.ts @@ -7,6 +7,11 @@ export type AssetFallbackOptions = { assetsDir?: string; serveSpaFallback?: boolean; notFound?: 'none' | '404-page' | 'single-page-application'; + /** + * Request paths whose application 404 is final (the image optimizer + * endpoint): an SPA `not_found_handling` would answer them with the shell. + */ + skipPaths?: ReadonlyArray; }; const ASSET_CONTENT_TYPES: Readonly> = { '.avif': 'image/avif', @@ -106,7 +111,8 @@ export function createAssetFallback( if ( response.status !== 404 || !env.ASSETS || - (request.method !== 'GET' && request.method !== 'HEAD') + (request.method !== 'GET' && request.method !== 'HEAD') || + options.skipPaths?.includes(new URL(request.url).pathname) ) { return response; } diff --git a/packages/void/test/integration/image-endpoint.test.ts b/packages/void/test/integration/image-endpoint.test.ts index debdf5ca1..2e2d53a20 100644 --- a/packages/void/test/integration/image-endpoint.test.ts +++ b/packages/void/test/integration/image-endpoint.test.ts @@ -107,7 +107,8 @@ beforeAll(async () => { if (request.url === '/remote.png') { response.writeHead(200, { 'content-type': 'application/octet-stream', - 'cache-control': 'public, max-age=600', + // Above the default minimumCacheTTL (14400), so the response must use it. + 'cache-control': 'public, max-age=86400', }); response.end(readFileSync(join(fixtures, 'test.png'))); return; @@ -294,7 +295,7 @@ describe('/_void/image with a local Images binding', () => { ); expect(response.status, new TextDecoder().decode(bytes)).toBe(200); expect(response.headers.get('content-type')).toBe('image/png'); - expect(response.headers.get('cache-control')).toBe('public, max-age=14400, must-revalidate'); + expect(response.headers.get('cache-control')).toBe('public, max-age=86400, must-revalidate'); expect(response.headers.get('content-disposition')).toBe( `attachment; filename="${path.slice(1)}"`, ); diff --git a/packages/void/test/integration/image-route-generated.test.ts b/packages/void/test/integration/image-route-generated.test.ts index 5937564cf..235cf4bbb 100644 --- a/packages/void/test/integration/image-route-generated.test.ts +++ b/packages/void/test/integration/image-route-generated.test.ts @@ -23,18 +23,25 @@ beforeAll(async () => { const middleware = join(directory, 'middleware'); const assets = join(directory, 'client'); const output = join(directory, 'output'); - for (const path of [routes, middleware, assets]) { + for (const path of [routes, middleware, assets, join(assets, 'members')]) { mkdirSync(path); } copyFileSync(join(fixtures, 'test.png'), join(assets, 'hero.png')); + copyFileSync(join(fixtures, 'test.png'), join(assets, 'members', 'premium.png')); + // The catch-all leaves .png paths to the static assets: a source the app + // answers itself is what the endpoint optimizes. writeFileSync( join(routes, '[...path].ts'), - `export function GET(c) { return new Response('catch-all ' + c.req.path); }\nexport function POST() { return new Response('catch-all post'); }\n`, + `export function GET(c) { return c.req.path.endsWith('.png') ? c.notFound() : new Response('catch-all ' + c.req.path); }\nexport function POST() { return new Response('catch-all post'); }\n`, ); writeFileSync( join(middleware, '01.headers.ts'), `export default async (c, next) => { await next(); c.header('x-user-middleware', 'ran'); };\n`, ); + writeFileSync( + join(middleware, '02.members.ts'), + `export default async (c, next) => { if (c.req.path.startsWith('/members/') && c.req.header('cookie') !== 'session=ok') return c.text('Unauthorized', 401); await next(); };\n`, + ); const scan: ScanResult = { routes: [ { @@ -46,7 +53,10 @@ beforeAll(async () => { methods: ['GET', 'POST'], }, ], - middleware: [{ order: 1, filePath: '01.headers.ts' }], + middleware: [ + { order: 1, filePath: '01.headers.ts' }, + { order: 2, filePath: '02.members.ts' }, + ], jobs: [], queues: [], pages: { pages: [], layouts: [], namedLayouts: [] }, @@ -144,6 +154,29 @@ describe('image optimizer in a generated Worker', () => { expect(response.headers.get('x-user-middleware')).toBe('ran'); }); + it('applies middleware that guards the source path', async () => { + const direct = await runtime!.dispatchFetch('https://app.test/members/premium.png'); + expect(direct.status).toBe(401); + await direct.arrayBuffer(); + const member = await runtime!.dispatchFetch('https://app.test/members/premium.png', { + headers: { cookie: 'session=ok' }, + }); + expect(member.status).toBe(200); + await member.arrayBuffer(); + // The source request carries no cookies, as in Next.js, so even a member + // cannot optimize a guarded file. + for (const cookie of [undefined, 'session=ok']) { + const response = await runtime!.dispatchFetch( + 'https://app.test/_void/image?url=%2Fmembers%2Fpremium.png&w=3840&q=75', + { headers: { accept: CHROME, ...(cookie ? { cookie } : {}) } }, + ); + expect(response.status).toBe(401); + expect(await response.text()).toBe( + '"url" parameter is valid but internal response is invalid', + ); + } + }); + it('answers other methods itself instead of the catch-all route', async () => { const response = await runtime!.dispatchFetch( 'https://app.test/_void/image?url=%2Fhero.png&w=64&q=75', diff --git a/packages/void/test/unit/application-analysis.test.ts b/packages/void/test/unit/application-analysis.test.ts index eb0e12b2a..7c9cf81dc 100644 --- a/packages/void/test/unit/application-analysis.test.ts +++ b/packages/void/test/unit/application-analysis.test.ts @@ -261,7 +261,7 @@ describe('image optimizer analysis', () => { ['a loaderFile', { images: { loaderFile: './image-loader.ts' } }], ['the cloudflare loader', { images: { loader: 'cloudflare' } }], ['an image CDN loader', { images: { loader: 'imgix', path: 'https://example.imgix.net/' } }], - ['a cross-origin path', { images: { path: 'https://images.example.com/_void/image' } }], + ['a bare-origin path', { images: { path: 'https://images.example.com' } }], ['a Node target', { target: 'node' }], ['static output', { output: 'static' }], ])('does not require the Images binding with %s', (_name, config) => { @@ -269,6 +269,14 @@ describe('image optimizer analysis', () => { expect(analysis.bindings).toMatchObject({ needsImages: false, usesImageOptimizer: true }); }); + it('requires the Images binding behind a CDN path, which the app serves at its pathname', () => { + const analysis = analyzeApplicationSync(imageApp(), { + config: { images: { path: 'https://images.example.com/_void/image' } }, + environment: 'prod', + }); + expect(analysis.bindings).toMatchObject({ needsImages: true, usesImageOptimizer: true }); + }); + it('keeps an env.IMAGES read when the optimizer is off', () => { const applicationRoot = imageApp(); source( diff --git a/packages/void/test/unit/auth-middleware.test.ts b/packages/void/test/unit/auth-middleware.test.ts index 998a3eadf..41efd58ed 100644 --- a/packages/void/test/unit/auth-middleware.test.ts +++ b/packages/void/test/unit/auth-middleware.test.ts @@ -10,7 +10,7 @@ vi.mock('better-auth', () => ({ betterAuth: mocks.betterAuth, })); -import { requireAuth, type AuthState, type AuthUser } from '../../src/runtime/auth.ts'; +import { getUser, requireAuth, type AuthState, type AuthUser } from '../../src/runtime/auth.ts'; import { createVoidAuthRuntime, type VoidAuthRuntimeOptions, @@ -418,12 +418,17 @@ describe('cross-origin cookie sessions', () => { }); describe('runVoidAuthMiddleware skipped paths', () => { - it('skips the session lookup and auth headers for the image optimizer path only', async () => { + const user = { id: 'user-1', email: 'ada@example.com' } as AuthUser; + const env = { BETTER_AUTH_SECRET: 'test-secret' }; + const imageUrl = 'http://localhost/_void/image?url=%2Fa.png&w=64&q=75'; + + function createSkipApp(response: AuthState | null) { const authHeaders = new Headers(); authHeaders.append('set-cookie', 'session=refresh; Path=/'); - mocks.getSession.mockResolvedValue({ response: null, headers: authHeaders }); + authHeaders.set('cache-control', 'no-store'); + mocks.getSession.mockResolvedValue({ response, headers: authHeaders }); mocks.betterAuth.mockReturnValue({ api: { getSession: mocks.getSession } }); - const app = new Hono<{ Variables: { user: unknown; session: unknown } }>(); + const app = new Hono<{ Variables: { user: AuthUser | null; session: unknown } }>(); app.use(async (c, next) => { await runtime.runVoidAuthMiddleware( c, @@ -432,22 +437,49 @@ describe('runVoidAuthMiddleware skipped paths', () => { {} as never, ); }); - app.get('/_void/image', (c) => c.json({ user: c.get('user'), session: c.get('session') })); + // A site-wide login wall, as in `middleware/auth.ts`. + app.use(async (c, next) => { + if (c.req.path !== '/login') { + requireAuth(c); + } + await next(); + }); + app.get('/_void/image', (c) => + c.json( + { user: c.get('user'), current: getUser() }, + { headers: { 'cache-control': 'public, max-age=60, must-revalidate' } }, + ), + ); app.get('/page', (c) => c.text('page')); + return app; + } + + it('keeps the signed-in user for guards on the image optimizer path without auth headers', async () => { + const app = createSkipApp({ user, session: { id: 'session-1' } } as AuthState); const init = { headers: { cookie: 'session=abc' } }; - const env = { BETTER_AUTH_SECRET: 'test-secret' }; - const image = await app.request( - 'http://localhost/_void/image?url=%2Fa.png&w=64&q=75', - init, - env, - ); - expect(await image.json()).toEqual({ user: null, session: null }); + const image = await app.request(imageUrl, init, env); + expect(image.status).toBe(200); + expect(await image.json()).toEqual({ user, current: user }); expect(image.headers.getSetCookie()).toEqual([]); - expect(mocks.betterAuth).not.toHaveBeenCalled(); + expect(image.headers.get('cache-control')).toBe('public, max-age=60, must-revalidate'); + expect(mocks.getSession).toHaveBeenLastCalledWith( + expect.objectContaining({ query: { disableRefresh: true } }), + ); const page = await app.request('http://localhost/page', init, env); expect(page.headers.getSetCookie()).toEqual(['session=refresh; Path=/']); - expect(mocks.getSession).toHaveBeenCalledTimes(1); + expect(mocks.getSession).toHaveBeenLastCalledWith( + expect.not.objectContaining({ query: expect.anything() }), + ); + }); + + it('skips the session lookup for anonymous image requests', async () => { + const app = createSkipApp(null); + + const image = await app.request(imageUrl, undefined, env); + expect(image.status).toBe(401); + expect(image.headers.getSetCookie()).toEqual([]); + expect(mocks.betterAuth).not.toHaveBeenCalled(); }); }); diff --git a/packages/void/test/unit/cloudflare-deploy.test.ts b/packages/void/test/unit/cloudflare-deploy.test.ts index 9a6c347fd..11db71353 100644 --- a/packages/void/test/unit/cloudflare-deploy.test.ts +++ b/packages/void/test/unit/cloudflare-deploy.test.ts @@ -742,6 +742,42 @@ describe('existing Cloudflare site handoff', () => { expect(activateVersion).not.toHaveBeenCalled(); }, ); + it('links a site whose env.IMAGES read is its own R2 binding', async () => { + const root = site(); + const configPath = join(root, 'wrangler.jsonc'); + writeFileSync( + configPath, + JSON.stringify({ + ...parseRootWranglerConfig(configPath), + r2_buckets: [{ binding: 'IMAGES', bucket_name: 'photos' }], + }), + ); + mkdirSync(join(root, 'routes/api'), { recursive: true }); + writeFileSync( + join(root, 'routes/api/photo.ts'), + 'export const GET = (c) => c.env.IMAGES.get(c.req.query("key"));', + ); + const activateVersion = vi + .fn>() + .mockResolvedValue({ + ok: true, + versionId: 'new', + previousVersionId: 'original', + previewUrl: 'https://site.example.com', + }); + const { deps } = harness({ + readExistingWorker: async () => ({ + ...existing, + bindings: [{ type: 'r2_bucket', name: 'IMAGES', bucket_name: 'photos' }], + }), + runBuild: () => build(root), + activateVersion, + }); + + await runCloudflareDeploy(root, { reuseExistingWorker: true }, silentCliLog, deps); + expect(activateVersion).toHaveBeenCalled(); + expect(parseRootWranglerConfig(join(root, 'dist/ssr/wrangler.json')).images).toBeUndefined(); + }); it('reuses the target and completes the same invocation without provisioning or secret writes', async () => { const root = site(); const activateVersion = vi @@ -6640,6 +6676,63 @@ describe('runCloudflareDeploy — framework artifact guard', () => { expect(readFileSync(join(root, 'wrangler.jsonc'), 'utf-8')).toContain('// keep this comment'); }); + it('adds the Images binding for framework env.IMAGES reads the root config does not own', async () => { + const prepare = async (rootConfig: Record) => { + const root = makeProject({ + 'package.json': JSON.stringify({ + name: 'skapp', + dependencies: { '@sveltejs/kit': '2.0.0' }, + }), + 'void.json': JSON.stringify({}), + 'wrangler.jsonc': JSON.stringify({ name: 'skapp', account_id: 'acc123', ...rootConfig }), + '.svelte-kit/cloudflare/_worker.js': + 'export default { fetch() { return new Response("ok") } };\n', + }); + await prepareCloudflareFrameworkArtifact({ + root, + configPath: join(root, 'wrangler.jsonc'), + accountId: 'acc123', + workerName: 'skapp', + voidConfig: readConfig(root), + framework: { name: 'sveltekit', class: 'b' }, + preset: FRAMEWORK_PRESETS.sveltekit, + images: { binding: 'IMAGES' }, + }); + return parseRootWranglerConfig(join(root, 'wrangler.jsonc')); + }; + + expect((await prepare({})).images).toEqual({ binding: 'IMAGES' }); + expect((await prepare({ images: { binding: 'MEDIA' } })).images).toEqual({ binding: 'MEDIA' }); + const r2 = await prepare({ r2_buckets: [{ binding: 'IMAGES', bucket_name: 'photos' }] }); + expect(r2.images).toBeUndefined(); + }); + + it('passes framework env.IMAGES reads to the deploy config and adds nothing without them', async () => { + const images: Array = []; + const deploy = async (route: string) => { + const root = makeProject({ + 'package.json': JSON.stringify({ + name: 'skapp', + dependencies: { '@sveltejs/kit': '2.0.0' }, + }), + 'void.json': JSON.stringify({}), + 'src/routes/+page.server.ts': route, + 'wrangler.jsonc': JSON.stringify({ account_id: 'acc123', name: 'skapp' }, null, 2), + }); + const { deps, exitCalls } = harness({ + prepareFrameworkArtifact: (async (options) => { + images.push(options.images); + }) as CloudflareDeployDeps['prepareFrameworkArtifact'], + }); + await runCloudflareDeploy(root, { platform: 'cloudflare' }, silentCliLog, deps); + expect(exitCalls).toEqual([]); + }; + + await deploy('export const load = ({ platform }) => platform.env.IMAGES.info(body);'); + await deploy('export const load = () => ({});'); + expect(images).toEqual([{ binding: 'IMAGES' }, undefined]); + }); + it('prepares Class A wrapper handlers without replacing its generated config pointer', async () => { const root = makeProject({ 'package.json': JSON.stringify({ diff --git a/packages/void/test/unit/compile.test.ts b/packages/void/test/unit/compile.test.ts index 71f5298b5..edc6daf54 100644 --- a/packages/void/test/unit/compile.test.ts +++ b/packages/void/test/unit/compile.test.ts @@ -1519,10 +1519,11 @@ describe('compileRouteTable — image optimizer mount', () => { } }); - it('passes the resolved config, assets dir and the route table deployment id', () => { + it('passes the resolved config, assets dir, the route table deployment id and the app', () => { const code = compileWithImages(); + // The app is passed so local sources run through middleware. const options = JSON.parse( - /app\.all\("\/_void\/image", __createImageHandler\((.*)\)\);/.exec(code)![1]!, + /app\.all\("\/_void\/image", __createImageHandler\((.*), app\)\);/.exec(code)![1]!, ) as { config: unknown; deploymentId: string; assetsDir: string }; const deploymentId = /export const __VOID_ISR_DEPLOYMENT_ID = "([^"]+)";/.exec(code)![1]; diff --git a/packages/void/test/unit/deploy-asset-config.test.ts b/packages/void/test/unit/deploy-asset-config.test.ts index 8609657cb..9bc91612b 100644 --- a/packages/void/test/unit/deploy-asset-config.test.ts +++ b/packages/void/test/unit/deploy-asset-config.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vite-plus/test'; +import { describe, expect, it, vi } from 'vite-plus/test'; import { hasApiRoutes, hasDocumentRoutes, @@ -428,6 +428,45 @@ describe('generated serveWithAssets — routing.notFound end to end', () => { hasMiddleware: true, } as const; + it('keeps 404s from skipped paths such as the image optimizer endpoint', async () => { + const policy = resolveAssetRoutingPolicy({ + isSsr: false, + hasPages: false, + hasApiRoutes: true, + hasDocumentRoutes: false, + hasMiddleware: false, + imagePath: '/_void/image', + }); + expect(policy.assetConfig.run_worker_first).toContain('/_void/image'); + const handling = policy.assetConfig.not_found_handling; + expect(handling).toBe('single-page-application'); + const assets = makeAssets(handling); + const fetchAsset = vi.spyOn(assets, 'fetch'); + const serveWithAssets = createAssetFallback({ + assetsDir: 'assets', + serveSpaFallback: policy.workerSpaFallback, + notFound: handling, + skipPaths: ['/_void/image'], + }); + const serve = (path: string, accept: string) => + serveWithAssets( + new Request(`https://app.test${path}`, { headers: { accept } }), + { ASSETS: assets }, + new Response('upstream 404', { status: 404 }), + ); + + const image = await serve('/_void/image?url=%2Fgone.png&w=64&q=75', 'image/avif,*/*'); + expect(image.status).toBe(404); + expect(await image.text()).toBe('upstream 404'); + const navigation = await serve('/_void/image?url=%2Fgone.png&w=64&q=75', 'text/html'); + expect(navigation.status).toBe(404); + expect(fetchAsset).not.toHaveBeenCalled(); + + const other = await serve('/deep/link', 'text/html'); + expect(other.status).toBe(200); + expect(await other.text()).toBe('SPA SHELL'); + }); + it('serves the SPA shell at 200 when notFound is unset (unchanged default)', async () => { expect(await serveUnmatched(ssgWithMiddleware)).toEqual({ status: 200, diff --git a/packages/void/test/unit/deploy-managed-images.test.ts b/packages/void/test/unit/deploy-managed-images.test.ts index eb33b32b2..14afe5763 100644 --- a/packages/void/test/unit/deploy-managed-images.test.ts +++ b/packages/void/test/unit/deploy-managed-images.test.ts @@ -4,6 +4,8 @@ import type { VoidConfig } from '../../src/config.ts'; const WARNING = "Image optimization isn't available on Void Platform yet, so Image serves original files."; +const BINDING_WARNING = + "The Cloudflare Images binding (env.IMAGES) isn't available on Void Platform yet, so reading it returns undefined. Use `void deploy --platform cloudflare` to deploy with it."; describe('managedImageOptimizerWarning', () => { it('warns when a Void app renders Image with the default loader', () => { @@ -35,4 +37,36 @@ describe('managedImageOptimizerWarning', () => { managedImageOptimizerWarning({ usesImageOptimizer: true }, {}, { name: 'astro', class: 'c' }), ).toBe(undefined); }); + + it('warns once with the Image message when the optimizer requires the binding', () => { + expect( + managedImageOptimizerWarning({ usesImageOptimizer: true, needsImages: true }, {}, undefined), + ).toBe(WARNING); + }); + + it.each<[string, { usesImageOptimizer?: boolean; needsImages?: boolean }, VoidConfig]>([ + ['an env.IMAGES read', { needsImages: true, usesImageOptimizer: false }, {}], + [ + 'an env.IMAGES read with Image unoptimized', + { needsImages: true, usesImageOptimizer: true }, + { images: { unoptimized: true } }, + ], + [ + 'inference.bindings.images', + { needsImages: true }, + { inference: { bindings: { images: true } } }, + ], + ])('warns that env.IMAGES is undefined for %s', (_name, bindings, config) => { + expect(managedImageOptimizerWarning(bindings, config, undefined)).toBe(BINDING_WARNING); + }); + + it('warns that env.IMAGES is undefined for framework apps that need the binding', () => { + expect( + managedImageOptimizerWarning( + { needsImages: true, usesImageOptimizer: true }, + {}, + { name: 'vinext-app', class: 'a' }, + ), + ).toBe(BINDING_WARNING); + }); }); diff --git a/packages/void/test/unit/image-config.test.ts b/packages/void/test/unit/image-config.test.ts index 0f4c5ec84..2203be3fc 100644 --- a/packages/void/test/unit/image-config.test.ts +++ b/packages/void/test/unit/image-config.test.ts @@ -2,14 +2,23 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; import { + compileLocalPattern, getImageConfigWarnings, imageOptimizerConfigured, isImageOptimizerActive, resolveImageConfig, + setImageAssetsDir, toClientImageConfig, + toMountedImageConfig, validateImagesConfig, } from '../../src/image-config.ts'; -import { IMAGE_DOCS_URL, imageConfigDefault, type ImageConfig } from '../../src/image/index.ts'; +import { + IMAGE_DOCS_URL, + hasLocalMatch, + imageConfigDefault, + type ImageConfig, + type ResolvedImageConfig, +} from '../../src/image/index.ts'; const root = join(import.meta.dirname, '.tmp-image-config-test'); @@ -258,6 +267,7 @@ describe('resolveImageConfig', () => { loaderFile: '', path: '/_void/image', unoptimized: false, + noOptimizer: false, dev: false, }); expect(isImageOptimizerActive(resolved)).toBe(true); @@ -297,6 +307,47 @@ describe('resolveImageConfig', () => { ).toHaveLength(1); }); + it('allows static imports under a custom build.assetsDir', () => { + const allows = (resolved: ResolvedImageConfig, path: string) => + hasLocalMatch(resolved.localPatterns, path); + const images: ImageConfig = { localPatterns: [{ pathname: '/photos/**', search: '' }] }; + const resolved = resolve(images, { assetsDir: '/static/' }); + expect(resolved.localPatterns.map((p) => p.pathname)).toEqual(['/photos/**', '/static/**']); + expect(allows(resolved, '/static/hero-abc.png')).toBe(true); + expect(allows(resolved, '/assets/hero-abc.png')).toBe(false); + + // The plugin learns `build.assetsDir` after resolving; later entries are kept. + const late = resolve(images); + late.localPatterns.push(compileLocalPattern({ pathname: '/@fs/**', search: '' })); + setImageAssetsDir(late, 'static'); + expect(late.localPatterns.map((p) => p.pathname)).toEqual([ + '/photos/**', + '/static/**', + '/@fs/**', + ]); + expect(allows(late, '/static/hero-abc.png')).toBe(true); + expect(allows(late, '/assets/hero-abc.png')).toBe(false); + setImageAssetsDir(late, 'assets'); + expect(late.localPatterns.map((p) => p.pathname)).toEqual([ + '/photos/**', + '/assets/**', + '/@fs/**', + ]); + + // A pattern the user lists is not added twice; an empty assetsDir is the root. + const listed = resolve({ localPatterns: [{ pathname: '/assets/**', search: '' }] }); + setImageAssetsDir(listed, 'static'); + expect(listed.localPatterns.map((p) => p.pathname)).toEqual(['/assets/**', '/static/**']); + setImageAssetsDir(listed, ''); + expect(listed.localPatterns.map((p) => p.pathname)).toEqual(['/assets/**', '/*']); + expect(allows(listed, '/hero-abc.png')).toBe(true); + + // Without user localPatterns every path is already allowed. + const open = resolve(undefined); + setImageAssetsDir(open, 'static'); + expect(open.localPatterns.map((p) => p.pathname)).toEqual(['**']); + }); + it('normalizes remote patterns', () => { const [fromUrl, plain] = resolve({ remotePatterns: [new URL('https://example.com/a/**'), { hostname: '*.example.com' }], @@ -340,6 +391,16 @@ describe('resolveImageConfig', () => { expect(resolve({ loader: 'custom', loaderFile: 'lib/loader.ts' }).path).toBe('/_void/image/'); }); + it('resolves a loaderFile with a leading slash from the project root, as Next.js joins it', () => { + const inRoot = join(root, 'lib/loader.ts').replaceAll('\\', '/'); + expect(resolve({ loader: 'custom', loaderFile: '/lib/loader.ts' }).loaderFile).toBe(inRoot); + // An absolute path to an existing file still works. + expect(resolve({ loader: 'custom', loaderFile: inRoot }).loaderFile).toBe(inRoot); + expect(() => resolve({ loaderFile: '/missing.ts' })).toThrow( + `Specified images.loaderFile does not exist at "${join(root, 'missing.ts').replaceAll('\\', '/')}".`, + ); + }); + it('rejects a missing loaderFile and loaderFile with a built-in CDN loader', () => { expect(() => resolve({ loaderFile: './missing.ts' })).toThrow( `Specified images.loaderFile does not exist at "${join(root, 'missing.ts')}".`, @@ -355,17 +416,24 @@ describe('resolveImageConfig', () => { ['deno target', { target: 'deno' }], ['static output', { output: 'static' as const }], ['Void Platform build', { managed: true }], - ])('forces unoptimized for a %s unless a custom loader is set', (_label, options) => { - expect(resolve(undefined, options).unoptimized).toBe(true); - expect(resolve({ loader: 'cloudflare' }, options).unoptimized).toBe(false); - expect(resolve({ loader: 'custom' }, options).unoptimized).toBe(false); - expect(resolve({ loaderFile: './lib/loader.ts' }, options).unoptimized).toBe(false); + ])('has no optimizer for a %s unless a custom loader is set', (_label, options) => { + const resolved = resolve(undefined, options); + // `unoptimized` stays the user's switch, so `loader` props still apply. + expect(resolved.unoptimized).toBe(false); + expect(resolved.noOptimizer).toBe(true); + expect(toClientImageConfig(resolved, 'client').noOptimizer).toBe(true); + expect(isImageOptimizerActive(resolved)).toBe(false); + expect(resolve({ unoptimized: true }, options).unoptimized).toBe(true); + expect(resolve({ loader: 'cloudflare' }, options).noOptimizer).toBe(false); + expect(resolve({ loader: 'custom' }, options).noOptimizer).toBe(false); + expect(resolve({ loaderFile: './lib/loader.ts' }, options).noOptimizer).toBe(false); expect(imageOptimizerConfigured(undefined, options)).toBe(false); }); it('keeps the optimizer for Cloudflare server builds', () => { const resolved = resolve(undefined, { target: 'cloudflare', output: 'server', dev: true }); expect(resolved.unoptimized).toBe(false); + expect(resolved.noOptimizer).toBe(false); expect(resolved.dev).toBe(true); }); }); @@ -379,8 +447,12 @@ describe('isImageOptimizerActive and imageOptimizerConfigured', () => { [{ loader: 'custom' as const }, false], [{ loader: 'cloudflare' as const }, false], [{ loaderFile: './lib/loader.ts' }, false], - [{ path: 'https://images.example.com/_next/image' }, false], - [{ path: '//images.example.com/_next/image' }, false], + // A CDN in front of the app: the endpoint is served at the URL's path. + [{ path: 'https://images.example.com/_next/image' }, true], + [{ path: '//images.example.com/_void/image' }, true], + [{ path: 'https://images.example.com' }, false], + [{ path: 'https://images.example.com/' }, false], + [{ path: 'images/optimize' }, false], ])('%j is %s', (images, expected) => { expect(imageOptimizerConfigured(images, {})).toBe(expected); expect(imageOptimizerConfigured(images, { target: 'cloudflare', output: 'server' })).toBe( @@ -388,6 +460,21 @@ describe('isImageOptimizerActive and imageOptimizerConfigured', () => { ); expect(isImageOptimizerActive(resolve(images))).toBe(expected); }); + + it('mounts an absolute images.path at its pathname and keeps the URL for Image', () => { + const resolved = resolve({ path: 'https://images.example.com/_next/image' }); + const mounted = toMountedImageConfig(resolved); + expect(mounted.path).toBe('/_next/image'); + expect(isImageOptimizerActive(mounted)).toBe(true); + expect(resolved.path).toBe('https://images.example.com/_next/image'); + expect(toClientImageConfig(resolved, 'client').path).toBe( + 'https://images.example.com/_next/image', + ); + expect(toMountedImageConfig(resolve({ path: '//cdn.example.com/img' })).path).toBe('/img'); + + const relative = resolve({ path: '/img' }); + expect(toMountedImageConfig(relative)).toBe(relative); + }); }); describe('toClientImageConfig', () => { diff --git a/packages/void/test/unit/image-html.test.ts b/packages/void/test/unit/image-html.test.ts index 1875a8593..2a36a80b6 100644 --- a/packages/void/test/unit/image-html.test.ts +++ b/packages/void/test/unit/image-html.test.ts @@ -156,6 +156,37 @@ describe('image preload links', () => { ); }); + it.each([ + ['crossOrigin: ""', { crossOrigin: '' }], + ['crossorigin: ""', { crossorigin: '' }], + ['a bare crossorigin attribute', { crossorigin: true }], + ])('keeps the anonymous CORS mode of %s', (_name, cors) => { + const { props: imgProps, meta } = props({ + src: '/a.png', + alt: '', + width: 10, + height: 10, + preload: true, + ...cors, + }); + const link = getImagePreloadLink(imgProps, meta)!; + expect(link.crossorigin).toBe(''); + expect(renderImagePreloadLinks([link])).toContain(' crossorigin=""'); + expect(toHtmlImgAttributes(imgProps)).toHaveProperty('crossorigin'); + }); + + it('leaves out crossorigin: false', () => { + const { props: imgProps, meta } = props({ + src: '/a.png', + alt: '', + width: 10, + height: 10, + preload: true, + crossorigin: false, + }); + expect(getImagePreloadLink(imgProps, meta)).not.toHaveProperty('crossorigin'); + }); + it('renders every attribute', () => { expect( renderImagePreloadLinks([ diff --git a/packages/void/test/unit/image-loader.test.ts b/packages/void/test/unit/image-loader.test.ts index 0992d0fa9..a2f4775bc 100644 --- a/packages/void/test/unit/image-loader.test.ts +++ b/packages/void/test/unit/image-loader.test.ts @@ -6,6 +6,7 @@ import { defaultLoader, findClosestQuality, getImageBlurSvg, + getImgProps, type ClientImageConfig, type ImageConfig, } from '../../src/image/index.ts'; @@ -123,6 +124,32 @@ describe('cloudflareLoader', () => { const dev = clientConfig({ loader: 'cloudflare' }, { dev: true }); expect(cloudflareLoader({ config: dev, src: '/a.png', width: 128 })).toBe('/a.png'); }); + + it('percent-encodes whitespace in src so srcset candidates stay valid', () => { + expect(cloudflareLoader({ config, src: '/images/a b.png', width: 128 })).toBe( + '/cdn-cgi/image/width=128,quality=85,format=auto/images/a%20b.png', + ); + expect(cloudflareLoader({ config, src: '/images/a%20b.png', width: 128 })).toBe( + '/cdn-cgi/image/width=128,quality=85,format=auto/images/a%20b.png', + ); + expect( + cloudflareLoader({ config, src: 'https://example.com/a b\tc.jpg?x=1,2', width: 32 }), + ).toBe( + '/cdn-cgi/image/width=32,quality=85,format=auto/https://example.com/a%20b%09c.jpg?x=1,2', + ); + const dev = clientConfig({ loader: 'cloudflare' }, { dev: true }); + expect(cloudflareLoader({ config: dev, src: '/images/a b.png', width: 128 })).toBe( + '/images/a%20b.png', + ); + + const { srcSet } = getImgProps( + { src: '/images/a b.png', alt: '', width: 100, height: 100 }, + { defaultLoader: cloudflareLoader, imgConf: config }, + ).props; + expect(srcSet).toBe( + '/cdn-cgi/image/width=128,quality=85,format=auto/images/a%20b.png 1x, /cdn-cgi/image/width=256,quality=85,format=auto/images/a%20b.png 2x', + ); + }); }); describe('getImageBlurSvg', () => { diff --git a/packages/void/test/unit/image-optimizer-handler.test.ts b/packages/void/test/unit/image-optimizer-handler.test.ts index 2a28eb85d..bdc61dbb2 100644 --- a/packages/void/test/unit/image-optimizer-handler.test.ts +++ b/packages/void/test/unit/image-optimizer-handler.test.ts @@ -427,7 +427,7 @@ describe('handleImageRequest', () => { await tasks.settled(); expect(first.headers.get('x-void-image-cache')).toBe('MISS'); expect([...entries.keys()]).toEqual([ - 'https://app.test/_void/image?url=%2Ftest.png&w=64&q=75&__void_img_fmt=image%2Fwebp&__void_img_v=1&__void_img_dpl=deployment-1', + 'https://app.test/_void/image?url=%2Ftest.png&w=64&q=75&__void_img_fmt=image%2Fwebp&__void_img_v=2&__void_img_dpl=deployment-1', ]); const second = await run(request('url=/test.png&w=64&q=75', { accept: 'image/webp' }), options); expect(second.headers.get('x-void-image-cache')).toBe('HIT'); @@ -472,7 +472,7 @@ describe('handleImageRequest', () => { expect( [...entries.keys()].filter((key) => !new URL(key).searchParams.has('__void_img_dpl')), ).toEqual([ - 'https://app.test/_void/image?url=https%3A%2F%2Fimages.test%2Fa.png&w=64&q=75&__void_img_fmt=auto&__void_img_v=1', + 'https://app.test/_void/image?url=https%3A%2F%2Fimages.test%2Fa.png&w=64&q=75&__void_img_fmt=auto&__void_img_v=2', ]); }); @@ -569,6 +569,71 @@ describe('handleImageRequest', () => { expect(isImmutable).toHaveBeenCalledWith('/assets/hero-abc123.png'); }); + it('keeps minimumCacheTTL for a fingerprinted original served after a failed transform', async () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const { cache, entries } = fakeCache(); + const tasks = background(); + const options = { + cache, + waitUntil: tasks.waitUntil, + isImmutable: () => true, + binding: fakeBinding({ code: 9422 }).binding, + assets: fakeAssets({ '/assets/hero-abc123.png': { body: fixture('test.png') } }).assets, + }; + const query = 'url=/assets/hero-abc123.png&w=64&q=75'; + const first = await run(request(query, { accept: 'image/webp' }), options); + await tasks.settled(); + // A later request, once the transform works again, still finds the entry + // until minimumCacheTTL passes. + const second = await run(request(query, { accept: 'image/webp' }), { + ...options, + binding: fakeBinding().binding, + }); + expect(second.headers.get('x-void-image-cache')).toBe('HIT'); + for (const response of [first, second]) { + expect(response.headers.get('content-type')).toBe('image/png'); + expect(response.headers.get('cache-control')).toBe('public, max-age=14400, must-revalidate'); + } + expect([...entries.values()][0].headers.get('cache-control')).toBe('public, max-age=14400'); + }); + + it('refuses a remote redirect to a host outside remotePatterns and warns once', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const remote = vi.fn(async (input: string) => { + if (input.startsWith('https://images.partner.com/r')) { + return new Response(null, { + status: 302, + headers: { location: new URL(input).searchParams.get('to')! }, + }); + } + return new Response(fixture('test.jpg')); + }); + const options = { + fetch: remote, + config: config({ + remotePatterns: [{ hostname: 'images.partner.com' }, { hostname: 'cdn.partner.com' }], + }), + }; + const via = (target: string): Request => + request( + `url=${encodeURIComponent(`https://images.partner.com/r?to=${encodeURIComponent(target)}`)}&w=64&q=75`, + ); + for (let attempt = 0; attempt < 2; attempt++) { + const refused = await run(via('http://127.0.0.1.nip.io:5432/'), options); + expect(refused.status).toBe(400); + expect(await refused.text()).toBe('"url" parameter is not allowed'); + } + expect(remote).toHaveBeenCalledTimes(2); + expect(warn.mock.calls).toEqual([ + [ + "[void] An image source redirected to http://127.0.0.1.nip.io:5432, which images.remotePatterns doesn't allow, so /_void/image refused it. Add the redirect target to images.remotePatterns to optimize it.", + ], + ]); + const followed = await run(via('https://cdn.partner.com/a.jpg'), options); + expect(followed.status).toBe(200); + expect(remote).toHaveBeenLastCalledWith('https://cdn.partner.com/a.jpg', expect.anything()); + }); + it('fetches allowed remote sources with the upstream max-age', async () => { const remote = vi.fn( async (_input: string, _init: RequestInit) => @@ -598,6 +663,81 @@ describe('createImageHandler', () => { return hono; } + describe('with the app', () => { + // Mirrors the generated route table: middleware, then the endpoint + // (given the app), then routes; static files come from ASSETS. + function guarded(): { hono: Hono; env: Record } { + const hono = new Hono(); + hono.use(async (c, next) => { + if (c.req.path.startsWith('/members/') && c.req.header('cookie') !== 'session=ok') { + return c.text('Unauthorized', 401); + } + if (c.req.path === '/loop.png') { + return hono.fetch( + new Request(new URL('/_void/image?url=%2Floop.png&w=64&q=75', c.req.url)), + c.env, + ); + } + await next(); + }); + hono.get( + '/_void/image', + createImageHandler( + { config: config(), deploymentId: 'd1', loadManifest: async () => null }, + hono, + ), + ); + hono.get('/route.png', () => new Response(fixture('test.jpg'))); + const files = { + '/members/premium.png': { body: fixture('test.png') }, + '/test.png': { body: fixture('test.png') }, + '/loop.png': { body: fixture('test.png') }, + }; + return { hono, env: { ASSETS: fakeAssets(files).assets, IMAGES: fakeBinding().binding } }; + } + + it('applies middleware that guards the source path, without the client cookies', async () => { + const { hono, env } = guarded(); + const direct = await hono.fetch(new Request('https://app.test/members/premium.png'), env); + expect(direct.status).toBe(401); + for (const cookie of [undefined, 'session=ok']) { + const response = await hono.fetch( + request('url=%2Fmembers%2Fpremium.png&w=3840&q=75', { + accept: 'image/webp', + ...(cookie ? { cookie } : {}), + }), + env, + ); + expect(response.status).toBe(401); + expect(await response.text()).toBe( + '"url" parameter is valid but internal response is invalid', + ); + } + }); + + it('falls back to ASSETS when the app answers 404, and uses what routes serve', async () => { + const { hono, env } = guarded(); + const asset = await hono.fetch( + request('url=/test.png&w=64&q=75', { accept: 'image/webp' }), + env, + ); + expect(asset.status).toBe(200); + expect(asset.headers.get('content-type')).toBe('image/webp'); + const route = await hono.fetch(request('url=/route.png&w=64&q=75', { accept: SAFARI }), env); + expect(route.status).toBe(200); + expect(route.headers.get('content-type')).toBe('image/jpeg'); + }); + + it('answers a source request rewritten back to the endpoint without recursing', async () => { + const { hono, env } = guarded(); + const response = await hono.fetch(request('url=/loop.png&w=64&q=75'), env); + expect(response.status).toBe(400); + expect(await response.text()).toBe( + '"url" parameter is valid but internal response is invalid', + ); + }); + }); + it('uses ASSETS and IMAGES from the Worker env and caches through caches.default', async () => { const { cache, entries } = fakeCache(); vi.stubGlobal('caches', { default: cache }); diff --git a/packages/void/test/unit/image-optimizer-upstream.test.ts b/packages/void/test/unit/image-optimizer-upstream.test.ts index db947f1d2..2d1b8c334 100644 --- a/packages/void/test/unit/image-optimizer-upstream.test.ts +++ b/packages/void/test/unit/image-optimizer-upstream.test.ts @@ -205,6 +205,49 @@ describe('fetchRemoteImage', () => { expect(calls).toEqual(['https://images.test/private.jpg']); }); + it('refuses a redirect target that isAllowedRedirect rejects, before fetching it', async () => { + const calls: Array = []; + const checked: Array = []; + const fetch = async (input: string): Promise => { + calls.push(input); + if (input === 'https://images.test/r?to=metadata') { + return new Response(null, { + status: 302, + headers: { location: 'http://169.254.169.254.nip.io/latest/meta-data/' }, + }); + } + if (input === 'https://images.test/r?to=cdn') { + return new Response(null, { + status: 302, + headers: { location: 'https://cdn.images.test/a.jpg' }, + }); + } + return new Response(JPEG, { headers: { 'content-type': 'image/jpeg' } }); + }; + const options: RemoteImageOptions = { + ...permissive, + dangerouslyAllowLocalIP: false, + fetch, + isAllowedRedirect: (url) => { + checked.push(url.href); + return url.hostname.endsWith('images.test'); + }, + }; + // A DNS name that resolves to a private address passes the literal-IP check. + expect(await failure(fetchRemoteImage('https://images.test/r?to=metadata', options))).toEqual({ + status: 400, + message: '"url" parameter is not allowed', + }); + expect(calls).toEqual(['https://images.test/r?to=metadata']); + expect(checked).toEqual(['http://169.254.169.254.nip.io/latest/meta-data/']); + + calls.length = 0; + expect((await fetchRemoteImage('https://images.test/r?to=cdn', options)).bytes).toEqual( + new Uint8Array(JPEG), + ); + expect(calls).toEqual(['https://images.test/r?to=cdn', 'https://cdn.images.test/a.jpg']); + }); + it.each([ ['an unparsable location', 'http://[bad', 500], ['a non-HTTP location', 'ftp://images.test/a.jpg', 500], @@ -338,6 +381,83 @@ describe('loadLocalImage', () => { ).toEqual(expected); }); + describe('through the app', () => { + const image = (): Response => + new Response(JPEG, { headers: { 'content-type': 'image/jpeg', etag: '"app"' } }); + function setup(answer: (request: Request) => Response | Promise) { + const dispatched: Array = []; + const read: Array = []; + return { + dispatched, + read, + dispatch: async (request: Request): Promise => { + dispatched.push(request); + return answer(request); + }, + assets: { + fetch: async (request: Request): Promise => { + read.push(request); + return image(); + }, + }, + }; + } + + it('fails with the status of a guard and never reads the asset', async () => { + const { dispatch, assets, dispatched, read } = setup( + () => new Response('Unauthorized', { status: 401 }), + ); + expect( + await failure( + loadLocalImage(assets, '/members/a.png', 'https://app.test/_void/image', 100, dispatch), + ), + ).toEqual({ status: 401, message: INTERNAL_INVALID }); + expect(read).toEqual([]); + expect(dispatched).toHaveLength(1); + expect(dispatched[0].method).toBe('GET'); + expect(dispatched[0].url).toBe('https://app.test/members/a.png'); + expect([...dispatched[0].headers]).toEqual([]); + }); + + it('fails on a redirect from the app', async () => { + const { dispatch, assets, read } = setup( + () => new Response(null, { status: 302, headers: { location: '/login' } }), + ); + expect( + await failure(loadLocalImage(assets, '/a.png', 'https://app.test/', 100, dispatch)), + ).toEqual({ status: 500, message: INTERNAL_INVALID }); + expect(read).toEqual([]); + }); + + it('falls back to the static assets when the app answers 404', async () => { + const { dispatch, assets, read } = setup(() => new Response('none', { status: 404 })); + const result = await loadLocalImage(assets, '/a.png', 'https://app.test/', 100, dispatch); + expect(result.bytes).toEqual(new Uint8Array(JPEG)); + expect(read.map((request) => request.url)).toEqual(['https://app.test/a.png']); + expect([...read[0].headers]).toEqual([]); + }); + + it('uses what the app serves, as page fallbacks serve static files', async () => { + const { dispatch, assets, read } = setup(image); + const result = await loadLocalImage(assets, '/a.png', 'https://app.test/', 100, dispatch); + expect(result.etag).toBe(Buffer.from('"app"').toString('base64url')); + expect(read).toEqual([]); + }); + + it('passes an app 404 through without assets and maps a throw to 500', async () => { + const notFound = async (): Promise => new Response('none', { status: 404 }); + expect( + await failure(loadLocalImage(undefined, '/a.png', 'https://app.test/', 100, notFound)), + ).toEqual({ status: 404, message: INTERNAL_INVALID }); + const broken = async (): Promise => { + throw new Error('boom'); + }; + expect( + await failure(loadLocalImage(undefined, '/a.png', 'https://app.test/', 100, broken)), + ).toEqual({ status: 500, message: UPSTREAM_INVALID }); + }); + }); + it('fails without an assets binding or when it throws', async () => { expect(await failure(loadLocalImage(undefined, '/a.png', 'https://app.test/', 10))).toEqual({ status: 500, diff --git a/packages/void/test/unit/image-props.test.ts b/packages/void/test/unit/image-props.test.ts index a04143d20..7cac80b19 100644 --- a/packages/void/test/unit/image-props.test.ts +++ b/packages/void/test/unit/image-props.test.ts @@ -202,6 +202,25 @@ describe('registry-aware entry points', () => { expect(props.src).toBe('https://cdn.example.com/a.png?w=32'); }); + it('reports a loaderFile without a default export instead of falling back', () => { + const config = toClientImageConfig( + resolveImageConfig(undefined, { root: '/', dev: false }), + 'server', + ); + const props = { src: '/a.png', alt: '', width: 10, height: 10 }; + setRegistration({ config, loader: undefined, hasLoaderFile: true }); + expect(getDefaultImageLoader()).toBeUndefined(); + expect(() => getImageProps(props)).toThrow( + 'images.loaderFile detected but the file is missing default export.', + ); + expect(() => resolveImageProps(props)).toThrow( + 'images.loaderFile detected but the file is missing default export.', + ); + // A default export that isn't a function fails like Next.js, not with /_void/image URLs. + setRegistration({ config, loader: {} as ImageLoader, hasLoaderFile: true }); + expect(() => getImageProps(props)).toThrow(TypeError); + }); + it('picks the Cloudflare loader for loader: "cloudflare"', () => { const config = toClientImageConfig( resolveImageConfig({ loader: 'cloudflare' }, { root: '/', dev: false }), @@ -222,6 +241,36 @@ describe('registry-aware entry points', () => { expect(getDefaultImageLoader({ ...getImageConfig(), loader: 'default' })).toBe(defaultLoader); }); + it.each([ + ['a node target', { target: 'node' }], + ['static output', { output: 'static' as const }], + ['a Void Platform build', { managed: true }], + ])('keeps loader props on %s, which has no optimizer', (_name, options) => { + const config = toClientImageConfig( + resolveImageConfig(undefined, { root: '/', dev: false, ...options }), + 'client', + ); + setRegistration({ config }); + const props = { src: '/a.png', alt: '', width: 100, height: 100 }; + const loader: ImageLoader = ({ src, width, quality }) => + `https://cdn.example.com${src}?w=${width}&q=${quality ?? 75}`; + + const withLoader = resolveImageProps({ ...props, loader }); + expect(withLoader.props.srcSet).toBe( + 'https://cdn.example.com/a.png?w=128&q=75 1x, https://cdn.example.com/a.png?w=256&q=75 2x', + ); + expect(withLoader.meta.unoptimized).toBe(false); + expect(getImageProps({ ...props, loader }).props.src).toBe( + 'https://cdn.example.com/a.png?w=256&q=75', + ); + + // Without a loader prop there is no endpoint to send the image to. + const plain = resolveImageProps(props); + expect(plain.props.src).toBe('/a.png'); + expect(plain.props.srcSet).toBeUndefined(); + expect(plain.meta.unoptimized).toBe(true); + }); + it('resolveImageProps applies component state', () => { const base = { src: '/a.png', alt: '', width: 100, height: 50, placeholder: 'blur' as const }; const blurDataURL = 'data:image/png;base64,AA'; diff --git a/packages/void/test/unit/plugin-image-routes.test.ts b/packages/void/test/unit/plugin-image-routes.test.ts index 32fc03260..513ee6157 100644 --- a/packages/void/test/unit/plugin-image-routes.test.ts +++ b/packages/void/test/unit/plugin-image-routes.test.ts @@ -140,7 +140,8 @@ describe('voidPlugin image optimizer wiring', () => { }); it('routes the endpoint to the Worker before the SPA shell', async () => { - const plugins = await createPlugins(createProjectRoot()); + const root = createProjectRoot(); + const plugins = await createPlugins(root); expect(resolveAssets(plugins, BUILD).run_worker_first).toEqual([ '/api', '/api/*', @@ -148,6 +149,11 @@ describe('voidPlugin image optimizer wiring', () => { '/__void/*', '/_void/image', ]); + // Endpoint errors stay errors instead of becoming the SPA shell. + const { entry } = await compileRoutes(root, plugins, BUILD); + expect(entry).toContain( + 'const serveWithAssets = __createAssetFallback({"assetsDir":"assets","serveSpaFallback":false,"notFound":"single-page-application","skipPaths":["/_void/image"]});', + ); }); it('keeps self-hosted ISR away from the endpoint', async () => { @@ -168,6 +174,7 @@ describe('voidPlugin image optimizer wiring', () => { expect(routes).not.toContain('__createImageHandler'); expect(entry).not.toContain('bypassPaths'); + expect(entry).not.toContain('skipPaths'); expect(entry).toContain('isr: { deploymentId: __VOID_ISR_DEPLOYMENT_ID, handle:'); expect(resolveAssets(plugins, BUILD).run_worker_first).toEqual([ '/api', @@ -177,6 +184,16 @@ describe('voidPlugin image optimizer wiring', () => { ]); }); + it('mounts the endpoint at the pathname of an absolute images.path', async () => { + const root = createProjectRoot({ images: { path: 'https://cdn.example.com/_next/image' } }); + const plugins = await createPlugins(root); + const { routes } = await compileRoutes(root, plugins, BUILD); + + expect(routes).toContain('app.all("/_next/image", __createImageHandler('); + expect(imageApi(plugins).getConfig()?.path).toBe('https://cdn.example.com/_next/image'); + expect(resolveAssets(plugins, BUILD).run_worker_first).toContain('/_next/image'); + }); + it('mounts the endpoint for inference.bindings.images without detected usage', async () => { const root = createProjectRoot({ inference: { bindings: { images: true } } }, {}); const { routes } = await compileRoutes(root, await createPlugins(root), BUILD); @@ -197,17 +214,17 @@ describe('voidPlugin image optimizer wiring', () => { expect(routes).not.toContain('__createImageHandler'); expect(imageApi(plugins).getMountedConfig(true)).toBeUndefined(); if (!('images' in voidConfig)) { - expect(resolved.unoptimized).toBe(true); + expect(resolved.noOptimizer).toBe(true); } }); - it('resolves Void Platform builds as unoptimized without an endpoint', async () => { + it('resolves Void Platform builds without an optimizer or an endpoint', async () => { vi.stubEnv('VOID_DEPLOY_PROJECT_ID', 'proj_1'); const root = createProjectRoot(); const plugins = await createPlugins(root); const { routes } = await compileRoutes(root, plugins, BUILD); - expect(imageApi(plugins).getConfig()?.unoptimized).toBe(true); + expect(imageApi(plugins).getConfig()?.noOptimizer).toBe(true); expect(routes).not.toContain('__createImageHandler'); }); diff --git a/packages/void/test/unit/plugin-image.test.ts b/packages/void/test/unit/plugin-image.test.ts index 9a2d792e3..ecc93658a 100644 --- a/packages/void/test/unit/plugin-image.test.ts +++ b/packages/void/test/unit/plugin-image.test.ts @@ -1,11 +1,26 @@ -import { copyFileSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'; +import { + copyFileSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import type { AddressInfo } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import type { ConfigEnv, EnvironmentOptions, ResolvedConfig, UserConfig } from 'vite'; +import { + createServer, + type ConfigEnv, + type EnvironmentOptions, + type ResolvedConfig, + type UserConfig, +} from 'vite'; import { afterEach, describe, expect, it, vi } from 'vite-plus/test'; import { resolveImageConfig, toClientImageConfig } from '../../src/image-config.ts'; -import { IMAGE_REGISTRY_KEY } from '../../src/image/config.ts'; +import { IMAGE_REGISTRY_KEY, type ResolvedImageConfig } from '../../src/image/config.ts'; import { getImageConfig, getImageProps } from '../../src/image/index.ts'; import { validateImageParams } from '../../src/image/optimizer/validate.ts'; import { @@ -109,23 +124,24 @@ describe('resolveImageConfigForCommand', () => { ['target: "node"', { target: 'node' as const }], ['target: "bun"', { target: 'bun' as const }], ['output: "static"', { output: 'static' as const }], - ])('resolves %s as unoptimized', (_name, options) => { + ])('resolves %s without an optimizer', (_name, options) => { const resolved = resolveImageConfigForCommand( root, { configFile: 'void.config.ts', ...options }, BUILD, ); - expect(resolved.unoptimized).toBe(true); + expect(resolved.noOptimizer).toBe(true); + expect(resolved.unoptimized).toBe(false); }); - it('resolves Void Platform builds of Cloudflare apps as unoptimized', () => { + it('resolves Void Platform builds of Cloudflare apps without an optimizer', () => { vi.stubEnv('VOID_DEPLOY_PROJECT_ID', 'proj_1'); const options = { configFile: 'void.config.ts' }; - expect(resolveImageConfigForCommand(root, options, BUILD).unoptimized).toBe(true); - expect(resolveImageConfigForCommand(root, options, SERVE).unoptimized).toBe(false); + expect(resolveImageConfigForCommand(root, options, BUILD).noOptimizer).toBe(true); + expect(resolveImageConfigForCommand(root, options, SERVE).noOptimizer).toBe(false); expect( resolveImageConfigForCommand(root, { ...options, images: { loader: 'cloudflare' } }, BUILD) - .unoptimized, + .noOptimizer, ).toBe(false); }); @@ -218,7 +234,7 @@ describe('imagePlugin config module', () => { expect(client).toContain('"dev":true'); }); - it('imports the loaderFile default export by absolute path', () => { + it('imports the loaderFile module by absolute path', () => { const root = createRoot(); writeFileSync(join(root, 'image-loader.ts'), 'export default ({ src }) => src;\n'); const plugin = createPlugin(root, { @@ -231,12 +247,29 @@ describe('imagePlugin config module', () => { expect(loaderPath).toMatch(/\/image-loader\.ts$/); expect(code.split('\n')[0]).toBe( - `import __voidImageLoader from ${JSON.stringify(loaderPath)};`, + `import * as __voidImageLoaderModule from ${JSON.stringify(loaderPath)};`, + ); + expect(code).toContain( + 'loader: Reflect.get(__voidImageLoaderModule, "default"), hasLoaderFile: true };', ); - expect(code).toContain('loader: __voidImageLoader };'); expect(code).toContain('"loader":"custom"'); }); + it('registers a loaderFile without a default export so Image reports it', () => { + const root = createRoot(); + writeFileSync(join(root, 'image-loader.ts'), 'export function loader() {}\n'); + const plugin = createPlugin(root, { images: { loaderFile: './image-loader.ts' } }); + runConfig(plugin, BUILD); + const code = load(plugin, '\0virtual:void/image-config', loadContext('server'))!; + const body = code.split('\n').slice(1).join('\n'); + + // oxlint-disable-next-line no-new-func -- evaluates the module with a namespace lacking `default` + new Function('__voidImageLoaderModule', body)({ loader() {} }); + expect(() => getImageProps({ src: '/a.png', alt: '', width: 10, height: 10 })).toThrow( + 'images.loaderFile detected but the file is missing default export.', + ); + }); + it('resolves the virtual module id', () => { const plugin = createPlugin('/project'); const resolveId = getHookHandler(plugin.resolveId) as (id: string) => string | undefined; @@ -261,10 +294,21 @@ describe('imagePlugin config module', () => { for (const options of [{ target: 'node' as const }, { output: 'static' as const }]) { const plugin = createPlugin('/project', options); runConfig(plugin, BUILD); - expect(plugin.api.getConfig()?.unoptimized).toBe(true); + expect(plugin.api.getConfig()?.noOptimizer).toBe(true); expect(plugin.api.getMountedConfig(true)).toBeUndefined(); } }); + + it('mounts an absolute images.path at its pathname and keeps the URL for Image', () => { + const path = 'https://cdn.example.com/_void/image'; + const plugin = createPlugin('/project', { images: { path } }); + runConfig(plugin, BUILD); + + expect(plugin.api.getConfig()?.path).toBe(path); + expect(plugin.api.getMountedConfig(true)?.path).toBe('/_void/image'); + expect(plugin.api.getMountedConfig(false)).toBeUndefined(); + expect(load(plugin, '\0virtual:void/image-config')).toContain(`"path":"${path}"`); + }); }); describe('imagePlugin void/image injection', () => { @@ -404,6 +448,36 @@ describe('imagePlugin ?image imports', () => { ).not.toHaveProperty('errorMessage'); }); + it('allows built ?image imports under a custom build.assetsDir', () => { + const images = { localPatterns: [{ pathname: '/photos/**', search: '' }] }; + const configResolved = (plugin: ImagePlugin) => + (getHookHandler(plugin.configResolved) as (config: ResolvedConfig) => void)({ + root: '/project', + build: { assetsDir: 'static' }, + } as ResolvedConfig); + const allowed = (resolved: ResolvedImageConfig, url: string) => + !( + 'errorMessage' in + validateImageParams(new URLSearchParams({ url, w: '256', q: '75' }), resolved) + ); + + // Vite resolves its config after the `config` hook resolved `images`. + const plugin = createPlugin('/project', { images }); + runConfig(plugin, BUILD); + configResolved(plugin); + const resolved: ResolvedImageConfig = plugin.api.getMountedConfig(true)!; + expect(resolved.localPatterns.map((p) => p.pathname)).toEqual(['/photos/**', '/static/**']); + expect(allowed(resolved, '/static/hero-CE0Ev3M-.png')).toBe(true); + expect(allowed(resolved, '/assets/hero-CE0Ev3M-.png')).toBe(false); + expect(load(plugin, '\0virtual:void/image-config', loadContext('server'))).toContain( + '"pathname":"/static/**"', + ); + + // A command resolved after Vite's config uses it too. + const preview: ResolvedImageConfig = plugin.api.resolve(PREVIEW); + expect(preview.localPatterns.map((p) => p.pathname)).toEqual(['/photos/**', '/static/**']); + }); + it('keeps Vite URLs for builds and for dev without user localPatterns', () => { const root = createRoot(); const file = join(root, 'src', 'hero.png').replaceAll('\\', '/'); @@ -433,3 +507,58 @@ describe('imagePlugin ?image imports', () => { ).toEqual(['/photos/**', '/assets/**', '/@fs/**']); }); }); + +describe('imagePlugin ?image imports on the dev server', () => { + it('keeps files outside server.fs.allow private', async () => { + const base = realpathSync(createRoot()); + const root = join(base, 'app'); + const outside = join(base, 'outside'); + mkdirSync(join(root, 'src'), { recursive: true }); + mkdirSync(outside); + const secret = join(outside, 'secret.png').replaceAll('\\', '/'); + const shared = join(outside, 'shared.png').replaceAll('\\', '/'); + copyFileSync(PNG_FIXTURE, secret); + copyFileSync(PNG_FIXTURE, shared); + writeFileSync( + join(root, 'src', 'main.js'), + `import hero from ${JSON.stringify(`${shared}?image`)};\nexport default hero;\n`, + ); + const server = await createServer({ + root, + configFile: false, + logLevel: 'silent', + optimizeDeps: { noDiscovery: true }, + server: { + host: '127.0.0.1', + port: 0, + ws: false, + preTransformRequests: false, + fs: { strict: true, allow: [root] }, + }, + plugins: [createPlugin(root)], + }); + try { + await server.listen(); + const origin = `http://127.0.0.1:${(server.httpServer!.address() as AddressInfo).port}`; + const get = async (path: string) => { + const response = await fetch(origin + path); + return { status: response.status, body: await response.text() }; + }; + + expect((await get(`/@fs${secret}`)).status).toBe(403); + const probe = await get(`/@fs${secret}?image&import`); + expect(probe.status).toBe(403); + expect(probe.body).not.toContain('width'); + expect((await get(`/@fs${secret}`)).status).toBe(403); + + // An import from the app still loads a file outside the allow list. + const main = await server.environments.client.transformRequest('/src/main.js'); + expect(main?.code).toContain(`"/@fs${shared}?import&image"`); + const image = await get(`/@fs${shared}?import&image`); + expect(image.status).toBe(200); + expect(image.body).toContain('width: 100, height: 50'); + } finally { + await server.close(); + } + }); +}); diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 716eeb583..8f15b589f 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -408,6 +408,32 @@ describe('syncWranglerBindings', () => { expect(parsed.kv_namespaces[0].binding).toBe('MY_KV'); }); + it('adds the Images binding for env.IMAGES reads unless the name is taken', () => { + setup('wrangler.jsonc', '{ "name": "test" }'); + expect(syncWranglerBindings(tmpDir, noBindings)).toBe(false); + expect(syncWranglerBindings(tmpDir, { ...noBindings, needsImages: true })).toBe(true); + const read = () => JSON.parse(readFileSync(join(tmpDir, 'wrangler.jsonc'), 'utf-8')); + expect(read().images).toEqual({ binding: 'IMAGES' }); + expect(syncWranglerBindings(tmpDir, { ...noBindings, needsImages: true })).toBe(false); + + setup('wrangler.jsonc', '{ "name": "test" }'); + syncWranglerBindings( + tmpDir, + { ...noBindings, needsImages: true }, + { bindingNames: { images: 'PHOTOS' } }, + ); + expect(read().images).toEqual({ binding: 'PHOTOS' }); + + for (const config of [ + { images: { binding: 'MEDIA' } }, + { r2_buckets: [{ binding: 'IMAGES', bucket_name: 'photos' }] }, + ]) { + setup('wrangler.jsonc', JSON.stringify(config)); + expect(syncWranglerBindings(tmpDir, { ...noBindings, needsImages: true })).toBe(false); + expect(read()).toEqual(config); + } + }); + it('does not duplicate existing bindings', () => { setup( 'wrangler.jsonc', diff --git a/packages/vue/src/runtime/image.ts b/packages/vue/src/runtime/image.ts index a489d1e70..13de7f4bb 100644 --- a/packages/vue/src/runtime/image.ts +++ b/packages/vue/src/runtime/image.ts @@ -58,6 +58,8 @@ export type ImageProps = Omit< style?: StyleValue; /** @deprecated Use `@load`. */ onLoadingComplete?: (img: HTMLImageElement) => void; + /** `blurDataURL` under the name a `blur-data-url` template attribute maps to. */ + blurDataUrl?: string; }; /** @@ -122,15 +124,17 @@ function toImageStyle(value: unknown): ImageStyle | undefined { /** * Vue props and attrs as `void/image` input: `class` and `className` become * `className`, `style` becomes an object, `fetchpriority` fills in - * `fetchPriority`, a user `srcset` is dropped (as `next/image` drops - * `srcSet`), and event listeners other than `onLoadingComplete` stay with the - * component. Other attributes keep their order ahead of `Image`'s own. + * `fetchPriority` and `blurDataUrl` fills in `blurDataURL`, a user `srcset` is + * dropped (as `next/image` drops `srcSet`), and event listeners other than + * `onLoadingComplete` stay with the component. Other attributes keep their + * order ahead of `Image`'s own. */ function toCoreProps(input: Record): ImageCoreProps & Record { const core: Record = {}; const classes: Array = []; let style: ImageStyle | undefined; let fetchpriority: unknown; + let blurDataUrl: unknown; for (const [key, value] of Object.entries(input)) { if (value === undefined) { continue; @@ -149,6 +153,10 @@ function toCoreProps(input: Record): ImageCoreProps & Record): ImageCoreProps & Record, default: undefined }, placeholder: { type: String as PropType, default: undefined }, blurDataURL: { type: String, default: undefined }, + // Vue camelizes a `blur-data-url` attribute to `blurDataUrl`, not `blurDataURL`. + blurDataUrl: { type: String, default: undefined }, overrideSrc: { type: String, default: undefined }, decoding: { type: String as PropType<'async' | 'auto' | 'sync'>, default: undefined }, fetchPriority: { type: String as PropType<'high' | 'low' | 'auto'>, default: undefined }, diff --git a/packages/vue/test/unit/image.test.ts b/packages/vue/test/unit/image.test.ts index a389640fe..c30cb895d 100644 --- a/packages/vue/test/unit/image.test.ts +++ b/packages/vue/test/unit/image.test.ts @@ -154,6 +154,24 @@ describe('Image with Vue attribute forms', () => { ); }); + it('takes blurDataURL as blur-data-url or blurDataUrl, the names templates produce', async () => { + const c = golden('placeholder blur'); + const { blurDataURL, ...props } = c.props; + for (const name of ['blur-data-url', 'blurDataUrl']) { + const { html } = await renderSsr(() => h(Image, { ...props, [name]: blurDataURL } as Props)); + expect(html).toBe(c.html); + expect( + getImageProps({ ...props, [name]: blurDataURL } as Parameters[0]), + ).toEqual(getImageProps(c.props as Parameters[0])); + } + // Development checks see the value instead of reporting it missing. + setRegistration({ config: clientConfig({ dev: true }) }); + const dev = await renderSsr(() => + h(Image, { ...props, 'blur-data-url': blurDataURL } as Props), + ); + expect(dev.html).toBe(c.html); + }); + it('treats the Vue boolean shorthand as true', async () => { const c = golden('fill'); const { html } = await renderSsr(() => h(Image, { ...c.props, fill: '' } as Props)); diff --git a/playground/svelte-pages/pages/image.svelte b/playground/svelte-pages/pages/image.svelte index 9d20bb800..64787f624 100644 --- a/playground/svelte-pages/pages/image.svelte +++ b/playground/svelte-pages/pages/image.svelte @@ -1,6 +1,8 @@
@@ -12,14 +14,28 @@ height={320} data-testid="image-fixed" /> + {#if showPreload} + Void image preload + {/if} + Void image preload +
Date: Fri, 16 Oct 2026 13:42:50 +0000 Subject: [PATCH 11/12] test(image): remove the Image playground pages Keep the Pages playgrounds and their suites unchanged. Image stays covered by the adapter unit tests, the endpoint integration tests and the packed consumer checks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6 --- .../integration/react-pages-client.test.ts | 140 ------------- .../solid-pages-playground.test.ts | 164 --------------- .../integration/svelte-pages-client.test.ts | 188 +----------------- .../test/integration/vue-pages-client.test.ts | 168 ---------------- playground/react-pages/pages/image.tsx | 38 ---- .../react-pages/public/void-image-test.png | Bin 813 -> 0 bytes .../react-pages/src/void-image-static.png | Bin 4729 -> 0 bytes .../solid-pages/assets/void-image-import.png | Bin 6265 -> 0 bytes playground/solid-pages/pages/image.tsx | 39 ---- .../solid-pages/public/void-image-test.png | Bin 813 -> 0 bytes .../svelte-pages/assets/void-image-import.png | Bin 6265 -> 0 bytes playground/svelte-pages/pages/image.svelte | 50 ----- .../svelte-pages/public/void-image-test.png | Bin 3030 -> 0 bytes playground/vue-pages/README.md | 6 - .../vue-pages/assets/void-image-import.png | Bin 6265 -> 0 bytes playground/vue-pages/pages/image.vue | 36 ---- .../vue-pages/public/void-image-test.png | Bin 3034 -> 0 bytes 17 files changed, 1 insertion(+), 828 deletions(-) delete mode 100644 playground/react-pages/pages/image.tsx delete mode 100644 playground/react-pages/public/void-image-test.png delete mode 100644 playground/react-pages/src/void-image-static.png delete mode 100644 playground/solid-pages/assets/void-image-import.png delete mode 100644 playground/solid-pages/pages/image.tsx delete mode 100644 playground/solid-pages/public/void-image-test.png delete mode 100644 playground/svelte-pages/assets/void-image-import.png delete mode 100644 playground/svelte-pages/pages/image.svelte delete mode 100644 playground/svelte-pages/public/void-image-test.png delete mode 100644 playground/vue-pages/assets/void-image-import.png delete mode 100644 playground/vue-pages/pages/image.vue delete mode 100644 playground/vue-pages/public/void-image-test.png diff --git a/packages/react/test/integration/react-pages-client.test.ts b/packages/react/test/integration/react-pages-client.test.ts index 794424b24..f40d9baf4 100644 --- a/packages/react/test/integration/react-pages-client.test.ts +++ b/packages/react/test/integration/react-pages-client.test.ts @@ -39,16 +39,6 @@ type PageHead = { const ROOT = join(import.meta.dirname, '..', '..', '..', '..'); const REACT_PAGES_ROOT = join(ROOT, 'playground/react-pages'); -// `pages/image.tsx`: srcset widths follow Next.js (the smallest configured size -// at least 1x and 2x the width), and React renders `srcSet` with that casing. -const IMAGE_ENDPOINT = '/_void/image?url=%2Fvoid-image-test.png'; -const FIXED_IMAGE_HTML = - 'Void image test`; -// `ReactDOM.preload` puts the link at the start of the rendered page, inside `#app`. -const PRELOAD_IMAGE_LINK = `
`; -const IMAGE_TEST_IDS = ['image-fixed', 'image-preload', 'image-fill', 'image-static']; const REACT_PAGES_LAYOUT = join(REACT_PAGES_ROOT, 'pages/layout.tsx'); const LATE_WORKER_DEPS_ROOT = join(REACT_PAGES_ROOT, 'node_modules/@void-repro'); let DEV_BASE: string; @@ -180,60 +170,6 @@ beforeAll(async () => { browser = await launchPagesBrowser(); }); -/** Width of a PNG from its IHDR chunk. */ -async function pngWidth(response: Response): Promise { - return Buffer.from(await response.arrayBuffer()).readUInt32BE(16); -} - -/** Every `Image` on `/image` loads through the endpoint and hydrates cleanly. */ -async function expectImagePageHydrates(base: string) { - const context = await browser.newContext(); - const errors: Array = []; - try { - const page = await context.newPage(); - page.setDefaultTimeout(10_000); - page.on('console', (msg) => { - if (msg.type() === 'error' || /hydrat/i.test(msg.text())) { - errors.push(msg.text()); - } - }); - page.on('pageerror', (error) => { - errors.push(error.message); - }); - - await page.goto(`${base}/image`); - await page.waitForSelector('#app[data-hydrated="true"]'); - await page.waitForFunction( - (ids) => - ids.every((id) => { - const img = document.querySelector(`[data-testid="${id}"]`); - return img?.complete && img.naturalWidth > 0; - }), - IMAGE_TEST_IDS, - ); - - const fixed = await page.evaluate(() => { - const img = document.querySelector('[data-testid="image-fixed"]')!; - return { - naturalWidth: img.naturalWidth, - srcset: img.getAttribute('srcset'), - currentSrc: new URL(img.currentSrc).pathname + new URL(img.currentSrc).search, - }; - }); - expect(fixed).toEqual({ - naturalWidth: 640, - srcset: `${IMAGE_ENDPOINT}&w=640&q=75 1x, ${IMAGE_ENDPOINT}&w=1920&q=75 2x`, - currentSrc: `${IMAGE_ENDPOINT}&w=640&q=75`, - }); - expect(await page.getAttribute('[data-testid="image-static"]', 'src')).toMatch( - /^\/_void\/image\?url=%2F/, - ); - expect(errors).toEqual([]); - } finally { - await context.close(); - } -} - afterAll(async () => { await browser?.close(); }); @@ -753,48 +689,6 @@ describe('react-pages client (dev)', () => { registerSharedDevSsrTest({ getBase: () => DEV_BASE, framework: 'React' }); - it('dev: Image renders Next.js markup and one preload link', async () => { - const response = await fetch(`${DEV_BASE}/image`); - expect(response.status).toBe(200); - const html = await response.text(); - expect(html).toContain(FIXED_IMAGE_HTML); - expect(html).toContain(PRELOAD_IMAGE_LINK); - expect(html.match(/ { - const webp = await fetch(`${DEV_BASE}${IMAGE_ENDPOINT}&w=640&q=75`, { - headers: { Accept: 'image/webp,*/*' }, - }); - expect(webp.status).toBe(200); - expect(webp.headers.get('content-type')).toBe('image/webp'); - expect(webp.headers.get('vary')).toBe('Accept'); - expect(webp.headers.get('cache-control')).toBe('public, max-age=0, must-revalidate'); - await webp.arrayBuffer(); - - const png = await fetch(`${DEV_BASE}${IMAGE_ENDPOINT}&w=640&q=75`, { - headers: { Accept: '*/*' }, - }); - expect(png.status).toBe(200); - expect(png.headers.get('content-type')).toBe('image/png'); - expect(await pngWidth(png)).toBe(640); - - const invalid = await fetch(`${DEV_BASE}${IMAGE_ENDPOINT}&w=641&q=75`); - expect(invalid.status).toBe(400); - expect(await invalid.text()).toBe('"w" parameter (width) of 641 is not allowed'); - }); - - it('dev: Image hydrates and loads through /_void/image', async () => { - await expectImagePageHydrates(DEV_BASE); - }); - it('dev: head tags, pages protocol, form handling, API coexistence', async () => { const rootHtml = await (await fetch(`${DEV_BASE}/`)).text(); expect(rootHtml).toContain('Home | React Pages Demo'); @@ -1032,38 +926,4 @@ describe('react-pages client (preview)', { retry: 2 }, () => { () => PREVIEW_BASE, () => browser, ); - - it('preview: Image uses the Images binding and caches fingerprinted imports', async () => { - const wranglerConfig = JSON.parse( - readFileSync(join(REACT_PAGES_ROOT, 'dist/ssr/wrangler.json'), 'utf-8'), - ) as { images?: unknown }; - expect(wranglerConfig.images).toEqual({ binding: 'IMAGES' }); - - const html = await (await fetch(`${PREVIEW_BASE}/image`)).text(); - expect(html).toContain(FIXED_IMAGE_HTML); - expect(html).toContain(PRELOAD_IMAGE_LINK); - - const publicImage = await fetch(`${PREVIEW_BASE}${IMAGE_ENDPOINT}&w=640&q=75`, { - headers: { Accept: 'image/webp,*/*' }, - }); - expect(publicImage.status).toBe(200); - expect(publicImage.headers.get('content-type')).toBe('image/webp'); - expect(publicImage.headers.get('cache-control')).toBe('public, max-age=14400, must-revalidate'); - await publicImage.arrayBuffer(); - - // Above the 4 KiB inline limit, so the build emits a fingerprinted asset. - const staticSrc = html.match( - /data-testid="image-static"[^>]* src="(\/_void\/image\?url=%2Fassets%2Fvoid-image-static-[\w-]+\.png&w=96&q=75)"/, - )?.[1]; - expect(staticSrc).toBeDefined(); - const staticImage = await fetch(`${PREVIEW_BASE}${staticSrc!.replaceAll('&', '&')}`); - expect(staticImage.status).toBe(200); - expect(staticImage.headers.get('content-type')).toBe('image/png'); - expect(staticImage.headers.get('cache-control')).toBe('public, max-age=315360000, immutable'); - await staticImage.arrayBuffer(); - }); - - it('preview: Image hydrates and loads through /_void/image', async () => { - await expectImagePageHydrates(PREVIEW_BASE); - }); }); diff --git a/packages/solid/test/integration/solid-pages-playground.test.ts b/packages/solid/test/integration/solid-pages-playground.test.ts index 3656ceb03..8377423c2 100644 --- a/packages/solid/test/integration/solid-pages-playground.test.ts +++ b/packages/solid/test/integration/solid-pages-playground.test.ts @@ -204,150 +204,6 @@ function registerClientTests(getBase: () => string, coverage: 'smoke' | 'full') }); } -// `pages/image.tsx`. Srcset widths follow `next/image`: the smallest configured -// size at least 1x and 2x the rendered width. -const IMAGE_ENDPOINT = '/_void/image?url=%2Fvoid-image-test.png'; -const IMAGE_FIXED_TAG = - 'Void image test`; -const IMAGE_PRELOAD_LINK = - '`; - -/** The SSR `` with `data-testid`, without Solid's hydration key. */ -function imageTag(html: string, testId: string): string { - const tag = html.match(new RegExp(`]*data-testid="${testId}"[^>]*>`))?.[0]; - expect(tag, ``).toBeDefined(); - return tag!.replace(/ data-hk="[^"]*"/, ''); -} - -/** The `/_void/image` URL of the `?image` import's 1x candidate, decoded from the SSR markup. */ -function importedImageUrl(html: string): string { - const srcset = imageTag(html, 'image-import').match(/ srcset="([^"]+)"/)?.[1] ?? ''; - return srcset.split(', ')[0].replace(/ 1x$/, '').replaceAll('&', '&'); -} - -/** - * `pages/image.tsx`: SSR markup, the `` preload link, the - * `/_void/image` endpoint (resized through the local Images binding) and - * hydration in the browser. - */ -function registerImageTests(getBase: () => string, mode: 'dev' | 'preview') { - it(`${mode}: Image renders next/image markup and a preload link`, async () => { - const res = await fetch(`${getBase()}/image`); - expect(res.status).toBe(200); - const html = await res.text(); - - expect(imageTag(html, 'image-fixed')).toBe(IMAGE_FIXED_TAG); - // `preload` drops `loading` and adds exactly one preload link to . - expect(imageTag(html, 'image-preload')).not.toContain('loading='); - const head = html.match(/([\s\S]*?)<\/head>/)?.[1] ?? ''; - expect(head).toContain(IMAGE_PRELOAD_LINK); - expect(html.match(/ { - const base = getBase(); - const cacheControl = - mode === 'dev' - ? 'public, max-age=0, must-revalidate' - : 'public, max-age=14400, must-revalidate'; - - const webp = await fetch(`${base}${IMAGE_ENDPOINT}&w=640&q=75`, { - headers: { Accept: 'image/webp,*/*' }, - }); - expect(webp.status).toBe(200); - expect(webp.headers.get('content-type')).toBe('image/webp'); - expect(webp.headers.get('vary')).toBe('Accept'); - expect(webp.headers.get('cache-control')).toBe(cacheControl); - const webpBytes = Buffer.from(await webp.arrayBuffer()); - expect(webpBytes.subarray(8, 12).toString('latin1')).toBe('WEBP'); - - // Without WebP in Accept the source format is kept; the PNG IHDR width is at byte 16. - const png = await fetch(`${base}${IMAGE_ENDPOINT}&w=640&q=75`, { - headers: { Accept: '*/*' }, - }); - expect(png.status).toBe(200); - expect(png.headers.get('content-type')).toBe('image/png'); - expect(png.headers.get('cache-control')).toBe(cacheControl); - expect(Buffer.from(await png.arrayBuffer()).readUInt32BE(16)).toBe(640); - - const rejected = await fetch(`${base}${IMAGE_ENDPOINT}&w=641&q=75`); - expect(rejected.status).toBe(400); - expect(await rejected.text()).toBe('"w" parameter (width) of 641 is not allowed'); - }); - - it(`${mode}: Image hydrates and loads through /_void/image`, async () => { - const context = await browser.newContext(); - const page = await context.newPage(); - page.setDefaultTimeout(10_000); - const problems: Array = []; - page.on('pageerror', (error) => problems.push(`pageerror: ${error.message}`)); - page.on('console', (msg) => { - const text = msg.text(); - if (msg.type() === 'error' || /hydrat/i.test(text) || text.includes('Image with src')) { - problems.push(`console.${msg.type()}: ${text}`); - } - }); - - try { - await page.goto(`${getBase()}/image`); - await page.waitForSelector('#app[data-hydrated="true"]'); - // `complete` is also true after an error, which leaves `naturalWidth` at 0. - await page.waitForFunction(() => - ['image-fixed', 'image-import'].every( - (id) => document.querySelector(`[data-testid="${id}"]`)?.complete, - ), - ); - const images = await page.evaluate(() => - Object.fromEntries( - ['image-fixed', 'image-import'].map((id) => { - const img = document.querySelector(`[data-testid="${id}"]`)!; - const current = new URL(img.currentSrc); - return [ - id, - { - naturalWidth: img.naturalWidth, - currentSrc: current.pathname + current.search, - srcset: img.getAttribute('srcset'), - }, - ]; - }), - ), - ); - // A 1x screen picks the 640w candidate, so the 1200px source was resized. - expect(images['image-fixed']).toEqual({ - naturalWidth: 640, - currentSrc: `${IMAGE_ENDPOINT}&w=640&q=75`, - srcset: `${IMAGE_ENDPOINT}&w=640&q=75 1x, ${IMAGE_ENDPOINT}&w=1920&q=75 2x`, - }); - expect(images['image-import'].naturalWidth).toBe(64); - expect(images['image-import'].currentSrc).toMatch(/^\/_void\/image\?url=%2Fassets%2F/); - expect(problems).toEqual([]); - } finally { - await context.close(); - } - }); -} - describe('solid-pages client (dev)', () => { let child: ChildProcess | undefined; @@ -380,7 +236,6 @@ describe('solid-pages client (dev)', () => { }); registerSharedDevSsrTest({ getBase: () => DEV_BASE, framework: 'Solid' }); - registerImageTests(() => DEV_BASE, 'dev'); defineClientTests(() => DEV_BASE, 'smoke'); defineSharedIslandTests( @@ -433,23 +288,4 @@ describe('solid-pages client (preview)', { retry: 2 }, () => { waitForValidation: (page) => page.getByText('Title is required').waitFor({ timeout: 20_000 }), }, ); - - registerImageTests(() => PREVIEW_BASE, 'preview'); - - it('preview: Image uses the Images binding and caches fingerprinted imports', async () => { - const wranglerConfig = JSON.parse( - readFileSync(join(ROOT, 'playground/solid-pages/dist/ssr/wrangler.json'), 'utf-8'), - ) as { images?: unknown }; - expect(wranglerConfig.images).toEqual({ binding: 'IMAGES' }); - - // The import is above Vite's 4 KiB inline limit, so the build emits a fingerprinted asset. - const html = await (await fetch(`${PREVIEW_BASE}/image`)).text(); - const imported = await fetch(`${PREVIEW_BASE}${importedImageUrl(html)}`, { - headers: { Accept: '*/*' }, - }); - expect(imported.status).toBe(200); - expect(imported.headers.get('content-type')).toBe('image/png'); - expect(imported.headers.get('cache-control')).toBe('public, max-age=315360000, immutable'); - expect(Buffer.from(await imported.arrayBuffer()).readUInt32BE(16)).toBe(64); - }); }); diff --git a/packages/svelte/test/integration/svelte-pages-client.test.ts b/packages/svelte/test/integration/svelte-pages-client.test.ts index dff4e2385..1b7662b24 100644 --- a/packages/svelte/test/integration/svelte-pages-client.test.ts +++ b/packages/svelte/test/integration/svelte-pages-client.test.ts @@ -1,5 +1,5 @@ import { execSync, type ChildProcess } from 'node:child_process'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync } from 'node:fs'; import { join } from 'node:path'; import { type Browser, type Page } from 'playwright'; import { describe, expect, it, beforeAll, afterAll } from 'vite-plus/test'; @@ -111,172 +111,6 @@ function registerClientTests(getBase: () => string, coverage: 'smoke' | 'full') }); } -const IMAGE_FIXED_TAG = - 'Void image test'; -const IMAGE_PRELOAD_LINK = - ''; -const IMAGE_ENDPOINT = '/_void/image?url=%2Fvoid-image-test.png&q=75'; - -function imageTag(html: string, testId: string): string { - const tag = html.match(new RegExp(`]*data-testid="${testId}"[^>]*>`))?.[0]; - expect(tag, ``).toBeDefined(); - return tag!; -} - -/** The `/_void/image` URL of the `?image` import's 1x candidate, decoded from the SSR markup. */ -function importedImageUrl(html: string): string { - const srcset = imageTag(html, 'image-import').match(/ srcset="([^"]+)"/)?.[1] ?? ''; - return srcset.split(', ')[0].replace(/ 1x$/, '').replaceAll('&', '&'); -} - -/** - * `pages/image.svelte`: SSR markup, the `` preload link and the - * `/_void/image` endpoint, which resizes through the local Images binding. - */ -function registerImageTests(getBase: () => string, mode: 'dev' | 'preview') { - it(`${mode}: Image renders next/image markup and a preload link`, async () => { - const res = await fetch(`${getBase()}/image`); - expect(res.status).toBe(200); - const html = await res.text(); - - expect(imageTag(html, 'image-fixed')).toBe(IMAGE_FIXED_TAG); - // `preload` drops `loading` and adds one preload link to for - // both preloaded copies of the image. - expect(imageTag(html, 'image-preload')).not.toContain('loading='); - expect(imageTag(html, 'image-preload-again')).not.toContain('loading='); - const head = html.match(/([\s\S]*?)<\/head>/)?.[1] ?? ''; - expect(head).toContain(IMAGE_PRELOAD_LINK); - expect(html.match(/ { - const base = getBase(); - const cacheControl = - mode === 'dev' - ? 'public, max-age=0, must-revalidate' - : 'public, max-age=14400, must-revalidate'; - - const webp = await fetch(`${base}${IMAGE_ENDPOINT}&w=640`, { - headers: { Accept: 'image/webp,*/*' }, - }); - expect(webp.status).toBe(200); - expect(webp.headers.get('content-type')).toBe('image/webp'); - expect(webp.headers.get('vary')).toBe('Accept'); - expect(webp.headers.get('cache-control')).toBe(cacheControl); - const webpBytes = Buffer.from(await webp.arrayBuffer()); - expect(webpBytes.subarray(8, 12).toString('latin1')).toBe('WEBP'); - - // Without WebP in Accept the source format is kept; the PNG IHDR width is at byte 16. - const png = await fetch(`${base}${IMAGE_ENDPOINT}&w=640`, { headers: { Accept: '*/*' } }); - expect(png.status).toBe(200); - expect(png.headers.get('content-type')).toBe('image/png'); - expect(png.headers.get('cache-control')).toBe(cacheControl); - expect(Buffer.from(await png.arrayBuffer()).readUInt32BE(16)).toBe(640); - - const rejected = await fetch(`${base}${IMAGE_ENDPOINT}&w=641`); - expect(rejected.status).toBe(400); - expect(await rejected.text()).toBe('"w" parameter (width) of 641 is not allowed'); - }); - - it(`${mode}: Image hydrates and loads through /_void/image`, async () => { - const context = await browser.newContext(); - const page = await context.newPage(); - page.setDefaultTimeout(10_000); - const problems: Array = []; - page.on('pageerror', (error) => problems.push(`pageerror: ${error.message}`)); - page.on('console', (msg) => { - const text = msg.text(); - if ( - msg.type() === 'error' || - text.includes('[svelte]') || - /hydrat/i.test(text) || - text.includes('Image with src') - ) { - problems.push(`console.${msg.type()}: ${text}`); - } - }); - - try { - await page.goto(`${getBase()}/image`); - await page.waitForSelector('#app[data-hydrated="true"]'); - // `complete` is also true after an error, which leaves `naturalWidth` at 0. - await page.waitForFunction(() => - ['image-fixed', 'image-import'].every( - (id) => document.querySelector(`[data-testid="${id}"]`)?.complete, - ), - ); - const images = await page.evaluate(() => - Object.fromEntries( - ['image-fixed', 'image-import'].map((id) => { - const img = document.querySelector(`[data-testid="${id}"]`)!; - const current = new URL(img.currentSrc); - return [ - id, - { - naturalWidth: img.naturalWidth, - currentSrc: current.pathname + current.search, - srcset: img.getAttribute('srcset'), - }, - ]; - }), - ), - ); - // A 1x screen picks the 640w candidate, so the 1200px source was resized. - expect(images['image-fixed']).toEqual({ - naturalWidth: 640, - currentSrc: '/_void/image?url=%2Fvoid-image-test.png&w=640&q=75', - srcset: - '/_void/image?url=%2Fvoid-image-test.png&w=640&q=75 1x, ' + - '/_void/image?url=%2Fvoid-image-test.png&w=1920&q=75 2x', - }); - expect(images['image-import'].naturalWidth).toBe(64); - expect(images['image-import'].currentSrc).toMatch(/^\/_void\/image\?url=%2Fassets%2F/); - // Hydration keeps the single preload link. - const preloadLinks = () => - page - .locator('head link[rel="preload"][as="image"]') - .evaluateAll((links) => links.map((link) => link.getAttribute('imagesrcset'))); - const preloadSrcSet = - '/_void/image?url=%2Fvoid-image-test.png&w=640&q=75 1x, ' + - '/_void/image?url=%2Fvoid-image-test.png&w=1080&q=75 2x'; - expect(await preloadLinks()).toEqual([preloadSrcSet]); - // When the image that rendered the link unmounts, its copy takes it over. - await page.click('[data-testid="image-preload-hide"]'); - await page.waitForSelector('[data-testid="image-preload"]', { state: 'detached' }); - expect(await preloadLinks()).toEqual([preloadSrcSet]); - expect(problems).toEqual([]); - } finally { - await context.close(); - } - }); -} - describe('svelte-pages client (dev)', () => { let child: ChildProcess | undefined; @@ -295,7 +129,6 @@ describe('svelte-pages client (dev)', () => { }); registerSharedDevSsrTest({ getBase: () => DEV_BASE, framework: 'Svelte' }); - registerImageTests(() => DEV_BASE, 'dev'); defineClientTests(() => DEV_BASE, 'smoke'); defineSharedIslandTests( @@ -342,23 +175,4 @@ describe('svelte-pages client (preview)', { retry: 2 }, () => { () => PREVIEW_BASE, () => browser, ); - registerImageTests(() => PREVIEW_BASE, 'preview'); - - it('preview: the build binds IMAGES and serves imported images as immutable', async () => { - const wrangler = JSON.parse( - readFileSync(join(ROOT, 'playground/svelte-pages/dist/ssr/wrangler.json'), 'utf-8'), - ) as { images?: unknown }; - expect(wrangler.images).toEqual({ binding: 'IMAGES' }); - - // The imported file is above Vite's inline limit, so it is a fingerprinted asset. - const html = await (await fetch(`${PREVIEW_BASE}/image`)).text(); - const imported = await fetch(`${PREVIEW_BASE}${importedImageUrl(html)}`, { - headers: { Accept: 'image/webp,*/*' }, - }); - expect(imported.status).toBe(200); - expect(imported.headers.get('content-type')).toBe('image/webp'); - expect(imported.headers.get('cache-control')).toBe('public, max-age=315360000, immutable'); - const importedBytes = Buffer.from(await imported.arrayBuffer()); - expect(importedBytes.subarray(8, 12).toString('latin1')).toBe('WEBP'); - }); }); diff --git a/packages/vue/test/integration/vue-pages-client.test.ts b/packages/vue/test/integration/vue-pages-client.test.ts index 3cd3f76e4..62f75e4d8 100644 --- a/packages/vue/test/integration/vue-pages-client.test.ts +++ b/packages/vue/test/integration/vue-pages-client.test.ts @@ -194,154 +194,6 @@ function registerClientTests(getBase: () => string, coverage: 'smoke' | 'full') }); } -const IMAGE_FIXED_TAG = - 'Void image test'; -const IMAGE_PRELOAD_LINK = - ''; -const IMAGE_ENDPOINT = '/_void/image?url=%2Fvoid-image-test.png&q=75'; - -function imageTag(html: string, testId: string): string { - const tag = html.match(new RegExp(`]*data-testid="${testId}"[^>]*>`))?.[0]; - expect(tag, ``).toBeDefined(); - return tag!; -} - -/** The `/_void/image` URL of the `?image` import's 1x candidate, decoded from the SSR markup. */ -function importedImageUrl(html: string): string { - const srcset = imageTag(html, 'image-import').match(/ srcset="([^"]+)"/)?.[1] ?? ''; - return srcset.split(', ')[0].replace(/ 1x$/, '').replaceAll('&', '&'); -} - -/** - * `pages/image.vue`: SSR markup, the `` preload link and the - * `/_void/image` endpoint, which resizes through the local Images binding. - */ -function registerImageTests(getBase: () => string, mode: 'dev' | 'preview') { - it(`${mode}: Image renders next/image markup and a preload link`, async () => { - const res = await fetch(`${getBase()}/image`); - expect(res.status).toBe(200); - const html = await res.text(); - - expect(imageTag(html, 'image-fixed')).toBe(IMAGE_FIXED_TAG); - // `preload` drops `loading` and adds exactly one preload link to . - expect(imageTag(html, 'image-preload')).not.toContain('loading='); - const head = html.match(/([\s\S]*?)<\/head>/)?.[1] ?? ''; - expect(head).toContain(IMAGE_PRELOAD_LINK); - expect(html.match(/ { - const base = getBase(); - const cacheControl = - mode === 'dev' - ? 'public, max-age=0, must-revalidate' - : 'public, max-age=14400, must-revalidate'; - - const webp = await fetch(`${base}${IMAGE_ENDPOINT}&w=640`, { - headers: { Accept: 'image/webp,*/*' }, - }); - expect(webp.status).toBe(200); - expect(webp.headers.get('content-type')).toBe('image/webp'); - expect(webp.headers.get('vary')).toBe('Accept'); - expect(webp.headers.get('cache-control')).toBe(cacheControl); - const webpBytes = Buffer.from(await webp.arrayBuffer()); - expect(webpBytes.subarray(8, 12).toString('latin1')).toBe('WEBP'); - - // Without WebP in Accept the source format is kept; the PNG IHDR width is at byte 16. - const png = await fetch(`${base}${IMAGE_ENDPOINT}&w=640`, { headers: { Accept: '*/*' } }); - expect(png.status).toBe(200); - expect(png.headers.get('content-type')).toBe('image/png'); - expect(png.headers.get('cache-control')).toBe(cacheControl); - expect(Buffer.from(await png.arrayBuffer()).readUInt32BE(16)).toBe(640); - - const rejected = await fetch(`${base}${IMAGE_ENDPOINT}&w=641`); - expect(rejected.status).toBe(400); - expect(await rejected.text()).toBe('"w" parameter (width) of 641 is not allowed'); - }); - - it(`${mode}: Image hydrates and loads through /_void/image`, async () => { - const context = await browser.newContext(); - const page = await context.newPage(); - page.setDefaultTimeout(10_000); - const problems: Array = []; - page.on('pageerror', (error) => problems.push(`pageerror: ${error.message}`)); - page.on('console', (msg) => { - const text = msg.text(); - if ( - msg.type() === 'error' || - text.includes('[Vue warn]') || - text.includes('Hydration') || - text.includes('Image with src') - ) { - problems.push(`console.${msg.type()}: ${text}`); - } - }); - - try { - await page.goto(`${getBase()}/image`); - await page.waitForSelector('#app[data-hydrated="true"]'); - // `complete` is also true after an error, which leaves `naturalWidth` at 0. - await page.waitForFunction(() => - ['image-fixed', 'image-import'].every( - (id) => document.querySelector(`[data-testid="${id}"]`)?.complete, - ), - ); - const images = await page.evaluate(() => - Object.fromEntries( - ['image-fixed', 'image-import'].map((id) => { - const img = document.querySelector(`[data-testid="${id}"]`)!; - const current = new URL(img.currentSrc); - return [ - id, - { - naturalWidth: img.naturalWidth, - currentSrc: current.pathname + current.search, - srcset: img.getAttribute('srcset'), - }, - ]; - }), - ), - ); - // A 1x screen picks the 640w candidate, so the 1200px source was resized. - expect(images['image-fixed']).toEqual({ - naturalWidth: 640, - currentSrc: '/_void/image?url=%2Fvoid-image-test.png&w=640&q=75', - srcset: - '/_void/image?url=%2Fvoid-image-test.png&w=640&q=75 1x, ' + - '/_void/image?url=%2Fvoid-image-test.png&w=1920&q=75 2x', - }); - expect(images['image-import'].naturalWidth).toBe(64); - expect(images['image-import'].currentSrc).toMatch(/^\/_void\/image\?url=%2Fassets%2F/); - expect(problems).toEqual([]); - } finally { - await context.close(); - } - }); -} - describe('vue-pages client (dev)', () => { let child: ChildProcess | undefined; @@ -364,7 +216,6 @@ describe('vue-pages client (dev)', () => { }); registerSharedDevSsrTest({ getBase: () => DEV_BASE, framework: 'Vue' }); - registerImageTests(() => DEV_BASE, 'dev'); defineClientTests(() => DEV_BASE, 'smoke'); defineSharedIslandTests( @@ -492,23 +343,4 @@ describe('vue-pages client (preview)', { retry: 2 }, () => { () => PREVIEW_BASE, () => browser, ); - registerImageTests(() => PREVIEW_BASE, 'preview'); - - it('preview: the build binds IMAGES and serves imported images as immutable', async () => { - const wrangler = JSON.parse( - readFileSync(join(ROOT, 'playground/vue-pages/dist/ssr/wrangler.json'), 'utf-8'), - ) as { images?: unknown }; - expect(wrangler.images).toEqual({ binding: 'IMAGES' }); - - // The imported file is above Vite's inline limit, so it is a fingerprinted asset. - const html = await (await fetch(`${PREVIEW_BASE}/image`)).text(); - const imported = await fetch(`${PREVIEW_BASE}${importedImageUrl(html)}`, { - headers: { Accept: 'image/webp,*/*' }, - }); - expect(imported.status).toBe(200); - expect(imported.headers.get('content-type')).toBe('image/webp'); - expect(imported.headers.get('cache-control')).toBe('public, max-age=315360000, immutable'); - const importedBytes = Buffer.from(await imported.arrayBuffer()); - expect(importedBytes.subarray(8, 12).toString('latin1')).toBe('WEBP'); - }); }); diff --git a/playground/react-pages/pages/image.tsx b/playground/react-pages/pages/image.tsx deleted file mode 100644 index 1003b99f9..000000000 --- a/playground/react-pages/pages/image.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { Image } from '@void/react'; -import staticImage from '../src/void-image-static.png?image'; - -export default function ImagePage() { - return ( -
-

Image

- Void image test - Void image preload -
- Void image fill -
- Void image static import -
- ); -} diff --git a/playground/react-pages/public/void-image-test.png b/playground/react-pages/public/void-image-test.png deleted file mode 100644 index 002caf3a0bfe2202007386a82ed0f7f18e30015e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 813 zcmeAS@N?(olHy`uVBq!ia0y~yVA;UHz!bs33>0y-c&`DZm;-!5Tyr&Ux~eZM268zI zJR*TK^9vAW+*%!!4b*VK)5S5Q;?~=PjJ!Z4hZcPIfBS|}1jrl(qaiQ^L%{rh9s{FB S)4eQ^+dWfTewaa*wSEE5LH!OR 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áNo&TWDmbipbRjGgf`J6MGXJp4>{)?XE;8TE1a_i6N=^LsLOsd2 zZCL-oBOwdiiOMXo8e(vf^V0OSS?1eUJh#%$80jvVWZ~jkLGp=H_h2QO|EB?jNuM*z*(J~NdhCb1WShc1jn&Cko${<+s~5b#*C12)TyQ*Ysw^s8|> zke{mRciWy;ZiZXr>afFII>cht*d>m7xy&JXkHNOSxF=kMHD8u;eS-;G$L6sO`TOSt zP4Sy|(^#311d86OvzM|NYnjuy3hWwf83Sjk+`5TZU%kc}{&3*9(8996kG(Q4Yg}nv zbpgJ^3>w`Fz%Mp&yG*<9Y3LxI`HalDhg_x6KMEr{na(&7XCQ|*xlS|(#esHgxX zfdz3#u4n5U_30Z+cHiKot-##F=MXs#4Sn$biNkXoIv2XJF1;iSjW-z6EtFb|j^y9Z zC={O^|6}ktHcv89LRI4Q^7yNn;?RsF(>`nUlMs}x zic;+X*!+KRRl^R+(1LF0bmQ9672+#+55>mIegz_-Fe;{gixvt*^&sE`^ZI#hyfiY} zyHJhB_T#j{j-5k(hbK_7rumx}J~VP%E_>qnhbdfLbX8jbWk?T=G)L}^c}89!deHkk zIFO;vI4o&X`i9|1i%$;($g`N}7A{i=#hf|!>I_=Po8nMUQ^_(ohr4s@n)cZmAo}DF z!ik)MqA({IQG8eYdJy(@5iHqbIE?JcSFptk}MD`a1|fmZ>EBp1sfN+MbyO zSG4l_XQrnCirNkJrKJ;p4a>`c|2y1hlN)OH($c+i>R z&>~9S*CFD#yYQRt=4-`Sqf#D9wi%?B{b`K=^`TNJyA&okG41(AEJL)m__bikgX+edM)eSs<(7*QAE81)q6q(jaX?U9 j#shNJ3JK}6uE(<-{Otg(1%#K!00000NkvXXu0mjfYAzTX diff --git a/playground/solid-pages/pages/image.tsx b/playground/solid-pages/pages/image.tsx deleted file mode 100644 index 86e3b5022..000000000 --- a/playground/solid-pages/pages/image.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Image } from '@void/solid'; -import importedPhoto from '../assets/void-image-import.png?image'; - -export default function ImagePage() { - return ( -
-

Images

- Void image test - Void image preload -
- Void image fill -
- Void image import -
- ); -} diff --git a/playground/solid-pages/public/void-image-test.png b/playground/solid-pages/public/void-image-test.png deleted file mode 100644 index 7fd915b114d760e1ed79c4d07aad058c1426f21a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 813 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 diff --git a/playground/svelte-pages/pages/image.svelte b/playground/svelte-pages/pages/image.svelte deleted file mode 100644 index 64787f624..000000000 --- a/playground/svelte-pages/pages/image.svelte +++ /dev/null @@ -1,50 +0,0 @@ - - -
-

Images

- Void image test - {#if showPreload} - Void image preload - {/if} - - Void image preload again - -
- Void image fill -
- Void image import -
diff --git a/playground/svelte-pages/public/void-image-test.png b/playground/svelte-pages/public/void-image-test.png deleted file mode 100644 index 9acf61c730ec2c5c6991e92e16156e191ea814b3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3030 zcmeAS@N?(olHy`uVBq!ia0y~yVA;UHz!bs31Qhw((%}oFI14-?iy0W0Uw|;<*6N^a z1_tg9PZ!6Kid%258}c$R@EqQtBwZh--6Oj>h2`g@xj;=HCa?g>HUS{v%)!9Wpa|q1 z5CEE*z`@ACV9){-mv90SJc`s*Mn|ha4sTGL0(3p?y@eV`Gz~#itH9A_(4s|~*ur8L zA}sMr(jfiNJE?;FN2go?4@yMn(jWkFWoJ5C1@<2)~ z(4}S^EdyxY0-<*eLg$3aFfxh&P{WtbAxl9sKt^+>McFi;yN;lei^0>?&t;ucLK6T? Ct4k07 diff --git a/playground/vue-pages/README.md b/playground/vue-pages/README.md index 4b904d713..b4c25986a 100644 --- a/playground/vue-pages/README.md +++ b/playground/vue-pages/README.md @@ -14,11 +14,6 @@ pages/ index.server.ts # Server handler with loader + action [id].vue # Dynamic user detail page [id].server.ts # Server handler for [id] page - image.vue # Image: fixed, preload, fill and ?image import -assets/ - void-image-import.png # Imported with ?image (fingerprinted in builds) -public/ - void-image-test.png # Served from /, optimized through /_void/image routes/api/ health.ts # Health check endpoint (GET /api/health) middleware/ @@ -41,4 +36,3 @@ db/ - **`Link` component** — SPA navigation (renders `
` for GET, `