DEV Community

islamBelabbes
islamBelabbes

Posted on

1

Building Basic Cloudflare Transform images Api Clone with TypeScript

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
Enter fullscreen mode Exit fullscreen mode

Now install the required dependencies:

npm install express sharp zod @upstash/ratelimit
npm install -D tsx tsconfig-paths tsup @types/express typescript
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/*"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

.env

UPSTASH_REDIS_REST_URL=""
UPSTASH_REDIS_REST_TOKEN=""
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode
  • 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";
Enter fullscreen mode Exit fullscreen mode

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";
  }
}
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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,
});
Enter fullscreen mode Exit fullscreen mode

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"];
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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();
};
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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");
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The format is:

/transform/[options]/[image-path]
Enter fullscreen mode Exit fullscreen mode

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:

  1. Run npm run build to compile the TypeScript code
  2. 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)

Heroku

Built for developers, by developers.

Whether you're building a simple prototype or a business-critical product, Heroku's fully-managed platform gives you the simplest path to delivering apps quickly — using the tools and languages you already love!

Learn More

Top comments (0)

👋 Kindness is contagious

Engage with a wealth of insights in this thoughtful article, valued within the supportive DEV Community. Coders of every background are welcome to join in and add to our collective wisdom.

A sincere "thank you" often brightens someone’s day. Share your gratitude in the comments below!

On DEV, the act of sharing knowledge eases our journey and fortifies our community ties. Found value in this? A quick thank you to the author can make a significant impact.

Okay