• 28/06 cập nhật đăng ký đăng nhập qua Google & Facebook

[CHIA SẺ] Tự host OTA cho app Expo bằng hot-updater + Cloudflare R2 — thoát phí EAS Update tính theo MAU

TenHoshi

Administrator
Staff member
Trusted Uploader
Bối cảnh Nhiều bạn dùng EAS Update và giật mình khi hoá đơn tăng theo số người dùng. Cách Expo tính: 1 máy tải ít nhất 1 bản update trong kỳ = 1 MAU. Gói Starter ($19) có 3.000 MAU, vượt thì ~$0,005/MAU. Ví dụ 10.800 MAU ⇒ riêng khoản update ~$39/tháng, app càng đông càng tốn.

Lưu ý hay hiểu nhầm: không phải "người cũ không tính, người mới mới tính". Máy nào tải update trong kỳ đều tính 1 lần (đẩy 1 hay 10 bản trong kỳ vẫn chỉ 1). Máy không tải gì thì không tính.

Giải pháp mình đang chạy
- Build: vẫn dùng EAS Build như cũ (phí cố định theo gói, không theo người dùng).
- OTA: hot-updater (github.com/gronxb/hot-updater) + Cloudflare R2 (lưu bundle) + D1 (metadata) + Worker (endpoint cho app hỏi update). R2 không tính phí egress, free tier dư cho vài chục nghìn user ⇒ gần như $0.
- Có sẵn: channel (preview/production), force update, rollback, console web, tự quay về bundle cũ nếu bundle mới crash lúc khởi động.

CÁC BƯỚC

1) Gỡ expo-updates (bắt buộc — 2 thư viện cùng giành quyền update sẽ crash)
npm uninstall expo-updates
Xoá trong app.config: khối "updates", "runtimeVersion", plugin "expo-updates". Xoá "channel" trong eas.json (của EAS Update).

img_v3_0215q_8e1d1e92-ff76-4122-8f4d-b434b4f1a9hu.png


2) Chuẩn bị Cloudflare
- Bật R2, tạo bucket (vd: myapp-ota), để private.
- Tạo R2 API token quyền Object Read & Write, CHỈ cho bucket đó (lấy Access Key + Secret).
- Tạo API token account quyền D1 Edit + Workers Scripts Edit (+ Workers R2 Storage Edit).
- Nếu account chưa từng dùng Workers: mở trang Workers & Pages 1 lần để Cloudflare tạo subdomain *.workers.dev. Đổi subdomain thành tên trung tính (tên công ty) NGAY LÚC NÀY — URL này sẽ gắn cứng vào app, phát hành rồi là không đổi được.

img_v3_0215q_02ef2b0e-4021-4a58-982b-44e2b0060dhu.png


img_v3_0215q_a314731d-8d02-453e-b406-0e102f21cdhu.png


img_v3_0215q_c880f8ac-f837-49cd-ae06-6957a20f08hu.png


3) Init
Cài CLI cục bộ trước (chạy npx trực tiếp dễ lỗi "Cannot find package @hot-updater/cloudflare"):
npm i -D hot-updater
npx hot-updater init → chọn Expo → Cloudflare
Ô "D1 API Token" điền token account (bước 2), KHÔNG phải token R2. Init sẽ tạo D1, deploy Worker, sinh hot-updater.config.ts và .env.hotupdater (nhớ gitignore).

Anh-man-hinh-2026-09-23-luc-11.00.42.png


4) Gắn vào app
app.config.js:
plugins: [["@hot-updater/react-native", { channel: process.env.HOT_UPDATER_CHANNEL || "production" }]]
eas.json: mỗi profile thêm env HOT_UPDATER_CHANNEL (development / preview / production).
App.tsx:

Code:
export default HotUpdater.wrap({ baseURL: "https://<worker>.<subdomain>.workers.dev/api/check-update", updateStrategy: "appVersion", onError: (e) => console.warn("[hot-updater]", e), })(App);
Build lại native (prebuild + pod install / eas build). Bản Debug (__DEV__) không hỏi update — test OTA phải dùng bản Release/TestFlight.

Anh-man-hinh-2026-09-23-luc-11.00.56.png


5) Đẩy update
npx hot-updater deploy -p ios -c production -t 1.1.0
Thêm -f để ép cập nhật (tải xong reload ngay). Rollback: npx hot-updater rollback production.
Mẹo: hiển thị HotUpdater.getBundleId() ở màn Cài đặt ⇒ nhìn là biết máy đang chạy bản OTA nào.

CHỌN appVersion HAY fingerprint?
- fingerprint: an toàn tuyệt đối (bundle chỉ tới máy có native khớp) nhưng mã hash khó nhớ, và hash lúc build trên EAS phải khớp hash lúc deploy — dễ lệch.
- appVersion: nhắm theo version "1.1.0", dễ nhớ. Quy tắc bắt buộc: ĐỔI NATIVE THÌ TĂNG VERSION. Quên là OTA tới máy cũ thiếu module ⇒ crash kiểu "TurboModuleRegistry.getEnforcing...".
Mình chọn appVersion + tự viết script guard: lúc build store thì ghi fingerprint native của version đó vào 1 file JSON; lúc deploy OTA thì tính lại, lệch là chặn. Người nhớ version, máy nhớ hash.

NHỮNG BẪY MÌNH ĐÃ DẪM
1. Biến EXPO_PUBLIC_* được Babel "nhúng cứng" LÚC BUNDLE OTA, không phải lúc build store. Deploy trên CI không có .env ⇒ bundle thiếu biến (vd Google Sign-In client id rỗng ⇒ hỏng đăng nhập của mọi máy nhận OTA). Deploy từ máy dev thì .env đang trỏ API dev cũng bị nhúng vào bản production. Cách chữa: script deploy export đúng env của eas.json (build.base.env) trước khi bundle.
2. Tự tính fingerprint bằng @expo/fingerprint: thư mục ios/ android/ (sinh ra từ prebuild, bị gitignore) làm hash máy dev khác CI ⇒ phải loại nguồn "bareNativeDir". App config đọc env ⇒ cũng phải ép env cố định khi tính.
3. "hot-updater app-version" đọc version từ thư mục native ⇒ trên CI (không có ios/android) ra null. Đọc version bằng "npx expo config --json" cho chắc.
4. "hot-updater fingerprint create" không chạy khi updateStrategy = appVersion.
5. Tự rollback chỉ bắt được crash ngay lúc khởi động. Lỗi xảy ra sau màn đầu tiên thì phải rollback tay ⇒ LUÔN thử channel preview trước production.
6. Khi gửi app cho Apple review, reviewer cũng nhận OTA channel production ⇒ đừng đẩy bản thử nghiệm lên production lúc đó.
7. Apple ID bị khoá (lỗi -20209) vì EAS tự đăng nhập bằng mật khẩu lưu Keychain. Build iOS nên thêm --non-interactive (dùng credentials đã lưu trên EAS, không đăng nhập Apple), submit bằng App Store Connect API key.

CI (tuỳ chọn)
GitHub Actions workflow_dispatch (bấm tay), chạy ubuntu là đủ (OTA chỉ bundle JS, không cần Mac). Đưa key Cloudflare vào Secrets, chỉ cấp cho đúng step deploy; chặn channel production nếu không chạy từ nhánh main; truyền input qua env (không nhét ${{ }} thẳng vào lệnh shell).

Kết quả
Đã test trên máy thật: update thường, ép cập nhật, rollback tay, bundle lỗi tự quay về bản cũ, mất mạng vẫn mở app bình thường, lệch version không nhận nhầm bundle. Chi phí OTA ~0đ, hoá đơn Expo cố định theo gói build.

Ai cần script guard fingerprint thì cmt mình gửi.
 

Guest Post 2022

Top