Hướng dẫn khởi tạo javascript client và gửi tin nhắn qua socket với EzyChat
Back to ezychatEzyChat sử dụng EzyFox JavaScript Client để duy trì kết nối thời gian thực giữa trình duyệt và socket server. Quá trình khởi tạo client gồm bốn giai đoạn:
sequenceDiagram
actor Browser as Trình duyệt
participant API as Web API
participant Socket as Socket server
participant Chat as EzyChat app
Browser->>API: Lấy socket access token
API-->>Browser: accessToken
Browser->>Socket: Mở kết nối WebSocket
Socket-->>Browser: Handshake
Browser->>Socket: Đăng nhập bằng access token
Socket-->>Browser: Đăng nhập thành công
Browser->>Chat: Truy cập app ezychat
Chat-->>Browser: App sẵn sàng
Browser->>Chat: Gửi command message
Chat-->>Browser: Push message đã lưu
Client chỉ nên gửi tin nhắn sau khi đã đăng nhập và truy cập thành công app
ezychat.Cài đặt thư viện
Cài EzyFox ES6 Client bằng npm:
npm install ezyfox-es6-client
Sau đó import thư viện:
import Ezy from 'ezyfox-es6-client';
Các ví dụ trong bài sử dụng ES module. Nếu dự án nạp EzyFox Client bằng thẻ
<script>, tên các class toàn cục có thể khác, chẳng hạn EzyClientConfig, EzyClients và EzyCommand.Chuẩn bị cấu hình
EzyChat sử dụng các giá trị mặc định sau:
const ZONE_NAME = 'chat'; const APP_NAME = 'ezychat'; const Commands = { MESSAGE: 'message', TYPING: 'typing', CHAT_BOT: 'chatBot', CHAT_BOT_TYPING: 'botTyping', };
Ứng dụng cần cung cấp:
- URL của socket server.
- Hàm lấy socket access token.
- Tên trường chứa access token khi đăng nhập.
Ví dụ:
const socketOptions = { websocketUrl: 'wss://chat.example.com/ws', socketLoginField: 'accessToken', async getAccessToken() { const response = await fetch( '/ezylogin/api/v1/socket-access-token', { credentials: 'include', }, ); if (!response.ok) { throw new Error( `Can not get socket access token: `, ); } const data = await response.json(); return data.accessToken; }, };
Access token nên được lấy từ backend sau khi người dùng đã xác thực. Không nhúng token cố định vào mã JavaScript.
Nếu trang web dùng HTTPS, socket URL phải dùng
wss://.Khởi tạo EzyFox Client
Tạo cấu hình client với zone
chat:function createClientConfig() { const config = new Ezy.ClientConfig(); config.zoneName = ZONE_NAME; config.reconnect.maxReconnectCount = 32768; return config; }
Khởi tạo client:
const config = createClientConfig(); const clients = Ezy.Clients.getInstance(); const client = clients.newClient(config); const { setup } = client;
Ezy.Clients quản lý các client đã được tạo. Nếu ứng dụng có nhiều kết nối socket, mỗi client cần được quản lý riêng để tránh đăng ký handler trùng lặp.Theo dõi trạng thái kết nối
Một kết nối socket thường đi qua các trạng thái:
offline -> connecting -> online
Sự kiện
CONNECTION_SUCCESS chỉ cho biết kết nối transport đã được mở. Client vẫn phải hoàn thành handshake, đăng nhập và app access trước khi được xem là online.let connectionStatus = 'offline'; let chatApp = null; function changeConnectionStatus(status) { connectionStatus = status; console.log('Socket status:', status); } const connectionSuccessHandler = new Ezy.ConnectionSuccessHandler(); connectionSuccessHandler.postHandle = () => { changeConnectionStatus('connecting'); }; const connectionFailureHandler = new Ezy.ConnectionFailureHandler(); connectionFailureHandler.postHandle = () => { chatApp = null; changeConnectionStatus('offline'); }; const disconnectionHandler = new Ezy.DisconnectionHandler(); disconnectionHandler.preHandle = () => { chatApp = null; changeConnectionStatus('offline'); }; setup.addEventHandler( Ezy.EventType.CONNECTION_SUCCESS, connectionSuccessHandler, ); setup.addEventHandler( Ezy.EventType.CONNECTION_FAILURE, connectionFailureHandler, ); setup.addEventHandler( Ezy.EventType.DISCONNECTION, disconnectionHandler, );
Giao diện có thể dựa vào
connectionStatus để bật hoặc tắt nút gửi tin nhắn.Đăng nhập bằng access token
Sau khi handshake hoàn tất, client lấy access token và gửi lệnh đăng nhập:
const handshakeHandler = new Ezy.HandshakeHandler(); handshakeHandler.handleLogin = async () => { try { const accessToken = await socketOptions.getAccessToken(); const loginData = { [socketOptions.socketLoginField]: accessToken, }; const loginRequest = [ config.zoneName, 'useAccessToken', 'useAccessToken', loginData, ]; client.sendRequest( Ezy.Command.LOGIN, loginRequest, ); } catch (error) { chatApp = null; changeConnectionStatus('offline'); console.error('Socket login failed:', error); } }; setup.addDataHandler( Ezy.Command.HANDSHAKE, handshakeHandler, );
Hai giá trị username và password trong ví dụ chỉ đóng vai trò placeholder. Danh tính thật của người dùng được xác định từ access token.
Không gửi
userId, username hoặc loại người dùng do client tự khai báo để thay thế cho token.Truy cập EzyChat app
Sau khi đăng nhập thành công, server trả về thông tin danh tính socket. Client lưu lại thông tin này rồi yêu cầu truy cập app
ezychat:let currentUser = null; const loginSuccessHandler = new Ezy.LoginSuccessHandler(); loginSuccessHandler.handleLoginSuccess = (data) => { currentUser = { ...client.me, userType: data.socketUserType, userUuid: data.socketUserUuid, userUuidWithType: `#`, }; client.sendRequest( Ezy.Command.APP_ACCESS, [APP_NAME], ); }; setup.addDataHandler( Ezy.Command.LOGIN, loginSuccessHandler, );
Giá trị
userUuidWithType hữu ích khi client cần phân biệt sự kiện của chính mình với sự kiện từ thành viên khác.Tiếp theo, lưu app sau khi truy cập thành công:
const appAccessHandler = new Ezy.AppAccessHandler(); appAccessHandler.postHandle = (app, data) => { chatApp = app; changeConnectionStatus('online'); console.log('EzyChat app is ready', data); }; setup.addDataHandler( Ezy.Command.APP_ACCESS, appAccessHandler, );
Thời điểm
postHandle được gọi mới là lúc client sẵn sàng gửi các command của EzyChat.Đăng ký handler nhận tin nhắn
Handler của app nên được đăng ký trước khi gọi
connect():const chatSetup = setup.setupApp(APP_NAME); chatSetup.addDataHandler( Commands.MESSAGE, (app, data) => { console.log('Received message:', data); }, );
Command
message được sử dụng theo cả hai hướng:- Client gửi yêu cầu tạo tin nhắn.
- Server phát tin nhắn đã lưu về các thành viên trong kênh.
Server cũng phát message về cho chính người gửi. Client có thể dùng phản hồi này làm dữ liệu chính thức để cập nhật giao diện.
Ví dụ:
function handleIncomingMessage(message) { if (message.channelId === activeChannelId) { appendMessageToConversation(message); scrollConversationToBottom(); return; } increaseChannelUnreadCount(message.channelId); moveChannelToTop(message.channelId); } chatSetup.addDataHandler( Commands.MESSAGE, (app, message) => { handleIncomingMessage(message); }, );
Mở kết nối socket
Sau khi đã đăng ký toàn bộ handler, mở kết nối:
client.connect(socketOptions.websocketUrl);
Không nên gọi
connect() nhiều lần trong khi client đang kết nối.let connecting = false; function connect() { if (connecting || connectionStatus === 'online') { return; } connecting = true; changeConnectionStatus('connecting'); client.connect(socketOptions.websocketUrl); }
Khi app access thành công hoặc kết nối thất bại, cập nhật lại cờ:
appAccessHandler.postHandle = (app, data) => {
chatApp = app;
connecting = false;
changeConnectionStatus('online');
};
connectionFailureHandler.postHandle = () => {
chatApp = null;
connecting = false;
changeConnectionStatus('offline');
};
disconnectionHandler.preHandle = () => {
chatApp = null;
connecting = false;
changeConnectionStatus('offline');
};
Gửi tin nhắn văn bản
Payload tối thiểu gồm
channelId và message:function sendMessage(channelId, message) { const content = (message || '').trim(); if (!chatApp) { return false; } if (!(channelId > 0) || !content) { return false; } chatApp.send(Commands.MESSAGE, { channelId, message: content, repliedMessageId: 0, }); return true; }
Cách gọi:
const sent = sendMessage( 123, 'Xin chào, tôi cần được hỗ trợ.', ); if (sent) { clearMessageInput(); }
Một số phiên bản EzyFox JavaScript Client cung cấp
sendRequest thay cho send:chatApp.sendRequest( Commands.MESSAGE, payload, );
Hãy dùng phương thức được cung cấp bởi phiên bản SDK đang tích hợp. Với
ezyfox-es6-client, EzyChat hiện sử dụng app.send(...).Cấu trúc payload tin nhắn
Một payload đầy đủ có thể chứa các trường:
| Trường | Kiểu | Ý nghĩa |
|---|---|---|
channelId | Number | ID kênh nhận tin nhắn |
message | String | Nội dung tin nhắn |
contentType | String | TEXT hoặc JSON |
repliedMessageId | Number | ID tin nhắn đang được trả lời |
threadId | Number | ID thread |
parentId | Number | ID tin nhắn cha |
title | String | Tiêu đề tùy chọn |
mediaId | Number | Media chính |
mediaIds | Number[] | Danh sách media đính kèm |
links | String[] | Danh sách đường dẫn |
type | String | Loại tin nhắn nghiệp vụ |
templateId | Number | ID mẫu tin nhắn |
Nếu không truyền
contentType, server sử dụng TEXT.Không cần gửi thông tin người gửi. Server lấy danh tính từ phiên socket đã đăng nhập.
Gửi tin nhắn trả lời
Để trả lời một tin nhắn, truyền
repliedMessageId:function replyMessage( channelId, repliedMessageId, message, ) { const content = (message || '').trim(); if ( !chatApp || !(channelId > 0) || !(repliedMessageId > 0) || !content ) { return false; } chatApp.send(Commands.MESSAGE, { channelId, message: content, repliedMessageId, }); return true; }
Sau khi gửi, giao diện nên xóa trạng thái reply để tin nhắn tiếp theo không tiếp tục tham chiếu tin nhắn cũ.
Gửi tin nhắn kèm media
Media phải được upload trước. Sau khi API upload trả về ID, gửi các ID đó trong
mediaIds:function sendMessageWithMedia({ channelId, message, mediaIds, repliedMessageId = 0, }) { if (!chatApp || !(channelId > 0)) { return false; } const normalizedMediaIds = Array.isArray(mediaIds) ? mediaIds : []; const payload = { channelId, message: (message || '').trim(), repliedMessageId, }; if (normalizedMediaIds.length > 0) { payload.mediaIds = normalizedMediaIds; } chatApp.send(Commands.MESSAGE, payload); return true; }
Ví dụ:
sendMessageWithMedia({
channelId: 123,
message: 'Tôi gửi kèm ảnh lỗi.',
mediaIds: [901, 902],
});
Socket không upload nội dung file trong command
message. Nó chỉ gửi ID của các media đã tồn tại trong hệ thống.Gửi danh sách đường dẫn
Client có thể trích xuất URL trong nội dung và gửi kèm trường
links:const URL_PATTERN = /(https?://[^s]+)/g; function extractLinks(message) { const matches = message.match(URL_PATTERN); return matches ? [...new Set(matches)] : []; }
Tạo payload:
function createMessagePayload({ channelId, message, repliedMessageId = 0, mediaIds = [], }) { const content = (message || '').trim(); const links = extractLinks(content); return { channelId, message: content, repliedMessageId, ...(mediaIds.length > 0 && { mediaIds, }), ...(links.length > 0 && { links, }), }; }
Gửi nội dung JSON
EzyChat hỗ trợ
TEXT và JSON. Với JSON, trường message vẫn phải là chuỗi:
chatApp.send(Commands.MESSAGE, {
channelId: 123,
contentType: 'JSON',
message: JSON.stringify({
action: 'open-order',
orderId: 456,
}),
repliedMessageId: 0,
});
Server kiểm tra chuỗi JSON trước khi lưu. Request sẽ bị từ chối nếu
contentType không hợp lệ hoặc message không phải JSON hợp lệ.Gửi trạng thái đang nhập
Command
typing có payload nhẹ hơn:function sendTyping(channelId, typing) { if (!chatApp || !(channelId > 0)) { return false; } chatApp.send(Commands.TYPING, { channelId, typing, }); return true; }
Khi người dùng bắt đầu nhập:
messageInput.addEventListener('input', () => {
sendTyping(activeChannelId, true);
});
Trước khi gửi message, nên tắt trạng thái đang nhập:
function sendChatMessage(channelId, message) { sendTyping(channelId, false); return sendMessage(channelId, message); }
Đăng ký handler nhận trạng thái:
chatSetup.addDataHandler(
Commands.TYPING,
(app, data) => {
if (
data.senderUuid
=== currentUser?.userUuidWithType
) {
return;
}
showTypingStatus(
data.channelId,
data.typing,
);
},
);
Giao diện nên tự xóa trạng thái đang nhập sau một khoảng timeout. Điều này xử lý trường hợp client gửi
typing: true rồi mất kết nối trước khi gửi typing: false.Tạo lớp EzyChat socket client hoàn chỉnh
Có thể đóng gói toàn bộ logic trong một module:
import Ezy from 'ezyfox-es6-client'; const ZONE_NAME = 'chat'; const APP_NAME = 'ezychat'; const Commands = { MESSAGE: 'message', TYPING: 'typing', CHAT_BOT: 'chatBot', CHAT_BOT_TYPING: 'botTyping', }; export function createEzyChatClient(options) { const config = new Ezy.ClientConfig(); config.zoneName = ZONE_NAME; config.reconnect.maxReconnectCount = options.maxReconnectCount ?? 32768; const client = Ezy.Clients .getInstance() .newClient(config); const { setup } = client; const listeners = new Map(); let app = null; let currentUser = null; let connecting = false; let status = 'offline'; function emit(eventName, data) { const eventListeners = listeners.get(eventName) || []; eventListeners.forEach((listener) => { listener(data); }); } function setStatus(nextStatus) { status = nextStatus; emit('statusChanged', nextStatus); } function listen(eventName, listener) { const eventListeners = listeners.get(eventName) || []; eventListeners.push(listener); listeners.set(eventName, eventListeners); return () => { const currentListeners = listeners.get(eventName) || []; listeners.set( eventName, currentListeners.filter( (item) => item !== listener, ), ); }; } const connectionSuccessHandler = new Ezy.ConnectionSuccessHandler(); connectionSuccessHandler.postHandle = () => { setStatus('connecting'); }; const connectionFailureHandler = new Ezy.ConnectionFailureHandler(); connectionFailureHandler.postHandle = (event) => { app = null; connecting = false; setStatus('offline'); emit('connectionFailed', event); }; const disconnectionHandler = new Ezy.DisconnectionHandler(); disconnectionHandler.preHandle = (event) => { app = null; connecting = false; setStatus('offline'); emit('disconnected', event); }; const handshakeHandler = new Ezy.HandshakeHandler(); handshakeHandler.handleLogin = async () => { try { const accessToken = await options.getAccessToken(); client.sendRequest( Ezy.Command.LOGIN, [ config.zoneName, 'useAccessToken', 'useAccessToken', { [options.socketLoginField || 'accessToken']: accessToken, }, ], ); } catch (error) { app = null; connecting = false; setStatus('offline'); emit('loginFailed', error); } }; const loginSuccessHandler = new Ezy.LoginSuccessHandler(); loginSuccessHandler.handleLoginSuccess = (data) => { currentUser = { ...client.me, userType: data.socketUserType, userUuid: data.socketUserUuid, userUuidWithType: `` + `#`, }; client.sendRequest( Ezy.Command.APP_ACCESS, [APP_NAME], ); }; const appAccessHandler = new Ezy.AppAccessHandler(); appAccessHandler.postHandle = ( accessedApp, data, ) => { app = accessedApp; connecting = false; setStatus('online'); emit('ready', data); }; setup.addEventHandler( Ezy.EventType.CONNECTION_SUCCESS, connectionSuccessHandler, ); setup.addEventHandler( Ezy.EventType.CONNECTION_FAILURE, connectionFailureHandler, ); setup.addEventHandler( Ezy.EventType.DISCONNECTION, disconnectionHandler, ); setup.addDataHandler( Ezy.Command.HANDSHAKE, handshakeHandler, ); setup.addDataHandler( Ezy.Command.LOGIN, loginSuccessHandler, ); setup.addDataHandler( Ezy.Command.APP_ACCESS, appAccessHandler, ); const chatSetup = setup.setupApp(APP_NAME); chatSetup.addDataHandler( Commands.MESSAGE, (socketApp, data) => { emit('message', data); }, ); chatSetup.addDataHandler( Commands.TYPING, (socketApp, data) => { emit('typing', data); }, ); chatSetup.addDataHandler( Commands.CHAT_BOT, (socketApp, data) => { emit('chatBot', data); }, ); chatSetup.addDataHandler( Commands.CHAT_BOT_TYPING, (socketApp, data) => { emit('botTyping', data); }, ); function connect() { if (connecting || status === 'online') { return false; } connecting = true; setStatus('connecting'); client.connect(options.websocketUrl); return true; } function send(command, data) { if (!app || status !== 'online') { return false; } app.send(command, data); return true; } function sendMessage({ channelId, message, repliedMessageId = 0, mediaIds = [], links, contentType, }) { if (!(channelId > 0)) { return false; } const content = (message || '').trim(); const messageLinks = links || extractLinks(content); if (!content && mediaIds.length === 0) { return false; } sendTyping(channelId, false); return send(Commands.MESSAGE, { channelId, message: content, repliedMessageId, ...(contentType && { contentType, }), ...(mediaIds.length > 0 && { mediaIds, }), ...(messageLinks.length > 0 && { links: messageLinks, }), }); } function sendTyping(channelId, typing) { if (!(channelId > 0)) { return false; } return send(Commands.TYPING, { channelId, typing, }); } return { Commands, connect, listen, send, sendMessage, sendTyping, getStatus() { return status; }, getCurrentUser() { return currentUser; }, isReady() { return Boolean( app && status === 'online', ); }, }; } const URL_PATTERN = /(https?://[^s]+)/g; function extractLinks(message) { const matches = message.match(URL_PATTERN); return matches ? [...new Set(matches)] : []; }
Sử dụng client
Khởi tạo:
const chatClient = createEzyChatClient({ websocketUrl: 'wss://chat.example.com/ws', async getAccessToken() { const response = await fetch( '/ezylogin/api/v1/socket-access-token', { credentials: 'include', }, ); if (!response.ok) { throw new Error('Can not get access token'); } const data = await response.json(); return data.accessToken; }, });
Đăng ký các listener trước khi kết nối:
chatClient.listen('statusChanged', (status) => { updateConnectionIndicator(status); }); chatClient.listen('ready', () => { enableMessageInput(); }); chatClient.listen('message', (message) => { handleIncomingMessage(message); }); chatClient.listen('typing', (data) => { const currentUser = chatClient.getCurrentUser(); if ( data.senderUuid === currentUser?.userUuidWithType ) { return; } showTypingStatus( data.channelId, data.typing, ); }); chatClient.listen('disconnected', () => { disableMessageInput(); });
Mở kết nối:
chatClient.connect();
Gửi tin nhắn:
const sent = chatClient.sendMessage({ channelId: activeChannelId, message: messageInput.value, repliedMessageId: replyingMessage?.id || 0, mediaIds: uploadedMedia.map( (media) => media.id, ), }); if (sent) { messageInput.value = ''; replyingMessage = null; uploadedMedia = []; }
Chỉ xóa nội dung nhập khi
sendMessage() trả về true. Nếu socket chưa sẵn sàng, người dùng vẫn giữ được nội dung để gửi lại.Cập nhật giao diện khi nhận message
Client không nên tự coi yêu cầu vừa gửi là message cuối cùng. Server còn thực hiện các bước:
- Kiểm tra quyền gửi vào kênh.
- Kiểm tra trạng thái khóa.
- Lưu tin nhắn.
- Gán ID.
- Chuẩn hóa thông tin người gửi.
- Gắn thông tin media và tin nhắn được trả lời.
- Cập nhật trạng thái và số lượng chưa đọc.
- Phát kết quả tới các thành viên.
Vì vậy, cách đơn giản nhất là cập nhật danh sách tin nhắn khi nhận command
message từ server:
chatClient.listen('message', (message) => { if (message.channelId === activeChannelId) { messages.push(message); renderMessages(messages); return; } unreadCountByChannelId[message.channelId] = (unreadCountByChannelId[message.channelId] || 0) + 1; renderChannelList(); });
Nếu cần optimistic UI, hãy tạo ID tạm phía client và có cơ chế đối chiếu với message do server trả về. Nếu chỉ thêm cả bản tạm lẫn bản từ socket, giao diện sẽ hiển thị trùng tin nhắn.
Đồng bộ sau khi reconnect
Socket chỉ chuyển các sự kiện thời gian thực. Nó không thay thế API lấy lịch sử.
Sau khi reconnect và app access thành công, client nên tải lại các message gần nhất:
chatClient.listen('ready', async () => { const response = await fetch( `/api/channels//messages`, { credentials: 'include', }, ); if (!response.ok) { return; } const pagination = await response.json(); replaceMessages(pagination.items); });
Việc này giúp khôi phục những message được gửi trong lúc client mất kết nối.
Các lỗi thường gặp
WebSocket đã kết nối nhưng không gửi được message
Kết nối transport đã thành công nhưng client chưa hoàn tất đăng nhập hoặc app access. Chỉ gửi khi trạng thái là
online.Không nhận được tin nhắn vừa gửi
Kiểm tra handler cho command
message đã được đăng ký trên app ezychat trước khi kết nối.Server báo không tìm thấy kênh
Người dùng hiện tại không phải thành viên của
channelId, hoặc client đang dùng ID kênh cũ.Server báo kênh bị khóa
Thành viên không được phép gửi thêm tin nhắn trong kênh đó.
Đăng nhập thất bại
Kiểm tra access token còn hạn, field đăng nhập có đúng là
accessToken, zone có phải chat và token có được cấp cho đúng socket server hay không.Client kết nối lặp lại
Không gọi
connect() khi client đang ở trạng thái connecting hoặc online.Message xuất hiện hai lần
Giao diện vừa thêm message ngay khi bấm gửi, vừa thêm lần nữa khi server push kết quả. Hãy chọn dữ liệu server làm nguồn chính hoặc triển khai đối chiếu optimistic message.
Mất message trong thời gian offline
Sau khi reconnect, tải lại lịch sử gần nhất qua API. Không giả định socket server sẽ phát lại toàn bộ sự kiện cũ.
Checklist tích hợp
- Đã cài
ezyfox-es6-client. - Zone name là
chat. - App name là
ezychat. - WebSocket URL dùng
wss://trên môi trường HTTPS. - Access token được lấy từ backend.
- Handler được đăng ký trước khi gọi
connect(). - Client chỉ gửi message sau khi app access thành công.
- Payload có
channelIdhợp lệ. - Client không tự gửi
senderId. - Media được upload trước khi gửi
mediaIds. - Command
messageđược dùng để nhận dữ liệu chính thức từ server. - Trạng thái
typingđược tắt khi gửi message. - Client tải lại lịch sử sau reconnect.
- Nút gửi giữ nguyên nội dung nếu socket chưa sẵn sàng.