Vue.js BroadcastChannel 跨分頁通訊:從踩坑到 Singleton Event Bus 架構設計

前言

在多分頁的 Web 應用中,跨 Tab 同步狀態是常見需求——例如閒置登出同步、Session 過期通知、T&C 接受後其他 Tab 強制刷新等。瀏覽器原生的 BroadcastChannel API 是最直覺的方案,搭配 VueUse 的 useBroadcastChannel 封裝更是方便。

但在實際開發中,我們踩了一個非常隱晦的坑——同一個 Tab 內建立多個 BroadcastChannel 實例,會互相收到訊息。本文記錄我們從「能用」到「好用」的架構演進過程。

Read More

跨框架 Partial View 架構設計 — 用 Vue 3 打造可嵌入任何頁面的 Header/Footer

前言

在多產品平台中,每個產品(體育、娛樂城、棋牌等)可能由不同的合作夥伴(Provider)開發,使用的技術棧也各不相同——React、Angular、甚至純 JS。但平台需要在所有產品頁面上維持一致的品牌形象、導航列、會員狀態和法律聲明。

要求每個 Provider 自行實作這些 UI 元件既不現實也難以維護。因此我們設計了 star4partialview — 一個獨立打包的 Vue 3 應用,輸出單一 partialView.js,Provider 只需引入一個 <script> 標籤就能獲得完整的 Header + Footer。

兩個獨立的 JS Runtime 透過 window.postMessage 通訊,實現了一種輕量級的 Micro Frontend 模式。

Read More

用 Elasticsearch Watcher 打造生產環境即時告警 — Game Launch 失敗率與 Error Log 成長監控

前言

在生產環境中,很多問題不會立即被人發現——部署後新版本的 Error Log 悄悄飆升、第三方 Provider 突然故障導致遊戲啟動連續失敗、甚至遭受攻擊產生大量異常請求。如果只靠人工盯 Kibana Dashboard,往往等到使用者回報時已經影響了大量用戶。

本文分享我們實際使用的兩個 Elasticsearch Watcher 告警規則,透過定時查詢 + 條件判斷 + 自動寄信,讓團隊在問題擴大前就能收到通知並介入處理。

Read More

Git Revert 後再次 Merge 失效?正式環境退版後的正確處理流程

前言

在團隊協作中,經常會遇到這種情境:你的功能分支已經 merge 到正式環境的 release branch,但上線前一天被通知要抽單退版。這時候如果直接在 release branch 上 git revert 那筆 merge commit,後續想把同一個功能分支重新 merge 回去時,Git 會認為「這些 commit 已經被 merge 過了」而跳過所有變更——導致程式碼根本沒有被帶回來。

本文記錄這個問題的成因,以及在不遺漏檔案的前提下,如何正確退版並繼續推進功能。

Read More

ERR_CONTENT_DECODING_FAILED — 當 Status 200 卻解碼失敗的踩坑紀錄

前言

之前在正式環境遇到一個棘手的問題:使用者回報某支 API 回傳 HTTP 200,瀏覽器卻直接噴出 ERR_CONTENT_DECODING_FAILED。當下排查了很久才發現問題出在 Response Header 中多了一個不該出現的 gzip,特別記錄一下整個排查過程。

問題現象

打開 DevTools 檢查異常 API 的 Response Headers,可以看到幾個關鍵資訊:

  • Content-Encoding: gzip — 伺服器宣稱回傳內容經過 gzip 壓縮
  • Transfer-Encoding: chunked — 資料以分段方式傳輸
  • X-Powered-By: ASP.NET — 請求可能直接打到後端 .NET 伺服器,或經過了某層 Proxy 轉發

問題核心:空 Body + gzip 標頭

瀏覽器收到 Content-Encoding: gzip 後,會自動嘗試用 gzip 解碼器解壓內容。但實際上這支 API 的 Response Body 是空的(長度為 0),解碼器無法處理一個空的壓縮流,因此直接報出 ERR_CONTENT_DECODING_FAILED

為什麼 Body 會是空的?根據 Header 中的 X-Powered-By: ASP.NET,可能有兩種原因:

  1. 後端程式異常中斷 — ASP.NET 拋出 Exception 後沒有回傳 500,而是直接結束了 Response,導致 Body 為空。
  2. 查詢超時 — 後端資料庫查詢耗時過久,連線中斷,最終只傳回了 Headers 就結束了。

關鍵線索:對比正常與異常的 API

進一步對比同專案中兩支 API 的 Response Headers,差異一目了然:

欄位 異常 API 正常 API
Content-Encoding gzip (無)
X-Powered-By ASP.NET (無)
X-Aspnet-Version 4.0.30319 (無)

正常的 API 回傳的 Headers 很乾淨,是經過 Nuxt BFF(Node.js)處理後回傳的;而異常的 API 卻直接暴露了 ASP.NET 的資訊。這代表:

  • 情況 A:這支 API 繞過了 Nuxt BFF,直接打到了後端 .NET 伺服器。
  • 情況 B:後端 .NET 伺服器強制啟動了 gzip 壓縮,但因資料量過大或超時導致傳輸中斷,最終變成「空 Body + gzip 標頭」的組合。

真相:Nitro 透傳 Response 物件導致 gzip 標頭外洩

後端伺服器其實沒有噴錯,它正常回傳了資料(只是內容為空陣列或空物件)。真正的問題出在 Nuxt Server(Nitro)端的 API Handler 寫法。

檢查 Server 端程式碼後,發現異常的 API Handler 是這樣寫的:

1
2
const response = await api.memberGetMemberPriorityHistories(query);
return response; // 直接回傳整個 Response 物件

這裡 api.memberGetMemberPriorityHistories() 回傳的是一個繼承自原生 Response 的物件。當直接 return response 時,Nitro 引擎會將其視為透傳(Proxy),把後端的所有原始標頭原封不動地轉發給瀏覽器。

整個流程是這樣的:

  1. 後端 ASP.NET 回傳帶有 Content-Encoding: gzip 的壓縮內容
  2. Nuxt Server(Node.js)在接收時已經自動解壓了 gzip 內容
  3. 但因為直接 return response,Nitro 把後端的原始 Headers(包含 Content-Encoding: gzip)照搬給瀏覽器
  4. 瀏覽器收到 Content-Encoding: gzip 標頭,嘗試解壓 Body —— 但 Body 早就被 Node.js 解壓過了,已經不是 gzip 格式
  5. 解碼失敗,噴出 ERR_CONTENT_DECODING_FAILED

簡單來說:gzip 被 Nuxt Server Side 解開了一次,但 gzip 標頭卻還在,瀏覽器又試圖解第二次,自然就失敗了。

這也解釋了為什麼對比表中異常 API 會帶有 X-Powered-By: ASP.NETX-Aspnet-Version — 這些都是後端的原始標頭被透傳出來的結果。

修正方式

根因找到後,修正方式很明確:不要直接回傳整個 Response 物件,改為只回傳 response.data 這樣 Nitro 會重新封裝成乾淨的 JSON Response,由 Nuxt 統一處理壓縮,後端的 ASP.NET 標頭就不會外洩。

修改前:

1
2
3
4
5
6
export default defineLogAndAuthHandler(async (event) => {
const query = getQuery(event);
const api = new GetMemberPriorityHistories();
const response = await api.memberGetMemberPriorityHistories(query);
return response; // 透傳整個物件,標頭外洩
});

修改後:

1
2
3
4
5
6
export default defineLogAndAuthHandler(async (event) => {
const query = getQuery(event);
const api = new GetMemberPriorityHistories();
const response = await api.memberGetMemberPriorityHistories(query);
return response.data; // 只回傳資料,Nitro 重新封裝 JSON
});

這個修正需要系統性地檢查所有 Server API Handler,只要有直接 return response 的地方都應該改為 return response.data,避免同樣的問題在其他 API 上重演。

結論

這個問題的根因不在後端、也不在前端元件,而是 Nuxt Server(Nitro)的 API Handler 直接透傳了後端 Response 物件。Node.js 在接收後端回應時已經解開了 gzip,但透傳卻把原始的 Content-Encoding: gzip 標頭一併帶給瀏覽器,導致瀏覽器嘗試二次解壓而失敗。

排查這類問題的關鍵:對比正常與異常 API 的 Response Headers。當你發現某支 API 突然多了 X-Powered-By: ASP.NETContent-Encoding: gzip,而其他 API 都沒有,很可能就是 Nitro 把後端的原始標頭透傳出來了。回去檢查 Server API Handler 的回傳值,把 return response 改成 return response.data 就能解決。

在 Nuxt 3 中打造統一的 API Handler:認證、日誌與錯誤處理的優雅封裝

前言

在開發後台管理系統時,我們經常會遇到一個問題:每個 API Handler 都需要處理認證驗證日誌記錄錯誤捕獲等重複性邏輯。如果在每個檔案中都寫一遍,不僅冗長,還容易遺漏。
本文將介紹如何在 Nuxt 3 的 Server API 中,利用 Higher-Order Function (高階函式) 的概念,打造一個統一的 defineLogAndAuthHandler,讓所有 API 自動擁有以下功能:

  • ✅ Session 認證與過期檢查
  • ✅ 請求/回應日誌記錄
  • ✅ 敏感資料自動遮蔽
  • ✅ 效能監控 (API 執行時間)
  • ✅ 統一錯誤處理與格式化

核心概念:Higher-Order Function

在 JavaScript/TypeScript 中,高階函式 (HOF) 是指「接收函式作為參數」或「回傳函式」的函式。我們的設計正是利用這個概念:

export const defineLogAndAuthHandler = <T extends EventHandlerRequest>(
  handler: EventHandler<T>
): EventHandler<T> => defineEventHandler<T>(async (event) => {
  // 在這裡加入認證、日誌等邏輯
  const response = await handler(event); // 執行原本的 Handler
  // 在這裡加入回應處理邏輯
  return response;
});
這就像是在原本的 API Handler 外面「包一層糖衣」,讓它自動具備額外的能力。

功能拆解
1. Session 認證與過期檢查
// 檢查是否已認證
if (!event.context?.session?.isAuthenticated && !isPublicRoute) {
  return createError({
    statusCode: 401,
    statusText: 'Session Expired',
  });
}
// 檢查 Session 是否已過期
const currentTime = new Date().getTime();
const lastLoginTimestamp = event.context?.session?.lastLoginTime;
const maxTimeDiff = (currentTime - lastLoginTimestamp) / 1000;
if (maxTimeDiff >= parseInt(maxExpiryInSeconds, 10)) {
  // 清除 Session 並回傳 401
  event.context.session.isAuthenticated = false;
  return createError({ statusCode: 401, statusText: 'Session Expired' });
}
設計重點:

白名單機制:登入、登出等 API 不需要認證
雙重檢查:不只檢查是否登入,還檢查 Session 是否超時
2. 請求日誌記錄與敏感資料遮蔽
const ignoreLogParams = ['password', 'newPassword', 'oldPassword'];
// 遮蔽敏感欄位
if (typeof body === 'object') {
  const filterBody = Object.assign({}, body);
  for (const key in filterBody) {
    if (ignoreLogParams.includes(key)) {
      filterBody[key] = '***mask***';
    }
  }
  logger.log('info', `[POST] ${JSON.stringify(filterBody)}`);
}
設計重點:

自動遮蔽密碼等敏感欄位,避免日誌外洩
支援 GET/POST/PUT/DELETE 不同方法的日誌格式
3. 效能監控
const startTime = Date.now();
const response = await handler(event);
const endTime = Date.now();
const duration = endTime - startTime;
logger.performance.log('info', `[${pathWithoutQueryString}] [${duration}ms]`);
設計重點:

記錄每個 API 的執行時間
方便後續分析效能瓶頸
4. 回應日誌與大型回應處理
const byPassLogUrls = ['/api/general/settings', '/api/promotion/promotions'];
function truncateString(body: string) {
  const resultSizeLimit = 1024;
  const stringSize = Buffer.byteLength(body);
  
  if (stringSize > resultSizeLimit) {
    return `Result size - ${stringSize} bytes`;
  }
  return body;
}
設計重點:

大型回應只記錄大小,避免日誌檔案爆炸
特定 API 可跳過輸出日誌(如設定檔、大量資料查詢)
5. 統一錯誤處理
catch (error: any) {
  console.error('API Error:', error);
  
  logger.error.log('error', `URL: ${event.path}, Message: ${error.message}`);
  
  return createError({
    statusCode: error.statusCode || 500,
    statusMessage: error.statusMessage || 'Internal Server Error',
    message: error.message || 'An unexpected error occurred',
  });
}
設計重點:

所有未捕獲的異常都會被統一處理
錯誤資訊會被記錄到日誌中,方便追蹤
使用方式
使用這個封裝後,原本的 API Handler 變得非常簡潔:

// server/api/member/summary.get.ts
import { GetMemberSummary } from '@/bospi/GetMemberSummary';
export default defineLogAndAuthHandler(async (event) => {
  const query = getQuery(event);
  const api = new GetMemberSummary();
  const response = await api.getMemberSummary(query);
  return response.data;
});
只需要專注在業務邏輯,認證、日誌、錯誤處理全部自動搞定!

架構圖
┌─────────────────────────────────────────────────────────┐
│                    Frontend (Browser)                    │
└─────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│              defineLogAndAuthHandler (Wrapper)           │
│  ┌─────────────────────────────────────────────────────┐│
│  │ 1. Session 認證檢查                                  ││
│  │ 2. 請求日誌記錄 (含敏感資料遮蔽)                      ││
│  │ 3. 效能計時開始                                      ││
│  └─────────────────────────────────────────────────────┘│
│                          │                               │
│                          ▼                               │
│  ┌─────────────────────────────────────────────────────┐│
│  │           實際的 API Handler (業務邏輯)              ││
│  └─────────────────────────────────────────────────────┘│
│                          │                               │
│                          ▼                               │
│  ┌─────────────────────────────────────────────────────┐│
│  │ 4. 效能計時結束                                      ││
│  │ 5. 回應日誌記錄                                      ││
│  │ 6. 錯誤捕獲與格式化                                  ││
│  └─────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘
                            │
                            ▼
┌─────────────────────────────────────────────────────────┐
│                    Backend SPI (ASP.NET)                 │
└─────────────────────────────────────────────────────────┘


這個設計模式其實就是 AOP (Aspect-Oriented Programming) 的體現——將「橫切關注點」(如日誌、認證、錯誤處理)從業務邏輯中抽離出來。

使用 Redis Sorted Set 實作 API Rate Limiting — 滑動視窗 + 封鎖機制

前言

在面向全球用戶的平台系統中,API 被濫用幾乎是必然會遇到的問題——無論是攻擊者暴力嘗試登入、
爬蟲高頻抓取資料,還是用戶無意間重複提交表單。

本文分享一個我們實際在生產環境中使用的 Redis-based API Rate Limiting 架構
涵蓋兩種策略:滑動視窗(Sliding Window)滑動視窗 + 封鎖鎖定(Sliding Window + Block Lock)
並支援以 Session ID、IP、Member Code 等多種身份維度進行限流。

Read More

LazyCache 踩坑紀錄:直接傳入 TimeSpan 預設是 SlidingExpiration,高流量下永遠不會過期

前言

這是一個只在正式環境才暴露的問題。
我們使用 LazyCache + Redis 搭建了一個兩層快取架構:
MemoryCache 作為 L1 熱快取,Redis 作為 L2 資料來源。
在 Dev、QAT、UAT 環境一切正常,
但上了 Production 後發現 Redis 資料更新了,應用卻永遠拿到舊值

原因?LazyCache 的 GetOrAdd 直接傳入 TimeSpan,預設行為是 SlidingExpiration

Read More

Feature Version Control — APP 版本功能控管架構

前言

Mobile APP 與 Web 不同,使用者不一定會即時更新到最新版本。當後端上線新功能時,舊版 APP 可能尚未支援該功能的 UI 或邏輯。若後端直接回傳新功能相關的資料,可能導致:

  • 舊版 APP 出現不預期的 UI 或行為
  • 新功能的回應格式在舊版 APP 上解析失敗
  • 部分功能僅限特定版本以上使用(例如安全性相關的新裝置驗證)

因此,我們設計了 Feature Version Control 機制,讓後端根據 APP 傳入的版本號,動態決定哪些功能要啟用、哪些要關閉。

Read More

JamWebDebugger

前幾天同事介紹一個好用的工具,最主要是它可以錄製網頁的 Network ,Console 資訊

因為大家也知道 QA 很常反應問題 一般 IT 還需要重現這個問題. 如果 QA 能夠提供更多的資訊

我們就可以減少 Debugger 的時候.而且這個軟體還是免費而且也整合了 jira 等等的套件

JAM

我們以 myfunnow 這個旅遊網站當例子

Demo

可以看到 他在幾秒的時候 Console 有出現 401 的錯誤 並且在 Network 的 tab 當下有記錄每一個 request 的相關資訊

這些可以幫助 IT 跟 QA 更快反應問題