Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Image Processing API — Multer + Sharp + the Observer Pattern

A small Express + TypeScript service that accepts an image upload and generates five resized WebP variants of it in one request. The resizing work is wired up using the Observer design pattern, which makes this a good teaching project for both image processing and classic OO patterns.


Table of contents

  1. What this project does
  2. Tech stack
  3. Project structure
  4. Setup
  5. Running the server
  6. Using the API
  7. How it works — the full walkthrough
  8. The Observer pattern, explained
  9. Request lifecycle diagram
  10. Extending the project
  11. Known gotchas
  12. Troubleshooting

1. What this project does

You POST a single image file to the server. The server:

  1. Saves the original file to disk (src/storage/).
  2. Creates five resized copies at widths 50, 100, 200, 500 and 600 px.
  3. Converts every copy to WebP at quality 80.
  4. Returns JSON metadata describing each generated file.

This is the same thing a CDN or a CMS does when it generates thumbnails — one upload, many derivative sizes.


2. Tech stack

Package Role
express HTTP server and routing
multer Parses multipart/form-data and writes the upload to disk
sharp High-performance image resizing / format conversion (libvips)
typescript Type safety
ts-node-dev Runs TypeScript directly and restarts on file changes
@types/express, @types/multer Type definitions

Why sharp? It's backed by libvips (native C), so it's several times faster and far lighter on memory than ImageMagick-based alternatives.


3. Project structure

ImageProcessing/
├── index.ts                  # Entry point — creates the Express app, listens on :3000
├── package.json
├── tsconfig.json
└── src/
    ├── index.ts              # Barrel file — re-exports the router
    ├── routes/
    │   └── index.ts          # Route definition + multer middleware
    ├── service/
    │   └── index.ts          # Observer pattern + sharp resize logic
    ├── utills/
    │   └── images.ts         # Multer disk-storage configuration
    └── storage/              # Uploaded originals + generated variants land here

The layering is deliberate and worth pointing out in a tutorial:

  • routes decides what URL and what middleware.
  • service decides what work happens.
  • utills holds infrastructure config (where files go).

Each layer can be tested and swapped independently.


4. Setup

Prerequisites

  • Node.js 18+ (Node 18 or 20 recommended; sharp ships prebuilt binaries for these)
  • npm

Install

git clone <your-repo-url>
cd ImageProcessing
npm install

npm install downloads a prebuilt sharp binary for your platform. No compiler toolchain is needed on macOS, Windows, or common Linux distros.

Create the storage folder

The app writes into src/storage/. If it doesn't exist, multer will throw ENOENT on the first upload:

mkdir -p src/storage

Tip: Git doesn't track empty folders. Add a src/storage/.gitkeep file so the directory exists after a fresh clone, and add src/storage/* (except .gitkeep) to .gitignore so generated images don't get committed.

TypeScript configuration

tsconfig.json uses a minimal, strict setup:

{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,              // lets you write `import express from "express"`
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true
  }
}

esModuleInterop is the important one here — without it, CommonJS packages like express and multer would need import * as express from "express".


5. Running the server

npm start

Which runs:

ts-node-dev index.ts --poll
  • ts-node-dev compiles TypeScript in memory and restarts the process when a file changes — no build step during development.
  • --poll uses polling instead of native filesystem events. This is needed inside Docker, WSL, and some network-mounted volumes where fs.watch doesn't fire.

You should see:

Server listening on port 3000

6. Using the API

POST /

Content-Type multipart/form-data
Field name image (must match exactly)
Body one image file

curl

curl -X POST http://localhost:3000/ \
  -F "image=@/path/to/photo.jpeg"

Response

{
  "success": true,
  "data": [
    { "format": "webp", "width": 50,  "height": 33,  "channels": 3, "size": 1204, "premultiplied": false },
    { "format": "webp", "width": 100, "height": 67,  "channels": 3, "size": 2810, "premultiplied": false },
    { "format": "webp", "width": 200, "height": 133, "channels": 3, "size": 7942, "premultiplied": false },
    { "format": "webp", "width": 500, "height": 333, "channels": 3, "size": 32118, "premultiplied": false },
    { "format": "webp", "width": 600, "height": 400, "channels": 3, "size": 44903, "premultiplied": false }
  ]
}

The array is sharp.OutputInfo — sharp's report of what it actually wrote.

Files produced

Uploading pexels-photo-18161318.jpeg gives you:

src/storage/
├── pexels-photo-18161318.jpeg        # original, untouched
├── 50_pexels-photo-18161318.jpeg     # 50px wide,  WebP data
├── 100_pexels-photo-18161318.jpeg
├── 200_pexels-photo-18161318.jpeg
├── 500_pexels-photo-18161318.jpeg
└── 600_pexels-photo-18161318.jpeg

7. How it works — the full walkthrough

7.1 Entry point — index.ts

import express from "express";
import { appRouter } from "./src";

const app = express();

app.use(appRouter);

app.listen(3000, () => {
  console.log("Server listening on port 3000");
});

Three lines of substance:

  • express() creates the application.
  • app.use(appRouter) mounts the router at the root path, so a route declared as POST / inside the router is reachable at POST /. If you changed this to app.use("/images", appRouter), the endpoint would become POST /images.
  • app.listen(3000, ...) binds the HTTP server.

Note there is no express.json() middleware, and that's correct — this endpoint receives multipart/form-data, not JSON. express.json() would ignore it anyway; multer is the parser for multipart bodies.

7.2 Barrel file — src/index.ts

import appRouter from "./routes";
export { appRouter };

This is a barrel: a file whose only job is to re-export things so consumers can import from a single, stable path (./src) instead of reaching into ./src/routes/index. It keeps the entry point decoupled from internal folder layout.

7.3 Storage config — src/utills/images.ts

const storage = multer.diskStorage({
  destination: (req, file, cb) => {
    cb(null, path.resolve(__dirname, "../", "storage"));
  },
  filename: (req, file, cb) => {
    cb(null, file.originalname);
  },
});

export const upload = multer({ storage });

Multer offers two storage engines:

  • memoryStorage — the file lands in req.file.buffer (RAM). Fast, but a large upload sits entirely in memory.
  • diskStorage — the file is streamed to disk and you get req.file.path. Used here, which is why sharp() can be handed a file path later.

Both callbacks follow Node's error-first convention: cb(error, value). Passing null as the first argument means "no error, here's the value".

  • destination resolves to src/storage. __dirname is src/utills at runtime, so ../storage lands in src/storage.
  • filename keeps the client's original filename. Simple and readable for a tutorial — see Known gotchas for why production code should not do this.

upload is now a configured multer instance, ready to be used as middleware.

7.4 Route — src/routes/index.ts

const route = Router();

route.post("/", upload.single("image"), imageService);

export default route;

upload.single("image") is middleware that:

  1. Parses the multipart/form-data body.
  2. Finds the part with field name image.
  3. Writes it to src/storage/<originalname>.
  4. Attaches a descriptor to req.file, then calls next().

By the time imageService runs, the file is already on disk. req.file looks like:

{
  fieldname: "image",
  originalname: "photo.jpeg",
  mimetype: "image/jpeg",
  destination: "/…/src/storage",
  filename: "photo.jpeg",
  path: "/…/src/storage/photo.jpeg",
  size: 482913
}

Related multer variants worth knowing: upload.array("images", 5) for multiple files under one field, upload.fields([...]) for several named fields, and upload.none() for text-only multipart forms.

7.5 The service — src/service/index.ts

This file holds the interesting part. It has three pieces:

a) The contracts

interface Subject {
  attach(observer: Observer): void;
}

interface Observer {
  resize(): Promise<sharp.OutputInfo>;
}

A Subject can collect observers. An Observer knows how to do one unit of work and report the result.

b) The subject — the registry and trigger

class ConcreteSubject implements Subject {
  private observers: Observer[] = [];

  attach(observer: Observer): void {
    this.observers.push(observer);
  }

  async fire() {
    const output: sharp.OutputInfo[] = [];
    for (const observer of this.observers) {
      const result = await observer.resize();
      output.push(result);
    }
    return output;
  }
}

attach registers. fire walks every registered observer, awaits its work, and collects the results. Crucially, the subject has no idea what resizing is — it only knows the Observer interface. That's the whole point of the pattern.

The loop is sequential (await inside for…of), so the five resizes happen one after another. That's a deliberate, readable choice; see Extending for the parallel version.

c) The observer — the actual image work

class ImageObserver implements Observer {
  constructor(private image: Express.Multer.File, private size: number) {}

  async resize(): Promise<sharp.OutputInfo> {
    return await sharp(this.image?.path)
      .resize(this.size, null, { fit: "cover" })
      .webp({ quality: 80 })
      .toFile(
        path.resolve(this.image.destination, `${this.size}_${this.image.filename}`)
      );
  }
}

TypeScript's parameter properties (private image, private size in the constructor signature) declare and assign the fields in one step — no explicit this.image = image needed.

The sharp chain reads left to right as a pipeline:

Call Effect
sharp(path) Opens the source image and returns a chainable pipeline
.resize(width, null, { fit: "cover" }) Fixes the width; null height means preserve aspect ratio
.webp({ quality: 80 }) Encodes as WebP; 80 is the usual quality/size sweet spot
.toFile(dest) Executes the pipeline and writes the file, resolving to OutputInfo

Nothing actually happens until .toFile() — sharp builds the operation graph lazily and runs it once at the end, in a single pass through libvips.

d) The handler that ties it together

export const imageService = async (req: Request, res: Response) => {
  const image = req.file as Express.Multer.File;
  const subject = new ConcreteSubject();

  subject.attach(new ImageObserver(image, 50));
  subject.attach(new ImageObserver(image, 100));
  subject.attach(new ImageObserver(image, 200));
  subject.attach(new ImageObserver(image, 500));
  subject.attach(new ImageObserver(image, 600));

  const data = await subject.fire();

  return res.status(200).json({ success: true, data });
};

Read it as a sentence: build a subject, register one observer per target size, fire once, return what came back.


8. The Observer pattern, explained

Definition: a subject maintains a list of observers and notifies them all when an event occurs, without knowing anything about what they do.

Mapping onto this project:

Pattern role This codebase
Subject ConcreteSubject — holds the observer list, fire() is the notification
Observer interface Observer — the resize() contract
Concrete observer ImageObserver — one per target size
Event The upload finishing, i.e. the call to fire()

Why it's a good fit here: adding a 1200px variant is a single line —

subject.attach(new ImageObserver(image, 1200));

— with zero changes to ConcreteSubject. That's the Open/Closed Principle in practice: open for extension, closed for modification.

Where it stretches the classic definition: in textbook Observer, notify() is fire-and-forget and observers don't return values. Here fire() awaits each observer and collects results, which makes it closer to an async pipeline or a command dispatcher. That's a fine adaptation for a real API — but worth naming explicitly so the concept lands accurately.

Observers also don't have to be resizers. You could attach a WatermarkObserver, a ThumbnailUploadObserver that pushes to S3, or a MetadataObserver that writes a database row — the subject wouldn't change.


9. Request lifecycle diagram

Client
  │  POST /  (multipart/form-data, field: image)
  ▼
Express app  (index.ts)
  │
  ▼
appRouter  (src/routes/index.ts)
  │
  ▼
multer  upload.single("image")        ← src/utills/images.ts
  │   writes original → src/storage/<name>
  │   sets req.file
  ▼
imageService  (src/service/index.ts)
  │
  ├─ new ConcreteSubject()
  ├─ attach ImageObserver(50)
  ├─ attach ImageObserver(100)
  ├─ attach ImageObserver(200)
  ├─ attach ImageObserver(500)
  ├─ attach ImageObserver(600)
  │
  ▼
subject.fire()
  │   for each observer:
  │     sharp(src) → resize(w) → webp(80) → toFile(<w>_<name>)
  │
  ▼
res.status(200).json({ success: true, data })

10. Extending the project

Run the resizes in parallel

The sequential loop is easy to read but leaves throughput on the table. Since each observer writes to a different file, they're independent:

async fire() {
  return Promise.all(this.observers.map((o) => o.resize()));
}

Prefer Promise.allSettled if you'd rather report partial success than fail the whole request when one variant errors.

Serve the generated images

app.use("/images", express.static(path.resolve(__dirname, "src/storage")));

Then 50_photo.jpeg is available at http://localhost:3000/images/50_photo.jpeg.

Restrict uploads to images

export const upload = multer({
  storage,
  limits: { fileSize: 5 * 1024 * 1024 },   // 5 MB
  fileFilter: (req, file, cb) => {
    if (!file.mimetype.startsWith("image/")) {
      return cb(new Error("Only image uploads are allowed"));
    }
    cb(null, true);
  },
});

Make sizes configurable

const SIZES = [50, 100, 200, 500, 600];
SIZES.forEach((size) => subject.attach(new ImageObserver(image, size)));

Add a new kind of observer

class WatermarkObserver implements Observer {
  constructor(private image: Express.Multer.File) {}

  async resize(): Promise<sharp.OutputInfo> {
    return sharp(this.image.path)
      .composite([{ input: "watermark.png", gravity: "southeast" }])
      .toFile(path.resolve(this.image.destination, `wm_${this.image.filename}`));
  }
}

Attach it alongside the resizers. ConcreteSubject needs no changes — which is exactly the payoff the pattern promises.


11. Known gotchas

These are real characteristics of the current code. Leaving them in a tutorial is fine, but call them out so learners don't ship them.

1. Output files keep the source extension. 600_photo.jpeg contains WebP data, not JPEG. Browsers sniff content type and will render it, but the filename lies. Fix by swapping the extension:

const base = path.parse(this.image.filename).name;
.toFile(path.resolve(this.image.destination, `${this.size}_${base}.webp`))

2. { fit: "cover" } does nothing here. fit only applies when both width and height are given. With height null, sharp always preserves aspect ratio. Either drop the option or pass a real height.

3. Filename collisions. filename: file.originalname means two users uploading photo.jpg overwrite each other. Production fix:

filename: (req, file, cb) => {
  const unique = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
  cb(null, `${unique}${path.extname(file.originalname)}`);
}

4. No error handling. If req.file is undefined (no file sent) or sharp throws on a corrupt file, the async handler rejects and — in Express 4 — the request hangs until it times out. Wrap the body in try/catch and add an error middleware:

export const imageService = async (req: Request, res: Response) => {
  try {
    const image = req.file;
    if (!image) return res.status(400).json({ success: false, message: "No image uploaded" });
    // …
  } catch (err) {
    return res.status(500).json({ success: false, message: (err as Error).message });
  }
};

5. Upscaling. Uploading a 300px-wide image still produces 500px and 600px variants — sharp enlarges by default. Pass withoutEnlargement: true to the resize options to skip that.

6. Hardcoded port. 3000 is baked in. Use process.env.PORT ?? 3000.

7. Folder name typo. src/utills should be src/utils. Harmless, but worth fixing before anyone else reads the repo.


12. Troubleshooting

Symptom Cause Fix
ENOENT: no such file or directory … /storage Storage folder missing mkdir -p src/storage
MulterError: Unexpected field Form field isn't named image Use -F "image=@file.jpg"
Request hangs, no response No file sent → req.file undefined → handler throws Add the if (!image) guard from gotcha #4
sharp install fails No prebuilt binary for your platform/Node version Use Node 18/20, then npm rebuild sharp
Cannot find module './src' Running from the wrong directory Run npm start from the project root
Changes don't trigger a restart Filesystem events not firing (Docker/WSL) --poll is already set; raise the interval with --poll-interval
EADDRINUSE: port 3000 Another process holds the port lsof -ti:3000 | xargs kill or change the port

License

ISC

About

No description, website, or topics provided.

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages