# "Project: image-processing API (Hono + sharp)"

This tutorial expands [blueprint 4](/en/docs/projects) into concrete steps. `sharp` carries native code, so smoke-testing the target environment before deployment is the first acceptance point of this project.

## Goal and final shape

```text
image-api/
├── routes/transform.ts   # POST /transform: validate → transform → store
├── services/image.ts     # the sharp pipeline, a pure testable function
├── storage.ts            # object-storage abstraction (local disk / S3-compatible)
├── main.ts               # Hono app assembly
└── deno.json
```

Request flow: the client uploads an image plus parameters as `multipart/form-data` → the server pre-checks the MIME type (client-supplied, so only a first filter), byte count, and dimension parameters → sharp decodes the real content to verify it and transforms → the result goes to object storage → the API returns a key.

## Setup

```bash
deno init --npm hono --template=deno image-api
cd image-api
deno add npm:sharp
```

## Milestone 1: a constrained transformation pipeline

### Wrap sharp as a pure function

```ts title="services/image.ts"
import sharp from "sharp";

const MAX_PIXELS = 40_000_000; // input pixel cap, width × height

export interface TransformOptions {
  width?: number;
  format: "webp" | "jpeg" | "png";
  quality?: number;
}

export function transformImage(input: Uint8Array, opts: TransformOptions) {
  return sharp(input, {
    // Reject inputs whose total pixel count exceeds the limit. The default
    // failOn is "warning" — keep it for untrusted input so corrupt data
    // errors out instead of being silently truncated.
    limitInputPixels: MAX_PIXELS,
  })
    .rotate() // honor EXIF orientation
    .resize({ width: opts.width, withoutEnlargement: true })
    .toFormat(opts.format, { quality: opts.quality ?? 80 })
    .toBuffer();
}
```

`limitInputPixels` is sharp's built-in resource guard (default roughly 268 megapixels); lower it deliberately to match your memory budget. Decompression bombs get stopped here rather than at OOM time.

### Storage abstraction

```ts title="storage.ts"
export interface Storage {
  put(key: string, data: Uint8Array, contentType: string): Promise<void>;
  publicUrl(key: string): string;
}

export class LocalStorage implements Storage {
  constructor(private dir: string, private baseUrl: string) {}
  async put(key: string, data: Uint8Array) {
    const path = `${this.dir}/${key}`;
    // Keys may contain subdirectories (images/<uuid>.webp); create the parent directory recursively first
    await Deno.mkdir(path.slice(0, path.lastIndexOf("/")), { recursive: true });
    await Deno.writeFile(path, data);
  }
  publicUrl(key: string) {
    return `${this.baseUrl}/${key}`;
  }
}
```

Development and tests use `LocalStorage` writing to `./data`; production swaps in an S3-compatible object store (`PutObject`, via AWS SDK v3 through npm compatibility) behind the same interface. Keys are server-generated (e.g. `crypto.randomUUID()`); the client's filename never enters a path.

### Route and validation

```ts title="routes/transform.ts"
import { Hono } from "hono";
import { transformImage } from "../services/image.ts";
import type { Storage } from "../storage.ts";

// Use a Set for a strict allowlist; `type in {...}` would also pass keys inherited from Object.prototype
const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB upload cap
const MAX_WIDTH = 4096;

export function transformRoute(storage: Storage) {
  const app = new Hono();

  app.post("/transform", async (c) => {
    const body = await c.req.parseBody();
    const file = body["file"];
    if (!(file instanceof File)) {
      return c.json({ error: "missing file" }, 400);
    }
    if (!ALLOWED_TYPES.has(file.type)) {
      return c.json({ error: "unsupported media type" }, 415);
    }
    if (file.size > MAX_BYTES) {
      return c.json({ error: "file too large" }, 413);
    }

    const widthParam = body["width"]?.toString();
    const width = widthParam ? Number(widthParam) : undefined;
    if (width !== undefined && (!Number.isInteger(width) || width < 1 || width > MAX_WIDTH)) {
      return c.json({ error: "invalid width" }, 400);
    }

    let output: Uint8Array;
    try {
      output = await transformImage(new Uint8Array(await file.arrayBuffer()), {
        width,
        format: "webp",
      });
    } catch {
      // sharp throws on corrupt or oversized input; don't echo internals
      return c.json({ error: "image processing failed" }, 422);
    }

    const key = `images/${crypto.randomUUID()}.webp`;
    await storage.put(key, output, "image/webp");
    return c.json({ key, url: storage.publicUrl(key) }, 201);
  });

  return app;
}
```

Error mapping is part of acceptance: wrong type 415, too large 413, bad parameters 400, processing failure 422, and a uniform 500 without internal details for anything else. Note that `File.type` comes from the client and is only the first filter — the real content check is sharp's decoder, and whatever fails to decode becomes a 422.

```ts title="main.ts"
import { Hono } from "hono";
import { serveStatic } from "hono/deno";
import { transformRoute } from "./routes/transform.ts";
import { LocalStorage } from "./storage.ts";

const app = new Hono();
app.get("/health", (c) => c.json({ ok: true }));
// In local mode, serve /files/* straight from ./data; once production moves to S3, delete this block and point URLs at the object store
app.use(
  "/files/*",
  serveStatic({ root: "./data", rewriteRequestPath: (p) => p.replace(/^\/files/, "") }),
);
app.route("/", transformRoute(new LocalStorage("./data", "/files")));

Deno.serve(app.fetch);
```

## Milestone 2: hardening

- **Request-body cap**: enforce it both at the platform layer (reverse proxy / Deploy) and via the `file.size` check; never trust `Content-Length` alone.
- **Timeouts**: wrap storage writes in `AbortSignal.timeout()` so a slow store can't pin a worker.
- **Concurrency**: sharp is CPU-bound; evaluate a per-instance concurrency ceiling and throttle or queue beyond it.
- **Output size**: `withoutEnlargement: true` prevents upscaling small images; converge on a single output format (webp), or negotiate via `Accept`.

## Test strategy

- **Pipeline unit tests**: generate fixtures with sharp itself (`sharp({ create: { width, height, channels: 3, background: "#333" } }).png().toBuffer()`), assert output format and dimensions; feed corrupt bytes and assert a throw; build an input exceeding `limitInputPixels` and assert rejection.
- **Route tests**: drive the app directly with Hono's `app.request()`, no network:

```ts title="routes/transform_test.ts"
import { assertEquals } from "jsr:@std/assert";
import { transformRoute } from "./transform.ts";
import { LocalStorage } from "../storage.ts";

Deno.test("rejects unsupported media types", async () => {
  const app = transformRoute(new LocalStorage(await Deno.makeTempDir(), ""));
  const form = new FormData();
  form.set("file", new File(["plain text"], "a.txt", { type: "text/plain" }));
  const res = await app.request("/transform", { method: "POST", body: form });
  assertEquals(res.status, 415);
});
```

- **Smoke test (native dependency)**: `sharp` loads prebuilt binaries through Node-API. On the target Deno version and target container/Deploy environment, run a minimal transform script **with the same permission flags as production** to confirm it loads and processes a 1×1 image. If this fails, do not ship:

```ts title="smoke.ts"
import sharp from "sharp";
const img = await sharp({
  create: { width: 1, height: 1, channels: 3, background: "#000" },
}).png().toBuffer();
const out = await sharp(img).resize(1, 1).webp().toBuffer();
console.log(`smoke ok, ${out.byteLength} bytes`);
```

## Deployment

- **Containers**: build on the official `denoland/deno` image, start with `deno task start`, and run the smoke test as a post-build CI step.
- **Permissions**: sharp is a Node-API native addon, so the hard requirements are a local `node_modules` (`nodeModulesDir: "auto"` or `--node-modules-dir`), approving its install lifecycle scripts (`deno approve-scripts`), and `--allow-ffi`. On top of that, add a minimal set: `--allow-net` (storage endpoint), `--allow-env` (allowlisted variable names), and `--allow-read=./data --allow-write=./data` in local-storage mode. The official sharp example also uses parts of `--allow-sys`; narrow it down by smoke-testing in the target environment, and never pre-grant `-A`:

```bash
deno run --allow-ffi --allow-env=PORT --allow-read=./data --allow-write=./data main.ts
```
- **Observability**: `/health` separates liveness from readiness; log the key, input byte count, duration, and error class — never image contents.

## Acceptance checklist

<Checklist id="image-api-acceptance" items={[
  "Four validation layers present: MIME allowlist, byte cap, pixel cap, dimension parameters",
  "Errors mapped to 400/413/415/422/500 without leaking internals",
  "Storage keys are server-generated; client filenames never enter paths",
  "The sharp smoke test passes on the target Deno version and deployment environment",
  "Production permissions are a minimal set (no -A), and the smoke test runs with the same flags",
  "deno fmt --check, deno lint, deno check, and deno test all pass"
]} />

Official references: [Hono on Deno](https://hono.dev/docs/getting-started/deno), [Hono request API](https://hono.dev/docs/api/request), [sharp constructor options](https://sharp.pixelplumbing.com/api-constructor).
