Workers — backend trong một file
Mã chạy phía máy chủ, không cần máy chủ. Dựng Worker đầu tiên, phục vụ cả web tĩnh lẫn API, và hiểu giới hạn thật của nó.
Đây là bài mở ra Cấp 3. Từ đây app của bạn có phía máy chủ: lưu dữ liệu chung, đăng nhập thật, giữ khóa bí mật, gửi email.
Workers là gì, nói cho người không chuyên
Pages trả file có sẵn. Workers chạy mã của bạn mỗi khi có người gọi tới, trên máy chủ Cloudflare gần người dùng nhất.
Người dùng ở Thanh Hóa gọi https://app.cua-ban.com/api/don-hang
│
▼
Cloudflare nhận tại điểm mạng gần nhất (Hà Nội / Singapore)
│
▼
Mã của bạn chạy NGAY TẠI ĐÓ (vài milli-giây)
│
▼
Trả kết quả
Không có máy chủ nào của bạn phải bật 24/7. Không có hóa đơn VPS. Không ai phải vá hệ điều hành. Không có chuyện "máy chủ đầy ổ cứng lúc 2 giờ sáng".
Cái giá của việc đó — và bạn phải biết trước:
| Workers không có | Nghĩa là |
|---|---|
| Hệ thống file để ghi | Không fs.writeFile. Lưu dữ liệu phải dùng D1/KV/R2 |
| Tiến trình chạy liên tục | Mỗi request là một lượt chạy riêng, xong là hết |
| Bộ nhớ giữ giữa các lượt | Biến toàn cục không đáng tin (xem bẫy ở dưới) |
| Toàn bộ thư viện Node.js | Phần lớn chạy được, một số thì không |
Mô hình đúng trong đầu: một hàm, nhận một request, trả một response, và quên hết.
Worker đầu tiên
cd C:\NVAI\Claude
npm create cloudflare@latest app-don-hang
Chọn: Hello World example → Worker only → TypeScript hay JavaScript đều được (giáo trình dùng JavaScript cho dễ đọc) → Git: Yes → Deploy: No (ta deploy tay để hiểu).
Mở src/index.js, nó đại khái là:
export default {
async fetch(request, env, ctx) {
return new Response("Xin chao!");
},
};
Ba tham số, học một lần dùng mãi:
request— ai gọi, gọi đường nào, mang gì theo.env— bí mật và các "binding": cơ sở dữ liệu, kho tệp, biến cấu hình.ctx— điều khiển vòng đời (ctx.waitUntil— xem cảnh báo ở bài Cron).
Chạy thử tại máy:
npx wrangler dev
Mở http://localhost:8787. Sửa chữ trong file, lưu — nó tự nạp lại.
Đưa lên mạng:
npx wrangler deploy
Ra link kiểu app-don-hang.ten-ban.workers.dev. Đó là backend của bạn, đang chạy thật, miễn phí.
Định tuyến: một Worker, nhiều đường
Một Worker thường phục vụ cả web lẫn API. Mẫu định tuyến tối giản, dùng được cho app thật:
export default {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const p = url.pathname;
// ---- API ----
if (p.startsWith("/api/")) {
try {
if (p === "/api/don-hang" && request.method === "GET") return await dsDonHang(request, env);
if (p === "/api/don-hang" && request.method === "POST") return await themDonHang(request, env);
return json({ loi: "Khong co duong nay" }, 404);
} catch (e) {
// Không bao giờ để lỗi lọt ra thành trang lỗi trắng của Cloudflare
console.log("Loi API:", p, e.message);
return json({ loi: "Loi he thong" }, 500);
}
}
// ---- Trang web tĩnh ----
return env.ASSETS.fetch(request);
},
};
function json(obj, status = 200) {
return new Response(JSON.stringify(obj), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
Bốn điểm trong 25 dòng này, đều là kinh nghiệm trả giá:
try/catchbọc cả nhánh API. Không có nó, một lỗi nhỏ trả về trang lỗi của Cloudflare và người dùng thấy màn hình lạ, còn bạn không biết lỗi gì.- Trả lỗi dạng JSON có cấu trúc, để phía trình duyệt hiện được thông báo tử tế.
charset=utf-8trong header. Thiếu là chữ tiếng Việt vỡ ở một số trình duyệt.- Thông báo lỗi trả cho người dùng không tiết lộ chi tiết nội bộ — chi tiết đi vào
console.log, bạn xem bằngwrangler tail.
Phục vụ web tĩnh từ Worker
Đây là cách dựng app thật: một Worker vừa trả HTML vừa có API. Trong wrangler.toml:
name = "app-don-hang"
main = "src/index.js"
compatibility_date = "2026-01-01"
[assets]
directory = "./public"
binding = "ASSETS"
Đặt index.html vào public/. Giờ https://app.../ trả trang web, https://app.../api/... chạy mã của bạn. Một tên miền, một project, không có chuyện CORS.
Đây là bẫy đã mất nhiều giờ. Khi một đường dẫn khớp với file trong public/, Cloudflare trả file đó ngay tại biên, không gọi hàm fetch của bạn.
Hậu quả thực tế:
- Đặt header (cache, bảo mật) trong mã Worker là vô nghĩa với các file tĩnh. Phải đặt trong public/_headers.
- Không chèn thêm được gì vào HTML bằng mã Worker theo cách thông thường.
- Nếu public/ tình cờ có file tên trùng một đường API, API của bạn bị file đó nuốt — rất khó đoán.
Luật: file tĩnh thì cấu hình bằng _headers; logic thì để dưới tiền tố /api/ và đừng để public/ có thứ gì tên giống đường API.
Xem log: wrangler tail
Đây là "Console" của phía máy chủ. Mở một cửa sổ riêng, để nó chạy trong lúc bạn bấm thử app:
npx wrangler tail --format=pretty
Mỗi request hiện một dòng, kèm mọi console.log và lỗi. Không có công cụ này thì phía máy chủ là một cái hộp đen.
Nhìn log thấy một request wallTime: 23000ms thì rất dễ tưởng app chậm thảm họa. Thực tế, wallTime cộng cả thời gian của việc chạy nền ctx.waitUntil — người dùng đã nhận kết quả từ giây đầu, phần còn lại là việc hậu kỳ.
Muốn biết người dùng chờ bao lâu thì đo từ phía người dùng: tab Network của trình duyệt, hoặc curl -w "%{time_total}".
Biến toàn cục và nhiều phiên bản chạy song song
Mã này trông đúng và là một cái bẫy kinh điển:
let soLuot = 0; // SAI nếu dùng để đếm thật
export default {
async fetch(request, env) {
soLuot++;
return new Response("Luot thu " + soLuot);
},
};
Cloudflare chạy mã của bạn trong nhiều bản độc lập, ở nhiều điểm mạng, và dọn chúng đi khi rảnh. Mỗi bản có soLuot riêng, bắt đầu từ 0. Con số bạn thấy là vô nghĩa.
Hệ quả nghiêm trọng hơn con số sai: mọi cơ chế "chỉ chạy mỗi 10 phút một lần" dựa trên biến toàn cục đều vỡ. Lúc tải cao, Cloudflare bung thêm bản mới, mỗi bản tưởng mình là lần đầu → việc định kỳ chạy dày hơn hàng chục lần. Chuyện này đã gây treo app thật.
Luật: cần nhớ gì giữa các lượt thì lưu vào D1 / KV / Durable Object, đừng tin biến trong bộ nhớ.
Tôi đã vận hành cả hai. So sánh thật, không phải quảng cáo:
| VPS 5 USD/tháng | Workers gói free | |
|---|---|---|
| Tiền/tháng | ~130 nghìn | 0 |
| Việc phải làm hằng tháng | vá bảo mật, xem ổ cứng, gia hạn chứng chỉ | không |
| Hỏng lúc 2 giờ sáng | bạn dậy xử lý | Cloudflare xử lý |
| Người dùng ở Thanh Hóa | ~200ms (máy ở Singapore) | ~20ms (điểm trong nước) |
| Việc chạy quá 30 giây | được | không — phải chia nhỏ |
| Ghi file lên ổ đĩa | được | không |
Hai dòng cuối là cái giá thật, và nó buộc bạn thiết kế khác đi. Nhưng với 90% app doanh nghiệp nhỏ — nhập liệu, xem báo cáo, duyệt, thông báo — bạn không bao giờ chạm tới hai giới hạn đó.
Điều tôi đánh giá cao nhất lại là thứ không có trong bảng: không có cuộc gọi lúc nửa đêm. Với người làm một mình, đó là yếu tố quyết định có trụ được lâu dài hay không.
Giới hạn gói miễn phí — con số cần biết
| Hạng mục | Gói free | Ý nghĩa thực tế |
|---|---|---|
| Số request | 100.000/ngày | App nội bộ 200 người dùng thì rất khó chạm |
| Thời gian CPU mỗi request | 10 ms (free) | Đây mới là giới hạn hay chạm, không phải số request |
| Thời gian chờ (I/O) | không tính vào CPU | Chờ cơ sở dữ liệu, chờ API ngoài thì không tốn CPU |
| Cỡ mã sau nén | 3 MB (free) | Thừa cho app một file |
Điểm hay bị hiểu sai: thời gian chờ không tính là CPU. Một request chờ D1 trả về 2 giây vẫn có thể chỉ dùng 3 ms CPU. Cái tốn CPU là tính toán: vòng lặp lớn, xử lý ảnh, mã hóa.
Hạn mức và giá đổi theo thời gian — kiểm lại tại developers.cloudflare.com/workers/platform/limits trước khi dựa vào con số nào.
Dựng một Cloudflare Worker trong thư mục này:
- Phục vụ web tĩnh từ ./public qua binding ASSETS.
- API dưới tiền tố /api/, chỉ 2 đường để bắt đầu:
GET /api/don-hang → trả danh sách (tạm thời trả mảng gán cứng)
POST /api/don-hang → nhận {ten, sdt, mon, soLuong}, kiểm dữ liệu,
tạm thời chỉ console.log rồi trả {ok:true}
- Bọc try/catch cho cả nhánh API, lỗi trả JSON {loi:"..."}, không để lộ
chi tiết nội bộ cho người dùng.
- Mọi response JSON có charset=utf-8.
- Chưa dùng cơ sở dữ liệu, chưa đăng nhập — sẽ thêm ở bước sau.
- wrangler.toml đầy đủ.
Xong rồi chạy `npx wrangler dev` và tự kiểm bằng curl cả hai đường,
báo cho tôi kết quả thật.
Câu cuối lại là câu quan trọng nhất: bắt nó tự chứng minh. "Tôi đã tạo xong" không có giá trị; curl trả về {"ok":true} thì có.
Tôi mất một tuần hiểu sai vì cái tên. "Không máy chủ" — vậy mã chạy ở đâu? Trên không khí?
Dịch cho người như tôi: vẫn có máy chủ, nhưng không phải máy của bạn và bạn không phải nghĩ về nó. Như đi xe khách thay vì mua xe: vẫn có xe, nhưng bạn không đổi dầu.
Thứ hai làm tôi vướng: "mỗi request là một lượt chạy riêng, xong là quên". Tôi cứ muốn "lưu tạm vào biến cho nhanh". Phải tự nhắc mình: muốn nhớ thì phải ghi ra ngoài — và bài sau là chỗ ghi.
- Đủ: mô hình Workers, ba tham số, định tuyến có xử lý lỗi, phục vụ tĩnh,
wrangler tail, bẫy biến toàn cục, bẫy static assets, hạn mức. - Chưa làm: chưa lưu được gì — bài sau (D1).
- Rủi ro còn lại: app hiện tại chưa có xác thực, mọi đường
/api/ai cũng gọi được. Đừng đưa dữ liệu thật vào trước khi học Đăng nhập.
- Dựng Worker theo đề ở trên, deploy, gọi thử bằng
curlcả GET và POST. - Mở
wrangler tailtrong cửa sổ thứ hai, bấm thử app, xem log chạy. - Cố ý gây lỗi: trong một đường API, gọi một biến không tồn tại. Kiểm rằng người dùng nhận
{"loi":"Loi he thong"}chứ không phải trang lỗi trắng, và chi tiết lỗi hiện trongwrangler tail. Đây là phép thử rằng lớptry/catchcủa bạn thật sự hoạt động.