文档实战项目

实战:图片处理 API(Hono + sharp)

用 Hono 与 npm:sharp 构建受限尺寸的图片转换服务,含 MIME/像素校验、对象存储与原生依赖 smoke test

本教程把 项目蓝图 4 展开为可执行步骤。sharp 是原生依赖,部署前必须在目标环境做 smoke test——这是本项目的首要验收点。

目标与最终形态

image-api/
├── routes/transform.ts   # POST /transform:校验 → 转换 → 存储
├── services/image.ts     # sharp 管线,纯函数可测
├── storage.ts            # 对象存储抽象(本地磁盘 / S3 兼容)
├── main.ts               # Hono app 装配
└── deno.json

请求流:客户端 multipart/form-data 上传图片和参数 → 服务端预检 MIME(来自客户端,仅作第一道过滤)、字节数与尺寸参数 → sharp 解码验证真实内容并转换 → 写入对象存储 → 返回 key。

初始化

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

里程碑一:受限的转换管线

sharp 封装为纯函数

import sharp from "sharp";

const MAX_PIXELS = 40_000_000; // 宽 × 高的输入像素上限

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

export function transformImage(input: Uint8Array, opts: TransformOptions) {
  return sharp(input, {
    // 拒绝像素总量超限的输入;默认 failOn 为 "warning",
    // 对不受信任的输入保持默认,让坏数据直接报错而不是静默截断
    limitInputPixels: MAX_PIXELS,
  })
    .rotate() // 按 EXIF 方向转正
    .resize({ width: opts.width, withoutEnlargement: true })
    .toFormat(opts.format, { quality: opts.quality ?? 80 })
    .toBuffer();
}

limitInputPixels 是 sharp 自带的资源防线(默认约 2.68 亿像素),按服务内存预算主动调低。像素解压炸弹在这里被挡掉,而不是在 OOM 时才被发现。

存储抽象

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}`;
    // key 可能含子目录(images/<uuid>.webp),先递归创建父目录
    await Deno.mkdir(path.slice(0, path.lastIndexOf("/")), { recursive: true });
    await Deno.writeFile(path, data);
  }
  publicUrl(key: string) {
    return `${this.baseUrl}/${key}`;
  }
}

开发和测试用 LocalStorage 写入 ./data;生产换成 S3 兼容对象存储(PutObject,可通过 npm 兼容层使用 AWS SDK v3),接口不变。key 由服务端生成(如 crypto.randomUUID()),绝不使用客户端提供的文件名。

路由与校验

import { Hono } from "hono";
import { transformImage } from "../services/image.ts";
import type { Storage } from "../storage.ts";

// 用 Set 做严格白名单;`type in {...}` 会放过 Object.prototype 上的继承键
const ALLOWED_TYPES = new Set(["image/jpeg", "image/png", "image/webp"]);
const MAX_BYTES = 10 * 1024 * 1024; // 10 MB 上传上限
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 对损坏或超限输入抛错;不回显内部错误细节
      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;
}

错误映射是验收的一部分:类型不符 415、超限 413、参数非法 400、处理失败 422,内部异常统一 500 且不泄露细节。注意 File.type 来自客户端,只是第一层过滤——真正的内容校验由 sharp 解码完成,解不开就是 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 }));
// 本地模式直接回源 /files/* 到 ./data;生产换 S3 后删除这段,URL 指向对象存储
app.use(
  "/files/*",
  serveStatic({ root: "./data", rewriteRequestPath: (p) => p.replace(/^\/files/, "") }),
);
app.route("/", transformRoute(new LocalStorage("./data", "/files")));

Deno.serve(app.fetch);

里程碑二:加固

  • 请求体上限:平台层(反向代理 / Deploy)和 file.size 检查双重限制;不要只信 Content-Length
  • 超时:存储写入包 AbortSignal.timeout(),避免慢存储拖住 worker。
  • 并发:sharp 是 CPU 密集操作,评估单实例并发上限,必要时限流或排队。
  • 输出尺寸withoutEnlargement: true 防止小图被放大,输出格式收敛到 webp 一种(或按 Accept 协商)。

测试策略

  • 管线单测:用 sharp 自己生成测试图(sharp({ create: { width, height, channels: 3, background: "#333" } }).png().toBuffer()),断言输出格式、尺寸;喂损坏字节断言抛错;构造超过 limitInputPixels 的输入断言被拒绝。
  • 路由测试:Hono 的 app.request() 直接驱动,不走网络:
import { assertEquals } from "jsr:@std/assert";
import { transformRoute } from "./transform.ts";
import { LocalStorage } from "../storage.ts";

Deno.test("拒绝不支持的类型", 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(原生依赖)sharp 通过 Node-API 加载预编译二进制。在目标 Deno 版本、目标容器/Deploy 环境跑一个最小转换脚本,用与生产相同的权限标志确认能加载并能处理一张 1×1 图片。这一步不过,不上线:
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`);

部署

  • 容器:基于官方 denoland/deno 镜像,deno task start 启动;把 smoke test 放进 CI 的镜像构建后步骤。
  • 权限:sharp 是 Node-API 原生插件,硬性要求是本地 node_modulesnodeModulesDir: "auto"--node-modules-dir)、批准其 install 生命周期脚本(deno approve-scripts),以及 --allow-ffi。在此之上按最小集合追加:--allow-net(存储端点)、--allow-env(白名单变量名)、本地存储模式加 --allow-read=./data --allow-write=./data。官方 sharp 示例还会用到部分 --allow-sys;以 smoke test 在目标环境下实测收窄,不要预先给 -A
deno run --allow-ffi --allow-env=PORT --allow-read=./data --allow-write=./data main.ts
  • 观测/health 区分存活与就绪;日志记录 key、输入字节数、耗时与错误分类,不记录图片内容。

验收清单

  • MIME 白名单、字节上限、像素上限、尺寸参数四层校验齐备
  • 错误映射为 400/413/415/422/500,不回显内部细节
  • 存储 key 由服务端生成,客户端文件名不参与路径
  • sharp smoke test 在目标 Deno 版本与部署环境通过
  • 生产权限为最小集合(无 -A),smoke test 用同一权限运行
  • deno fmt --check、deno lint、deno check、deno test 全部通过

官方参考:Hono on DenoHono request APIsharp constructor 选项

输入关键词搜索全部文档。