InteractiveFrameworks

Service to service

A gateway in front of the cats service: a dashboard that aggregates what the service knows and an adoption route that forwards a request and passes the service's refusals through, both over a client built from the service's type with the service's own request() as its fetch.

What you'll learn

  • Build a client to another service from its exported type, with a fetch that is a binding, the network, or the other app's request()
  • Call a service from inside a handler and let its types flow into the gateway's own response
  • Narrow on the service's status and answer with the same status to pass a refusal through

A typed client is not only for browsers. On Cloudflare, one Worker calls another through a service binding; on Node, one process calls another over HTTP; either way the caller is a Hono app itself, and it can hold a client built from the other service's type. This lesson builds a gateway in front of the cats service: a dashboard that aggregates what the service knows, and an adoption route that forwards a request and passes the service's refusals through. The service is a second Hono app in the same worker, and the gateway talks to it the way it would across the network.

The fetch is the network

const cats = hc<CatsService>('http://cats.internal', { fetch: service.request });

hc builds requests and hands them to a fetch. Which fetch decides where they go: the platform's, to a URL; a service binding's, c.env.CATS.fetch.bind(c.env.CATS) in the guide's custom-fetch example; or, as here, the other app's own request(), which runs it in the same process. The client code does not change between the three, and neither does its type, which comes from export type CatsService = typeof service in the service's own file. The base URL still matters: the path is built on it, a binding ignores the host, and $url() needs it to be absolute.

Building the client at module level, once, is right: it holds no connection, only the base URL and the options.

A handler that is also a client

.get('/dashboard', async (c) => {
  const res = await cats.cats.$get();
  const all = await res.json(); // Cat[], from the service's type
  return c.json({ total: all.length }, 200);
})

Two contexts meet in one handler, and it helps to keep them apart in the names: c is the request the gateway is answering, res is the service's answer. The service's type flows through: all is Cat[], and all.filter((cat) => !cat.adopted) is checked against the service's Cat, not a copy the gateway maintains. When the service changes its shape, the gateway fails to compile instead of failing at runtime, which is the point of sharing the type across the boundary.

Passing refusals through

The service answers an adoption with 200 and the cat, 404 when there is no such cat, 409 when it is already adopted, and the gateway should not flatten those into one error:

.post('/adopt/:id', async (c) => {
  const res = await cats.cats[':id'].adopt.$post({ param: { id: c.req.param('id') } });
  if (res.status === 200) {
    const cat = await res.json();
    return c.json({ message: `${cat.name} has a home` }, 200);
  }
  const { error } = await res.json();
  return c.json({ error }, res.status);
})

After the 200 branch returns, res.status is 404 | 409, which is a valid status for c.json(), so the gateway answers with the service's own status and message. The gateway's type, in turn, records 200 | 404 | 409 for /adopt/:id, and its own clients narrow on them the same way. parseResponse() would do the reading, at the cost of a try/catch around a DetailedError whose statusCode is any; narrowing on res.status keeps the types all the way through.

Your task

cats-service.ts is given, chained, with three cats and its type exported. main.ts is the gateway, with a client to build and two routes that answer 501.

  1. Build the client from CatsService, with the service's request as its fetch.
  2. GET /dashboard answers { total, adoptable, oldest }: the number of cats, the names of those not adopted, the name of the oldest.
  3. POST /adopt/:id forwards to the service's adoption route; { message: "<name> has a home" } on success, the service's { error } with the service's status otherwise.

Send POST /adopt/1 twice from the request bar and read the second answer.

When it fails

  • 'all' is of type 'unknown' or Property 'adopted' does not exist: the client was built without CatsService, or from typeof an app that was not chained.
  • GET /dashboard answers 500 and the console shows Unexpected token '4', "404 Not Found": the client's route does not match the service, which answered its 404 page, and the gateway tried to read it as JSON.
  • POST /adopt/1 the second time answers 200 with undefined has a home: the gateway read the cat without checking the status first; the 409 body has no name.
  • Property 'adopt' does not exist on type …: the service's route is /cats/:id/adopt, so the client path is cats[':id'].adopt; a segment after a parameter is a property again.

Remember

  • hc<ServiceType>(baseUrl, { fetch }): the type from the service's file, the fetch from wherever the service is, a binding, the network, or the app itself.
  • In a handler, c is the request in and res the service's answer; the service's types flow into the handler.
  • Narrow on res.status, and answer with the same status to pass a refusal through.
  • Build clients once at module level; they hold no connection.
Stuck? Show a hint

const cats = hc<CatsService>('http://cats.internal', { fetch: service.request }). Dashboard: const all = await (await cats.cats.$get()).json(), then filter the adoptable, pick the oldest by age. Adopt: cats.cats[':id'].adopt.$post({ param: { id: c.req.param('id') } }); on 200 answer { message: `${cat.name} has a home` }, otherwise read { error } and answer it with res.status.