Router cho Workers: vanilla, Itty, hay Hono

3 lựa chọn: vanilla fetch (0 bundle), Itty (3KB), Hono (13KB). Cú pháp, chuỗi middleware, validate Zod, khi nào chọn cái nào, và vì sao blog này dùng vanilla dù có 40 route.

· 6 phút đọc · Read in English
So sánh 3 router cho Worker: vanilla fetch + URLPattern (0 bundle), Itty Router ~3KB functional, Hono ~13KB kiểu Express, cùng middleware chain và validate request bằng Zod

TL;DR

3 lựa chọn cho route Worker:

  1. Vanilla: switch (url.pathname) + URLPattern. Không có bundle. Tự làm mọi thứ.
  2. Itty Router: ~3KB, phong cách functional, chuỗi middleware.
  3. Hono: ~13KB, kiểu Express, RPC client, hệ sinh thái middleware lớn.

Luận điểm chính:

“Hono cho mọi thứ” là lời khuyên phổ biến nhưng không phải đúng cho mọi khối lượng công việc. Blog này có 40+ route và chạy vanilla. 50 route, team quen Express, cần OpenAPI: Hono. 10-50 route, muốn functional, nhẹ: Itty. < 10 route hoặc cực nhạy với cold start: vanilla.

Bài này đi qua cú pháp từng router, pattern middleware, validate request với Zod, và lý do blog này chọn vanilla dù có nhiều route.


Dành cho ai

  • Dev mới bắt đầu project Worker, đang chọn router.
  • Người đang dùng vanilla và cân nhắc chuyển sang Hono khi mở rộng.
  • Ai đã dùng Express/Fastify và muốn cái tương đương trên Workers.

Nên đọc trước: Part 2 (handler fetch), Part 3 (mental model 3-binding).

Sau bài này bạn sẽ:

  • Biết cú pháp 3 router phổ biến.
  • Dựng chuỗi middleware (CORS, auth, rate limit, validate).
  • Quyết định được router nào cho project của mình.

Bài này không nói về gì

  • Framework full-stack (Remix, Astro, SvelteKit): Part 11.
  • ORM (Drizzle, Prisma): Part 10.
  • Sinh spec OpenAPI: chỉ nhắc là Hono hỗ trợ, không đi sâu.

So sánh 3 lựa chọn

So sánh vanilla, Itty Router, Hono qua 7 tiêu chí: kích thước bundle (0KB vs 3KB vs 13KB), type safety, middleware, tham số route, validate request, CORS/header, test. Khi nào chọn cái nào.


Vanilla: không bundle

Cách đơn giản nhất. Handler fetch + switch hoặc if/else.

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);

    if (url.pathname === "/") {
      return new Response("Home");
    }

    if (url.pathname.startsWith("/api/posts/")) {
      const slug = url.pathname.slice("/api/posts/".length);
      return handleGetPost(slug, env);
    }

    if (url.pathname === "/api/posts" && request.method === "POST") {
      return handleCreatePost(request, env);
    }

    return new Response("Not found", { status: 404 });
  }
};

Với nhiều route, dùng URLPattern (chuẩn Web):

const patterns = {
  getPost: new URLPattern({ pathname: "/api/posts/:slug" }),
  listPosts: new URLPattern({ pathname: "/api/posts" }),
  adminDashboard: new URLPattern({ pathname: "/admin/*" }),
};

async fetch(request, env) {
  const url = request.url;

  const getPostMatch = patterns.getPost.exec(url);
  if (getPostMatch) {
    const { slug } = getPostMatch.pathname.groups;
    return handleGetPost(slug, env);
  }

  if (patterns.adminDashboard.test(url)) {
    return handleAdmin(request, env);
  }

  // ...
}

Khi nào vanilla thắng

  • < 10 route: switch đủ, thêm framework là thừa.
  • Cực nhạy với cold start: 0 byte bundle = isolate nạp nhanh nhất (nhưng thực tế ~5ms cold start đã rất rẻ).
  • Học Workers API thuần: không bị framework che.
  • Nhiều route nhưng code đã tổ chức tốt: tách thành module, pattern ổn định.

Blog này có 40+ route (trang public, /api/, /admin/, /og/, /preview/) vẫn chạy vanilla. Lý do: code đã chín, không muốn thêm dependency.

Cú pháp vanilla thật gọn

// worker/index.ts — blog này
import { handleSubscribe } from "./routes/subscribe";
import { handleWebmention } from "./routes/webmention";
import { handleAdmin } from "./routes/admin";

export default {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const url = new URL(request.url);
    const path = url.pathname;

    if (path.startsWith("/api/subscribe")) return handleSubscribe(request, env, ctx);
    if (path.startsWith("/api/webmention")) return handleWebmention(request, env, ctx);
    if (path.startsWith("/admin/")) return handleAdmin(request, env, ctx);
    if (path.startsWith("/og/") && path.endsWith(".png")) return handleOg(request, env, ctx);

    // Everything else: static asset
    return env.ASSETS.fetch(request);
  }
};

Bảng dispatch rõ ràng, mỗi route handler ở file riêng. Không khác Hono nhiều.


Itty Router: functional, nhẹ

Itty Router ~3KB sau tree-shake. Phong cách functional, xích các middleware lại với nhau.

npm install itty-router
import { Router } from "itty-router";

const router = Router();

router
  .get("/api/posts", async (request, env) => {
    const rows = await env.DB.prepare("SELECT * FROM posts").all();
    return Response.json(rows.results);
  })
  .get("/api/posts/:slug", async (request, env) => {
    const { slug } = request.params;
    const post = await env.DB.prepare("SELECT * FROM posts WHERE slug = ?")
      .bind(slug).first();
    return post ? Response.json(post) : new Response("Not found", { status: 404 });
  })
  .post("/api/posts", async (request, env) => {
    const body = await request.json();
    // ...
    return Response.json({ created: true }, { status: 201 });
  })
  .all("*", () => new Response("Not found", { status: 404 }));

export default {
  async fetch(request, env, ctx) {
    return router.fetch(request, env, ctx);
  }
};

Middleware Itty

Middleware là function trả void (đi tiếp trong chuỗi) hoặc trả Response (cắt ngắn).

// Middleware CORS
const withCors = (request: Request) => {
  if (request.method === "OPTIONS") {
    return new Response(null, {
      status: 204,
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
      }
    });
  }
};

// Middleware Auth
const withAuth = async (request: Request, env: Env) => {
  const token = request.headers.get("Authorization")?.slice("Bearer ".length);
  if (!token) return new Response("Unauthorized", { status: 401 });
  const claims = await verifyToken(token, env);
  if (!claims) return new Response("Invalid token", { status: 401 });
  (request as any).claims = claims;
};

router
  .all("*", withCors)
  .all("/admin/*", withAuth)
  .get("/admin/stats", async (request, env) => {
    const claims = (request as any).claims;
    // ...
  });

Khi nào Itty thắng

  • 10-50 route: đủ tính năng, không thừa.
  • Team thích functional: không muốn class, decorator.
  • Ít dependency: 3KB, không kéo theo transitive.
  • Không cần validator có sẵn: nếu OK với Zod viết tay.

Hono: đầy đủ tính năng

Hono ~13KB. API kiểu Express, hệ sinh thái middleware lớn, RPC client type-safe, sinh OpenAPI.

npm install hono
import { Hono } from "hono";
import { cors } from "hono/cors";
import { bearerAuth } from "hono/bearer-auth";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";

type Env = { Bindings: { DB: D1Database } };

const app = new Hono<Env>();

// Middleware toàn cục
app.use("*", cors());
app.use("/admin/*", bearerAuth({ token: "secret" }));

// Route với validator
const postSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(1),
  tags: z.array(z.string()).optional(),
});

app.post(
  "/api/posts",
  zValidator("json", postSchema),
  async (c) => {
    const { title, body, tags } = c.req.valid("json");
    await c.env.DB.prepare("INSERT INTO posts (title, body) VALUES (?, ?)")
      .bind(title, body).run();
    return c.json({ created: true }, 201);
  }
);

app.get("/api/posts/:slug", async (c) => {
  const slug = c.req.param("slug");
  const post = await c.env.DB.prepare("SELECT * FROM posts WHERE slug = ?")
    .bind(slug).first();
  return post ? c.json(post) : c.notFound();
});

export default app;

Hono context (c)

c gộp request, env, response builder:

app.get("/test", async (c) => {
  c.req.query("q");           // query param
  c.req.param("id");          // path param
  c.req.header("X-Custom");   // header
  c.req.json();               // body JSON

  c.env.DB;                   // binding
  c.executionCtx.waitUntil(); // ctx

  return c.json({ ok: true }, 200, { "X-Custom": "yes" });
});

Khác vanilla: handler nhận c thay vì (request, env, ctx) tách rời.

Validator Hono

zValidator kết hợp Zod:

const querySchema = z.object({
  page: z.coerce.number().min(1).default(1),
  limit: z.coerce.number().min(1).max(100).default(20),
});

app.get("/api/posts",
  zValidator("query", querySchema),
  async (c) => {
    const { page, limit } = c.req.valid("query");
    const offset = (page - 1) * limit;
    // ...
  }
);

Validator fail → tự trả 400 với schema lỗi. Không throw trong handler.

Hono RPC: client type-safe

Tính năng killer. Server + client chia sẻ type:

// server
const app = new Hono()
  .get("/api/posts/:slug", (c) => {
    const slug = c.req.param("slug");
    return c.json({ slug, title: "...", body: "..." });
  })
  .post("/api/posts",
    zValidator("json", postSchema),
    (c) => c.json({ created: true })
  );

export type AppType = typeof app;
export default app;
// client (another app, browser, Node, whatever)
import { hc } from "hono/client";
import type { AppType } from "../worker/server";

const client = hc<AppType>("https://my-app.workers.dev");

// Type-safe: client.api.posts[":slug"] tồn tại trong type
const res = await client.api.posts[":slug"].$get({ param: { slug: "hello" } });
const post = await res.json();
// post is typed correctly

Khi client + server cùng repo, RPC rất tốt. Khi client bên thứ ba, vẫn phải OpenAPI.

Khi nào Hono thắng

  • 50+ route: chuỗi middleware + router lồng không rối.
  • Cần validate request sạch: middleware Zod.
  • Sinh spec OpenAPI tự động: @hono/zod-openapi.
  • RPC client cùng repo: type-safe từ đầu đến cuối.
  • Team quen Express: đường cong học ngắn.

Pattern chuỗi middleware

Chuỗi middleware cho Worker: request đi qua middleware CORS, Auth JWT, giới hạn tốc độ, validator Zod rồi mới tới handler. Mỗi middleware có thể cắt ngắn trả response sớm nếu fail (OPTIONS → 204, JWT không hợp lệ → 401, vượt rate → 429, schema sai → 400).

Middleware nên có thứ tự:

  1. CORS: preflight OPTIONS trả ngay.
  2. Auth: từ chối sớm nếu không có token hợp lệ.
  3. Giới hạn tốc độ: kiểm tra trước khi đụng storage.
  4. Validator: parse + validate body/query.
  5. Handler: logic nghiệp vụ, đã yên tâm đầu vào sạch.

Ví dụ Hono

import { Hono } from "hono";
import { cors } from "hono/cors";
import { rateLimiter } from "./middleware/rate-limit";
import { requireAdmin } from "./middleware/auth";
import { zValidator } from "@hono/zod-validator";

const app = new Hono<Env>();

app.use("*", cors({ origin: "https://my-app.com" }));
app.use("/admin/*", requireAdmin);
app.use("/api/*", rateLimiter({ max: 60, window: 60 }));

app.post("/api/contact",
  zValidator("json", contactSchema),
  async (c) => {
    const { email, message } = c.req.valid("json");
    // Handler: đã qua CORS, giới hạn tốc độ, validate schema
    await sendEmail(c.env, email, message);
    return c.json({ ok: true });
  }
);

Vanilla ghép thủ công

type Middleware = (request: Request, env: Env, ctx: ExecutionContext) => Promise<Response | void>;

async function compose(request: Request, env: Env, ctx: ExecutionContext, middlewares: Middleware[], handler: Middleware) {
  for (const mw of middlewares) {
    const result = await mw(request, env, ctx);
    if (result instanceof Response) return result;
  }
  return handler(request, env, ctx) as Promise<Response>;
}

async fetch(request, env, ctx) {
  if (url.pathname === "/api/contact" && request.method === "POST") {
    return compose(request, env, ctx, [
      withCors,
      withRateLimit(60, 60),
      withZod(contactSchema),
    ], handleContact);
  }
}

Làm được nhưng dài. Đến đây Itty/Hono tiện hơn rõ.


Validate request với Zod

Dù dùng router nào, Zod là chuẩn de-facto cho validate.

import { z } from "zod";

const postSchema = z.object({
  title: z.string().min(1).max(200),
  body: z.string().min(10),
  tags: z.array(z.string().regex(/^[a-z0-9-]+$/)).max(10).optional(),
  published: z.boolean().default(false),
});

// Vanilla
async function handleCreatePost(request: Request, env: Env) {
  let body;
  try {
    body = await request.json();
  } catch {
    return new Response("Invalid JSON", { status: 400 });
  }

  const parsed = postSchema.safeParse(body);
  if (!parsed.success) {
    return Response.json({ errors: parsed.error.flatten() }, { status: 400 });
  }

  const { title, body: postBody, tags, published } = parsed.data;
  // ...
}

// Hono với zValidator (ngắn gọn hơn)
app.post("/posts", zValidator("json", postSchema), async (c) => {
  const data = c.req.valid("json");  // đã có type đầy đủ
  // ...
});

Test router

Cả 3 router đều test cùng cách với vitest-pool-workers:

import { SELF } from "cloudflare:test";
import { describe, it, expect } from "vitest";

describe("API routes", () => {
  it("GET /api/posts returns list", async () => {
    const res = await SELF.fetch("https://example.com/api/posts");
    expect(res.status).toBe(200);
    const posts = await res.json();
    expect(Array.isArray(posts)).toBe(true);
  });

  it("POST /api/posts validates body", async () => {
    const res = await SELF.fetch("https://example.com/api/posts", {
      method: "POST",
      body: JSON.stringify({ title: "" }),  // validate thất bại
    });
    expect(res.status).toBe(400);
  });

  it("OPTIONS triggers CORS preflight", async () => {
    const res = await SELF.fetch("https://example.com/api/posts", {
      method: "OPTIONS",
    });
    expect(res.status).toBe(204);
    expect(res.headers.get("access-control-allow-methods")).toContain("POST");
  });
});

Với Hono có thêm app.request():

const res = await app.request("/api/posts");
expect(res.status).toBe(200);

Nhanh hơn SELF.fetch (không khởi động isolate riêng).


Vì sao blog này vẫn vanilla

40+ route nhưng vẫn vanilla, không Hono. Lý do:

  1. Code đã chín: 18 tháng chạy, pattern ổn định, không cần refactor.
  2. Không cần OpenAPI: API nội bộ, không có bên thứ ba tiêu thụ.
  3. Không có RPC client: frontend là Astro SSG, fetch qua fetch() chuẩn.
  4. Kỷ luật dependency: mỗi package npm là gánh nặng bảo trì. Vanilla = 0 deps.
  5. Rõ ràng khi debug: lớp trừu tượng của Hono đôi khi ẩn luồng request.

Ngược lại, nếu bắt đầu mới:

  • Project có bên thứ ba tiêu thụ API → Hono với OpenAPI.
  • Ứng dụng full-stack với client có type → Hono RPC.
  • Nhiều endpoint với validator phức tạp → Hono + zValidator.

“Bắt đầu mới = Hono” là mặc định đúng. “Refactor từ vanilla sang Hono” = tính chi phí/lợi ích kỹ.


Gotcha

URLPattern chưa hỗ trợ đầy đủ ở mọi browser

Với Worker OK (Cloudflare hỗ trợ). Nhưng sao chép code sang Deno/Node cần kiểm tra.

② Thứ tự middleware Hono là quan trọng

// SAI: giới hạn tốc độ trước auth → bot không có token vẫn chạm DO giới hạn tốc độ
app.use("/api/*", rateLimiter);
app.use("/api/*", requireAuth);

// ĐÚNG: auth trước → từ chối 401 sớm
app.use("/api/*", requireAuth);
app.use("/api/*", rateLimiter);

Hoặc: endpoint ẩn danh → giới hạn tốc độ trước (chặn bot). Endpoint đã xác thực → auth trước.

③ Middleware Itty không chain Promise

// SAI: trả Promise không await → middleware không block
router.all("*", (request, env) => {
  someAsync(request, env);  // không await
});

// ĐÚNG
router.all("*", async (request, env) => {
  await someAsync(request, env);
});

④ Hono RPC không tự sync khi triển khai lệch

Client type sinh từ typeof app export. Triển khai server mới mà client chưa rebuild → type cũ, runtime lệch.

Fix: monorepo + triển khai atomic, hoặc API có version (/v1/, /v2/).

app.request() của Hono không test middleware gọi bên ngoài

// middleware gọi API Turnstile thực
app.use("/api/subscribe", turnstileMiddleware);

app.request() chạy trong test runner Node, gọi Turnstile thật → chậm + flaky. Dùng SELF.fetch() với vitest-pool-workers để mock middleware hoặc fetch.


Chọn như thế nào

Sơ đồ đơn giản:

Bắt đầu project mới?
├── Có → Hono (mặc định, mở rộng tốt)
└── Không
    └── Code vanilla đã chạy ổn?
        ├── Có → giữ vanilla, không refactor vì trào lưu
        └── Không → đánh giá điểm đau:
            ├── Validate lặp → Zod + Hono zValidator
            ├── Middleware lộn xộn → Hono hoặc Itty
            └── Chỉ cần 1-2 route mới → thêm pattern vanilla

Lời khuyên thực tế

  • Không loại bỏ vanilla vì “không cool”. 0 dependency, 0 đường cong học cho team mới, rõ ràng.
  • Không chọn Hono “vì ai cũng nói Hono”. Kiểm tra khối lượng công việc cụ thể.
  • Itty là điểm ngọt cho nhiều project cỡ vừa, ít được thảo luận.
  • Trải nghiệm viết test quan trọng ngang thiết kế API. app.request() của Hono + router.fetch() của Itty đều test nhanh.

Production checklist

  • Router được chọn khớp với quy mô (< 10 / 10-50 / 50+ route).
  • Thứ tự middleware: CORS → Auth → Rate → Validate → Handler.
  • Validate với Zod hoặc tương đương, không tin đầu vào thô.
  • Response lỗi nhất quán (định dạng JSON, không trộn HTML + JSON).
  • Handler 404 tường minh, không lộ stack trace.
  • Test phủ happy path + validate fail + auth fail cho từng route.
  • Kiểm soát kích thước bundle (xem đầu ra wrangler deploy).
  • TypeScript strict mode để bắt chữ ký router sai sớm.

Kết

3 router, 3 đánh đổi khác nhau. Vanilla cho đơn giản + 0 deps. Itty cho nhẹ + functional. Hono cho đầy đủ tính năng + RPC.

Không có “router tốt nhất”. Có “khớp khối lượng công việc”.

Part 10 tới: ORM trên D1 — Drizzle vs Prisma. SQL thô vs ORM, type safety, migration, và khi nào bỏ qua ORM hoàn toàn.


Tham khảo