検索

記事と日記を検索…

Elysia 的 context.server 為什麼是 undefined?一次 requestIP 回傳 null 的除錯紀錄

2026-08-03 に記す・大暑

この記事はまだ日本語に翻訳されていません。原文を表示しています。

他の言語: 繁體中文

最近在幫部落格的留言功能補上「IP 歸屬地」顯示——就是 B 站、微博那種在留言旁邊標一個「來自 廣東」的小標籤。資料表欄位、ip2region 套件都接好了,結果在本機測試時,ip 欄位一直是 null。

這篇記錄整個追查過程,以及最後挖到的、有點反直覺的根因:Elysia 的 sucrose 靜態分析,而且它在 bun run 跟 bun build --compile 下的行為完全相反。

TL;DR

Elysia 為了效能,會用靜態分析只組裝你「看起來有用到」的 context 欄位。server 是其中之一。如果你對 context.server 的存取藏在外部函式裡,dev 模式下它會被判定為「沒用到」而設成 undefined,導致 server.requestIP() 拿不到值。但編譯成單一執行檔後,這個分析會失效並改為「全部都給」,反而正常。

一、症狀

留言建立時,我在 context 裡解析來源資訊:

TypeScript
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 位址:

TypeScript
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 測:

TypeScript
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 抓得到。接著我一個一個把變因加回去重現:

全 4 項目

比照正式程式碼的路由設定。結果:requestIP 照樣正常回 ::1。

全部都正常。我的測試怎麼測都對,但正式程式碼就是 null。差別到底在哪?

四、關鍵那一行 log

加了一行 debug,直接印出 context.server 到底存不存在:

TypeScript
console.log("[ip-debug] server exists:", !!context.server);
console.log("[ip-debug] resolved ip:", ip);

實際請求印出來:

テキスト
[ip-debug] server exists: false
[ip-debug] resolved ip: null

server 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 裡可以看到判斷邏輯(節錄):

JavaScript
!inference.server && access("server", alias) && (inference.server = !0)

只有當它在 handler 文字裡掃到對 server 的存取,才會把 inference.server 設成 true。

回頭看原本的寫法:

TypeScript
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。

這就解釋了一切:

有(測試:直接寫) 沒有(正式:藏在外部函式) handler 原始碼文字 sucrose 掃到 .server? 組裝 server 欄位requestIP 正常 server = undefinedrequestIP 拿不到 → ip: null

為什麼 agent / headers 沒事?

因為 request 不在 sucrose 的管控清單裡——它是 context 的核心欄位,永遠都在。所以 context.request.headers 永遠可用,這也是為什麼 agent(來自 header)正常、只有走 server 的 ip 壞掉。

六、修法

讓 handler 的原始碼文字裡出現字面的 context.server,sucrose 才掃得到。也就是把整包 context 拆開、明確傳 request 與 server:

TypeScript
// 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 一定在)。真正需要修的是:

  1. 本機 dev 不改就是壞的——開發時永遠測不出 IP,體驗很差。
  2. 不該把「保底機制」當保證——那是 sucrose 解析失敗的副作用,是內部實作細節,Elysia / Bun 一升級就可能變。寫成字面 context.server 才是明確、不靠運氣的做法。
  3. 對有反向代理的正式環境而言,真實 IP 走的是 cf-connecting-ip / x-forwarded-for header,那條路徑根本不碰 server。requestIP() 只是「直連、沒反代」時的 fallback。

八、教訓

幾個帶得走的重點

  • Elysia 的 context 是惰性組裝的,欄位有沒有取決於 handler 原始碼文字長什麼樣,不是只看型別。
  • 需要 server、cookie、set 這類被管控的欄位時,在 handler 裡用字面寫法直接碰它,別把整包 context 傳進外部函式再間接存取。
  • 靜態分析類的優化(sucrose、Terser 的 property mangling…)在 dev 與 build 後行為可能不同,遇到「本機正常 / 線上不正常」或反過來的詭異現象時,記得把這層也納入懷疑範圍。
  • 除錯時,先確認「東西在不在」,再去懷疑「東西好不好用」。我一開始假設 requestIP() 壞了,繞了一大圈;一行 !!context.server 才是真正的轉捩點。

丙午年 大暑

唯一 書

コメント欄を読み込み中...