diff --git a/src/server.ts b/src/server.ts index 6553371..0a6ad6b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -1,5 +1,6 @@ import { negotiate } from "@fastify/accept-negotiator"; import { decode } from "ufo"; +import { defu } from "defu"; import getEtag from "etag"; import { defineEventHandler, @@ -11,6 +12,7 @@ import { toPlainHandler, toWebHandler, createError, + EventHandler, H3Event, H3Error, send, @@ -22,51 +24,54 @@ import { IPX } from "./ipx"; const MODIFIER_SEP = /[&,]/g; const MODIFIER_VAL_SEP = /[:=_]/; +/** + * Object which identifies a requested resource, consisting of an id string (the source file path) and a mapping of + * image modifiers with their requested values. + */ +export interface IPXResource { + id: string; + modifiers: Record; +} + +export type IPXH3HandlerOptions = { + /** + * Optional function which determines how image URL paths are parsed into IPX resource identifiers. When undefined, + * the default URL parsing logic is used, which accepts URLs in the form `//`. This function must + * return a {@link IPXResource} object. The first argument is the {@link H3Event} for the request, from which the + * `path` can be obtained. (Note: the request path does not include the base URL, only the part after `.../_ipx`.). + */ + parseUrl?: (event: H3Event) => IPXResource; +}; + /** * Creates an H3 handler to handle images using IPX. * @param {IPX} ipx - An IPX instance to handle image requests. - * @returns {H3Event} An H3 event handler that processes image requests, applies modifiers, handles caching, + * @param {IPXH3HandlerOptions} options - Configuration options for the H3 handler instance. + * @returns {EventHandler} An H3 event handler that processes image requests, applies modifiers, handles caching, * and returns the processed image data. See {@link H3Event}. * @throws {H3Error} If there are problems with the request parameters or processing the image. See {@link H3Error}. */ -export function createIPXH3Handler(ipx: IPX) { +export function createIPXH3Handler( + ipx: IPX, + options?: IPXH3HandlerOptions, +): EventHandler { + const { parseUrl } = defu(options, { + parseUrl: defaultUrlParser, + }); + const _handler = async (event: H3Event) => { // Parse URL - const [modifiersString = "", ...idSegments] = event.path - .slice(1 /* leading slash */) - .split("/"); - - const id = safeString(decode(idSegments.join("/"))); + const { id, modifiers } = parseUrl(event); // Validate - if (!modifiersString) { - throw createError({ - statusCode: 400, - statusText: `IPX_MISSING_MODIFIERS`, - message: `Modifiers are missing: ${id}`, - }); - } if (!id || id === "/") { throw createError({ statusCode: 400, statusText: `IPX_MISSING_ID`, - message: `Resource id is missing: ${event.path}`, + message: `Resource id is missing or malformed: ${event.path}`, }); } - // Contruct modifiers - const modifiers: Record = Object.create(null); - - // Read modifiers from first segment - if (modifiersString !== "_") { - for (const p of modifiersString.split(MODIFIER_SEP)) { - const [key, ...values] = p.split(MODIFIER_VAL_SEP); - modifiers[safeString(key)] = values - .map((v) => safeString(decode(v))) - .join("_"); - } - } - // Auto format const mFormat = modifiers.f || modifiers.format; if (mFormat === "auto") { @@ -87,7 +92,7 @@ export function createIPXH3Handler(ipx: IPX) { } // Create request - const img = ipx(id, modifiers); + const img = ipx(safeString(id), modifiers); // Get image meta from source const sourceMeta = await img.getSourceMeta(); @@ -166,39 +171,81 @@ export function createIPXH3Handler(ipx: IPX) { /** * Creates an H3 application configured to handle image processing using a supplied IPX instance. * @param {IPX} ipx - An IPX instance to handle image handling requests. + * @param {IPXH3HandlerOptions} options - Configuration options for the H3 handler instance. * @returns {any} An H3 application configured to use the IPX image handler. */ -export function createIPXH3App(ipx: IPX) { +export function createIPXH3App(ipx: IPX, options?: IPXH3HandlerOptions) { const app = createApp({ debug: true }); - app.use(createIPXH3Handler(ipx)); + app.use(createIPXH3Handler(ipx, options)); return app; } /** * Creates a web server that can handle IPX image processing requests using an H3 application. * @param {IPX} ipx - An IPX instance configured for the server. See {@link IPX}. + * @param {IPXH3HandlerOptions} options - Configuration options for the H3 handler instance. * @returns {any} A web handler suitable for use with web server environments that support the H3 library. */ -export function createIPXWebServer(ipx: IPX) { - return toWebHandler(createIPXH3App(ipx)); +export function createIPXWebServer(ipx: IPX, options?: IPXH3HandlerOptions) { + return toWebHandler(createIPXH3App(ipx, options)); } /** * Creates a web server that can handle IPX image processing requests using an H3 application. * @param {IPX} ipx - An IPX instance configured for the server. See {@link IPX}. + * @param {IPXH3HandlerOptions} options - Configuration options for the H3 handler instance. * @returns {any} A web handler suitable for use with web server environments that support the H3 library. */ -export function createIPXNodeServer(ipx: IPX) { - return toNodeListener(createIPXH3App(ipx)); +export function createIPXNodeServer(ipx: IPX, options?: IPXH3HandlerOptions) { + return toNodeListener(createIPXH3App(ipx, options)); } /** * Creates a simple server that can handle IPX image processing requests using an H3 application. * @param {IPX} ipx - An IPX instance configured for the server. + * @param {IPXH3HandlerOptions} options - Configuration options for the H3 handler instance. * @returns {any} A handler suitable for plain HTTP server environments that support the H3 library. */ -export function createIPXPlainServer(ipx: IPX) { - return toPlainHandler(createIPXH3App(ipx)); +export function createIPXPlainServer(ipx: IPX, options?: IPXH3HandlerOptions) { + return toPlainHandler(createIPXH3App(ipx, options)); +} + +/** + * The default IPX resource URL parsing function, which accepts URLs in the form `//`. + * @param {H3Event} event - An H3 event object carrying the incoming request and context. + * @returns {IPXResource} Object containing the source file `id` and `modifiers` parsed from the URL. + */ +export function defaultUrlParser(event: H3Event): IPXResource { + const [modifiersString = "", ...idSegments] = event.path + .slice(1 /* leading slash */) + .split("/"); + + return { + id: decode(idSegments.join("/")), + modifiers: parseModifiersString(modifiersString), + }; +} + +/** + * Parses an encoded modifiers string from a URL into a mapping of modifier keys and values. + * @param input - Encoded string of modifiers, e.g. `w_300&h_600&f_webp`. + * @returns {Record} Mapping of each requested modifier key to its value. (Can be an empty object.) + */ +export function parseModifiersString(input: string): Record { + const modifiers: Record = Object.create(null); + + if (input === "" || input === "_") { + return modifiers; + } + + for (const p of input.split(MODIFIER_SEP)) { + const [key, ...values] = p.split(MODIFIER_VAL_SEP); + modifiers[safeString(key)] = values + .map((v) => safeString(decode(v))) + .join("_"); + } + + return modifiers; } // --- Utils --- diff --git a/test/server.test.ts b/test/server.test.ts new file mode 100644 index 0000000..d5df9f7 --- /dev/null +++ b/test/server.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; +import { defaultUrlParser, parseModifiersString } from "../src"; +import { H3Event } from "h3"; + +describe("ipx: defaultUrlParser", () => { + it("path with modifiers", async () => { + const { id, modifiers } = defaultUrlParser({ + path: "/w_300&h_300&f_webp/assets/bliss.jpg", + } as unknown as H3Event); + + expect(id).toBe("assets/bliss.jpg"); + expect(modifiers).toEqual({ + w: "300", + h: "300", + f: "webp", + }); + }); + + it("path with empty modifiers", async () => { + const { id, modifiers } = defaultUrlParser({ + path: "/_/assets2/unjs.jpg", + } as unknown as H3Event); + + expect(id).toBe("assets2/unjs.jpg"); + expect(modifiers).toEqual({}); + }); +}); + +describe("ipx: parseModifiersString", () => { + it("ordinary modifiers", async () => { + const modifiers = parseModifiersString("w_300&h_600&f_webp"); + + expect(modifiers).toEqual({ + w: "300", + h: "600", + f: "webp", + }); + }); + + it("alternative modifier value separators", async () => { + const modifiers = parseModifiersString("w:300&h=600&f_jpeg"); + + expect(modifiers).toEqual({ + w: "300", + h: "600", + f: "jpeg", + }); + }); + + it("boolean modifier", async () => { + const modifiers = parseModifiersString("animated&s_300x300"); + + expect(modifiers).toEqual({ + animated: "", + s: "300x300", + }); + }); +});