Skip to content

orpc-vs-ts-rest

ts-rest 躺得很安詳,決定來認識認識 oRPC

大家好,我是鱈魚 🐟

前陣子回頭翻自己寫的 ts-rest 實務篇,順手到 npm 上看了眼版本。

@ts-rest/core 停在 3.52.1,最後更新是 2025 年 6 月。

我一直在等的 3.53.0(支援 Standard Schema 的版本)呢?躺在 rc 標籤裡,相當安詳。(´・ω・`)

不管是 GitHub 還是 Discord 都找不到作者,作者人間蒸發惹 ( ´•̥̥̥ ω •̥̥̥` )

反正 AI 改很快,來試試看 oRPC 吧。ԅ( ˘ω˘ԅ)

說是這麼說,別衝動改舊專案,新專案或實驗專案再來試試看。( ´ ▽ ` )ノ

相關套件版本如下:

  • @orpc/server、@orpc/client、@orpc/contract:1.14.14
  • @ts-rest/core:3.52.13.53.0-rc.1 尚未轉正)

甚麼是 oRPC?

oRPC 全名為 OpenAPI Remote Procedure Call,同樣是端到端型別安全的 TypeScript 工具。

名字其實已經把重點講完了,它是 RPC 風格,但同時相容 OpenAPI 規範。

最短的範例

先定義一個 procedure,這是 oRPC 的最小單位。

server/router.ts

ts
import { os } from '@orpc/server'
import * as z from 'zod'

export const findPlanet = os
  .input(z.object({ id: z.string() }))
  .handler(async ({ input }) => ({
    id: input.id,
    name: '地球',
  }))

export const router = {
  planet: { find: findPlanet },
}

前端拿到的東西長這樣。

web/api.ts

ts
import type { RouterClient } from '@orpc/server'
// 就是上面那包 router,用 import type 拿進來
import type { router } from '../server/router'
import { createORPCClient } from '@orpc/client'
import { RPCLink } from '@orpc/client/fetch'

const link = new RPCLink({ url: 'http://localhost:3000/rpc' })
const client: RouterClient<typeof router> = createORPCClient(link)

const planet = await client.planet.find({ id: '1' })

沒有 method、沒有 path、沒有 status code,就是呼叫一個函式,回傳值直接就是資料。

想要 REST 也可以

剛才那個範例走的是 oRPC 自家的 RPC 協定,那想變成 REST 呢?

router 一個字都不用改,換掉伺服器上的 handler 就好。

ts
// 走 oRPC 自家協定
import { RPCHandler } from '@orpc/server/fetch'

const handler = new RPCHandler(router)
ts
// 走標準 REST
import { OpenAPIHandler } from '@orpc/openapi/fetch'

const handler = new OpenAPIHandler(router)

兩者的 handle() 介面完全相同,所以接線的程式碼也不用動。

ts
export default async function fetch(request: Request) {
  const { matched, response } = await handler.handle(request, {
    prefix: '/api',
    context: {},
  })

  return matched ? response : new Response('Not Found', { status: 404 })
}

所謂「換一種協定」,其實就只是換掉 new 後面那個字而已。ヾ(◍'౪`◍)ノ゙


那端點路徑會長怎樣?沒特別指定的話,oRPC 會給一組預設值。

  • method 是 POST
  • path 由 router 的 key 用 / 串起來
  • 成功狀態碼是 200

以剛才的 planet.find 為例,端點就是 POST /api/planet/find,能跑,但很 RPC 味。( ˙꒳​˙)

想要像樣的 RESTful 端點,就要自行補上 .route()

ts
export const findPlanet = os
  .route({
    method: 'GET',
    path: '/planets/{id}',
    successStatus: 200,
  })
  .input(z.object({ id: z.string() }))
  .handler(async ({ input }) => ({ id: input.id, name: '地球' }))

端點就變成 GET /api/planets/{id} 惹。路徑參數用大括號 {id},不是 ts-rest 的 :id,這點要習慣一下。

.route() 只對 OpenAPIHandler 有意義

RPCHandler 時,路徑一律由 router 的 key 決定,.route() 標的 method 與 path 完全用不到。

換句話說 .route() 不是必填,除非你在意端點好不好看,或是要接 @orpc/nest(後面會聊到,那邊強制要求)。(´,,•ω•,,)

兩種 handler 的取捨如下。

  • RPCHandler:oRPC 自家協定,Date、Set 等型別原樣送達,但人類看不懂
  • OpenAPIHandler:標準 REST,可以直接產生 OpenAPI spec 丟給別人,代價是型別會被 JSON 化

也可以兩個一起掛,一條 /rpc 給自家前端,一條 /api 給外部串接 (ゝ∀・)b

產生 spec 也相當簡單:

ts
import { OpenAPIGenerator } from '@orpc/openapi'
import { ZodToJsonSchemaConverter } from '@orpc/zod/zod4'

const generator = new OpenAPIGenerator({
  schemaConverters: [new ZodToJsonSchemaConverter()],
})

const spec = await generator.generate(router, {
  info: { title: '鱈魚水族館 API', version: '1.0.0' },
})

當然也有合約優先(contract-first)

ts-rest 只有 contract-first 一條路,oRPC 兩種都行。

想先立合約的話,改用 @orpc/contractoc

ts
import { oc } from '@orpc/contract'
import * as z from 'zod'

export const planetContract = {
  find: oc
    .route({ method: 'GET', path: '/planets/{id}' })
    .input(z.object({ id: z.string() }))
    .output(planetSchema),
}

後端用 implement 把合約轉成實作器。

ts
import { implement } from '@orpc/server'

const os = implement(planetContract)

export const router = os.router({
  find: os.find.handler(async ({ input }) => getPlanet(input.id)),
})

os.router() 會強制檢查實作有沒有對齊合約,少一支 procedure 就會把錯誤噴到你眼前。(・∀・)9

這時候前端改成從合約推型別。

ts
import type { ContractRouterClient } from '@orpc/contract'
import { planetContract } from '@project-code/shared'

const client: ContractRouterClient<typeof planetContract> = createORPCClient(link)

合約要怎麼共享?和概念篇的結論一樣,私有 npm 或 monorepo 挑一個。ԅ( ˘ω˘ԅ)

心智模型

比較表如下,第一列是關鍵部分。

項目ts-restoRPC
合約本體HTTP 端點描述(method、path、responses)函式簽章(input、output、errors)
REST必須選配,靠 .route() 開啟
錯誤處理HTTP status code + response schema具名 typed error
驗證器Zod(3.53.0 起支援 Standard Schema)Standard Schema,Zod、Valibot、ArkType 隨你挑
可傳輸型別JSON 表達得出來的範圍外加 Date、BigInt、Set、Map、File、Blob 等
後端整合Nest、Express、Fastify、Next 等Nest、Hono、Elysia、Node、Bun、Deno、Workers 等
前端整合Fetch Client、TanStack Query(React / Vue / Solid)Fetch Client、TanStack Query(React / Vue / Solid / Svelte / Angular)、Pinia Colada
最新穩定版3.52.1(2025-06)1.14.x,持續更新中

實務篇在 oRPC 會怎樣?

來看看實務篇提到的內容,換成 oRPC 會變怎樣。◝( •ω• )◟

為了方便對照,以下沿用實務篇的分法,一樣切成三塊。

  1. 通用概念

    不分前後端的注意事項

  2. 前端

    Client 初始化、結果與錯誤處理、表單驗證等

  3. 後端

    NestJS 整合、拿合約寫測試,最後加碼實務篇沒有的 Nuxt

通用概念

jsonQuery

實務篇花了不少篇幅講 jsonQuery,因為 REST 的 query string 全是字串,數字、布林、單一元素矩陣都會出現歧異。

RPCHandler 時就沒這問題了。

oRPC 有自己的序列化協定,Date 傳過去還是 Date,Set 還是 Set,undefined 也不會憑空蒸發。

ts
await client.planet.create({
  name: '地球',
  discoveredAt: new Date(),
  tags: new Set(['js', 'ts']),
  cover: file, // File 也可以直接塞
})

代價是這條線只有 oRPC client 能通,你用 cURL 會看到一堆天書。(◜௰◝)


OpenAPIHandler 時,oRPC 支援 Bracket Notation,可以用 ?tags[0]=js&tags[1]=ts 表達結構化資料,數字與布林一樣交給驗證器轉換。

概念和 jsonQuery 想解決的事情一樣,只是編碼方式不同。

ClientInfer、ServerInfer

ts-rest 分 ClientInferRequestServerInferRequest,因為驗證器的輸入與輸出型別可能不一樣。

oRPC 一樣會遇到這件事,只是工具改叫 InferRouterInputsInferRouterOutputs

ts
import type { InferRouterInputs, InferRouterOutputs } from '@orpc/server'

type Inputs = InferRouterInputs<typeof router>
type Outputs = InferRouterOutputs<typeof router>

type FindPlanetInput = Inputs['planet']['find']

contract-first 的話,改用 @orpc/contractInferContractRouterInputsInferContractRouterOutputs

切法從「client 視角 / server 視角」變成「輸入 / 輸出」。ԅ(´∀` ԅ)

前端

接著來看看前端。(๑•̀ㅂ•́)و✧

初始化 Client 與 memoize

實務篇為了避免重複建立 client,特地拉了 memoizeobject-hash 包一層,還寫了三個測試確認快取有效。

這段在 oRPC 直接可以刪掉。

oRPC 的 client 是一個 Proxy,整包 router 建立一次就好,之後到處 import 都是同一個。

ts
// api.ts
export const client: RouterClient<typeof router> = createORPCClient(link)

link 可以換

後端用 RPCHandler 就配 RPCLink,用 OpenAPIHandler(包含後面會聊的 @orpc/nest)則配 OpenAPILink

兩者的 urlheadersfetchinterceptors 幾乎一模一樣,所以底下的寫法兩邊都通用,換掉 link 就好。( ‧ω‧)ノ╰(‧ω‧ )

那需要動態調整的部分呢?交給 link 的 headerscontext

ts
const link = new RPCLink<{ skipAuth?: boolean }>({
  url: `${import.meta.env.VITE_API_BASE_URL}/rpc`,
  headers: async ({ context }) => {
    if (context?.skipAuth) {
      return {}
    }

    const { accessToken } = useAuthStore()
    return { Authorization: `Bearer ${accessToken}` }
  },
})

呼叫時把 context 帶進去即可。

ts
await client.planet.find({ id: '1' }, {
  context: { skipAuth: true },
})

根本差別在於,ts-rest 是「不同設定等於不同 client 實例」,所以要想辦法快取實例。

oRPC 則是「一個 client,設定逐次用 context 傳」,快取問題自然消失。✧⁑。٩(ˊᗜˋ*)و✧⁕。

401 自動 refresh

實務篇是覆寫 api 參數,自己包一層 tsRestFetchApi,再用 ref 加 VueUse 的 until 防止重複 refresh。

oRPC 同樣可以覆寫 fetch,寫法幾乎一模一樣,只是掉到更底層一點。

ts
/** 用於防止多次 refresh */
const isRefreshing = ref(false)

const link = new RPCLink({
  url: `${import.meta.env.VITE_API_BASE_URL}/rpc`,
  headers: () => ({
    Authorization: `Bearer ${useAuthStore().accessToken}`,
  }),
  fetch: async (request, init) => {
    await until(isRefreshing).toBe(false)

    // 重試需要用到請求內容,先留一份
    const retryRequest = request.clone()

    const response = await globalThis.fetch(request, init)
    if (response.status !== 401) {
      return response
    }

    // 防止多次 refresh
    if (!isRefreshing.value) {
      isRefreshing.value = true
      await refreshToken()
      isRefreshing.value = false
    }

    await until(isRefreshing).toBe(false)

    // headers 是建立請求時就算好的,重試前要換上新的 token
    retryRequest.headers.set(
      'Authorization',
      `Bearer ${useAuthStore().accessToken}`,
    )
    return globalThis.fetch(retryRequest, init)
  },
})

官方也有現成的重試工具

@orpc/client/plugins 提供 ClientRetryPlugin,可以設定 retryretryDelayshouldRetry 等等。

單純的重試建議直接用它,上面那段是因為 refresh token 需要換 header,才自己動手。( ´ ▽ ` )ノ

狀態與結果資料

這個部分差異就比較大了。(◜௰◝)

實務篇的做法是把 HTTP code 寫進合約,前端拿 result.status 收斂型別。

ts
// ts-rest
const result = await accountApi.create({ body: form })

if (result.status === 200) {
  const newId = result.body.id
}

if (result.status === 400) {
  if (result.body.reason === 'username-duplicate') {
    // 處理重複的使用者名稱
  }
}

oRPC 把錯誤變成合約的一部分,用 .errors() 定義。

ts
export const accountContract = {
  create: oc
    .errors({
      CONFLICT: {
        message: '使用者名稱重複',
        data: z.object({ username: z.string() }),
      },
    })
    .input(createAccountDtoSchema)
    .output(z.object({ id: z.string() })),
}

錯誤代碼與 HTTP 狀態碼

CONFLICTNOT_FOUNDUNAUTHORIZED 等常見代碼內建了 HTTP 狀態碼對照,走 OpenAPI 模式時會自動套用。

代碼也可以自訂,取捨就看你多在意 HTTP 語意了。(´,,•ω•,,)

後端從 handler 參數拿到 errors,直接 throw。

ts
.handler(async ({ input, errors }) => {
  if (await isUsernameDuplicate(input.username)) {
    throw errors.CONFLICT({
      data: { username: input.username },
    })
  }

  return { id: await createAccount(input) }
})

前端則用 safeisDefinedError 收斂。

ts
import { isDefinedError, safe } from '@orpc/client'

const [error, data] = await safe(client.account.create(form))

if (isDefinedError(error)) {
  if (error.code === 'CONFLICT') {
    // error.data.username 有完整型別
  }
}
else if (error) {
  // 非預期錯誤,例如網路斷掉
}
else {
  // data.id 有完整型別
}

TIP

這裡的 safe 用法剛好和我以前介紹過的 await-to-js 相同,真有趣 (´,,•ω•,,)

兩者差別在於:

  • ts-rest 是「先看 status,再看 body 裡的 reason」。
  • oRPC 是「錯誤本身就有名字」,error.data 的型別跟著 code 一起收斂。

表單資料驗證

實務篇是直接從合約上取 schema,不獨立匯出輸入輸出的 schema。

ts
// ts-rest
const schema = accountContract.create.body

這個寫法的價值在於,合約才是入口基準,不然會發生 schema 很多種導致誤取問題。


好消息是 oRPC 也拿得到,只是沒開成公開屬性,東西收在 ~orpc 裡面。

ts
import { accountContract } from '@project-code/shared'

const schema = accountContract.create['~orpc'].inputSchema

只是這寫法不算正規解法,波浪號是 oRPC 標記非公開 API 的慣例,不保證版本相容。

也有人也在官方討論串問了一模一樣的問題,作者本人的回覆就是這樣,並強調要從 contract 取得,不是 client。

希望 oRPC 盡快解決。(´・ω・`)

Vue Query

ts-rest 要另外建立一個 initQueryClient,變成兩種 client 並存。

oRPC 不用,同一個 client 包一層工具就好。

ts
import { createTanstackQueryUtils } from '@orpc/tanstack-query'

export const orpc = createTanstackQueryUtils(client)

接著就是熟悉的 TanStack Query。

ts
const query = useQuery(computed(() => orpc.planet.find.queryOptions({
  input: { id: id.value },
})))

const mutation = useMutation(orpc.planet.create.mutationOptions())

失效處理也有現成 key 可以用。

ts
queryClient.invalidateQueries({
  queryKey: orpc.planet.key(),
})

Pinia Colada

oRPC 也有整合 Pinia Colada,大家也可以試試看。

後端

後端分成三塊,NestJS 整合、拿合約寫測試,最後加碼一個實務篇沒有的 Nuxt。੭ ˙ᗜ˙ )੭

NestJS 整合

概念篇用的是 @TsRestHandlertsRestHandler,oRPC 則是 @Implementimplement

ts
import { Implement, implement } from '@orpc/nest'

@Controller()
export class AccountController {
  constructor(
    private readonly loggerService: LoggerService,
    private readonly accountService: AccountService,
  ) { }

  @Implement(accountContract.create)
  create() {
    return implement(accountContract.create).handler(async ({
      input,
      errors,
    }) => {
      if (await this.accountService.isUsernameDuplicate(input.username)) {
        throw errors.CONFLICT({
          data: { username: input.username },
        })
      }

      const [error, data] = await to(this.accountService.create(input))
      if (error) {
        this.loggerService.error('建立 account 錯誤')
        this.loggerService.error(error)
        throw error
      }

      return data
    })
  }
}

寫法幾乎一樣,差別在錯誤從「回傳一個 status」變成「throw 一個有名字的錯誤」。

ts
// ts-rest:錯誤是一種回傳值
return tsRestHandler(accountContract.create, async ({ body }) => {
  if (await this.accountService.isUsernameDuplicate(body.username)) {
    return { 
      status: 400, 
      body: { reason: 'username-duplicate' }, 
    } 
  }

  return {
    status: 200,
    body: await this.accountService.create(body),
  }
})
ts
// oRPC:錯誤是一種例外
return implement(accountContract.create).handler(async ({ input, errors }) => {
  if (await this.accountService.isUsernameDuplicate(input.username)) {
    throw errors.CONFLICT({ 
      data: { username: input.username }, 
    }) 
  }

  return this.accountService.create(input)
})

ts-rest 不管成功與失敗都用 { status, body } 回傳,oRPC 失敗則是丟合約裡定義好的錯誤。

相較於 returnthrow 有個明確的好處:

  • return 只能結束當前函式,在 forEach() 這類 callback 裡就要想辦法把結果帶回最外層在次 return

  • throw 則可以從任意深處直接拋出,直接中斷後續流程。(ゝ∀・)b

ts
return implement(accountContract.create).handler(async ({ input, errors }) => {
  input.tagList.forEach((tag) => {
    if (isReservedTag(tag)) {
      // 巢狀再深都拋得出去,errors 也還在 closure 裡
      throw errors.CONFLICT({ data: { username: input.username } }) 
    }
  })

  return this.accountService.create(input)
})

callback 換成 async 就接不住了

forEach() 的 callback 加上 async 之後,throw 只會變成沒人 await 的 rejection,handler 照樣往下跑。

這是 JavaScript 本身的規則,與 oRPC 無關,改用 for...of 就好。(´,,•ω•,,)

全域設定

ORPCModule 負責全域設定,forRootforRootAsync 都有。

ts
import { ORPCModule } from '@orpc/nest'
import { onError } from '@orpc/server'

@Module({
  imports: [
    ORPCModule.forRootAsync({
      inject: [LoggerService],
      useFactory: (loggerService: LoggerService) => ({
        interceptors: [
          onError((error) => loggerService.error(error)),
        ],
        eventIteratorKeepAliveInterval: 5000,
      }),
    }),
  ],
})
export class AppModule { }

想在 handler 裡拿到 request 之類的東西,就補上初始 context。

ts
declare module '@orpc/nest' {
  interface ORPCGlobalContext {
    request: Request;
  }
}

ORPCModule.forRootAsync({
  inject: [REQUEST],
  useFactory: (request: Request) => ({
    context: { request },
  }),
})

幾個小小的坑

nest 實際導入可能會遇到以下問題。ԅ( ˘ω˘ԅ)

1. 純 ESM

oRPC 是 ESM-only 套件。tsconfig.jsonmodule 要設成 NodeNext,Node.js 建議 22 以上,才 require 得動 ESM。

用 Nest 預設打包設定的話,可能還要另外調整,讓 @orpc/nest 一起被編譯進去。

2. 合約一定要有 path

每支合約的 .route() 都要給 path,少一個編譯階段就直接擋下來。

懶得一支一支寫的話,用 populateContractRouterPaths 自動補。

ts
import { populateContractRouterPaths } from '@orpc/contract'

export const contract = populateContractRouterPaths({
  account: accountContract,
})

3. 關掉 NestJS 的 body parser

ts
const app = await NestFactory.create(AppModule, {
  bodyParser: false,
})

oRPC 會優先用 NestJS 解析的 body,解析不了才用自己的。

問題是 NestJS 的 urlencoded parser 不認得 Bracket Notation,而且 application/json 這類 content type 的檔案上傳,可能不會解析成 File

4. 錯誤預設會被包成 HttpException

這點最容易中招。oRPC 會攔下 handler 丟出的錯誤,再對 NestJS 丟一個通用的 HttpException,也就是說你原本寫的 Exception Filter 看到的不是原始錯誤。

想讓 NestJS 拿到原始錯誤,要掛 Rethrow Plugin。

ts
import { experimental_RethrowHandlerPlugin as RethrowHandlerPlugin } from '@orpc/server/plugins'

ORPCModule.forRoot({
  plugins: [
    new RethrowHandlerPlugin({
      // 不是 ORPCError 的錯誤就丟回去給 NestJS 處理
      filter: (error) => !(error instanceof ORPCError),
    }),
  ],
})

5. 404 完全不會經過 procedure

路由沒對上時,NestJS 直接回 404,procedure 與它掛的 plugin 一個都不會跑。

所以像 OpenAPI Reference 這種需要攔截未匹配路徑的 plugin,在 NestJS 底下可能不如預期。

6. Fastify 的路徑參數有限制

含斜線的路徑參數({+path})在 Fastify 平台上不支援,因為它沒有 wildcard aliasing。有這需求就乖乖待在 Express。

v2 beta 已經在路上了

@orpc/nest 穩定版是 1.14.x,但 2.0.0-beta 也在跑了,而且合約定義換了寫法。

.route({ path }) 變成 oc.meta(openapi({ path }))populateContractRouterPaths 也改名為 populateRouterContractOpenAPIPaths

官方文件網站目前是 v1 內容,GitHub 主線則是 v2,看文件時要注意自己在看哪一份。


還有件事要講清楚,走 @orpc/nest 的話,端點一定是 OpenAPI 相容的 REST,不會是 oRPC 自家的 RPC 協定。

所以前面聊的 RPCLink 在這個組合下要換成 OpenAPILink,下一段馬上就會用到。(ゝ∀・)b

合約 + 測試

NestJS e2e 得打真的 HTTP,所以之前用 ts-rest 時,要先把合約還原成 url、method、query、body 才交得給 Supertest。

實務篇為此刻了一個 useContract,接著每個資源還要再包一層 API 轉換層。

oRPC 合約本來就能變出 client,所以直接拿 client 打測試 server 就好,完全不需要轉換層。ヽ(●`∀´●)ノ

ts
import type { ContractRouterClient } from '@orpc/contract'
import type { JsonifiedClient } from '@orpc/openapi-client'
import { createORPCClient, isDefinedError, safe } from '@orpc/client'
import { OpenAPILink } from '@orpc/openapi-client/fetch'

let client: JsonifiedClient<ContractRouterClient<typeof contract>>

beforeAll(async () => {
  // ...啟動 Server 與 DB

  client = createORPCClient(new OpenAPILink(contract, {
    url: await app.getUrl(),
    headers: () => ({ Authorization: `Bearer ${token}` }),
  }))
}, 60000)

describe('建立 account', () => {
  it('全部參數', async () => {
    const result = await client.account.create({
      username: 'cod',
      password: 'fish',
    })

    expect(result.id).toBeDefined()
  })

  it('重複的 username 應該要被擋下來', async () => {
    await client.account.create({ username: 'cod', password: 'fish' })

    const [error] = await safe(
      client.account.create({ username: 'cod', password: 'fish' }),
    )

    expect(isDefinedError(error) && error.code).toBe('CONFLICT')
  })
})

為什麼要包一層 JsonifiedClient

OpenAPILink 走的是標準 JSON,Date 傳過來會變成字串。

JsonifiedClient 就是把型別修正成「JSON 化之後的樣子」,免得型別說是 Date、手上拿到的卻是字串。(´,,•ω•,,)

換成 RPCHandlerRPCLink 就沒這問題,因為 RPC 協定原生保留了型別。

如果只想測 handler 邏輯,連 server 都不用開,用 call 直接呼叫。

ts
import { call } from '@orpc/server'

const result = await call(router.account.create, {
  username: 'cod',
  password: 'fish',
}, {
  context: { db },
})

Nuxt 整合

ts-rest 官方沒有 Nuxt 或 Nitro 的 adapter,要自己拿 @ts-rest/serverless/fetch 在 Nitro 路由裡慢慢湊。

oRPC 則可以無痛使用,因為 Nitro 本身就吃 Fetch API,oRPC 的 fetch adapter 可以直接上場。ヽ(●`∀´●)ノ

Server Routes 掛上 handler

在 Nuxt 的 Server Routes 放一個 catch-all 路由。

ts
// server/routes/rpc/[...].ts
import { onError } from '@orpc/server'
import { RPCHandler } from '@orpc/server/fetch'

const handler = new RPCHandler(router, {
  interceptors: [
    onError((error) => console.error(error)),
  ],
})

export default defineEventHandler(async (event) => {
  const request = toWebRequest(event)

  const { response } = await handler.handle(request, {
    prefix: '/rpc',
    context: {}, // 需要的話在這裡給初始 context
  })

  if (response) {
    return response
  }

  setResponseStatus(event, 404, 'Not Found')
  return 'Not found'
})

catch-all 匹配不到 /rpc 本身,所以還要補一個 index 轉過去。

ts
// server/routes/rpc/index.ts
export { default } from './[...]'

這裡的 handler 換成 OpenAPIHandler 也行,端點就變成標準 REST API。

Client 放進 Nuxt Plugin

client 要放進 Nuxt Plugin,SSR 才拿得到 request headers。

ts
// app/plugins/orpc.ts
export default defineNuxtPlugin(() => {
  const event = useRequestEvent()

  const link = new RPCLink({
    url: `${typeof window !== 'undefined' ? window.location.origin : 'http://localhost:3000'}/rpc`,
    headers: event?.headers,
  })

  const client: RouterClient<typeof router> = createORPCClient(link)

  return {
    provide: { client },
  }
})

元件裡用 const { $client } = useNuxtApp() 取出來就能開工了。(๑•̀ㅂ•́)و✧

SSR 別再繞一圈 HTTP

上面的寫法很合理,但是多繞了一圈。(◉◞౪◟◉ )

SSR 時 Nuxt 的 server 會對自己的 /rpc 發一次真的 HTTP 請求,等於自己打自己,白白多一趟來回。

解法是拆成兩個 plugin,讓瀏覽器走 HTTP,server 直接呼叫函式。

ts
// app/plugins/orpc.client.ts
export default defineNuxtPlugin(() => {
  const link = new RPCLink({
    url: `${window.location.origin}/rpc`,
    headers: () => ({}),
  })

  const client: RouterClient<typeof router> = createORPCClient(link)

  return {
    provide: { client },
  }
})
ts
// app/plugins/orpc.server.ts
export default defineNuxtPlugin(() => {
  const event = useRequestEvent()

  const client = createRouterClient(router, {
    context: {
      headers: event?.headers,
    },
  })

  return {
    provide: { client },
  }
})

Nuxt 的 .client.ts.server.ts 後綴會自動分流,所以 router 實作也不會被打包進瀏覽器,不用擔心 server 端程式碼外洩。

同一個 $client,SSR 時是本地函式呼叫,瀏覽器則是 HTTP 請求,元件完全不用改。✧⁑。٩(ˊᗜˋ*)و✧⁕。

這招不限 Nuxt,Next.js、SvelteKit 都是同一套概念,只是分流的手法不同。

oRPC 優勢

oRPC 在檔案、Date、SSE 串流這幾點體驗更好。

FileBlob 可以直接進出 procedure,Date 不會在路上變成字串。

串流則是用 eventIterator 定義,handler 回傳 async generator,前端拿到的就是能 for await 的迭代器,型別一路保留。

而且 oRPC 的生態系整合相當豐富,ts-rest 則已停止更新,應該是不可能追上了。( ˘・з・)

總結 🐟

  • oRPC 是 RPC 風格但相容 OpenAPI,最大的差別在心智模型,ts-rest 的合約是 HTTP 端點,oRPC 的合約是函式簽章
  • 實務篇的 jsonQuery、client memoize、e2e 轉換層,在 oRPC 上大多直接消失
  • 錯誤處理從 status code 換成具名 typed error,前端用 safeisDefinedError 收斂
  • NestJS 的坑集中在 ESM 與錯誤攔截,導入前務必先看過那六點
  • Nuxt 靠 .client.ts.server.ts 雙 plugin,就能讓 SSR 省掉一趟 HTTP

感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去 (*´∀`)~♥

有錯誤或不周全之處,還請多多指教 ( ´ ▽ ` )ノ