0

Write Once, Run on Any Runtime: Practical Runtime-Agnostic TypeScript for Node, Deno, Bun and Cloudflare Workers

Tin Cloudflare mua lại Deno đang leo top Hacker News, và phần bình luận chia làm hai phe: một phe mừng vì Deno có "nhà giàu" chống lưng, phe kia lo bị vendor lock-in. Mình thấy câu hỏi thực tế hơn cho dev là: nếu ngày mai runtime bạn đang dùng đổi chủ, đổi giá hoặc đổi hướng đi, code của bạn mất bao lâu để chuyển sang chỗ khác? Với nhiều team mình từng làm cùng, câu trả lời là "vài tuần", vì process.env, fs, Buffer và req.body của Express nằm rải rác khắp codebase. Bài này chia sẻ cách mình viết TypeScript backend để cùng một core chạy được trên Node.js 22/24, Deno 2.x, Bun 1.2+ và Cloudflare Workers mà gần như không phải sửa gì.

Nền tảng chung: Web Standard APIs

Vài năm trước, mỗi runtime có một API riêng. Bây giờ thì khác: nhờ nhóm WinterTC (trước đây là WinterCG), cả bốn runtime đều hỗ trợ một tập API chung:

  • Request, Response, Headers, fetch
    • URL, URLSearchParams
    • ReadableStream, TextEncoder/TextDecoder
    • crypto.subtle, crypto.randomUUID()
    • structuredClone, AbortController

Vậy nguyên tắc đầu tiên rất đơn giản: business logic chỉ được dùng Web Standard APIs. Code nào cần đến node:fs, Deno.env hay env.MY_KV thì đẩy ra ngoài rìa, vào một lớp adapter mỏng.

graph TD
    A[Core: fetch handler + business logic] --> B[Web Standard APIs]
        C[entry.node.ts] --> A
            D[entry.deno.ts] --> A
                E[entry.bun.ts] --> A
                    F[entry.worker.ts] --> A
                        G[Config / Storage interface] --> A
                        ```
                        
                        Core không biết nó đang chạy ở đâu. Mỗi file entry chỉ dài 5-15 dòng, làm đúng một việc: đọc config của runtime đó rồi truyền vào core.
                        
                        ## Viết core dưới dạng fetch handler
                        
                        Thay vì `(req, res) => {}` kiểu Express, ta viết một hàm nhận `Request` và trả về `Promise<Response>`. Đây là "hợp đồng" chung mà runtime nào cũng hiểu.
                        
                        ```typescript
                        // src/core/app.ts
                        export interface AppDeps {
                          config: { apiKey: string; env: "dev" | "prod" };
                            store: KeyValueStore;
                            }
                            
                            export interface KeyValueStore {
                              get(key: string): Promise<string | null>;
                                put(key: string, value: string, ttlSec?: number): Promise<void>;
                                }
                                
                                async function sha256(text: string): Promise<string> {
                                  const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text));
                                    return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join("");
                                    }
                                    
                                    export function createApp(deps: AppDeps) {
                                      return async function fetch(req: Request): Promise<Response> {
                                          const url = new URL(req.url);
                                          
                                              if (url.pathname === "/health") {
                                                    return Response.json({ ok: true, env: deps.config.env });
                                                        }
                                                        
                                                            if (url.pathname === "/shorten" && req.method === "POST") {
                                                                  const { link } = (await req.json()) as { link: string };
                                                                        const id = (await sha256(link)).slice(0, 8);
                                                                              await deps.store.put(id, link, 60 * 60 * 24 * 30);
                                                                                    return Response.json({ id, short: `${url.origin}/${id}` }, { status: 201 });
                                                                                        }
                                                                                        
                                                                                            const target = await deps.store.get(url.pathname.slice(1));
                                                                                                return target ? Response.redirect(target, 302) : new Response("Not found", { status: 404 });
                                                                                                  };
                                                                                                  }
                                                                                                  ```
                                                                                                  
                                                                                                  Có ba điểm cần để ý:
                                                                                                  
                                                                                                  1. **Dùng `crypto.subtle` chứ không dùng `node:crypto`.** Chạy được ở mọi nơi, kể cả Workers khi không bật `nodejs_compat`.
                                                                                                  2. **Config được inject vào, không đọc trực tiếp.** Trong core tuyệt đối không có `process.env`. Đây là lỗi mình gặp nhiều nhất khi review code muốn port sang Workers, vì ở đó env là tham số `env` của handler chứ không phải biến global.
                                                                                                  3. **Storage là một interface.** Trên Node, bạn có thể dùng Redis. Trên Deno thì Deno KV. Trên Workers thì Workers KV. Còn khi test thì một `Map` là đủ.
                                                                                                  
                                                                                                  Nếu không muốn tự viết routing, Hono (v4.x) được thiết kế đúng theo triết lý này và chạy được trên cả bốn runtime. Nhưng bạn vẫn nên hiểu cơ chế bên dưới trước khi dùng framework.
                                                                                                  
                                                                                                  ## Adapter cho từng runtime
                                                                                                  
                                                                                                  Đây là phần duy nhất được phép "bẩn":
                                                                                                  
                                                                                                  ```typescript
                                                                                                  // entry.node.ts  (Node 22+, cần: npm i @hono/node-server ioredis)
                                                                                                  import { serve } from "@hono/node-server";
                                                                                                  import { createApp } from "./src/core/app.ts";
                                                                                                  import { redisStore } from "./src/adapters/redis.ts";
                                                                                                  
                                                                                                  const fetch = createApp({
                                                                                                    config: { apiKey: process.env.API_KEY!, env: "prod" },
                                                                                                      store: redisStore(process.env.REDIS_URL!),
                                                                                                      });
                                                                                                      serve({ fetch, port: 3000 });
                                                                                                      
                                                                                                      // entry.deno.ts  (Deno 2.x)
                                                                                                      import { createApp } from "./src/core/app.ts";
                                                                                                      import { denoKvStore } from "./src/adapters/deno-kv.ts";
                                                                                                      
                                                                                                      const kv = await Deno.openKv();
                                                                                                      Deno.serve({ port: 3000 }, createApp({
                                                                                                        config: { apiKey: Deno.env.get("API_KEY")!, env: "prod" },
                                                                                                          store: denoKvStore(kv),
                                                                                                          }));
                                                                                                          
                                                                                                          // entry.worker.ts  (Cloudflare Workers, wrangler 4.x)
                                                                                                          import { createApp } from "./src/core/app";
                                                                                                          import { workersKvStore } from "./src/adapters/workers-kv";
                                                                                                          
                                                                                                          export default {
                                                                                                            fetch(req: Request, env: { API_KEY: string; LINKS: KVNamespace }) {
                                                                                                                return createApp({
                                                                                                                      config: { apiKey: env.API_KEY, env: "prod" },
                                                                                                                            store: workersKvStore(env.LINKS),
                                                                                                                                })(req);
                                                                                                                                  },
                                                                                                                                  };
                                                                                                                                  ```
                                                                                                                                  
                                                                                                                                  Với Bun thì đơn giản hơn nữa: `Bun.serve({ port: 3000, fetch })`, hoặc dùng luôn cú pháp `export default { fetch }` giống Workers.
                                                                                                                                  
                                                                                                                                  Luồng xử lý một request lúc này trông như sau:
                                                                                                                                  
                                                                                                                                  ```mermaid
                                                                                                                                  sequenceDiagram
                                                                                                                                      participant C as Client
                                                                                                                                          participant R as Runtime (Node/Deno/Workers)
                                                                                                                                              participant A as Adapter entry
                                                                                                                                                  participant Core as Core fetch()
                                                                                                                                                      participant S as Store impl
                                                                                                                                                          C->>R: HTTP POST /shorten
                                                                                                                                                              R->>A: native request
                                                                                                                                                                  A->>Core: Request (Web standard)
                                                                                                                                                                      Core->>S: store.put(id, link)
                                                                                                                                                                          S-->>Core: ok
                                                                                                                                                                              Core-->>A: Response
                                                                                                                                                                                  A-->>C: 201 JSON
                                                                                                                                                                                  ```
                                                                                                                                                                                  
                                                                                                                                                                                  ## Kiểm tra tính portable bằng CI
                                                                                                                                                                                  
                                                                                                                                                                                  Nói "code chạy được mọi nơi" mà không có test thì chỉ là hy vọng. Mình viết test theo kiểu gọi thẳng vào fetch handler (không cần mở port), dùng một `Map` làm store, rồi chạy cùng một file test trên nhiều runtime:
                                                                                                                                                                                  
                                                                                                                                                                                  ```bash
                                                                                                                                                                                  # Node 22+ có test runner sẵn và chạy được TS với --experimental-strip-types
                                                                                                                                                                                  node --experimental-strip-types --test tests/app.test.ts
                                                                                                                                                                                  
                                                                                                                                                                                  # Deno 2.x chạy được node:test nhờ lớp tương thích
                                                                                                                                                                                  deno test --allow-env tests/app.test.ts
                                                                                                                                                                                  
                                                                                                                                                                                  # Bun
                                                                                                                                                                                  bun test tests/app.test.ts
                                                                                                                                                                                  
                                                                                                                                                                                  # Workers: dùng @cloudflare/vitest-pool-workers để chạy trong workerd thật
                                                                                                                                                                                  npx vitest run --config vitest.workers.config.ts
                                                                                                                                                                                  ```
                                                                                                                                                                                  
                                                                                                                                                                                  Trong GitHub Actions, đặt bốn lệnh này vào một job matrix. Thêm một rule ESLint chặn import vào thư mục core:
                                                                                                                                                                                  
                                                                                                                                                                                  ```json
                                                                                                                                                                                  {
                                                                                                                                                                                    "rules": {
                                                                                                                                                                                        "no-restricted-imports": ["error", { "patterns": ["node:*", "fs", "path", "bun"] }],
                                                                                                                                                                                            "no-restricted-globals": ["error", "process", "Buffer", "Deno", "Bun"]
                                                                                                                                                                                              }
                                                                                                                                                                                              }
                                                                                                                                                                                              ```
                                                                                                                                                                                              
                                                                                                                                                                                              Chỉ áp rule này cho `src/core/**`. Lần đầu bật lên có thể bạn sẽ thấy hàng chục lỗi, và đó chính là danh sách "nợ lock-in" của bạn.
                                                                                                                                                                                              
                                                                                                                                                                                              Một vài cái bẫy mình từng dính:
                                                                                                                                                                                              
                                                                                                                                                                                              - **`Buffer`** vẫn len lỏi vào qua các thư viện cũ. Hãy thay bằng `Uint8Array` và `TextEncoder`.
                                                                                                                                                                                              - **Thư viện npm dùng `node:net` hoặc `node:tls`** (nhiều driver database kiểu cũ) sẽ không chạy trên Workers. Khi chọn thư viện, ưu tiên loại kết nối qua HTTP/fetch.
                                                                                                                                                                                              - **Cùng là Request nhưng không phải lúc nào cũng có giới hạn CPU time như nhau.** Workers giới hạn CPU time cho mỗi request, Node thì không. Code nào hash nặng hoặc parse JSON lớn thì phải đo lại khi chuyển sang edge.
                                                                                                                                                                                              - **Top-level `await`** dùng thoải mái trên Deno/Node ESM, nhưng trên Workers thì nên khởi tạo bên trong handler hoặc lazy-init.
                                                                                                                                                                                              
                                                                                                                                                                                              ## Kết luận
                                                                                                                                                                                              
                                                                                                                                                                                              Các thương vụ mua bán trong làng runtime sẽ còn tiếp diễn, và bạn không kiểm soát được chuyện đó. Thứ bạn kiểm soát được là chi phí để rời đi. Các bước nên làm ngay tuần này:
                                                                                                                                                                                              
                                                                                                                                                                                              1. **Grep codebase** tìm `process.env`, `Buffer`, `require("fs")` trong phần business logic. Mỗi kết quả là một điểm khóa chặt bạn vào runtime.
                                                                                                                                                                                              2. **Chuyển handler sang chữ ký `(Request) => Promise<Response>`.** Có thể bắt đầu từ một endpoint mới, không cần rewrite toàn bộ.
                                                                                                                                                                                              3. **Inject config và storage qua interface**, đừng đọc global.
                                                                                                                                                                                              4. **Bật rule ESLint cho `src/core`** và chạy test trên ít nhất hai runtime trong CI.
                                                                                                                                                                                              5. **Ưu tiên `crypto.subtle`, `fetch`, `ReadableStream`** thay vì API riêng của Node.
                                                                                                                                                                                              
                                                                                                                                                                                              Làm được như vậy, việc chọn Node, Deno, Bun hay Workers chỉ còn là chuyện deploy config, không còn là quyết định kiến trúc. Và khi có tin "X acquires Y" tiếp theo, bạn có thể đọc nó với tâm thế thoải mái.

All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.