← Blog

Thời gian thực dưới dạng hooks: các package của chúng tôi cho React, Vue và Nuxt

· Hibira

Đội nào nối pusher-js vào React hay Vue cũng viết cùng một useEffect và gặp cùng những lỗi. Chúng tôi gói nó thành ba package, và cho chúng stream câu trả lời AI đến mọi tab, mọi đồng nghiệp.

Nối pusher-js vào React thường bắt đầu như thế này:

useEffect(() => {
  const channel = pusher.subscribe('orders');
  channel.bind('order.created', (order) => setOrders((list) => [order, ...list]));
  return () => pusher.unsubscribe('orders');
}, []);

Sáu dòng, trông chẳng có vấn đề gì. Rồi dự án lớn dần: danh sách đơn hàng và chiếc chuông thông báo ở góc trên bên phải đều nghe orders. Chuyển trang, danh sách bị unmount, chuông vẫn còn, nhưng từ đó chuông không nhận được thông báo nào nữa. Ở chế độ phát triển, StrictMode của React mount component hai lần, và kết nối cũng mở hai lần theo. Chuyển sang Next.js, new Pusher() chạy cả lúc render phía máy chủ, mở một WebSocket trên máy chủ mà không ai dùng.

Chúng tôi đã thấy kiểu code này ở rất nhiều dự án, viết gần như giống hệt nhau, và vướng đúng những cái bẫy giống nhau. Vì vậy chúng tôi gói nó lại thành ba package: @hibira-io/react, @hibira-io/vue và @hibira-io/nuxt. Bài này kể chúng giải quyết vấn đề gì, giải quyết ra sao, và thêm một việc chúng tôi làm luôn trên đường đi: gửi câu trả lời của AI đến mọi tab, mọi đồng nghiệp ngay khi nó đang được viết.

Cái bẫy thứ nhất: hai component, một kênh

subscribe() của pusher-js chỉ đăng ký một tên kênh một lần, nên hai component nhận cùng một đối tượng kênh. Vấn đề nằm ở unsubscribe(): nó không quan tâm ai còn đang dùng, mà hủy đăng ký cả kênh.

Danh sách đơn hàngChuông thông báopusher-jssubscribe('orders')subscribe('orders')nhận cùng một kênhunsubscribe('orders')danh sách bị unmountCả kênh bị hủy, chuông không còn nhận thông báo
Hai component dùng chung kênh orders, rồi một cái bị unmount

Cách chúng tôi sửa là đếm theo từng kênh: mỗi component dùng kênh thì cộng một, mỗi lần unmount thì trừ một, và chỉ thật sự hủy đăng ký khi số đếm về không. Bạn chỉ cần viết useChannel('orders') hoặc useEvent('orders', 'order.created', handler), không cần bận tâm ai mount trước hay ai rời đi trước.

Mount

Danh sách đơn hànguseEvent('orders')
đếm 1
Kênh ordersđã đăng ký
đếm 2
Chuông thông báouseEvent('orders')

Unmount

Danh sách đơn hàngunmount
đếm 1
Kênh ordersvẫn đăng ký
đếm 0
Chuông thông báohủy sau khi nó rời đi
Số đếm quyết định khi nào thật sự hủy đăng ký

useEvent cũng nhớ handler mới nhất của bạn. Component tạo hàm mới mỗi lần render; nếu bind lại sự kiện mỗi lần, kênh bận rộn sẽ có những khoảng hở ngắn bị lỡ sự kiện. Chúng tôi chỉ bind một lần và gọi handler mới nhất khi sự kiện đến.

Cái bẫy thứ hai: render phía máy chủ và StrictMode

Next.js, Remix và Nuxt render trang trên máy chủ trước. Nếu bạn tạo kết nối trong thân component hoặc trong useMemo, máy chủ cũng sẽ kết nối: thêm một WebSocket không ai dùng, và thêm một kết nối bị tính vào gói.

Từ React 18, StrictMode mount, unmount rồi mount lại component ở chế độ phát triển để tìm các side effect chưa được dọn. Code kết nối viết tay thường để lại kết nối thứ hai đúng ở chỗ này.

Render phía máy chủkhông kết nối, hooks trả giá trị rỗng
HTML
Hydration trong trình duyệtgiống hệt máy chủ
sau khi mount
Kết nốiws.hibira.io
Chỉ kết nối trong trình duyệt, và chỉ sau khi mount

HibiraProvider chỉ kết nối trong useEffect và ngắt khi unmount, nên lần mount thứ hai của StrictMode nhận một kết nối mới sạch sẽ. Trước khi kết nối mở, usePresence trả danh sách thành viên rỗng và useConnectionState trả initialized, đúng như máy chủ đã render, nên hydration không cảnh báo lệch. Bản Vue cũng làm như vậy: plugin chỉ kết nối lần đầu được dùng trong trình duyệt, và không làm gì trên máy chủ.

Thêm một việc: câu trả lời của AI, không chỉ cho tab đã hỏi

Hai năm qua rất nhiều sản phẩm đã thêm trợ lý AI. Cách phổ biến là Server-Sent Events: trình duyệt gửi một request, backend ghi đầu ra của mô hình vào chính response đó từng đoạn một.

Cách này ổn khi một người dùng một tab. Nhưng khi sản phẩm trở thành nơi làm việc chung, vấn đề xuất hiện. Alice hỏi AI trong tài liệu đặc tả dùng chung, Bob ngồi ngay cạnh không thấy câu trả lời đang được viết, phải đợi phía Alice viết xong, lưu lại, Bob tải lại trang mới thấy. Tab thứ hai Alice tự mở cũng không thấy. Điện thoại mất sóng một giây, response SSE đứt, câu trả lời chỉ còn một nửa.

Server-Sent Events

Mô hình
stream
Backend của bạn
cùng một HTTP response
Tab đã hỏi

Gửi vào kênh

Mô hình
stream
Backend của bạnstreamToChannel()
trigger()
Hibiraphân phối
WebSocket
Mọi tab, mọi đồng nghiệp
SSE chỉ trả về request đã hỏi; kênh đến được mọi người đã đăng ký

Vì vậy các package có thêm một cặp công cụ: streamToChannel() cho backend và useStream() cho trình duyệt. Backend đưa stream của mô hình cho streamToChannel(), nó sẽ phát văn bản vào một kênh; bất kỳ component nào đăng ký kênh đó đều nhận văn bản đang dài dần qua useStream().

Hai trình duyệt cùng xem một câu trả lời AI đang được viết: bên trái là Alice, người hỏi, bên phải là Bob
Hai trình duyệt cùng xem một câu trả lời AI đang được viết: bên trái là Alice, người hỏi, bên phải là Bob

Chúng tôi chụp ảnh này khi thử trên máy: Alice bấm "Hỏi AI", câu trả lời xuất hiện cùng lúc trên màn hình của Alice và Bob. Bob không cần làm gì cả.

Không phải mỗi token một tin nhắn

Mô hình viết vài chục token mỗi giây. Phát mỗi token một tin nhắn thì một câu trả lời dài hơn một chút sẽ thành cả nghìn tin: hạn mức tin nhắn cạn nhanh, và trình duyệt phía nhận phải render lại cả nghìn lần.

streamToChannel() gom token lại và phát mỗi 100 mili giây một lần. Mô hình nhanh đến đâu thì một kênh cũng chỉ nhận khoảng mười tin mỗi giây, và văn bản trên màn hình vẫn chảy ra liền mạch.

Mỗi token một tin1,000 tinstreamToChannel(), mỗi 100 ms một tin203 tin
Số tin nhắn được phát cho 1.000 token ở tốc độ 50 token mỗi giây (đo thực tế)

Mô hình càng nhanh, tiết kiệm càng nhiều: cùng 1.000 token ở tốc độ 100 token mỗi giây chỉ cần 103 tin. Mỗi tin cũng nằm dưới 3.500 byte, thấp hơn giới hạn 4 KB mỗi tin của gói Free, và khi chia văn bản dài sẽ không bao giờ cắt đôi một ký tự có dấu hay một emoji.

Thứ tự, rớt kết nối và người vào giữa chừng

Mỗi tin nhắn mang một số thứ tự; useStream() nối văn bản theo thứ tự đó, và nếu tin đến lộn xộn, nó đợi tin còn thiếu rồi mới nối.

Tin cuối cùng mang dấu "xong" và, nếu vừa trong một tin, toàn bộ câu trả lời. Việc này dành cho người vào giữa chừng: Bob mở trang khi câu trả lời đã viết được một nửa, không thấy phần đầu, nhưng ngay khi tin cuối đến, màn hình của anh ấy chuyển thành câu trả lời đầy đủ. Ai bị rớt kết nối trong chốc lát cũng vậy.

Backend của bạnHibiraAliceBobtin 0–5phần đầutin 0–5Bob lúc này mới mở trang và đăng kýtin 6–9tin 6–9tin 6–9chỉ thấy nửa sautin 10: xong + toàn bộ câu trả lờixongtoàn bộ câu trả lờimàn hình của Bob đầy đủ
Bob vào khi câu trả lời đã viết được một nửa

Nếu câu trả lời quá dài để tin cuối mang hết, người vào giữa chừng sẽ thấy incomplete: true, và bạn có thể gọi API một lần để lấy câu trả lời đã lưu. Khi mô hình lỗi hoặc bị dừng, người đăng ký cũng nhận error, thay vì một biểu tượng "đang tạo" không bao giờ dừng.

Viết thành code thì thế này

React

import { HibiraProvider, useEvent, usePresence, useStream } from '@hibira-io/react';

export function App() {
  return (
    <HibiraProvider appKey="your app key" options={{ channelAuthorization: { endpoint: '/api/hibira/auth', transport: 'ajax' } }}>
      <Room id="spec-42" />
    </HibiraProvider>
  );
}

function Room({ id }) {
  const { members } = usePresence(`presence-room-${id}`);
  const { text, done } = useStream(`private-room-${id}-ai`);
  useEvent(`private-room-${id}`, 'comment.created', (comment) => addComment(comment));
  return (
    <>
      <Avatars members={members} />
      <Answer text={text} loading={!done} />
    </>
  );
}

Backend (Node.js, framework nào cũng được) đưa nguyên stream của mô hình cho streamToChannel(). Nó hiểu định dạng stream của Anthropic, OpenAI, Gemini và Vercel AI SDK:

import Pusher from 'pusher';
import { streamToChannel } from '@hibira-io/react/server';

const pusher = new Pusher({ appId, key, secret, host: 'ws.hibira.io', useTLS: true });
const stream = anthropic.messages.stream({ model, max_tokens: 1024, messages });
await streamToChannel(pusher, `private-room-${id}-ai`, stream, { meta: { asker: user.name } });

Vue

Chạy trên Vue 3 và Vue 2.7. Tên kênh có thể là ref hoặc getter, nên khi tham số route đổi, việc đăng ký cũng tự đổi theo:

// main.js
createApp(App)
  .use(createHibira({ appKey: import.meta.env.VITE_HIBIRA_KEY, options: { channelAuthorization: { endpoint: '/api/hibira/auth', transport: 'ajax' } } }))
  .mount('#app');
<script setup>
import { usePresence, useStream } from '@hibira-io/vue';

const props = defineProps({ id: String });
const { members, count } = usePresence(() => `presence-room-${props.id}`);
const { text, done } = useStream(() => `private-room-${props.id}-ai`);
</script>

Nuxt

Trên Nuxt 3 và Nuxt 4 chỉ cần thêm một dòng cấu hình module. Các composable được import tự động, và server route cũng có sẵn useHibiraServer() cùng streamToChannel():

// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@hibira-io/nuxt'],
});
// server/api/ask.post.ts
export default defineEventHandler(async (event) => {
  const { roomId, question } = await readBody(event);
  const stream = anthropic.messages.stream({ model, max_tokens: 1024, messages: [{ role: 'user', content: question }] });
  event.waitUntil(streamToChannel(useHibiraServer(event), `private-room-${roomId}-ai`, stream));
  return { ok: true };
});

Dự án vẫn dùng Nuxt 2 cũng không bị bỏ quên: Nuxt 2.16 trở lên chạy trên Vue 2.7, nên hãy dùng module @hibira-io/nuxt/nuxt2.

Chúng tôi kiểm thử thế nào

Cả ba package đều nhỏ và không phụ thuộc gì ngoài pusher-js. Nhưng chúng chạy trong rất nhiều môi trường, nên phạm vi kiểm thử rộng hơn nhiều so với bản thân code:

  • Unit test trên React 17, 18, 19 và Vue 3.5, Vue 2.7, mỗi phiên bản chạy một lượt.
  • Kiểm thử end-to-end: hai client kết nối tới máy chủ thật, kiểm tra presence vào và ra, client event, và một stream 200 token được ghép lại đầy đủ ở cả hai phía.
  • Mỗi bản Nuxt 4.6, 3.21 và 2.18 một ứng dụng nhỏ, mở trong Chromium với hai người dùng, kiểm tra kết nối, presence, sự kiện từ máy chủ và văn bản stream, không có lỗi console và không có cảnh báo hydration.

Bắt đầu

npm install @hibira-io/react pusher-js   # React
npm install @hibira-io/vue pusher-js     # Vue 3 / Vue 2.7
npx nuxi module add @hibira-io/nuxt      # Nuxt 3 / 4

Trước khi bắt đầu, hãy thiết lập vài thứ trong bảng điều khiển Hibira:

  • Tạo ứng dụng và giữ thông tin xác thực. App ID và key luôn có trên trang Overview của ứng dụng. Secret chỉ hiện một lần khi tạo ứng dụng, nên hãy lưu ngay vào biến môi trường của backend (với Nuxt: NUXT_PUBLIC_HIBIRA_KEY, NUXT_HIBIRA_APP_ID, NUXT_HIBIRA_SECRET). Key là công khai; secret chỉ nằm trên máy chủ.
  • Thêm allowed origins. Gói Free không dùng được *: hãy thêm địa chỉ trang web của bạn (khi phát triển, ví dụ http://localhost:3000) vào Allowed origins, nếu không trình duyệt sẽ không kết nối được.
  • Chuẩn bị route xác thực. Kênh private và presence cần /api/hibira/auth ở backend để kiểm tra ai đang đăng nhập và ký cho việc đăng ký. README của cả ba package đều có ví dụ để sao chép.
  • 4 KB mỗi tin. Trên gói Free mỗi sự kiện tối đa 4 KB. streamToChannel() đã tự giữ tin nhắn trong giới hạn này; sự kiện bạn tự phát thì cần để ý kích thước.

Muốn tự tái hiện màn hình của Alice và Bob, hãy dùng hai trình duyệt khác nhau, hoặc một cửa sổ thường và một cửa sổ ẩn danh, mỗi bên đăng nhập một người dùng khác. Hai tab trong cùng một trình duyệt thường là cùng một người dùng, và presence chỉ tính là một người.

Cả ba đều theo giấy phép MIT, mã nguồn trên GitHub: hibira-io/react, hibira-io/vue, hibira-io/nuxt. Chúng nói giao thức Pusher, nên dùng được với mọi máy chủ tương thích. Nếu dự án của bạn đã dùng Laravel Echo, chỉ cần truyền echo.connector.pusher vào là dùng chung kết nối với Echo.

Gói Free không cần thẻ tín dụng. Nếu bạn sắp thêm trợ lý AI vào sản phẩm, hoặc đang có cả đống useEffect viết tay để xử lý tin nhắn thời gian thực, hãy thử chuyển một trang sang các hooks này. Có ý tưởng gì, cứ mở issue trên GitHub để kể cho chúng tôi nghe.