Skip to content

A folder with backend: { stack: "h3" }. Everything here is H3-specific; the routing model, validation and fetch clients are the same for every backend.

The handler argument is an event, not a context - it is the same object H3 passes everywhere, and KosmoJS adds event.validated to it.

What the folder contains

FileH3-specific?
api/app.tsyes - app.use(onError(...)) plus app middleware
api/errors.tsyes - may return a Response, object or string
api/dev.tsyes - toNodeHandler(app) from h3/node
api/env.d.tsyes - augments DefaultContext
api/use.tsshared shape, H3 idioms inside
api/server.tsno
route filesshared shape, H3 idioms inside

Attaching the error handler

H3 wraps the handler in its own onError helper, registered as middleware:

api/app.ts
ts
// H3: api/app.ts
export default appFactory(routes, ({ app }) => {
  app.use(onError(defaultErrorHandler));
});

The handler may return a Response, a plain object or a string. Branch on event.url when behaviour should differ per route.

Responding from a handler

Return the value - H3 serializes it:

api/users/[id]/index.ts
ts
// H3: api/users/[id]/index.ts
import { HTTPError } from "@kosmojs/core/errors";

export default defineRoute<"users/[id]", [number]>(({ GET }) => [
  GET(async (event) => {
    const { id } = event.validated.params;
    const user = await db.users.find(id);

    if (!user) throw new HTTPError([404, "User not found"]);

    return user;
  }),
]);
TaskH3
read a headerevent.req.headers.get("authorization")
respondreturn value
set a statusevent.res.status = 400
carry per-request stateevent.context.x = value

Global middleware

api/use.ts
ts
// H3: api/use.ts
import { use } from "_/api";

export default [
  // will run on every route
  use(async function requestId(event, next) {
    event.context.requestId = crypto.randomUUID();
    return next();
  }),
];

State goes on event.context.

Method-specific middleware

api/example/index.ts
ts
// H3: api/example/index.ts
export default defineRoute<"example">(({ GET, POST, use }) => [
  use(async (event, next) => {
    event.context.user = await verifyToken(event.req.headers.get("authorization"));
    return next();
  }, {
    on: ["POST"],
  }),

  GET(async (event) => {
    // no auth required
  }),

  POST(async (event) => {
    // event.context.user is available
  }),
]);

App middleware

Registered on the native instance in api/app.ts, this layer runs before any route chain - so KosmoJS helpers such as ctx.validated are not available here:

api/app.ts
ts
// H3: api/app.ts
import { onError } from "h3";

import appFactory, { routes } from "_/api:factory";
import defaultErrorHandler from "./errors";

export default appFactory(routes, ({ app }) => {
  app.use(onError(defaultErrorHandler));

  app.use(async (event, next) => {
    const started = performance.now();
    await next();
    console.log([ event.req.method, event.url.pathname, performance.now() - started ]);
  });
});

Order matters: the error handler goes first, or middleware registered before it sits outside its reach.

Folder-level middleware and UseT

A use.ts in an api/ subfolder wraps that subtree and may extend the context. Export UseT to type what it adds - the global api/use.ts is the exception and does not export one:

api/users/use.ts
ts
// H3: api/users/use.ts
import { use } from "_/api";

export type UseT = {
  user: { id: number; role: "admin" | "user" };
};

export default [
  use<UseT>(async (event, next) => {
    const token = event.req.headers.get("authorization")?.replace("Bearer ", "");
    // validate before adding to context - UseT promises this property exists
    if (!token) throw new HTTPError({ status: 401, message: "Authentication required" });
    event.context.user = await verifyToken(token);
    return next();
  })
];

Routes underneath destructure event.context, fully typed. Note H3's HTTPError takes an object - { status, message } - where KosmoJS's own HTTPError from @kosmojs/core/errors takes a tuple.

Third-party middleware

Middleware that should join the route chain goes through use():

api/users/use.ts
ts
// H3: api/users/use.ts
import { use } from "_/api";

export default [
  use(async function noStore(event, next) {
    event.res.headers.set("cache-control", "no-store");
    return next();
  }),
];

H3 ships fewer off-the-shelf middleware packages than Hono or Koa, so more of this layer tends to be hand-written against event.req / event.res.

Middleware that must see every request - CORS included - goes on the native instance in api/app.ts instead, because the route chain is skipped for preflight OPTIONS and for 405 responses.

Edge middleware slots

Slots are not framework-specific - edge:auth and edge:ratelimit behave the same on every backend. What differs is the code inside the handler.

Auth that must run before everything, including unmatched routes, belongs on the native instance:

api/app.ts
ts
// H3: api/app.ts
import { onError } from "h3";

import appFactory, { routes } from "_/api:factory";
import defaultErrorHandler from "./errors";

export default appFactory(routes, ({ app }) => {
  app.use(onError(defaultErrorHandler));

  app.use(async (event, next) => {
    const token = event.req.headers.get("authorization")?.replace("Bearer ", "");
    if (!token) return new Response("Authentication required", { status: 401 });
    return next();
  });
});

Dev hooks

api/dev.ts tells the dev server how to dispatch:

api/dev.ts
ts
// H3: api/dev.ts
import { toNodeHandler } from "h3/node";

import { devSetup } from "_/api:factory";
import app from "./app";

export default devSetup({
  requestHandler() {
    return toNodeHandler(app);
  },
});

teardownHandler() runs before every reload - close DB connections and sockets there or they leak across restarts.

Validation errors

ValidationError reaches api/errors.ts like any other throw:

api/errors.ts
ts
// H3: api/errors.ts
if (error instanceof ValidationError) {
  event.res.status = 400;
  return { error: error.errorMessage };
}

Typing the context

H3 has a single surface, so there is one interface to augment:

api/env.d.ts
ts
// H3: api/env.d.ts
export declare module "_/api" {
  interface DefaultContext {
    permissions: Array<"read" | "write" | "admin">;
  }
}

Hono augments DefaultVariables/DefaultBindings, Koa DefaultState/DefaultContext.

Per-route context typing

Beyond the global env.d.ts, a single route widens its own context through defineRoute's type parameters:

api/users/[id]/index.ts
ts
// H3: api/users/[id]/index.ts
defineRoute<
  "route-name",
  ParamsTuple,      // param refinements
  Context,          // route-specific context properties
>

H3 takes three - there is only one context surface to widen:

api/users/[id]/index.ts
ts
// H3: api/users/[id]/index.ts
export default defineRoute<
  "users/[id]",
  [number],
  { permissions: Array<"read" | "write"> },  // event.context.permissions
>(({ GET }) => [
  GET(async (event) => {
    const { id } = event.validated.params;
    const { permissions } = event.context;
  }),
]);

Route templates

backend.templates seeds new route files by route-name pattern. The template is a string, so it has to be written in this backend's idiom:

kosmo.config.ts
ts
// H3: kosmo.config.ts
const template = `
import { defineRoute } from "_/api";

export default defineRoute<"{{route.name}}">(({ GET }) => [
  GET(async (event) => {
    // return the value directly - objects serialize as JSON
    return { ok: true };
  }),
]);`;

{{route.name}} is substituted per route.

Worth knowing

  • The argument is named event throughout H3's own docs and in the seeded code. Handlers written against ctx will typecheck, since the name is yours to choose, but ctx.req.header(...) is Hono's API and does not exist on an H3 event.

Released under the MIT License.