Skip to content

RPC ​

RPC 기능은 서버와 클라이언트 간에 API 스펙을 공유할 수 있게 해준다.

Validator로 지정된 입력 타입과 json() 메서드로 반환되는 출력 타입을 내보낼 수 있다. 그러면 Hono 클라이언트가 이를 가져다 사용할 수 있다.

NOTE

모노레포에서 RPC 타입이 제대로 동작하려면 클라이언트와 서버의 tsconfig.json 파일에서 compilerOptions에 "strict": true를 설정해야 한다. 자세히 알아보기

서버 ​

서버 측에서는 단순히 검증기를 작성하고 route 변수를 생성하기만 하면 된다. 다음 예제는 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
    )
  }
)

그런 다음, 클라이언트와 API 스펙을 공유하기 위해 타입을 내보낸다.

ts
export type AppType = typeof route

클라이언트 ​

클라이언트 측에서는 먼저 hc와 AppType을 임포트한다.

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

hc는 클라이언트를 생성하는 함수다. 제네릭으로 AppType을 전달하고, 서버 URL을 인자로 지정한다.

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

client.{path}.{method}를 호출하고, 서버로 전송할 데이터를 인자로 전달한다.

ts
const res = await client.posts.$post({
  form: {
    title: 'Hello',
    body: 'Hono는 멋진 프로젝트입니다.',
  },
})

res는 "fetch"의 Response와 호환된다. res.json()을 사용해 서버에서 데이터를 가져올 수 있다.

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

파일 업로드

현재 클라이언트는 파일 업로드를 지원하지 않는다.

상태 코드 ​

c.json()에 200이나 404와 같은 상태 코드를 명시적으로 지정하면, 클라이언트에 전달할 타입으로 추가된다.

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) // 404 지정
    }

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

export type AppType = typeof app

상태 코드를 통해 데이터를 가져올 수 있다.

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
>

찾을 수 없음(Not Found) ​

클라이언트를 사용할 때, c.notFound()를 사용하면 안 된다. 이 경우 클라이언트가 서버로부터 받는 데이터를 올바르게 추론할 수 없다.

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가 unknown으로 추론됨

대신 c.json()을 사용하고, 상태 코드를 명시적으로 지정해야 한다.

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.json({ error: 'not found' }, 404) // 404 상태 코드 지정
    }

    return c.json({ post }, 200) // 200 상태 코드 지정
  }
)

경로 파라미터 ​

경로 파라미터를 포함하는 라우트도 처리할 수 있다.

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

경로에 포함할 문자열을 param으로 지정한다.

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

헤더 ​

요청에 헤더를 추가할 수 있다.

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

모든 요청에 공통으로 적용할 헤더를 추가하려면, hc 함수의 인자로 지정한다.

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

init 옵션 ​

fetch의 RequestInit 객체를 init 옵션으로 요청에 전달할 수 있다. 아래는 요청을 중단하는 예제이다.

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: {
      // 요청 본문
    },
  },
  {
    // RequestInit 객체
    init: {
      signal: abortController.signal,
    },
  }
)

// ...

abortController.abort()

INFO

init으로 정의된 RequestInit 객체는 가장 높은 우선순위를 가진다. 이 옵션은 body | method | headers와 같은 다른 옵션으로 설정된 값을 덮어쓸 수 있다.

$url() ​

$url()을 사용하면 엔드포인트에 접근하기 위한 URL 객체를 얻을 수 있다.

WARNING

이 기능을 사용하려면 절대 URL을 전달해야 한다. 상대 URL /을 전달하면 다음과 같은 오류가 발생한다.

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

ts
// ❌ 오류 발생
const client = hc<AppType>('/')
client.api.post.$url()

// ✅ 정상 작동
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`

커스텀 fetch 메서드 설정 ​

기본 fetch 메서드 대신 커스텀 fetch 메서드를 설정할 수 있다.

아래 예제는 Cloudflare Worker에서 Service Bindings의 fetch 메서드를 사용하는 방법을 보여준다.

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

타입 추론 ​

InferRequestType과 InferResponseType을 사용해 요청할 객체의 타입과 반환될 객체의 타입을 알 수 있다.

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>

SWR 사용하기 ​

SWR과 같은 React Hook 라이브러리를 사용할 수도 있다.

tsx
import useSWR from 'swr'
import { hc } from 'hono/client'
import type { InferRequestType } from 'hono/client'
import { 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 사용하기 ​

대규모 애플리케이션 구축에서 언급한 예제와 같은 대규모 애플리케이션을 다룰 때는 타입 추론에 주의해야 한다. 간단한 방법으로 핸들러를 체이닝하여 타입을 항상 추론되도록 할 수 있다.

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

그런 다음 서브 라우트를 평소처럼 임포트하고, 핸들러도 체이닝해야 한다. 이 경우 앱의 최상위 레벨이므로 이 타입을 내보내고자 한다.

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

이제 등록된 AppType을 사용해 새로운 클라이언트를 생성하고 평소처럼 사용할 수 있다.

알려진 문제 ​

IDE 성능 이슈 ​

RPC를 사용할 때 라우트가 많을수록 IDE의 성능이 저하된다. 이는 앱의 타입을 추론하기 위해 대량의 타입 인스턴스화가 실행되기 때문이다.

예를 들어, 다음과 같은 라우트가 있는 앱을 생각해 보자:

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

Hono는 이 라우트의 타입을 다음과 같이 추론한다:

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

이는 단일 라우트에 대한 타입 인스턴스화 예시다. 사용자가 이러한 타입 인자를 직접 작성할 필요는 없지만, 타입 인스턴스화에는 상당한 시간이 소요된다. IDE에서 사용하는 tsserver는 앱을 사용할 때마다 이 시간이 걸리는 작업을 수행한다. 라우트가 많을 경우 IDE 성능이 크게 저하될 수 있다.

이 문제를 완화하기 위한 몇 가지 팁이 있다.

Hono 버전 불일치 ​

백엔드와 프론트엔드를 분리해서 다른 디렉토리에 두고 있다면, Hono 버전이 일치하는지 확인해야 한다. 백엔드와 프론트엔드에서 서로 다른 Hono 버전을 사용하면 "타입 인스턴스화가 지나치게 깊어지고 무한할 가능성이 있다"와 같은 문제가 발생할 수 있다.

hono-version-mismatch

TypeScript 프로젝트 참조 ​

Hono 버전 불일치 사례에서와 마찬가지로, 백엔드와 프론트엔드가 분리된 경우 문제가 발생할 수 있다. 프론트엔드에서 백엔드의 코드(예: AppType)에 접근하려면 프로젝트 참조를 사용해야 한다. TypeScript의 프로젝트 참조 기능은 하나의 TypeScript 코드베이스가 다른 TypeScript 코드베이스의 코드에 접근하고 사용할 수 있도록 한다. (출처: Hono RPC And TypeScript Project References).

코드를 사용하기 전에 컴파일하기 (권장) ​

tsc는 컴파일 시점에 타입 인스턴스화와 같은 무거운 작업을 수행할 수 있다. 이렇게 하면 tsserver가 매번 모든 타입 인자를 인스턴스화할 필요가 없어져 IDE의 속도가 크게 빨라진다.

서버 앱을 포함한 클라이언트를 컴파일하면 최상의 성능을 얻을 수 있다. 프로젝트에 다음 코드를 추가한다:

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

// 컴파일 시점에 타입을 계산하기 위한 트릭
const client = hc<typeof app>('')
export type Client = typeof client

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

컴파일 후에는 hc 대신 hcWithType을 사용해 이미 계산된 타입을 가진 클라이언트를 얻을 수 있다.

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

프로젝트가 모노레포 구조라면 이 솔루션이 잘 맞는다. turborepo 같은 도구를 사용하면 서버 프로젝트와 클라이언트 프로젝트를 쉽게 분리하고, 의존성을 더 효율적으로 관리할 수 있다. 작동 예제를 참고한다.

concurrently나 npm-run-all 같은 도구를 사용해 빌드 프로세스를 수동으로 조정할 수도 있다.

타입 인자를 직접 지정하기 ​

이 방법은 다소 번거롭지만, 타입 인자를 직접 지정하면 타입 인스턴스화를 피할 수 있다.

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

단일 타입 인자만 지정해도 성능에 차이를 만들 수 있다. 하지만 라우트가 많을 경우 시간과 노력이 많이 들 수 있다.

앱과 클라이언트를 여러 파일로 분리하기 ​

더 큰 애플리케이션에서 RPC 사용하기에서 설명한 것처럼, 앱을 여러 개의 앱으로 분할할 수 있다. 또한 각 앱에 대한 클라이언트를 별도로 생성할 수 있다:

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

이렇게 하면 tsserver가 모든 라우트에 대한 타입을 한 번에 인스턴스화할 필요가 없다.

Released under the MIT License.