DocsProject blueprints

Project: image-processing API (Hono + sharp)

A size-limited image transformation service with Hono and npm:sharp, including MIME/pixel validation, object storage, and a native-dependency smoke test

This tutorial expands blueprint 4 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

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

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

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

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

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.

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:
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:
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:
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

  • 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, Hono request API, sharp constructor options.

Type to search all documentation.