ch05-hono
在 GitHub 上檢視·9 個檔案·6.6 KB
取得並執行
這個範例可以獨立 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說明
ch05 — Hono API skeleton
Section titled “ch05 — Hono API skeleton”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 exportnpm installecho 'API_KEY="dev-key-123"' > .dev.varsnpm run cf-typegennpm run devB=localhost:8787; H='Authorization: Bearer dev-key-123'curl -s $B/healthcurl -s $B/v1/links # 401curl -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"Verified findings
Section titled “Verified findings”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:
| Stack | Upload | gzip |
|---|---|---|
| bare Worker | 0.30 KiB | 0.23 KiB |
| + Hono core | 63.66 KiB | 15.37 KiB |
| + cors / factory / http-exception | 67.63 KiB | 16.38 KiB |
| + zod v4 + @hono/zod-validator | 620.55 KiB | 97.87 KiB |
| + zod/mini | 88.83 KiB | 21.09 KiB |
| + valibot | 72.30 KiB | 17.31 KiB |
| + valibot + @hono/standard-validator | 80.09 KiB | 19.31 KiB |
Full zod v4 costs ~80 KiB gzipped — eight times Hono itself — against a 3 MB (Free) / 10 MB (Paid) compressed script limit.
3. zValidator bypasses app.onError()
Section titled “3. zValidator bypasses app.onError()”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.jsoncpackage.jsonsrc/index.tssrc/middleware.tssrc/routes/links.tssrc/types.tssrc/validate.ts.dev.vars.exampletsconfig.json
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/routes/links.ts
import { Hono } from "hono";
import { validate } from "../validate";
import { HTTPException } from "hono/http-exception";
import { z } from "zod";
import type { AppEnv } from "../types";
const CreateLink = z.object({
slug: z.string().min(1).max(64).regex(/^[a-z0-9-]+$/),
url: z.url(),
});
const ListQuery = z.object({
limit: z.coerce.number().int().min(1).max(100).default(20),
cursor: z.string().optional(),
});
// Routes are CHAINED, not declared separately. Chaining is what lets the RPC
// client infer the route types — see the note in the chapter.
export const links = new Hono<AppEnv>()
.get("/", validate("query", ListQuery), async (c) => {
const { limit, cursor } = c.req.valid("query");
const page = await c.env.LINKS.list({ limit, cursor });
return c.json(
{
items: page.keys.map((k) => k.name),
cursor: page.list_complete ? null : page.cursor,
},
200,
);
})
.post("/", validate("json", CreateLink), async (c) => {
const { slug, url } = c.req.valid("json");
if (await c.env.LINKS.get(slug)) {
throw new HTTPException(409, { message: `slug "${slug}" already exists` });
}
await c.env.LINKS.put(slug, url, { metadata: { tenantId: c.get("tenantId") } });
return c.json({ slug, url }, 201);
})
.get("/:slug", async (c) => {
const slug = c.req.param("slug");
const url = await c.env.LINKS.get(slug);
if (!url) throw new HTTPException(404, { message: "not found" });
return c.json({ slug, url }, 200);
});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"] }