Aller au contenu

RPC ​

La fonctionnalité RPC permet de partager les spécifications de l’API entre le serveur et le client.

Commencez par exporter le typeof de votre application Hono (généralement nommé AppType), ou uniquement les routes à rendre accessibles au client, depuis votre code serveur.

En acceptant AppType comme paramètre générique, le client Hono peut inférer les types d’entrée définis par le validateur ainsi que les types de sortie produits par les gestionnaires qui renvoient c.json().

NOTE

Pour que les types RPC fonctionnent correctement dans un monorepo, définissez "strict": true dans compilerOptions dans les fichiers tsconfig.json du client et du serveur. En savoir plus.

Serveur ​

Côté serveur, il suffit d’écrire un validateur et de créer une variable route. L’exemple suivant utilise 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

Standard Schema Validator fonctionne également : vous pouvez donc utiliser toute bibliothèque Standard Schema, comme Valibot.

Exportez ensuite le type pour partager les spécifications de l’API avec le client.

ts
export type AppType = typeof route

Client ​

Côté client, importez d’abord hc et AppType.

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

hc est une fonction qui crée un client. Transmettez AppType comme type générique et indiquez l’URL du serveur comme argument.

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

Appelez client.{path}.{method} et transmettez comme argument les données à envoyer au serveur.

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

res est compatible avec la Response de fetch. Vous pouvez récupérer les données du serveur avec res.json().

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

Cookies ​

Pour que le client envoie les cookies à chaque requête, ajoutez { 'init': { 'credentials": 'include' } } aux options lors de sa création.

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',
  },
})

Code de statut ​

Si vous indiquez explicitement un code de statut, comme 200 ou 404, dans c.json(), il sera ajouté au type transmis au client.

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

Vous pouvez récupérer les données selon le code de statut.

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
>

Réponse globale ​

Le client RPC de Hono n’infère pas automatiquement les types de réponse des gestionnaires d’erreur globaux comme app.onError() ou des middlewares globaux. L’utilitaire de types ApplyGlobalResponse permet de fusionner les types des réponses d’erreur globales dans toutes les routes.

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')

Le client connaît désormais les réponses de succès et d’erreur :

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 }

Vous pouvez aussi définir plusieurs codes de statut d’erreur globaux à la fois :

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

Réponse introuvable ​

Pour utiliser un client, n’utilisez pas c.notFound() pour la réponse introuvable. Les données reçues du serveur ne pourraient pas être correctement inférées.

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

Utilisez plutôt c.json() et indiquez le code de statut de la réponse introuvable.

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
  }
)

Vous pouvez aussi étendre l’interface NotFoundResponse par augmentation de module. Cela permet à c.notFound() de renvoyer une réponse typée :

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

Le client peut maintenant inférer correctement le type de la réponse 404.

Paramètres de chemin ​

Vous pouvez aussi traiter les routes contenant des paramètres de chemin ou des valeurs de requête.

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',
    })
  }
)

Les paramètres de chemin comme les valeurs de requête doivent être transmis sous forme de string, même si la valeur sous-jacente a un autre type.

Indiquez la chaîne à inclure dans le chemin avec param et les valeurs de requête avec query.

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

Plusieurs paramètres ​

Traitez des routes comportant plusieurs paramètres.

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',
    })
  }
)

Ajoutez plusieurs [''] pour définir les paramètres du chemin.

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

Inclure des barres obliques ​

La fonction hc n’encode pas les valeurs de param dans l’URL. Pour inclure des barres obliques dans les paramètres, utilisez des expressions régulières.

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

Les paramètres de chemin ordinaires sans expression régulière ne correspondent pas aux barres obliques. Si vous transmettez un param contenant des barres obliques avec la fonction hc, le routage du serveur peut ne pas se comporter comme prévu. Il est recommandé d’encoder les paramètres avec encodeURIComponent pour assurer un routage correct.

En-têtes ​

Vous pouvez ajouter des en-têtes à la requête.

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

Pour ajouter un en-tête commun à toutes les requêtes, indiquez-le comme argument de la fonction hc.

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

Option init ​

Vous pouvez transmettre l’objet RequestInit de fetch à la requête via l’option init. Voici un exemple d’annulation d’une requête.

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()

Information

Un objet RequestInit défini par init a la priorité la plus élevée. Il peut remplacer des valeurs définies par d’autres options, comme body | method | headers.

$url() ​

Vous pouvez obtenir un objet URL pour accéder au point de terminaison avec $url().

Attention

Pour que cela fonctionne, vous devez fournir une URL absolue. Une URL relative / provoque l’erreur suivante.

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`

URL typée ​

Vous pouvez transmettre l’URL de base comme deuxième paramètre de type à hc pour obtenir des types d’URL plus précis :

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

Cela est utile lorsque vous souhaitez utiliser l’URL comme clé typée dans des bibliothèques comme SWR.

$path() ​

$path() ressemble à $url(), mais renvoie une chaîne de chemin plutôt qu’un objet URL. Contrairement à $url(), il n’inclut pas l’origine de l’URL de base ; il fonctionne donc quelle que soit l’URL de base transmise à hc.

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`

Vous pouvez aussi transmettre des paramètres de requête :

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

Téléversement de fichiers ​

Vous pouvez téléverser des fichiers à l’aide d’un corps de formulaire :

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),
    })
  )
  // ...
)

Méthode fetch personnalisée ​

Vous pouvez définir une méthode fetch personnalisée.

Dans l’exemple suivant pour Cloudflare Worker, la méthode fetch des Service Bindings est utilisée à la place de la méthode fetch par défaut.

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),
})

Sérialisation personnalisée des paramètres de requête ​

Vous pouvez personnaliser la sérialisation des paramètres de requête avec l’option buildSearchParams. Cela est utile si vous avez besoin de la notation avec crochets pour les tableaux ou d’autres formats personnalisés :

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
  },
})

Inférence ​

Utilisez InferRequestType et InferResponseType pour connaître le type de l’objet à envoyer et celui de l’objet à recevoir.

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>

Analyser une réponse avec un utilitaire assurant la sûreté des types ​

L’utilitaire parseResponse() permet d’analyser facilement une Response de hc avec sûreté des types.

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

Utiliser SWR ​

Vous pouvez aussi utiliser une bibliothèque de hooks React comme SWR.

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

Utiliser RPC dans de grandes applications ​

Dans une application plus grande, comme celle présentée dans Construire une application plus grande, soyez attentif à l’inférence de types. Une façon simple de procéder consiste à chaîner les gestionnaires pour que les types soient toujours inférés.

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

Vous pouvez ensuite importer les sous-routeurs comme d’habitude et veiller à chaîner leurs gestionnaires également. Il s’agit ici du niveau supérieur de l’application, dont nous souhaitons exporter le type.

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

Vous pouvez maintenant créer un nouveau client avec l’AppType enregistré et l’utiliser normalement.

Problèmes connus ​

Performances de l’IDE ​

Avec RPC, plus vous avez de routes, plus votre IDE ralentit. L’une des principales raisons est le grand nombre d’instanciations de types exécutées pour inférer le type de votre application.

Supposons par exemple que votre application possède cette route :

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

Hono infère le type suivant :

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))

Il s’agit de l’instanciation de type d’une seule route. L’utilisateur n’a pas besoin d’écrire manuellement ces arguments de type, ce qui est pratique, mais l’instanciation de types est connue pour être coûteuse. Le tsserver utilisé par votre IDE effectue cette tâche à chaque utilisation de l’application. Un grand nombre de routes peut donc ralentir considérablement votre IDE.

Voici toutefois quelques conseils pour atténuer ce problème.

Versions de Hono différentes ​

Si votre backend est séparé du frontend et se trouve dans un autre répertoire, assurez-vous que leurs versions de Hono sont identiques. Des versions différentes peuvent provoquer des erreurs comme « Type instantiation is excessively deep and possibly infinite ».

Références de projets TypeScript ​

Comme pour les versions de Hono différentes, vous pouvez rencontrer des problèmes si le backend et le frontend sont séparés. Pour accéder au code du backend (par exemple AppType) depuis le frontend, utilisez des références de projets. Elles permettent à un projet TypeScript d’accéder au code d’un autre projet TypeScript et de l’utiliser. (source : Hono RPC et les références de projets TypeScript).

tsc peut effectuer les tâches coûteuses comme l’instanciation de types au moment de la compilation ! tsserver n’a alors plus besoin d’instancier tous les arguments de type à chaque utilisation. Votre IDE sera beaucoup plus rapide !

Compiler votre client en incluant l’application serveur offre les meilleures performances. Ajoutez le code suivant à votre projet :

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)

Après la compilation, utilisez hcWithType à la place de hc pour obtenir un client dont le type a déjà été calculé.

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

Cette solution convient bien aux monorepos. Avec un outil comme turborepo, vous pouvez facilement séparer le projet serveur du projet client et mieux gérer leurs dépendances. Voici un exemple fonctionnel.

Vous pouvez aussi coordonner manuellement les étapes de compilation avec des outils comme concurrently ou npm-run-all.

Indiquer manuellement les arguments de type ​

C’est un peu fastidieux, mais vous pouvez indiquer les arguments de type manuellement pour éviter leur instanciation.

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

Indiquer un seul argument de type améliore déjà les performances, mais cela peut demander beaucoup de temps et d’efforts si vous avez de nombreuses routes.

Séparer l’application et le client en plusieurs fichiers ​

Comme expliqué dans Utiliser RPC dans de grandes applications, vous pouvez diviser votre application en plusieurs applications. Vous pouvez aussi créer un client pour chacune :

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')

Ainsi, tsserver n’a pas besoin d’instancier les types de toutes les routes à la fois.

Gestionnaires qui renvoient une chaîne de promesses ​

Un gestionnaire qui renvoie directement une chaîne .then() perd son type de réponse ; le client infère alors 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

Il s’agit d’une limite de l’inférence TypeScript : le type de réponse ne peut pas être inféré à travers une chaîne .then(). Utilisez plutôt 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 }

Si vous ne pouvez pas éviter cette chaîne, annoter then() fonctionne aussi :

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))
)

Publié sous licence MIT.