Skip to content

feat(image): add next/image-compatible Image and the /_void/image optimizer - #450

Open
ogawa0071 wants to merge 17 commits into
voidzero-dev:mainfrom
ogawa0071:feat/image-optimization
Open

ogawa0071 wants to merge 17 commits into
voidzero-dev:mainfrom
ogawa0071:feat/image-optimization

Conversation

@ogawa0071

@ogawa0071 ogawa0071 commented Oct 16, 2026 •

Copy link
Copy Markdown

Closes #441

Summary

This adds image optimization compatible with next/image in Next.js 16, backed by the Cloudflare Images binding.

  • Image and getImageProps() in @void/react, @void/vue, @void/svelte and @void/solid. They take the props of next/image and render the same <img> attributes, including srcset.
  • @void/react/image has the next/image module shape, so resolve.alias: { 'next/image': '@void/react/image' } compiles existing components unchanged.
  • images in void.config.ts takes the keys, types and defaults of images in next.config.js, including loader and loaderFile.
  • /_void/image follows /_next/image: the same url/w/q validation and 400 messages, Accept negotiation and caching headers. It transforms images with env.IMAGES and caches results in caches.default.
  • IMAGES is inferred from Image and getImageProps imports and from env.IMAGES reads. inference.bindings.images enables, renames or removes it.
  • import hero from './hero.png?image' returns StaticImageData ({ src, width, height }).

Most of the diff is tests and checked-in fixtures:

Area Added Removed Files
Tests and fixtures 13,519 17 57
void/image core and endpoint 3,441 0 22
Plugin wiring, inference, routing and CLI 2,368 66 33
Adapters 1,712 39 20
Docs 786 12 14
Manifests, schema, build config and CI script 270 10 8

How it works

  • Shared core. void/image holds prop resolution, srcset selection, the built-in loaders, pattern matching and the config types, ported from Next.js 16.4.2. It runs in Workers and browsers. Patterns are compiled with picomatch at build time, in Node.
  • Config transport. The plugin prepends a virtual:void/image-config import to the void/image entry in every environment, so server rendering and hydration use the same config and loaderFile. void/image is excluded from dependency optimization, so prebundling can't drop that import.
  • Endpoint mounting. The generated route table mounts the endpoint after auth and user middleware, and before WebSocket hooks, routes and pages. It does so only for apps that use Image with the default loader, or that set inference.bindings.images. Apps without images generate the same code as before. Anonymous requests to the path skip the auth session lookup, and the path also skips self-host ISR and the SPA fallback.
  • Local sources are requested through the app without client headers, like Next.js's internal request, so middleware that guards the path applies. An app 404 falls back to env.ASSETS.
  • Remote sources follow Next.js: remotePatterns, maximumRedirects, maximumResponseBody and a 7-second timeout, without client headers. maximumResponseBody is capped at the binding's 20 MB input limit.
  • Caching. Results are stored in caches.default, keyed on source, width, quality and negotiated format. Cached local sources are scoped to the deployment. Optimized ?image imports are fingerprinted, so they get public, max-age=315360000, immutable.
  • Fallbacks. Animated GIF, PNG and WebP sources pass through unchanged, as do SVGs when dangerouslyAllowSVG is set, as in Next.js. A failed transformation serves the original and logs a warning. That includes error 9422, returned once the Images Free allowance is used up.

Workers Free and other targets

  • The Images Free plan includes 5,000 unique transformations a month, so the feature is on by default under the Workers Free rule in AGENTS.md. Nothing probes Images entitlement.
  • Apps that don't use Image, getImageProps or env.IMAGES emit no binding, var or route. Tests cover this.
  • Void Platform has no Images binding. Managed builds render originals and void deploy warns, unless a custom loader or the cloudflare loader is set.
  • node, bun and deno targets and output: "static" render originals unless a custom loader is set.
  • images.loader: 'cloudflare' emits /cdn-cgi/image/... URLs for zones with transformations enabled. It adds no binding and no endpoint.

Decisions on the open questions

  1. Static imports: ?image returns StaticImageData, and plain imports keep Vite's URL string. There is no opt-in to Next.js import semantics yet.
  2. Blur placeholders: blurDataURL isn't generated. placeholder="blur" needs an explicit blurDataURL.
  3. Void Platform: originals and a deploy warning, as above, until the platform can provide a binding.
  4. Local sources served by routes: implemented as Next.js does, instead of static assets only. A route or page that answers the path supplies the image, without the visitor's cookies.
  5. Durable cache: caches.default only.

Other choices worth reviewing:

  • Redirect targets must also match remotePatterns. Next.js resolves each hop and refuses private addresses, but a Worker can't resolve DNS, and Miniflare can reach private networks in development.
  • AVIF is skipped above 1,200 px, Cloudflare's limit. Quality is passed through without Next.js's AVIF scaling, which is tuned for sharp.
  • ?image files are marked immutable in the client manifest, but they aren't passed to the hashed-file Worker bypass from fix(auth): keep static file cache policy for signed-in users, and skip the Worker for hashed chunks #552. Image file names and counts therefore can't turn that bypass off.

Differences from next/image

These are documented in docs/guide/images.md under "Migrating from next/image":

  • The endpoint is /_void/image instead of /_next/image, and its cache status header is X-Void-Image-Cache.
  • Static imports need ?image, and blurDataURL isn't generated.
  • AVIF output is limited to 1,200 px wide, and maximumResponseBody to 20 MB.
  • dangerouslyAllowLocalIP covers only localhost and IP addresses written in the URL, because hostnames aren't resolved.
  • maximumDiskCacheSize, disableStaticImages and customCacheHandler are accepted with a warning and have no effect.

Not verified on Cloudflare

I tested with Miniflare's local Images binding, not a deployed Worker. The issue's "to verify" items are handled so that the outcome doesn't depend on the answer:

  • Images binding in a Workers for Platforms namespace: not used. Void Platform renders originals.
  • Error 9422: every transformation error serves the original, so the exact error shape doesn't matter.
  • AVIF wider than 1,200 px: never requested from the binding.
  • AVIF quality scaling: not applied.
  • caches.default on workers.dev: if writes have no effect there, responses are still correct; images are just transformed again.
  • Offline binding in development: width and format apply. q has no visible effect locally, which the guide documents.

Testing

  • Markup parity: golden fixtures generated once from next@16.4.2 (getImgProps and the next/image component) for a table of props: fixed sizes, sizes, fill, preload, unoptimized, custom loaders, overrideSrc and each placeholder. They are checked in, so the repository doesn't depend on next.
  • Endpoint: the expected 400 results come from running Next.js 16.4.2's ImageOptimizerCache.validateParams. Other cases cover format negotiation, the AVIF cap, the 9422 and passthrough fallbacks, cache keys and deployment scoping.
  • Integration: image-endpoint.test.ts runs the endpoint against Miniflare's local binding, and image-route-generated.test.ts exercises the generated route table.
  • Inference and deploy: apps without images emit no binding, var or route. Other cases cover overrides, custom names, vinext's imagesOptimizer() and native deploy validation.
  • Adapters: tests for all four adapters, including preload links from page and island renderers.
  • Packed consumer: .github/scripts/packed-consumer.mjs renders Image from the installed packages and requests /_void/image.

Run locally on this branch, merged with the current main:

  • vp check, vp run check, vp run build:all, node test/check-playground-build-artifacts.mjs and vp run build:docs pass.
  • vp test run --project unit --project unit-pglite --project cli-unit passes, except one HTTP proxy test that fails the same way on main in my environment.
  • vp test run --project parallel-integration passes.
  • The packed consumer check passes with pnpm (smoke suite).

Changes to existing tests

  • packages/react/test/integration/md-layout-frontmatter.test.ts: the stub for the pages plugin now prebundles React, as the real plugin does. @void/react now imports react-dom for Image, and discovering it during hydration made Vite re-optimize dependencies and reload the page mid-test.
  • Other existing tests only gain cases, the new needsImages and usesImageOptimizer fields, or allowlist entries such as __VOID_IMAGES_BINDING in the kitchen-sink leak check.

ogawa0071 and others added 10 commits October 16, 2026 03:56
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6
Detect Image/getImageProps usage (including Vue and Svelte script blocks),
env.IMAGES reads and vinext's Cloudflare images optimizer, emit the
images binding only when the optimizer is used, and teach native deploy
validation and the Void Platform deploy about it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6
…the plugin

Register the resolved images config for Image in every environment,
mount the optimizer after middleware only when the app uses it, add
?image static imports with build-time dimensions, and keep the image
path out of auth session lookups, ISR and the SPA asset fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6
… warn once

The dev server serves imported images from their source path, so with
user localPatterns ?image now uses the /@fs/ URL that the dev config
allows. Image config warnings now print once per build instead of once
per Vite environment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6
…SSR entries

Treat next/image imports in Void apps like @void/react/image so the
migration alias needs no extra config, render Image preloads in Vue and
Svelte SSR entries from void gen, and exercise Image in the packed
consumer checks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6
- Guard ?image reads with server.fs.allow in dev.
- Load local sources through the app without credentials, falling back
  to static assets, so path-scoped middleware still protects them.
- Require redirect targets to match remotePatterns.
- Resolve sessions for signed-in image requests without refreshing them.
- Keep transform-failure fallbacks out of the immutable cache policy.
- Mount the optimizer at the pathname of an absolute images.path.
- Keep per-image loader props when the build has no optimizer.
- Report loaderFile modules without a default export like Next.js.
- Honor build.assetsDir for ?image imports with custom localPatterns.
- Keep 404s from the endpoint out of the SPA fallback.
- Emit the Images binding for Class B/C frameworks that read env.IMAGES.
- Only block existing-Worker links when the Images binding is added.
- Deduplicate Svelte preload links, accept blur-data-url in Vue, and
  keep crossorigin on preload links.
- Document scanning limits, env.IMAGES on Void Platform, and custom
  SSR preloads.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6
@ogawa0071
ogawa0071 requested a review from a team as a code owner October 16, 2026 12:26
@socket-security

socket-security Bot commented Oct 16, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Added@​types/​picomatch@​4.0.31001007381100

View full report

ogawa0071 and others added 5 commits October 16, 2026 13:42
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6
…view

- Resolve bare `void/image` imports to the package entry, so an adapter
  installed with its own nested copy of void reads the same config; exclude
  it from dependency optimization in every environment and narrow the
  transform filter to the entry path.
- Import built `?image` files with `?no-inline`, so small images keep their
  fingerprinted URL instead of becoming data URLs.
- Apply the source timeout to local sources too (504), including a body
  that stalls after the headers.
- Detect an animated WebP from the VP8X flags instead of scanning for ANIM.
- Share `warnOnce` with the handler (keyed messages) and bound only the
  request-derived keys.
- Load the client manifest again after a failed load instead of caching
  the failure.
- Read vinext `imagesOptimizer` options through `as`/`satisfies` and
  same-file string constants, and try Vite config files in Vite's order.
- Dedupe the Images binding helpers (`findImagesBindingOwner`) and export
  the `void/image` config symbols by name.
- Docs: shorter table cells, vinext detection, never-inlined imports.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6
…at/image-optimization

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6
@cpojer
cpojer marked this pull request as draft October 20, 2026 12:01
@yangshun

yangshun commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

@ogawa0071 checking in on this PR 😄

ogawa0071 and others added 2 commits November 3, 2026 15:04
Catch up with voidzero-dev/void main (7091135). Conflicts were resolved by
taking upstream's code and re-applying only the image lines, adapted to
upstream's refactors:

- adapters: re-apply image preloads on top of the shared islands plugin.
  Solid wraps the island page render in ImagePreloadContext (islands now
  hydrate under their own render IDs), Svelte adds the preload context next
  to the frontmatter context, and Vue keeps its SSR context.
- inference: detect IMAGES through upstream's static env access and
  destructuring detection.
- routing: include the image optimizer mount in the dev route-input key,
  mark the image handler as a route handler for the named-action marker, and
  pass the image API to the hoisted Cloudflare routing plugin.
- tests: mock src/plugin-cloudflare.ts like upstream's other plugin tests,
  and let the React Markdown island test prebundle React like the real pages
  plugin, since @void/react now imports react-dom for Image.
- docs: keep upstream's tables and add IMAGES beside ASSETS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6
?image files are stamped immutable in the client manifest, and the list
stampClientAssetCachePolicy returns now feeds upstream's hashed-file Worker
bypass. One image name a run_worker_first rule cannot express (such as
hero@2x.png), or enough images to pass Cloudflare's 100-rule limit, turned
the bypass off for every chunk. Return only chunk files, as upstream does;
images keep their immutable policy through the Worker.

Also bring the Image Optimization notes in packages/void/AGENTS.md in line
with the merged code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013AVevZE8cTSdamUqCypuq6
@ogawa0071
ogawa0071 force-pushed the feat/image-optimization branch from 258bc58 to c0bf511 Compare November 3, 2026 20:43
@ogawa0071 ogawa0071 changed the title Feat/image optimization feat(image): add next/image-compatible Image and the /_void/image optimizer Nov 3, 2026
@ogawa0071
ogawa0071 marked this pull request as ready for review November 3, 2026 20:59

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

next/image compatible component backed by Cloudflare Images

2 participants