Học từ Facebook #22: MQTT ngay trong trình duyệt, tự chuyển sang long-poll
Messenger trên web nói MQTT nhị phân qua WebSocket, tự lùi về fetch streaming khi WebSocket bị chặn, và một bug mất tin ở chỗ ghép byte giữa các chunk.
Realtime trên web thường đồng nghĩa với "mở WebSocket, gửi JSON". Messenger trên facebook.com làm khác: nó nói MQTT, giao thức nhị phân vốn dành cho thiết bị IoT, qua WebSocket. Khi WebSocket không chạy được (proxy công ty, firewall, mạng di động lạ), client tự lùi về long-poll bằng fetch streaming. Cả hai đường đều đi qua cùng một bộ mã hoá MQTT viết bằng JavaScript.
Bài này mổ xẻ ba phần: định dạng gói MQTT, cách giải một luồng byte (nơi bản gốc có bug làm mất tin), và quy tắc quyết định khi nào bỏ WebSocket.
Tip
Tóm tắt
- MQTT gói tin rất gọn: 1 byte header (loại gói + cờ), độ dài dạng varint 1–4 byte, rồi phần thân. Một tin chat nhỏ chỉ thêm khoảng 10 byte overhead.
- Đọc từ một luồng thì một gói có thể bị cắt giữa hai chunk. Bộ giải phải giữ phần dư và ghép với chunk sau. Đường WebSocket của FB làm đúng, đường fetch streaming thì vứt phần dư.
- Chuyển sang long-poll khi: không có WebSocket, lỗi fatal, lỗi 3 lần, hoặc chờ quá 3s. Nếu phiên trước đã phải long-poll thì chỉ chờ 1s hoặc 1 lần lỗi.
- Thử lại theo backoff
max(0, 2^n·(1000+rand·100) − 2000): hai lần đầu gần như ngay lập tức, trần khoảng 1 phút.
▶ Mở demo toàn màn hình ↗ · Sửa code trên StackBlitz ↗ · Xem source trên GitHub ↗
#Facebook làm thế nào?
Trong facebook-codebase có cả một họ module Mqtt* (bundle 16.js, một bản khác ở 115.js và 240.js): MqttProtocolCodec, MqttProtocolUtils, MqttProtocolClient (đường WebSocket), MqttLongPollingRunner, MqttFetchClient, MqttLongPollingClient, MqttUserName… Tất cả là code của Facebook, không dùng thư viện MQTT có sẵn nào.
#Codec: MQTT 3.1 "cổ"
p = Object.freeze({ CONNECT: 1, CONNACK: 2, PUBLISH: 3, PUBACK: 4, SUBSCRIBE: 8, SUBACK: 9,
UNSUBSCRIBE: 10, UNSUBACK: 11, PINGREQ: 12, PINGRESP: 13, DISCONNECT: 14 }),
q = [0, 6, 77, 81, 73, 115, 100, 112, 3];
js
q là phần đầu gói CONNECT: độ dài 6, chuỗi "MQIsdp" (77 81 73 115 100 112), protocol level 3. Đây là tên giao thức của MQTT 3.1, bản trước khi chuẩn hoá thành 3.1.1 ("MQTT", level 4). Có lẽ để tương thích với hạ tầng MQTT sẵn có của app di động.
Gói CONNECT đặt cờ 2 | 128 (clean session + có username) và không có password. Mọi thông tin phiên nằm trong userName, là một chuỗi JSON do MqttUserName.gen tạo:
JSON.stringify({ u: userId, s: sessionId, chat_on, fg: document.hasFocus(), d: deviceId,
ct: "websocket", aid: appId, st: subscribedTopics, pm: pendingPublishes, ... })
js
Nhét danh sách topic (st) và publish đang chờ (pm) vào ngay gói CONNECT giúp tiết kiệm một vòng SUBSCRIBE sau khi kết nối.
Giải mã một gói (đã đổi tên biến):
function decodeOne(a, pos) {
const header = a[pos], type = header >> 4;
const len = decodeMultiByteInt(a, pos + 1);
if (len == null) return { wireMessage: null, position: pos }; // varint chưa đủ byte
const end = len.offset + len.value;
if (end > a.length) return { wireMessage: null, position: pos }; // thân chưa đủ byte
switch (type) {
case PUBLISH:
const flags = header & 15, qos = flags >> 1 & 3; ...
}
}
function decodeByteMessages(a) {
... while (c < a.length) { const r = decodeOne(a, c); c = r.position; if (r.wireMessage) b.push(...); else break; }
return { messages: b, remaining: c < a.length ? a.subarray(c) : null };
}
js
decodeByteMessages trả về cả remaining: phần đuôi chưa đủ một gói. Đây là chỗ quan trọng nhất của cả bài.
#Hai đường đọc, hai cách xử lý remaining
Đường WebSocket (MqttProtocolClient) làm đúng:
a = new Uint8Array(event.data);
this.$10 != null && (a = concatBuffers(this.$10, a), bumpCounter("protocol.debug.usingMessagesBuffer"), this.$10 = null);
a = decodeByteMessages(a);
this.$10 = a.remaining; // giữ lại, ghép với message sau
js
Có hẳn một counter usingMessagesBuffer để đo xem trường hợp này xảy ra bao nhiêu. Tức là server có gửi một gói MQTT cắt qua hai message WebSocket.
Đường long-poll mới bằng fetch streaming (MqttFetchClient) thì không:
readLoop(reader) { reader.read().then(({ done, value }) => { this.onData(value); this.readLoop(reader); }) }
onData(chunk) {
const messages = decodeByteMessages(new Uint8Array(chunk)).messages; // remaining bị bỏ
...
}
js
Mỗi chunk được giải độc lập. Một gói bị cắt ở cuối chunk thì mất. Tệ hơn, chunk tiếp theo bắt đầu giữa gói đó: byte đầu tiên bị đọc như header, ra loại gói vô nghĩa, codec ném Invalid MQTT message type và client đóng luồng (dataDecodeException) để poll lại. Với tin chat ngắn và chunk lớn thì hiếm gặp; với tin dài hoặc mạng cắt nhỏ thì gặp thường xuyên. Đường XHR long-poll cũ (MqttLongPollingClient) không bị vì nó nhận trọn response một lần.
#Khi nào bỏ WebSocket
MqttLongPollingRunner đánh giá lại lúc khởi động, sau 1100ms, sau 3100ms và sau mỗi lần connect lỗi:
shouldPoll() {
if (!wsPollingEnabled) return false;
if (pollNow) return true;
if (!hasWSSupport) return true;
if (wsFatal) return true;
if (hasSuccess) return false; // WS đã từng chạy thì không lùi
if (fastLP) {
const elapsed = Date.now() - startedAt;
if (lastStatus === "yes") { if (failures >= 1) return true; if (elapsed > 1000) return true; }
else if (elapsed > 3000) return true;
}
return failures >= 3;
}
js
lastStatus đọc từ cấu hình lưu bền last_lp: poll thành công thì ghi "yes", WebSocket kết nối được thì xoá. Người dùng ngồi sau firewall chặn WebSocket chỉ chờ 3 giây ở lần đầu; từ lần sau chỉ còn 1 giây.
Thử lại dùng exponentialBackoff:
const delay = Math.max(0, Math.pow(2, Math.min(n, 6)) * (1000 + random() * 100) - 2000);
js
Trừ 2000ms làm hai lần đầu gần như tức thì (0 và 0–200ms), sau đó 2s, 6s, 14s, 30s, rồi kẹp ở khoảng 62–68s. Phần random() * 100 là jitter: hàng triệu client mất kết nối cùng lúc không đồng loạt kết nối lại.
#Bên trong trình duyệt: vì sao luồng bị cắt ở chỗ bất kỳ
TCP là luồng byte, không có khái niệm "tin". Response streaming của fetch đưa dữ liệu lên JavaScript theo từng chunk, kích thước tuỳ vào cách server flush, các proxy ở giữa, nén, và lúc trình duyệt đọc socket. Không có gì đảm bảo một chunk chứa trọn một gói MQTT. WebSocket thì có khung message, nhưng server vẫn có thể chia một gói MQTT ra nhiều message, như counter usingMessagesBuffer cho thấy.
sequenceDiagram participant S as "Server" participant N as "Mạng / proxy" participant C as "Client" S->>N: "Gói A (40B) + Gói B (230B) + Gói C (40B)" N->>C: "chunk 1: A + 60B đầu của B" N->>C: "chunk 2: 170B còn lại của B + C" Note over C: "Giải từng chunk: được A, mất B, chunk 2 bắt đầu giữa B nên lỗi" Note over C: "Giữ phần dư: ghép 60B + chunk 2, được B và C"
#Viết lại
Codec viết lại gồm varint, encode các gói hay dùng, và decodeOne giải một gói theo đúng cách của FB ở trên (trả lại vị trí cũ nếu gói chưa trọn). Phần đáng xem là giải nhiều gói và bộ giải cho luồng:
src/lib/mqttCodec.ts
/**
* Giải mọi gói trọn vẹn trong `bytes`. Phần đuôi chưa đủ một gói trả về trong `remaining`
* (giống decodeByteMessages của FB) — NGƯỜI GỌI phải giữ lại và ghép với chunk sau.
*/
export function decodeMessages(bytes: Uint8Array): { messages: WireMessage[]; remaining: Uint8Array | null } {
const messages: WireMessage[] = [];
let at = 0;
while (at < bytes.length) {
const { message, next } = decodeOne(bytes, at);
if (!message) break;
messages.push(message);
at = next;
}
return { messages, remaining: at < bytes.length ? bytes.slice(at) : null };
}
/**
* Bộ giải cho luồng byte (WebSocket, fetch streaming): tự ghép phần dư giữa các chunk.
* Đây là chỗ MqttFetchClient của FB bỏ sót: nó giải từng chunk riêng và vứt `remaining`.
*/
export class MqttStreamDecoder {
private pending: Uint8Array | null = null;
push(chunk: Uint8Array): WireMessage[] {
let bytes = chunk;
if (this.pending) {
bytes = new Uint8Array(this.pending.length + chunk.length);
bytes.set(this.pending);
bytes.set(chunk, this.pending.length);
}
const { messages, remaining } = decodeMessages(bytes);
this.pending = remaining;
return messages;
}
/** số byte đang chờ ghép (để debug / hiển thị) */
get pendingBytes() {
return this.pending?.length ?? 0;
}
}
ts
Quy tắc chọn transport:
src/lib/mqttTransport.ts
export function shouldLongPoll(s: TransportState): Decision {
if (!s.wsSupported) return { longPoll: true, reason: 'không có WebSocket' };
if (s.wsFatal) return { longPoll: true, reason: 'WebSocket lỗi fatal' };
// đã chạy được WS thì không bao giờ tự lùi về long-poll
if (s.wsConnected) return { longPoll: false, reason: 'WebSocket đã kết nối' };
if (s.lastSessionUsedLongPoll) {
if (s.wsFailures >= 1) return { longPoll: true, reason: 'phiên trước long-poll + WS lỗi 1 lần' };
if (s.elapsedMs > FAST_DELAY_MS) return { longPoll: true, reason: `phiên trước long-poll + chờ WS > ${FAST_DELAY_MS}ms` };
} else if (s.elapsedMs > REGULAR_DELAY_MS) {
return { longPoll: true, reason: `chờ WS > ${REGULAR_DELAY_MS}ms` };
}
if (s.wsFailures >= MAX_WS_FAILURES) return { longPoll: true, reason: `WS lỗi ${MAX_WS_FAILURES} lần` };
return { longPoll: false, reason: 'tiếp tục chờ WebSocket' };
}
ts
Xem toàn bộ src/lib/mqttCodec.ts
src/lib/mqttCodec.ts
/**
* Codec nhị phân MQTT 3.1 tối giản chạy trong trình duyệt
* (ý tưởng từ MqttProtocolCodec / MqttProtocolUtils của Facebook, dùng cho chat realtime).
*
* Một gói MQTT: [1 byte header][độ dài còn lại: varint 1–4 byte][phần thân]
* Byte header: 4 bit cao = loại gói, 4 bit thấp = cờ (DUP, QoS 2 bit, RETAIN).
*/
export const MessageType = {
CONNECT: 1,
CONNACK: 2,
PUBLISH: 3,
PUBACK: 4,
SUBSCRIBE: 8,
SUBACK: 9,
PINGREQ: 12,
PINGRESP: 13,
DISCONNECT: 14,
} as const;
export type MessageTypeName = keyof typeof MessageType;
const TYPE_NAME: Record<number, MessageTypeName> = Object.fromEntries(
Object.entries(MessageType).map(([name, code]) => [code, name as MessageTypeName]),
);
/** Tên giao thức "MQIsdp" + protocol level 3 = MQTT 3.1 (FB dùng đúng mảng này). */
const PROTOCOL_HEADER = [0, 6, 77, 81, 73, 115, 100, 112, 3];
const encoder = new TextEncoder();
const decoder = new TextDecoder();
// ---- varint "độ dài còn lại": 7 bit dữ liệu mỗi byte, bit cao = còn byte nữa ----
export function encodeLength(value: number): number[] {
if (value < 0 || value > 268_435_455) throw new RangeError(`MQTT length out of range: ${value}`);
const out: number[] = [];
do {
let byte = value % 128;
value = Math.floor(value / 128);
if (value > 0) byte |= 0x80;
out.push(byte);
} while (value > 0);
return out;
}
/** Trả null nếu mảng hết trước khi varint kết thúc: gói chưa về đủ. */
export function decodeLength(bytes: Uint8Array, offset: number): { value: number; offset: number } | null {
let value = 0;
let multiplier = 1;
for (let i = 0; i < 4; i++) {
if (offset >= bytes.length) return null;
const byte = bytes[offset++];
value += (byte & 0x7f) * multiplier;
if ((byte & 0x80) === 0) return { value, offset };
multiplier *= 128;
}
throw new Error('Malformed MQTT remaining length (more than 4 bytes)');
}
function u16(value: number) {
return [(value >> 8) & 0xff, value & 0xff];
}
function str(value: string) {
const bytes = encoder.encode(value);
return [...u16(bytes.length), ...bytes];
}
function frame(type: number, flags: number, body: number[] | Uint8Array): Uint8Array {
const length = encodeLength(body.length);
const out = new Uint8Array(1 + length.length + body.length);
out[0] = (type << 4) | (flags & 0x0f);
out.set(length, 1);
out.set(body, 1 + length.length);
return out;
}
// ---- encode ----
export function encodeConnect(clientId: string, userName: string, keepAliveSeconds = 60) {
// cờ 0x82 = username có mặt + clean session (FB: 2 | 128). FB không dùng password:
// userName là một chuỗi JSON (MqttUserName: user id, session, topic đã subscribe, tab có focus không…)
return frame(MessageType.CONNECT, 0, [
...PROTOCOL_HEADER,
0x82,
...u16(keepAliveSeconds),
...str(clientId),
...str(userName),
]);
}
export function encodeSubscribe(messageId: number, topic: string, qos: 0 | 1 = 0) {
return frame(MessageType.SUBSCRIBE, 0b0010, [...u16(messageId), ...str(topic), qos]);
}
export function encodePublish(topic: string, payload: string | Uint8Array, qos: 0 | 1 = 0, messageId?: number) {
if (qos !== 0 && qos !== 1) throw new TypeError(`Unsupported QoS ${qos}`);
if (qos === 1 && messageId == null) throw new TypeError('QoS 1 needs a messageId');
const data = typeof payload === 'string' ? encoder.encode(payload) : payload;
const body = [...str(topic), ...(qos === 1 ? u16(messageId!) : [])];
const full = new Uint8Array(body.length + data.length);
full.set(body);
full.set(data, body.length);
return frame(MessageType.PUBLISH, qos << 1, full);
}
export const encodePuback = (messageId: number) => frame(MessageType.PUBACK, 0, u16(messageId));
export const encodePingreq = () => frame(MessageType.PINGREQ, 0, []);
export const encodeDisconnect = () => frame(MessageType.DISCONNECT, 0, []);
// ---- decode ----
export type WireMessage =
| { type: 'PUBLISH'; topic: string; payload: Uint8Array; qos: number; messageId: number | null; retain: boolean; dup: boolean }
| { type: 'CONNACK'; sessionPresent: boolean; returnCode: number }
| { type: 'PUBACK' | 'SUBACK'; messageId: number }
| { type: 'PINGREQ' | 'PINGRESP' | 'DISCONNECT' }
| { type: 'CONNECT' | 'SUBSCRIBE'; raw: Uint8Array };
/** Giải một gói bắt đầu ở `start`. Gói chưa đủ byte thì trả message null và vị trí cũ. */
function decodeOne(bytes: Uint8Array, start: number): { message: WireMessage | null; next: number } {
const header = bytes[start];
const type = header >> 4;
const len = decodeLength(bytes, start + 1);
if (len == null) return { message: null, next: start };
const bodyStart = len.offset;
const end = bodyStart + len.value;
if (end > bytes.length) return { message: null, next: start };
const read16 = (at: number) => (bytes[at] << 8) | bytes[at + 1];
switch (TYPE_NAME[type]) {
case 'PUBLISH': {
const flags = header & 0x0f;
const qos = (flags >> 1) & 0b11;
let at = bodyStart;
const topicLength = read16(at);
at += 2;
const topic = decoder.decode(bytes.subarray(at, at + topicLength));
at += topicLength;
let messageId: number | null = null;
if (qos > 0) {
messageId = read16(at);
at += 2;
}
return {
message: { type: 'PUBLISH', topic, payload: bytes.slice(at, end), qos, messageId, retain: (flags & 1) === 1, dup: (flags & 8) === 8 },
next: end,
};
}
case 'CONNACK':
return { message: { type: 'CONNACK', sessionPresent: (bytes[bodyStart] & 1) === 1, returnCode: bytes[bodyStart + 1] }, next: end };
case 'PUBACK':
case 'SUBACK':
return { message: { type: TYPE_NAME[type] as 'PUBACK' | 'SUBACK', messageId: read16(bodyStart) }, next: end };
case 'PINGREQ':
case 'PINGRESP':
case 'DISCONNECT':
return { message: { type: TYPE_NAME[type] as 'PINGREQ' | 'PINGRESP' | 'DISCONNECT' }, next: end };
case 'CONNECT':
case 'SUBSCRIBE':
return { message: { type: TYPE_NAME[type] as 'CONNECT' | 'SUBSCRIBE', raw: bytes.slice(start, end) }, next: end };
default:
throw new Error(`Invalid MQTT message type ${type}`);
}
}
/**
* Giải mọi gói trọn vẹn trong `bytes`. Phần đuôi chưa đủ một gói trả về trong `remaining`
* (giống decodeByteMessages của FB) — NGƯỜI GỌI phải giữ lại và ghép với chunk sau.
*/
export function decodeMessages(bytes: Uint8Array): { messages: WireMessage[]; remaining: Uint8Array | null } {
const messages: WireMessage[] = [];
let at = 0;
while (at < bytes.length) {
const { message, next } = decodeOne(bytes, at);
if (!message) break;
messages.push(message);
at = next;
}
return { messages, remaining: at < bytes.length ? bytes.slice(at) : null };
}
/**
* Bộ giải cho luồng byte (WebSocket, fetch streaming): tự ghép phần dư giữa các chunk.
* Đây là chỗ MqttFetchClient của FB bỏ sót: nó giải từng chunk riêng và vứt `remaining`.
*/
export class MqttStreamDecoder {
private pending: Uint8Array | null = null;
push(chunk: Uint8Array): WireMessage[] {
let bytes = chunk;
if (this.pending) {
bytes = new Uint8Array(this.pending.length + chunk.length);
bytes.set(this.pending);
bytes.set(chunk, this.pending.length);
}
const { messages, remaining } = decodeMessages(bytes);
this.pending = remaining;
return messages;
}
/** số byte đang chờ ghép (để debug / hiển thị) */
get pendingBytes() {
return this.pending?.length ?? 0;
}
}
export const payloadText = (payload: Uint8Array) => decoder.decode(payload);
ts
Xem toàn bộ src/lib/mqttTransport.ts
src/lib/mqttTransport.ts
/**
* Khi nào bỏ WebSocket để chuyển sang long-poll (ý tưởng từ MqttLongPollingRunner của Facebook),
* và lịch thử lại (exponentialBackoff).
*/
export interface TransportState {
/** trình duyệt có WebSocket không */
wsSupported: boolean;
/** WebSocket lỗi không thể cứu (vd: bị proxy/firewall chặn hẳn) */
wsFatal: boolean;
/** WebSocket đã từng kết nối thành công trong phiên này */
wsConnected: boolean;
/** số lần connect WebSocket thất bại */
wsFailures: number;
/** ms từ lúc bắt đầu thử kết nối */
elapsedMs: number;
/** phiên trước có phải dùng long-poll không (FB lưu cờ `last_lp`) */
lastSessionUsedLongPoll: boolean;
}
export const FAST_DELAY_MS = 1000; // phiên trước đã phải long-poll: chờ WS 1s là đủ
export const REGULAR_DELAY_MS = 3000; // bình thường: cho WS 3s
export const MAX_WS_FAILURES = 3;
export type Decision = { longPoll: boolean; reason: string };
export function shouldLongPoll(s: TransportState): Decision {
if (!s.wsSupported) return { longPoll: true, reason: 'không có WebSocket' };
if (s.wsFatal) return { longPoll: true, reason: 'WebSocket lỗi fatal' };
// đã chạy được WS thì không bao giờ tự lùi về long-poll
if (s.wsConnected) return { longPoll: false, reason: 'WebSocket đã kết nối' };
if (s.lastSessionUsedLongPoll) {
if (s.wsFailures >= 1) return { longPoll: true, reason: 'phiên trước long-poll + WS lỗi 1 lần' };
if (s.elapsedMs > FAST_DELAY_MS) return { longPoll: true, reason: `phiên trước long-poll + chờ WS > ${FAST_DELAY_MS}ms` };
} else if (s.elapsedMs > REGULAR_DELAY_MS) {
return { longPoll: true, reason: `chờ WS > ${REGULAR_DELAY_MS}ms` };
}
if (s.wsFailures >= MAX_WS_FAILURES) return { longPoll: true, reason: `WS lỗi ${MAX_WS_FAILURES} lần` };
return { longPoll: false, reason: 'tiếp tục chờ WebSocket' };
}
/**
* Độ trễ lần thử thứ `attempt` (0, 1, 2…) như exponentialBackoff của FB:
* max(0, 2^min(n,6) · (1000 + random·100) − 2000) → 0; 0–0.2s; 2–2.4s; 6–6.8s; 14–15.6s; 30–33.2s; rồi trần 62–68.4s.
* Hai lần đầu gần như thử lại ngay: lỗi mạng thoáng qua là chuyện thường.
*/
export function backoffDelay(attempt: number, random = Math.random) {
return Math.max(0, 2 ** Math.min(attempt, 6) * (1000 + random() * 100) - 2000);
}
/** Lưu/đọc cờ "phiên trước phải long-poll" (FB: configWrite('last_lp', 'yes'|null)). */
export const lastSessionStore = {
read(): boolean {
try {
return localStorage.getItem('mqtt:last_lp') === 'yes';
} catch {
return false;
}
},
write(usedLongPoll: boolean) {
try {
if (usedLongPoll) localStorage.setItem('mqtt:last_lp', 'yes');
else localStorage.removeItem('mqtt:last_lp'); // WS chạy được thì xoá cờ
} catch {
/* private mode: bỏ qua */
}
},
};
ts
Khác bản gốc:
MqttStreamDecodergom việc "giữ phần dư rồi ghép" vào một chỗ, để mọi transport (WebSocket, fetch streaming) dùng chung và không thể quên.decodeLengthdừng sau 4 byte varint (giới hạn của MQTT) và ném lỗi, thay vì đọc tiếp vô hạn với dữ liệu rác.- Bản gốc kiểm tra QoS bằng
if (d < 0 && d > 1 || …): không số nào vừa nhỏ hơn 0 vừa lớn hơn 1, nên QoS 2 lọt qua mà không báo lỗi. Bản viết lại chỉ nhận 0 hoặc 1. - Phần gate cấu hình (
mqtt_ws_polling_enabled,mqtt_fast_lp…) được bỏ; ngưỡng 1s, 3s, 3 lần lỗi giữ nguyên.
#Đi qua code
- Varint độ dài. Mỗi byte mang 7 bit dữ liệu, bit cao báo "còn byte nữa". 0–127 tốn 1 byte, tới 16383 tốn 2 byte.
decodeLengthtrảnullkhi hết mảng giữa chừng: nghĩa là "chưa đủ dữ liệu", khác với "dữ liệu sai". decodeOnetrả về vị trí cũ nếu gói chưa trọn.decodeMessagesdừng ở gói chưa trọn đầu tiên và trả phần còn lại.MqttStreamDecoder.pushnối phần dư lần trước với chunk mới rồi mới giải. Test trong bài cắt một luồng 3 gói theo mọi kích thước chunk từ 1 byte trở lên: lần nào cũng ra đủ 3 gói, không còn byte thừa.shouldLongPolllà hàm thuần nhận một "ảnh chụp" trạng thái, dễ test. Runner chỉ cần gọi nó ở các mốc 0, 1100ms, 3100ms và sau mỗi lần connect lỗi, như FB.
#Tại sao không làm cách khác?
- JSON qua WebSocket. Đơn giản hơn nhiều. Facebook có lẽ dùng MQTT để client web nói cùng giao thức với app di động và cùng một hạ tầng server. Nếu bạn không có ràng buộc đó, JSON (hoặc một khung nhị phân tự đặt) là đủ.
- Server-Sent Events cho chiều server → client. SSE chạy trên HTTP thường nên đi qua được hầu hết proxy, có tự kết nối lại. Nhưng SSE chỉ chở text, và vẫn cần một kênh khác để gửi lên. Fetch streaming của FB chở được byte nhị phân.
- Chờ WebSocket timeout rồi mới chuyển. Timeout mặc định của trình duyệt có thể tới hàng chục giây. Ngưỡng 3s/1s chủ động của FB làm người dùng sau firewall có kênh realtime nhanh hơn nhiều.
- Nhớ "dùng long-poll luôn" mãi mãi. Nếu người dùng chuyển từ mạng công ty về mạng nhà, WebSocket lại chạy được. FB vẫn thử WebSocket trước, chỉ rút ngắn thời gian chờ.
#Edge case và cạm bẫy
- Gói lớn bị cắt nhiều lần. Phần dư có thể lớn dần qua vài chunk. Đặt giới hạn kích thước gói (MQTT cho phép tới 256MB) và đóng kết nối nếu vượt, tránh một server lỗi làm client giữ bộ đệm vô hạn.
- Dữ liệu rác. Một khi đã lệch khung, mọi gói sau đều sai. Đừng cố "đoán lại" ranh giới gói: đóng luồng và kết nối lại, như FB làm khi ném lỗi giải mã.
- QoS 1 cần PUBACK. Fetch client của FB gom các message id cần ACK, gửi kèm request sau hoặc sau tối đa 5 giây. Gửi ACK cho từng tin một qua HTTP thì tốn request.
- Topic không hỗ trợ trên long-poll. XHR long-poll cũ của FB chỉ cho một danh sách topic (
/t_ms,/messenger_sync_get_diffs,/webrtc…). Tính năng nào cần topic khác phải biết là mình đang chạy ở chế độ hạn chế. localStoragecho cờlast_lpcó thể ném lỗi ở chế độ riêng tư. Bọctry/catchvà coi như chưa từng long-poll.
#Dùng trong dự án
const decoder = new MqttStreamDecoder();
const res = await fetch('/realtime/pull?chunked=true', { method: 'POST', body: connectBytes, credentials: 'include' });
const reader = res.body!.getReader();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
for (const msg of decoder.push(value)) {
if (msg.type === 'PUBLISH') onMessage(msg.topic, payloadText(msg.payload));
}
}
ts
Với WebSocket, đặt ws.binaryType = 'arraybuffer' rồi gọi decoder.push(new Uint8Array(event.data)) trong onmessage. Cùng một bộ giải, không phải viết lại logic ghép byte cho từng transport.
#Kiểm thử
Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { MqttStreamDecoder, decodeLength, decodeMessages, encodeLength, encodePublish, payloadText } from './mqttCodec';
describe('mqttCodec', () => {
it('varint độ dài: 127 dùng 1 byte, 128 dùng 2 byte', () => {
expect(encodeLength(127)).toEqual([0x7f]);
expect(encodeLength(128)).toEqual([0x80, 0x01]);
expect(decodeLength(Uint8Array.from([0xff, 0xff, 0x7f]), 0)).toEqual({ value: 2_097_151, offset: 3 });
expect(decodeLength(Uint8Array.from([0x80]), 0)).toBeNull(); // chưa về đủ byte
});
it('PUBLISH QoS 1 mã hoá rồi giải ngược ra đúng', () => {
const [m] = decodeMessages(encodePublish('/t_ms', 'chào', 1, 42)).messages;
expect(m).toMatchObject({ type: 'PUBLISH', topic: '/t_ms', qos: 1, messageId: 42 });
expect(m.type === 'PUBLISH' && payloadText(m.payload)).toBe('chào');
});
it('gói chưa trọn được trả về trong remaining', () => {
const frame = encodePublish('/t', 'x'.repeat(300));
const { messages, remaining } = decodeMessages(frame.slice(0, 100));
expect(messages).toHaveLength(0);
expect(remaining).toHaveLength(100);
});
it('MqttStreamDecoder ghép đúng mọi cách cắt chunk', () => {
const frames = [encodePublish('/a', '1'), encodePublish('/b', 'x'.repeat(200)), encodePublish('/c', '3')];
const all = new Uint8Array(frames.reduce((n, f) => n + f.length, 0));
let at = 0;
for (const f of frames) all.set(f, (at += f.length) - f.length);
for (let size = 1; size <= all.length; size++) {
const decoder = new MqttStreamDecoder();
const topics: string[] = [];
for (let i = 0; i < all.length; i += size)
for (const m of decoder.push(all.slice(i, i + size))) if (m.type === 'PUBLISH') topics.push(m.topic);
expect(topics).toEqual(['/a', '/b', '/c']);
expect(decoder.pendingBytes).toBe(0);
}
});
});
ts
#So với thư viện và chuẩn web
- MQTT.js chạy được trên trình duyệt qua WebSocket và hỗ trợ MQTT 3.1.1/5. Nếu cần MQTT thật, dùng thư viện này thay vì tự viết codec. Bản tự viết trong bài để hiểu định dạng và lỗi ghép byte.
fetchvớiresponse.body.getReader()(đọc luồng) đã có ở mọi trình duyệt hiện đại. Gửi luồng lên bằngfetch(request body làReadableStream,duplex: 'half') thì chưa có ở mọi nơi (tính tới 10/2026, xem caniuse); FB gửi lên bằng các POST riêng, gom tối đa 20 tin mỗi lần.- WebTransport chạy trên HTTP/3 và có cả stream lẫn datagram. Nó có thể thay cả WebSocket lẫn long-poll trong tương lai, nhưng đi qua proxy doanh nghiệp còn khó hơn WebSocket.
Bài này thuộc series Học từ mã nguồn Facebook (106 bài, đọc theo thứ tự).
Xem nhanh danh sách bài trong series
Phần 1
- Mở đầu: cách đọc code minify của Facebook
- #1 Dùng chung IntersectionObserver + infinite scroll
- #2 useViewportDuration: đo thời gian thực sự được xem
- #3 Preload code + data khi hover
- #4 Toast manager với external store
- #5 Network status + heartbeat backoff
- #6 ErrorGuard: bắt lỗi theo ngữ cảnh
- #7 Banzai: gom log theo lô + sendBeacon
- #8 Focus trap + Esc theo layer
Phần 2
- #9 Mẹo calc(… * 9999): container query, line-clamp, mũi tên carousel bằng CSS
- #10 Thanh cuộn tuỳ biến bằng CSS 3D
- #11 Modal cuộn bằng window, giữ vị trí trang nền
- #12 Popover, tooltip group và hovercard
- #13 Primitive Pressable + focus-visible
- #14 Tab "Xem thêm" + sticky header bằng IntersectionObserver
- #15 Avatar khoét mask, gradient Messenger, skeleton steps()
- #16 Khung chat không giật
- #17 Thuật toán chọn bố cục collage ảnh
- #18 Keep-alive tab trong SPA
- #19 Tự đo Visual Completion & interaction tracing
- #20 Router SPA bền bỉ & tải chunk có retry
Phần 3
- #21 Smart Compose: gợi ý chữ mờ khi gõ
- #22 MQTT ngay trong trình duyệt, tự chuyển sang long-poll (bài này)
- #23 Đo thời gian dùng bằng bitmap 64 giây
- #24 Bảng reaction nhấn giữ rồi trượt
- #25 Tìm kiếm gấp dấu, phiên âm và highlight đầu từ
- #26 Ring buffer biết trace nào đã mất log
- #27 Engine animation JS: một vòng rAF và solver cubic-bezier
- #28 Bootloader: bitmap tài nguyên đã tải, chỉ reload khi cần
- #29 Nhận biết click do screen reader tạo ra
- #30 Tải trước feed theo chất lượng mạng, đoán hạng thiết bị
- #31 Hàng đợi gửi tin chat giữ đúng thứ tự
- #32 Gộp lời gọi API trong cùng một tick
- #33 Resize ảnh trong pool Web Worker
- #34 Crop ảnh theo nhiều khuôn mặt
- #35 Kéo-thả sắp xếp và vùng thả file không nhấp nháy
- #36 Tiện ích UI: fade-out, sticky event, hàm huỷ được
- #37 Hạ tầng nhỏ: cookie, hash lỗi, prefetch khi chuột dừng
Phần 4–6
- #38 Đồng bộ tin nhắn: phát hiện lỗ seqID và fetch bù theo tầng
- #39 OTID: ID tin nhắn kiểu Snowflake sinh ở client
- #40 QPL: lấy mẫu từ lúc bắt đầu, đo RTT gửi tin đầu-cuối
- #41 Bộ điều phối request bảy chế độ gộp và chống trùng
- #42 Thread list theo mốc thời gian, cookie presence có version
- #43 Composer dạng plugin: reducer, dirty checker, chuỗi kiểm tra
- #44 Thanh toán bất đồng bộ: subscribe trước, poll dự phòng
- #45 Validate số thẻ: sửa lỗi gõ, dải BIN, Luhn
- #46 Lịch chọn khoảng ngày: bo góc dải chọn, phím đủ bộ, RTL
- #47 Giờ mở cửa theo tuần: qua đêm, gộp khoảng, phần bù
- #48 Tiến độ đăng realtime: giữ event chờ snapshot, watchdog
- #49 Tiền tệ theo đơn vị nhỏ nhất và ô nhập tiền
- #50 Sửa hàng loạt Empty/Uniform/Mixed, seen/read bằng 2 bit
- #51 Phát hiện script lạ hook vào hàm native
- #52 localStorage tự dọn khi đầy quota
- #53 Cache tuyến do server phân giải, chờ sẵn promise
- #54 Lưới phím mũi tên 2D và focus không làm cuộn trang
- #55 Tách emoji bằng máy trạng thái
- #56 Đọc trước ghi sau trong một frame, con tự biết vị trí
- #57 Hàng đợi lệnh cho video và stream sắp lại chunk
Phần 7–10
- #58 Ước lượng băng thông EWMA: loại outlier, nhớ qua phiên
- #59 ABR theo độ tự tin phát và cắt bậc bitrate theo MOS
- #60 Prefetch segment dự đoán từ manifest, parse hộp sidx
- #61 Đọc metadata MP4/MOV ngay trên client
- #62 Upload tiếp tục được: offset, hash dedupe, phát hiện treo
- #63 Fenwick tree: chọn ngẫu nhiên có trọng số và offset dòng
- #64 Vạch trục số và trục thời gian "đẹp"
- #65 Damerau-Levenshtein theo dải chéo và tự sửa tên gõ sai
- #66 PRNG có seed, shuffle tái lập và lấy mẫu trace
- #67 Cặp min/max-heap tái dùng vị trí trong danh sách ảo
- #68 Memo nhiều đối số bằng Map/WeakMap và interning object
- #69 Thống kê cửa sổ trượt và đại số khoảng thời gian
- #70 Int64 từ hai số 32-bit, decode UTF, offset theo code point
- #71 Engine múi giờ IANA trên client, phát hiện lệch giờ
- #72 Tự cài semver range
- #73 Auto-link URL, cắt chữ an toàn, giới hạn LaTeX
- #74 Parser query kiểu PHP chống prototype pollution
- #75 Popover tự lật theo phần tràn nhỏ nhất
- #76 Tiền tố for(;😉 chống JSON hijacking, đọc stream
- #77 Chống lộ Referer, linkshim, xoá token trên URL
- #78 setImmediate bằng postMessage, gom delta chuột theo frame
- #79 Phát hiện crash qua heartbeat, dò proxy cache hỏng
- #80 DPR vào cookie cho ảnh @2x, che khoá trong log
- #81 Flux waitFor tự viết và proxy có đệm
- #82 Crop đúng tỉ lệ, xoay sticker bằng ma trận
Phần 11–15
- #83 Chia subdomain giữa các tab bằng lease, long-poll theo bảng trạng thái
- #84 MQTT QoS1: giữ publish chờ ack, gửi lại khi kết nối lại
- #85 Cache Promise gộp request, cache lỗi ngắn, đua dữ liệu nhúng sẵn
- #86 Lịch retry: backoff có trần, Fibonacci, báo lỗi CDN mỗi N ngày
- #87 Giữ nguyên tham chiếu nhánh dữ liệu không đổi
- #88 Store optimistic: copy-on-write, rollback rồi áp lại
- #89 Chỉ mục subscription theo record, hàng đợi task theo chiều sâu
- #90 Phân trang connection: cursor khớp đầu, edge không trùng
- #91 Profiler kiểu middleware và memo selector có lấy mẫu kiểm tra
- #92 Đọc EBML/WebM Cues để tìm byte range của segment
- #93 Stream HTTP Range tạm dừng được, tải tiếp đúng byte
- #94 Bảng khoảng thời gian gắn nhãn và nhảy qua lỗ buffer
- #95 Gom chunk cho SourceBuffer, đồng bộ video bằng playbackRate
- #96 Gom tin nhắn thành cụm, vạch ngày và avatar đã xem
- #97 Bao nhiêu tab chat vừa màn hình, chuỗi luật mở tab
- #98 Phím tắt Markdown trong editor, Backspace để hoàn tác
- #99 Offset theo code point hay UTF-16: bốn bug cùng một gốc
- #100 Typeahead không nhảy: cache tiền tố, chặn reflow, khoá hover
- #101 Hit-test trên canvas bằng phát lại path, nét đứt và cung Bézier
- #102 Vòng tròn theo mét trên bản đồ Mercator
- #103 Dò tính năng trình duyệt: ảnh EXIF 2×1, getter bẫy, scrollbar
- #104 Bug định dạng số và kiểm tra dữ liệu theo locale
- #105 Dispose cho đúng: idle callback, splice(-1), xoá nhầm key
Code + demo: examples/facebook-patterns · Mở trên StackBlitz

