Loading article Tianwu is bringing the content over, slow and steady…
Shanverse A space to learn, build and share knowledge about software development.
© 2026 Shanverse. All rights reserved.
Built with Next.js and Notion.
Lập trình WebSocket thuần trên Browser và Node.js: Xây dựng Chat không cần thư viện | Shanverse
Home / Blog / Lập trình WebSocket thuần trên Browser và Node.js: Xây dựng Chat không cần thư viện Back to all articlesThis article is available in Vietnamese only.
Backend & Database
Lập trình WebSocket thuần trên Browser và Node.js: Xây dựng Chat không cần thư viện Hướng dẫn lập trình WebSocket không cần thư viện ngoài: Native WebSocket API trên Browser, WebSocket server Node.js thuần, handshake, frame parsing, ping/pong, Chat UI và bảo mật.
Shanverse20 Aug 2026 Updated 29 Sept 2026 13 min read— views Loading views 💬
Note
Browser có Native WebSocket client API. Node.js hiện đại cũng có WebSocket client , nhưng không cung cấp một WebSocket server API tương đương trong core. Vì vậy bài này tự xử lý HTTP Upgrade và WebSocket frames bằng http , crypto , Buffer và TextDecoder —không cài package ngoài.
Mục tiêu Sau bài này, chúng ta có một phòng Chat nhỏ:
Mermaid Written by
Shanverse Software engineer sharing practical notes about building products and systems.
new WebSocket()
.
Node xử lý handshake 101 Switching Protocols .
Server parse frame bị mask từ browser.
Hỗ trợ text message, continuation frame, ping, pong và close.
Broadcast message đến mọi client.
Heartbeat phát hiện connection chết.
UI dùng textContent để tránh XSS. Không dùng Express, Socket.IO hay package ws . ⚠️
Warning
Implementation WebSocket server thuần chỉ phù hợp để học protocol . Production nên dùng thư viện đã được fuzz-test và audit vì RFC còn nhiều edge case: extensions, compression, fragmentation, close semantics, backpressure, proxy behavior và denial-of-service.
Cấu trúc dự án
Protocol Chat của ứng dụng WebSocket chỉ vận chuyển text/binary; ứng dụng vẫn cần message schema.
Các type khác: welcome , system , error và presence .
Phần 1: WebSocket server Node.js thuần
Những phần quan trọng trong server
server.on('upgrade') Node HTTP server phát event upgrade thay vì tạo request thông thường. Ta kiểm tra method, path, Connection , Upgrade , version, key và Origin trước khi trả 101 .
head TCP là byte stream. Node có thể đã đọc một phần WebSocket frame đầu tiên cùng HTTP headers; phần dư nằm trong head . Bỏ qua nó có thể mất message đầu.
parseFrames() Một TCP chunk không tương đương một WebSocket frame:
Một frame có thể bị chia qua nhiều chunk. Nhiều frame có thể nằm trong cùng chunk. Một message có thể fragment thành nhiều frame. Vì vậy mỗi client giữ buffer , parser chỉ consume khi đủ byte và giữ phần còn lại cho lần data tiếp theo.
Masking Browser phải mask mọi frame client → server bằng key 4 byte. Server XOR từng byte:
Masking không phải encryption; production vẫn phải dùng wss:// .
Heartbeat Server gửi Ping mỗi 30 giây. Browser/WebSocket stack tự trả Pong ở protocol level. Nếu vòng sau chưa nhận Pong, server hủy socket chết.
Phần 2: Chat client bằng Native Browser WebSocket API
Chạy ứng dụng Mỗi tab tạo một WebSocket connection. Gửi message ở một tab; server broadcast đến tất cả tab.
Vòng đời Native WebSocket trên Browser Trước khi gửi dữ liệu lớn, kiểm tra bufferedAmount để tránh queue tăng vô hạn:
Test frame parser Hai JSON message trong một TCP chunk. Một frame bị chia qua nhiều chunk. Text message fragment thành nhiều frame. Ping xen giữa các fragment. Client gửi frame không mask → close 1002 . UTF-8 lỗi → close 1007 . Payload quá giới hạn → close 1009 . Client đóng tab → cleanup khỏi clients . Server restart → browser reconnect có backoff. Nhiều tab gửi cùng lúc → không corrupt buffer per-client.
Close code dùng trong demo
Những gì demo cố ý chưa hỗ trợ permessage-deflate và mọi RSV extension. Binary Chat message. Authentication/authorization thật. Persistent message history. Multi-room và private message. Rate limiting theo IP/user. Backpressure server hoàn chỉnh qua socket.write() / drain . Horizontal scaling qua Redis/NATS/Kafka. TLS termination và reverse proxy. Full validation mọi close payload edge case. Đó là lý do không nên xem code giáo dục này như WebSocket library production.
Nâng cấp authentication an toàn Browser API không cho tự đặt arbitrary Authorization header. Các phương án phù hợp:
Same-site HttpOnly session cookie + Origin allowlist. Browser gọi HTTPS endpoint lấy single-use ticket sống vài chục giây. Mở wss://.../ws?ticket=... ; server consume ticket một lần và tránh log URL. Authorize từng room/message sau handshake. Không dùng access token dài hạn trong query string.
Đưa qua Nginx Dùng HTTPS ở frontend để browser kết nối wss:// ; nếu trang HTTPS mở ws:// , browser sẽ chặn mixed content.
Scale nhiều Node.js process Set clients chỉ tồn tại trong một process. Khi có nhiều instance:
Mỗi node publish Chat event vào broker; mọi node subscribe và broadcast cho connection cục bộ. Sticky session không đồng bộ message giữa node và không thay thế broker.
Checklist trước khi production
Tổng kết ✅
Note
Tự viết server WebSocket là cách tốt để hiểu handshake, masking và framing. Nhưng giá trị của bài tập nằm ở kiến thức protocol—không phải việc thay thế một WebSocket library production đã được kiểm thử kỹ.
Code plain text
Copy1 native-websocket-chat/ 2 ├── server.js 3 └── public/ 4 └── index.html Code plain text
Copy1 Browser Native WebSocket 2 → HTTP Upgrade 3 → Node tự tính Sec-WebSocket-Accept 4 → parse masked client frames 5 → validate JSON 6 → broadcast server frames 7 → ping/pong + close + reconnect Code bash
Copy1 mkdir -p native-websocket-chat/public 2 cd native-websocket-chat Code json
Copy1 { "type" : "join" , "name" : "Son" } 2 { "type" : "chat" , "text" : "Hello!" } Code json
Copy1 { 2 "type" : "chat" , 3 "id" : "uuid" , 4 "name" : "Son" , 5 "text" : "Hello!" , 6 "sentAt" : "2026-08-20T03:19:00.000Z" 7 } Code javascript
Copy1 'use strict' ; 2 3 const http = require ( 'node:http' ); 4 const fs = require ( 'node:fs/promises' ); 5 const path = require ( 'node:path' Code javascript
Copy1 payload[i] ^= mask[i % 4 ]; Code javascript
Copy1 if (socket.bufferedAmount > 256 * 1024 ) { 2 console. warn ( 'Client đang gửi nhanh hơn network' ); 3 } Code javascript
Copy1 map $http_upgrade $connection_upgrade { 2 default upgrade; 3 '' close; 4 } 5 6 location / ws { 7 proxy_pass http : //127.0.0.1:3000; 8 proxy_http_version 1.1 ; 9 proxy_set_header Host $host; 10 proxy_set_header X - Forwarded - For $proxy_add_x_forwarded_for; 11 proxy_set_header Upgrade $http_upgrade; 12 proxy_set_header Connection $connection_upgrade; 13 proxy_read_timeout 65s; 14 } Code html
Copy1 <! doctype html > 2 < html lang = "vi" > 3 < head > 4 < meta charset = "utf-8" /> 5 < meta name = "viewport" content = "width=device-width, initial-scale=1" );
6 const { createHash , randomUUID } = require ( 'node:crypto' );
7 const { TextDecoder } = require ( 'node:util' );
8
9 const PORT = Number (process.env. PORT || 3000 );
10 const HOST = '0.0.0.0' ;
11 const WS_PATH = '/ws' ;
12 const MAX_MESSAGE_BYTES = 64 * 1024 ;
13 const GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' ;
14 const utf8Decoder = new TextDecoder ( 'utf-8' , { fatal: true });
15 const clients = new Set ();
16
17 function sendHttp ( socket , statusLine , headers = {}) {
18 const lines = [statusLine];
19 for ( const [ name , value ] of Object. entries (headers)) {
20 lines. push ( `${ name }: ${ value }` );
21 }
22 lines. push ( '' , '' );
23 socket. write (lines. join ( ' \r\n ' ));
24 }
25
26 function hasToken ( value , expected ) {
27 return String (value || '' )
28 . split ( ',' )
29 . map ( token => token. trim (). toLowerCase ())
30 . includes (expected. toLowerCase ());
31 }
32
33 function isValidWebSocketKey ( key ) {
34 if ( typeof key !== 'string' ) return false ;
35 try {
36 return Buffer. from (key, 'base64' ). length === 16 ;
37 } catch {
38 return false ;
39 }
40 }
41
42 function isAllowedOrigin ( origin , host ) {
43 const allowed = new Set ([
44 `http://${ host }` ,
45 `https://${ host }` ,
46 `http://localhost:${ PORT }` ,
47 `http://127.0.0.1:${ PORT }` ,
48 ]);
49 allowed. add ( 'http://' + host);
50 allowed. add ( 'https://' + host);
51 return typeof origin === 'string' && allowed. has (origin);
52 }
53
54 function createAccept ( key ) {
55 return createHash ( 'sha1' )
56 . update (key + GUID , 'ascii' )
57 . digest ( 'base64' );
58 }
59
60 function createFrame ( opcode , payload = Buffer. alloc ( 0 )) {
61 if ( ! Buffer. isBuffer (payload)) payload = Buffer. from (payload);
62
63 let header;
64 if (payload. length < 126 ) {
65 header = Buffer. alloc ( 2 );
66 header[ 1 ] = payload. length ;
67 } else if (payload. length <= 0xffff ) {
68 header = Buffer. alloc ( 4 );
69 header[ 1 ] = 126 ;
70 header. writeUInt16BE (payload. length , 2 );
71 } else {
72 header = Buffer. alloc ( 10 );
73 header[ 1 ] = 127 ;
74 header. writeBigUInt64BE ( BigInt (payload. length ), 2 );
75 }
76
77 header[ 0 ] = 0x80 | opcode; // FIN = 1
78 return Buffer. concat ([header, payload]);
79 }
80
81 function sendJson ( client , message ) {
82 if (client.socket.destroyed) return ;
83 const payload = Buffer. from ( JSON . stringify (message), 'utf8' );
84 client.socket. write ( createFrame ( 0x1 , payload));
85 }
86
87 function sendClose ( client , code = 1000 , reason = '' ) {
88 if (client.closeSent || client.socket.destroyed) return ;
89 client.closeSent = true ;
90
91 const safeReason = Buffer. from (reason, 'utf8' ). subarray ( 0 , 123 );
92 const payload = Buffer. alloc ( 2 + safeReason. length );
93 payload. writeUInt16BE (code, 0 );
94 safeReason. copy (payload, 2 );
95
96 client.socket. write ( createFrame ( 0x8 , payload));
97 }
98
99 function protocolError ( client , reason ) {
100 sendClose (client, 1002 , reason);
101 setTimeout (() => client.socket. destroy (), 100 ). unref ();
102 }
103
104 function broadcast ( message ) {
105 for ( const client of clients) sendJson (client, message);
106 }
107
108 function broadcastPresence () {
109 broadcast ({
110 type: 'presence' ,
111 count: clients.size,
112 });
113 }
114
115 function removeClient ( client ) {
116 if ( ! clients. delete (client)) return ;
117
118 if (client.name) {
119 broadcast ({ type: 'system' , text: `${ client . name } đã rời phòng.` });
120 }
121 broadcastPresence ();
122 }
123
124 function sanitizeName ( value ) {
125 return String (value || '' ). trim (). replace ( / \s + / g , ' ' ). slice ( 0 , 30 );
126 }
127
128 function sanitizeText ( value ) {
129 return String (value || '' ). trim (). slice ( 0 , 1000 );
130 }
131
132 function handleTextMessage ( client , payload ) {
133 let text;
134 try {
135 text = utf8Decoder. decode (payload);
136 } catch {
137 sendClose (client, 1007 , 'Invalid UTF-8' );
138 return ;
139 }
140
141 let message;
142 try {
143 message = JSON . parse (text);
144 } catch {
145 sendJson (client, { type: 'error' , text: 'JSON không hợp lệ.' });
146 return ;
147 }
148
149 if ( ! message || typeof message !== 'object' ) return ;
150
151 if (message.type === 'join' ) {
152 if (client.name) return ;
153
154 const name = sanitizeName (message.name);
155 if ( ! name) {
156 sendJson (client, { type: 'error' , text: 'Tên không hợp lệ.' });
157 return ;
158 }
159
160 client.name = name;
161 sendJson (client, { type: 'welcome' , id: client.id, name });
162 broadcast ({ type: 'system' , text: `${ name } đã vào phòng.` });
163 broadcastPresence ();
164 return ;
165 }
166
167 if (message.type === 'chat' ) {
168 if ( ! client.name) {
169 sendJson (client, { type: 'error' , text: 'Hãy join trước khi chat.' });
170 return ;
171 }
172
173 const chatText = sanitizeText (message.text);
174 if ( ! chatText) return ;
175
176 broadcast ({
177 type: 'chat' ,
178 id: randomUUID (),
179 senderId: client.id,
180 name: client.name,
181 text: chatText,
182 sentAt: new Date (). toISOString (),
183 });
184 }
185 }
186
187 function handleCompleteMessage ( client , opcode , payload ) {
188 if (payload. length > MAX_MESSAGE_BYTES ) {
189 sendClose (client, 1009 , 'Message too large' );
190 return ;
191 }
192
193 if (opcode === 0x1 ) {
194 handleTextMessage (client, payload);
195 } else if (opcode === 0x2 ) {
196 sendClose (client, 1003 , 'Binary is not supported' );
197 }
198 }
199
200 function parseFrames ( client , chunk ) {
201 client.buffer = Buffer. concat ([client.buffer, chunk]);
202
203 while ( true ) {
204 const buffer = client.buffer;
205 if (buffer. length < 2 ) return ;
206
207 const first = buffer[ 0 ];
208 const second = buffer[ 1 ];
209 const fin = Boolean (first & 0x80 );
210 const rsv = first & 0x70 ;
211 const opcode = first & 0x0f ;
212 const masked = Boolean (second & 0x80 );
213 let payloadLength = second & 0x7f ;
214 let offset = 2 ;
215
216 if (rsv !== 0 ) return protocolError (client, 'Unsupported extension' );
217 if ( ! masked) return protocolError (client, 'Client frame must be masked' );
218
219 if (payloadLength === 126 ) {
220 if (buffer. length < 4 ) return ;
221 payloadLength = buffer. readUInt16BE ( 2 );
222 offset = 4 ;
223 } else if (payloadLength === 127 ) {
224 if (buffer. length < 10 ) return ;
225 const size = buffer. readBigUInt64BE ( 2 );
226 if (size > BigInt ( MAX_MESSAGE_BYTES )) {
227 sendClose (client, 1009 , 'Message too large' );
228 return ;
229 }
230 payloadLength = Number (size);
231 offset = 10 ;
232 }
233
234 const isControl = opcode >= 0x8 ;
235 if (isControl && ( ! fin || payloadLength > 125 )) {
236 return protocolError (client, 'Invalid control frame' );
237 }
238
239 if (payloadLength > MAX_MESSAGE_BYTES ) {
240 sendClose (client, 1009 , 'Message too large' );
241 return ;
242 }
243
244 const totalLength = offset + 4 + payloadLength;
245 if (buffer. length < totalLength) return ;
246
247 const mask = buffer. subarray (offset, offset + 4 );
248 offset += 4 ;
249
250 const payload = Buffer. from (buffer. subarray (offset, offset + payloadLength));
251 for ( let i = 0 ; i < payload. length ; i += 1 ) {
252 payload[i] ^= mask[i % 4 ];
253 }
254
255 client.buffer = buffer. subarray (totalLength);
256
257 if (opcode === 0x8 ) {
258 if ( ! client.closeSent) client.socket. write ( createFrame ( 0x8 , payload));
259 client.socket. end ();
260 return ;
261 }
262
263 if (opcode === 0x9 ) {
264 client.socket. write ( createFrame ( 0xA , payload));
265 continue ;
266 }
267
268 if (opcode === 0xA ) {
269 client.isAlive = true ;
270 continue ;
271 }
272
273 if (opcode === 0x0 ) {
274 if (client.fragmentOpcode === null ) {
275 return protocolError (client, 'Unexpected continuation' );
276 }
277 client.fragments. push (payload);
278 client.fragmentBytes += payload. length ;
279
280 if (client.fragmentBytes > MAX_MESSAGE_BYTES ) {
281 sendClose (client, 1009 , 'Message too large' );
282 return ;
283 }
284
285 if (fin) {
286 const complete = Buffer. concat (client.fragments);
287 const originalOpcode = client.fragmentOpcode;
288 client.fragments = [];
289 client.fragmentBytes = 0 ;
290 client.fragmentOpcode = null ;
291 handleCompleteMessage (client, originalOpcode, complete);
292 }
293 continue ;
294 }
295
296 if (opcode !== 0x1 && opcode !== 0x2 ) {
297 return protocolError (client, 'Unsupported opcode' );
298 }
299
300 if (client.fragmentOpcode !== null ) {
301 return protocolError (client, 'Fragment sequence already open' );
302 }
303
304 if (fin) {
305 handleCompleteMessage (client, opcode, payload);
306 } else {
307 client.fragmentOpcode = opcode;
308 client.fragments = [payload];
309 client.fragmentBytes = payload. length ;
310 }
311 }
312 }
313
314 async function serveIndex ( res ) {
315 try {
316 const html = await fs. readFile (path. join (__dirname, 'public' , 'index.html' ));
317 res. writeHead ( 200 , {
318 'Content-Type' : 'text/html; charset=utf-8' ,
319 'Content-Length' : html. length ,
320 'Cache-Control' : 'no-store' ,
321 });
322 res. end (html);
323 } catch {
324 res. writeHead ( 500 ). end ( 'Cannot load index.html' );
325 }
326 }
327
328 const server = http. createServer (( req , res ) => {
329 if (req.method === 'GET' && req.url === '/' ) return serveIndex (res);
330 res. writeHead ( 404 , { 'Content-Type' : 'text/plain; charset=utf-8' });
331 res. end ( 'Not found' );
332 });
333
334 server. on ( 'upgrade' , ( req , socket , head ) => {
335 const url = new URL (req.url, 'http://' + (req.headers.host || 'localhost' )); // original base: `http://${req.headers.host || 'localhost'}`);
336 const key = req.headers[ 'sec-websocket-key' ];
337
338 const valid =
339 req.method === 'GET' &&
340 url.pathname === WS_PATH &&
341 hasToken (req.headers.connection, 'upgrade' ) &&
342 hasToken (req.headers.upgrade, 'websocket' ) &&
343 req.headers[ 'sec-websocket-version' ] === '13' &&
344 isValidWebSocketKey (key) &&
345 isAllowedOrigin (req.headers.origin, req.headers.host);
346
347 if ( ! valid) {
348 sendHttp (socket, 'HTTP/1.1 400 Bad Request' , { Connection: 'close' });
349 socket. destroy ();
350 return ;
351 }
352
353 const accept = createAccept (key);
354 sendHttp (socket, 'HTTP/1.1 101 Switching Protocols' , {
355 Upgrade: 'websocket' ,
356 Connection: 'Upgrade' ,
357 'Sec-WebSocket-Accept' : accept,
358 });
359
360 const client = {
361 id: randomUUID (),
362 name: null ,
363 socket,
364 buffer: Buffer. alloc ( 0 ),
365 fragments: [],
366 fragmentBytes: 0 ,
367 fragmentOpcode: null ,
368 isAlive: true ,
369 closeSent: false ,
370 };
371
372 clients. add (client);
373 socket. setNoDelay ( true );
374 socket. setTimeout ( 0 );
375
376 socket. on ( 'data' , chunk => parseFrames (client, chunk));
377 socket. on ( 'close' , () => removeClient (client));
378 socket. on ( 'end' , () => removeClient (client));
379 socket. on ( 'error' , () => removeClient (client));
380
381 if (head. length > 0 ) parseFrames (client, head);
382 sendJson (client, { type: 'system' , text: 'Đã kết nối. Hãy chọn tên.' });
383 broadcastPresence ();
384 });
385
386 const heartbeat = setInterval (() => {
387 for ( const client of clients) {
388 if ( ! client.isAlive) {
389 client.socket. destroy ();
390 continue ;
391 }
392
393 client.isAlive = false ;
394 client.socket. write ( createFrame ( 0x9 ));
395 }
396 }, 30_000 );
397 heartbeat. unref ();
398
399 server. listen ( PORT , HOST , () => {
400 console. log ( `Chat: http://localhost:${ PORT }` );
401 });
402
403 function shutdown () {
404 clearInterval (heartbeat);
405 for ( const client of clients) sendClose (client, 1001 , 'Server shutdown' );
406 server. close (() => process. exit ( 0 ));
407 setTimeout (() => process. exit ( 1 ), 5_000 ). unref ();
408 }
409
410 process. on ( 'SIGINT' , shutdown);
411 process. on ( 'SIGTERM' , shutdown);
/>
6 < title >Native WebSocket Chat</ title >
7 < style >
8 :root { color-scheme : light dark ; font-family : system-ui , sans-serif ; }
9 body { max-width : 760 px ; margin : 32 px auto ; padding : 0 16 px ; }
10 header , form { display : flex ; gap : 8 px ; align-items : center ; }
11 header { justify-content : space-between ; }
12 #messages { min-height : 360 px ; max-height : 60 vh ; overflow-y : auto ;
13 border : 1 px solid #8885 ; border-radius : 12 px ; padding : 12 px ; list-style : none ; }
14 #messages li { padding : 6 px 8 px ; border-bottom : 1 px solid #8883 ; }
15 #messages .system { opacity : .7 ; font-style : italic ; }
16 input { flex : 1 ; padding : 10 px 12 px ; }
17 button { padding : 10 px 14 px ; cursor : pointer ; }
18 #status [ data-state = "open" ] { color : #20a060 ; }
19 #status [ data-state = "closed" ] { color : #d05050 ; }
20 </ style >
21 </ head >
22 < body >
23 < header >
24 < h1 >Native WebSocket Chat</ h1 >
25 < span id = "status" data-state = "closed" >Disconnected</ span >
26 </ header >
27
28 < p >< span id = "presence" >0</ span > kết nối</ p >
29 < ul id = "messages" aria-live = "polite" ></ ul >
30
31 < form id = "chat-form" >
32 < input id = "message" maxlength = "1000" autocomplete = "off"
33 placeholder = "Nhập tin nhắn..." disabled />
34 < button id = "send" disabled >Gửi</ button >
35 </ form >
36
37 < script >
38 const statusEl = document. querySelector ( '#status' );
39 const presenceEl = document. querySelector ( '#presence' );
40 const messagesEl = document. querySelector ( '#messages' );
41 const form = document. querySelector ( '#chat-form' );
42 const input = document. querySelector ( '#message' );
43 const sendButton = document. querySelector ( '#send' );
44
45 const name = ( prompt ( 'Tên của bạn?' ) || 'Guest' ). trim (). slice ( 0 , 30 );
46 let socket;
47 let retry = 0 ;
48 let reconnectTimer;
49 let manuallyClosed = false ;
50
51 function appendMessage ( text , className = '' ) {
52 const item = document. createElement ( 'li' );
53 item.className = className;
54 item.textContent = text; // Không dùng innerHTML với dữ liệu người dùng.
55 messagesEl. append (item);
56 messagesEl.scrollTop = messagesEl.scrollHeight;
57 }
58
59 function setConnected ( connected ) {
60 statusEl.textContent = connected ? 'Connected' : 'Disconnected' ;
61 statusEl.dataset.state = connected ? 'open' : 'closed' ;
62 input.disabled = ! connected;
63 sendButton.disabled = ! connected;
64 if (connected) input. focus ();
65 }
66
67 function connect () {
68 clearTimeout (reconnectTimer);
69 const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:' ;
70 socket = new WebSocket ( `${ protocol }//${ location . host }/ws` );
71
72 socket. addEventListener ( 'open' , () => {
73 retry = 0 ;
74 setConnected ( true );
75 socket. send ( JSON . stringify ({ type: 'join' , name }));
76 });
77
78 socket. addEventListener ( 'message' , event => {
79 let message;
80 try {
81 message = JSON . parse (event.data);
82 } catch {
83 appendMessage ( 'Server gửi JSON không hợp lệ.' , 'system' );
84 return ;
85 }
86
87 if (message.type === 'chat' ) {
88 const time = new Date (message.sentAt). toLocaleTimeString ();
89 appendMessage ( `[${ time }] ${ message . name }: ${ message . text }` );
90 } else if (message.type === 'system' ) {
91 appendMessage (message.text, 'system' );
92 } else if (message.type === 'error' ) {
93 appendMessage ( `Lỗi: ${ message . text }` , 'system' );
94 } else if (message.type === 'presence' ) {
95 presenceEl.textContent = String (message.count);
96 }
97 });
98
99 socket. addEventListener ( 'close' , event => {
100 setConnected ( false );
101 appendMessage ( `Mất kết nối (${ event . code }).` , 'system' );
102
103 if ( ! manuallyClosed) {
104 const delay = Math. min ( 1000 * 2 ** retry, 15_000 );
105 const jitter = Math. random () * 500 ;
106 retry += 1 ;
107 reconnectTimer = setTimeout (connect, delay + jitter);
108 }
109 });
110
111 socket. addEventListener ( 'error' , () => {
112 // Event close sẽ xử lý reconnect.
113 });
114 }
115
116 form. addEventListener ( 'submit' , event => {
117 event. preventDefault ();
118 const text = input.value. trim ();
119 if ( ! text || socket.readyState !== WebSocket. OPEN ) return ;
120
121 socket. send ( JSON . stringify ({ type: 'chat' , text }));
122 input.value = '' ;
123 input. focus ();
124 });
125
126 window. addEventListener ( 'beforeunload' , () => {
127 manuallyClosed = true ;
128 socket?. close ( 1000 , 'Page closed' );
129 });
130
131 connect ();
132 </ script >
133 </ body >
134 </ html >
Related posts More articles with related topics and technologies.
Backend & Database
#Collaborative Editing
#Real-time
+1
Hướng dẫn kiến trúc và triển khai Live Presence, multiplayer cursors và Collaborative Editing bằng Yjs CRDT, WebSocket, awareness, Redis, persistence và scale-out.
Backend & Database
Hướng dẫn chi tiết về WebSocket cho người mới bắt đầu, bao gồm khái niệm, cách hoạt động và ứng dụng thực tế.
Shan Dev Backend & Database
#WebSocket
#Server-Sent Events
+1
Hướng dẫn xây real-time interactive quiz chịu tải lớn bằng WebSocket/SSE, Redis Pub/Sub/Streams, horizontal scaling, reconnect, backpressure, anti-cheat và load testing.