diff --git a/README.md b/README.md index 3cde43c..476d339 100644 --- a/README.md +++ b/README.md @@ -483,6 +483,22 @@ Every option can also be set universally with an `IPX_*` environment variable, w Requested `width`, `height` and `resize` dimensions are clamped to `maxOutputDimension`, preserving the requested aspect ratio, and `extend` edges are clamped so the extended canvas stays within it. This bounds how much memory a single request can allocate: sharp only limits the _input_ size, so without it `/enlarge,s_20000x20000/image.jpg` (or `/extend_10000_10000_10000_10000/image.jpg`) allocates gigabytes from a small source image. Set to `false` to disable, which is only safe when modifiers come from a trusted source. +### Server (`createIPXFetchHandler`, `createIPXNodeHandler`, `serveIPX`) + +| Option | Environment variable | Default | Description | +| ------------- | -------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `autoFormats` | `IPX_AUTO_FORMATS` | `avif`, `webp`, `jpeg`, `png`, `tiff`, `heif`, `gif` | Formats `f_auto` can pick from, in order of preference. | + +`f_auto` serves the format that the client's `accept` header lists with the highest q-value, and equal q-values go to the earlier entry in `autoFormats`. Wildcards such as `image/*` and `*/*` do not match a format, so browsers only negotiate the formats they list explicitly (in practice `avif`, `webp` and `png`). When nothing matches, `jpeg` is served, even if it is not in the list. Animated images only consider `webp` and `gif`, and fall back to `gif`. + +AVIF compresses best but is much slower to encode, so leave it out when encoding time matters more than size: + +```ts +createIPXFetchHandler(ipx, { autoFormats: ["webp", "jpeg"] }); +``` + +With the CLI: `IPX_AUTO_FORMATS=webp,jpeg ipx serve`. Unknown formats throw when the handler is created. + ### Filesystem source (`ipxFSStorage`) Enabled by default with the CLI only. diff --git a/src/index.ts b/src/index.ts index a675793..6fe1652 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ export { export { HTTPError } from "h3"; export { + type IPXAutoFormat, type IPXHandlerOptions, type IPXParsedURL, type IPXURLParser, diff --git a/src/ipx.ts b/src/ipx.ts index b321def..bd9d21d 100644 --- a/src/ipx.ts +++ b/src/ipx.ts @@ -217,7 +217,7 @@ const DEFAULT_MAX_OUTPUT_DIMENSION = 8192; // https://sharp.pixelplumbing.com/#formats // (gif and svg are not supported as output) -const SUPPORTED_FORMATS = new Set([ +export const SUPPORTED_FORMATS: ReadonlySet = new Set([ "jpeg", "png", "webp", diff --git a/src/server.ts b/src/server.ts index 51a413a..72667cf 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,9 +1,9 @@ import getEtag from "etag"; import { negotiate } from "@fastify/accept-negotiator"; import { defineEventHandler, HTTPError } from "h3"; -import { getBuiltinModule, requireModule } from "./utils.ts"; +import { getBuiltinModule, getEnv, requireModule } from "./utils.ts"; -import type { IPX } from "./ipx.ts"; +import { SUPPORTED_FORMATS, type IPX } from "./ipx.ts"; import type { H3Event, EventHandlerWithFetch } from "h3"; import type { NodeHttpHandler, Server, ServerOptions } from "srvx"; @@ -29,8 +29,33 @@ export interface IPXHandlerOptions { * @optional */ parseURL?: IPXURLParser; + + /** + * Output formats `f_auto` can pick from, in order of preference. + * + * Useful to leave out formats that are slow to encode, such as `avif`. + * + * The format the client's `Accept` header lists with the highest q-value wins, + * and equal q-values go to the earlier entry. Wildcards such as `image/*` do + * not match a format, so browsers only negotiate the formats they list + * explicitly (in practice `avif`, `webp` and `png`). + * + * Animated images only consider `webp` and `gif` from this list. When nothing + * matches, `jpeg` (or `gif` for animated images) is used. + * + * Unknown formats throw when the handler is created. + * + * Can also be set with the `IPX_AUTO_FORMATS` environment variable (JSON array + * or comma separated list). + * + * @default ["avif", "webp", "jpeg", "png", "tiff", "heif", "gif"] + */ + autoFormats?: IPXAutoFormat[]; } +export type IPXAutoFormat = + "avif" | "webp" | "jpeg" | "jpg" | "png" | "tiff" | "heif" | "heic" | "gif"; + export function createIPXFetchHandler( ipx: IPX, opts?: IPXHandlerOptions, @@ -53,8 +78,8 @@ export function serveIPX( opts?: Omit & IPXHandlerOptions, ): Server { const { serve } = requireModule("srvx"); - const { parseURL, ...serverOptions } = opts || {}; - const fetch = createIPXFetchHandler(ipx, { parseURL }); + const { parseURL, autoFormats, ...serverOptions } = opts || {}; + const fetch = createIPXFetchHandler(ipx, { parseURL, autoFormats }); return serve({ ...serverOptions, fetch }); } @@ -125,6 +150,9 @@ function createIPXHandler( opts: IPXHandlerOptions = {}, ): EventHandlerWithFetch { const parseURL = opts.parseURL || parseIPXURL; + const autoFormats = resolveAutoFormats( + opts.autoFormats || getEnv("IPX_AUTO_FORMATS"), + ); return defineEventHandler(async (event: H3Event) => { // Parse URL (never trust the parser output: it can be user provided) @@ -155,6 +183,7 @@ function createIPXHandler( const animated = modifiers.animated ?? modifiers.a; const autoFormat = autoDetectFormat( acceptHeader, + autoFormats, // #234 "animated" param adds {animated: ''} to the modifiers // TODO: fix modifiers to normalized to boolean !!animated || animated === "", @@ -308,20 +337,65 @@ function opaqueTag(tag: string): string { return tag.startsWith("W/") ? tag.slice(2) : tag; } -function autoDetectFormat(acceptHeader: string, animated: boolean): string { +const DEFAULT_AUTO_FORMATS = [ + "avif", + "webp", + "jpeg", + "png", + "tiff", + "heif", + "gif", +]; + +const ANIMATED_FORMATS = new Set(["webp", "gif"]); + +interface AutoFormats { + mimes: string[]; + animatedMimes: string[]; +} + +function resolveAutoFormats(input: unknown): AutoFormats { + let list: unknown[] = []; + if (typeof input === "string") { + list = input.split(","); + } else if (Array.isArray(input)) { + list = input; + } + const formats = list + .map((f) => + String(f ?? "") + .trim() + .toLowerCase(), + ) + .filter(Boolean) + .map((f) => (f === "jpg" ? "jpeg" : f)); + const invalid = formats.filter((f) => !SUPPORTED_FORMATS.has(f)); + if (invalid.length > 0) { + throw new TypeError( + `[ipx] Unsupported \`autoFormats\`: ${invalid.join(", ")} (supported: ${[...SUPPORTED_FORMATS].join(", ")})`, + ); + } + if (formats.length === 0) { + formats.push(...DEFAULT_AUTO_FORMATS); + } + return { + mimes: formats.map((f) => `image/${f}`), + animatedMimes: formats + .filter((f) => ANIMATED_FORMATS.has(f)) + .map((f) => `image/${f}`), + }; +} + +function autoDetectFormat( + acceptHeader: string, + autoFormats: AutoFormats, + animated: boolean, +): string { if (animated) { - const acceptMime = negotiate(acceptHeader, ["image/webp", "image/gif"]); + const acceptMime = negotiate(acceptHeader, autoFormats.animatedMimes); return acceptMime?.split("/")[1] || "gif"; } - const acceptMime = negotiate(acceptHeader, [ - "image/avif", - "image/webp", - "image/jpeg", - "image/png", - "image/tiff", - "image/heif", - "image/gif", - ]); + const acceptMime = negotiate(acceptHeader, autoFormats.mimes); return acceptMime?.split("/")[1] || "jpeg"; } diff --git a/test/server.test.ts b/test/server.test.ts index ac75d16..507f1f0 100644 --- a/test/server.test.ts +++ b/test/server.test.ts @@ -1,12 +1,17 @@ -import { beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { type RequestListener, createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import { resolve } from "node:path"; import { type IPX, + type IPXAutoFormat, type IPXURLParser, createIPX, createIPXFetchHandler, + createIPXNodeHandler, ipxFSStorage, parseIPXURL, + serveIPX, } from "../src/index.ts"; describe("server", () => { @@ -381,6 +386,180 @@ describe("server", () => { }); }); + describe("f_auto", () => { + const chrome = "image/avif,image/webp,image/apng,image/*,*/*;q=0.8"; + + const detect = async ( + url: string, + accept: string, + opts?: Parameters[1], + ) => { + const handler = createIPXFetchHandler(ipx, opts); + const res = await handler(new Request(url, { headers: { accept } })); + return { format: lastRequest.modifiers?.format, res }; + }; + + it("negotiates the best format by default", async () => { + const { format, res } = await detect( + "http://example.com/f_auto/test.jpg", + chrome, + ); + expect(format).toEqual("avif"); + expect(res.headers.get("vary")).toEqual("Accept"); + expect(lastRequest.modifiers).not.toHaveProperty("f"); + }); + + it("falls back to jpeg (or gif when animated)", async () => { + expect( + (await detect("http://example.com/f_auto/test.jpg", "text/html")) + .format, + ).toEqual("jpeg"); + expect( + (await detect("http://example.com/f_auto,a/test.gif", "text/html")) + .format, + ).toEqual("gif"); + }); + + it("autoFormats limits and orders the candidates", async () => { + const opts = { autoFormats: ["webp", "jpg"] as IPXAutoFormat[] }; + expect( + (await detect("http://example.com/f_auto/test.jpg", chrome, opts)) + .format, + ).toEqual("webp"); + expect( + ( + await detect( + "http://example.com/f_auto/test.jpg", + "image/avif,image/jpeg", + opts, + ) + ).format, + ).toEqual("jpeg"); + }); + + it("autoFormats only uses animated capable formats when animated", async () => { + const { format } = await detect( + "http://example.com/f_auto,animated/test.gif", + chrome, + { autoFormats: ["avif", "gif"] }, + ); + expect(format).toEqual("gif"); + }); + + it("prefers the highest q-value over the list order", async () => { + const { format } = await detect( + "http://example.com/f_auto/test.jpg", + "image/webp;q=0.5,image/avif", + { autoFormats: ["webp", "avif"] }, + ); + expect(format).toEqual("avif"); + }); + + it("does not match wildcards", async () => { + for (const accept of ["image/*", "*/*"]) { + const { format } = await detect( + "http://example.com/f_auto/test.jpg", + accept, + { autoFormats: ["png"] }, + ); + expect(format).toEqual("jpeg"); + } + }); + + it("falls back to jpeg even when autoFormats excludes it", async () => { + const { format } = await detect( + "http://example.com/f_auto/test.jpg", + "", + { autoFormats: ["webp", "png"] }, + ); + expect(format).toEqual("jpeg"); + }); + + it("throws on unsupported autoFormats", () => { + for (const autoFormats of [["image/webp"], ["svg"], ["foo", "webp"]]) { + expect(() => + createIPXFetchHandler(ipx, { autoFormats: autoFormats as any }), + ).toThrow(/Unsupported `autoFormats`/); + } + }); + + describe("IPX_AUTO_FORMATS", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each(["webp, jpeg", '["webp", "jpeg"]'])("reads %s", async (env) => { + vi.stubEnv("IPX_AUTO_FORMATS", env); + const { format } = await detect( + "http://example.com/f_auto/test.jpg", + chrome, + ); + expect(format).toEqual("webp"); + }); + + it.each([",", " ", "5", '{"a":1}'])( + "uses the defaults for %j", + async (env) => { + vi.stubEnv("IPX_AUTO_FORMATS", env); + const { format } = await detect( + "http://example.com/f_auto/test.jpg", + chrome, + ); + expect(format).toEqual("avif"); + }, + ); + + it("throws on unsupported formats", () => { + vi.stubEnv("IPX_AUTO_FORMATS", "webp,foo"); + expect(() => createIPXFetchHandler(ipx)).toThrow(/foo/); + }); + + it("is overridden by the option", async () => { + vi.stubEnv("IPX_AUTO_FORMATS", "avif"); + const { format } = await detect( + "http://example.com/f_auto/test.jpg", + chrome, + { autoFormats: ["webp"] }, + ); + expect(format).toEqual("webp"); + }); + }); + + it("createIPXNodeHandler and serveIPX pass autoFormats through", async () => { + const accept = { accept: chrome }; + + const nodeServer = createServer( + createIPXNodeHandler(ipx, { autoFormats: ["webp"] }) as RequestListener, + ); + await new Promise((r) => nodeServer.listen(0, "127.0.0.1", r)); + try { + const { port } = nodeServer.address() as AddressInfo; + await fetch(`http://127.0.0.1:${port}/f_auto/test.jpg`, { + headers: accept, + }); + expect(lastRequest.modifiers?.format).toEqual("webp"); + } finally { + nodeServer.close(); + } + + const server = serveIPX(ipx, { + port: 0, + hostname: "127.0.0.1", + silent: true, + autoFormats: ["png"], + }); + try { + await server.ready(); + await fetch(new URL("/f_auto/test.jpg", server.url), { + headers: { accept: "image/avif,image/png" }, + }); + expect(lastRequest.modifiers?.format).toEqual("png"); + } finally { + await server.close(true); + } + }); + }); + describe("error handling", () => { it("IPX_MISSING_MODIFIERS", async () => { const handler = createIPXFetchHandler(ipx);