Skip to content

feat: add option to customise URL parsing in IPX server #259

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 84 additions & 37 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { negotiate } from "@fastify/accept-negotiator";
import { decode } from "ufo";
import { defu } from "defu";
import getEtag from "etag";
import {
defineEventHandler,
Expand All @@ -11,6 +12,7 @@
toPlainHandler,
toWebHandler,
createError,
EventHandler,
H3Event,
H3Error,
send,
Expand All @@ -22,51 +24,54 @@
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<string, string>;
}

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 `/<modifiers>/<id>`. 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,
});

Check warning on line 60 in src/server.ts

View check run for this annotation

Codecov / codecov/patch

src/server.ts#L55-L60

Added lines #L55 - L60 were not covered by tests

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);

Check warning on line 64 in src/server.ts

View check run for this annotation

Codecov / codecov/patch

src/server.ts#L64

Added line #L64 was not covered by tests

// 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}`,

Check warning on line 71 in src/server.ts

View check run for this annotation

Codecov / codecov/patch

src/server.ts#L71

Added line #L71 was not covered by tests
});
}

// Contruct modifiers
const modifiers: Record<string, string> = 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") {
Expand All @@ -87,7 +92,7 @@
}

// Create request
const img = ipx(id, modifiers);
const img = ipx(safeString(id), modifiers);

Check warning on line 95 in src/server.ts

View check run for this annotation

Codecov / codecov/patch

src/server.ts#L95

Added line #L95 was not covered by tests

// Get image meta from source
const sourceMeta = await img.getSourceMeta();
Expand Down Expand Up @@ -166,39 +171,81 @@
/**
* 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));

Check warning on line 179 in src/server.ts

View check run for this annotation

Codecov / codecov/patch

src/server.ts#L179

Added line #L179 was not covered by tests
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));

Check warning on line 190 in src/server.ts

View check run for this annotation

Codecov / codecov/patch

src/server.ts#L190

Added line #L190 was not covered by tests
}

/**
* 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));

Check warning on line 200 in src/server.ts

View check run for this annotation

Codecov / codecov/patch

src/server.ts#L200

Added line #L200 was not covered by tests
}

/**
* 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));
}

Check warning on line 211 in src/server.ts

View check run for this annotation

Codecov / codecov/patch

src/server.ts#L210-L211

Added lines #L210 - L211 were not covered by tests

/**
* The default IPX resource URL parsing function, which accepts URLs in the form `/<modifiers>/<id>`.
* @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<string, string>} Mapping of each requested modifier key to its value. (Can be an empty object.)
*/
export function parseModifiersString(input: string): Record<string, string> {
const modifiers: Record<string, string> = 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 ---
Expand Down
58 changes: 58 additions & 0 deletions test/server.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
});
});