@@ -430,7 +435,9 @@ function generateIslandClientEntry(
async function hydrateIsland(el, mod, props) {
const { hydrate } = await import("svelte");
const Component = mod.default || mod;
- hydrate(Component, { target: el, props });
+ // A fresh image preload context, as the island's server wrapper provides.
+ const context = new Map([[${JSON.stringify(IMAGE_PRELOADS_KEY)}, new Map()]]);
+ hydrate(Component, { target: el, props, context });
el.setAttribute("data-hydrated", "true");
}
diff --git a/packages/svelte/src/runtime/App.svelte b/packages/svelte/src/runtime/App.svelte
index b4a330941..033e41821 100644
--- a/packages/svelte/src/runtime/App.svelte
+++ b/packages/svelte/src/runtime/App.svelte
@@ -1,6 +1,7 @@
+
+
+
{#if preloadLink && ownsPreload}
{/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-preloads.ts b/packages/svelte/src/runtime/image-preloads.ts
new file mode 100644
index 000000000..00c9532d5
--- /dev/null
+++ b/packages/svelte/src/runtime/image-preloads.ts
@@ -0,0 +1,71 @@
+/**
+ * Context key of the `ImagePreloads` of one render root. `App.svelte`, island
+ * wrappers and island hydration each provide a fresh map.
+ */
+export const IMAGE_PRELOADS_KEY = '__void_image_preloads';
+
+/** An `Image` that wants a preload link. */
+export type ImagePreloadClaim = {
+ /** The preload key this `Image` claimed. */
+ key?: string;
+ /** Called when this `Image` becomes the one that renders the link for its key. */
+ promote(): void;
+};
+
+/**
+ * The `Image`s of one render root that want each preload link, in render
+ * order. Only the first renders the link, so a render has one preload link
+ * per image, as `next/image` gets from React.
+ */
+export type ImagePreloads = Map
>;
+
+/**
+ * Claims `key` for `claim`, releasing the key it held before. Returns whether
+ * `claim` renders the preload link: it is the first claim for `key`, or there
+ * is no `preloads` to share links through.
+ */
+export function claimImagePreload(
+ preloads: ImagePreloads | undefined,
+ claim: ImagePreloadClaim,
+ key: string | undefined,
+): boolean {
+ if (!preloads) {
+ return true;
+ }
+ if (claim.key !== key) {
+ releaseImagePreload(preloads, claim);
+ if (key !== undefined) {
+ const claims = preloads.get(key);
+ if (claims) {
+ claims.push(claim);
+ } else {
+ preloads.set(key, [claim]);
+ }
+ claim.key = key;
+ }
+ }
+ return key !== undefined && preloads.get(key)?.[0] === claim;
+}
+
+/** Releases the key `claim` holds; the next claim for that key renders the link. */
+export function releaseImagePreload(
+ preloads: ImagePreloads | undefined,
+ claim: ImagePreloadClaim,
+): void {
+ const key = claim.key;
+ if (!preloads || key === undefined) {
+ return;
+ }
+ claim.key = undefined;
+ const claims = preloads.get(key);
+ const index = claims ? claims.indexOf(claim) : -1;
+ if (!claims || index === -1) {
+ return;
+ }
+ claims.splice(index, 1);
+ if (claims.length === 0) {
+ preloads.delete(key);
+ } else if (index === 0) {
+ claims[0].promote();
+ }
+}
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..851c5d2d2 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,13 @@ 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, ImgAttributes as ImageAttributes } from './image.ts';
+export type {
+ ImageLoader,
+ ImageLoaderProps,
+ PlaceholderValue,
+ StaticImageData,
+ StaticImport,
+} from 'void/image';
export type { DeferredState as Deferred, InferProps } from 'void';
diff --git a/packages/svelte/test/unit/image-preloads.test.ts b/packages/svelte/test/unit/image-preloads.test.ts
new file mode 100644
index 000000000..6516a1688
--- /dev/null
+++ b/packages/svelte/test/unit/image-preloads.test.ts
@@ -0,0 +1,79 @@
+import { describe, expect, it } from 'vite-plus/test';
+import {
+ claimImagePreload,
+ releaseImagePreload,
+ type ImagePreloadClaim,
+ type ImagePreloads,
+} from '../../src/runtime/image-preloads.ts';
+
+function createClaim(): ImagePreloadClaim & { promotions: number } {
+ return {
+ promotions: 0,
+ promote() {
+ this.promotions++;
+ },
+ };
+}
+
+describe('image preload claims', () => {
+ it('lets the first claim for a key render the link', () => {
+ const preloads: ImagePreloads = new Map();
+ const first = createClaim();
+ const second = createClaim();
+ const other = createClaim();
+ expect(claimImagePreload(preloads, first, 'a')).toBe(true);
+ expect(claimImagePreload(preloads, second, 'a')).toBe(false);
+ expect(claimImagePreload(preloads, other, 'b')).toBe(true);
+ // Claiming the same key again changes nothing.
+ expect(claimImagePreload(preloads, first, 'a')).toBe(true);
+ expect(claimImagePreload(preloads, second, 'a')).toBe(false);
+ expect(first.promotions + second.promotions + other.promotions).toBe(0);
+ });
+
+ it('promotes the next claim when the first is released', () => {
+ const preloads: ImagePreloads = new Map();
+ const first = createClaim();
+ const second = createClaim();
+ const third = createClaim();
+ claimImagePreload(preloads, first, 'a');
+ claimImagePreload(preloads, second, 'a');
+ claimImagePreload(preloads, third, 'a');
+
+ // Releasing a claim that does not render the link promotes nobody.
+ releaseImagePreload(preloads, second);
+ expect(second.promotions + third.promotions).toBe(0);
+
+ releaseImagePreload(preloads, first);
+ expect(third.promotions).toBe(1);
+ expect(claimImagePreload(preloads, third, 'a')).toBe(true);
+
+ releaseImagePreload(preloads, third);
+ expect(preloads.size).toBe(0);
+ // Releasing twice is harmless.
+ releaseImagePreload(preloads, third);
+ });
+
+ it('releases the previous key when a claim changes key', () => {
+ const preloads: ImagePreloads = new Map();
+ const first = createClaim();
+ const second = createClaim();
+ claimImagePreload(preloads, first, 'a');
+ claimImagePreload(preloads, second, 'a');
+
+ expect(claimImagePreload(preloads, first, 'b')).toBe(true);
+ expect(second.promotions).toBe(1);
+ expect(claimImagePreload(preloads, second, 'a')).toBe(true);
+
+ // Dropping the key (no preload) releases it too.
+ expect(claimImagePreload(preloads, first, undefined)).toBe(false);
+ expect(preloads.has('b')).toBe(false);
+ });
+
+ it('renders every link without a preload context', () => {
+ const claim = createClaim();
+ expect(claimImagePreload(undefined, claim, 'a')).toBe(true);
+ expect(claimImagePreload(undefined, createClaim(), 'a')).toBe(true);
+ releaseImagePreload(undefined, claim);
+ expect(claim.promotions).toBe(0);
+ });
+});
diff --git a/packages/svelte/test/unit/image.test.ts b/packages/svelte/test/unit/image.test.ts
new file mode 100644
index 000000000..b9a01a179
--- /dev/null
+++ b/packages/svelte/test/unit/image.test.ts
@@ -0,0 +1,563 @@
+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 { islandsPlugin } from '../../src/plugin-islands.ts';
+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(', ');
+}
+
+const IMAGE_SOURCE = join(PACKAGE_DIR, 'src/runtime/Image.svelte');
+
+/** Preloaded `Image`s: the same image three times (once with `priority`) and another image. */
+const PRELOAD_PAGE = `
+
+
+
+
+
+
+`;
+
+const ISLAND_IMAGES = `
+
+
+`;
+
+let Image: Component;
+let App: Component>;
+let PreloadPage: Component;
+
+/** The island wrapper `plugin-islands` generates for `islandFile`. */
+function islandWrapper(islandFile: string): string {
+ const plugin = islandsPlugin(join(TMP, 'pages'))[0];
+ const load = plugin.load as { handler: (id: string) => string };
+ return load.handler(
+ `/@void-island-wrap/Island.svelte?src=${encodeURIComponent(islandFile)}&island=Island&hydrate=load`,
+ );
+}
+
+beforeAll(async () => {
+ mkdirSync(TMP, { recursive: true });
+ const islandFile = join(TMP, 'IslandImages.svelte');
+ writeFileSync(islandFile, ISLAND_IMAGES);
+ writeFileSync(join(TMP, 'island-wrapper.svelte'), islandWrapper(islandFile));
+ writeFileSync(join(TMP, 'PreloadPage.svelte'), PRELOAD_PAGE);
+
+ // The unit project has no Svelte plugin: compile the shipped components 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: true,
+ write: false,
+ rollupOptions: {
+ input: {
+ Image: IMAGE_SOURCE,
+ App: join(PACKAGE_DIR, 'src/runtime/App.svelte'),
+ PreloadPage: join(TMP, 'PreloadPage.svelte'),
+ },
+ external: [/^svelte($|\/)/, /^void($|\/)/],
+ },
+ },
+ })) as Rolldown.RolldownOutput;
+ const out = join(TMP, 'out');
+ const entries: Record = {};
+ for (const item of output.output) {
+ if (item.type === 'chunk') {
+ const file = join(out, item.fileName);
+ mkdirSync(join(file, '..'), { recursive: true });
+ writeFileSync(file, item.code);
+ if (item.isEntry) {
+ entries[item.name] = pathToFileURL(file).href;
+ }
+ }
+ }
+ const load = async (name: string) => ((await import(entries[name])) as { default: T }).default;
+ Image = await load>('Image');
+ App = await load>>('App');
+ PreloadPage = await load('PreloadPage');
+}, 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('emits one preload link per image in a page, and per island', () => {
+ const { body, head } = render(App, {
+ props: {
+ page: PreloadPage,
+ layouts: [],
+ pageProps: {},
+ shared: {},
+ params: {},
+ router: null,
+ stores: null,
+ },
+ });
+ expect(tags(body, 'img')).toHaveLength(8);
+ const links = tags(head, 'link').map((link) => Object.fromEntries(link).imagesrcset);
+ const logo =
+ '/_void/image?url=%2Flogo.png&w=64&q=75 1x, /_void/image?url=%2Flogo.png&w=128&q=75 2x';
+ const hero =
+ '/_void/image?url=%2Fhero.png&w=64&q=75 1x, /_void/image?url=%2Fhero.png&w=128&q=75 2x';
+ // Islands hydrate on their own, so each keeps its own links.
+ expect(links).toEqual([logo, hero, logo, logo]);
+ });
+
+ 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/svelte/test/unit/plugin-islands.test.ts b/packages/svelte/test/unit/plugin-islands.test.ts
index 757abc4c8..6bea4ab88 100644
--- a/packages/svelte/test/unit/plugin-islands.test.ts
+++ b/packages/svelte/test/unit/plugin-islands.test.ts
@@ -5,6 +5,7 @@ import { compile } from 'svelte/compiler';
import { build, normalizePath, type Rolldown } from 'vite';
import { describe, expect, it, beforeEach, afterEach } from 'vite-plus/test';
import { islandsPlugin } from '../../src/plugin-islands.ts';
+import { IMAGE_PRELOADS_KEY } from '../../src/runtime/image-preloads.ts';
const TMP = join(import.meta.dirname, '__tmp_islands_plugin__');
const PAGES_DIR = join(TMP, 'pages');
@@ -172,6 +173,26 @@ import Sidebar from "./Sidebar.svelte" with { island: "load" }
expect(clientEntry).not.toContain('Sidebar');
});
+
+ it('hydrates each island with its own image preload context, as its server wrapper renders', async () => {
+ writeFile(
+ 'index.island.svelte',
+ `
+ `,
+ );
+ writeFile('Counter.svelte', `0 `);
+
+ 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 7f8a2ec70..255533b07 100644
--- a/packages/void/AGENTS.md
+++ b/packages/void/AGENTS.md
@@ -133,6 +133,18 @@ Two features need extra wiring on a self-hosted `vite build && wrangler deploy`
- **`ISR_CACHE` self-host requirement** — self-host ISR (`@void/isr/runtime`, `runtime/isr.ts`) reads `env.ISR_CACHE` (a `KVNamespace`) + `env.__VOID_ISR_DEPLOYMENT_ID`. Unlike D1/KV/R2, `ISR_CACHE` is **not** auto-injected (a KV binding needs a real namespace id), so the user must create the namespace and declare it as `ISR_CACHE` in `cloudflare.kv_namespaces`. `routing.revalidate` (void.config.ts) and per-page `export const revalidate` are merged by `collectRevalidate` (`isr-config.ts`) and baked as `__VOID_ISR_REVALIDATE`. On-demand `revalidate()` on self-host purges the worker's own KV (global) plus the local-colo `caches.default` edge variants only.
- **Framework ISR on direct deploy** — `void deploy --platform cloudflare` wraps each supported framework Worker when `routing.revalidate` is active. Its ISR runtime is bundled as `__void_isr.mjs` beside the Worker entry because Class A output uses `no_bundle: true`; keep the import relative and exclude the module from co-located static assets. Only HTML document requests and responses enter this cache. Managed framework deploys use platform dispatch instead.
+### Image Optimization
+
+`Image`/`getImageProps` in the Pages adapters, `void/image`, and the `/_void/image` endpoint follow `next/image` and `/_next/image` from Next.js 16. User docs: [Images](../../docs/guide/images.md).
+
+- **Config transport**: `imagePlugin` (`src/plugin-image.ts`) resolves bare `void/image` imports to the package's own entry (so an adapter's nested void copy reads the same config), prepends `import "virtual:void/image-config"` to that entry in every environment (matched by real path, query stripped) and excludes `void/image` from `optimizeDeps` everywhere 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()`, 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. Builds import the file with `?no-inline` (dev would keep that query in the URL), so a small file is never a data URL and keeps its fingerprinted name. 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`.
+- **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
[package.json](./package.json) is the authoritative export map and declares
@@ -171,6 +183,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. `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`.
## Coding Agent Integration
diff --git a/packages/void/package.json b/packages/void/package.json
index 8b76fa47c..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",
@@ -376,6 +382,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 +393,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/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/skills/void/SKILL.md b/packages/void/skills/void/SKILL.md
index 3b380b312..097ee493f 100644
--- a/packages/void/skills/void/SKILL.md
+++ b/packages/void/skills/void/SKILL.md
@@ -43,6 +43,7 @@ the app's existing saved destination, including when the target project is new.
| Authentication | `docs/guide/auth.md`, `docs/guide/env-vars.md` |
| Cloudflare runtime bindings and config | `docs/integrations/cloudflare.md`, `docs/reference/config.md`, `docs/guide/env-vars.md` |
| AI inference (Workers AI, providers) | `docs/guide/ai.md` |
+| Images, `Image`, `getImageProps` | `docs/guide/images.md`, `docs/reference/config.md` |
| Durable State and WebSockets | `docs/guide/durable-state.md`, `docs/guide/websockets.md` |
| Server-sent events and live streams | `docs/guide/sse.md`, `docs/guide/live.md` |
| Sandboxes | `docs/guide/sandboxes.md` |
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/asset-routing.ts b/packages/void/src/asset-routing.ts
index 58961205f..20d9f8b67 100644
--- a/packages/void/src/asset-routing.ts
+++ b/packages/void/src/asset-routing.ts
@@ -19,6 +19,12 @@ export type AssetRoutingInputs = {
* changes. `workerSpaFallback` DOES follow it — see below.
*/
notFound?: NotFoundHandling;
+ /**
+ * The image optimizer path (`images.path`) when the generated routes mount
+ * it. SPA policies send it to the Worker first, so the SPA shell never
+ * answers it.
+ */
+ imagePath?: string;
};
export type AssetRoutingPolicy = {
@@ -73,16 +79,19 @@ export function resolveAssetRoutingPolicy(inputs: AssetRoutingInputs): AssetRout
const workerFirst = workerOwnsHtmlRouting(inputs);
const apiWorkerFirst =
!workerFirst && (inputs.hasApiRoutes === true || inputs.authEnabled === true);
+ const internalWorkerFirst = inputs.imagePath
+ ? [...VOID_INTERNAL_WORKER_FIRST, inputs.imagePath]
+ : VOID_INTERNAL_WORKER_FIRST;
const assetConfig: AssetConfig = workerFirst
? { not_found_handling: 'none', run_worker_first: ['/**'] }
: apiWorkerFirst
? {
not_found_handling: 'single-page-application',
- run_worker_first: ['/api', '/api/*', ...VOID_INTERNAL_WORKER_FIRST],
+ run_worker_first: ['/api', '/api/*', ...internalWorkerFirst],
}
: {
not_found_handling: 'single-page-application',
- run_worker_first: [...VOID_INTERNAL_WORKER_FIRST],
+ run_worker_first: [...internalWorkerFirst],
};
// Explicit `routing.notFound` wins over the inferred value. Applied here (on
// the inputs) rather than at the call sites so dev, build, and deploy can't
diff --git a/packages/void/src/cli/deploy-artifact-validation.ts b/packages/void/src/cli/deploy-artifact-validation.ts
index 3e57961dc..8ca9e343f 100644
--- a/packages/void/src/cli/deploy-artifact-validation.ts
+++ b/packages/void/src/cli/deploy-artifact-validation.ts
@@ -21,6 +21,7 @@ import {
isEmptyPassthroughValue,
verifyNoInjectedExternalDurableObject,
DEFAULT_VOID_AI_BINDING,
+ DEFAULT_VOID_IMAGES_BINDING,
verifyNoInjectedDestructiveMigration,
normalizeContainerImagePaths,
collectEmittedQueueGroupNames,
@@ -149,12 +150,16 @@ export function verifyGeneratedDeployConfig(
bindingIdentitySnapshot?: BindingIdentitySnapshot;
/** Pre-build entries for binding fields Void never emits. */
passthroughIdentitySnapshot?: Map>>;
- /** Pre-build raw values for whole-object fields, `durable_objects`, `ai` and `migrations`. */
+ /** Pre-build raw values for whole-object fields, `durable_objects`, `ai`, `images` and `migrations`. */
passthroughRawFields?: Map;
/** Whether the source uses Workers AI; only then may the build add Void's AI binding. */
needsAI?: boolean;
/** Custom `inference.bindings.ai` name authorized for Void's emitted AI binding. */
aiBindingName?: string;
+ /** Whether the app needs Cloudflare Images; only then may the build add Void's Images binding. */
+ needsImages?: boolean;
+ /** Custom `inference.bindings.images` name authorized for Void's emitted Images binding. */
+ imagesBindingName?: string;
/** Require BETTER_AUTH_SECRET in `secrets.required` so the upload keeps the remote value. */
authEnabled?: boolean;
/** Additional Worker secret names the generated version must inherit. */
@@ -403,6 +408,28 @@ export function verifyGeneratedDeployConfig(
};
}
}
+ const emittedImages = generated.images;
+ const rootImages = raw.get('images');
+ if (rootImages !== undefined) {
+ // A root images binding must survive unchanged; removing it fails too.
+ if (!deepEqual(emittedImages, rootImages)) {
+ return {
+ ok: false,
+ reason: `generated config images binding does not match the pre-build root images binding — refusing to deploy an images binding the build removed or rewrote.`,
+ };
+ }
+ } else if (emittedImages !== undefined) {
+ // Otherwise only Void's own Images binding, and only when the app needs it.
+ const expectedImages = {
+ binding: expected.imagesBindingName ?? DEFAULT_VOID_IMAGES_BINDING,
+ };
+ if (!(expected.needsImages === true && deepEqual(emittedImages, expectedImages))) {
+ return {
+ ok: false,
+ reason: `generated config declares an images binding the build injected — the pre-build inference found no Image or env.IMAGES use in the source and the root config declares no images binding.`,
+ };
+ }
+ }
// Void emits only creation migrations; a destructive one must come from the root config.
const destructiveMigration = verifyNoInjectedDestructiveMigration(
generated.migrations,
diff --git a/packages/void/src/cli/deploy-binding-snapshot.ts b/packages/void/src/cli/deploy-binding-snapshot.ts
index 00a6a41ff..fb2c0fd90 100644
--- a/packages/void/src/cli/deploy-binding-snapshot.ts
+++ b/packages/void/src/cli/deploy-binding-snapshot.ts
@@ -123,6 +123,7 @@ export const DEPLOY_PASSTHROUGH_EXCLUDE_FIELDS: ReadonlySet = new Set = [
/**
* Root values captured for {@link verifyGeneratedDeployConfig}: the whole-object fields plus
- * `durable_objects`, `ai` and `migrations`, which Void emits but a build could still tamper with.
+ * `durable_objects`, `ai`, `images` and `migrations`, which Void emits but a build could still
+ * tamper with.
*/
const DEPLOY_RAW_SNAPSHOT_FIELDS: ReadonlyArray = [
...DEPLOY_WHOLE_OBJECT_PINNED_FIELDS,
'durable_objects',
'ai',
+ 'images',
'migrations',
];
/** Default Workers AI binding name that Void emits. */
export const DEFAULT_VOID_AI_BINDING = 'AI';
+/** Default Cloudflare Images binding name that Void emits. */
+export const DEFAULT_VOID_IMAGES_BINDING = 'IMAGES';
+
/** `durable_objects` sub-fields that bind another Worker's Durable Objects. Void never emits them. */
const DURABLE_OBJECT_EXTERNAL_FIELDS = ['script_name', 'environment'];
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-migrate.ts b/packages/void/src/cli/deploy-cloudflare-migrate.ts
index 768015fa5..66f23ae39 100644
--- a/packages/void/src/cli/deploy-cloudflare-migrate.ts
+++ b/packages/void/src/cli/deploy-cloudflare-migrate.ts
@@ -43,6 +43,8 @@ export async function migrateCloudflareWorker(input: {
desired,
needsAI,
aiBindingName,
+ needsImages,
+ imagesBindingName,
expectedCrons,
dialect,
migrationsDir,
@@ -72,6 +74,8 @@ export async function migrateCloudflareWorker(input: {
passthroughRawFields,
needsAI,
aiBindingName,
+ needsImages,
+ imagesBindingName,
authEnabled,
expectedContainers,
expectedDurableObjectBindings: expectedDurableObjectState.bindings,
diff --git a/packages/void/src/cli/deploy-cloudflare-preflight.ts b/packages/void/src/cli/deploy-cloudflare-preflight.ts
index 38d21f492..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(
@@ -195,6 +195,8 @@ export async function preflightCloudflareWorker(input: {
emailHandlers,
authEnabled,
needsAI,
+ needsImages,
+ imagesBindingName,
expectedCrons,
desired,
migrations: preBuildMigrations,
@@ -346,6 +348,7 @@ export async function preflightCloudflareWorker(input: {
authEnabled ||
needsSandbox ||
needsAI ||
+ 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 c6aad32ce..fd1c593ab 100644
--- a/packages/void/src/cli/deploy-managed-full.ts
+++ b/packages/void/src/cli/deploy-managed-full.ts
@@ -1,6 +1,7 @@
import { execSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'pathslash';
+import { isImageOptimizerEnabled } from '../application-features.ts';
import { analyzeApplication } from '../application-routing.ts';
import {
hasApiRoutes as routeScanHasApiRoutes,
@@ -8,6 +9,7 @@ import {
resolveAssetConfig,
} from '../asset-routing.ts';
import { type VoidConfig, isNodeTarget } from '../config.ts';
+import { type InferredBindings } from '../inference/walk.ts';
import { collectRevalidate } from '../isr-config.ts';
import { voidWarn } from '../log.ts';
import { type DetectedFramework } from '../plugin-inference.ts';
@@ -63,6 +65,26 @@ import type { AssetConfig } from './types.ts';
import type { WrapperFeatures } from './wrapper.ts';
import { needsWrapper } from './wrapper.ts';
+/**
+ * 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,
+ voidConfig: Pick,
+ detected: DetectedFramework | undefined,
+): string | undefined {
+ 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;
+}
+
export async function runFullDeploy(
root: string,
config: ProjectConfig,
@@ -187,6 +209,10 @@ export async function runFullDeploy(
log.error('deploy: void/live is currently supported in Void apps only.');
process.exit(1);
}
+ const imageOptimizerWarning = managedImageOptimizerWarning(bindings, voidConfig, detected);
+ if (imageOptimizerWarning) {
+ log.warn(imageOptimizerWarning);
+ }
const sandboxConfig = bindings.needsSandbox ? resolveSandboxConfig(voidConfig, root) : undefined;
if (bindings.needsSandbox && requiresSandboxPlatformImage(voidConfig)) {
diff --git a/packages/void/src/cli/deploy-resources.ts b/packages/void/src/cli/deploy-resources.ts
index 9bee0675f..43ce5be00 100644
--- a/packages/void/src/cli/deploy-resources.ts
+++ b/packages/void/src/cli/deploy-resources.ts
@@ -11,7 +11,7 @@ import { MATERIALIZED_CLOUDFLARE_FILE } from '../cloudflare-config.ts';
import { type DatabaseDialect, type VoidConfig } from '../config.ts';
import { collectRevalidate, hasRevalidate } from '../isr-config.ts';
import { hasEmailHandlers, type EmailHandlersScan } from '../router/email.ts';
-import { DEFAULT_VOID_AI_BINDING } from './deploy-binding-snapshot.ts';
+import { DEFAULT_VOID_AI_BINDING, DEFAULT_VOID_IMAGES_BINDING } from './deploy-binding-snapshot.ts';
import { type CloudflareAuthSecretFailureReason } from './deploy-types.ts';
import {
isRealD1Id,
@@ -194,6 +194,10 @@ export async function computeDesiredCloudflareResources(
needsAI: boolean;
/** Workers AI binding emitted by the build (default `AI`). */
aiBindingName: string;
+ /** `Image` or `env.IMAGES` use, which authorizes the emitted Images binding. */
+ needsImages: boolean;
+ /** Cloudflare Images binding emitted by the build (default `IMAGES`). */
+ imagesBindingName: string;
/** `void.config.ts` enables Sandbox or the source imports `void/sandbox`. */
needsSandbox: boolean;
/** Whether the source imports void/live and the build may emit its Durable Object. */
@@ -310,6 +314,8 @@ export async function computeDesiredCloudflareResources(
migrations,
needsAI: bindings.needsAI,
aiBindingName: bindingNames?.ai ?? DEFAULT_VOID_AI_BINDING,
+ needsImages: bindings.needsImages === true,
+ imagesBindingName: bindingNames?.images ?? DEFAULT_VOID_IMAGES_BINDING,
expectedCrons,
needsEmail: bindings.needsEmail === true || hasEmailHandlers(emailHandlers),
emailHandlers,
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/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts
index 22e4fcfec..a71332c8a 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,37 @@ 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 {
+ return (
+ needsImages === true &&
+ config.images === undefined &&
+ findImagesBindingOwner(config, name) === undefined
+ );
+}
+
+/** The label of another binding in `config` that uses the Images binding name, if any. */
+export function findImagesBindingOwner(
+ config: Record,
+ name: string,
+): string | undefined {
+ try {
+ return findBindingNameOwner(config, 'images', name);
+ } catch {
+ // A malformed field is Wrangler's to report; this check only names a collision.
+ return undefined;
+ }
+}
+
/**
* 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/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..b10730449
--- /dev/null
+++ b/packages/void/src/image-config.ts
@@ -0,0 +1,628 @@
+/**
+ * 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 { isAbsolute, join, resolve } from 'pathslash';
+import picomatch from 'picomatch';
+import { ascending } from './image/client-config.ts';
+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'];
+
+/** Vite's default `build.assetsDir`, where static `?image` imports are emitted. */
+const DEFAULT_ASSETS_DIR = 'assets';
+
+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;
+ /**
+ * 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). */
+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;
+}
+
+/** 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 set `noOptimizer` 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 = resolveLocalPatterns(
+ merged.localPatterns,
+ options.assetsDir ?? DEFAULT_ASSETS_DIR,
+ );
+
+ 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".`,
+ );
+ }
+ loaderFile = resolveLoaderFile(options.root, merged.loaderFile);
+ }
+
+ const optimizerUnavailable =
+ (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);
+
+ const resolved: ResolvedImageConfig = {
+ 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: optimizerUnavailable && !customLoader,
+ dev: options.dev,
+ };
+ localPatternSources.set(resolved, {
+ user: merged.localPatterns,
+ count: resolved.localPatterns.length,
+ });
+ return resolved;
+}
+
+/** `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 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 &&
+ 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
+ * 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 getImageOptimizerPath(images?.path ?? DEFAULT_IMAGE_PATH) !== undefined;
+}
diff --git a/packages/void/src/image-size.ts b/packages/void/src/image-size.ts
new file mode 100644
index 000000000..899c000bd
--- /dev/null
+++ b/packages/void/src/image-size.ts
@@ -0,0 +1,361 @@
+/**
+ * Intrinsic dimensions of an image file, read from its header the way the
+ * `image-size` package (used by Next.js static image imports) reads them.
+ * Pure: takes the file's bytes, has no dependencies, and never decodes pixels.
+ */
+import { SVG_ROOT } from './image/optimizer/content-type.ts';
+
+/** Formats `readImageSize` recognizes. */
+export type ImageSizeType =
+ | 'png'
+ | 'jpeg'
+ | 'gif'
+ | 'webp'
+ | 'avif'
+ | 'heif'
+ | 'svg'
+ | 'bmp'
+ | 'ico';
+
+/** The intrinsic size of an image, in CSS pixels for SVG. */
+export type ImageSize = { width: number; height: number; type: ImageSizeType };
+
+const textDecoder = new TextDecoder();
+
+function ascii(bytes: Uint8Array, start: number, end: number): string {
+ return end <= bytes.length ? textDecoder.decode(bytes.subarray(start, end)) : '';
+}
+
+function u16be(bytes: Uint8Array, offset: number): number {
+ return (bytes[offset]! << 8) | bytes[offset + 1]!;
+}
+
+function u16le(bytes: Uint8Array, offset: number): number {
+ return bytes[offset]! | (bytes[offset + 1]! << 8);
+}
+
+function u24le(bytes: Uint8Array, offset: number): number {
+ return bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16);
+}
+
+function u32be(bytes: Uint8Array, offset: number): number {
+ return bytes[offset]! * 2 ** 24 + bytes[offset + 1]! * 2 ** 16 + u16be(bytes, offset + 2);
+}
+
+function u32le(bytes: Uint8Array, offset: number): number {
+ return u16le(bytes, offset) + bytes[offset + 2]! * 2 ** 16 + bytes[offset + 3]! * 2 ** 24;
+}
+
+function i32le(bytes: Uint8Array, offset: number): number {
+ return u16le(bytes, offset) + bytes[offset + 2]! * 2 ** 16 + (bytes[offset + 3]! << 24);
+}
+
+function has(bytes: Uint8Array, length: number): boolean {
+ return bytes.length >= length;
+}
+
+const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
+
+function readPng(bytes: Uint8Array): Omit | undefined {
+ if (!has(bytes, 24) || !PNG_SIGNATURE.every((byte, index) => bytes[index] === byte)) {
+ return undefined;
+ }
+ // Apple's CgBI PNGs put a CgBI chunk before IHDR.
+ const ihdr = ascii(bytes, 12, 16) === 'CgBI' ? 28 : 12;
+ if (ascii(bytes, ihdr, ihdr + 4) !== 'IHDR' || !has(bytes, ihdr + 12)) {
+ return undefined;
+ }
+ return { width: u32be(bytes, ihdr + 4), height: u32be(bytes, ihdr + 8) };
+}
+
+/** SOF0-SOF15 except DHT (C4), JPG (C8) and DAC (CC). */
+function isStartOfFrame(marker: number): boolean {
+ return marker >= 0xc0 && marker <= 0xcf && marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
+}
+
+function readJpeg(bytes: Uint8Array): Omit | undefined {
+ if (!has(bytes, 4) || bytes[0] !== 0xff || bytes[1] !== 0xd8) {
+ return undefined;
+ }
+ let offset = 2;
+ while (offset + 3 < bytes.length) {
+ if (bytes[offset] !== 0xff) {
+ // Skip padding between segments.
+ offset += 1;
+ continue;
+ }
+ const marker = bytes[offset + 1]!;
+ if (marker === 0xff) {
+ // Fill byte before a marker.
+ offset += 1;
+ continue;
+ }
+ if (marker === 0x01 || (marker >= 0xd0 && marker <= 0xd8)) {
+ // Markers without a length.
+ offset += 2;
+ continue;
+ }
+ if (marker === 0xd9 || marker === 0xda) {
+ // End of image or start of scan before any frame header.
+ return undefined;
+ }
+ const length = u16be(bytes, offset + 2);
+ if (length < 2) {
+ return undefined;
+ }
+ if (isStartOfFrame(marker)) {
+ if (!has(bytes, offset + 9)) {
+ return undefined;
+ }
+ // EXIF orientation is not applied, as in `image-size` (Next.js).
+ return { height: u16be(bytes, offset + 5), width: u16be(bytes, offset + 7) };
+ }
+ offset += 2 + length;
+ }
+ return undefined;
+}
+
+function readGif(bytes: Uint8Array): Omit | undefined {
+ const signature = ascii(bytes, 0, 6);
+ if ((signature !== 'GIF87a' && signature !== 'GIF89a') || !has(bytes, 10)) {
+ return undefined;
+ }
+ return { width: u16le(bytes, 6), height: u16le(bytes, 8) };
+}
+
+function readWebp(bytes: Uint8Array): Omit | undefined {
+ if (ascii(bytes, 0, 4) !== 'RIFF' || ascii(bytes, 8, 12) !== 'WEBP' || !has(bytes, 30)) {
+ return undefined;
+ }
+ const chunk = ascii(bytes, 12, 16);
+ if (chunk === 'VP8X') {
+ // Canvas size, stored minus one.
+ return { width: 1 + u24le(bytes, 24), height: 1 + u24le(bytes, 27) };
+ }
+ if (chunk === 'VP8 ') {
+ // Key frame start code, then 14-bit dimensions.
+ if (bytes[23] !== 0x9d || bytes[24] !== 0x01 || bytes[25] !== 0x2a) {
+ return undefined;
+ }
+ return { width: u16le(bytes, 26) & 0x3fff, height: u16le(bytes, 28) & 0x3fff };
+ }
+ if (chunk === 'VP8L') {
+ if (bytes[20] !== 0x2f) {
+ return undefined;
+ }
+ const b1 = bytes[21]!;
+ const b2 = bytes[22]!;
+ const b3 = bytes[23]!;
+ const b4 = bytes[24]!;
+ return {
+ width: 1 + (((b2 & 0x3f) << 8) | b1),
+ height: 1 + (((b4 & 0x0f) << 10) | (b3 << 2) | ((b2 & 0xc0) >> 6)),
+ };
+ }
+ return undefined;
+}
+
+type Box = { type: string; start: number; contentStart: number; end: number };
+
+/** ISO-BMFF boxes between `start` and `end`. */
+function* boxes(bytes: Uint8Array, start: number, end: number): Generator {
+ let offset = start;
+ while (offset + 8 <= end) {
+ let size = u32be(bytes, offset);
+ const type = ascii(bytes, offset + 4, offset + 8);
+ let header = 8;
+ if (size === 1) {
+ // 64-bit `largesize`.
+ if (offset + 16 > end) {
+ return;
+ }
+ size = u32be(bytes, offset + 8) * 2 ** 32 + u32be(bytes, offset + 12);
+ header = 16;
+ } else if (size === 0) {
+ // The box extends to the end of the file.
+ size = end - offset;
+ }
+ if (size < header || offset + size > end) {
+ return;
+ }
+ yield { type, start: offset, contentStart: offset + header, end: offset + size };
+ offset += size;
+ }
+}
+
+function findBox(bytes: Uint8Array, start: number, end: number, type: string): Box | undefined {
+ for (const box of boxes(bytes, start, end)) {
+ if (box.type === type) {
+ return box;
+ }
+ }
+ return undefined;
+}
+
+const HEIF_BRANDS: Record = {
+ avif: 'avif',
+ avis: 'avif',
+ mif1: 'heif',
+ msf1: 'heif',
+ heic: 'heif',
+ heix: 'heif',
+ hevc: 'heif',
+ hevx: 'heif',
+};
+
+function readHeif(bytes: Uint8Array): ImageSize | undefined {
+ if (ascii(bytes, 4, 8) !== 'ftyp') {
+ return undefined;
+ }
+ const type = HEIF_BRANDS[ascii(bytes, 8, 12)];
+ if (!type) {
+ return undefined;
+ }
+ const meta = findBox(bytes, 0, bytes.length, 'meta');
+ // `meta` and `ispe` are full boxes: version and flags precede their content.
+ const iprp = meta && findBox(bytes, meta.contentStart + 4, meta.end, 'iprp');
+ const ipco = iprp && findBox(bytes, iprp.contentStart, iprp.end, 'ipco');
+ if (!ipco) {
+ return undefined;
+ }
+ // Use the largest `ispe`: grid images list their tiles and thumbnails too,
+ // and the primary image is the full-size one. `irot` is not applied, as in
+ // `image-size` (Next.js).
+ let size: ImageSize | undefined;
+ for (const box of boxes(bytes, ipco.contentStart, ipco.end)) {
+ if (box.type !== 'ispe' || box.contentStart + 12 > box.end) {
+ continue;
+ }
+ const width = u32be(bytes, box.contentStart + 4);
+ const height = u32be(bytes, box.contentStart + 8);
+ if (!size || width * height > size.width * size.height) {
+ size = { width, height, type };
+ }
+ }
+ return size;
+}
+
+function readBmp(bytes: Uint8Array): Omit | undefined {
+ if (ascii(bytes, 0, 2) !== 'BM' || !has(bytes, 26)) {
+ return undefined;
+ }
+ // OS/2 BITMAPCOREHEADER stores 16-bit dimensions.
+ if (u32le(bytes, 14) === 12) {
+ return { width: u16le(bytes, 18), height: u16le(bytes, 20) };
+ }
+ // Height is negative for top-down bitmaps.
+ return { width: u32le(bytes, 18), height: Math.abs(i32le(bytes, 22)) };
+}
+
+function readIco(bytes: Uint8Array): Omit | undefined {
+ if (!has(bytes, 6)) {
+ return undefined;
+ }
+ const reserved = u16le(bytes, 0);
+ const kind = u16le(bytes, 2);
+ const count = u16le(bytes, 4);
+ // 1 = icon, 2 = cursor.
+ if (reserved !== 0 || (kind !== 1 && kind !== 2) || count === 0 || !has(bytes, 6 + count * 16)) {
+ return undefined;
+ }
+ // The largest entry; a stored 0 means 256 pixels.
+ let size: Omit | undefined;
+ for (let index = 0; index < count; index++) {
+ const entry = 6 + index * 16;
+ const width = bytes[entry] || 256;
+ const height = bytes[entry + 1] || 256;
+ if (!size || width * height > size.width * size.height) {
+ size = { width, height };
+ }
+ }
+ return size;
+}
+
+const SVG_WIDTH = /\swidth=(['"])([^%]+?)\1/;
+const SVG_HEIGHT = /\sheight=(['"])([^%]+?)\1/;
+const SVG_VIEW_BOX = /\sviewBox=(['"])(.+?)\1/i;
+const CM_PER_INCH = 2.54;
+const SVG_UNITS: Record = {
+ in: 96,
+ cm: 96 / CM_PER_INCH,
+ em: 16,
+ ex: 8,
+ m: (96 / CM_PER_INCH) * 100,
+ mm: 96 / CM_PER_INCH / 10,
+ pc: 96 / 72 / 12,
+ pt: 96 / 72,
+ px: 1,
+};
+const SVG_LENGTH = new RegExp(`^([0-9.]+(?:e\\d+)?)(${Object.keys(SVG_UNITS).join('|')})?$`);
+
+function parseSvgLength(value: string): number | undefined {
+ const match = SVG_LENGTH.exec(value.trim());
+ return match ? Math.round(Number(match[1]) * (SVG_UNITS[match[2] ?? 'px'] ?? 1)) : undefined;
+}
+
+function readSvg(bytes: Uint8Array): Omit | undefined {
+ const root = SVG_ROOT.exec(textDecoder.decode(bytes))?.[0];
+ if (!root) {
+ return undefined;
+ }
+ const width = parseSvgLength(SVG_WIDTH.exec(root)?.[2] ?? '');
+ const height = parseSvgLength(SVG_HEIGHT.exec(root)?.[2] ?? '');
+ if (width && height) {
+ return { width, height };
+ }
+ const viewBox = SVG_VIEW_BOX.exec(root)?.[2]
+ ?.trim()
+ .split(/[\s,]+/);
+ if (!viewBox || viewBox.length !== 4) {
+ return undefined;
+ }
+ const boxWidth = parseSvgLength(viewBox[2]!);
+ const boxHeight = parseSvgLength(viewBox[3]!);
+ if (!boxWidth || !boxHeight) {
+ return undefined;
+ }
+ // One authored dimension keeps the viewBox aspect ratio.
+ const ratio = boxWidth / boxHeight;
+ if (width) {
+ return { width, height: Math.floor(width / ratio) };
+ }
+ if (height) {
+ return { width: Math.floor(height * ratio), height };
+ }
+ return { width: boxWidth, height: boxHeight };
+}
+
+const READERS: Array<[ImageSizeType, (bytes: Uint8Array) => Omit | undefined]> =
+ [
+ ['png', readPng],
+ ['jpeg', readJpeg],
+ ['gif', readGif],
+ ['webp', readWebp],
+ ['bmp', readBmp],
+ ['ico', readIco],
+ ];
+
+function isDimension(value: number): boolean {
+ return Number.isSafeInteger(value) && value > 0;
+}
+
+/**
+ * The width and height stored in an image's header: PNG, JPEG, GIF, WebP
+ * (VP8, VP8L, VP8X), AVIF/HEIC (`ispe`), SVG (`width`/`height` or
+ * `viewBox`), BMP and ICO. `undefined` for other or malformed files.
+ */
+export function readImageSize(bytes: Uint8Array): ImageSize | undefined {
+ let size: ImageSize | undefined = readHeif(bytes);
+ for (const [type, read] of READERS) {
+ if (size) {
+ break;
+ }
+ const found = read(bytes);
+ size = found && { ...found, type };
+ }
+ if (!size) {
+ const svg = readSvg(bytes);
+ size = svg && { ...svg, type: 'svg' };
+ }
+ return size && isDimension(size.width) && isDimension(size.height) ? size : undefined;
+}
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..c8be8c97f
--- /dev/null
+++ b/packages/void/src/image/client-config.ts
@@ -0,0 +1,55 @@
+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 (resolved.noOptimizer) {
+ config.noOptimizer = true;
+ }
+ 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;
+}
+
+export 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/config.ts b/packages/void/src/image/config.ts
new file mode 100644
index 000000000..c5550251a
--- /dev/null
+++ b/packages/void/src/image/config.ts
@@ -0,0 +1,263 @@
+/**
+ * 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
+ * `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 = {
+ 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;
+ /** `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;
+};
+
+/**
+ * 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;
+ /** `ResolvedImageConfig.noOptimizer`, present only when true. */
+ noOptimizer?: 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/get-img-props.ts b/packages/void/src/image/get-img-props.ts
new file mode 100644
index 000000000..3e6dce245
--- /dev/null
+++ b/packages/void/src/image/get-img-props.ts
@@ -0,0 +1,607 @@
+/**
+ * `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 (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';
+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 (config.noOptimizer && isDefaultLoader) {
+ // Nothing serves the default loader's URLs in this build; a `loader` prop still applies.
+ 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..dabe5f7a9
--- /dev/null
+++ b/packages/void/src/image/html.ts
@@ -0,0 +1,230 @@
+/**
+ * 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;
+}
+
+/**
+ * `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
+ * 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 = crossOriginAttribute(record);
+ if (crossorigin !== undefined) {
+ 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 += ' ';
+ }
+ return html;
+}
+
+/** Collects the preload links of one render, once per key. */
+export type ImagePreloadCollector = {
+ add(link: ImagePreloadLink): void;
+ links(): Array;
+ 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..21c7a5fdc
--- /dev/null
+++ b/packages/void/src/image/index.ts
@@ -0,0 +1,49 @@
+/**
+ * `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 {
+ imageConfigDefault,
+ IMAGE_REGISTRY_KEY,
+ type ClientImageConfig,
+ type CompiledLocalPattern,
+ type CompiledRemotePattern,
+ type ImageConfig,
+ type ImageConfigComplete,
+ type ImageFormat,
+ type ImageLoaderName,
+ type LocalPattern,
+ type RemotePattern,
+ type ResolvedImageConfig,
+} 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 } 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..a4f25b348
--- /dev/null
+++ b/packages/void/src/image/loader.ts
@@ -0,0 +1,140 @@
+/**
+ * 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,
+});
+
+/**
+ * `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 encodeWhitespace(src);
+ }
+ const params = `width=${width},quality=${findClosestQuality(quality, config)},format=auto`;
+ 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` 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/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..08498ed1a
--- /dev/null
+++ b/packages/void/src/image/optimizer/content-type.ts
@@ -0,0 +1,347 @@
+/**
+ * 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']);
+/** The opening `` tag of an SVG document (`image-size`'s validator). */
+export const SVG_ROOT: RegExp = /"']|"[^"]*"|'[^']*')*>/;
+
+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);
+ 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;
+}
+
+/**
+ * An animated WebP uses the extended format: a `VP8X` first chunk whose flags
+ * byte has the animation bit set. `is-animated` scans the whole file for an
+ * `ANIM` chunk instead, which costs a Worker's CPU budget on large sources.
+ */
+function isAnimatedWebp(bytes: Uint8Array): boolean {
+ return ascii(bytes, 12, 16) === 'VP8X' && (bytes[20] & 0x02) !== 0;
+}
+
+/**
+ * Whether a GIF has more than one image, a PNG is an APNG with frame data,
+ * or a WebP declares animation (`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..dc06b0510
--- /dev/null
+++ b/packages/void/src/image/optimizer/handler.ts
@@ -0,0 +1,409 @@
+import type { ImageFormat, ResolvedImageConfig } from '../config.ts';
+import { hasRemoteMatch } from '../patterns.ts';
+import { warnOnce } from '../warn-once.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,
+ type LocalImageDispatch,
+} 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;
+ /**
+ * 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. */
+ 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;
+ /** Source read timeout in milliseconds (default 7000, per remote redirect hop). */
+ timeoutMs?: number;
+};
+
+type ImageResult = {
+ bytes: Uint8Array;
+ contentType: string;
+ /** 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 = '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 warnedSources = new Set();
+/**
+ * One warning per source per isolate for request-derived keys (an animated
+ * source, a refused redirect origin). Bounded, unlike `warnOnce`, so requests
+ * cannot grow the set without limit.
+ */
+function warnSourceOnce(key: string, message: string): void {
+ if (warnedSources.has(key) || warnedSources.size >= 256) {
+ return;
+ }
+ warnedSources.add(key);
+ console.warn(message);
+}
+
+function warnTransformFailure(error: unknown, path: string): void {
+ const code = (error as { code?: unknown } | null)?.code;
+ if (code === 9422) {
+ warnOnce(
+ `[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(
+ 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.`,
+ `code:${typeof code === 'number' ? code : 'unknown'}`,
+ );
+}
+
+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),
+ fallback: response.headers.get(CACHE_FALLBACK_HEADER) === '1',
+ };
+ } catch {
+ return undefined;
+ }
+}
+
+function writeCache(
+ options: ImageRequestOptions,
+ key: Request,
+ result: ImageResult,
+ isStatic: boolean,
+): void {
+ const ttl = isStatic && !result.fallback ? IMMUTABLE_CACHE_TTL : result.maxAge;
+ if (!options.cache || !options.waitUntil || ttl <= 0) {
+ return;
+ }
+ const headers: Record = {
+ '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(() => {});
+ } 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)) {
+ warnSourceOnce(
+ `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, fallback: true };
+ if (!options.binding) {
+ warnOnce(
+ `[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');
+ // 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 && !result.fallback
+ ? 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), any
+ * binding failure serves the original image (never marked immutable), and
+ * remote redirect targets must match `images.remotePatterns`.
+ */
+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,
+ isAllowedRedirect: (target) => {
+ if (hasRemoteMatch(config.domains, config.remotePatterns, target)) {
+ return true;
+ }
+ warnSourceOnce(
+ `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, {
+ maximumResponseBody: config.maximumResponseBody,
+ dispatch: options.fetchLocal,
+ timeoutMs: options.timeoutMs,
+ });
+ 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..5623fdbaf
--- /dev/null
+++ b/packages/void/src/image/optimizer/index.ts
@@ -0,0 +1,42 @@
+/**
+ * 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 LocalImageDispatch,
+ 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..8f4b9e33e
--- /dev/null
+++ b/packages/void/src/image/optimizer/upstream.ts
@@ -0,0 +1,280 @@
+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;
+ /**
+ * 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;
+};
+
+/**
+ * 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.
+ *
+ * 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,
+ 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);
+ }
+ 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,
+ });
+ }
+ 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),
+ };
+}
+
+/** Runs a request through the application (middleware, routes and page fallbacks). */
+export type LocalImageDispatch = (request: Request) => Promise;
+
+export type LocalImageOptions = {
+ maximumResponseBody: number;
+ dispatch?: LocalImageDispatch;
+ timeoutMs?: number;
+};
+
+/** Rejects with `504` when `signal` aborts before `promise` settles. */
+function withTimeout(promise: Promise, signal: AbortSignal): Promise {
+ return new Promise((resolve, reject) => {
+ const abort = (): void => reject(new ImageError(504, UPSTREAM_TIMED_OUT));
+ signal.addEventListener('abort', abort, { once: true });
+ promise.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort));
+ });
+}
+
+/**
+ * Load a local source with an empty header set (`GET`, no cookies or
+ * conditional headers), like Next.js's internal image request, within the
+ * same timeout as a remote source (`504`).
+ *
+ * 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,
+ options: LocalImageOptions,
+): Promise {
+ const { dispatch } = options;
+ const hasAssets = !!assets && typeof assets.fetch === 'function';
+ if (!hasAssets && !dispatch) {
+ throw new ImageError(500, UPSTREAM_INVALID);
+ }
+ const url = new URL(href, requestUrl);
+ const signal = AbortSignal.timeout(options.timeoutMs ?? UPSTREAM_TIMEOUT_MS);
+ let response: Response | undefined;
+ try {
+ if (dispatch) {
+ response = await withTimeout(dispatch(new Request(url, { signal })), signal);
+ if (response.status === 404 && hasAssets) {
+ discard(response);
+ response = undefined;
+ }
+ }
+ response ??= await withTimeout(assets!.fetch(new Request(url, { signal })), signal);
+ } catch (error) {
+ throw error instanceof ImageError ? error : 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, options.maximumResponseBody, INTERNAL_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) {
+ // A cancelled read ends like a complete one, with the bytes received so far.
+ throw new ImageError(504, UPSTREAM_TIMED_OUT);
+ }
+ 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..bb7a455dd
--- /dev/null
+++ b/packages/void/src/image/registry.ts
@@ -0,0 +1,132 @@
+/**
+ * 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;
+ * `hasLoaderFile` is set whenever `images.loaderFile` is.
+ */
+export type ImageConfigRegistration = {
+ config: ClientImageConfig;
+ loader?: ImageLoader | undefined;
+ hasLoaderFile?: boolean;
+};
+
+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. 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 | undefined {
+ const registration = getRegistration();
+ if (registration?.hasLoaderFile) {
+ return registration.loader;
+ }
+ const loader = registration?.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/static-import.d.ts b/packages/void/src/image/static-import.d.ts
new file mode 100644
index 000000000..971268553
--- /dev/null
+++ b/packages/void/src/image/static-import.d.ts
@@ -0,0 +1,8 @@
+// Types for static image imports: `import hero from './hero.png?image'`.
+// Shipped as `dist/image/static-import.d.ts` (with the import pointing at
+// `./index.mjs`) and referenced from the `void/env` and `void/image` types.
+
+declare module '*?image' {
+ const image: import('./types.ts').StaticImageData;
+ export default image;
+}
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/packages/void/src/image/warn-once.ts b/packages/void/src/image/warn-once.ts
new file mode 100644
index 000000000..5f3a17a67
--- /dev/null
+++ b/packages/void/src/image/warn-once.ts
@@ -0,0 +1,14 @@
+/** Keys already logged by `warnOnce` in this module instance. */
+const warnings = new Set();
+
+/**
+ * `console.warn` a message once per module instance (Next.js `warnOnce`),
+ * keyed by `key` (default: the message) so messages with varying details
+ * collapse. Development callers gate it on `config.dev`.
+ */
+export function warnOnce(message: string, key: string = message): void {
+ if (!warnings.has(key)) {
+ console.warn(message);
+ }
+ warnings.add(key);
+}
diff --git a/packages/void/src/index.ts b/packages/void/src/index.ts
index 431a3eec2..d21a41422 100644
--- a/packages/void/src/index.ts
+++ b/packages/void/src/index.ts
@@ -4,7 +4,11 @@ import { join, resolve } from 'pathslash';
import pc from 'picocolors';
import { type Plugin } from 'vite';
import { analyzeApplicationSync, type ApplicationAnalysis } from './application-analysis.ts';
-import { applyBindingOverrides } from './application-features.ts';
+import {
+ applyBindingOverrides,
+ applyImageOptimizerRequirement,
+ isImageOptimizerEnabled,
+} from './application-features.ts';
import {
hasApiRoutes as routeScanHasApiRoutes,
hasDocumentRoutes as routeScanHasDocumentRoutes,
@@ -27,8 +31,9 @@ import {
restoreCloudflarePreviewPointer,
stashCloudflareDeployPointer,
} from './cloudflare-config.ts';
+import { LEGACY_CONFIG_FILE, TYPED_CONFIG_FILE } from './config-loader.ts';
import { readConfig, isNodeTarget } from './config.ts';
-import { inferBindingsFromSource } from './inference/walk.ts';
+import { getImageConfigWarnings } from './image-config.ts';
import { resolveLocalD1Target, writeLocalD1PersistState } from './local-d1.ts';
import { voidLog, voidWarn } from './log.ts';
import { pagesDevCssAssetsPlugin } from './pages/dev-css-assets.ts';
@@ -56,7 +61,13 @@ import {
} from './plugin-framework-entry.ts';
import { frameworkTriggersPlugin } from './plugin-framework-triggers.ts';
import { createFrameworkPlugins } from './plugin-framework.ts';
-import { DEFAULT_SCAN_DIRS, FRAMEWORK_SCAN_DIRS, type Framework } from './plugin-inference.ts';
+import { imagePlugin } from './plugin-image.ts';
+import {
+ DEFAULT_SCAN_DIRS,
+ FRAMEWORK_SCAN_DIRS,
+ inferBindingsFromFile,
+ type Framework,
+} from './plugin-inference.ts';
import { migrationPlugin } from './plugin-migration.ts';
import { nodeTargetPlugin } from './plugin-node-target.ts';
import { prerenderPlugin } from './plugin-prerender.ts';
@@ -85,6 +96,10 @@ const scheduledPointerCleanups = new Set();
const scheduledPreviewPointerCleanups = new Set();
+// Vite resolves the config, and so calls voidPlugin(), once per build
+// environment; print each `images` config warning once per process.
+const printedImageConfigWarnings = new Set();
+
/** Returns the Vite plugins that build and serve a Void app for its configured target. */
export function voidPlugin(options?: {
persistTo?: string;
@@ -107,6 +122,15 @@ export function voidPlugin(options?: {
? crypto.randomUUID()
: getOrCreateDevTriggerToken(root);
const config = readConfig(root);
+ const configFile = existsSync(join(root, TYPED_CONFIG_FILE))
+ ? TYPED_CONFIG_FILE
+ : LEGACY_CONFIG_FILE;
+ for (const warning of getImageConfigWarnings(config.images, configFile)) {
+ if (!printedImageConfigWarnings.has(warning)) {
+ printedImageConfigWarnings.add(warning);
+ voidWarn(warning);
+ }
+ }
const projectCloudflareConfigPath = !isNodeTarget(config.target)
? materializeCloudflareConfig(root)
: undefined;
@@ -339,6 +363,16 @@ export function voidPlugin(options?: {
// envPlugin.config so mergeBindings can gate credential injection on 'serve'.
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 with
+ // `noOptimizer` (unless a custom loader is set) and mount nothing.
+ const images = imagePlugin(root, {
+ images: config.images,
+ target: config.target,
+ output: config.output,
+ inferenceBinding: config.inference?.bindings?.images,
+ configFile,
+ });
// Merge curated wrangler fields from void.config.ts
const wranglerCompat = isNodeTarget(config.target) ? undefined : readWranglerCompat(root, config);
@@ -416,6 +450,10 @@ export function voidPlugin(options?: {
application = getApplicationAnalysis(routeEnvironment);
bindings = application.bindings;
authEnabled = application.authEnabled;
+ if (!framework) {
+ // The asset routing policy below depends on whether the image optimizer is mounted.
+ images.api.resolve({ command, isPreview });
+ }
// Re-resolve for the real environment. Framework and node-target branches return before
// `ssrEntry` is declared (TDZ) and must leave `workerConfig.assets` unset.
if (!framework && !isNodeTarget(config.target)) {
@@ -572,7 +610,10 @@ export function voidPlugin(options?: {
try {
const code = readFileSync(path, 'utf-8');
const fileBindings = applyBindingOverrides(
- inferBindingsFromSource(code, path.split('/').pop()!),
+ applyImageOptimizerRequirement(
+ inferBindingsFromFile(path, code),
+ isImageOptimizerEnabled(config, detected),
+ ),
config.inference?.bindings,
);
const newBindings: Array = [];
@@ -588,6 +629,9 @@ export function voidPlugin(options?: {
if (fileBindings.needsAI && !bindings.needsAI) {
newBindings.push('AI (void/ai)');
}
+ if (fileBindings.needsImages && !bindings.needsImages) {
+ newBindings.push('Images (Image)');
+ }
if (fileBindings.needsSandbox && !bindings.needsSandbox) {
newBindings.push('Sandbox (void/sandbox)');
}
@@ -841,6 +885,7 @@ export function voidPlugin(options?: {
serverRuntimeOptimizerPlugin,
envSchemaPlugin(root),
...pagesDevCssAssetsPlugin(root, devTriggerToken),
+ images,
routingPlugin(root, {
getApplicationAnalysis,
isAuthEnabled: () => false,
@@ -850,6 +895,7 @@ export function voidPlugin(options?: {
target: config.target as 'node' | 'bun' | 'deno',
routing: config.routing,
hasSandbox: false,
+ images: images.api,
}),
prerenderPlugin(root, config),
...nodeTargetPlugin(config.target as 'node' | 'bun' | 'deno'),
@@ -892,6 +938,7 @@ export function voidPlugin(options?: {
authEnabled,
liveEnabled: shouldEmitLive() && liveConfig !== undefined,
notFound: config.routing?.notFound,
+ imagePath: images.api.getMountedConfig(application.bindings.usesImageOptimizer)?.path,
});
const rules = mergeRoutingRules({ dir: assetRoutingPublicDir, routing: config.routing });
const nativeOwnsEdgeRouting =
@@ -971,6 +1018,7 @@ export function voidPlugin(options?: {
bindingRuntimePlugin(bindingNames),
...(bindings.needsSandbox ? [createSandboxSdkResolvePlugin()] : []),
...pagesDevCssAssetsPlugin(root, devTriggerToken),
+ images,
routingPlugin(root, {
getApplicationAnalysis,
isAuthEnabled: () => authEnabled,
@@ -994,6 +1042,7 @@ export function voidPlugin(options?: {
durableObjects,
websocketTombstones,
onDevAssetRoutingPolicyChange: updateDevAssetRoutingPolicy,
+ images: images.api,
}),
drizzlePlugin(root, true, dialect, {
d1BindingName: bindingNames?.d1,
diff --git a/packages/void/src/inference/vinext-images.ts b/packages/void/src/inference/vinext-images.ts
new file mode 100644
index 000000000..7cbb65157
--- /dev/null
+++ b/packages/void/src/inference/vinext-images.ts
@@ -0,0 +1,172 @@
+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, unwrapStaticExpression } 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';
+
+/** Vite's `DEFAULT_CONFIG_FILES`, in the order Vite tries them. */
+const VITE_CONFIG_FILES = [
+ 'vite.config.js',
+ 'vite.config.mjs',
+ 'vite.config.ts',
+ 'vite.config.cjs',
+ 'vite.config.mts',
+ 'vite.config.cts',
+];
+
+/** Top-level `const NAME = ''` declarations of the config module. */
+function topLevelStringConstants(program: AstNode): Map