RPC
RPC 기능은 서버와 클라이언트 간에 API 스펙을 공유할 수 있게 해준다.
Validator로 지정된 입력 타입과 json() 메서드로 반환되는 출력 타입을 내보낼 수 있다. 그러면 Hono 클라이언트가 이를 가져다 사용할 수 있다.
NOTE
모노레포에서 RPC 타입이 제대로 동작하려면 클라이언트와 서버의 tsconfig.json 파일에서 compilerOptions에 "strict": true를 설정해야 한다. 자세히 알아보기
서버
서버 측에서는 단순히 검증기를 작성하고 route 변수를 생성하기만 하면 된다. 다음 예제는 Zod Validator를 사용한다.
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 스펙을 공유하기 위해 타입을 내보낸다.
export type AppType = typeof route클라이언트
클라이언트 측에서는 먼저 hc와 AppType을 임포트한다.
import { AppType } from '.'
import { hc } from 'hono/client'hc는 클라이언트를 생성하는 함수다. 제네릭으로 AppType을 전달하고, 서버 URL을 인자로 지정한다.
const client = hc<AppType>('http://localhost:8787/')client.{path}.{method}를 호출하고, 서버로 전송할 데이터를 인자로 전달한다.
const res = await client.posts.$post({
form: {
title: 'Hello',
body: 'Hono는 멋진 프로젝트입니다.',
},
})res는 "fetch"의 Response와 호환된다. res.json()을 사용해 서버에서 데이터를 가져올 수 있다.
if (res.ok) {
const data = await res.json()
console.log(data.message)
}파일 업로드
현재 클라이언트는 파일 업로드를 지원하지 않는다.
상태 코드
c.json()에 200이나 404와 같은 상태 코드를 명시적으로 지정하면, 클라이언트에 전달할 타입으로 추가된다.
// 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상태 코드를 통해 데이터를 가져올 수 있다.
// 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()를 사용하면 안 된다. 이 경우 클라이언트가 서버로부터 받는 데이터를 올바르게 추론할 수 없다.
// 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()을 사용하고, 상태 코드를 명시적으로 지정해야 한다.
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 상태 코드 지정
}
)경로 파라미터
경로 파라미터를 포함하는 라우트도 처리할 수 있다.
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으로 지정한다.
const res = await client.posts[':id'].$get({
param: {
id: '123',
},
query: {},
})헤더
요청에 헤더를 추가할 수 있다.
const res = await client.search.$get(
{
//...
},
{
headers: {
'X-Custom-Header': 'Here is Hono Client',
'X-User-Agent': 'hc',
},
}
)모든 요청에 공통으로 적용할 헤더를 추가하려면, hc 함수의 인자로 지정한다.
const client = hc<AppType>('/api', {
headers: {
Authorization: 'Bearer TOKEN',
},
})init 옵션
fetch의 RequestInit 객체를 init 옵션으로 요청에 전달할 수 있다. 아래는 요청을 중단하는 예제이다.
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
// ❌ 오류 발생
const client = hc<AppType>('/')
client.api.post.$url()
// ✅ 정상 작동
const client = hc<AppType>('http://localhost:8787/')
client.api.post.$url()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 메서드를 사용하는 방법을 보여준다.
# wrangler.toml
services = [
{ binding = "AUTH", service = "auth-service" },
]// src/client.ts
const client = hc<CreateProfileType>('/', {
fetch: c.env.AUTH.fetch.bind(c.env.AUTH),
})타입 추론
InferRequestType과 InferResponseType을 사용해 요청할 객체의 타입과 반환될 객체의 타입을 알 수 있다.
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 라이브러리를 사용할 수도 있다.
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 사용하기
대규모 애플리케이션 구축에서 언급한 예제와 같은 대규모 애플리케이션을 다룰 때는 타입 추론에 주의해야 한다. 간단한 방법으로 핸들러를 체이닝하여 타입을 항상 추론되도록 할 수 있다.
// 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// 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그런 다음 서브 라우트를 평소처럼 임포트하고, 핸들러도 체이닝해야 한다. 이 경우 앱의 최상위 레벨이므로 이 타입을 내보내고자 한다.
// 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의 성능이 저하된다. 이는 앱의 타입을 추론하기 위해 대량의 타입 인스턴스화가 실행되기 때문이다.
예를 들어, 다음과 같은 라우트가 있는 앱을 생각해 보자:
// app.ts
export const app = new Hono().get('foo/:id', (c) =>
c.json({ ok: true }, 200)
)Hono는 이 라우트의 타입을 다음과 같이 추론한다:
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 버전을 사용하면 "타입 인스턴스화가 지나치게 깊어지고 무한할 가능성이 있다"와 같은 문제가 발생할 수 있다.
TypeScript 프로젝트 참조
Hono 버전 불일치 사례에서와 마찬가지로, 백엔드와 프론트엔드가 분리된 경우 문제가 발생할 수 있다. 프론트엔드에서 백엔드의 코드(예: AppType)에 접근하려면 프로젝트 참조를 사용해야 한다. TypeScript의 프로젝트 참조 기능은 하나의 TypeScript 코드베이스가 다른 TypeScript 코드베이스의 코드에 접근하고 사용할 수 있도록 한다. (출처: Hono RPC And TypeScript Project References).
코드를 사용하기 전에 컴파일하기 (권장)
tsc는 컴파일 시점에 타입 인스턴스화와 같은 무거운 작업을 수행할 수 있다. 이렇게 하면 tsserver가 매번 모든 타입 인자를 인스턴스화할 필요가 없어져 IDE의 속도가 크게 빨라진다.
서버 앱을 포함한 클라이언트를 컴파일하면 최상의 성능을 얻을 수 있다. 프로젝트에 다음 코드를 추가한다:
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을 사용해 이미 계산된 타입을 가진 클라이언트를 얻을 수 있다.
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 같은 도구를 사용해 빌드 프로세스를 수동으로 조정할 수도 있다.
타입 인자를 직접 지정하기
이 방법은 다소 번거롭지만, 타입 인자를 직접 지정하면 타입 인스턴스화를 피할 수 있다.
const app = new Hono().get<'foo/:id'>('foo/:id', (c) =>
c.json({ ok: true }, 200)
)단일 타입 인자만 지정해도 성능에 차이를 만들 수 있다. 하지만 라우트가 많을 경우 시간과 노력이 많이 들 수 있다.
앱과 클라이언트를 여러 파일로 분리하기
더 큰 애플리케이션에서 RPC 사용하기에서 설명한 것처럼, 앱을 여러 개의 앱으로 분할할 수 있다. 또한 각 앱에 대한 클라이언트를 별도로 생성할 수 있다:
// 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가 모든 라우트에 대한 타입을 한 번에 인스턴스화할 필요가 없다.