GĐ17 — Cloudflare cho Backend: Workers, R2, D1, KV, Queues, Durable Objects, DNS/CDN

Giai đoạn này đưa một API nhỏ lên Cloudflare, nối compute với dữ liệu và file, xử lý việc nền, rồi quan sát request ở production. Mục tiêu là hiểu cách chọn đúng sản phẩm và ranh giới của chúng — không phải học thuộc toàn bộ danh mục Cloudflare.

Kiểm chứng ngày 2026-10-05: giới hạn Workers đọc từ Workers limits, giới hạn D1 từ D1 limits, cờ nodejs_compat từ Node.js compatibility. Đã chạy trong thư mục tạm bằng wrangler 4.147.0 và TypeScript 7.0.2 (tsc --strict sạch): Durable Object, Cron Trigger, batch() của D1, kiểm kích thước R2 và tạo instance Workflow trên wrangler dev local, cùng wrangler deploy --dry-run cho cấu hình KV và env.staging. Chưa chạy trên tài khoản Cloudflare thật: deploy, wrangler tail, versions và rollback. Con số giới hạn đổi theo thời gian, nên luôn đối chiếu lại trang tài liệu trước khi dựa vào chúng.


1. Sau giai đoạn này, bạn làm được gì?#

  • Giải thích request đi từ DNS/proxy đến cache, Worker và binding dữ liệu như thế nào.
  • Viết được một Worker API bằng TypeScript và chạy thử bằng Wrangler.
  • Chọn đúng nơi lưu: R2 cho object/file, D1 cho dữ liệu quan hệ, KV cho dữ liệu đọc nhiều và chấp nhận eventual consistency.
  • Đưa công việc khỏi request bằng Queues, có retry, DLQ và xử lý lặp an toàn.
  • Nhận ra lúc nào cần Durable Objects để điều phối một trạng thái dùng chung.
  • Phân biệt quyền tài khoản Cloudflare, API token, secret của ứng dụng và binding của Worker.
  • Đọc log/trace, giới hạn cache đúng cách, triển khai staging rồi mới production và dọn tài nguyên lab.

Giai đoạn trước đã giúp bạn hiểu Docker, CI/CD và AWS. Giai đoạn này so sánh các ý tưởng tương đương để dễ định hướng, nhưng không coi sản phẩm hai nền tảng là thay thế 1:1.

Nhu cầuCloudflare thường dùngGần với AWS ở khía cạnhKhác biệt cần nhớ
Chạy API / request handlerWorkersLambdaWorker chạy trên runtime web/edge; không phải máy Linux luôn bật như EC2
File và objectR2S3R2 có API S3-compatible và binding riêng; quyền truy cập công khai cần cấu hình có chủ đích
SQLD1RDS ở góc nhìn “database có quản lý”D1 là SQLite serverless, không phải PostgreSQL/RDS thu nhỏ tương đương
Đọc key-value rất nhiềuWorkers KVMột phần use case DynamoDB/cacheKV eventually consistent; không dùng làm nguồn sự thật cần transaction
Việc nềnQueuesSQS ở khái niệm hàng đợiTin nhắn có thể giao lặp; consumer phải idempotent
Trạng thái cần điều phốiDurable ObjectsMột phần use case stateful service/lockMỗi object là một điểm điều phối có tên và storage riêng, mạnh về một nhóm trạng thái cụ thể
DNS, proxy, phân phối nội dungDNS, CDN/Cache, RulesRoute 53 + CloudFront ở một số lớpCache phụ thuộc proxy, header và rule; DNS không tự làm ứng dụng an toàn

Cloudflare không cung cấp một bản sao VPC theo cách AWS tổ chức VPC/subnet/security group. Nếu cần Worker truy cập database riêng tư đang nằm trong AWS/on-prem, xem Hyperdrive + Workers VPC/Tunnel; đó là kết nối tới mạng/database hiện có, không phải thay VPC AWS.


2. Mô hình request: edge, Worker, binding#

Với domain được proxy qua Cloudflare, request thường đi theo các bước sau:

textReady
Browser  → DNS trả về địa chỉ anycast của Cloudflare  → Cloudflare edge: TLS, rules, WAF và cache phù hợp  → Worker nếu route/ứng dụng yêu cầu chạy code  → binding tới D1 / R2 / KV / Queue / Durable Object  → Response trở lại client; cache chỉ áp dụng nếu chính sách cho phép

Binding là cách Worker truy cập dịch vụ Cloudflare bằng một biến runtime như env.DB hoặc env.FILES. Binding giúp ứng dụng gọi resource qua API đã định nghĩa, không cần tự gọi Cloudflare REST API hay nhúng credential quản trị vào code. Tên biến binding do bạn đặt trong cấu hình Wrangler.

Static asset có thể được trả trực tiếp mà không gọi Worker. Với Workers Static Assets, request khớp file tĩnh thường được phục vụ trước; các route API hoặc request không khớp mới đi vào Worker theo cấu hình. Đây là cách ghép site tĩnh và API trong cùng ứng dụng.

Domain DNS-only chỉ dùng DNS để phân giải tên; request không đi qua Cloudflare proxy nên không tự được CDN/cache/WAF của Cloudflare xử lý. Muốn áp dụng tính năng edge cho host đó, record cần được proxy và sản phẩm/rule tương ứng phải được bật.

Tài liệu: Workers overview, bindings, Static Assets.


3. Workers — compute cho request và API#

Worker là gì?#

Worker là code xử lý request/event chạy trên runtime của Cloudflare. Với API, entry point thường nhận Request, env chứa binding/biến môi trường, rồi trả Response. Nhiều API web tiêu chuẩn như fetch, Request, Response, URL, crypto hoạt động tự nhiên.

Worker không phải Express server chạy mãi trên một VM. Không thiết kế dựa trên process sống lâu, file cục bộ bền vững, socket nghe cổng hoặc cache trong bộ nhớ dùng chung cho mọi request. Dữ liệu cần tồn tại phải đặt trong binding/store phù hợp. Nếu code Node cần module hoặc API runtime cụ thể, kiểm tra khả năng tương thích Node. Với compatibility_date từ 2026-08-04 trở đi, Workers bật sẵn nodejs_compat; với ngày từ 2024-09-23 đến 2026-08-03 phải tự thêm "compatibility_flags": ["nodejs_compat"] (tài liệu, đọc 2026-10-05). Tương thích Node không biến Worker thành máy chủ Node Linux đầy đủ.

Worker API tối thiểu#

Ví dụ sau có route health check và route tạo ghi chú. Nó minh họa parse URL, kiểm tra method/input và bind tham số SQL; chưa bao gồm đăng nhập hay authorization.

typescriptReady
interface Env {  DB: D1Database}export default {  async fetch(request: Request, env: Env): Promise<Response> {    const url = new URL(request.url)    if (request.method === 'GET' && url.pathname === '/health') {      return Response.json({ ok: true })    }    if (request.method === 'POST' && url.pathname === '/api/notes') {      let body: unknown      try {        body = await request.json()      } catch {        return Response.json({ error: 'Invalid JSON' }, { status: 400 })      }      if (        typeof body !== 'object' ||        body === null ||        !('title' in body) ||        typeof body.title !== 'string' ||        body.title.trim().length === 0 ||        body.title.length > 200      ) {        return Response.json({ error: 'title is required' }, { status: 400 })      }      const id = crypto.randomUUID()      const title = body.title.trim()      await env.DB.prepare(        'INSERT INTO notes (id, title) VALUES (?, ?)',      ).bind(id, title).run()      return Response.json({ id, title }, { status: 201 })    }    return Response.json({ error: 'Not found' }, { status: 404 })  },} satisfies ExportedHandler<Env>

Vì sao dùng prepared statement? ? là chỗ giữ tham số, .bind(...) đưa dữ liệu riêng vào query. Đừng ghép input thành chuỗi SQL. Trong app thật, thêm authentication và authorization trước khi đọc/ghi tài nguyên của user; validation không thay thế phân quyền.

Wrangler và môi trường#

Wrangler là CLI phát triển/deploy Worker. Cấu hình mới nên theo format wrangler.jsonc và được xem là source of truth cho tên Worker, compatibility date, bindings, observability và môi trường.

bashReady
npm create cloudflare@latest -- cloudflare-notes-apicd cloudflare-notes-apinpx wrangler dev

Chọn template Worker TypeScript để bài lab bắt đầu từ API đơn giản. wrangler dev chạy môi trường local; trước khi dùng binding remote, hãy kiểm tra cấu hình — local code vẫn có thể được nối tới resource thật nếu bật remote binding.

Tách staging và production bằng Wrangler environments hoặc project/deployment riêng. Mỗi môi trường phải gắn đúng database, bucket, queue, route và secret. Không để staging dùng nhầm production database. Sau khi thêm binding, chạy npx wrangler types để sinh kiểu TypeScript theo cấu hình thật.

Giới hạn cần biết trước khi thiết kế#

Giới hạnWorkers FreeWorkers Paid
Request mỗi ngày100.000không giới hạn
CPU time mỗi request HTTP10 msmặc định 30 giây, tối đa 5 phút
Bộ nhớ128 MB128 MB
Subrequest mỗi request5010.000
Số Worker mỗi tài khoản100500
Kích thước bundle (chưa nén)64 MiB64 MiB

Nguồn: Workers limits, đọc 2026-10-05. Hai điểm hay hiểu nhầm: giới hạn là CPU time, không phải thời gian chờ I/O (chờ fetch, KV hay truy vấn database không tính vào CPU time), và ctx.waitUntil() chỉ kéo dài việc thêm tối đa 30 giây sau khi response đã gửi. Việc nặng hơn thế đi qua Queues hoặc Workflows (cuối mục 9).

Cấu hình KV, môi trường staging và Durable Object#

Binding và biến môi trường không kế thừa từ cấu hình gốc vào env.<tên>: mỗi môi trường phải khai báo lại, nếu không staging có thể thiếu binding hoặc dùng nhầm tài nguyên (Environments, đọc 2026-10-05). Mẫu rút gọn:

jsonReady
{  "name": "private-docs-api",  "main": "src/index.ts",  "compatibility_date": "2026-10-01",  "kv_namespaces": [{ "binding": "CACHE", "id": "<id KV production>" }],  "durable_objects": {    "bindings": [{ "name": "RATE_LIMITER", "class_name": "RateLimiter" }]  },  "exports": {    "RateLimiter": { "type": "durable-object", "storage": "sqlite" }  },  "env": {    "staging": {      "kv_namespaces": [{ "binding": "CACHE", "id": "<id KV staging>" }],      "durable_objects": {        "bindings": [{ "name": "RATE_LIMITER", "class_name": "RateLimiter" }]      },      "vars": { "APP_ENV": "staging" }    }  }}

Đã chạy npx wrangler deploy --dry-run --config <file> --env staging với id giả: binding CACHE trỏ đúng id staging và APP_ENV là "staging"; không có --env thì CACHE trỏ id gốc. compatibility_date này từ 2026-08-04 trở đi nên không cần cờ nodejs_compat. Deploy staging dùng npx wrangler deploy --env staging; Worker khi đó tên private-docs-api-staging. Bài lab ở mục 10 dựng đủ D1, R2 và queue riêng cho staging.


4. Workers Static Assets, Pages, DNS và CDN#

Site tĩnh kết hợp API#

Workers Static Assets cho phép deploy static files cùng Worker trong một lần. Ví dụ site có /index.html, /assets/app.js và /api/*: file khớp asset có thể được phục vụ từ CDN; route API chạy Worker.

Cloudflare Pages cũng có thể host site và Pages Functions. Backend Guide hiện đang chạy trên Pages, vì vậy dùng repo này làm ví dụ đọc cấu hình/build/deploy là hợp lý; giai đoạn học không yêu cầu chuyển dự án hiện tại sang Workers. Với project mới, trang Pages của Cloudflare khuyến nghị bắt đầu bằng Workers ("Start new projects with Workers"), còn Pages không bị gỡ; project Pages có sẵn chuyển sang Workers theo hướng dẫn migrate from Pages (trang Pages, đọc 2026-10-05). Xem thêm Workers Static Assets và framework adapter đang dùng trước khi chọn cách deploy.

Cache đúng nội dung#

Cache giảm request về origin và thời gian phản hồi, nhưng response chứa dữ liệu theo user không được cache như asset công khai. Với dữ liệu cá nhân, đặt chính sách rõ như Cache-Control: private, no-store; kiểm tra Set-Cookie, Authorization, cache key và rule trước khi bật “cache everything”. Cloudflare không mặc định cache HTML/JSON trong nhiều cấu hình CDN, nhưng Cache Rules có thể thay đổi hành vi đó.

Ví dụ phân loại:

URLCách xử lý
/assets/app.abc123.jsAsset có hash, cache dài hạn; khi nội dung đổi thì tên file đổi
/api/catalogChỉ cache nếu response thực sự dùng chung, có TTL và cách purge/đổi version
/api/meTheo user; thường trả private, no-store, không dùng cache chung
/api/documents/:idKiểm tra quyền user trước khi đọc object; không suy ra quyền từ URL/key

Tài liệu: Cloudflare Cache, default cache behavior, Cache Rules, Static Assets routing.


5. R2 — lưu object và file#

Khi dùng R2?#

Dùng R2 cho file do người dùng tải lên, ảnh, export, bản sao lưu hoặc artifact lớn. Đây là object storage: mỗi object có key, metadata và bytes; nó không phải filesystem để ứng dụng tùy ý rename/thực hiện transaction thư mục.

Worker dùng R2 binding để thao tác trực tiếp. Bucket private là mặc định tốt cho file user; chỉ mở đường dẫn tải sau khi API xác thực quyền, hoặc phát hành URL có scope/thời hạn phù hợp. Không trả objectKey công khai rồi cho rằng nó bí mật là đủ bảo vệ file.

Ví dụ upload qua Worker#

typescriptReady
interface Env {  FILES: R2Bucket}const MAX_BYTES = 10 * 1024 * 1024async function uploadFile(request: Request, env: Env, userId: string) {  if (!request.body) {    return Response.json({ error: 'Empty body' }, { status: 400 })  }  // chặn trước khi ghi: R2 không tự giới hạn theo nhu cầu của app bạn  const declared = Number(request.headers.get('content-length'))  if (!Number.isInteger(declared) || declared <= 0) {    return Response.json({ error: 'length required' }, { status: 411 })  }  if (declared > MAX_BYTES) {    return Response.json({ error: 'too large' }, { status: 413 })  }  const key = `users/${userId}/${crypto.randomUUID()}`  const object = await env.FILES.put(key, request.body, {    httpMetadata: { contentType: 'application/octet-stream' },    customMetadata: { ownerId: userId },  })  return Response.json({ key, etag: object?.etag }, { status: 201 })}

Đã chạy đoạn kiểm kích thước này trên wrangler dev local: body nhỏ trả 201, body 11 MB trả 413, request không có Content-Length (chunked) trả 411. userId trong ví dụ phải lấy từ identity đã xác thực, không lấy trực tiếp từ body/path do client gửi. Ngoài kích thước, kiểm tra loại file theo allowlist/magic bytes phù hợp, áp quota, chống upload lặp và không ghi file nhạy cảm vào log. Nếu cần file lớn, hỗ trợ multipart/resumable flow thay vì buffer toàn bộ file vào RAM.

Metadata và vòng đời file#

Thông tin cần query như owner, trạng thái quét, thời gian tạo và liên kết domain nên nằm trong D1/SQL; bytes nằm trong R2. Ghi file và ghi DB không phải một transaction chung. Thiết kế compensation/cleanup: nếu upload thành công nhưng insert DB lỗi thì xóa object hoặc để job đối soát tìm orphan; nếu DB record được tạo nhưng object bị xóa, đánh dấu lỗi và phục hồi/xóa metadata theo quy trình.

Worker download phải kiểm tra quyền trên metadata trước khi get(key). Trả Content-Type đã kiểm tra, X-Content-Type-Options: nosniff, và cân nhắc Content-Disposition: attachment cho file user. Nếu cần giao file trực tiếp qua S3-compatible API, tìm hiểu riêng credentials/scope và presigned URL; API binding trong Worker và S3-compatible API không hoàn toàn giống nhau.

Tài liệu: Use R2 from Workers, R2 Workers API reference.


6. D1 và Hyperdrive — chọn database theo workload#

D1: SQL qua binding#

D1 là database SQL serverless của Cloudflare, dựa trên SQLite và được truy cập từ Worker qua binding. Đây là cách dễ bắt đầu cho ứng dụng/lab chạy gần Worker. D1 có migration, prepared query và transaction semantics riêng; đọc giới hạn/consistency/concurrency trước khi chọn cho workload lớn hoặc nhiều ghi đồng thời.

Ví dụ migration migrations/0001_create_notes.sql:

textReady
CREATE TABLE notes (  id TEXT PRIMARY KEY,  title TEXT NOT NULL CHECK (length(title) BETWEEN 1 AND 200),  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);CREATE INDEX notes_created_at_idx ON notes (created_at DESC);

Cấu hình Worker gắn D1 bằng tên binding, tên database và ID database do Cloudflare tạo. Trong code, dùng env.DB.prepare(...).bind(...).run() như ví dụ ở phần Workers. Tạo migration để thay đổi schema có thể review và chạy lặp theo môi trường; không sửa production bằng câu lệnh ad-hoc rồi quên đưa thay đổi vào source control.

bashReady
npx wrangler d1 create cloudflare-notesnpx wrangler d1 migrations apply cloudflare-notes --localnpx wrangler dev

Chỉ chạy migration --remote sau khi xác nhận binding, tài khoản và database đích. Tên database trong lệnh phải đúng với cấu hình Wrangler.

D1: giới hạn và transaction bằng batch#

Giới hạn đáng nhớ (D1 limits, đọc 2026-10-05):

Giới hạnFreePaid
Dung lượng mỗi database500 MB10 GB
Query mỗi lần invoke Worker501.000
Tham số bind mỗi query100100
Độ dài câu SQL100 KB100 KB
Kích thước một dòng, chuỗi hoặc BLOB2 MB2 MB

100 tham số bind nghĩa là INSERT nhiều dòng một lần với ? cho từng cột sẽ vượt giới hạn khi số dòng nhân số cột quá 100: chia nhóm hoặc dùng batch(). Vì trần mỗi database là 10 GB, hãy cân nhắc chia dữ liệu thành nhiều database nhỏ (ví dụ mỗi tenant một database) khi dự đoán vượt trần, thay vì dồn vào một database.

Khi cần nhiều thay đổi nguyên tử, dùng batch(): tài liệu mô tả các câu trong batch là một transaction SQL, thực thi tuần tự và không song song, câu nào lỗi thì cả chuỗi bị huỷ và rollback (D1 Database API, đọc 2026-10-05).

typescriptReady
await env.DB.batch([  env.DB.prepare('INSERT INTO docs (id) VALUES (?)').bind(id),  env.DB.prepare('INSERT INTO quota (owner, used) VALUES (?, ?)').bind(userId, 2),])

Đã chạy trên wrangler dev local với bảng quota có CHECK (used <= 1): câu thứ hai vi phạm ràng buộc, batch ném lỗi CHECK constraint failed và truy vấn lại cho thấy dòng của câu thứ nhất không còn (0 dòng). Dùng exec() cho việc bảo trì và migration một lần, không cho luồng nghiệp vụ: tài liệu nói rõ khi lỗi nó chỉ dừng, không nói về rollback. Việc tự mở transaction bằng BEGIN/COMMIT qua D1: chưa xác minh trong tài liệu, vì vậy đừng dùng; batch() là đường được ghi lại.

Hyperdrive: giữ Postgres/MySQL hiện có#

Nếu dự án đã dùng PostgreSQL/MySQL — ví dụ RDS ở GĐ16 — không cần chuyển database sang D1 để chạy Worker. Hyperdrive cho Worker kết nối tới database hiện có, quản lý pool gần database và tối ưu kết nối từ mạng edge. Nó giữ vai trò cầu nối/tối ưu truy cập, không biến RDS thành D1 và cũng không xóa nhu cầu cấu hình firewall, credential, backup, schema migration hay connection budget.

Chọn theo câu hỏi:

  1. Schema và query có phù hợp SQLite, workload gọn và muốn dùng database native của Workers? Thử D1.
  2. Hệ thống đã có Postgres/MySQL, cần ORM/driver hoặc migration đang dùng? Giữ DB và cân nhắc Hyperdrive.
  3. Có yêu cầu transaction, extension, query pattern, backup/restore hoặc workload vượt giới hạn dịch vụ? Đo workload và so sánh database managed trước khi quyết định.

Tài liệu: D1 getting started, D1 Wrangler commands, Hyperdrive overview, kết nối database trong Workers.


7. KV và Durable Objects — key-value phân tán hay điều phối trạng thái?#

Workers KV: đọc nhiều, ghi tương đối ít#

KV lưu cặp key-value và cache dữ liệu tại edge. Hợp với cấu hình, feature flags, nội dung đọc nhiều, lookup gần như tĩnh hoặc cache có TTL. Khi key được ghi ở một vùng, thay đổi có thể mất thời gian mới nhìn thấy ở vùng khác: KV eventually consistent. Vì vậy không dùng nó làm nguồn sự thật cho số dư tiền, khóa duy nhất, revocation phải tức thời hay cập nhật cần read-your-writes toàn cầu.

Ví dụ cache cấu hình công khai:

typescriptReady
const value = await env.CACHE.get('catalog:v3', 'json')if (value) return Response.json(value)const fresh = await loadPublicCatalog()await env.CACHE.put('catalog:v3', JSON.stringify(fresh), {  expirationTtl: 300,})return Response.json(fresh)

Cache này chấp nhận stale tối đa một khoảng ngắn. Tăng version key khi đổi format (catalog:v4), đặt TTL hợp với độ tươi, và có chiến lược fallback nếu cache miss hoặc binding lỗi.

Durable Objects: một điểm xử lý cho một nhóm trạng thái#

Durable Object có tên định danh duy nhất, storage gắn với object và xử lý tuần tự các yêu cầu tới cùng instance. Dùng khi nhiều request/client phải phối hợp trên cùng một trạng thái: phòng chat, phiên cộng tác, bộ đếm giới hạn theo khóa, presence hoặc lock có vòng đời rõ.

Ví dụ, tạo một object theo roomId; mọi message của phòng đó được đưa về cùng object để cấp thứ tự và broadcast. Các phòng khác nhau vẫn phân tán độc lập. Lưu dữ liệu cần tồn tại trong storage bền, đừng chỉ dựa vào biến RAM vì object có thể idle và khởi tạo lại.

Không dùng Durable Object mặc định cho mọi cache hoặc DB. Mỗi object thường là một vùng điều phối riêng; thiết kế khóa kém (ví dụ một object toàn hệ thống cho mọi tenant) có thể tạo hotspot. Chọn key để chia tải và giới hạn tác động nếu một object chậm/hỏng.

Durable Object tối thiểu: bộ đếm theo khóa#

Bộ đếm rate limit theo userId: mỗi khóa là một Durable Object riêng, nên hai request cùng khóa được xử lý lần lượt và con số không bị đua. Cấu hình ở phần "Cấu hình KV, môi trường staging và Durable Object" (mục 3): class nằm ở exports kèm "storage": "sqlite", còn durable_objects.bindings đặt tên biến env.RATE_LIMITER.

typescriptReady
import { DurableObject } from 'cloudflare:workers'export class RateLimiter extends DurableObject<Env> {  constructor(ctx: DurableObjectState, env: Env) {    super(ctx, env)    ctx.blockConcurrencyWhile(async () => {      ctx.storage.sql.exec(        'CREATE TABLE IF NOT EXISTS hits (win INTEGER PRIMARY KEY, n INTEGER NOT NULL)',      )    })  }  async hit(limit: number, windowSeconds: number) {    const win = Math.floor(Date.now() / 1000 / windowSeconds)    this.ctx.storage.sql.exec('DELETE FROM hits WHERE win < ?', win)    const row = this.ctx.storage.sql      .exec<{ n: number }>(        'INSERT INTO hits (win, n) VALUES (?, 1) ' +          'ON CONFLICT(win) DO UPDATE SET n = n + 1 RETURNING n',        win,      )      .one()    return { allowed: row.n <= limit, remaining: Math.max(0, limit - row.n) }  }}// trong fetch handler của Worker (class phải được export từ file main)const stub = env.RATE_LIMITER.getByName(userId)const { allowed } = await stub.hit(60, 60)if (!allowed) return Response.json({ error: 'rate limited' }, { status: 429 })

Cú pháp class, exports và getByName theo Durable Objects: get started (đọc 2026-10-05). Đã chạy trên wrangler dev local: với limit 3 trong cửa sổ 60 giây, ba lần gọi đầu cùng khóa trả allowed: true (còn 2, 1, 0), lần thứ tư trả allowed: false, còn khóa khác vẫn allowed: true. Chạy npx wrangler types sau khi đã có class để env.RATE_LIMITER có kiểu DurableObjectNamespace<RateLimiter>; chạy trước thì stub.hit báo lỗi kiểu. Một khóa là một object nên chọn khóa theo user hay phòng, đừng dùng một khóa toàn hệ thống.

Tài liệu: KV consistency và use cases, Durable Objects.


8. Queues — xử lý việc nền và giao nhận lặp an toàn#

Đưa việc chậm hoặc có thể retry khỏi request chính. Ví dụ: sau khi file vào R2, Worker ghi metadata và gửi message scan-upload; consumer quét virus/trích metadata rồi cập nhật D1. Request upload trả về sớm với trạng thái processing.

Queues cung cấp batching, retry/delay và DLQ. Delivery mặc định là at-least-once: một message có thể tới consumer nhiều hơn một lần. Vì thế:

  • Payload nhỏ, có jobId, schemaVersion và chỉ chứa dữ liệu cần thiết; không nhét secret/PII dư thừa.
  • Consumer ghi kết quả bằng idempotency key hoặc unique constraint trong DB.
  • Phân loại lỗi tạm thời (retry) và lỗi vĩnh viễn (đưa DLQ/đánh dấu fail).
  • Theo dõi độ sâu queue, tuổi message cũ nhất, retry và DLQ; có quy trình replay sau khi sửa lỗi.
  • Đặt timeout và batch size theo thời gian xử lý/giới hạn dependency, không chỉ chọn số lớn.

Ví dụ consumer ở mức minh họa:

typescriptReady
interface UploadJob {  jobId: string  objectKey: string  schemaVersion: 1}interface QueueEnv {  DB: D1Database}export default {  async queue(batch: MessageBatch<UploadJob>, env: QueueEnv) {    for (const message of batch.messages) {      try {        const job = message.body        await processUploadOnce(env, job) // DB unique key jobId chống xử lý lặp        message.ack()      } catch (error) {        console.error({ event: 'upload_job_failed', error })        message.retry()      }    }  },} satisfies ExportedHandler<QueueEnv>

Trong code production, không log raw body hoặc token. processUploadOnce phải thiết kế sao cho retry sau crash không gửi email, tính phí hoặc tạo record trùng. Việc gọi API ngoài cũng nên truyền idempotency key nếu provider hỗ trợ.

Tài liệu: Queues overview, delivery guarantees, how Queues works.


9. Quyền, secrets, log và chi phí#

Ba loại quyền không được nhầm#

  1. Account/API token: quyền của người hoặc CI khi quản trị Cloudflare. Tạo token theo đúng account, resource và action tối thiểu; tách token CI khỏi token cá nhân, đặt hạn dùng và có quy trình thu hồi.
  2. Worker binding: quyền runtime tới đúng dịch vụ mà Worker được gắn, như bucket R2, database D1 hoặc namespace KV. Không đưa account API token vào request handler để truy cập dịch vụ nội bộ.
  3. Secret ứng dụng: credential tới dịch vụ ngoài, ví dụ webhook signing key. Lưu bằng secret mechanism của Workers/Wrangler, không commit .dev.vars, không in secret vào log.

Observability#

Ghi log có cấu trúc để lọc theo route, event, request id và kết quả. Ví dụ:

typescriptReady
console.log({  event: 'document_upload_completed',  requestId,  documentId,  bytes: object.size,})

Không ghi access token, URL có chữ ký, nội dung file hoặc dữ liệu cá nhân không cần thiết. Bật Workers Logs/observability theo cấu hình, xem invocation/error logs, kiểm tra sampling và retention. Với nhiều request, đo tỉ lệ lỗi và latency theo route; “deploy thành công” chưa chứng minh API hoạt động.

Chi phí và cleanup#

Tính giá hiện tại trong Cloudflare pricing/calculator trước khi tạo tài nguyên thật. Chi phí có thể đến từ request compute, storage, thao tác đọc/ghi, log ingestion, database, queue và dịch vụ add-on. Đặt budget/alert nếu tài khoản hỗ trợ, dùng resource lab riêng, giới hạn upload, log sampling phù hợp và dọn resource không dùng. Free plan/limit thay đổi theo thời gian — không dùng con số đọc từ bài blog cũ làm cam kết.

Tài liệu: Wrangler secrets, environments, Workers Logs, Workers best practices, pricing.

Workflows, Cron Triggers, tail log và rollback#

Cron Trigger chạy hàm scheduled theo lịch, tính theo UTC (Cron Triggers, đọc 2026-10-05). Khai báo "triggers": { "crons": ["0 3 * * *"] } trong wrangler.jsonc, thêm handler cạnh fetch:

typescriptReady
export default {  async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext) {    ctx.waitUntil(cleanupExpiredUploads(env))   // việc dọn dẹp, nên idempotent  },} satisfies ExportedHandler<Env>

Chạy thử local bằng curl "http://localhost:8787/cdn-cgi/local/scheduled" khi wrangler dev đang chạy (đã chạy: handler được gọi; không truyền tham số cron thì controller.cron rỗng). Viết hàm idempotent để một lần chạy lại không nhân đôi kết quả.

Workflows dành cho chuỗi bước dài có retry và chờ (onboarding gửi email sau một ngày, quy trình duyệt). Mỗi step.do lưu kết quả, nên khi lỗi hoặc restart, workflow chạy tiếp từ bước đã xong thay vì làm lại từ đầu (Workflows: get started, đọc 2026-10-05):

typescriptReady
import { WorkflowEntrypoint } from 'cloudflare:workers'import type { WorkflowEvent, WorkflowStep } from 'cloudflare:workers'export class Onboarding extends WorkflowEntrypoint<Env, { userId: string }> {  async run(event: WorkflowEvent<{ userId: string }>, step: WorkflowStep) {    const profile = await step.do('create profile', async () => ({ userId: event.payload.userId }))    await step.sleep('wait before tips', '1 day')    await step.do('send tips email', { retries: { limit: 3, delay: '10 seconds', backoff: 'exponential' } }, async () => {      console.log({ event: 'tips_email', userId: profile.userId })    })  }}

Binding khai báo "workflows": [{ "name": "onboarding", "binding": "ONBOARDING", "class_name": "Onboarding" }], và Worker tạo instance bằng env.ONBOARDING.create({ params: { userId } }). Đã chạy tsc --strict cho class này và tạo instance thành công trên wrangler dev; chưa chờ qua bước sleep một ngày nên email giả chưa chạy. Chọn Queues khi chỉ cần giao việc rời rạc, Workflows khi các bước phụ thuộc nhau và cần chờ.

Quan sát và quay lui (lệnh Wrangler theo Workers commands, đọc 2026-10-05; chưa chạy vì cần tài khoản Cloudflare):

bashReady
npx wrangler tail private-docs-api --status error --format pretty   # log trực tiếp, chỉ lỗinpx wrangler versions upload --message "tăng giới hạn upload"       # tải bản mới, chưa phát hànhnpx wrangler versions deploy                                       # phát hành version đã tải; chia traffic: xem --helpnpx wrangler versions list                                          # 10 version gần nhấtnpx wrangler rollback <version-id> --message "lùi vì tăng lỗi 5xx"  # triển khai lại version trước

Tách versions upload (không đổi traffic) khỏi versions deploy cho phép phát hành dần; rollback triển khai ngay một version đã có. Với versions deploy, tài liệu nêu tham số [VERSION-SPECS] và cờ --percentage; cú pháp chia traffic cụ thể chưa xác minh, hãy đọc npx wrangler versions deploy --help trước khi dùng. Rollback chỉ lùi code và cấu hình của Worker; migration D1 đã áp, dữ liệu R2 và message trong queue không tự lùi.


10. Bài lab — private document API trên Cloudflare#

Mục tiêu#

Build một API nhỏ để user upload tài liệu riêng tư: Worker xác thực request, bytes vào R2, metadata vào D1, job quét file gửi qua Queue, rồi endpoint chỉ cho owner tải lại. Thêm KV chỉ cho dữ liệu danh mục công khai; thêm Durable Object như bài mở rộng để điều phối quota theo tenant hoặc phiên xử lý. Không cần ép cả bảy sản phẩm vào nếu use case không đòi hỏi.

Bước 1: khởi tạo và mô hình dữ liệu#

  1. Tạo Worker TypeScript bằng npm create cloudflare@latest và chạy npx wrangler dev.

    Đáp án

    Lệnh trong terminal (chưa chạy; --local chỉ dùng trạng thái giả lập trong .wrangler/):

    bashReady
    npm create cloudflare@latest -- private-docs-api   # chọn Worker TypeScriptprintf 'TOKENS={"dev-token-a":"user-a","dev-token-b":"user-b"}\n' > .dev.vars   # giá trị giả, thêm vào .gitignorenpx wrangler types && npx tsc --noEmitnpx wrangler d1 migrations apply DB --localnpx wrangler dev                      # cổng mặc định 8787
  2. Tạo D1 database và R2 bucket riêng cho lab; bind vào Worker bằng tên rõ như DB và FILES.

    Đáp án

    Cấu hình wrangler.jsonc (binding DB, FILES, SCAN_QUEUE; consumer cùng Worker; DLQ khai báo tường minh vì không có DLQ thì message hết retry bị xoá). database_id thật chỉ có sau wrangler d1 create; chạy --local không cần.

    jsonReady
    {  "name": "private-docs-api",  "main": "src/index.ts",  "compatibility_date": "2026-10-01",  "observability": { "enabled": true },  "d1_databases": [    { "binding": "DB", "database_name": "private-docs-lab",      "database_id": "<id do wrangler d1 create trả về>", "migrations_dir": "migrations" }  ],  "r2_buckets": [{ "binding": "FILES", "bucket_name": "private-docs-lab" }],  "queues": {    "producers": [{ "binding": "SCAN_QUEUE", "queue": "scan-upload" }],    "consumers": [{ "queue": "scan-upload", "max_retries": 3, "dead_letter_queue": "scan-upload-dlq" }]  }}

    Hai queue (scan-upload và DLQ scan-upload-dlq) phải tồn tại trước khi deploy. Tạo bằng npx wrangler queues create scan-upload và npx wrangler queues create scan-upload-dlq (lệnh tác động tài khoản thật, chưa chạy); chạy --local thì không cần.

  3. Tạo migration có bảng documents(id, owner_id, object_key, content_type, byte_size, status, created_at); thêm unique constraint cho object_key và index theo (owner_id, created_at).

    Đáp án

    migrations/0001_create_documents.sql (thêm scan_log(job_id PRIMARY KEY) làm bản ghi idempotency cho bước 3):

    textReady
    CREATE TABLE documents (  id TEXT PRIMARY KEY,  owner_id TEXT NOT NULL,  object_key TEXT NOT NULL UNIQUE,  content_type TEXT NOT NULL,  byte_size INTEGER NOT NULL,  status TEXT NOT NULL DEFAULT 'pending_scan',  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);CREATE INDEX documents_owner_created_idx ON documents (owner_id, created_at DESC);CREATE TABLE scan_log (  job_id TEXT PRIMARY KEY,  document_id TEXT NOT NULL,  result TEXT NOT NULL,  scanned_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP);
  4. Thêm middleware/auth layer; user ID lấy từ token/session đã xác minh, không tin header tự gửi từ browser.

    Đáp án

    Lab dùng bảng token giả đọc từ secret TOKENS (thực tế verify JWT/session). Chỉ userId từ bảng này được dùng, không đọc từ header do client đặt:

    typescriptReady
    function authenticate(request: Request, env: Env): string | null {  const token = request.headers.get('authorization')?.replace(/^Bearer /, '')  if (!token) return null  const users = JSON.parse(env.TOKENS) as Record<string, string>  return Object.hasOwn(users, token) ? users[token] : null}

    fetch() gọi hàm này trước mọi route trừ /health và trả 401 nếu null. Toàn bộ Worker nằm trong Khung và mã dùng chung.

Bước 2: upload an toàn#

  1. Kiểm tra user có quyền upload, quota, request size, file type và tên file.

    Đáp án
    typescriptReady
    const MAX_BYTES = 1_000_000const ALLOWED_TYPES = new Set(['application/pdf', 'text/plain'])if (!ALLOWED_TYPES.has(contentType)) return Response.json({ error: 'unsupported type' }, { status: 415 })if (!Number.isInteger(length) || length <= 0) return Response.json({ error: 'length required' }, { status: 411 })if (length > MAX_BYTES) return Response.json({ error: 'too large' }, { status: 413 })

    Quota theo user: đếm SELECT COUNT(*) FROM documents WHERE owner_id = ? trước khi nhận, hoặc dùng Durable Object theo tenantId ở bản mở rộng. Tên file client gửi không được dùng làm key.

  2. Sinh object key ngẫu nhiên phía server, chẳng hạn users/<authenticated-user-id>/<uuid>.

    Đáp án
    typescriptReady
    const id = crypto.randomUUID()const key = `users/${userId}/${id}`   // userId từ authenticate(), không từ body hay path
  3. Stream body vào private R2 bucket; không đọc toàn bộ file vào memory nếu file lớn.

    Đáp án
    typescriptReady
    // Biết trước độ dài thì dùng FixedLengthStream để stream thẳng vào R2, không buffer vào RAM.const { readable, writable } = new FixedLengthStream(length)const piping = request.body.pipeTo(writable)const put = env.FILES.put(key, readable, { httpMetadata: { contentType }, customMetadata: { ownerId: userId } })await Promise.all([put, piping]) // một bên lỗi thì Promise.all bắt cả hai, không có rejection bị bỏ rơi

    Bucket giữ private; không bật public access cho bucket này.

  4. Ghi metadata D1 với trạng thái pending_scan, rồi enqueue message có jobId.

    Đáp án
    typescriptReady
    await env.DB.prepare(  'INSERT INTO documents (id, owner_id, object_key, content_type, byte_size) VALUES (?, ?, ?, ?, ?)',).bind(id, userId, key, contentType, length).run()   // status mặc định pending_scanawait env.SCAN_QUEUE.send({ jobId: crypto.randomUUID(), documentId: id, objectKey: key, schemaVersion: 1 })
  5. Nếu bước giữa thất bại, ghi log có request ID và dùng cleanup/compensation để không để orphan vĩnh viễn.

    Đáp án
    typescriptReady
    } catch (error) {  await env.FILES.delete(key) // compensation: không để object mồ côi  console.error({ event: 'upload_failed', documentId: id, error: String(error) })  return Response.json({ error: 'internal' }, { status: 500 })}

    Compensation chỉ cứu lỗi bắt được. Process chết giữa hai bước vẫn để lại object mồ côi, nên cần job đối soát định kỳ (so danh sách key R2 với bảng documents).

Bước 3: xử lý queue#

  1. Consumer kiểm tra jobId đã được xử lý chưa bằng unique constraint/record idempotency.

    Đáp án

    scan_log.job_id là khoá chính; INSERT OR IGNORE không làm gì khi job lặp:

    typescriptReady
    const [logged] = await env.DB.batch([  env.DB.prepare('INSERT OR IGNORE INTO scan_log (job_id, document_id, result) VALUES (?, ?, ?)')    .bind(job.jobId, job.documentId, result),  env.DB.prepare("UPDATE documents SET status = ? WHERE id = ? AND status = 'pending_scan'")    .bind(object ? 'ready' : 'rejected', job.documentId),])if (logged.meta.changes === 0) console.log({ event: 'scan_duplicate_skipped', jobId: job.jobId })

    db.batch() chạy như một transaction. Việc cập nhật trạng thái cũng idempotent nhờ điều kiện status = 'pending_scan'.

  2. Xác minh object tồn tại, chạy bước quét/trích metadata, cập nhật trạng thái ready hoặc rejected.

    Đáp án
    typescriptReady
    const object = await env.FILES.head(job.objectKey)const result = object ? 'clean' : 'missing'            // bước quét thật thay vào đây// ... UPDATE documents SET status = ready | rejected (xem câu trên)
  3. Lỗi tạm thời retry có giới hạn; lỗi dữ liệu không hợp lệ vào trạng thái thất bại/DLQ.

    Đáp án
    typescriptReady
    async queue(batch: MessageBatch<ScanJob>, env: Env) {  for (const message of batch.messages) {    try {      await scanOnce(env, message.body)      message.ack()    } catch (error) {      console.error({ event: 'scan_failed', jobId: message.body.jobId, attempt: message.attempts, error: String(error) })      message.retry({ delaySeconds: 2 ** message.attempts })    }  }},

    max_retries: 3 và dead_letter_queue ở wrangler.jsonc: hết 3 lần retry thì message vào scan-upload-dlq. Lỗi dữ liệu vĩnh viễn (object không tồn tại) không ném lỗi mà ghi rejected rồi ack, để không retry vô ích.

  4. Tạo một test gọi consumer hai lần với cùng job và chứng minh không có side effect trùng.

    Đáp án

    Vitest chạy trong runtime Workers (tham chiếu theo tài liệu Cloudflare, chưa chạy): gọi scanOnce hai lần với cùng jobId, khẳng định scan_log có đúng một dòng. D1 trong test bắt đầu rỗng, nên cần một file setup áp migration, nếu không test báo no such table. Cấu hình dưới đây theo trang cấu hình của tài liệu hiện hành (gói @cloudflare/vitest-plugin, hàm cloudflareTest); tên gói và cách import env đã đổi giữa các phiên bản, chưa xác minh với bản bạn cài, nên đối chiếu tài liệu trước khi chép.

    vitest.config.ts:

    typescriptReady
    import path from 'node:path'import { cloudflareTest, readD1Migrations } from '@cloudflare/vitest-plugin'import { defineConfig } from 'vitest/config'export default defineConfig({  plugins: [    cloudflareTest(async () => {      const migrations = await readD1Migrations(path.join(__dirname, 'migrations'))      return {        wrangler: { configPath: './wrangler.jsonc' },        miniflare: { bindings: { TEST_MIGRATIONS: migrations } },      }    }),  ],  test: { setupFiles: ['./test/apply-migrations.ts'] },})

    test/apply-migrations.ts (khai báo thêm kiểu TEST_MIGRATIONS cho env trong test):

    typescriptReady
    import { applyD1Migrations, env } from 'cloudflare:test'await applyD1Migrations(env.DB, env.TEST_MIGRATIONS)

    Test:

    typescriptReady
    import { env } from 'cloudflare:test'import { expect, it } from 'vitest'import { scanOnce } from '../src/index'it('scanOnce chạy hai lần cùng job vẫn chỉ có một bản ghi', async () => {  await env.FILES.put('users/u/doc1', 'x')  await env.DB.prepare("INSERT INTO documents (id, owner_id, object_key, content_type, byte_size) VALUES ('doc1','u','users/u/doc1','text/plain',1)").run()  const job = { jobId: 'j1', documentId: 'doc1', objectKey: 'users/u/doc1', schemaVersion: 1 as const }  await scanOnce(env, job)  await scanOnce(env, job)  const row = await env.DB.prepare('SELECT COUNT(*) AS n FROM scan_log WHERE job_id = ?').bind('j1').first<{ n: number }>()  expect(row?.n).toBe(1)})

Bước 4: download và cache#

  1. Endpoint GET /api/documents/:id tìm metadata bằng id + owner_id trong cùng query.

    Đáp án
    typescriptReady
    const doc = await env.DB.prepare(  'SELECT object_key, content_type, status FROM documents WHERE id = ? AND owner_id = ?',).bind(id, userId).first()if (!doc) return Response.json({ error: 'not found' }, { status: 404 }) // của người khác cũng là 404

    Tìm theo id và owner_id trong cùng một query để không có khoảng hở kiểm tra rồi mới đọc.

  2. Chỉ tải R2 object nếu user là owner hoặc qua được policy chia sẻ.

    Đáp án

    Trong lời giải chỉ có owner (không có chia sẻ). Dòng if (doc.status !== 'ready') return ... 409 chặn tải file chưa quét xong; FILES.get(doc.object_key) chỉ chạy sau khi truy vấn ở trên trả hàng. Nếu thêm chia sẻ, đưa quyền vào bảng riêng và join trong cùng query.

  3. Response file đặt header an toàn; nội dung riêng tư trả Cache-Control: private, no-store.

    Đáp án
    typescriptReady
    return new Response(object.body, {  headers: {    'content-type': doc.content_type,    'content-disposition': 'attachment',    'x-content-type-options': 'nosniff',    'cache-control': 'private, no-store',  },})
  4. Kiểm tra request của user B không thể tải file user A kể cả khi biết document ID/object key.

    Đáp án

    Hai token ở ma trận kiểm tra (xem yêu cầu 3 của Bước 5): user B gọi GET /api/documents/$ID với $ID của user A phải nhận 404, giống hệt khi ID không tồn tại. Biết object key cũng vô dụng vì Worker không có route đọc R2 theo key.

Bước 5: staging, quan sát và dọn#

  1. Tạo staging environment có D1/bucket/queue riêng; cấu hình lại các binding không tự kế thừa như mong đợi.

    Đáp án

    Thêm khối "env": { "staging": { ... } } trong wrangler.jsonc khai báo lại d1_databases, r2_buckets, queues với D1, R2, queue (và DLQ) riêng, vì binding không kế thừa như mong đợi. Tạo tài nguyên (mỗi lệnh tác động tài khoản thật, chưa chạy):

    bashReady
    npx wrangler d1 create private-docs-stagingnpx wrangler r2 bucket create private-docs-stagingnpx wrangler queues create scan-upload-staging
  2. Thêm secret thử nghiệm qua Wrangler/dashboard; ví dụ npx wrangler secret put API_KEY --env staging. Lệnh thêm secret triển khai Worker ngay, nên xác nhận environment/resource đích trước khi chạy; tuyệt đối không dùng secret production ở local hoặc staging.

    Đáp án
    bashReady
    npx wrangler secret put TOKENS --env staging   # xác nhận đúng environment trước khi chạy

    Lệnh này triển khai Worker ngay. Không dùng secret production ở local hoặc staging; ở local dùng .dev.vars với giá trị giả.

  3. Chạy migration local, request success/error, queue retry/DLQ, upload quá giới hạn và authorization matrix.

    Đáp án
    bashReady
    curl -s localhost:8787/healthcurl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8787/api/documentscurl -s -X POST localhost:8787/api/documents -H 'authorization: Bearer dev-token-a' \  -H 'content-type: text/plain' --data-binary 'hello private'curl -s localhost:8787/api/documents/$ID -H 'authorization: Bearer dev-token-a'curl -s localhost:8787/api/documents/$ID -H 'authorization: Bearer dev-token-b'head -c 1000001 /dev/zero | curl -s -X POST localhost:8787/api/documents \  -H 'authorization: Bearer dev-token-a' -H 'content-type: text/plain' --data-binary @-curl -s -X POST localhost:8787/api/documents -H 'authorization: Bearer dev-token-a' \  -H 'content-type: image/png' --data-binary 'x'

    Mong đợi: xem bảng trong Khung và mã dùng chung. Thêm test queue retry/DLQ bằng cách làm scanOnce ném lỗi tạm thời trong môi trường thử.

  4. Deploy lên staging; gọi endpoint, xem Workers Logs, xác minh object/row/queue state và tắt cache cho dữ liệu user.

    Đáp án

    Sau khi kiểm chi phí và đích deploy: npx wrangler deploy --env staging (hoặc qua CI). Gọi lại ma trận kiểm tra trên URL staging, xem Workers Logs (observability đã bật) và kiểm tra hàng trong D1, object trong R2, độ sâu queue. Phản hồi file đã có cache-control: private, no-store.

  5. Sau khi kiểm tra chi phí và đích deploy, mới deploy production theo CI/CD của dự án; dọn database, bucket, queue và secret lab khi xong.

    Đáp án

    Deploy production theo CI/CD của dự án sau khi staging sạch. Dọn lab (chỉ khi đã tạo thật):

    bashReady
    npx wrangler secret delete TOKENS --env staging   # xoá secret TRƯỚC khi xoá Workernpx wrangler delete --env stagingnpx wrangler queues delete scan-upload-staging && npx wrangler queues delete scan-upload-dlqnpx wrangler r2 bucket delete private-docs-staging   # bucket phải rỗngnpx wrangler d1 delete private-docs-staging

    Xoá thư mục .wrangler/ và .dev.vars local. Với lab chỉ chạy --local thì không có gì trên tài khoản để dọn.

Lệnh Wrangler và cách khai báo binding có thể thay đổi. Tra Wrangler configuration, local development và tài liệu từng binding trước khi chạy thao tác remote. Đặc biệt, lệnh migration/bucket/deploy tác động resource thật nếu chọn remote/account production.

Khung và mã dùng chung

Tự làm trước, rồi mở. Chưa chạy trên Cloudflare thật (không dùng tài khoản hay API token Cloudflare, không wrangler deploy, không tạo tài nguyên có phí). Mức bằng chứng: code Worker đã qua tsc --noEmit với kiểu sinh bởi wrangler types (wrangler 4.x); chưa chạy wrangler dev, nên "Kết quả mong đợi" là suy ra từ code và tài liệu. Mọi lệnh --local chỉ dùng trạng thái giả lập trong thư mục .wrangler/; lệnh --remote, deploy, secret put, r2 bucket create tác động tài khoản thật nên chỉ chạy khi bạn đã chọn đúng tài khoản lab.

Sơ đồ

textReady
 client ──POST /api/documents (Bearer)──► Worker fetch()                                           1 auth ─► 401                                           2 type/size ─► 415/411/413                                           3 stream ─► R2 users/<uid>/<uuid>                                           4 INSERT D1 (pending_scan)                                           5 enqueue {jobId,...} ─► 201                                             │ lỗi ở 4/5 ─► R2.delete                                             │              (compensation) Queue ──batch──► Worker queue()                    scanOnce: INSERT OR IGNORE scan_log + UPDATE status                    │ lỗi ─► retry (backoff) ─ quá max_retries ─► DLQ client ──GET /api/documents/:id──► SELECT ... WHERE id=? AND owner_id=?                                      không thấy ─► 404 (kể cả của người khác)                                      status != ready ─► 409                                      ready ─► R2.get, no-store

Worker đầy đủ (src/index.ts). Các yêu cầu ở trên dùng từng đoạn của file này.

typescriptReady
export interface Env {  DB: D1Database  FILES: R2Bucket  SCAN_QUEUE: Queue<ScanJob>  TOKENS: string // JSON {"<token>":"<userId>"}; secret, không nằm trong wrangler.jsonc}export interface ScanJob { jobId: string; documentId: string; objectKey: string; schemaVersion: 1 }const MAX_BYTES = 1_000_000const ALLOWED_TYPES = new Set(['application/pdf', 'text/plain'])function authenticate(request: Request, env: Env): string | null {  const token = request.headers.get('authorization')?.replace(/^Bearer /, '')  if (!token) return null  const users = JSON.parse(env.TOKENS) as Record<string, string>  return Object.hasOwn(users, token) ? users[token] : null}async function upload(request: Request, env: Env, userId: string) {  const contentType = request.headers.get('content-type') ?? ''  if (!ALLOWED_TYPES.has(contentType)) return Response.json({ error: 'unsupported type' }, { status: 415 })  const length = Number(request.headers.get('content-length'))  if (!Number.isInteger(length) || length <= 0) return Response.json({ error: 'length required' }, { status: 411 })  if (length > MAX_BYTES) return Response.json({ error: 'too large' }, { status: 413 })  if (!request.body) return Response.json({ error: 'empty body' }, { status: 400 })  const id = crypto.randomUUID()  const key = `users/${userId}/${id}`  // Biết trước độ dài thì dùng FixedLengthStream để stream thẳng vào R2, không buffer vào RAM.  const { readable, writable } = new FixedLengthStream(length)  const piping = request.body.pipeTo(writable)  const put = env.FILES.put(key, readable, { httpMetadata: { contentType }, customMetadata: { ownerId: userId } })  await Promise.all([put, piping]) // một bên lỗi thì Promise.all bắt cả hai, không có rejection bị bỏ rơi  try {    await env.DB.prepare(      'INSERT INTO documents (id, owner_id, object_key, content_type, byte_size) VALUES (?, ?, ?, ?, ?)',    ).bind(id, userId, key, contentType, length).run()    await env.SCAN_QUEUE.send({ jobId: crypto.randomUUID(), documentId: id, objectKey: key, schemaVersion: 1 })  } catch (error) {    await env.FILES.delete(key) // compensation: không để object mồ côi    console.error({ event: 'upload_failed', documentId: id, error: String(error) })    return Response.json({ error: 'internal' }, { status: 500 })  }  return Response.json({ id, status: 'pending_scan' }, { status: 201 })}async function download(env: Env, userId: string, id: string) {  const doc = await env.DB.prepare(    'SELECT object_key, content_type, status FROM documents WHERE id = ? AND owner_id = ?',  ).bind(id, userId).first<{ object_key: string; content_type: string; status: string }>()  if (!doc) return Response.json({ error: 'not found' }, { status: 404 }) // của người khác cũng là 404  if (doc.status !== 'ready') return Response.json({ error: 'not ready', status: doc.status }, { status: 409 })  const object = await env.FILES.get(doc.object_key)  if (!object) return Response.json({ error: 'not found' }, { status: 404 })  return new Response(object.body, {    headers: {      'content-type': doc.content_type,      'content-disposition': 'attachment',      'x-content-type-options': 'nosniff',      'cache-control': 'private, no-store',    },  })}export default {  async fetch(request: Request, env: Env): Promise<Response> {    const url = new URL(request.url)    if (request.method === 'GET' && url.pathname === '/health') return Response.json({ ok: true })    const userId = authenticate(request, env)    if (!userId) return Response.json({ error: 'unauthorized' }, { status: 401 })    if (request.method === 'POST' && url.pathname === '/api/documents') return upload(request, env, userId)    const match = url.pathname.match(/^\/api\/documents\/([0-9a-f-]{36})$/)    if (request.method === 'GET' && match) return download(env, userId, match[1])    return Response.json({ error: 'not found' }, { status: 404 })  },  async queue(batch: MessageBatch<ScanJob>, env: Env) {    for (const message of batch.messages) {      try {        await scanOnce(env, message.body)        message.ack()      } catch (error) {        console.error({ event: 'scan_failed', jobId: message.body.jobId, attempt: message.attempts, error: String(error) })        message.retry({ delaySeconds: 2 ** message.attempts })      }    }  },} satisfies ExportedHandler<Env, ScanJob>// Idempotent: scan_log.job_id là khoá chính, nhận lại cùng job thì không tạo thêm gì.export async function scanOnce(env: Env, job: ScanJob) {  const object = await env.FILES.head(job.objectKey)  const result = object ? 'clean' : 'missing'  const [logged] = await env.DB.batch([    env.DB.prepare('INSERT OR IGNORE INTO scan_log (job_id, document_id, result) VALUES (?, ?, ?)')      .bind(job.jobId, job.documentId, result),    env.DB.prepare("UPDATE documents SET status = ? WHERE id = ? AND status = 'pending_scan'")      .bind(object ? 'ready' : 'rejected', job.documentId),  ])  if (logged.meta.changes === 0) console.log({ event: 'scan_duplicate_skipped', jobId: job.jobId })}

Kết quả mong đợi (suy ra, chưa quan sát)

LệnhMong đợi
/health{"ok":true}
Upload không token401
Upload text/plain hợp lệ201 {"id":"<uuid>","status":"pending_scan"}
GET ngay sau uploadCó thể 409 not ready vài trăm ms; sau khi consumer chạy: 200, body hello private, header cache-control: private, no-store, content-disposition: attachment
GET bằng token user B404 (không lộ sự tồn tại; quy ước 404 cho tài nguyên của người khác)
Body 1 000 001 byte413
image/png415
Test idempotencyQua (n = 1)
Log localCó scan_duplicate_skipped khi job lặp; không có token hay nội dung file trong log

KV và Durable Object không dùng trong lời giải: đề cho phép bỏ khi use case không cần. Nếu muốn mở rộng quota theo tenant, dùng một Durable Object theo tenantId (không phải một object toàn hệ thống) để tăng bộ đếm tuần tự.

Lỗi hay gặp

  • Quên database_id thật khi deploy: lệnh local vẫn chạy, nhưng deploy trỏ sai hoặc không tìm thấy database. Chạy wrangler d1 create rồi chép ID vào cấu hình.
  • Staging dùng nhầm binding production vì khối env.staging không khai báo lại binding. Đặt tên tài nguyên có hậu tố và kiểm bằng dashboard.
  • wrangler dev nối remote binding khi bạn bật remote trong binding: dữ liệu local và remote lẫn nhau. Mặc định giữ local.
  • Tin content-type từ client: nó chỉ lọc sơ. Dữ liệu thật vẫn nên kiểm magic bytes trong consumer.
  • Không có DLQ khai báo: message hết max_retries bị xoá, mất dấu job lỗi.
  • Gọi consumer với side effect ngoài (email, tính phí) mà không truyền idempotency key: lặp message sẽ gửi lặp.
  • put vào R2 với stream không biết độ dài: dùng FixedLengthStream khi biết kích thước (nêu theo tài liệu R2; chưa xác minh quy tắc bắt buộc trong mục E).
  • Ghi D1 và R2 không cùng transaction: nếu process chết giữa hai bước vẫn có object mồ côi; cần job đối soát định kỳ (compensation trong catch chỉ cứu lỗi bắt được).

Tài liệu Cloudflare đã dùng: Wrangler configuration, R2 Workers API, D1 migrations và batch, Queues delivery guarantees, Queues DLQ, Wrangler environments, Vitest integration.

Definition of done#

  • Có sơ đồ request flow và giải thích được lúc nào code chạy trên Worker, lúc nào asset được CDN trả trực tiếp.
  • API xác thực user, validate input, phân quyền theo owner và không lộ bucket public.
  • File stream vào R2; metadata/migration ở D1 hoặc DB được chọn có lý do.
  • Queue consumer idempotent, có retry/DLQ và theo dõi message cũ.
  • KV chỉ được dùng ở nơi chấp nhận stale; biết tại sao KV không làm database transaction.
  • Có log để truy một request mà không lộ secret/PII.
  • Staging dùng resource riêng; biết cách ước tính chi phí và dọn lab.
  • Viết được một đoạn giải thích vì sao Cloudflare hoặc AWS phù hợp hơn với project đã chọn.

Liên hệ với repo Backend Guide#

Repo này đang minh họa một lựa chọn khác với Worker API thuần: VitePress build thành static site, deploy trên Cloudflare Pages và dùng R2 private cho ebook. Đọc wrangler.jsonc, .github/workflows/deploy.yml và phần Ebook trong README.md; lần theo build → Pages deployment → R2 binding → kiểm tra session Supabase → byte-range response. Dùng flow này để so sánh static hosting + Worker endpoint với app full-stack chạy trực tiếp trên Workers.


Tài liệu chính thức nên đọc#