[實作筆記] 用 cloudflared tunnel 讓 n8n 安全對外,不開防火牆

前情提要

上一篇在 GCP 免費 VM 上把 n8n 跑起來了,但還沒辦法從外部存取。

這篇用 cloudflared tunnel 解決這個問題。

好處是:

  • 不用改 GCP 防火牆,不暴露任何 port
  • 自動 HTTPS
  • 可以加 Cloudflare Access(Google 帳號驗證)擋在前面

架構概觀

這篇要做的事情分兩層:

第一層:cloudflared tunnel

在 VM 裡跑一個 cloudflared process,它會主動連到 Cloudflare 的網路,建立一條加密的隧道。
外部流量進來的路徑是:

1
使用者瀏覽器 → Cloudflare → cloudflared tunnel(VM 內)→ n8n(127.0.0.1:5678)

VM 不需要開任何防火牆 port,因為連線是由 VM 內部主動發起的。

第二層:Cloudflare Access(Zero Trust)

cloudflared tunnel 只負責轉流量,本身不做身份驗證,任何人都能連進來。
Cloudflare Access 是加在 tunnel 前面的門禁:

1
使用者瀏覽器 → Cloudflare Access(驗證身份)→ cloudflared tunnel → n8n

只有通過驗證(這裡用 email OTP)的人才能到達 n8n。
這就是「Zero Trust」的概念:不信任任何人,每次都要驗證身份。

這篇的步驟順序

  1. 安裝 cloudflared
  2. 登入 Cloudflare、建立 tunnel、設定路由
  3. 設定 Cloudflare Access,加上身份驗證(先做,tunnel 上線前就有門禁
  4. 啟動 tunnel
  5. 設定 systemd,讓 tunnel 開機自動啟動

步驟零:安裝 cloudflared

在 VM 上執行:

1
2
3
curl -L https://pkg.cloudflare.com/cloudflared-stable-linux-amd64.deb -o /tmp/cloudflared.deb
sudo dpkg -i /tmp/cloudflared.deb
cloudflared --version

看到版本號就安裝成功了。


命名決策

這台 VM 不只跑 n8n,之後可能還有其他自動化服務。
所以 tunnel 和 subdomain 都以機器用途命名,而不是以某個服務命名。

butler(管家),因為這台機器的定位是「幫你自動處理事情的後台」。

  • Tunnel 名稱:butler
  • 對外網址:butler.marsen.me

之後如果加新服務,在 config 裡多加一條 ingress 規則就好。


步驟一:登入 Cloudflare

在 VM 上跑:

1
cloudflared tunnel login

它會印出一個 URL,複製到瀏覽器,選你要授權的 domain(這裡是 marsen.me)。

授權完成後 cert 會自動存到:

1
~/.cloudflared/cert.pem

步驟二:建立 Tunnel

1
cloudflared tunnel create butler

成功後會顯示 tunnel ID(UUID 格式),同時在 ~/.cloudflared/ 產生一個 credentials JSON 檔。

1
2
Tunnel credentials written to /home/USER/.cloudflared/<tunnel-id>.json
Created tunnel butler with id <tunnel-id>

把 tunnel ID 記下來,後面要用。


步驟三:建立 config 檔

1
2
3
4
5
6
7
8
9
cat > ~/.cloudflared/config.yml << 'EOF'
tunnel: <your-tunnel-id>
credentials-file: /home/<USER>/.cloudflared/<your-tunnel-id>.json

ingress:
- hostname: butler.marsen.me
service: http://127.0.0.1:5678
- service: http_status:404
EOF

注意最後的 EOF 必須從行首開始,前面不能有任何空格。

為什麼用 127.0.0.1 而不是 localhost
localhost 在某些環境會被解析成 IPv6 的 [::1],但 n8n 預設只監聽 IPv4。
直接寫 127.0.0.1 避免這個問題。

其中 5678 是 n8n 在 Docker 容器裡對外暴露的 port。

可以用以下指令驗證 config 是否正確:

1
cloudflared tunnel ingress validate

看到 OK 就是正確。


步驟四:新增 DNS 記錄

1
cloudflared tunnel route dns butler butler.marsen.me

這會在 Cloudflare DNS 自動加一筆 CNAME,指向你的 tunnel。


步驟五:設定 Cloudflare Access

在啟動 tunnel 之前先設好 Access,這樣 n8n 上線的那一刻就已經有門禁,不會有公開暴露的空窗期。

進 Cloudflare 主控台 → 左側找 Zero Trust

第一次進入需要選擇團隊名稱(之後可以改),Free 方案 50 人以下免費。

進去後選:安全地存取私人 Web 應用程式連結私人 Web 應用程式

填入應用程式資訊:

欄位 填什麼
應用程式名稱 n8n
內部主機名稱或 IP 127.0.0.1
通訊協定 HTTP
連接埠 5678
子網域 butler
網域 marsen.me

為什麼 IP 填 127.0.0.1 而不是 GCP 外部 IP?

因為 cloudflared 在 VM 裡跑,負責把流量從 Cloudflare 帶進來。
Access 只需要知道「cloudflared 要連哪裡」,也就是 VM 內部的 127.0.0.1:5678
GCP 不用開防火牆,外部 IP 不需要暴露。

1
2
3
4
5
6
7
使用者瀏覽器
↓ HTTPS
Cloudflare Access(驗證身份)
↓ 通過後
cloudflared tunnel(在 VM 裡跑)
↓ 本機連線
127.0.0.1:5678(n8n)

設定 Policy:選 One-time PIN(Cloudflare 寄驗證碼到你的 email),
並填入允許的 email,只有這個 email 能通過驗證。

為什麼要在 tunnel 啟動前先做?

你一開 n8n 就會看到「Set up owner account」頁面,這個頁面是公開的。
如果沒有 Access 保護,任何人都能搶先填、搶走 owner 帳號。


步驟六:啟動 Tunnel

1
cloudflared tunnel run butler

看到 Registered tunnel connection 就是連上了。
這時開 https://butler.marsen.me,應該會先看到 Cloudflare Access 的驗證頁面。


步驟七:設定開機自動啟動

讓 cloudflared 在 VM 重開機後自動啟動,不用每次手動跑。

cloudflared service install 需要知道 config 在哪,但用 sudo 跑時 home 會變成 root 的,
所以要明確指定路徑。安裝後 systemd 會把 config 複製到 /etc/cloudflared/config.yml

1
2
3
sudo cloudflared --config /home/<USER>/.cloudflared/config.yml service install
sudo systemctl start cloudflared
sudo systemctl status cloudflared

<USER> 換成你的 OS Login 用戶名稱(Gmail 帳號把 .@ 換成 _,例如 yourname_gmail_com)。

看到 active (running) 就完成了。之後 VM 重開機,cloudflared 和 n8n 都會自動恢復。


踩坑紀錄

踩坑一:瀏覽器顯示連線錯誤

症狀:tunnel 連上了(看到 Registered tunnel connection),但開瀏覽器只看到錯誤頁面。

瀏覽器的連線錯誤原因很多,不要猜。先查 n8n log:

1
sudo docker logs n8n --tail 20

根據 log 的錯誤訊息再對症下藥。


踩坑二:n8n 啟動失敗(permission denied)

出現時機docker logs 看到 EACCES: permission denied

原因docker run 之前沒有預先建立 ~/.n8n,Docker 自動用 root 身份建立這個目錄。
n8n 容器裡的 node 用戶(UID 1000)不是 root,沒有寫入權限,n8n 啟動就 crash。

解法:把目錄 owner 改成 UID 1000,再重建容器:

1
2
3
4
5
6
7
8
9
sudo chown -R 1000:1000 ~/.n8n
sudo docker rm -f n8n
sudo docker run -d \
--name n8n \
--restart unless-stopped \
-p 5678:5678 \
-v ~/.n8n:/home/node/.n8n \
-e N8N_SECURE_COOKIE=false \
docker.n8n.io/n8nio/n8n

預防:照第一篇步驟五,docker run 前先執行 mkdir -p ~/.n8n && sudo chown 1000:1000 ~/.n8n,就不會踩到這個坑。

n8n 映像檔本來就用非 root 的 node 用戶執行,不需要加 --user


小結

  • cloudflared tunnel 讓 n8n 對外,不用開 GCP 防火牆、不暴露任何 port
  • Cloudflare Access 擋在前面,只有通過驗證的 email 才能進到 n8n
  • cloudflared 設成 systemd service,VM 重開機自動恢復,不需要手動啟動
  • 整條鏈路:瀏覽器 → Cloudflare Access(驗證)→ cloudflared tunnel → n8n

(fin)

[實作筆記] 用 GCP 免費 VM 跑 n8n,打造個人自動化平台

前情提要

最近想整合 AI 與自已的職能,建立一些小工具。

第一個想法是資料搜集器,可以自由擴充的個人內容自動化平台。

核心概念

「捕捉資料」、「整理資料」、「發送報告」拆成三種抽象概念元件,

隨時可以組合或替換。

1
2
3
4
5
6
7
Sources(捕捉資料)  →  Processors(整理)  →  Sinks(發送)
───────────────── ───────────────── ─────────────
AI 新聞 摘要整理 LINE
RSS 訂閱 翻譯 Email
YouTube 事實查核 Instagram
Gmail 格式化 GitHub
... ... ...

一條 pipeline = 選幾個 Source + Processor + Sink 組合起來。
加新管道只需要加一個 Sink 節點,不動其他邏輯。

技術選擇與環境規格

選 n8n 當 runtime,因為它本來就是這個思路設計的,

而且可以自己架,不用把資料交給第三方。

也有考慮過其他方案:

方案 優點 缺點
CCR(Claude Code Remote,Anthropic 雲端排程 agent) 免維護 無法自訂節點、靈活度低
VM + cron 自由度高 要自己維護腳本
n8n 視覺化、可擴充、節點生態豐富、私心想學 需要維護 VM

考慮控制力與學習新技能,最後選 n8n。

一、跑在 GCP e2-micro,always free,完全不用花錢。

GCP Always Free 的條件:

  • 機器:e2-micro(2 vCPU 共享、1 GB RAM)
  • 區域:美國區(us-east1 / us-west1 / us-central1)
  • 磁碟:30 GB 標準永久磁碟
  • 網路:每月 1 GB 對外流量

個人用的 n8n 排程任務,這個規格完全夠。

二、在 GCP 的 VM 上安裝 n8n(文章待補)

三、在 n8n 上設定 pipeline(文章待補)


步驟一:建立 VM

以下我直接讓 AI 執行,你也可以手動或是使用瀏覽器的圖形介面操作

先裝 gcloud CLI:

1
brew install google-cloud-sdk

登入並設定 project:

1
2
gcloud auth login
gcloud config set project YOUR_PROJECT_ID

建立 VM:

1
2
3
4
5
6
7
8
gcloud compute instances create n8n-server \
--zone=us-east1-b \
--machine-type=e2-micro \
--image-family=debian-12 \
--image-project=debian-cloud \
--boot-disk-size=30GB \
--boot-disk-type=pd-standard \
--tags=http-server,https-server

步驟二:設定 SSH(OS Login)

預設的 SSH key 方式要管理本機的 key 檔案,換機器或多人共用都麻煩。

我個人比較喜歡 OS Login 改用 Google 帳號做身份驗證,key 綁在帳號上,不依賴本機檔案。

實際上還是有 ssh key 只是不用特別在意它。

開啟 OS Login:

1
2
3
4
gcloud compute instances add-metadata n8n-server \
--metadata enable-oslogin=TRUE \
--zone=us-east1-b \
--project=YOUR_PROJECT_ID

之後連線就直接,不需要帶任何 key 參數:

1
gcloud compute ssh n8n-server --zone=us-east1-b --project=YOUR_PROJECT_ID

第一次連會問你要不要建立 SSH key,輸入 y 就好,之後不用再動。


步驟三:設定 Swap

e2-micro 只有 1 GB RAM,n8n 本身就吃掉一半左右。
加 swap 避免 OOM crash:

1
2
3
4
5
sudo fallocate -l 2G /swapfile
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile none swap sw 0 0' | sudo tee -a /etc/fstab

最後一行讓 swap 重開機後也自動生效。


步驟四:安裝 Docker

1
2
curl -fsSL https://get.docker.com | sudo sh
sudo usermod -aG docker $USER

usermod 讓你的帳號不用 sudo 就能跑 docker,要重新登入才生效。


步驟五:跑 n8n

n8n 容器裡的 node 用戶 UID 是 1000。

先把 volume 目錄的 owner 設成 1000,避免容器啟動後出現 permission denied:

port 使用預設的 5678。後面會用 cloudflared tunnel 轉發,這個 port 不會對外暴露,改不改都一樣。

1
2
3
4
5
6
7
8
9
mkdir -p ~/.n8n
sudo chown 1000:1000 ~/.n8n
sudo docker run -d \
--name n8n \
--restart unless-stopped \
-p 5678:5678 \
-v ~/.n8n:/home/node/.n8n \
-e N8N_SECURE_COOKIE=false \
docker.n8n.io/n8nio/n8n

幾個參數說明:

參數 說明
--restart unless-stopped VM 重開機自動重啟 n8n
-v ~/.n8n:/home/node/.n8n 資料持久化,不會因為容器重建而消失
N8N_SECURE_COOKIE=false n8n 預設要求 HTTPS 才設定 cookie。對外雖然是 HTTPS,但 cloudflared 到 n8n 的內部連線是 HTTP,所以這個設定要保留

n8n 映像檔本來就用非 root 的 node 用戶執行,不需要加 --user

確認有跑起來:

1
2
sudo docker ps
sudo docker logs n8n --tail 20

正常的話會看到 port 0.0.0.0:5678->5678/tcp,STATUS 是 Up

log 裡可能有 community packages 的 error,是 n8n 找不到額外套件,不影響運作,忽略即可。


小結

到這裡,n8n 已經在 GCP 免費 VM 上跑起來了。

  • GCP e2-micro 免費,加 swap 跑 n8n 沒問題
  • OS Login 用 Google 帳號認證,不依賴本機 SSH key 檔案
  • n8n image 約 2.3GB,第一次 pull 要等幾分鐘
  • --restart unless-stopped 確保 VM 重開機後 n8n 自動恢復

目前 n8n 只能從 VM 內部存取,下一篇會用 cloudflared tunnel 讓它可以從外部用 HTTPS 開啟,不用動防火牆。

(fin)

[生活筆記] 系統思考打工人生

前情提要

最近看到一篇文章,討論台灣的系統性剝削。

「系統性剝削」讓財富自動從勞動者流向資產持有者,普通人很難透過努力翻身。

這是我們這代人的問題。反思一下,身為玩家可以做什麼?


以台灣為例

最低工資每年調,但同時:

  • 匯率壓低、利率壓低 → 貨幣寬鬆 → 通膨
  • 房價持有成本趨近於零(沒有空屋稅)→ 房價只漲不跌
  • 引入大量移工 → 勞動供給過剩 → 薪資天花板下降

設計規則的人也是持有資產的人。

在這樣的賽局裡,你再努力,薪資漲幅永遠追不上資產漲幅。

因為規則保證了這件事。

40 年來台灣 GDP 一直在漲,但多數人的體感是愈來愈苦。

以前一個男人能養家買房,後來要雙薪,現在年輕人連婚姻想都不想了。

這是制度的必然產物,不見得是個人努力不夠。


那要怎麼辦?

在一場注定輸的賽局裡,有兩條路:繼續玩、或是換桌子。

繼續玩的問題在於:你投入的時間與精力,最終會被制度吸走。

努力的成果被通膨稀釋、被房價吃掉、被移工競爭壓制。

要如何破局? 抗爭 ? 躺平 ?

我想是降低對這套制度的依賴,至少換張好一點牌桌。

收益結構

勞動所得的天花板由雇主決定。

你的薪資,是在別人設定的規則裡拿到的分數。

AI 工具讓「建立自動化系統、服務全球市場」的門檻大幅下降。

從勞動所得換成系統所得,才有機會脫離這個天花板。

資產配置

持有現金本身變成一個高風險的事情,通膨不斷的在消耗它的價值。

把所有資產鎖在台灣計價的東西裡,就是讓財富的增減由地主民代的政策來決定。

至少配置部分全球指數基金(VTI、VT 之類),

讓資產跟全球經濟走,不只跟台灣的政策走。

技能可攜帶

稅制剝削不走你的技能。

投資通用的、可以帶著走的能力,

而不是深綁單一公司制度或內部系統的專業。

可攜帶的技能是最難被制度稀釋的資產。

不玩不合理的遊戲

房價所得比畸形就不接盤。

不是放棄,是選擇把資源投入到勝率更好的地方。

換桌子,不是撞牆。


小結

  • 台灣的制度讓薪資漲幅永遠追不上資產漲幅,這是設計,不是意外
  • 在結構化賽局裡努力玩,輸的速度只是慢一點
  • 破局的核心:降低對單一制度的依賴
  • AI 與遠端工作讓個人破局的可行性比過去高很多

(fin)

[實作筆記] 在 Next.js 安全讀取 window:從 useEffect 到 useSyncExternalStore

前情提要

在做韓文注音學習工具時,需要偵測瀏覽器是否支援 speechSynthesis,然後顯示提示訊息。

1
2
3
4
5
const [speechSupported, setSpeechSupported] = useState(true);

useEffect(() => {
setSpeechSupported("speechSynthesis" in window);
}, []);

寫完 CI 報錯:

1
react-hooks/set-state-in-effect: Avoid calling setState() directly within an effect

第一反應是加 eslint-disable,但這樣太逃避。研究了一下,發現這個問題有更正確的解法。


為什麼不能直接讀 window

Next.js 的頁面先在 Server render 一次,再到 Client hydration。

Server 端沒有 window,所以這樣寫會直接炸:

1
2
// ❌ Server 端沒有 window
const supported = "speechSynthesis" in window;

useEffect 只在瀏覽器執行,所以在裡面讀 window 是安全的——這就是第一版寫法的出發點。


useEffect + setState 的問題

1
2
3
4
5
const [speechSupported, setSpeechSupported] = useState(true);

useEffect(() => {
setSpeechSupported("speechSynthesis" in window); // ← lint 報這裡
}, []);

這個寫法會造成兩次 render:

1
2
3
第一次 render:speechSupported = true(初始值)
useEffect 執行:setSpeechSupported(真實值)
第二次 render:speechSupported = 真實值

ESLint 規則的意思是:「你在 useEffect 裡面直接 setState,造成 cascading render,能不能用更好的方式?」

它不是說這樣會壞掉,而是說有更乾淨的做法。


三種解法

解法一:eslint-disable(不推薦)

1
2
3
4
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
setSpeechSupported("speechSynthesis" in window);
}, []);

問題沒有解決,只是把警告蓋掉。


解法二:useState lazy initializer

1
2
3
const [speechSupported] = useState(
typeof window !== "undefined" ? "speechSynthesis" in window : true
);

Server 端 typeof window === "undefined"true,所以初始值是 true
Hydration 時 React 不重新執行 lazy initializer,直接沿用,所以不會 mismatch。

缺點:如果使用者的瀏覽器真的不支援 speechSynthesis,這個值永遠是 true,訊息永遠顯示不了。只適合「假設支援、不在乎誤判」的場景。


解法三:useSyncExternalStore(推薦)

React 18 為 Server/Client 差異設計了這個 hook:

1
2
3
4
5
6
7
import { useSyncExternalStore } from "react";

const speechSupported = useSyncExternalStore(
() => () => {}, // subscribe(不訂閱任何東西,no-op)
() => "speechSynthesis" in window, // Client 端的值
() => true, // Server 端的值
);

三個參數分別對應:

參數 意義
subscribe 訂閱外部資料變更,不需要就給 no-op
getSnapshot 瀏覽器端回傳什麼值
getServerSnapshot 伺服器端回傳什麼值

流程:

1
2
3
Server render → 呼叫 getServerSnapshot → 回傳 true
Client hydration → 呼叫 getSnapshot → 回傳真實值
若兩者不同 → React 知道這是 client-only 差異,自動補一次 render,不報 hydration error

不需要 useState,不需要 useEffect,ESLint 不報錯,能正確偵測「真的不支援」的瀏覽器。


三種解法比較

useEffect + setState useState lazy useSyncExternalStore
Server 安全
Hydration 安全
多餘 render ❌ 有 ✅ 無 ✅ React 自動處理
ESLint 通過
能偵測真的不支援

小結

useSyncExternalStore 名字聽起來像是給 Redux 這種外部 store 用的,但它的第三個參數 getServerSnapshot 就是為了 SSR/Client 差異設計的——哪怕你根本沒有要訂閱任何東西。

遇到「要讀 window 但又要 SSR 安全」的情境,先考慮這個 hook,不要直接跳到 useEffect + setState

(fin)

[實作筆記] 用 Cloudflare Tunnel 讓 LINE Bot Webhook 打到本機

前情提要

在本機開發時需要 Webhook 來接受第三方的回呼,

例如「LINE Bot 的 Messaging API 」 通常需要真實的 HTTPS endpoint 的 Callback Url,

第一反應是用 ngrok,指令一行就能跑:

但是每次重開 URL 都會換不一樣,需要再去設定一次。

所以我決定換個做法。

Cloudflare Named Tunnel 的原因:

  • URL 固定(綁自己的 domain),設定一次就好
  • 已有 Cloudflare 管理 domain,沒有額外成本
  • 搭配 launchd 開機自啟,幾乎感覺不到它的存在

前兩項是必要前置條件,剛好我都有,沒有的小朋友可能需要設定一下

或是找其它解法,文末有提供一些別的方法給你參考

前置條件

  • 已有 Cloudflare 帳號
  • 要使用的 domain 已托管在 Cloudflare(NS 已指向 Cloudflare)
  • 本機 LINE Bot 已可正常啟動並監聽某個 port

為什麼選 Named Tunnel,不用 Quick Tunnel

Quick Tunnel 每次啟動 URL 都會變,要一直去 LINE Console 更新 Webhook URL,很麻煩。

Named Tunnel 設定一次,URL 固定(綁自己的 domain),之後只管跑就好。


設定步驟

本文以 tunnel 名稱 mybot、domain mybot.example.com、本機 port 3000 為例。

1. 安裝 cloudflared

1
brew install cloudflare/cloudflare/cloudflared

2. 登入

1
cloudflared tunnel login

瀏覽器會開啟授權頁面,選擇要綁定的 domain。

3. 建立 Named Tunnel

1
cloudflared tunnel create mybot

會產生一組 tunnel ID 跟 credentials 檔,路徑類似 ~/.cloudflared/<tunnel-id>.json

4. 設定 config

寫入 ~/.cloudflared/config.yml

1
2
3
4
5
6
7
tunnel: <tunnel-id>
credentials-file: /Users/<user>/.cloudflared/<tunnel-id>.json

ingress:
- hostname: mybot.example.com
service: http://localhost:3000
- service: http_status:404

<tunnel-id> 從 Step 3 的輸出取得,或執行 cloudflared tunnel list 查看。

5. 設定 DNS

1
cloudflared tunnel route dns mybot mybot.example.com

Cloudflare DNS 會自動新增一筆 CNAME 指向 tunnel,不用手動加。

6. 跑起來

1
cloudflared tunnel run mybot

這樣 https://mybot.example.com 的流量就會轉進 localhost:3000


LINE Console 設定

注意:Verify 前要確認 bot 已在本機跑起來, LINE 會實際打一個請求過來,bot 沒跑就會 Verify 失敗。

LINE Developers Console → Messaging API → Webhook URL 填:

1
https://mybot.example.com/webhook

勾選「Use webhook」→ 點「Verify」,回傳 200 就完成了。


Port 選擇

LINE 打來的 Webhook 是外部流量,建議選高號碼 port(49152–65535),
比常見的 3000、8080 不容易被掃描到。
config.ymlservice 欄位對應好本機 port 即可。


其他常見做法與沒選的原因

方案 為什麼沒選
ngrok 免費版 URL 每次重啟都變,跟 Quick Tunnel 一樣的問題
localtunnel 開源,URL 可指定但不保證穩定,偶有被佔用的情況
smee.io Webhook 轉發專用,需要額外的 client 程式,多一層複雜度
VS Code Dev Tunnels 綁定 VS Code,換編輯器就失效
Tailscale Funnel 要先建 Tailscale 網路,對沒在用的人成本高
直接部署到雲端 URL 穩定,但每次改 code 都要重部署,開發迭代太慢
Cloudflare Quick Tunnel URL 每次重啟都換,手動更新 Webhook 很煩

小結

Cloudflare Named Tunnel 解決了「本機服務需要公開 HTTPS endpoint」的問題,
對 LINE Bot 開發來說特別實用——URL 固定,不用每次重啟都更新 Webhook 設定。
搭配 launchd 讓 cloudflared 開機自動啟動,幾乎可以忘記它的存在。

(fin)

[實作筆記] Ghostty 打字出現 [O[27u 亂碼的原因與修正

前情提要

剛裝好 Ghostty,打字有時正常,有時畫面會出現像這樣的亂碼:

1
[O[27u

查了一下,原因跟 Ghostty 預設啟用的 Kitty Keyboard Protocol 有關。

原因

Ghostty 預設使用 TERM=xterm-ghostty,同時啟用 Kitty Keyboard Protocol(又叫 CSI u encoding)。

這個協定會把按鍵轉成擴展的 escape sequence,格式長這樣:

1
ESC [ <code> u

例如 Escape 鍵 → ESC[27u,搭上其他序列就變成你看到的 [O[27u

這本來是為了解決傳統終端機鍵碼不夠精確的問題,現代工具(Neovim、WezTerm、Kitty)都支援。

但如果你的 shell 或其他程式不支援這個協定,就不會解析這些 escape sequence,直接把它們當一般文字印出來,就是你看到的亂碼。

修正方法

改掉 TERM 設定,換成廣泛相容的 xterm-256color,讓 Ghostty 不送出這些擴展序列。

建立或編輯 ~/.config/ghostty/config,加入:

1
term = xterm-256color

完整設定檔範例:

1
2
3
4
# ~/.config/ghostty/config

# 使用廣泛相容的 TERM,避免 Kitty keyboard protocol 造成亂碼
term = xterm-256color

存檔後完全關閉 Ghostty 再重開(Cmd+Q,不只是關視窗),讓設定生效。

如果你有用 tmux

tmux 本身也可能會攔截或破壞這些 escape sequence,在 ~/.tmux.conf 加入:

1
2
set -g extended-keys on
set -as terminal-features 'xterm-ghostty:extkeys'

或直接關掉:

1
set -g extended-keys off

最簡單的做法還是先改 term = xterm-256color,大多數情況直接解決。

小結

Ghostty 預設啟用 Kitty Keyboard Protocol,對不支援的程式來說會直接吐出 [27u 這類亂碼。
改一行設定 term = xterm-256color 就搞定,完全關掉重開生效。

(fin)

[工具筆記] Terminal 工具怎麼選?Ghostty、iTerm2 與其他選手比較

前情提要

最近有人問我 Ghostty 跟 iTerm2 哪個比較好,
順便聊到還有哪些選手值得比較,就整理一下。

選手介紹

iTerm2

macOS 老牌 terminal,功能多到有點過頭。

split pane、tmux 整合、Triggers、Shell Integration、Python scripting API 全都有,
幾乎是 macOS 工程師的預設選項,做了很多年。

缺點是比較重,啟動慢一點,只能用在 macOS。

Ghostty

2024 底爆紅的新秀,現在已經是很多人的主力工具。

Rust 的精神,GPU 加速渲染,開箱即用,預設樣式就乾淨好看。
支援 Kitty keyboard protocol,Nerd Fonts 圖示正常顯示,光標移動順到不行。

設定是純文字檔,簡單幾行就搞定:

1
2
3
font-family = "JetBrainsMono Nerd Font"
font-size = 14
theme = "GruvboxDark"

macOS + Linux 都支援。

Warp

主打 AI 整合,內建 AI 指令輔助,Block-based UI,輸出可以區塊操作。

Rust 寫成,效能不錯。
最大的爭議是需要帳號登入,有隱私疑慮,這點讓不少人卻步。

Alacritty

效能派代表,極簡哲學。

刻意拿掉 Tab、split pane、滑鼠增強,理由是「這些交給 tmux 做就好」。
純文字設定(TOML),跨平台(macOS / Linux / Windows)。

缺點是預設偏素,要自己調設定,不適合「裝好就好看好用」的需求。

Kitty

GPU 加速,原生支援 split pane 和 tab。
Kitty keyboard protocol 就是它發明的,Ghostty 後來也採用。
macOS + Linux,設定有點多,學習曲線稍高。

WezTerm

GPU 加速,Lua 腳本設定,功能豐富。
內建 multiplexer,split、tab、session 管理全包,不需要 tmux。
跨平台(macOS / Linux / Windows)。


快速比較

iTerm2 Ghostty Warp Alacritty Kitty WezTerm
效能 普通 極快
跨平台 macOS only mac+Linux mac+Linux 全平台 mac+Linux 全平台
設定方式 GUI 文字 GUI TOML conf Lua
AI 功能
內建 multiplexer
Nerd Fonts 支援
成熟度

補充:tmux 是什麼

很多文章提到要配合 tmux 使用,稍微說明一下。

tmux 是 terminal multiplexer,在一個 terminal 視窗裡模擬多視窗和分割畫面。
最大的賣點是 session 保持,關掉視窗程式不會死,SSH 到遠端時特別實用。

1
2
3
4
5
6
7
8
9
┌─────────────────────────────┐
│ tmux session │
│ ┌──────────┬──────────┐ │
│ │ vim │ shell │ │
│ │ │ │ │
│ ├──────────┴──────────┤ │
│ │ npm run dev │ │
│ └─────────────────────┘ │
└─────────────────────────────┘

現代 terminal(Ghostty、WezTerm、iTerm2)內建 split 和 Tab,
一般使用不需要特別學 tmux,除非有 SSH session 保持的需求。


我的建議

需求很簡單,好看 + 圖示顯示 + 光標流暢,直接選 Ghostty

裝好之後指定一個 Nerd Font,10 分鐘搞定:

1
2
brew install --cask ghostty
brew install --cask font-jetbrains-mono-nerd-font

Nerd Fonts 常用選擇:

字型 特色
JetBrains Mono NF 易讀、開發者最愛
FiraCode NF ligature 好看
Hack NF 傳統乾淨

其他場景的建議:

  • 需要 AI 輔助 → Warp
  • 要配合 tmux、極致效能 → Alacritty
  • 要 Lua 腳本、功能完整 → WezTerm
  • 已經習慣、不想換 → iTerm2 繼續用也沒問題

小結

業界風向已經轉向 Ghostty,不是因為它功能最多,
而是它把該有的都做好了,多餘的不硬塞。

開箱好看、設定簡單、效能夠快,這樣就夠了。

(fin)

[實作筆記] Next.js Hydration 與隨機性:從問題到正確架構

前情提要

最近在開發 Next.js 專案時,有 A/B 測試的需求,結果遇到一個奇怪的錯誤:

1
Error: Hydration failed because the initial UI does not match what was rendered on the server.

追了一下,發現是 Math.random() 惹的禍。

問題根源

Next.js 的頁面渲染分兩個步驟:

第一步,Server 產生 HTML,送到瀏覽器,使用者馬上看到畫面,但還不能互動。

第二步,Client 載入 JavaScript,把事件綁上去,畫面變成可互動的。

第二步就叫 Hydration(注水)— 把靜態 HTML 注入互動能力,像幫乾燥的海綿注水。

Hydration 的核心規則:Server 產生的 HTML 和 Client 產生的 HTML 必須一模一樣。

問題在於 Math.random() 在 Server 和 Client 各自執行一次:

1
2
Server  跑 Math.random() → 0.3 → 選 Layout A → 產生 HTML
Client 跑 Math.random() → 0.7 → 選 Layout B → 跟 Server 的 HTML 對不上 → 爆炸

初步解法:繞開 SSR,在前端處理

要解決 mismatch,核心思路是讓 Math.random() 只在 Client 端跑一次。有兩種做法:

做法一:dynamic({ ssr: false })

直接告訴 Next.js 這個 component 跳過 SSR:

1
2
3
4
5
6
7
8
9
// ABComponent.tsx
'use client'
export default function ABComponent() {
const variant = Math.random() < 0.5 ? 'a' : 'b'
return <Layout variant={variant} />
}

// page.tsx
const ABComponent = dynamic(() => import('./ABComponent'), { ssr: false })

component 完全不在 Server 端跑,Math.random() 只在瀏覽器執行,沒有 mismatch。

做法二:useState + useEffect

讓 component 在 Server 和 Client 第一次都 render null,mount 後才隨機:

1
2
3
4
5
6
7
const [variant, setVariant] = useState<string | null>(null)

useEffect(() => {
setVariant(Math.random() < 0.5 ? 'a' : 'b')
}, [])

if (!variant) return null

兩邊第一次渲染結果一致,hydration 成功,mount 後才決定變體。

哪個比較好?

做法一(dynamic)比較好。 程式碼更簡單,意圖也更清楚:「這個 component 不需要 Server Side Render」。

做法二的 useState + useEffect 除了更繁瑣,還可能被部分嚴格的 lint rule 擋住,lint auto-fix 之後反而重新引入 hydration bug,修 A 破 B。

不過兩種做法有一個共同的致命問題:

同一個用戶每次重整都重新隨機,這次看 A、下次看 B,A/B 測試數據完全沒意義。

正確做法:Middleware 決定,Server Side 處理

讓 Server 在第一次請求時決定變體,存進 cookie,之後每次讀同一個值。

1
2
3
4
5
6
7
Middleware 執行 Math.random(),結果存進 cookie

Server Component 讀 cookie 取得變體值

以 prop 傳給 Client Component

Server render 和 Client hydration 讀到同一個 prop → 沒有 mismatch

middleware.ts

1
2
3
4
5
6
7
8
9
10
export function middleware(request: NextRequest) {
const response = NextResponse.next()

if (!request.cookies.get('ab-variant')) {
const variant = Math.random() < 0.5 ? 'a' : 'b'
response.cookies.set('ab-variant', variant, { httpOnly: true })
}

return response
}

page.tsx(Server Component)

1
2
3
4
5
6
import { cookies } from 'next/headers'

export default function Page() {
const variant = cookies().get('ab-variant')?.value ?? 'a'
return <Layout variant={variant} />
}

同一個用戶拿到同一個 cookie,每次看到同一個版本,A/B 測試數據才有意義。

小結

這次踩坑之後整理出一個原則:

隨機、時間、用戶身份這類「非確定性資料」,應該從 Server 端注入,Client 只負責呈現。

資料類型 不建議做法 建議做法
隨機值(A/B 測試) Client useState + Math.random() Middleware 寫 cookie,Server 讀取傳入
當前時間 Client Date.now() Server props 傳入
用戶身份 Client 讀 localStorage Server 讀 session cookie

下次再給我選的話,我會選用在 Server Side 決定隨機性,而不在前端。

(fin)

[生活筆記] 2026 台新 Richart Life 切換方案回饋最大化

前情提要

自從 2025 年 9 月台新把 @GoGo 卡、FlyGo 卡、玫瑰 Giving 卡、太陽/玫瑰卡這些信用卡整合成一張 Richart 卡之後,
就有點搞不清楚怎麼才能拿到最佳的回饋。
研究了一下,七個方案乍看之下有點眼花撩亂,但搞懂之後其實邏輯很清楚,稍稍記錄一下。

七大刷卡方案一覽

Richart 卡的核心玩法就是「切換刷」——在 Richart Life APP 裡面,每天可以切換一次方案,
當天所有消費都會依照你最終選定的方案來計算回饋。

以下是七個方案的整理:

方案名稱 適用場景 最高回饋率 綁定支付方式 注意事項
Pay著刷 台新Pay / LINE Pay 綁定支付 3.8% 台新Pay, LINE Pay 需用台新 Pay 或 LINE Pay 才能拿最高回饋
數趣刷 網購 / 電商平台 3.3% 一般刷卡 通路須屬於指定電商,PChome 儲值券會整筆排除
玩旅刷 海外消費 / 旅遊訂房 / 機票 3.3% 一般刷卡 海外交易需在方案指定範圍內
天天刷 超商 / 量販 / 交通 3.3% 一般刷卡 一般日常消費的好選擇
大筆刷 百貨 / OUTLET / 家居大額消費 3.3% 一般刷卡 百貨店中店(如 UNIQLO)不適用
好饗刷 餐飲 / 外送 / 演出活動 / 加油 3.3% 一般刷卡 餐飲娛樂類消費
假日刷 週末或國定假日不限通路 2.0% 一般刷卡 四大超商、繳稅費不適用

資料來源:台新 Richart 卡官方權益頁面假日刷活動說明

什麼時候切什麼方案?我的建議(待測試)

其實最關鍵的一點是:方案以當天最後一次切換為準
也就是說,你早上八點切了天天刷去家樂福,晚上又切成好饗刷去吃大餐,
那你早上那筆家樂福的消費就只會拿到 0.3% 的一般回饋,而不是天天刷的 3.3%。

所以我想到的做法是:盡量在一天快結束的時候,回顧當天消費,再決定切換哪個方案。

以下是我自己的日常切換邏輯:

平日(週一到週五)

  • 今天主要花在網購(蝦皮、momo 等) → 切「數趣刷」
  • 今天去量販店、搭捷運、去超商 → 切「天天刷」
  • 今天吃大餐、叫外送、去看電影 → 切「好饗刷」
  • 今天去百貨公司買了大件東西 → 切「大筆刷」
  • 今天有在海外消費或訂了機票飯店 → 切「玩旅刷」
  • 今天主要用台新 Pay 或 LINE Pay 付款 → 切「Pay著刷」(3.8% 最高)

假日(週末 + 國定假日)

假日的選擇就比較有趣了。如果你假日的消費剛好不屬於任何特定方案的指定通路,
那「假日刷」的 2% 不限通路就是你的好朋友。
但如果你假日去吃大餐(好饗刷 3.3%)或是逛百貨(大筆刷 3.3%),
那切特定方案反而比假日刷的 2% 更划算。

簡單判斷:假日消費有明確分類 → 切對應的 3.3% 方案;假日消費很雜 → 切假日刷 2%。

出國旅遊期間

出國期間果斷切「玩旅刷」就對了,海外消費 3.3% 很簡單直接。
另外如果有搭配 Richart Life APP 的領券活動,海外消費還有機會疊加到 5%。

幾個容易踩的坑

1. 支付工具的限制

這點很重要:街口支付、悠遊付、icash Pay、中油 Pay、全支付、全盈+PAY、OPEN 錢包這些第三方支付,
通通不適用七大權益的加碼回饋。

能拿到加碼回饋的支付方式:

  • 實體卡刷卡(含線上輸入卡號)
  • 台新 Pay
  • Apple Pay / Google 錢包 / Samsung Pay 綁定
  • LINE Pay(僅限 Pay著刷 和 假日刷 方案)

注意 LINE Pay 在其他五個方案(數趣刷、玩旅刷、天天刷、大筆刷、好饗刷)是不適用加碼的。

2. 四大超商在假日刷不回饋

假日刷雖然號稱「不限通路」,但四大超商和繳稅費是被排除的。
不過這不代表四大超商在所有方案都不給回饋,天天刷就有包含超商。

3. PChome 的坑

如果你在 PChome 24h 購物車裡面混了儲值金或電子票券,整筆訂單都不會拿到數趣刷 3.3% 回饋,
只剩下 0.3%。所以記得把一般商品和儲值/票券類商品分開結帳。

4. 回饋是點數,不是現金

Richart 卡的回饋是「台新 Point(信用卡)」,不是直接現金回饋。
而且從 2026/1/1 起,點數折抵帳單的比例從 100% 降到了 30%,
其他用途包括在 Richart Life APP 商城消費、兌換全家 Fa 點、LINE POINTS 等。
點數有效期限是兩年,記得定期使用。

5. 要設定台新帳戶自動扣繳(我已設定)

要享受最高回饋(LEVEL 2),需要設定台新帳戶自動扣繳信用卡帳款。
沒設定的話,加碼回饋最高只有 1.3%,差距不小。

小結

台新 Richart 卡的七大刷卡方案,核心邏輯就是「一天一切換,依當天消費選方案」。
回饋率在 2%~3.8% 之間,而且是無上限的(每期帳單消費上限為信用額度+30萬),
以一張卡能涵蓋的場景來說算是蠻全面的。

我自己覺得最需要注意的就是支付工具的限制和切換時機,
養成睡前花三秒鐘在 APP 切方案的習慣,基本上就能把回饋最大化了。

以上是基於 2026 年上半年的權益整理,台新的活動更新蠻頻繁的,
建議還是定期到官方頁面確認最新資訊。

(fin)

[生活筆記] 2025 T社面試心得 - 二面

前情提要

延續一面的紀錄,在完成 Python 基礎與演算法測驗後,很快地收到了二面的邀請。

二面同樣採用線上面試的形式,主要聚焦在演算法題目的實作與程式碼重構能力的考察,

並且會有主管線上監考。

面試流程

二面總時長約 90 分鐘,分為兩個部分:

  1. 演算法題目實作(約 60 分鐘)
  2. 程式碼重構討論(約 30 分鐘)

題目與解題

Q1: 最長不重複子字串

題目描述:給定一個字串 s,找出最長的不含重複字元的子字串長度。

解題思路

  • 使用 sliding window 的概念
  • strtmp 維護當前不重複的子字串
  • 當遇到重複字元時,清空 strtmp 重新開始
  • 持續追蹤最大長度

Q2: 股票交易最大利潤

題目描述:給定股票每日價格陣列 prices 和交易手續費 fee,求最大利潤。
可進行多次交易,但每次交易需支付一次手續費,且賣出後才能再買入。

Q2 解法評價與反思

坦白說,這題在本來想展示 TDD 的過程,

並在過程中覺察重複,並使用重構技巧完成解答。

但可能弄巧成拙,看起來只是把測試案例的答案硬編碼進去。

這個策略的問題

  • 優點:至少讓測試通過,展示我理解題意
  • 缺點:面試官會認為我不會動態規劃,或者時間管理有問題
  • ⚠️ 風險:如果面試官追加測試案例,立刻露餡

未來再用 TDD 來解上面兩題


Q3: 程式碼重構

題目背景

這是一個批次匯入資料的函式,會有效能與欄位合併的問題,需要重構:

原始程式碼,基於對面試公司的尊重,不予公佈

我的重構建議:

  1. config 敏感資料
  2. config MAX_RECORD_PER_ROUND
  3. 拆單一職責子方法(取資料/組 SQL/寫資料)
  4. SQL BULK
  5. 重命名參數

Q3.解法評價與反思

面試官更著重在使用 python 的語法使用
如下,

語法糖 用途 優勢
**dict 字典解包 簡化參數傳遞
with Context Manager 自動資源管理
f-string 字串格式化 可讀性高
sql.SQL() SQL 組裝 防止 SQL Injection
List Comprehension 列表生成 簡潔、快速
Generator 惰性求值 省記憶體
:= Walrus 賦值表達式 減少重複運算
@dataclass 資料類別 減少 boilerplate
@contextmanager 自訂 CM 封裝 setup/teardown
Type Hints 型別註記 IDE 支援、可讀性

面試心得與反思

從 Web 2.0 到 AI 時代,工作方式已經改變:

Web 2.0 時代:手寫演算法是基本功,熟悉語法是專業展現

AI 時代:演算法交給 AI,重點是整合工具。我的工作流程:TDD + AI → Review → 上線&維運

往前有需求分析與設計,往後有系統維運,之間交錯的成本控管/資安/流程優化,都是在開發之外的能力

但這次面試仍在考:能不能手寫 LeetCode、熟不熟 Python 語法、時間壓力下能不能寫出正確代碼。

角色認知的斷層

Senior Engineer:LeetCode 能力 = 技術能力

Tech Lead:系統思維 = 技術決策能力,整合能力 = 創造價值

這次面試用 Senior Engineer 的標準在評估 Tech Lead 的職位

我用 Tech Lead 這個角色,應徵 Senior Engineer。

雙向選擇

面試不只是公司選我,我也在選公司,可惜沒有給我問問題的機會:

  • 他們看重:LeetCode、語法(糖)熟悉度、coding speed
  • 我看重:系統設計討論、技術決策空間、團隊文化

還在用舊時代標準評估新時代角色的公司,或許不是我的理想選擇。

而我的多語言開發背景,可能也只是 jack of all trades master of none

參考資料

(fin)

Please enable JavaScript to view the LikeCoin. :P