跳到內容

ch05-hono

取得並執行

這個範例可以獨立 clone 執行,不依賴其他章節。

npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch05-hono
cd ch05-hono
npm install

可用指令

npm run dev	# wrangler dev
npm run deploy	# wrangler deploy
npm run typecheck	# tsc --noEmit
npm run cf-typegen	# wrangler types --env-interface CloudflareBindings

說明

Companion example for docs/05-hono.md.

src/
├── types.ts # AppEnv — single source of the Hono generic shape
├── middleware.ts # requestId, accessLog, requireApiKey
├── validate.ts # zValidator wrapper that THROWS (see below)
├── routes/links.ts # chained routes, RPC-compatible
└── index.ts # composition, onError, notFound, AppType export
Terminal window
npm install
echo 'API_KEY="dev-key-123"' > .dev.vars
npm run cf-typegen
npm run dev
Terminal window
B=localhost:8787; H='Authorization: Bearer dev-key-123'
curl -s $B/health
curl -s $B/v1/links # 401
curl -s -X POST $B/v1/links -H "$H" -H 'content-type: application/json' \
-d '{"slug":"cf","url":"https://developers.cloudflare.com/workers/"}'
curl -s "$B/v1/links?limit=5" -H "$H"
curl -s $B/v1/links/cf -H "$H"

2026-07-28 · hono@4.12.32 · zod@4.4.3 · valibot@1.4.2 · wrangler@4.114.0

1. The Env collision produces a uniquely unhelpful error

Section titled “1. The Env collision produces a uniquely unhelpful error”

new Hono<Env>(), where Env is the global from a default wrangler types run:

error TS2559: Type 'Env' has no properties in common with type 'Env'.
error TS18048: 'c.env' is possibly 'undefined'.
error TS2339: Property 'APP_NAME' does not exist on type 'object'.

Fix: wrangler types --env-interface CloudflareBindings, then define AppEnv once in src/types.ts.

2. Validation library dominates bundle size

Section titled “2. Validation library dominates bundle size”

Measured with wrangler deploy --dry-run:

StackUploadgzip
bare Worker0.30 KiB0.23 KiB
+ Hono core63.66 KiB15.37 KiB
+ cors / factory / http-exception67.63 KiB16.38 KiB
+ zod v4 + @hono/zod-validator620.55 KiB97.87 KiB
+ zod/mini88.83 KiB21.09 KiB
+ valibot72.30 KiB17.31 KiB
+ valibot + @hono/standard-validator80.09 KiB19.31 KiB

Full zod v4 costs ~80 KiB gzipped — eight times Hono itself — against a 3 MB (Free) / 10 MB (Paid) compressed script limit.

Default behaviour returns its own response instead of throwing:

{"success":false,"error":{"name":"ZodError","message":"[\n {\n \"origin\": \"string\", ..."}}

So a unified error shape does not apply to the most common error in the API, and internal schema structure leaks to callers. src/validate.ts wraps it with a hook that throws HTTPException instead:

{"error":{"message":"slug: Invalid string: must match pattern /^[a-z0-9-]+$/; url: Invalid URL","requestId":"67fb24a2-..."}}

The same applies to sValidator and vValidator — all accept a third hook argument, all bypass onError without one.

Exercise: revert src/routes/links.ts to the bare zValidator and POST an invalid payload to see both error shapes side by side.

原始碼

wrangler.jsonc

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "ch05-hono",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-24",
  "observability": { "enabled": true },
  "vars": { "APP_NAME": "ch05-hono" },
  "kv_namespaces": [{ "binding": "LINKS", "id": "0000000000000000000000000000bbbb" }],
  "secrets": { "required": ["API_KEY"] }
}

package.json

{"name":"ch05-hono","private":true,"type":"module","scripts":{"dev":"wrangler dev","deploy":"wrangler deploy","typecheck":"tsc --noEmit","cf-typegen":"wrangler types --env-interface CloudflareBindings"},"dependencies":{"@hono/standard-validator":"^0.3.0","@hono/valibot-validator":"^0.6.1","@hono/zod-validator":"^0.9.0","hono":"^4.12.0","valibot":"^1.4.2","zod":"^4.0.0"},"devDependencies":{"typescript":"^5.9.0","wrangler":"^4.114.0"}}

src/index.ts

import { Hono } from "hono";
import { cors } from "hono/cors";
import { HTTPException } from "hono/http-exception";
import { accessLog, requestId, requireApiKey } from "./middleware";
import { links } from "./routes/links";
import type { AppEnv } from "./types";

const app = new Hono<AppEnv>();

app.use("*", requestId);
app.use("*", accessLog);
app.use("/v1/*", cors({ origin: ["https://example.com"], maxAge: 86400 }));
app.use("/v1/*", requireApiKey);

app.get("/health", (c) => c.json({ ok: true, app: c.env.APP_NAME }));

const routes = app.route("/v1/links", links);

// One error shape for the whole API. Without this, HTTPException renders as
// plain text and everything else becomes an opaque 500.
app.onError((err, c) => {
  const status = err instanceof HTTPException ? err.status : 500;
  if (status >= 500) {
    console.log(
      JSON.stringify({ event: "error", id: c.get("requestId"), message: err.message }),
    );
  }
  return c.json(
    { error: { message: err.message, requestId: c.get("requestId") } },
    status,
  );
});

app.notFound((c) => c.json({ error: { message: "route not found" } }, 404));

export default app;

// The type the RPC client consumes. Exported as a type only — no runtime cost.
export type AppType = typeof routes;

src/middleware.ts

import { createMiddleware } from "hono/factory";
import { HTTPException } from "hono/http-exception";
import type { AppEnv } from "./types";

/** Attach a request id and echo it back, so logs and clients can correlate. */
export const requestId = createMiddleware<AppEnv>(async (c, next) => {
  const id = c.req.header("cf-ray") ?? crypto.randomUUID();
  c.set("requestId", id);
  await next();
  c.header("x-request-id", id);
});

/** Structured access log. JSON objects are what Workers Logs can index. */
export const accessLog = createMiddleware<AppEnv>(async (c, next) => {
  await next();
  console.log(
    JSON.stringify({
      event: "request",
      id: c.get("requestId"),
      method: c.req.method,
      path: c.req.path,
      status: c.res.status,
      country: c.req.raw.cf?.country ?? null,
    }),
  );
});

/** Bearer auth against a secret. Real auth lands in chapter 27. */
export const requireApiKey = createMiddleware<AppEnv>(async (c, next) => {
  const header = c.req.header("authorization") ?? "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : "";
  const expected = c.env.API_KEY;

  // Constant-time compare. Lengths must match first — see chapter 41.
  const ok =
    token.length === expected.length &&
    crypto.subtle.timingSafeEqual(
      new TextEncoder().encode(token),
      new TextEncoder().encode(expected),
    );

  if (!ok) throw new HTTPException(401, { message: "Invalid API key" });

  c.set("tenantId", "demo-tenant");
  await next();
});

src/types.ts

// The single place the Hono generic shape is defined.
// `CloudflareBindings` comes from `wrangler types --env-interface CloudflareBindings`,
// which keeps it from colliding with Hono's own `Env` type.
export type AppEnv = {
  Bindings: CloudflareBindings;
  Variables: {
    requestId: string;
    tenantId: string | null;
  };
};

src/validate.ts

import { HTTPException } from "hono/http-exception";
import { zValidator as baseZValidator } from "@hono/zod-validator";
import type { ZodType } from "zod";
import type { ValidationTargets } from "hono";

/**
 * zValidator with a hook that THROWS instead of returning its own response.
 *
 * Without this, a validation failure short-circuits with @hono/zod-validator's
 * default body and never reaches app.onError() — so your unified error shape
 * silently does not apply to the most common error in the API.
 */
export const validate = <T extends ZodType, Target extends keyof ValidationTargets>(
  target: Target,
  schema: T,
) =>
  baseZValidator(target, schema, (result) => {
    if (!result.success) {
      throw new HTTPException(400, {
        message: result.error.issues
          .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
          .join("; "),
      });
    }
  });

.dev.vars.example

API_KEY="dev-key-123"

tsconfig.json

{ "compilerOptions": { "target": "esnext", "lib": ["esnext"], "module": "esnext",
  "moduleResolution": "bundler", "types": ["./worker-configuration.d.ts"],
  "strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true,
  "jsx": "react-jsx", "jsxImportSource": "hono/jsx" },
  "include": ["src/**/*.ts", "worker-configuration.d.ts"] }