
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.1(3.53.0-rc.1尚未轉正)
甚麼是 oRPC?
oRPC 全名為 OpenAPI Remote Procedure Call,同樣是端到端型別安全的 TypeScript 工具。
名字其實已經把重點講完了,它是 RPC 風格,但同時相容 OpenAPI 規範。
最短的範例
先定義一個 procedure,這是 oRPC 的最小單位。
server/router.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
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 就好。
// 走 oRPC 自家協定
import { RPCHandler } from '@orpc/server/fetch'
const handler = new RPCHandler(router)// 走標準 REST
import { OpenAPIHandler } from '@orpc/openapi/fetch'
const handler = new OpenAPIHandler(router)兩者的 handle() 介面完全相同,所以接線的程式碼也不用動。
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()。
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 也相當簡單:
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/contract 的 oc。
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 把合約轉成實作器。
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
這時候前端改成從合約推型別。
import type { ContractRouterClient } from '@orpc/contract'
import { planetContract } from '@project-code/shared'
const client: ContractRouterClient<typeof planetContract> = createORPCClient(link)合約要怎麼共享?和概念篇的結論一樣,私有 npm 或 monorepo 挑一個。ԅ( ˘ω˘ԅ)
心智模型
比較表如下,第一列是關鍵部分。
| 項目 | ts-rest | oRPC |
|---|---|---|
| 合約本體 | 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 會變怎樣。◝( •ω• )◟
為了方便對照,以下沿用實務篇的分法,一樣切成三塊。
通用概念
不分前後端的注意事項
前端
Client 初始化、結果與錯誤處理、表單驗證等
後端
NestJS 整合、拿合約寫測試,最後加碼實務篇沒有的 Nuxt
通用概念
jsonQuery
實務篇花了不少篇幅講 jsonQuery,因為 REST 的 query string 全是字串,數字、布林、單一元素矩陣都會出現歧異。
走 RPCHandler 時就沒這問題了。
oRPC 有自己的序列化協定,Date 傳過去還是 Date,Set 還是 Set,undefined 也不會憑空蒸發。
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 分 ClientInferRequest 與 ServerInferRequest,因為驗證器的輸入與輸出型別可能不一樣。
oRPC 一樣會遇到這件事,只是工具改叫 InferRouterInputs 與 InferRouterOutputs。
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/contract 的 InferContractRouterInputs、InferContractRouterOutputs。
切法從「client 視角 / server 視角」變成「輸入 / 輸出」。ԅ(´∀` ԅ)
前端
接著來看看前端。(๑•̀ㅂ•́)و✧
初始化 Client 與 memoize
實務篇為了避免重複建立 client,特地拉了 memoize 加 object-hash 包一層,還寫了三個測試確認快取有效。
這段在 oRPC 直接可以刪掉。
oRPC 的 client 是一個 Proxy,整包 router 建立一次就好,之後到處 import 都是同一個。
// api.ts
export const client: RouterClient<typeof router> = createORPCClient(link)link 可以換
後端用 RPCHandler 就配 RPCLink,用 OpenAPIHandler(包含後面會聊的 @orpc/nest)則配 OpenAPILink。
兩者的 url、headers、fetch、interceptors 幾乎一模一樣,所以底下的寫法兩邊都通用,換掉 link 就好。( ‧ω‧)ノ╰(‧ω‧ )
那需要動態調整的部分呢?交給 link 的 headers 與 context。
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 帶進去即可。
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,寫法幾乎一模一樣,只是掉到更底層一點。
/** 用於防止多次 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,可以設定 retry、retryDelay、shouldRetry 等等。
單純的重試建議直接用它,上面那段是因為 refresh token 需要換 header,才自己動手。( ´ ▽ ` )ノ
狀態與結果資料
這個部分差異就比較大了。(◜௰◝)
實務篇的做法是把 HTTP code 寫進合約,前端拿 result.status 收斂型別。
// 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() 定義。
export const accountContract = {
create: oc
.errors({
CONFLICT: {
message: '使用者名稱重複',
data: z.object({ username: z.string() }),
},
})
.input(createAccountDtoSchema)
.output(z.object({ id: z.string() })),
}錯誤代碼與 HTTP 狀態碼
CONFLICT、NOT_FOUND、UNAUTHORIZED 等常見代碼內建了 HTTP 狀態碼對照,走 OpenAPI 模式時會自動套用。
代碼也可以自訂,取捨就看你多在意 HTTP 語意了。(´,,•ω•,,)
後端從 handler 參數拿到 errors,直接 throw。
.handler(async ({ input, errors }) => {
if (await isUsernameDuplicate(input.username)) {
throw errors.CONFLICT({
data: { username: input.username },
})
}
return { id: await createAccount(input) }
})前端則用 safe 與 isDefinedError 收斂。
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-rest
const schema = accountContract.create.body這個寫法的價值在於,合約才是入口基準,不然會發生 schema 很多種導致誤取問題。
好消息是 oRPC 也拿得到,只是沒開成公開屬性,東西收在 ~orpc 裡面。
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 包一層工具就好。
import { createTanstackQueryUtils } from '@orpc/tanstack-query'
export const orpc = createTanstackQueryUtils(client)接著就是熟悉的 TanStack Query。
const query = useQuery(computed(() => orpc.planet.find.queryOptions({
input: { id: id.value },
})))
const mutation = useMutation(orpc.planet.create.mutationOptions())失效處理也有現成 key 可以用。
queryClient.invalidateQueries({
queryKey: orpc.planet.key(),
})Pinia Colada
oRPC 也有整合 Pinia Colada,大家也可以試試看。
後端
後端分成三塊,NestJS 整合、拿合約寫測試,最後加碼一個實務篇沒有的 Nuxt。੭ ˙ᗜ˙ )੭
NestJS 整合
概念篇用的是 @TsRestHandler 加 tsRestHandler,oRPC 則是 @Implement 加 implement。
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-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),
}
})// 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 失敗則是丟合約裡定義好的錯誤。
相較於 return,throw 有個明確的好處:
return只能結束當前函式,在forEach()這類 callback 裡就要想辦法把結果帶回最外層在次return。throw則可以從任意深處直接拋出,直接中斷後續流程。(ゝ∀・)b
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 負責全域設定,forRoot 與 forRootAsync 都有。
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。
declare module '@orpc/nest' {
interface ORPCGlobalContext {
request: Request;
}
}
ORPCModule.forRootAsync({
inject: [REQUEST],
useFactory: (request: Request) => ({
context: { request },
}),
})幾個小小的坑
nest 實際導入可能會遇到以下問題。ԅ( ˘ω˘ԅ)
1. 純 ESM
oRPC 是 ESM-only 套件。tsconfig.json 的 module 要設成 NodeNext,Node.js 建議 22 以上,才 require 得動 ESM。
用 Nest 預設打包設定的話,可能還要另外調整,讓 @orpc/nest 一起被編譯進去。
2. 合約一定要有 path
每支合約的 .route() 都要給 path,少一個編譯階段就直接擋下來。
懶得一支一支寫的話,用 populateContractRouterPaths 自動補。
import { populateContractRouterPaths } from '@orpc/contract'
export const contract = populateContractRouterPaths({
account: accountContract,
})3. 關掉 NestJS 的 body parser
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。
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 就好,完全不需要轉換層。ヽ(●`∀´●)ノ
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、手上拿到的卻是字串。(´,,•ω•,,)
換成 RPCHandler 加 RPCLink 就沒這問題,因為 RPC 協定原生保留了型別。
如果只想測 handler 邏輯,連 server 都不用開,用 call 直接呼叫。
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 路由。
// 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 轉過去。
// server/routes/rpc/index.ts
export { default } from './[...]'這裡的 handler 換成 OpenAPIHandler 也行,端點就變成標準 REST API。
Client 放進 Nuxt Plugin
client 要放進 Nuxt Plugin,SSR 才拿得到 request headers。
// 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 直接呼叫函式。
// 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 },
}
})// 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 串流這幾點體驗更好。
File、Blob 可以直接進出 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,前端用
safe與isDefinedError收斂 - NestJS 的坑集中在 ESM 與錯誤攔截,導入前務必先看過那六點
- Nuxt 靠
.client.ts與.server.ts雙 plugin,就能讓 SSR 省掉一趟 HTTP
感謝您讀到這裡,如果您覺得有收穫,歡迎分享出去 (*´∀`)~♥
有錯誤或不周全之處,還請多多指教 ( ´ ▽ ` )ノ