Zum Inhalt springen

RPC ​

Die RPC-Funktion ermöglicht es, API-Spezifikationen zwischen Server und Client zu teilen.

Exportiere zunächst aus deinem Servercode den mit typeof ermittelten Typ deiner Hono-Anwendung (üblicherweise AppType genannt) oder nur den Typ der Routen, die dem Client zur Verfügung stehen sollen.

Indem der Hono-Client AppType als generischen Parameter entgegennimmt, kann er sowohl die vom Validator angegebenen Eingabetypen als auch die Ausgabetypen der Handler inferieren, die c.json() zurückgeben.

NOTE

Damit die RPC-Typen in einem Monorepo korrekt funktionieren, setze in den tsconfig.json-Dateien sowohl des Clients als auch des Servers unter compilerOptions den Wert "strict": true. Weitere Informationen.

Server ​

Auf der Serverseite musst du lediglich einen Validator schreiben und eine Variable route erstellen. Das folgende Beispiel verwendet den Zod-Validator.

ts
const route = app.post(
  '/posts',
  zValidator(
    'form',
    z.object({
      title: z.string(),
      body: z.string(),
    })
  ),
  (c) => {
    // ...
    return c.json(
      {
        ok: true,
        message: 'Created!',
      },
      201
    )
  }
)

TIP

Der Standard-Schema-Validator funktioniert ebenfalls. Du kannst also jede Standard-Schema-Bibliothek verwenden, zum Beispiel Valibot.

Exportiere dann den Typ, um die API-Spezifikation mit dem Client zu teilen.

ts
export type AppType = typeof route

Client ​

Importiere auf der Clientseite zunächst hc und AppType.

ts
import type { AppType } from '.'
import { hc } from 'hono/client'

hc ist eine Funktion zur Erstellung eines Clients. Übergib AppType als generischen Typ und gib die Server-URL als Argument an.

ts
const client = hc<AppType>('http://localhost:8787/')

Rufe client.{path}.{method} auf und übergib die Daten, die du an den Server senden möchtest, als Argument.

ts
const res = await client.posts.$post({
  form: {
    title: 'Hello',
    body: 'Hono is a cool project',
  },
})

res ist mit der Response von fetch kompatibel. Mit res.json() kannst du Daten vom Server abrufen.

ts
if (res.ok) {
  const data = await res.json()
  console.log(data.message)
}

Cookies ​

Damit der Client bei jeder Anfrage Cookies sendet, füge beim Erstellen des Clients { 'init': { 'credentials": 'include' } } zu den Optionen hinzu.

ts
// client.ts
const client = hc<AppType>('http://localhost:8787/', {
  init: {
    credentials: 'include',
  },
})

// This request will now include any cookies you might have set
const res = await client.posts.$get({
  query: {
    id: '123',
  },
})

Statuscode ​

Wenn du den Statuscode, zum Beispiel 200 oder 404, explizit in c.json() angibst, wird er als Typinformation an den Client weitergegeben.

ts
// server.ts
const app = new Hono().get(
  '/posts',
  zValidator(
    'query',
    z.object({
      id: z.string(),
    })
  ),
  async (c) => {
    const { id } = c.req.valid('query')
    const post: Post | undefined = await getPost(id)

    if (post === undefined) {
      return c.json({ error: 'not found' }, 404) // Specify 404
    }

    return c.json({ post }, 200) // Specify 200
  }
)

export type AppType = typeof app

Du kannst die Daten abhängig vom Statuscode abrufen.

ts
// client.ts
const client = hc<AppType>('http://localhost:8787/')

const res = await client.posts.$get({
  query: {
    id: '123',
  },
})

if (res.status === 404) {
  const data: { error: string } = await res.json()
  console.log(data.error)
}

if (res.ok) {
  const data: { post: Post } = await res.json()
  console.log(data.post)
}

// { post: Post } | { error: string }
type ResponseType = InferResponseType<typeof client.posts.$get>

// { post: Post }
type ResponseType200 = InferResponseType<
  typeof client.posts.$get,
  200
>

Globale Antwort ​

Der Hono-RPC-Client inferiert Antworttypen aus globalen Fehler-Handlern wie app.onError() oder globaler Middleware nicht automatisch. Mit dem Typ-Helper ApplyGlobalResponse kannst du globale Fehlerantworttypen in alle Routen integrieren.

ts
import type { ApplyGlobalResponse } from 'hono/client'

const app = new Hono()
  .get('/api/users', (c) => c.json({ users: ['alice', 'bob'] }, 200))
  .onError((err, c) => c.json({ error: err.message }, 500))

type AppWithErrors = ApplyGlobalResponse<
  typeof app,
  {
    500: { json: { error: string } }
  }
>

const client = hc<AppWithErrors>('http://localhost')

Jetzt kennt der Client sowohl Erfolgs- als auch Fehlerantworten:

ts
const res = await client.api.users.$get()

if (res.ok) {
  const data = await res.json() // { users: string[] }
}

// InferResponseType includes the global error type
type ResType = InferResponseType<typeof client.api.users.$get>
// { users: string[] } | { error: string }

Du kannst auch mehrere globale Fehlerstatuscodes gleichzeitig definieren:

ts
type AppWithErrors = ApplyGlobalResponse<
  typeof app,
  {
    401: { json: { error: string; message: string } }
    500: { json: { error: string; message: string } }
  }
>

Nicht gefunden ​

Wenn du einen Client verwenden möchtest, solltest du für eine Nicht-gefunden-Antwort nicht c.notFound() verwenden. Die Daten, die der Client vom Server erhält, lassen sich dann nicht korrekt inferieren.

ts
// server.ts
export const routes = new Hono().get(
  '/posts',
  zValidator(
    'query',
    z.object({
      id: z.string(),
    })
  ),
  async (c) => {
    const { id } = c.req.valid('query')
    const post: Post | undefined = await getPost(id)

    if (post === undefined) {
      return c.notFound() // ❌️
    }

    return c.json({ post })
  }
)

// client.ts
import { hc } from 'hono/client'

const client = hc<typeof routes>('/')

const res = await client.posts[':id'].$get({
  param: {
    id: '123',
  },
})

const data = await res.json() // 🙁 data is unknown

Verwende bitte c.json() und gib den Statuscode für die Nicht-gefunden-Antwort an.

ts
export const routes = new Hono().get(
  '/posts',
  zValidator(
    'query',
    z.object({
      id: z.string(),
    })
  ),
  async (c) => {
    const { id } = c.req.valid('query')
    const post = await getPost(id)

    if (!post) {
      return c.json({ error: 'not found' }, 404) // Specify 404
    }

    return c.json({ post }, 200) // Specify 200
  }
)

Alternativ kannst du die Schnittstelle NotFoundResponse mithilfe von Modulerweiterung ergänzen. So kann c.notFound() eine typisierte Antwort zurückgeben:

ts
// server.ts
import { Hono, TypedResponse } from 'hono'

declare module 'hono' {
  interface NotFoundResponse
    extends Response,
      TypedResponse<{ error: string }, 404, 'json'> {}
}

const app = new Hono()
  .get('/posts/:id', async (c) => {
    const post = await getPost(c.req.param('id'))
    if (!post) {
      return c.notFound()
    }
    return c.json({ post }, 200)
  })
  .notFound((c) => c.json({ error: 'not found' }, 404))

export type AppType = typeof app

Jetzt kann der Client den Typ der 404-Antwort korrekt inferieren.

Pfadparameter ​

Du kannst auch Routen mit Pfadparametern oder Abfragewerten verarbeiten.

ts
const route = app.get(
  '/posts/:id',
  zValidator(
    'query',
    z.object({
      page: z.coerce.number().optional(), // coerce to convert to number
    })
  ),
  (c) => {
    // ...
    return c.json({
      title: 'Night',
      body: 'Time to sleep',
    })
  }
)

Sowohl Pfadparameter als auch Abfragewerte müssen als string übergeben werden, selbst wenn der zugrunde liegende Wert einen anderen Typ hat.

Gib mit param die Zeichenfolge an, die du in den Pfad aufnehmen möchtest, und mit query die Abfragewerte.

ts
const res = await client.posts[':id'].$get({
  param: {
    id: '123',
  },
  query: {
    page: '1', // `string`, converted by the validator to `number`
  },
})

Mehrere Parameter ​

Verarbeite Routen mit mehreren Parametern.

ts
const route = app.get(
  '/posts/:postId/:authorId',
  zValidator(
    'query',
    z.object({
      page: z.string().optional(),
    })
  ),
  (c) => {
    // ...
    return c.json({
      title: 'Night',
      body: 'Time to sleep',
    })
  }
)

Füge mehrere [''] hinzu, um die Parameter im Pfad anzugeben.

ts
const res = await client.posts[':postId'][':authorId'].$get({
  param: {
    postId: '123',
    authorId: '456',
  },
  query: {},
})

Schrägstriche einschließen ​

Die Funktion hc führt für die Werte von param keine URL-Kodierung durch. Verwende reguläre Ausdrücke, um Schrägstriche in Parametern zuzulassen.

ts
// client.ts

// Requests /posts/123/456
const res = await client.posts[':id'].$get({
  param: {
    id: '123/456',
  },
})

// server.ts
const route = app.get(
  '/posts/:id{.+}',
  zValidator(
    'param',
    z.object({
      id: z.string(),
    })
  ),
  (c) => {
    // id: 123/456
    const { id } = c.req.valid('param')
    // ...
  }
)

NOTE

Einfache Pfadparameter ohne reguläre Ausdrücke erfassen keine Schrägstriche. Wenn du über die Funktion hc einen param mit Schrägstrichen übergibst, entspricht das Routing des Servers möglicherweise nicht deinen Erwartungen. Wir empfehlen, die Parameter mit encodeURIComponent zu kodieren, um korrektes Routing sicherzustellen.

Header ​

Du kannst der Anfrage Header hinzufügen.

ts
const res = await client.search.$get(
  {
    //...
  },
  {
    headers: {
      'X-Custom-Header': 'Here is Hono Client',
      'X-User-Agent': 'hc',
    },
  }
)

Um allen Anfragen einen gemeinsamen Header hinzuzufügen, gib ihn als Argument der Funktion hc an.

ts
const client = hc<AppType>('/api', {
  headers: {
    Authorization: 'Bearer TOKEN',
  },
})

Option init ​

Du kannst das RequestInit-Objekt von fetch als Option init an die Anfrage übergeben. Das folgende Beispiel zeigt das Abbrechen einer Anfrage.

ts
import { hc } from 'hono/client'

const client = hc<AppType>('http://localhost:8787/')

const abortController = new AbortController()
const res = await client.api.posts.$post(
  {
    json: {
      // Request body
    },
  },
  {
    // RequestInit object
    init: {
      signal: abortController.signal,
    },
  }
)

// ...

abortController.abort()

Info

Ein über init definiertes RequestInit-Objekt hat die höchste Priorität. Damit kannst du Einstellungen überschreiben, die durch andere Optionen wie body | method | headers gesetzt wurden.

$url() ​

Mit $url() kannst du ein URL-Objekt zum Zugriff auf den Endpunkt abrufen.

Achtung

Damit dies funktioniert, musst du eine absolute URL übergeben. Wenn du die relative URL / übergibst, tritt folgender Fehler auf.

Uncaught TypeError: Failed to construct 'URL': Invalid URL

ts
// ❌ Will throw error
const client = hc<AppType>('/')
client.api.post.$url()

// ✅ Will work as expected
const client = hc<AppType>('http://localhost:8787/')
client.api.post.$url()
ts
const route = app
  .get('/api/posts', (c) => c.json({ posts }))
  .get('/api/posts/:id', (c) => c.json({ post }))

const client = hc<typeof route>('http://localhost:8787/')

let url = client.api.posts.$url()
console.log(url.pathname) // `/api/posts`

url = client.api.posts[':id'].$url({
  param: {
    id: '123',
  },
})
console.log(url.pathname) // `/api/posts/123`

Typisierte URL ​

Du kannst die Basis-URL als zweiten Typparameter an hc übergeben, um präzisere URL-Typen zu erhalten:

ts
const client = hc<typeof route, 'http://localhost:8787'>(
  'http://localhost:8787/'
)

const url = client.api.posts.$url()
// url is TypedURL with precise type information
// including protocol, host, and path

Das ist hilfreich, wenn du die URL als typsicheren Schlüssel für Bibliotheken wie SWR verwenden möchtest.

$path() ​

$path() ähnelt $url(), gibt aber statt eines URL-Objekts eine Pfadzeichenfolge zurück. Anders als $url() enthält es nicht den Origin der Basis-URL und funktioniert deshalb unabhängig davon, welche Basis-URL du an hc übergibst.

ts
const route = app
  .get('/api/posts', (c) => c.json({ posts }))
  .get('/api/posts/:id', (c) => c.json({ post }))

const client = hc<typeof route>('http://localhost:8787/')

let path = client.api.posts.$path()
console.log(path) // `/api/posts`

path = client.api.posts[':id'].$path({
  param: {
    id: '123',
  },
})
console.log(path) // `/api/posts/123`

Du kannst auch Abfrageparameter übergeben:

ts
const path = client.api.posts.$path({
  query: {
    page: '1',
    limit: '10',
  },
})
console.log(path) // `/api/posts?page=1&limit=10`

Datei-Uploads ​

Du kannst Dateien mit einem Formular-Body hochladen:

ts
// client
const res = await client.user.picture.$put({
  form: {
    file: new File([fileToUpload], filename, {
      type: fileToUpload.type,
    }),
  },
})
ts
// server
const route = app.put(
  '/user/picture',
  zValidator(
    'form',
    z.object({
      file: z.instanceof(File),
    })
  )
  // ...
)

Eigene fetch-Methode ​

Du kannst eine eigene fetch-Methode festlegen.

Im folgenden Beispielskript für einen Cloudflare Worker wird die Methode fetch des Service Bindings anstelle des standardmäßigen fetch verwendet.

toml
# wrangler.toml
services = [
  { binding = "AUTH", service = "auth-service" },
]
ts
// src/client.ts
const client = hc<CreateProfileType>('http://localhost', {
  fetch: c.env.AUTH.fetch.bind(c.env.AUTH),
})

Eigener Abfrage-Serialisierer ​

Mit der Option buildSearchParams kannst du anpassen, wie Abfrageparameter serialisiert werden. Das ist nützlich, wenn du die Schreibweise mit eckigen Klammern für Arrays oder andere eigene Formate benötigst:

ts
const client = hc<AppType>('http://localhost', {
  buildSearchParams: (query) => {
    const searchParams = new URLSearchParams()
    for (const [k, v] of Object.entries(query)) {
      if (v === undefined) {
        continue
      }
      if (Array.isArray(v)) {
        v.forEach((item) => searchParams.append(`${k}[]`, item))
      } else {
        searchParams.set(k, v)
      }
    }
    return searchParams
  },
})

Typen inferieren ​

Mit InferRequestType und InferResponseType kannst du den Typ des zu sendenden und des zurückgegebenen Objekts ermitteln.

ts
import type { InferRequestType, InferResponseType } from 'hono/client'

// InferRequestType
const $post = client.todo.$post
type ReqType = InferRequestType<typeof $post>['form']

// InferResponseType
type ResType = InferResponseType<typeof $post>

Eine Antwort mit einem typsicheren Helper parsen ​

Mit dem Helper parseResponse() kannst du eine Response von hc einfach und typsicher parsen.

ts
import { parseResponse, DetailedError } from 'hono/client'

// result contains the parsed response body (automatically parsed based on Content-Type)
const result = await parseResponse(client.hello.$get()).catch(
  (e: DetailedError) => {
    console.error(e)
  }
)
// parseResponse automatically throws an error if response is not ok

SWR verwenden ​

Du kannst auch eine React-Hook-Bibliothek wie SWR verwenden.

tsx
import useSWR from 'swr'
import { hc } from 'hono/client'
import type { InferRequestType } from 'hono/client'
import type { AppType } from '../functions/api/[[route]]'

const App = () => {
  const client = hc<AppType>('/api')
  const $get = client.hello.$get

  const fetcher =
    (arg: InferRequestType<typeof $get>) => async () => {
      const res = await $get(arg)
      return await res.json()
    }

  const { data, error, isLoading } = useSWR(
    'api-hello',
    fetcher({
      query: {
        name: 'SWR',
      },
    })
  )

  if (error) return <div>failed to load</div>
  if (isLoading) return <div>loading...</div>

  return <h1>{data?.message}</h1>
}

export default App

RPC in größeren Anwendungen verwenden ​

Bei einer größeren Anwendung, wie dem Beispiel aus Eine größere Anwendung erstellen, musst du auf die Typinferenz achten. Eine einfache Vorgehensweise ist, die Handler zu verketten, damit die Typen immer inferiert werden.

ts
// authors.ts
import { Hono } from 'hono'

const app = new Hono()
  .get('/', (c) => c.json('list authors'))
  .post('/', (c) => c.json('create an author', 201))
  .get('/:id', (c) => c.json(`get ${c.req.param('id')}`))

export default app
ts
// books.ts
import { Hono } from 'hono'

const app = new Hono()
  .get('/', (c) => c.json('list books'))
  .post('/', (c) => c.json('create a book', 201))
  .get('/:id', (c) => c.json(`get ${c.req.param('id')}`))

export default app

Anschließend kannst du die Unterrouter wie gewohnt importieren und auch deren Handler verketten. Da dies in diesem Fall die oberste Ebene der Anwendung ist, möchten wir diesen Typ exportieren.

ts
// index.ts
import { Hono } from 'hono'
import authors from './authors'
import books from './books'

const app = new Hono()

const routes = app.route('/authors', authors).route('/books', books)

export default app
export type AppType = typeof routes

Du kannst nun mit dem registrierten AppType einen neuen Client erstellen und ihn wie gewohnt verwenden.

Bekannte Probleme ​

IDE-Leistung ​

Bei der Verwendung von RPC wird deine IDE langsamer, je mehr Routen du hast. Ein wesentlicher Grund dafür ist, dass sehr viele Typinstanziierungen ausgeführt werden, um den Typ deiner Anwendung zu inferieren.

Angenommen, deine Anwendung hat zum Beispiel diese Route:

ts
// app.ts
export const app = new Hono().get('foo/:id', (c) =>
  c.json({ ok: true }, 200)
)

Hono inferiert den Typ wie folgt:

ts
export const app = Hono<BlankEnv, BlankSchema, '/'>().get<
  'foo/:id',
  'foo/:id',
  JSONRespondReturn<{ ok: boolean }, 200>,
  BlankInput,
  BlankEnv
>('foo/:id', (c) => c.json({ ok: true }, 200))

Dies ist eine Typinstanziierung für eine einzelne Route. Dass du diese Typargumente nicht manuell schreiben musst, ist praktisch. Allerdings benötigen Typinstanziierungen bekanntermaßen viel Zeit. Der in deiner IDE verwendete tsserver führt diese zeitaufwendige Aufgabe bei jeder Verwendung der Anwendung aus. Bei vielen Routen kann das deine IDE erheblich verlangsamen.

Wir haben jedoch einige Tipps, um dieses Problem abzumildern.

Unterschiedliche Hono-Versionen ​

Wenn dein Backend vom Frontend getrennt ist und in einem anderen Verzeichnis liegt, musst du sicherstellen, dass die Hono-Versionen übereinstimmen. Verwendest du im Backend eine andere Hono-Version als im Frontend, können Probleme wie „Type instantiation is excessively deep and possibly infinite“ auftreten, also eine zu tiefe und möglicherweise unendliche Typinstanziierung.

TypeScript-Projektreferenzen ​

Wie bei unterschiedlichen Hono-Versionen können Probleme auftreten, wenn Backend und Frontend getrennt sind. Wenn du im Frontend auf Backend-Code zugreifen möchtest (zum Beispiel auf AppType), musst du Projektreferenzen verwenden. Mit TypeScript-Projektreferenzen kann eine TypeScript-Codebasis auf Code einer anderen TypeScript-Codebasis zugreifen und ihn verwenden. (Quelle: Hono RPC und TypeScript-Projektreferenzen).

tsc kann aufwendige Aufgaben wie die Typinstanziierung bereits zur Kompilierzeit erledigen! Dann muss tsserver nicht bei jeder Verwendung alle Typargumente instanziieren. Das macht deine IDE deutlich schneller!

Die beste Leistung erhältst du, wenn du deinen Client einschließlich der Serveranwendung kompilierst. Füge folgenden Code in dein Projekt ein:

ts
import { app } from './app'
import { hc } from 'hono/client'

// this is a trick to calculate the type when compiling
const client = hc<typeof app>('')
export type Client = typeof client

export const hcWithType = (...args: Parameters<typeof hc>): Client =>
  hc<typeof app>(...args)

Nach dem Kompilieren kannst du hcWithType anstelle von hc verwenden, um den Client mit bereits berechnetem Typ zu erhalten.

ts
const client = hcWithType('http://localhost:8787/')
const res = await client.posts.$post({
  form: {
    title: 'Hello',
    body: 'Hono is a cool project',
  },
})

Für ein Monorepo eignet sich diese Lösung besonders gut. Mit einem Werkzeug wie turborepo kannst du das Server- und das Client-Projekt einfach trennen und ihre Abhängigkeiten besser verwalten. Hier ist ein funktionsfähiges Beispiel.

Du kannst deinen Build-Prozess auch manuell mit Werkzeugen wie concurrently oder npm-run-all koordinieren.

Typargumente manuell angeben ​

Das ist etwas umständlich, aber du kannst Typargumente manuell angeben, um Typinstanziierungen zu vermeiden.

ts
const app = new Hono().get<'foo/:id'>('foo/:id', (c) =>
  c.json({ ok: true }, 200)
)

Schon die Angabe eines einzelnen Typarguments kann die Leistung verbessern. Bei vielen Routen kann dies jedoch viel Zeit und Aufwand erfordern.

Anwendung und Client auf mehrere Dateien aufteilen ​

Wie unter RPC in größeren Anwendungen verwenden beschrieben, kannst du deine Anwendung in mehrere Anwendungen aufteilen. Du kannst auch für jede Anwendung einen Client erstellen:

ts
// authors-cli.ts
import { app as authorsApp } from './authors'
import { hc } from 'hono/client'

const authorsClient = hc<typeof authorsApp>('/authors')

// books-cli.ts
import { app as booksApp } from './books'
import { hc } from 'hono/client'

const booksClient = hc<typeof booksApp>('/books')

So muss tsserver nicht die Typen aller Routen gleichzeitig instanziieren.

Handler, die eine Promise-Kette zurückgeben ​

Ein Handler, der direkt eine .then()-Kette zurückgibt, verliert seinen Antworttyp. Deshalb inferiert der Client unknown:

ts
const app = new Hono().get('/', (c) =>
  Promise.resolve({ hello: 'world' }).then((d) => c.json(d))
)

const client = hc<typeof app>('')
const res = await client.index.$get()
const data = await res.json() // unknown

Das ist eine Einschränkung der TypeScript-Typinferenz: Der Antworttyp kann nicht durch eine .then()-Kette hindurch inferiert werden. Verwende stattdessen async/await:

ts
const app = new Hono().get('/', async (c) => {
  const d = await Promise.resolve({ hello: 'world' })
  return c.json(d)
})

const client = hc<typeof app>('')
const res = await client.index.$get()
const data = await res.json() // { hello: string }

Wenn du die Kette nicht vermeiden kannst, funktioniert auch eine Typannotation für then():

ts
import type { TypedResponse } from 'hono/types'

const app = new Hono().get('/', (c) =>
  Promise.resolve({ hello: 'world' }).then<
    TypedResponse<{ hello: string }, 200, 'json'>
  >((d) => c.json(d, 200))
)

Veröffentlicht unter der MIT-Lizenz.