Cloudflare's Images service provides a powerful Transform API that allows you to optimize and manipulate images. In this tutorial, we will try to implement our own basic version using TypeScript.
What We're Building
transformation API that lets you:
- Resize images by width and height
- Convert between formats (PNG, JPG, WebP)
- Adjust image quality
Our API will follow a URL-based transformation pattern similar to Cloudflare's Image API: /transform/[options]/[image-path]
Prerequisites
- Node.js and npm installed
- Basic knowledge of TypeScript and Express
- An Upstash account for Redis (used for rate limiting)
Project Setup
Let's start by creating our project structure and installing dependencies:
mkdir picform
cd picform
npm init -y
Now install the required dependencies:
npm install express sharp zod @upstash/ratelimit
npm install -D tsx tsconfig-paths tsup @types/express typescript
Let's look at what each package does:
- express: Our web server framework
- sharp: High-performance image processing library
- zod: TypeScript-first schema validation
- @upstash/ratelimit: Rate limiting with Upstash Redis
- tsx: TypeScript execution environment
- tsconfig-paths: For path aliases in TypeScript
- tsup: TypeScript bundler for building our application
Project Structure
Our project follows a feature-based structure:
/
├── uploads/ # Directory for uploaded images
├── src/
│ ├── features/ # Feature modules
│ │ └── transform/ # Image transformation feature
│ ├── lib/ # Shared utilities
│ └── server.ts # Main application entry
├── .env # Environment variables
└── tsconfig.json # TypeScript configuration
Configuration Files
Let's set up our TypeScript configuration first:
tsconfig.json
{
"compilerOptions": {
"esModuleInterop": true,
"skipLibCheck": true,
"target": "es2022",
"allowJs": true,
"resolveJsonModule": true,
"moduleResolution": "bundler",
"module": "ESNext",
"noEmit": true,
"moduleDetection": "force",
"isolatedModules": true,
"verbatimModuleSyntax": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"outDir": "dist",
"sourceMap": true,
"lib": ["es2022"],
"paths": {
"@lib/*": ["./src/lib/*"],
"@features/*": ["./src/features/*"]
}
}
}
.env
UPSTASH_REDIS_REST_URL=""
UPSTASH_REDIS_REST_TOKEN=""
Copy this file to .env
and fill in your Upstash credentials.
package.json Scripts
Update your package.json with the following scripts:
{
"scripts": {
"dev": "tsx --watch --env-file=.env src/server.ts",
"build": "tsup src/",
"start": "node --env-file=.env dist/server.js"
}
}
-
dev
: Runs the development server with hot reloading -
build
: Bundles the TypeScript code into JavaScript using tsup -
start
: Runs the production build
Building the Core Libraries
Let's create some utility functions first:
src/lib/constants.ts
export const UPLOAD_PATH = "/uploads";
This constant defines where our images will be stored.
src/lib/errors.ts
export class AppError extends Error {
constructor(message: string) {
super(message);
this.name = "AppError";
}
}
This custom error class helps us distinguish application-specific errors from system errors.
src/lib/safe.ts
This utility provides a convenient way to handle errors in our application:
export type Safe<T> =
| {
success: true;
data: T;
}
| {
success: false;
error: unknown;
};
export function safe<T>(promise: Promise<T>, err?: unknown): Promise<Safe<T>>;
export function safe<T>(func: () => T, err?: unknown): Safe<T>;
export function safe<T>(
promiseOrFunc: Promise<T> | (() => T),
err?: unknown,
): Promise<Safe<T>> | Safe<T> {
if (promiseOrFunc instanceof Promise) {
return safeAsync(promiseOrFunc, err);
}
return safeSync(promiseOrFunc, err);
}
export async function safeAsync<T>(
promise: Promise<T>,
err?: unknown,
): Promise<Safe<T>> {
try {
const data = await promise;
return { data, success: true };
} catch (e) {
if (err !== undefined) {
return { success: false, error: err };
}
return { success: false, error: e };
}
}
export function safeSync<T>(func: () => T, err?: unknown): Safe<T> {
try {
const data = func();
return { data, success: true };
} catch (e) {
console.error(e);
if (err !== undefined) {
return { success: false, error: err };
}
if (e instanceof Error) {
return { success: false, error: e.message };
}
return { success: false, error: e };
}
}
src/lib/upstash.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
export const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(2, "3 m"),
analytics: true,
});
This configures rate limiting to allow 2 requests per IP per image path in a 3-minute sliding window.
Type Definitions
Create a type definition file to extend Express's Request interface:
type.d.ts
import { Transform } from "@features/transform/schema";
declare global {
namespace Express {
interface Request {
transformOptions?: Transform["options"];
transformPath?: Transform["path"];
}
}
}
This adds our custom properties to the Express Request type.
Building the Transform Feature
Now let's create the core transformation feature:
src/features/transform/schema.ts
import { z } from "zod";
const format = z.enum(["png", "jpg", "webp"]);
export const transformSchema = z.object({
path: z.custom<`${string}.${z.infer<typeof format>}`>(
(val) => {
if (typeof val !== "string") return false;
return /\.(png|jpg|webp)$/i.test(val);
},
{
message: "Path must end with .png, .jpg, or .webp",
}
),
options: z.object({
width: z.coerce.number().min(100).max(1920).optional(),
height: z.coerce.number().min(100).max(1080).optional(),
quality: z.coerce.number().min(1).max(100).optional(),
format: format.optional(),
}),
});
export type Transform = z.infer<typeof transformSchema>;
This Zod schema defines our API's validation rules:
- Images must have valid extensions (.png, .jpg, or .webp)
- Width can be between 100-1920px
- Height can be between 100-1080px
- Quality can be between 1-100
src/features/transform/service.ts
This is where the image transformation happens using Sharp:
import { AppError } from "@lib/errors";
import { type Transform } from "./schema";
import fs from "fs";
import path from "path";
import sharp from "sharp";
import { UPLOAD_PATH } from "@lib/constants";
export const transformService = async (data: Transform) => {
// Construct the full file path
const _path = path.join(process.cwd(), UPLOAD_PATH, data.path);
// Check if the image exists
const imageExists = fs.existsSync(_path);
if (!imageExists) {
throw new AppError("Image does not exist");
}
// Extract file extension from path and use as default format
const fileExt = path
.extname(_path)
.split(".")[1] as Transform["options"]["format"];
const format = data.options.format ?? fileExt;
// Read the image file
const image = fs.readFileSync(_path);
// Initialize Sharp and resize the image
const resize = sharp(image).resize({
height: data.options.height,
width: data.options.width,
});
// Apply format-specific transformations
switch (format) {
case "png":
resize.png({ quality: data.options.quality });
break;
case "jpg":
resize.jpeg({ quality: data.options.quality });
break;
case "webp":
resize.webp({ quality: data.options.quality });
break;
default:
throw new AppError("Invalid format");
}
// Generate the transformed image buffer with metadata
const result = await resize.toBuffer({ resolveWithObject: true });
return result;
};
src/features/transform/middleware.ts
Here we handle request parsing and rate limiting:
import { type NextFunction, type Request, type Response } from "express";
import { transformSchema } from "./schema";
import { ratelimit } from "@lib/upstach";
export const parseTransformRequest = (
req: Request,
res: Response,
next: NextFunction
) => {
// Extract options and path from URL parameters
const _options = req.params[0];
const path = req.params[1];
if (!_options || !path) {
res.status(400).send("params missing");
return;
}
// Parse comma-separated options into key-value object
let options = _options
.split(",")
.reduce<Record<string, unknown>>((acc, current) => {
const [key, value] = current.split("=");
acc[key!] = value;
return acc;
}, {});
// Validate input using Zod schema
const validated = transformSchema.safeParse({
path,
options,
});
if (!validated.success) {
res.status(400).send({
success: false,
message: "invalid request",
errors: validated.error.errors.map((i) => ({
path: Array.isArray(i.path) ? i.path[1] : i.path,
message: i.message,
})),
});
return;
}
// Store validated data in request object for next middleware
req.transformOptions = validated.data.options;
req.transformPath = validated.data.path;
next();
};
export const rateLimit = async (
req: Request,
res: Response,
next: NextFunction
) => {
const path = req.transformPath;
if (!path) {
res.status(400).send("middleware missing");
return;
}
// Create unique key for rate limiting (IP + image path)
const key = `${req.ip}-${path}`;
// Apply rate limit
const limit = await ratelimit.limit(key);
if (!limit.success) {
// Calculate retry time and send appropriate headers
const retry = Math.max(0, Math.floor((limit.reset - Date.now()) / 1000));
res.setHeader("Retry-After", retry);
res.status(429).send({
success: false,
message: "too many requests",
});
return;
}
return next();
};
src/features/transform/controller.ts
import { type Response, type Request } from "express";
import { transformService } from "./service";
import { safe } from "@lib/safe";
import { AppError } from "@lib/errors";
export const transformController = async (req: Request, res: Response) => {
const path = req.transformPath;
const options = req.transformOptions;
if (!options || !path) {
res.status(400).send("middleware missing");
return;
}
// Process the image transformation with error handling
const data = await safe(transformService({ path, options }));
if (!data.success) {
// Handle application-level errors with 400 status
if (data.error instanceof AppError) {
res.status(400).send({
success: false,
message: data.error.message,
});
return;
}
// Handle system errors with 500 status
res.status(500).send({
success: false,
message: "Something went wrong",
});
return;
}
// Set appropriate headers for the image response
res.setHeader(
"Content-Disposition",
`inline; filename=transformed.${data.data.info.format}`
);
res.setHeader("Content-Type", `image/${data.data.info.format}`);
res.setHeader("Cache-Control", "public, max-age=31536000, immutable");
// Send the transformed image
res.send(data.data.data);
};
here we are Setting the cache headres so hopefuly the CDN takes care of caching and serving the images that were allready transformed
src/features/transform/route.ts
import { Router } from "express";
import { transformController } from "./controller";
import { parseTransformRequest, rateLimit } from "./middleware";
const transformRouter = Router();
// Define the route using regex pattern to capture parameters
transformRouter.get(
/^\/transform\/([a-zA-Z0-9=,]+)\/(.+)$/,
parseTransformRequest,
rateLimit,
transformController
);
export default transformRouter;
Main Server File
Finally, let's set up our main server file:
src/server.ts
import transformRouter from "@features/transform/route";
import { UPLOAD_PATH } from "@lib/constants";
import express from "express";
import fs from "fs";
import path from "path";
const app = express();
// Create uploads directory if it doesn't exist
const uploadPath = path.join(process.cwd(), UPLOAD_PATH);
if (!fs.existsSync(uploadPath)) {
fs.mkdirSync(uploadPath);
}
// Register our transform router
app.use(transformRouter);
// Start the server
app.listen(3000, () => {
console.log("Server is running on port 3000");
});
Using the API
With everything set up, you can now use the API:
http://localhost:3000/transform/width=500,height=300,quality=80,format=webp/example-image.png
The format is:
/transform/[options]/[image-path]
Where:
- options are comma-separated key=value pairs
- image-path is the relative path to the image in the uploads directory
Building for Production
When you're ready to deploy:
- Run
npm run build
to compile the TypeScript code - Use
npm start
to run the production build
Conclusion
In this tutorial, we've built a basic image transformation API inspired by Cloudflare's Image API. The implementation uses TypeScript, Express, Sharp, and Upstash to provide a clean, well-structured service with:
- Type-safe request handling
- Input validation with Zod
- Rate limiting with Upstash
- High-performance image processing with Sharp
- Clear separation of concerns through a feature-based architecture
Github Repo Link (⭐ if u like it)
Top comments (0)