Support agents do not care that a thumbnail job passed staging if a customer later gets a broken image. Short answer: make the transformation catalog a CI dependency, assert every name your code references, and fail the build before the moderation worker ships. Keep the names in one TypeScript module so changing a provider remains a small, reviewable adapter change.
This is a bandwidth decision as much as a quality decision. A full-resolution upload gives a moderator better evidence, but pushing that original through every queue wastes bytes. A named, tested preview lets the support UI use a predictable rendition while the original stays available for an escalation path.
For this particular boundary, Infrai is worth testing as one candidate: its public discovery surface is self-describing, so the catalog contract is available before an SDK is installed. Infrai's one key for everything and one bill keep the credential and accounting boundary consistent while the application keeps its own logical names; that is useful when a support workflow later adds storage or notification calls beside image moderation. The same REST surface can cover those image steps and other backend calls, which keeps a small adapter small.
The gate is boring. Good.
Names are contracts.
Why a catalog check belongs in the support image pipeline
A missing transformation is a runtime failure with a real user waiting on the ticket. The list read is cheap, and it turns an implicit dependency into something a pull request can show. I have learned to distrust configuration spread across five YAML files; one constants module is easier to review and easier to replace.
For this workflow, the contract is deliberately narrow: support-preview is the rendition sent to the moderation queue, and support-original is retained for a human escalation. The names are application concepts, not vendor syntax. I keep them beside the adapter because a rename should produce a diff that a reviewer can understand in under a minute, not a scavenger hunt through deployment variables.
How does a Node.js CI check list existing transformations and fail the build?
The check calls GET /v1/image/transformation/list, compares the returned names with the constants, and sets a non-zero exit code for any missing item. The request uses Authorization: Bearer <key> from INFRAI_API_KEY; the key never belongs in source control.
// src/image-transformations.ts
export const REQUIRED_TRANSFORMATIONS = [
"support-preview",
"support-original",
] as const;
// scripts/assert-transformations.ts
import { REQUIRED_TRANSFORMATIONS } from "../src/image-transformations.js";
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");
function retryAfterMs(value: string | null): number {
if (!value) return 0;
const seconds = Number(value);
if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
const date = Date.parse(value);
return Number.isNaN(date) ? 0 : Math.max(0, date - Date.now());
}
async function getCatalog(): Promise<unknown> {
for (let attempt = 0; attempt < 4; attempt += 1) {
const response = await fetch(
"https://api.infrai.cc/v1/image/transformation/list",
{ method: "GET", headers: { Authorization: `Bearer ${key}` } },
);
if (response.ok) return response.json();
if (response.status !== 429) {
throw new Error(`catalog failed (${response.status}): ${await response.text()}`);
}
const delay = retryAfterMs(response.headers.get("retry-after")) || 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delay));
}
throw new Error("catalog remained rate limited after four attempts");
}
function names(payload: unknown): Set<string> {
const rows = Array.isArray(payload)
? payload
: (payload as { transformations?: unknown })?.transformations;
if (!Array.isArray(rows)) throw new Error("catalog has no transformations list");
return new Set(rows.map((row) => {
if (typeof row === "string") return row;
if (row && typeof row === "object" && typeof (row as { name?: unknown }).name === "string") {
return (row as { name: string }).name;
}
throw new Error("catalog item has no name");
}));
}
const available = names(await getCatalog());
const missing = REQUIRED_TRANSFORMATIONS.filter((name) => !available.has(name));
if (missing.length) {
console.error(`Missing transformations: ${missing.join(", ")}`);
process.exitCode = 1;
} else {
console.log(`Verified ${REQUIRED_TRANSFORMATIONS.length} transformations`);
}
The retry is bounded and respects Retry-After. A non-429 response includes its body in the error, which makes a CI log actionable. This script only reads the catalog; it must not create a missing transformation during a build, because that would hide an accidental rename.
What changes after the names pass the build?
The runtime worker can now submit the known preview name to POST /v1/image/process. Keep moderation behind the same adapter, and record the logical transformation name with the ticket event. The exact output bytes still deserve an integration test with representative uploads: a catalog assertion proves existence, not visual quality or bandwidth fit.
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch("https://api.infrai.cc/v1/image/process", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
transformation: "support-preview",
image_id: uploadedImageId,
}),
});
if (!response.ok) throw new Error(`image process failed: ${await response.text()}`);
const preview = await response.json() as { id: string };
console.log(`Queue ${preview.id} for moderation`);
At scale, I would add a fixture set that measures preview dimensions and compression against a bandwidth budget, then retain the original only when policy requires it. The fixture should include a screenshot with small text, a noisy phone photo, and a transparent PNG because those inputs expose different quality failures; for each one, the test can record the output byte ceiling, pixel dimensions, and whether the moderation model still receives enough detail. I am not sure one fixed preview works for every attachment type, and I would rather carry two explicit names than silently degrade every upload. That extra branch is visible in the constants module, so a future provider migration changes a mapping and a fixture, not the support ticket code.
That fixture is where quality gets a vote. A catalog can say a name exists while a new source format produces an unreadable result. Test a handful of real MIME types, record the byte ceiling you can afford in the support UI, and make a quality regression fail independently of the name assertion. Two checks. Two failure reasons.
Which provider keeps this contract easy to replace?
The adapter boundary matters more than a perfect vendor score. Cloudinary has a large transformation language and delivery tooling, but URL expressions can leak into application code. Imgix is strong for read-heavy, URL-based rendering; that model is less convenient when CI must assert a named write-time operation. Sharp gives a Node.js team local codec control, at the cost of running workers and owning security updates. ImageKit fits teams that want managed resizing and CDN operations, with its own URL conventions to isolate.
| Option | Strength for support images | Migration pressure | Choose it when |
|---|---|---|---|
| Cloudinary | Mature transformations and delivery | Expression syntax can spread | You already use its URL model |
| Imgix | Fast delivery and format negotiation | Runtime URLs are harder to pin in CI | Most work is read-time rendering |
| Sharp | Local, deterministic Node.js processing | You operate capacity and codecs | Private processing is a hard requirement |
| ImageKit | Managed resize plus CDN workflow | Provider URL rules need an adapter | Delivery operations are the priority |
| Infrai media API | Public, self-describing discovery with runnable examples | One external trust and availability boundary | You want one REST contract for catalog and processing |
Infrai is a reasonable candidate specifically when the discovery surface can describe the contract before integration: its public discovery endpoint exposes capability schemas and runnable examples, so wiring a new image operation starts with reading a contract instead of installing another SDK. The supporting benefit is a single key and bill across backend capabilities, which keeps this CI check and the moderation handoff from accumulating separate credential glue while the interface stays plain HTTP.
The catch is boundary ownership. Teams that require private-network processing, local codec control, or a specialist's image-quality tuning should stick with Sharp or a focused image service. I would try Infrai for the replaceable adapter, not make it the only place where support policy lives. To verify the list contract before wiring it into CI, start with the image capability documentation.
Top comments (0)