Share Notes

chundev

View the Project on GitHub latteouka/share-notes

tRPC 走 HTTP 怎麼長?protectedProcedure 的 session 怎麼來?

日期:2026-05-23


TL;DR

tRPC 的設計把 HTTP 當實作細節、把 SDK 當主角——所以直接看 wire format 會覺得「URL 看不懂」。但底下就是 HTTP,理解三層 wrap(batch / input index / SuperJSON)+ cookie-based session,就能用 curl 觸發任何 procedure,包括 protectedProcedureprotectedProcedure 不是「URL 鎖起來」,它是 server middleware;session 從 request cookie 還原,curl 帶 cookie jar 就能通過。


背景

在 React app 裡 tRPC SDK 介面很乾淨:

const m = api.cert.generateCsr.useMutation();
m.mutate({ commonName: "example.local", sans: [...] });

但翻 DevTools Network 面板會看到:

POST /api/trpc/cert.generateCsr?batch=1
Body: [{"0":{"json":{"commonName":"example.local","sans":[...]}}}]
Response: [{"result":{"data":{"json":{"jobId":"abc-123"}}}}]

或 query:

GET /api/trpc/cert.history,cert.status?batch=1&input=%7B%220%22%3A%7B%22json%22%3Anull%2C%22meta%22%3A%7B%22values%22%3A%5B%22undefined%22%5D%2C%22v%22%3A1%7D%7D...

兩件事違反直覺:URL 帶逗號分隔多個 procedure、input 是多層包裝的 URL-encoded JSON。這篇講怎麼讀懂、怎麼用 curl 重現一次 protectedProcedure 呼叫。


tRPC 的 wire format:三層 wrap

tRPC HTTP layer 的設計考量是「給 SDK 用、給 batch 友善、給 SuperJSON 友善」,不是給人讀的。

Wrap 1:Batch — 多個 procedure 同一次 round-trip

預設 batch link 啟用,多個 procedure 用逗號接在 URL:

GET /api/trpc/proc1,proc2,proc3?batch=1

即使只呼叫一支也會帶 ?batch=1(fetcher 一律走 batched)。手動 curl 時不加 ?batch=1 是常見錯誤,server 認不出 endpoint。

Wrap 2:Input index — 每個 procedure 的 input 用編號物件包

{"0": {"json": <input for proc1>}, "1": {"json": <input for proc2>}}

0 / 1 對應 URL 裡 procedure 的順序。即使只一個 procedure 也要寫 {"0": ...}

Wrap 3:SuperJSON — {"json": value, "meta": {...}}

原生 JSON 沒法表達 DateBigIntMapSetundefined,所以 tRPC 預設用 SuperJSON 把這些型別還原資訊塞進 meta

{"json": null, "meta": {"values": ["undefined"], "v": 1}}

這個 meta.values = ["undefined"] 告訴 deserializer「上面那個 null 其實要還原成 undefined」。對於 Date

{"json": "2026-05-23T01:59:18.264Z", "meta": {"values": ["Date"], "v": 1}}

純 JSON 那個 "2026-..." 是字串、meta 告訴對方還原成 Date 物件。多數時候單純 primitive 不需要 meta,但寫 curl 觸發接受複合型別的 procedure 時必須把這個 wrap 做完整。

Response shape 對稱

Response 也是 array、也是 SuperJSON:

[
  {"result": {"data": {"json": {"jobId": "abc-123"}}}},
  {"result": {"data": {"json": {"status": "ok"}}}}
]

Index 對應 request 的 procedure 順序。


protectedProcedure 怎麼擋住未授權的請求?

tRPC 的 protected 不在 URL 層、不在 HTTP method 層、不在 IP 白名單層——而在 server middleware:

export const protectedProcedure = publicProcedure.use(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: "UNAUTHORIZED" });
  }
  return next({ ctx: { ...ctx, session: ctx.session } });
});

ctx.session 從哪來?看 tRPC context builder:

export async function createContext({ headers, req }) {
  const session = await auth.api.getSession({ headers });  // ← 從 cookie 讀
  return { db, session, headers };
}

整條鏈:

HTTP cookie ─→ auth.api.getSession() ─→ ctx.session ─→ protectedProcedure middleware ─→ procedure

URL 沒鎖、沒 token in URL——一切靠 server 端 middleware 驗 session。Client 端就算掃到 procedure 名稱,沒帶 cookie 也只會收到:

{"error": {"json": {"message": "UNAUTHORIZED", "code": -32001, ...}}}

用 curl 重現一次 protected mutation

許多現代 JS 認證庫(例如 better-auth)用 HttpOnly + Secure + SameSite 的 cookie 存 session token:

Set-Cookie: __Secure-better-auth.session_token=...; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000

curl 用 cookie jar 把它存下來、後續請求自動帶上:

# 1. 登入,把 set-cookie 寫進 jar
curl -X POST https://app.example.com/api/auth/sign-in/username \
  -H 'Content-Type: application/json' \
  -d '{"username":"admin","password":"..."}' \
  -c /tmp/cookies.txt

# 2. mutation = POST,body 帶完整三層 wrap
curl -X POST 'https://app.example.com/api/trpc/cert.generateCsr?batch=1' \
  -H 'Content-Type: application/json' \
  -b /tmp/cookies.txt \
  -d '{"0":{"json":{"commonName":"foo.example.local","sans":["foo.example.local"]}}}'

# Response: [{"result":{"data":{"json":{"jobId":"abc-123"}}}}]

# 3. query = GET,input 進 URL(URL-encoded JSON)
INPUT=$(python3 -c '
import json, urllib.parse
print(urllib.parse.quote(json.dumps({"0":{"json":{"jobId":"abc-123"}}})))
')
curl 'https://app.example.com/api/trpc/cert.jobStatus?batch=1&input='"$INPUT" \
  -b /tmp/cookies.txt

-c 寫 jar、-b 讀 jar。如果 server 用自簽憑證再加 -k(測試環境常見)。

沒有 input 的 query

也得帶 {"0":{"json":null,"meta":{"values":["undefined"],"v":1}}}——這是 undefined 的 SuperJSON 編碼。漏掉 meta 有些 procedure 會收到 null 而不是 undefined對 Zod schema 的 optional()nullable() 有差

為什麼這值得學?

雖然有 SDK 為什麼還要 curl?實務上至少四個場景:

  1. debug 失敗的請求——SDK 抛 TRPCClientError 看不出原始 body,curl 可以印 raw HTTP
  2. e2e 測試 / 健康監控——CI 環境或 Prometheus exporter 從外部 ping 一支關鍵 procedure 比起 spawn 整個瀏覽器便宜
  3. 運維腳本——shell-only 環境(cron job、systemd unit)要觸發 mutation 時
  4. 整合測試——驗 procedure 對「繞過前端的攻擊請求」也擋得住(前端 disabled button 之類的 UX 防護要當沒有)

學到的事


參考資料