最近在幫部落格的留言功能補上「IP 歸屬地」顯示——就是 B 站、微博那種在留言旁邊標一個「來自 廣東」的小標籤。資料表欄位、ip2region 套件都接好了,結果在本機測試時,ip 欄位一直是 null。
這篇記錄整個追查過程,以及最後挖到的、有點反直覺的根因:Elysia 的 sucrose 靜態分析,而且它在 bun run 跟 bun build --compile 下的行為完全相反。
TL;DR
Elysia 為了效能,會用靜態分析只組裝你「看起來有用到」的 context 欄位。server 是其中之一。如果你對 context.server 的存取藏在外部函式裡,dev 模式下它會被判定為「沒用到」而設成 undefined,導致 server.requestIP() 拿不到值。但編譯成單一執行檔後,這個分析會失效並改為「全部都給」,反而正常。
一、症狀
留言建立時,我在 context 裡解析來源資訊:
const ip = context.ip ?? null;
const agent = context.userAgent?.slice(0, 512) ?? null;
const location = await resolveIpLocation(ip);
console.log("留言來源資訊:", { ip, agent, location });本機送出一則留言,log 印出:
留言來源資訊: {
ip: null,
agent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ... Edg/149.0.0.0",
location: null,
}agent(來自 header)正常,但 ip 是 null。location 因為沒有 IP 自然也是 null。
二、IP 是怎麼取的
取 IP 的邏輯很標準——反向代理優先,直連時退回 socket 位址:
function getClientIp(context: ElysiaContext): string | null {
const headers = context.request.headers;
// 反向代理 / CDN 帶來的真實 IP
const xff = headers.get("x-forwarded-for");
if (xff) return xff.split(",")[0].trim();
const xReal = headers.get("x-real-ip");
if (xReal) return xReal.trim();
// 直連時:Elysia / Bun 的 socket 位址
const socketIp = context.server?.requestIP?.(context.request)?.address;
return socketIp ?? null;
}本機直連 localhost,瀏覽器不會帶 x-forwarded-for / x-real-ip,所以一定走到最後一行的 context.server.requestIP()。問題就在這裡——它沒回傳值。
三、一連串「重現不出來」
我先假設是 requestIP() 機制本身的問題,寫了一個最小 Elysia server 測:
new Elysia()
.get("/test", (context) => {
const ip = context.server?.requestIP?.(context.request);
console.log("requestIP:", ip); // { address: "::1", family: "IPv6", ... }
return { ip };
})
.listen(3999);結果正常,::1 抓得到。接著我一個一個把變因加回去重現:
全部都正常。我的測試怎麼測都對,但正式程式碼就是 null。差別到底在哪?
四、關鍵那一行 log
加了一行 debug,直接印出 context.server 到底存不存在:
console.log("[ip-debug] server exists:", !!context.server);
console.log("[ip-debug] resolved ip:", ip);實際請求印出來:
[ip-debug] server exists: false
[ip-debug] resolved ip: nullserver exists: false。 context.server 本身就是 undefined,根本不是 requestIP() 的問題。
但我的測試裡 context.server 明明都在。兩邊唯一的差別是——
差別在這裡
我的測試是在 handler 裡直接寫 context.server.requestIP(...);
正式程式碼是把整包 context 丟給外部函式:createContext({ context }),真正的 .server 存取藏在另一個檔案的 getClientIp() 裡。
五、真兇:sucrose 靜態分析
Elysia 不會每個 request 都建構一個「完整」的 context 物件,那樣太慢。它在啟動時會用一個叫 sucrose 的機制,用正規表達式掃描你 handler 函式的原始碼文字,判斷你到底碰了哪些 context 欄位,然後只組裝你用到的那幾個。
被它管控的欄位包括:query、headers、body、cookie、set、server、route、url、path。
在 dist/sucrose.js 裡可以看到判斷邏輯(節錄):
!inference.server && access("server", alias) && (inference.server = !0)只有當它在 handler 文字裡掃到對 server 的存取,才會把 inference.server 設成 true。
回頭看原本的寫法:
async (context) => {
const { response } = await rpcHandler.handle(context.request, {
prefix: "/rpc",
context: await createContext({ context }), // 文字裡沒有 .server
});
...
}.server 的存取被藏在 getClientIp() 裡(另一個檔案)。sucrose 掃這個 handler 的文字,看不到任何 .server → 判定「沒用到 server」→ 把 context.server 設成 undefined。
這就解釋了一切:
為什麼 agent / headers 沒事?
因為 request 不在 sucrose 的管控清單裡——它是 context 的核心欄位,永遠都在。所以 context.request.headers 永遠可用,這也是為什麼 agent(來自 header)正常、只有走 server 的 ip 壞掉。
六、修法
讓 handler 的原始碼文字裡出現字面的 context.server,sucrose 才掃得到。也就是把整包 context 拆開、明確傳 request 與 server:
// index.ts
context: await createContext({ context }),
// context.ts
export async function createContext({ context }: { context: ElysiaContext }) {
const ip = getClientIp(context); // .server 藏在這裡,sucrose 看不到
}七、最反直覺的部分:dev 與 compiled 行為相反
修完之後我多問了一句:這個 sucrose 只有 dev 有嗎?編譯成 binary 後還會這樣嗎? 用 production 完全相同的 flags(--minify --bytecode)編了一個同時含「壞寫法」與「好寫法」的測試 binary,實測結果:
| 執行方式 | 壞寫法(藏在外部函式) | 好寫法(字面 .server) |
|---|---|---|
bun run / bun --watch(dev) | ❌ server undefined | ✅ 正常 |
bun build --compile(含/不含 minify) | ✅ 正常 | ✅ 正常 |
編譯後,連壞寫法都正常。
原因:sucrose 靠 Function.prototype.toString() 拿 handler 原始碼來分析。
- dev (
bun run):直接跑,.toString()拿得到原始的箭頭函式 → 精準分析 → 砍掉沒用到的server。 - compiled binary:函式經過 bundle / 編譯,
.toString()已不是原貌 → sucrose 無法可靠解析 → 觸發「分析不出來就全部給」的保底機制 → 所有欄位(含server)都在。
所以該不該修?
該修,但理由不是「production 會壞」。
你的 compiled 部署其實不改也會動(保底機制讓 server 一定在)。真正需要修的是:
- 本機 dev 不改就是壞的——開發時永遠測不出 IP,體驗很差。
- 不該把「保底機制」當保證——那是 sucrose 解析失敗的副作用,是內部實作細節,Elysia / Bun 一升級就可能變。寫成字面
context.server才是明確、不靠運氣的做法。 - 對有反向代理的正式環境而言,真實 IP 走的是
cf-connecting-ip/x-forwarded-forheader,那條路徑根本不碰server。requestIP()只是「直連、沒反代」時的 fallback。
八、教訓
幾個帶得走的重點
- Elysia 的 context 是惰性組裝的,欄位有沒有取決於 handler 原始碼文字長什麼樣,不是只看型別。
- 需要
server、cookie、set這類被管控的欄位時,在 handler 裡用字面寫法直接碰它,別把整包 context 傳進外部函式再間接存取。 - 靜態分析類的優化(sucrose、Terser 的 property mangling…)在 dev 與 build 後行為可能不同,遇到「本機正常 / 線上不正常」或反過來的詭異現象時,記得把這層也納入懷疑範圍。
- 除錯時,先確認「東西在不在」,再去懷疑「東西好不好用」。我一開始假設
requestIP()壞了,繞了一大圈;一行!!context.server才是真正的轉捩點。