[實作筆記] Google OAuth Refresh Token(一):Testing 模式卡住,只活 7 天

前情提要

本來正常 AI 私人祕書晨報功能突然失效了,記錄一下追查的記錄與原因。

檢查過程

查詢 API 的呼叫 logs ,發現是 invalid_grant

簡單說就是 token 失效,但正常情況下不會這麼快失效才對?

直接拿 .env 裡的 GOOGLE_REFRESH_TOKEN 打 Google 的 token endpoint 測:

1
2
3
4
5
curl -s -X POST https://oauth2.googleapis.com/token \
-d "client_id=$GOOGLE_CLIENT_ID" \
-d "client_secret=$GOOGLE_CLIENT_SECRET" \
-d "refresh_token=$GOOGLE_REFRESH_TOKEN" \
-d "grant_type=refresh_token"

回應:

1
2
3
4
{
"error": "invalid_grant",
"error_description": "Token has been expired or revoked."
}

不是網路問題、不是程式碼問題——Google 自己說這把 token 已經過期或被撤銷。

雷點:過期的原因

Google Cloud Console 的 OAuth consent screen(新介面叫 Google Auth Platform)有個「Publishing status」欄位,兩種狀態:

  • Testing:refresh token 效期固定 7 天,不管有沒有呼叫過 API,時間到就是死
  • In production:refresh token 效期正常,不會這樣莫名其妙過期

console.cloud.google.com → 選對的專案 → 左側選單「Google Auth Platform」→「Audience」,

就能看到目前的 Publishing status。

檢查後,這個專案果然是 Testing——難怪 refresh token 活不過一週。

怎麼解

同一頁下面就有「Publish app」按鈕,點下去確認就會變成「In production」,refresh token 的 7 天限制就解除了。

幾個原本擔心、後來確認不成立的疑慮:

  • 會不會收費? 不會。發布狀態本身完全免費。只有用到 restricted scope、服務大量外部使用者時,才需要走 Google 的第三方資安審查(CASA),那個才要花錢,跟這裡無關。
  • 要不要走完整審核? 單一使用者、個人用途,不需要。發布後最多是每次授權畫面多一個「Google hasn’t verified this app」的警示,點「Advanced → 繼續前往」就過去了,純粹是多一次點擊,不影響功能。
  • User cap 100 人的限制會不會卡到? 那是 Testing 模式底下才有的限制,是「測試使用者清單當下能放幾人」的容量上限,不是整個專案生命週期累計加過的人數——刪一個舊的、補一個新的沒問題。單一使用者用途完全用不到。

參考

(fin)

[實作筆記] Tool Calling 是什麼:從一次介面改版學到的事

前情提要

AI 私人祕書 的 LINE 雙向對話(讓她能記事、能查資料)需要 LLM 呼叫兩個自訂動作:create_record(記一筆事)、query_records(查資料)。

一開始設計 LlmPort 介面時,假設的是「LLM 說出想做什麼,我的程式碼自己執行」這種單次來回模式。

實際上接 Claude Agent SDK 才發現:它不是這樣運作的,介面得改。

第一反應是抗拒的——介面不是應該保持抽象嗎?因為一個 SDK 的實作細節就要改介面,這不就是洩漏實作細節嗎? 這篇記錄想清楚這件事的過程。

Tool 到底是什麼

LLM 本質上只做一件事:吃文字進去,吐文字出來。它沒有手,碰不到資料庫,沒辦法自己寫 Notion。

Tool 就是告訴 LLM:「這裡有幾件事你可以『開口要求』別人幫你做,我先把它們的名字跟說明給你」。

比喻成餐廳:LLM 是客人,不會下廚。Tool 定義是菜單,客人只能「點餐」(說出要呼叫哪個 tool、附上什麼參數),真正把菜端出來的是服務生——也就是我們自己寫的程式碼。

一個 tool 定義長這樣,四個欄位各自的工作完全不同:

欄位 給誰看 做什麼
name LLM 點餐時用的名字
description LLM 白話說明「這道菜是幹嘛的」,讓 LLM 自己判斷什麼時候該點
parameters LLM 規格書,點這道菜要附哪些資訊
handler 我們的程式碼 LLM 點餐之後真正被執行的動作

前三個都只是「說明書」,LLM 讀了自己做決策,不會執行任何東西。handler 才是唯一真的會動的部分。

這四個欄位就是 ToolDefinition 這個介面本身,上面表格的四行對應這裡的四個欄位:

1
2
3
4
5
6
interface ToolDefinition {
name: string // 給 LLM:點餐用的名字
description: string // 給 LLM:白話說明這道菜是幹嘛的
parameters: unknown // 給 LLM:JSON Schema 規格書
handler: (args: unknown) => Promise<unknown> // 給我們的程式碼:真正執行的動作
}

parametershandler 的參數故意都寫成 unknown,不是懶得定型別:不同引擎要的規格格式不一樣(Agent SDK 可能要 Zod schema,原生 API 可能要 JSON Schema),介面留中立才不會綁死某一種格式;handlerunknown 也是逼實作者自己做安全轉型(像下面的 as {...}),比直接放行 any 嚴謹。

兩種模式:誰負責「執行完再繼續問」這個迴圈

原生的 Messages API(單次呼叫)長這樣:

1
2
呼叫一次 → 拿到「LLM 想呼叫 create_record,附上這些參數」
→ 我自己執行 → 把結果餵回去 → 再呼叫一次 → 拿到最終文字

這個「執行完、餵回去、再問一次」的迴圈,是呼叫端自己寫的。我原本設計 LlmPort 就是照這個模式:llm() 回傳 { text, toolCalls? },呼叫端自己檢查 toolCalls、自己執行。

Claude Agent SDK 不是這樣。你把 tool 的「說明書 + 真正會執行的程式碼」一次交給它,它自己在內部跑完整輪,只吐出最終文字。這個迴圈在 SDK 內部,外部沒有介面可以攔截「LLM 想呼叫 X」然後自己接手執行。

怎麼拿到最終答案

「執行完 tool、繼續問、直到拿到最終答案」這個迴圈邏輯,本來就該放在哪一層?

  • 用 Agent SDK:這個迴圈它自己包辦
  • 假設換成 Gemini 的原生 API(不是 agent 框架):它只會「說」要呼叫哪個 tool,不會自己執行——這時候 GeminiAdapter 就得自己把這個迴圈寫出來:呼叫、看到 function call、執行、餵回去、再呼叫,直到沒有更多呼叫為止

兩種引擎,ProcessIncomingMessageUseCase(呼叫端)看到的介面完全一樣,一行都不用改。差別永遠關在 adapter 內部。

反過來想,如果堅持照原本的設計把 toolCalls 传回呼叫端,那個「執行、餵回去、再問一次」的迴圈就會被迫寫進 Application 層——這才是真正把「這個引擎需要幾輪來回」這種 SDK 專屬細節,洩漏到不該知道的地方。改介面不是妥協,是把一個本來放錯位置的責任歸位。

具體長什麼樣

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const createRecordTool: ToolDefinition = {
name: 'create_record',
description: '把交辦的一件事記到 AI 私人祕書 的記憶裡',
parameters: {
type: 'object',
properties: {
content: { type: 'string' }, // 這兩個欄位名字是我們自己取的,
type: { type: 'string' }, // 不是 LLM 供應商規定的格式
},
required: ['content', 'type'],
},
handler: async (args) => {
const { content, type } = args as { content: string; type: RecordType }
await memory.record({ content, type, capturedAt: new Date() })
return { saved: true }
},
}

handler 收到的 args,形狀對應的是 parameters.properties(規格書列出的欄位),不是 parameters 這個物件整體——規格書跟真實資料是兩件事。(實際程式碼欄位更多,這裡只留兩個示範重點)

handler 內部做的事很薄:把 args 翻譯成 AI 私人祕書 內部的 RawRecord 格式,呼叫 memory.record()。真正「存去哪裡」的邏輯,在 MemoryPort 介面背後的那個具體實作(目前是 NotionMemoryAdapter)——handler 完全不知道底層是 Notion 還是 SQL,也不需要知道。

Application 層實際怎麼呼叫

createRecordTool 只是定義,真正用到它的地方在 ProcessIncomingMessageUseCase

1
2
3
4
5
6
7
8
9
10
11
12
// 1. 把準備好的 tool 定義收集成一份「菜單」
const tools: ToolDefinition[] = [createRecordTool]

// 2. 呼叫 LlmPort,把使用者說的話跟菜單一起丟進去
const response = await llmPort.llm(userMessage, tools)

// 3. 中間發生了什麼事——LLM 有沒有點餐、點了幾次、
// SDK 內部跑了幾輪「執行→餵回去→再問」的迴圈——
// 全部關在 llm() 這個方法的實作裡,呼叫端完全不用管

// 4. 拿到的永遠是「最終文字」,交給 ChannelPort 送出去,回給使用者
await channelPort.reply(replyToken, [response.text])

呼叫端(Application 層)從頭到尾只做兩件事:準備菜單、丟進去等文字回來。

真正麻煩的「要不要執行 tool、執行完要不要再問一次」,都被關在 LlmPort 的實作內部,

這也是前面「兩種模式」那段在講的事——不管背後是 Agent SDK 自己跑,還是原生 API adapter 自己包,呼叫端看到的永遠是這三行。

小結

一開始會抗拒改介面,是因為直覺把「介面因為某個實作而調整」當成壞味道。

但抽象該不該動,看的不是「有沒有因為某個具體東西而改」,而是改完之後,介面外部看到的形狀有沒有變窄、變得只服務單一實作

這次沒有——llm(prompt, tools) → text 還是一樣通用,只是把「誰負責跑執行迴圈」這個責任,

從「假設呼叫端會寫」改成「adapter 自己決定要不要寫」。

換一個引擎,Application 層完全不用動,這才是抽象該有的樣子。

(fin)

[AI生成] 20260706 科技周報

AI 週報配圖

本周要點

其他訊息

(fin)

[實作筆記] 設定 Resend 自訂 Tracking Domain,開啟 Open & Click Tracking

前情提要

Resend 是一個以開發者為核心設計的 Email 發送服務,API 乾淨、SDK 齊全,免費層每月 3,000 封,適合個人專案或 SaaS 的交易信(訂單、通知、驗證碼)。

Open & Click Tracking 是 email 行銷的基本指標蒐集機制:

  • Open Tracking:在 email 裡埋一張 1px 透明圖片,收件人開信時瀏覽器會載入它,Resend 藉此記錄「開信率」。
  • Click Tracking:把 email 裡的連結換成中繼 URL,點擊後先經過 Resend 記錄,再跳到原始目標,藉此追蹤「點擊率」。

這個功能預設關閉,開啟前必須先設定自訂 tracking subdomain,才能讓追蹤連結掛在你自己的 domain 下,而非 Resend 的共用 domain。

為什麼不能直接用 Resend 預設的 tracking domain

Open Tracking 的原理是在 email 裡塞一個 1px 透明圖片,收件人開信時瀏覽器載入這張圖,Resend 就知道「被打開了」。
Click Tracking 則是把 email 裡的連結換成中繼 URL,收件人點了之後先到 Resend,記錄一筆,再跳到原始連結。

預設這兩個 URL 的 domain 都是 Resend 的(例如 track.resend.dev)。

問題有兩個:

  1. deliverability:Gmail、Outlook 的垃圾信過濾會注意 email 內的 domain 跟寄件人是否一致。圖片跟連結指向一個陌生的 domain,可疑分數會上升。

  2. 共用 reputation:Resend 的 tracking domain 是所有用戶共用的。如果有人用 Resend 大量發垃圾信被封,你也可能受連帶影響。

設定自己的 subdomain 可以解決這兩個問題。

設定步驟

1. 在 Resend 找到 Domain 設定

進 Resend Dashboard → Domains,點開你的 domain(這個 domain 要已經驗證完成,也就是已經能寄信的那個)。

2. 開啟 Tracking

在 domain 設定頁面找到 Tracking 區塊,開啟 Open Tracking 和 Click Tracking。

開啟後 Resend 會給你一筆 CNAME 紀錄要加到 DNS:

1
2
3
類型:CNAME
名稱:tracking
值:links1.resend-dns.com

最終效果是你的 tracking.yourdomain.com 會指向 Resend 的 tracking endpoint。

3. 在 DNS 加 CNAME 記錄

如果 DNS 是用 Cloudflare 管的,Resend 有直接整合——在 Resend 介面授權連結 Cloudflare,它會自動幫你加好那筆 CNAME,不需要手動操作。

其他 DNS 提供商就要自己去加。Cloudflare 的話記得把 Proxy 設成 DNS only(灰色雲),CNAME 才能正常傳遞。

4. 等 DNS 生效

一般 5 分鐘到幾小時,Cloudflare 通常很快。

回到 Resend 按 Verify,綠燈就是設定完成。

開啟後的行為

設定完之後,Resend 寄出去的 email 裡:

  • 圖片 URL 會從 track.resend.dev/open/xxx 變成 track.yourdomain.com/open/xxx
  • 連結會從 track.resend.dev/click/xxx 變成 track.yourdomain.com/click/xxx

都是你自己的 domain,deliverability 更好。

程式碼端不需要改

這是 Resend 的 infrastructure 層設定,你的程式碼不需要動任何東西。只要 domain 驗證完成,之後的每封信都自動套用。

小結

整個設定大概 10 分鐘。核心就一件事:加一筆 CNAME 到 DNS,把 tracking 的 subdomain 接到 Resend。

之後 open rate 和 click rate 就能在 Resend Dashboard 看到了。

(fin)

[實作筆記] 藍新金流沙盒是獨立測試站,要另外申請

前情提要

在串接藍新金流時,以為在正式站註冊完就能直接測試,結果找了半天都找不到沙盒設定。
記錄一下這個讓人繞路的細節。

沙盒和正式站是兩個獨立網站

正式站 沙盒(測試站)
後台網址 https://www.newebpay.com https://cwww.newebpay.com
API endpoint https://core.newebpay.com/MPG/mpg_gateway https://ccore.newebpay.com/MPG/mpg_gateway

帳號、商店、金鑰完全獨立,在正式站申請的帳號無法登入沙盒,反之亦然。

申請流程

  1. https://cwww.newebpay.com 另外註冊一個帳號
  2. 審核通過後,到「商店管理」→「開立商店設定」建立測試商店
  3. 進商店後台取得 MerchantIDHashKeyHashIV

個人就可以申請,不需要公司行號或統一編號。

測試信用卡

1
2
3
卡號:4000-2211-1111-1111
有效期:任意未來日期(例如 12/30)
CVV:任意三碼(例如 123)

小結

藍新正式站和沙盒完全分開,要測試就要去 cwww.newebpay.com 另開一套帳號。
不像綠界有公開固定的沙盒憑證,藍新要自己申請才有測試用的 MerchantID / HashKey / HashIV。

(fin)

[實作筆記] ECPay 綠界沙盒串接:本機測試完整流程

前情提要

串接 ECPay MPG(多元收款)時,本機測試有幾個坑需要提前知道。
整理一下從申請沙盒帳號到 webhook 打進來的完整流程。

申請沙盒帳號

到 ECPay 開發者後台申請測試帳號:

1
https://vendor.ecpay.com.tw

登入後進入「廠商後台」→「系統開發」→「系統介接測試」,
可以拿到沙盒專用的三個憑證:

1
2
3
ECPAY_MERCHANT_ID=3002607
ECPAY_HASH_KEY=pwFHCqoQZGmho4w6
ECPAY_HASH_IV=EkRm7uxTO9nv1tog

這三組是 ECPay 官方的公開測試值,可以直接用。

兩個重要 URL 的差別

ECPay MPG 有兩個容易搞混的 URL 參數:

| 參數 | 觸發時機 | 方向 |
| —— | ——— | —— |
| ReturnURL | 付款完成後 ECPay 主動通知 | ECPay → 你的 server(POST) |
| OrderResultURL | 付款完成後瀏覽器跳回 | ECPay → 使用者瀏覽器(POST redirect) |

兩個都要設,缺一個就會有問題:

  • 沒有 ReturnURL:訂單狀態永遠不會更新,使用者付了錢系統不知道
  • 沒有 OrderResultURL:付款完成後使用者停在 ECPay 頁面,不會跳回你的網站

本機測試的問題

ECPay 的 ReturnURL 是 server-to-server 的 webhook,
ECPay 的機器要能打到你的 server,所以 localhost 根本不行。

解法是用 tunnel 工具讓本機有一個公開 URL。
推薦用 cloudflared,不要用 localtunnel(理由另篇說明)。

1
npx cloudflared tunnel --url http://localhost:3000

拿到 URL 之後更新 .env.local

1
NEXT_PUBLIC_BASE_URL=https://your-tunnel.trycloudflare.com

重啟 dev server,這個 URL 就會被用來組 ReturnURLOrderResultURL

CheckMacValue 驗證

ECPay 打來的 webhook 會帶一個 CheckMacValue
這是用 HASH_KEY 和 HASH_IV 算出來的簽章,用來確認請求是真的來自 ECPay。

驗證流程:

  1. 把收到的 POST body 解析成 key-value
  2. 移除 CheckMacValue 欄位本身
  3. 依 key 字母排序,組成 key=value&key=value 字串
  4. 前後加上 HashKey=...&&HashIV=...
  5. URL encode(小寫)後做 SHA256,轉大寫
  6. 比對結果與收到的 CheckMacValue

驗證不過要回 0|FAIL,成功要回 1|OK
不管成功失敗,HTTP status 都回 200,這是 ECPay 的協定要求。

測試信用卡

測試卡號請參考官方「測試介接資訊」頁面,卡號較多且會更新,不在這裡複製。

ECPay 沙盒付款頁網址:

1
https://payment-stage.ecpay.com.tw/Cashier/AioCheckout/index

這個頁面上的所有付款方式(包括 Apple Pay)都是沙盒,不會真實扣款。

常見錯誤

付款失敗

通常是 CheckMacValue 算錯。
確認 HASH_KEY、HASH_IV 正確,URL encode 用的是 encodeURIComponent 後轉小寫(不是 encodeURI)。

付款成功但訂單沒更新

ReturnURL 沒收到 webhook callback。

  1. 地端測試時,可以檢查tunnel 有沒有在跑
  2. ReturnURL 有沒有帶到正確的 tunnel URL
  3. dev server log 有沒有收到 POST

付款完成後使用者停在 ECPay 頁面

OrderResultURL 沒設或設錯,ECPay 不知道要跳回哪裡。

redirect 跑到 https://localhost:3000

在 API Route Handler 裡用 request.url 拿 origin 時,
cloudflared 會加 X-Forwarded-Proto: https,導致 Next.js 認為 origin 是 https://localhost:3000
解法是直接讀 NEXT_PUBLIC_BASE_URL 環境變數,不要從 request 推。

參考

小結

ECPay 沙盒串接的關鍵點:

  1. ReturnURLOrderResultURL 都要設,職責不同
  2. 本機測試一定要用 tunnel,推薦 cloudflared
  3. CheckMacValue 驗證不能省,這是確認 webhook 來源的唯一機制
  4. API Route 裡的 redirect URL 要從環境變數讀,不要從 request 推

(fin)

[工具筆記] 本地 webhook 測試:用 cloudflared,不要用 localtunnel

前情提要

在本機開發時,第三方金流(ECPay、TapPay)的 webhook 需要一個公開 URL 才能打回來。
我試了 localtunnel,然後換成 cloudflared,體驗差很多,記錄一下。

localtunnel 的問題

localtunnel 安裝很簡單:

1
npx localtunnel --port 3000

但實際用起來有幾個痛點:

不穩定。tunnel 動不動就斷,斷了 URL 就換掉,要重新設定金流後台的 webhook URL。
測到一半付款成功,webhook 送過來的時候 tunnel 已經掛了,log 什麼都沒有,很難 debug。

IP 驗證頁。localtunnel 為了防濫用,第一次打這個 URL 會先跳出一個「請輸入你的 IP」確認頁面。
金流打 webhook 的時候是自動 POST,不是人在點,所以它打到的是那個驗證頁,根本進不來。

解法是在 header 加 bypass-tunnel-reminder: true,但第三方的 webhook request 你改不了,等於這個問題無解。

cloudflared:免安裝,開箱即用

cloudflared 是 Cloudflare 官方出的 tunnel 工具。
最棒的一點:不需要帳號,不需要安裝,一行指令直接跑:

1
npx cloudflared tunnel --url http://localhost:3000

跑起來會拿到一個 trycloudflare.com 的 URL,例如:

1
https://rolled-intro-operator-meat.trycloudflare.com

沒有 IP 驗證頁,webhook 直接打進來,不會被擋。
穩定度比 localtunnel 好很多,整個測試過程沒斷過。

快速比較

localtunnel cloudflared
安裝 npm i -g localtunnel 不需要
帳號 不需要 不需要
穩定度 容易斷 穩定
IP 驗證頁 有(webhook 無法繞過)
URL 固定
免費

使用方式

1
npx cloudflared tunnel --url http://localhost:3000

拿到 URL 之後,把它設到需要公開 URL 的地方,例如 .env.local

1
NEXT_PUBLIC_BASE_URL=https://rolled-intro-operator-meat.trycloudflare.com

然後重啟 dev server,金流後台的 webhook URL 也一起更新,就可以測了。

每次重新跑 URL 會換,這點跟 localtunnel 一樣,需要重設一次。
但只要 tunnel 不斷,URL 就是固定的,這點比 localtunnel 好太多。

小結

本機測 webhook,直接用 cloudflared,不要碰 localtunnel。
免安裝、沒有驗證頁擋路、穩定,完全沒有理由繼續用 localtunnel。

(fin)

[實作筆記] Next.js 錯誤處理的三層:middleware、try-catch、instrumentation.ts

前情提要

在串接 ECPay 金流時,一個 API Route 裡用了 redirect()(from next/navigation),

結果 ECPay 的 callback 打進來之後整個 dev server 直接掛掉。

通常我會設計成主要的商業邏輯(包含錯誤的商業邏輯)走流程處理,

無法處理的由最終防線(通常會在 middleware 的後面)接住錯誤。

主要的商業邏輯,儘可能少一點的 try-catch(原則上不用)。

但 Next 還要考慮到 Edge Runtime, 借這個機會深入了解一下 Next 的錯誤處理機制

Next.js 請求的三層

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
HTTP Request


┌─────────────────────────────────────┐
│ middleware(Edge Runtime) │ ← auth guard、redirect
│ │
│ 出錯 → onRequestError 觸發 │ ← instrumentation 接得住
│ → 但 Pino logger 在這裡失效 │ ⚠️ Edge Runtime 限制
│ → Next.js 回 500 │
└──────────────┬──────────────────────┘


┌─────────────────────────────────────┐
│ API Route / Server Component │
│ (Node.js,完整環境) │
│ │
│ ┌─────────────────────────────┐ │
│ │ 業務層 try-catch │ │ ← 有業務理由才放
│ │ (ECPay→0|FAIL、OAuth→ │ │
│ │ /login?error=...) │ │
│ └──────────────┬──────────────┘ │
│ │ 未被接住 │
│ ▼ │
│ ┌─────────────────────────────┐ │
│ │ instrumentation.ts │ │ ← 最終防線,全域自動生效
│ │ onRequestError │ │
│ │ Node.js → Pino logger ✅ │ │
│ │ → Next.js 回 500 │ │
│ └─────────────────────────────┘ │
└─────────────────────────────────────┘


HTTP Response

每一層的職責不一樣,不能混用。

第一層:middleware

Next.js 的 middleware, 跑在 Edge Runtime——這是一個故意閹割過的 JS 執行環境,

只有 Web 標準 API(fetchRequestResponse),沒有 Node.js 的東西。

所以 middleware 能做的事很有限:

1
2
// ✅ 可以:讀 cookie、做 redirect、檢查 JWT
// ❌ 不行:連資料庫、用 Pino logger、用大多數 npm 套件

這不是 Vercel 的限制,是 Next.js 的設計決策——middleware 在每個請求最前面跑,

設計目標是輕量、快,所以鎖死在 Edge Runtime。

結論:middleware 只放輕量的守衛邏輯(auth check、redirect),不是錯誤處理的地方。

第二層:業務層 try-catch

API Route 跑在完整 Node.js 上,可以做任何事。

但 try-catch 的原則是不使用,除非有業務理由

什麼叫業務理由?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// ✅ ECPay webhook 協定要求:出錯必須回 0|FAIL,不能回 500
try {
// ... 處理邏輯
return new Response('1|OK', { status: 200 })
} catch (err) {
logger.error('ECPay webhook error', { error: err })
return new Response('0|FAIL', { status: 200 })
}

// ✅ OAuth 失敗:應該導到 /login?error=... 而不是白畫面
try {
// ... OAuth 流程
} catch {
return NextResponse.redirect(new URL('/login?error=server_error', request.url))
}

// ❌ 只是怕 crash,沒有業務意義
try {
// ...
} catch (err) {
return new Response('error', { status: 500 }) // 跟 Next.js 預設行為一樣,多此一舉
}

另外注意:在 API Route Handler 裡不能用 redirect() from next/navigation,那是給 Server Component / Server Action 用的。API Route 要用 Response.redirect()

1
2
3
4
5
6
// ❌ API Route 裡用這個會讓 server crash
import { redirect } from 'next/navigation'
redirect('/some-page')

// ✅ 正確做法
return Response.redirect(`${baseUrl}/some-page`, 302)

第三層:instrumentation.ts

Next.js 15 起提供 onRequestError,是真正的全域最終防線。任何沒被 try-catch 接住的 exception 都會進來。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// src/instrumentation.ts
import type { Instrumentation } from 'next'

export const onRequestError: Instrumentation.onRequestError = async (
err,
request,
context,
) => {
const { getLoggerService } = await import('@/infrastructure/di/container')
getLoggerService().error('Unhandled request error', {
error: err.message,
digest: err.digest,
path: request.path,
method: request.method,
routeType: context.routeType,
routePath: context.routePath,
})
}

這個 hook 的好處:

  • 全域自動生效,新加的 route 不需要記得處理
  • API Route、Server Component、Server Action 全部覆蓋
  • 用 dynamic import 避免初始化順序問題

它不會改變 response(user 還是收到 500),但你起碼有 log 可以查根因。

陷阱:middleware 錯誤接得住,但 logger 失效

onRequestError 在 Edge Runtime 和 Node.js 都會觸發,包含 middleware 的錯誤(context.routeType === 'proxy')。

但 Pino 是 Node.js 專用的,在 Edge Runtime 下會直接失敗。

要完整處理,需要根據 runtime 分開:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
export const onRequestError: Instrumentation.onRequestError = async (
err,
request,
context,
) => {
if (process.env.NEXT_RUNTIME === 'nodejs') {
// Node.js:用 Pino 正常記 log
const { getLoggerService } = await import('@/infrastructure/di/container')
getLoggerService().error('Unhandled request error', {
error: err.message,
path: request.path,
routeType: context.routeType,
})
} else {
// Edge Runtime(middleware 錯誤):只能用 console 或送外部服務
console.error('[edge] Unhandled middleware error', err.message, request.path)
}
}

這個專案的 middleware 只做 auth guard,邏輯薄、出錯機率低,目前先用 console.error 兜底即可。

三層的職責總結

層次 位置 做什麼 限制
middleware 請求最前面 auth guard、redirect Edge Runtime,無 DB/Logger
try-catch 各 route 內 業務錯誤對應 只放有業務理由的
instrumentation.ts 全域最後 接住所有漏網錯誤、記 log 無法改 response;Edge 下需另處理

參考

小結

不要為了「怕 crash」而到處加 try-catch,那只是把問題藏起來。

正確的順序是:先把 instrumentation.ts 設起來,確保所有未處理的錯誤都有 log,然後只在真的有業務需求的地方加 try-catch。

這樣新加的 route 自動有防護,不需要靠人記得。

(fin)

[實作筆記] 為什麼 InversifyJS 在 Next.js 不能用?SWC 相容性問題與 awilix 解法

前情提要

在做電商平台時,遇到一個問題:要同時支援多家金流(TapPay、ECPay、藍新),

所以我設計了多個不同的 PaymentService 來實作同一個 interface。

這算是 DI 的經典場景,自然就想到引入 DI container 來管理。

調查一輪之後,才發現 Next.js 的編譯器對這件事有一個很重要的限制。

Next.js 預設用 SWC 編譯

SWC 是用 Rust 寫的 JavaScript/TypeScript 編譯器,Next.js 從 v12 開始改用它作為預設編譯器。

快很多,這是事實。但問題來了。

InversifyJS 和 tsyringe 為什麼不能用

InversifyJS 和 tsyringe 是目前最主流的兩個 TypeScript DI framework,

兩個都靠裝飾器(decorator)做依賴注入:

1
2
3
4
@injectable()
class TapPayPaymentAdapter implements PaymentService {
constructor(@inject('Logger') private logger: LoggerService) {}
}

這個模式需要兩個東西:

  1. emitDecoratorMetadata:TypeScript 編譯時把型別資訊保留下來
  2. reflect-metadata:在執行期讀取那些型別資訊

問題在於:SWC 對 emitDecoratorMetadata 的支援不完整

SWC 的目標是快,不是完整複製 tsc 的行為。

reflect-metadata 依賴的 metadata 在 SWC 編譯後不保證存在,跑起來會出錯或行為異常。

要硬用 InversifyJS,得把 Next.js 的編譯器換回 tsc,放棄 SWC 的效能優勢。代價太高。

awilix 為什麼可以

awilix 完全不用裝飾器,也不依賴 reflect-metadata

它靠的是命名慣例:建構子參數的名稱對應 container 裡的 key。

1
2
3
4
5
6
7
8
9
10
11
// container 裡這樣註冊
container.register({
loggerService: asClass(PinoLoggerService),
tapPayService: asClass(TapPayPaymentAdapter),
ecpayService: asClass(EcpayPaymentAdapter),
})

// adapter 建構子這樣寫
class TapPayPaymentAdapter {
constructor({ loggerService }: { loggerService: LoggerService }) {}
}

名稱對上,awilix 自動注入。不需要編譯器幫你保留任何型別資訊,SWC 完全相容。

三家比較

InversifyJS tsyringe awilix
週下載量 1.5M 600K 400K
GitHub Stars 12K 6K 4.2K
裝飾器 需要 需要 不需要
reflect-metadata 需要 需要 不需要
Next.js / SWC
學習曲線
Token/Symbol 保護 ❌ 參數名即 key
Minification 安全 ⚠️ 需額外設定
型別保護時機 編譯期 編譯期 執行期

下載量 InversifyJS 最高,但 Next.js 專案用不了。

awilix 能跑,代價是命名耦合與 minification 的隱患,小專案先忽略。

業界趨勢

2026 的調查顯示,小型 Node.js 專案的趨勢反而是遠離 DI container

回到手寫的 module-level singleton 或手動 dependency passing。

這不是說 DI container 不好,而是:

  • 依賴圖簡單時,手寫 container 清楚又好讀
  • 依賴圖複雜到手寫開始讓人痛了,再引入才是真正有感的投資

我的考量是早期建立團隊開發共識與原則

在 AI 時代,這樣技術學習門檻不高,早期引入就變成慣例,讓 AI 用乾淨架構去開發,才不會一沱。

小結

Next.js 用 SWC,SWC 不完整支援 reflect-metadata,所以 InversifyJS 和 tsyringe 不能用。

Next.js 專案要引入 DI container,awilix 是目前唯一合理的選擇。

(fin)

[實作筆記] Next.js 16 — middleware 改名為 proxy 了

前情提要

升到 Next.js 16 之後,dev server 一直噴這個警告:

1
2
⚠ The "middleware" file convention is deprecated.
Please use "proxy" instead.

查了一下,原來 Next.js 16 把 middleware 整個改名了,記錄一下到底換了什麼。

為什麼改

Next.js 官方說,middleware 這個名字太模糊,
實際上它做的事情是網路邊界的路由代理
改叫 proxy 更能表達它的職責。

改了哪些東西

項目 舊(deprecated)
檔名 middleware.ts proxy.ts
export 函式名 export function middleware export function proxy
config export 不變 不變(matcher 一樣)
runtime edge / nodejs 只支援 nodejs

configmatcher 不用動,只有檔名和 function 名稱要換。

最重要的限制

新的 proxy 只跑 Node.js runtime,不支援 edge官方文件說得很清楚(截至 Next.js 16 / 2026-06):

The runtime config option is not available in Proxy files. Setting the runtime config option in Proxy will throw an error.

不是暫時限制,是設計決策。官方的方向是讓開發者不依賴 middleware/proxy:

Next.js is moving forward to provide better APIs with better ergonomics so that developers can achieve their goals without Middleware.

所以如果你目前的 middleware.ts 有設 export const runtime = 'edge',或用了依賴 edge runtime 的套件,先不要遷移——繼續用 middleware.ts 也能跑,只是會有 deprecation 警告,等官方提供替代方案再說。

怎麼遷移

方式一:官方 codemod(推薦)

1
npx @next/codemod@latest middleware-to-proxy .

自動幫你改檔名和 function 名稱。

方式二:手動

1
mv src/middleware.ts src/proxy.ts

然後把 function 名改掉:

1
2
3
4
5
// 原本
export async function middleware(request: NextRequest) { ... }

// 改成
export async function proxy(request: NextRequest) { ... }

NextRequestNextResponseconfigmatcher 全部不用動。

小結

Next.js 16 把 middleware 改名叫 proxy,概念一樣,名字更準確。
遷移很簡單,用 codemod 一行搞定;
唯一要注意的是 edge runtime 目前(2026-06)不支援,有用到的先等等。

(fin)

Please enable JavaScript to view the LikeCoin. :P