Web Totals

Học từ Facebook #70: Int64 từ hai số 32-bit, decode UTF và offset theo code point

Facebook mang theo Int64 hai nửa 32-bit, decoder UTF-8/16/32 tự viết và hàm đổi offset code point sang UTF-16. Ba chỗ dễ sai: tràn số, overlong, emoji.

hunghg255hunghg25525 min read
#facebook#unicode#utf-8#bigint#javascript
Học từ Facebook #70: Int64 từ hai số 32-bit, decode UTF và offset theo code point

ID bài viết trên Facebook có 17 chữ số, vượt Number.MAX_SAFE_INTEGER (2^53 − 1, 16 chữ số). Đưa thẳng vào Number thì chữ số cuối bị làm tròn: 10158012345678901 thành 10158012345678900, và bạn vừa mở nhầm bài.

Chữ cũng có chuyện tương tự. Server đếm vị trí theo code point, JavaScript đếm theo đơn vị UTF-16. Mỗi emoji trong câu làm hai cách đếm lệch nhau thêm một hoặc nhiều đơn vị, và link "@Lan" được tô đậm thành "t L".

Bài này đọc ba module nhỏ trong bundle Facebook xử lý ba chuyện đó, tìm thấy vài lỗi thật (một trong số đó là vòng lặp vô hạn), rồi viết lại bằng BigInt và thuật toán decode chuẩn WHATWG.

Tip

Tóm tắt

  • Int64 là bản port của goog.math.Long: hai số 32-bit low_/high_, fromString cắt chuỗi thành khúc 8 chữ số. Nó không báo lỗi khi tràn (9223372036854775808 thành số âm) hay gặp ký tự lạ ("12a45" thành 12). Ngày nay dùng BigInt và kiểm tra chặt.
  • StringEncoding.Decoder thử UTF-8, hỏng thì thử UTF-16 rồi UTF-32. Nó nhận dạng overlong (C0 AF thành /, nên ..%C0%AFetc thành ../etc), nhận surrogate lẻ, và chính sách "ignore" với ký tự NUL kẹt vòng lặp vô hạn.
  • decodeEntityRanges đổi range theo code point sang chỉ số UTF-16 bằng UnicodeUtils.substr. Mỗi range duyệt lại chuỗi từ đầu. Bản viết lại đổi tất cả trong một lượt.

#Facebook làm thế nào?

#Int64: goog.math.Long đổi tên

Module Int64 có hai bản (1004.js và 17.js). Đọc là nhận ra ngay goog.math.Long của Closure Library: low_, high_, IntCache_ cho các số từ −128 tới 127, các hằng TWO_PWR_32_DBL_, phép nhân chia nhỏ thành bốn khúc 16-bit để không vượt độ chính xác của double. Có một chi tiết lạ là module gán vào biến toàn cục (Int64 = function (a, b) {...}) thay vì biến cục bộ.

Phần đáng đọc là fromString:

var c = Int64.fromNumber(Math.pow(b, 8)), d = Int64.ZERO;
for (var e = 0; e < a.length; e += 8) {
  var f = Math.min(8, a.length - e), g = parseInt(a.substring(e, e + f), b);
  if (f < 8) d = d.multiply(Int64.fromNumber(Math.pow(b, f))).add(Int64.fromNumber(g));
  else d = d.multiply(c).add(Int64.fromNumber(g));
}
js

Mỗi khúc 8 chữ số (tối đa 99.999.999) vừa vặn trong double, nhân dồn bằng phép nhân 64-bit. Nhưng parseInt dừng ở ký tự đầu tiên không phải số và không báo lỗi, còn phép nhân 64-bit thì quấn vòng khi tràn. Chạy chính module này trong Node:

Đầu vào Int64.fromString(…).toString()
9223372036854775807 9223372036854775807 (đúng, giá trị lớn nhất)
9223372036854775808 -9223372036854775808 (tràn, thành số âm)
18446744073709551615 -1
12a45 12 (parseInt("12a45") = 12, khúc ngắn nên nhân 10^5 rồi cộng 12)
1e3 1

#StringEncoding: thử lần lượt ba bảng mã

Module StringEncoding (trong 177.js) có ba hàm decode và một lớp Decoder thử chúng theo thứ tự ["utf-8", "utf-16", "utf-32"]. Hàm nào chạy không ném lỗi thì kết quả của nó được trả về ngay; cả ba đều lỗi thì các lỗi được nối lại bằng dấu phẩy thành một Error.

Hàm UTF-8 đọc byte đầu để biết độ dài chuỗi byte, chấp nhận tới 6 byte theo RFC 2279 cũ:

if (e >> 7 === 0) f = 1;
else if (e >> 5 === 6) e &= 31, f = 2;
else if (e >> 4 === 14) e &= 15, f = 3;
else if (e >> 3 === 30) e &= 7, f = 4;
else if (e >> 2 === 62) e &= 3, f = 5;
else if (e >> 1 === 126) e &= 1, f = 6;
js

Sau đó ghép bit, kiểm tra > 0x10FFFF, và xử lý ký tự NUL theo chính sách error / ignore / truncate / include.

#Bên trong: bốn lỗi

flowchart TD
  B["bytes"] --> U8{"UTF-8 hợp lệ?"}
  U8 -->|"có, kể cả overlong"| T1["trả chuỗi"]
  U8 -->|"lỗi"| U16{"độ dài chẵn?"}
  U16 -->|"có"| T2["đọc như UTF-16: chữ Hán vô nghĩa"]
  U16 -->|"không"| U32{"chia hết cho 4?"}
  U32 -->|"không"| E["ném lỗi gộp"]
  N["NUL + ignore"] -->|"continue không tăng chỉ số"| N
  1. Overlong. C0 AF là cách mã hoá / bằng hai byte, bị cấm từ Unicode 3.1 vì cho phép lách bộ lọc: chuỗi ..\xC0\xAFetc qua bộ lọc tìm ../ mà không bị bắt, sau khi decode lại thành ../etc. Decoder này trả "../etc". Dạng 5 byte F8 80 80 80 AF cũng ra /.
  2. Surrogate lẻ. ED A0 80 ra "\ud800", một chuỗi JavaScript không hợp lệ. Gửi chuỗi đó qua encodeURIComponent sẽ ném URIError.
  3. Rơi sang UTF-16. Bốn byte Latin-1 63 61 66 E9 ("café") không phải UTF-8 hợp lệ, nhưng có độ dài chẵn nên bản UTF-16 đọc được: kết quả là "慣" cộng một ký tự vùng riêng (U+E966). Không có lỗi nào được báo.
  4. "ignore" kẹt. Nhánh xử lý NUL viết case "ignore": continue;, nhưng lệnh tăng chỉ số c += f nằm ở cuối thân vòng lặp nên bị bỏ qua. Vòng while đọc lại đúng byte NUL đó mãi mãi. Chạy new Decoder(["utf-8"], "ignore").decode(...) với một byte 00 trong Node thì tiến trình treo tới khi bị timeout giết. Bản UTF-16 và UTF-32 có cùng lỗi.

Thêm một lỗi về kích thước: kết quả được tạo bằng String.fromCodePoint.apply(String, d). Mảng vài trăm nghìn phần tử vượt giới hạn số đối số, ném RangeError: Maximum call stack size exceeded. Với 300 KB chữ ASCII thì cả ba bảng mã đều thất bại.

#decodeEntityRanges: đếm theo code point

Range (mention, link, hashtag) từ server có offset và length tính theo code point. Module decodeEntityRanges (trong 232.js) đánh dấu từng đơn vị UTF-16:

function a(text, ranges) {
  var c = Array(text.length).fill(null);
  ranges && ranges.forEach(function (r) {
    var start = substr(text, 0, r.offset).length,
        end = start + substr(text, r.offset, r.length).length;
    for (var i = start; i < end; i++) c[i] = r.key;
  });
  return c;
}
js

UnicodeUtils.substr cắt theo code point, .length của kết quả là số đơn vị UTF-16. Đúng, nhưng mỗi range duyệt lại chuỗi từ đầu: một bình luận dài có 50 mention là 50 lượt duyệt. Chiều ngược lại, encodeImplicitURLRanges dùng UnicodeUtils.strlen(text.slice(0, start)) để đổi vị trí URL tìm được bằng regex (UTF-16) sang code point trước khi gửi lên, bỏ các URL chồng lên range có sẵn, và thêm http khi URL thiếu giao thức.

#Viết lại

Tất cả trong src/lib/int64Text.ts. Phần Int64 chỉ vài dòng vì có BigInt: kiểm tra định dạng bằng regex, so với -(2n ** 63n) và 2n ** 63n - 1n, tách hi/lo bằng BigInt.asIntN(32, …).

Decoder UTF-8 theo đặc tả WHATWG Encoding. Điểm chính là cặp lower/upper: byte đầu E0, ED, F0, F4 thu hẹp khoảng hợp lệ của byte tiếp theo, nên overlong, surrogate và > U+10FFFF bị chặn ngay ở byte thứ hai:

src/lib/int64Text.ts
  for (let i = 0; i < bytes.length; i++) {
    const b = bytes[i];
    if (needed === 0) {
      if (b <= 0x7f) {
        if (b === 0) {
          if (nulls === 'error') throw new TypeError(`Có ký tự NUL tại byte ${i}`);
          if (nulls === 'truncate') break;
          if (nulls === 'ignore') continue; // vòng for vẫn tăng i, không kẹt như bản gốc
        }
        emit(b);
      } else if (b >= 0xc2 && b <= 0xdf) {
        needed = 1;
        cp = b & 0x1f;
      } else if (b >= 0xe0 && b <= 0xef) {
        if (b === 0xe0) lower = 0xa0; // chặn overlong 3 byte
        if (b === 0xed) upper = 0x9f; // chặn surrogate D800–DFFF
        needed = 2;
        cp = b & 0x0f;
      } else if (b >= 0xf0 && b <= 0xf4) {
        if (b === 0xf0) lower = 0x90; // chặn overlong 4 byte
        if (b === 0xf4) upper = 0x8f; // chặn > U+10FFFF
        needed = 3;
        cp = b & 0x07;
      } else {
        bad(i); // 80–C1, F5–FF: không bao giờ là byte đầu hợp lệ
      }
      continue;
    }
    if (b < lower || b > upper) {
      // chuỗi đang dở bị cắt: báo lỗi một lần rồi xử lý lại byte này như byte đầu
      needed = seen = cp = 0;
      lower = 0x80;
      upper = 0xbf;
      bad(i);
      i--;
      continue;
    }
    lower = 0x80;
    upper = 0xbf;
    cp = (cp << 6) | (b & 0x3f);
    if (++seen === needed) {
      emit(cp);
      needed = seen = cp = 0;
    }
  }
ts

Phần đổi range (toUtf16Ranges, utf16ToCodePoint, segment) nằm cuối file:

Xem toàn bộ src/lib/int64Text.ts
src/lib/int64Text.ts
/**
 * Ba việc nhỏ quanh "số và chữ" mà client Facebook tự làm:
 * 1. Số nguyên 64-bit (ID, timestamp micro giây) không mất chính xác: Facebook mang theo bản port
 *    của goog.math.Long (hai nửa 32-bit). Ở đây dùng BigInt, chỉ giữ phần chuyển đổi hi/lo.
 * 2. Decode UTF-8 từ bytes: Facebook tự viết StringEncoding. Bản này làm đúng thuật toán
 *    WHATWG (giống TextDecoder) và thêm các chính sách xử lý ký tự NUL.
 * 3. Đổi offset tính theo code point (server gửi) sang offset UTF-16 (JavaScript dùng),
 *    ý tưởng từ decodeEntityRanges.
 */

// ---------------- 1. Int64 ----------------

const MIN = -(1n << 63n);
const MAX = (1n << 63n) - 1n;

/** Parse chặt: sai định dạng hay tràn 64-bit thì ném lỗi, không âm thầm quấn vòng. */
export function parseInt64(text: string): bigint {
  if (!/^-?\d+$/.test(text)) throw new SyntaxError(`Không phải số nguyên: "${text}"`);
  const n = BigInt(text);
  if (n < MIN || n > MAX) throw new RangeError(`Tràn int64: ${text}`);
  return n;
}

/** Tách thành hai nửa 32-bit có dấu, đúng như low_/high_ của Long. */
export function toHiLo(n: bigint): { high: number; low: number } {
  const u = BigInt.asUintN(64, n);
  return { high: Number(BigInt.asIntN(32, u >> 32n)), low: Number(BigInt.asIntN(32, u)) };
}

export function fromHiLo(high: number, low: number): bigint {
  return BigInt.asIntN(64, (BigInt(high) << 32n) | BigInt(low >>> 0));
}

/** ID dạng chuỗi có đi qua Number an toàn không? (2^53 trở lên thì không chắc) */
export function survivesNumber(text: string): boolean {
  return String(Number(text)) === text;
}

/** JSON.parse giữ số lớn ở dạng chuỗi: bọc các số nguyên dài >= 16 chữ số trong ngoặc kép. */
export function parseJsonKeepBigInts(json: string): unknown {
  return JSON.parse(json.replace(/("(?:[^"\\]|\\.)*")|(?<![\d.eE+-])(-?\d{16,})(?=\s*[,}\]])/g, (m, str, num) => (str ? m : `"${num}"`)));
}

// ---------------- 2. UTF-8 ----------------

export type NullPolicy = 'error' | 'ignore' | 'truncate' | 'include';

export interface Utf8Options {
  /** true: gặp byte sai thì ném lỗi; false: thay bằng U+FFFD như TextDecoder */
  fatal?: boolean;
  nulls?: NullPolicy;
}

/**
 * Decoder UTF-8 theo đặc tả WHATWG Encoding. Chặn được: dạng overlong (C0 AF), surrogate
 * mã hoá trong UTF-8 (ED A0 80), code point > U+10FFFF, dạng 5-6 byte, chuỗi cụt.
 */
export function decodeUtf8(bytes: Uint8Array, { fatal = false, nulls = 'include' }: Utf8Options = {}): string {
  const out: string[] = [];
  let chunk: number[] = [];
  const emit = (cp: number) => {
    chunk.push(cp);
    // String.fromCodePoint(...arr) với mảng rất lớn sẽ tràn stack, nên đẩy theo từng khúc
    if (chunk.length === 8192) {
      out.push(String.fromCodePoint(...chunk));
      chunk = [];
    }
  };
  const bad = (i: number) => {
    if (fatal) throw new TypeError(`UTF-8 không hợp lệ tại byte ${i}`);
    emit(0xfffd);
  };

  let needed = 0;
  let seen = 0;
  let cp = 0;
  let lower = 0x80;
  let upper = 0xbf;

  for (let i = 0; i < bytes.length; i++) {
    const b = bytes[i];
    if (needed === 0) {
      if (b <= 0x7f) {
        if (b === 0) {
          if (nulls === 'error') throw new TypeError(`Có ký tự NUL tại byte ${i}`);
          if (nulls === 'truncate') break;
          if (nulls === 'ignore') continue; // vòng for vẫn tăng i, không kẹt như bản gốc
        }
        emit(b);
      } else if (b >= 0xc2 && b <= 0xdf) {
        needed = 1;
        cp = b & 0x1f;
      } else if (b >= 0xe0 && b <= 0xef) {
        if (b === 0xe0) lower = 0xa0; // chặn overlong 3 byte
        if (b === 0xed) upper = 0x9f; // chặn surrogate D800–DFFF
        needed = 2;
        cp = b & 0x0f;
      } else if (b >= 0xf0 && b <= 0xf4) {
        if (b === 0xf0) lower = 0x90; // chặn overlong 4 byte
        if (b === 0xf4) upper = 0x8f; // chặn > U+10FFFF
        needed = 3;
        cp = b & 0x07;
      } else {
        bad(i); // 80–C1, F5–FF: không bao giờ là byte đầu hợp lệ
      }
      continue;
    }
    if (b < lower || b > upper) {
      // chuỗi đang dở bị cắt: báo lỗi một lần rồi xử lý lại byte này như byte đầu
      needed = seen = cp = 0;
      lower = 0x80;
      upper = 0xbf;
      bad(i);
      i--;
      continue;
    }
    lower = 0x80;
    upper = 0xbf;
    cp = (cp << 6) | (b & 0x3f);
    if (++seen === needed) {
      emit(cp);
      needed = seen = cp = 0;
    }
  }
  if (needed) bad(bytes.length);
  out.push(String.fromCodePoint(...chunk));
  return out.join('');
}

// ---------------- 3. Offset code point ↔ UTF-16 ----------------

export interface CodePointRange<K = string> {
  offset: number; // tính theo code point
  length: number;
  key: K;
}

/**
 * Đổi một lượt cho nhiều range: duyệt chuỗi một lần, O(độ dài + số range).
 * Bản gốc gọi substr theo code point cho từng range, mỗi lần duyệt lại từ đầu chuỗi.
 */
export function toUtf16Ranges<K>(text: string, ranges: CodePointRange<K>[]) {
  const marks = new Set<number>();
  for (const r of ranges) marks.add(r.offset).add(r.offset + r.length);
  const at = new Map<number, number>();
  let cpIndex = 0;
  let u16 = 0;
  for (const ch of text) {
    if (marks.has(cpIndex)) at.set(cpIndex, u16);
    cpIndex++;
    u16 += ch.length; // 2 với emoji/ký tự ngoài BMP
  }
  at.set(cpIndex, u16); // cho range kết thúc đúng cuối chuỗi
  const clamp = (cp: number) => at.get(Math.min(cp, cpIndex)) ?? u16;
  return ranges.map((r) => ({ key: r.key, start: clamp(r.offset), end: clamp(r.offset + r.length) }));
}

/** Ngược lại: offset UTF-16 (ví dụ từ selectionStart) sang offset code point để gửi lên server. */
export function utf16ToCodePoint(text: string, u16Offset: number): number {
  let cp = 0;
  for (let i = 0; i < u16Offset && i < text.length; cp++) {
    const c = text.charCodeAt(i);
    i += c >= 0xd800 && c <= 0xdbff ? 2 : 1;
  }
  return cp;
}

/** Cắt chuỗi thành các đoạn để render (đậm, link…), range chồng nhau thì range sau bị bỏ. */
export function segment<K>(text: string, ranges: CodePointRange<K>[]) {
  const spans = toUtf16Ranges(text, ranges).sort((a, b) => a.start - b.start);
  const parts: { text: string; key: K | null }[] = [];
  let pos = 0;
  for (const s of spans) {
    if (s.start < pos) continue;
    if (s.start > pos) parts.push({ text: text.slice(pos, s.start), key: null });
    parts.push({ text: text.slice(s.start, s.end), key: s.key });
    pos = s.end;
  }
  if (pos < text.length) parts.push({ text: text.slice(pos), key: null });
  return parts;
}
ts

Khác bản gốc:

  • parseInt64 dùng BigInt và ném lỗi khi chuỗi không phải số nguyên hoặc vượt khoảng int64, thay vì quấn vòng. toHiLo/fromHiLo giữ lại để nói chuyện với giao thức nhị phân dùng hai nửa 32-bit.
  • Decoder chỉ làm UTF-8, đúng thuật toán của TextDecoder: chặn overlong, surrogate, dạng 5–6 byte, > U+10FFFF. Không âm thầm thử bảng mã khác.
  • Byte lỗi thì thay bằng U+FFFD (mặc định) hoặc ném lỗi (fatal: true), giống TextDecoder.
  • nulls: 'ignore' dùng continue trong vòng for, nên chỉ số vẫn tăng.
  • Kết quả được ghép theo khúc 8.192 code point nên không tràn stack với dữ liệu lớn.
  • toUtf16Ranges đổi mọi range trong một lượt duyệt; segment cắt chuỗi thành các đoạn để render.

#Đi qua code

  • survivesNumber(text) kiểm tra String(Number(text)) === text. Nhanh, đủ để cảnh báo trong dev mode khi ai đó vô tình Number(id).
  • parseJsonKeepBigInts bọc số nguyên từ 16 chữ số trở lên trong ngoặc kép trước khi JSON.parse. Regex khớp chuỗi JSON trước (nhánh "(?:[^"\\]|\\.)*") để không đụng vào số nằm trong chuỗi, và có lookbehind để không bắt nhầm phần thập phân.
  • Trong decodeUtf8, needed là số byte tiếp theo còn thiếu. Byte đầu đặt needed và thu hẹp lower/upper nếu cần. Byte tiếp theo nằm ngoài khoảng thì phát một U+FFFD rồi xử lý lại chính byte đó như byte đầu (i--). Nhờ vậy E0 41 ra "�" + "A", không nuốt mất chữ A.
  • Fuzz 20.000 chuỗi byte ngẫu nhiên, so với new TextDecoder().decode(...): không lệch chuỗi nào.
  • toUtf16Ranges gom mọi vị trí cần đổi vào một Set, duyệt chuỗi bằng for…of (mỗi bước là một code point), ghi lại chỉ số UTF-16 tại các vị trí đó.

#Tại sao không làm cách khác?

  • TextDecoder có sẵn là lựa chọn đúng cho hầu hết trường hợp, và từ 2020 mọi trình duyệt đều có. Bản tự viết chỉ đáng khi cần chính sách NUL riêng hoặc chạy ở môi trường không có TextDecoder (một số worklet, JS engine nhúng). Code của Facebook có lẽ được viết trước khi TextDecoder phổ biến.
  • Thử nhiều bảng mã nghe tiện nhưng nguy hiểm: dữ liệu hỏng thành chữ vô nghĩa mà không ai biết. Nên biết trước bảng mã (header Content-Type, BOM) và báo lỗi rõ ràng.
  • Number cho ID: chỉ an toàn tới 2^53. Facebook, Twitter/X, Discord đều gửi ID dạng chuỗi trong JSON vì lý do này.
  • Đếm theo grapheme (Intl.Segmenter) thay vì code point: đúng hơn về mặt hiển thị (👩🏽‍💻 là một ký tự, bốn code point), nhưng server và client phải cùng phiên bản Unicode mới đếm giống nhau. Code point ổn định hơn để làm offset.

#Edge case và cạm bẫy

  • JSON.stringify không nhận BigInt: JSON.stringify({ id: 1n }) ném TypeError. Gửi đi thì đổi về chuỗi.
  • Số 16 chữ số vẫn có thể an toàn (1000000000000000 < 2^53). parseJsonKeepBigInts đổi hết sang chuỗi cho nhất quán, kiểu dữ liệu không phụ thuộc vào độ lớn của giá trị.
  • BOM. TextDecoder mặc định bỏ BOM EF BB BF ở đầu, bản tự viết thì giữ. Muốn giống hẳn thì bỏ 3 byte đầu khi gặp BOM.
  • Range chồng nhau. segment bỏ range bắt đầu bên trong range trước. Muốn lồng (link trong đoạn in đậm) thì cần cây, không phải danh sách phẳng.
  • Offset vượt độ dài chuỗi (server và client lệch dữ liệu) được kẹp về cuối chuỗi thay vì ném lỗi.

#Dùng trong dự án

Nhận ID từ API cũ trả số:

const res = await fetch('/api/posts').then((r) => r.text());
const posts = parseJsonKeepBigInts(res) as { id: string; title: string }[];
navigate(`/posts/${posts[0].id}`); // ID còn nguyên 17 chữ số
ts

Render bình luận có mention:

function Comment({ text, ranges }: { text: string; ranges: CodePointRange<'mention' | 'link'>[] }) {
  return (
    <p>
      {segment(text, ranges).map((s, i) => (s.key === 'mention' ? <b key={i}>{s.text}</b> : <span key={i}>{s.text}</span>))}
    </p>
  );
}
tsx

Gửi vị trí con trỏ lên server: utf16ToCodePoint(textarea.value, textarea.selectionStart).

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import {
  decodeUtf8,
  fromHiLo,
  parseInt64,
  parseJsonKeepBigInts,
  segment,
  toHiLo,
  toUtf16Ranges,
  utf16ToCodePoint,
} from '../src/lib/int64Text';

describe('Int64', () => {
  it('parse chặt, không quấn vòng', () => {
    expect(parseInt64('9223372036854775807')).toBe(2n ** 63n - 1n);
    expect(() => parseInt64('9223372036854775808')).toThrow(RangeError);
    expect(() => parseInt64('12a45')).toThrow(SyntaxError);
  });

  it('hi/lo đi một vòng không mất gì', () => {
    for (const n of [0n, -1n, 2n ** 63n - 1n, -(2n ** 63n), 10158012345678901n]) {
      const { high, low } = toHiLo(n);
      expect(fromHiLo(high, low)).toBe(n);
    }
  });

  it('giữ số lớn trong JSON ở dạng chuỗi', () => {
    expect(parseJsonKeepBigInts('{"id":10158012345678901,"s":"10158012345678901","x":1.5}')).toEqual({
      id: '10158012345678901',
      s: '10158012345678901',
      x: 1.5,
    });
  });
});

describe('decodeUtf8', () => {
  const b = (...x: number[]) => Uint8Array.from(x);
  it('chặn overlong, surrogate, dạng 5 byte', () => {
    expect(decodeUtf8(b(0x2e, 0x2e, 0xc0, 0xaf))).toBe('..��');
    expect(() => decodeUtf8(b(0xed, 0xa0, 0x80), { fatal: true })).toThrow();
    expect(() => decodeUtf8(b(0xf8, 0x80, 0x80, 0x80, 0xaf), { fatal: true })).toThrow();
  });

  it('khớp TextDecoder với byte ngẫu nhiên', () => {
    const td = new TextDecoder();
    for (let k = 0; k < 2000; k++) {
      const bytes = Uint8Array.from({ length: 8 }, () => Math.floor(Math.random() * 256));
      expect(decodeUtf8(bytes)).toBe(td.decode(bytes));
    }
  });

  it('chính sách NUL, ignore không kẹt', () => {
    expect(decodeUtf8(b(0x61, 0, 0x62), { nulls: 'ignore' })).toBe('ab');
    expect(decodeUtf8(b(0x61, 0, 0x62), { nulls: 'truncate' })).toBe('a');
    expect(() => decodeUtf8(b(0x61, 0), { nulls: 'error' })).toThrow();
  });

  it('dữ liệu lớn không tràn stack', () => {
    expect(decodeUtf8(new Uint8Array(1_000_000).fill(0x41))).toHaveLength(1_000_000);
  });
});

describe('offset code point', () => {
  const text = 'Hi 👋🏽 Lan';
  it('đổi sang UTF-16 và ngược lại', () => {
    const [r] = toUtf16Ranges(text, [{ offset: 6, length: 3, key: 'm' }]);
    expect(text.slice(r.start, r.end)).toBe('Lan');
    expect(utf16ToCodePoint(text, r.start)).toBe(6);
    expect(segment(text, [{ offset: 6, length: 3, key: 'm' }]).at(-1)).toEqual({ text: 'Lan', key: 'm' });
  });
});
ts

#So với thư viện và chuẩn web

  • TextDecoder / TextEncoder: chuẩn WHATWG Encoding, có trên mọi trình duyệt và Node. fatal: true để ném lỗi thay vì thay U+FFFD, ignoreBOM: true để giữ BOM. Đây là thứ nên dùng mặc định.
  • String.prototype.isWellFormed() / toWellFormed(): kiểm tra và sửa surrogate lẻ trong chuỗi JavaScript, có trên các trình duyệt chính (tính tới 10/2026).
  • JSON.parse source text access: callback reviver nhận thêm context.source (chuỗi gốc của số), nên đọc ID lớn thành BigInt mà không cần regex. Đã có trên Chromium và Firefox, các trình duyệt khác chưa đồng đều (tính tới 10/2026); kiểm tra trước khi dùng. Thư viện json-bigint và lossless-json làm việc này từ lâu.
  • Intl.Segmenter: tách theo grapheme cluster để đếm "ký tự người đọc thấy", dùng cho bộ đếm ký tự trong ô nhập, không dùng cho offset.

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

Phần 2

Phần 3

Phần 4–6

Phần 7–10

Phần 11–15

Code + demo: examples/facebook-patterns · Mở trên StackBlitz