Hono Edge API skill for Codex

One typed Hono app for Workers, Bun, Deno and Node. Codex builds Hono services with typed Bindings and Variables, keeps runtime-specific code in the entry file, and validates every input with Zod through c.req.valid. Errors flow through HTTPException and onError into one JSON shape, routes are chained so the hc RPC client stays typed, and tests call app.request with fake bindings. Production deploys with wrangler wait for your approval.

  • Backend & APIs
  • $hono-edge-api
  • 228 lines
  • 2 files
  • ~2,200 tokens
  • MIT
  • Updated

Install

curl -fsSL codexskills.dev/install.sh \  | sh -s -- hono-edge-api

Installs to ~/.agents/skills/hono-edge-api/ for every project on this machine.

104-line POSIX script. No sudo, no telemetry. Read install.sh

Use it in Codex

Start a prompt with $hono-edge-api, or pick it from /skills. You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.

What Codex reads before loading it

Builds Hono APIs that run on Cloudflare Workers, Bun, Deno and Node - typed Bindings and Variables, route grouping with app.route, built-in middleware (cors, secure-headers, jwt, body-limit), createMiddleware, Zod validation with c.req.valid, HTTPException and onError, RPC clients via hc, and app.request tests. Use when starting a Hono project, porting an Express API to the edge, adding auth or validation middleware, sharing types with a frontend through Hono RPC, or debugging runtime-specific env and context errors.

Use it when

  • Starting an API on Cloudflare Workers, Bun, Deno or Node with Hono
  • Porting an Express or itty-router API to Hono
  • Sharing request and response types with a frontend through Hono RPC
  • Debugging env, bindings or executionCtx errors that differ between runtimes

Use something else when

  • The question is Workers platform config, limits or bindings rather than routing: use cloudflare-workers
  • The service is a long-running Node API with heavy plugin needs: use fastify-api

How to check the result

tsc and vitest pass, app.request tests cover 401, 422 and 404 JSON responses with fake bindings, and wrangler dev or the Node entry serves the health route locally.

The mistake it prevents

Reading configuration from process.env in Workers handlers instead of c.env, so bindings and secrets work under local Node runs but are missing on the Worker.

~/.agents/skills/hono-edge-api/SKILL.md

--- name: hono-edge-api description: Builds Hono APIs that run on Cloudflare Workers, Bun, Deno and Node - typed Bindings and Variables, route grouping with app.route, built-in… license: MIT ---

Done well means: the app is typed end to end (bindings, context variables, validated input, RPC client), runtime specifics are confined to the entry file, every input is validated before use, errors map to one JSON shape, and tests call app.request() with no server or network.

Inspect first

  • Read AGENTS.md, then package.json (hono major 4.x, validator packages) and the runtime config: wrangler.toml/wrangler.jsonc (Workers), bunfig.toml, deno.json, or a Node entry using @hono/node-server.
  • Find the app root and sub-apps: rg -n "new Hono|app\.route\(|createMiddleware" src.
  • Note which runtime APIs code touches (c.env, c.executionCtx, process.env, Bun.*, Deno.*). Those calls decide portability.

Entry points by runtime

RuntimeEntryEnv access
Cloudflare Workersexport default appc.env.MY_BINDING (typed via Bindings)
Bunexport default app or export default { port: 3000, fetch: app.fetch }process.env / Bun.env
DenoDeno.serve(app.fetch)Deno.env.get() (needs --allow-env)
Nodeserve({ fetch: app.fetch, port: 3000 }) from @hono/node-serverprocess.env

For code that must run everywhere, read env through env(c) from hono/adapter. c.executionCtx exists only on Workers (and similar); accessing it on Node throws.

App structure

// src/app.ts
import { Hono } from "hono";
import { secureHeaders } from "hono/secure-headers";
import { cors } from "hono/cors";
import { requestId } from "hono/request-id";
import { HTTPException } from "hono/http-exception";
import { orders } from "./routes/orders";

export type Env = {
  Bindings: { DB: D1Database; JWT_SECRET: string; ALLOWED_ORIGIN: string };
  Variables: { userId: string };
};

const app = new Hono<Env>()
  .use("*", requestId())
  .use("*", secureHeaders())
  .use("/api/*", (c, next) => cors({ origin: c.env.ALLOWED_ORIGIN, credentials: true })(c, next))
  .get("/healthz", (c) => c.json({ ok: true }))
  .route("/api/orders", orders);

app.onError((err, c) => {
  if (err instanceof HTTPException) {
    return c.json({ error: { code: err.status, message: err.message } }, err.status);
  }
  console.error(err);
  return c.json({ error: { code: 500, message: "Internal server error" } }, 500);
});
app.notFound((c) => c.json({ error: { code: 404, message: "Not found" } }, 404));

export default app;
export type AppType = typeof app;

Chain route definitions (new Hono().get(...).post(...)) when you want RPC types: the type of the chained expression carries the routes; routes added in separate statements are not reflected in typeof app.

Middleware

// src/middleware/auth.ts
import { createMiddleware } from "hono/factory";
import { verify } from "hono/jwt";
import { HTTPException } from "hono/http-exception";
import type { Env } from "../app";

export const requireUser = createMiddleware<Env>(async (c, next) => {
  const token = c.req.header("Authorization")?.replace(/^Bearer /, "");
  if (!token) throw new HTTPException(401, { message: "Missing token" });
  const payload = await verify(token, c.env.JWT_SECRET, "HS256").catch(() => null);
  if (!payload?.sub) throw new HTTPException(401, { message: "Invalid token" });
  c.set("userId", String(payload.sub));
  await next();
});
  • Always await next(); a middleware that forgets to await breaks response ordering.
  • Built-ins worth knowing: hono/cors, hono/secure-headers, hono/jwt, hono/bearer-auth, hono/body-limit, hono/etag, hono/timing, hono/logger, hono/request-id, hono/csrf.
  • Pass the JWT algorithm explicitly to verify rather than relying on defaults.
  • Middleware registered with app.use("/api/*", ...) must come before the routes it guards; registration order is execution order.

Validation

// src/routes/orders.ts
import { Hono } from "hono";
import { zValidator } from "@hono/zod-validator";
import { z } from "zod";
import { requireUser } from "../middleware/auth";
import type { Env } from "../app";

const CreateOrder = z.object({ sku: z.string().min(1), qty: z.number().int().positive() });

export const orders = new Hono<Env>()
  .use("*", requireUser)
  .post("/", zValidator("json", CreateOrder, (r, c) => {
    if (!r.success) return c.json({ error: { code: 422, issues: r.error.issues } }, 422);
  }), async (c) => {
    const input = c.req.valid("json");
    const id = crypto.randomUUID();
    await c.env.DB.prepare("INSERT INTO orders (id, user_id, sku, qty) VALUES (?, ?, ?, ?)")
      .bind(id, c.get("userId"), input.sku, input.qty).run();
    return c.json({ id, ...input }, 201);
  })
  .get("/:id", zValidator("param", z.object({ id: z.string().uuid() })), async (c) => {
    const { id } = c.req.valid("param");
    const row = await c.env.DB.prepare("SELECT * FROM orders WHERE id = ? AND user_id = ?")
      .bind(id, c.get("userId")).first();
    if (!row) throw new HTTPException(404, { message: "Order not found" });
    return c.json(row);
  });

Targets: json, form, query, param, header, cookie. The json validator only parses when the request has a JSON Content-Type; a missing header fails validation rather than parsing. For Valibot or ArkType, use @hono/standard-validator.

RPC client

import { hc } from "hono/client";
import type { AppType } from "../server/src/app";
const client = hc<AppType>("https://api.example.com");
const res = await client.api.orders.$post({ json: { sku: "A1", qty: 2 } });
if (res.ok) { const order = await res.json(); }

Large apps make RPC type-checking slow; export per-route-group types and create clients per group if the editor lags.

Workers specifics

  • Background work after the response: c.executionCtx.waitUntil(promise).
  • Bindings (D1, KV, R2, Queues, Durable Objects) come from wrangler config; regenerate types with npx wrangler types after changing bindings.
  • wrangler dev runs locally on workerd. wrangler deploy publishes to production: ask the user before running it, and never deploy from an unreviewed branch.
  • No Node built-ins unless nodejs_compat is enabled in the compatibility flags.
  • For D1, KV and R2 design questions, hand off to $cloudflare-d1 or $cloudflare-kv-queues.

Testing

import { describe, it, expect } from "vitest";
import app from "../src/app";

const env = { DB: fakeD1(), JWT_SECRET: "test-secret", ALLOWED_ORIGIN: "http://localhost" };

describe("orders", () => {
  it("rejects missing token", async () => {
    const res = await app.request("/api/orders", { method: "POST" }, env);
    expect(res.status).toBe(401);
  });
});

app.request(path, init, env) takes bindings as the third argument. For tests against real Workers bindings, use @cloudflare/vitest-pool-workers. testClient from hono/testing gives typed calls matching the RPC client.

Porting from Express

ExpressHono
req.params.idc.req.param("id") or validated c.req.valid("param")
req.queryc.req.query("k"), c.req.queries("k")
req.body (after express.json)await c.req.json() or validated json target
res.status(201).json(x)return c.json(x, 201)
res.locals.userc.set("user", u) / c.get("user") with typed Variables
next(err)throw new HTTPException(...)

Every handler must return a Response. Forgetting return yields a 404 or an empty response.

Verification

npx tsc --noEmit
npx vitest run
npx wrangler dev        # Workers: then curl http://localhost:8787/healthz

Confirm the health route, a validation failure (422 JSON), an unauthenticated call (401 JSON) and an unknown path (404 JSON). Run wrangler deploy --dry-run to check the bundle builds without publishing.

Gotchas

  • Do not read config from process.env in Workers code: it is populated only with nodejs_compat on recent compatibility dates, holds strings only, and never exposes D1, KV or R2 bindings. Use c.env (or env(c) for portable code).
  • Module-level state in Workers persists per isolate, not per request and not globally; never use it for per-user data or counters.
  • Webhook signature checks need the exact raw bytes: read await c.req.text() (or arrayBuffer()) and verify before parsing, instead of re-serializing parsed JSON.
  • CORS middleware must run before auth so preflight OPTIONS requests are not rejected.

Output template

## Hono change: <summary>
- Runtime(s): <Workers | Bun | Deno | Node>
- Routes: <method path, validators, status codes>
- Middleware: <order and scope>
- Bindings/env added: <name, type, where configured>
- Tests: <app.request cases>
- Deploy: not run | run after user approval

What the zip contains

SKILL.md plus the interface file below. Nothing runs on install.

agents/openai.yamlgenerated from this page

Gives the Codex app a display name, a one-line description and a starter prompt for this skill.

interface:
  display_name: "Hono Edge API"
  short_description: "One typed Hono app for Workers, Bun, Deno and Node"
  default_prompt: "$hono-edge-api Build a Hono API on Cloudflare Workers with D1, JWT auth and validation"

About this skill

Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.

esc
New

Introducing Codex Skills Pro

79 professional skills that take Codex through complete engagements, each with its own scripts, references and a report that verifies the result.

All-AccessOne-time payment, no subscription
$49
Get All-AccessOr browse individual skills from $2.99 →

Secure checkout by Creem