Web Totals

Học từ Facebook #72: Tự cài semver range

VersionRange của Facebook kiểm tra phiên bản trình duyệt cho UserAgent.isBrowser. Nó âm thầm sai với ^, với AND bằng dấu cách và với prerelease. Viết lại cho khớp npm.

hunghg255hunghg25524 min read
#facebook#semver#parser#user-agent#javascript
Học từ Facebook #72: Tự cài semver range

"Phiên bản 3.0.0 có nằm trong >=1.2.0 <2.0.0 không?" Câu trả lời hiển nhiên là không. Nhưng hàm kiểm tra phiên bản trong bundle Facebook trả lời có, và không báo lỗi gì.

Hàm đó là VersionRange.contains, nền của UserAgent.isBrowser('Chrome >= 100') và UserAgent.isPlatform('iOS < 15'), các điều kiện mà code giao diện dùng để bật tắt tính năng theo trình duyệt. Bài này đọc nó, liệt kê các chỗ nó lệch với cách npm hiểu cùng một chuỗi, rồi viết lại một bản nhỏ khớp npm semver (fuzz 400.000 ca, chỉ lệch vài ca, đều ở một góc riêng của npm).

Tip

Tóm tắt

  • VersionRange tách ||, rồi tách khoảng gạch nối a - b, rồi đọc toán tử (<, <=, =, >=, >, ~, ~>) và so từng thành phần sau dấu chấm: phần số đầu so như số, còn lại so như chuỗi. Version có bao nhiêu thành phần cũng được, hợp với version trình duyệt như 120.0.6099.109.
  • Không có AND bằng dấu cách: ">=1.2.0 <2.0.0" tách theo dấu chấm thành >=1, 2, 0 <2, 0, 0: toán tử >= và phần sau dấu cách lẫn vào các thành phần, nên 3.0.0 lọt qua. Không có ^: "^1.2.0" luôn false.
  • Prerelease so như chuỗi: "0-rc" > "0", nên 1.0.0-rc.1 lớn hơn 1.0.0. ~1.2 theo kiểu Ruby (< 2), không theo npm (< 1.3). Cận trên thiếu số như <=15 loại luôn 15.4.
  • Bản viết lại tách range thành các bộ so sánh đơn (>=1.2.0, <2.0.0-0) trước khi so, đúng cách npm làm. Version thiếu hoặc dư số thì dùng loose: true.

#Facebook làm thế nào?

#Ai dùng VersionRange?

Trong bundle chỉ có một module phụ thuộc vào VersionRange: UserAgent (trong 99 ChatComposer/all.js):

function g(name, version, query, normalize) {
  if (name === query) return true;            // "Chrome" khớp mọi phiên bản
  if (!query.startsWith(name)) return false;
  query = query.slice(name.length);           // "Chrome >= 100" → " >= 100"
  if (version != null) return VersionRange.contains(normalize ? normalize(query) : query, version);
  return false;
}
isBrowser: memoizeStringOnly(q => g(UserAgentData.browserName, UserAgentData.browserFullVersion, q)),
isPlatform: memoizeStringOnly(q => g(UserAgentData.platformName, UserAgentData.platformFullVersion, q, stripNT)),
js

Tên và phiên bản trình duyệt lấy từ UserAgentData (server đã parse user agent sẵn). Điều kiện được viết dạng chuỗi gọn: isBrowser("IE"), isBrowser("Safari < 14"), isPlatform("Windows >= 10"). stripNT bỏ chữ NT trong "Windows NT 10.0". Kết quả được memo theo chuỗi điều kiện.

#VersionRange.contains

Module VersionRange (trong 22.js) dùng ba regex:

var i = /\|\|/, j = /\s+\-\s+/, k = /^(<=|<|=|>=|~>|~|>|)?\s*(.+)/;
js

Thứ tự xử lý:

  1. Tách theo ||: đúng một nhánh là đủ.
  2. Tách theo -: hai vế thì thành >=a và <=b.
  3. Còn lại là một điều kiện đơn: tách version trong range theo dấu chấm, đọc toán tử ở thành phần đầu tiên.

So sánh hai mảng thành phần: lấp "0" cho bên ngắn hơn, thành phần x/* ở range biến cả hai bên về "0", rồi so từng cặp:

function A(a, b) {
  var c = parseInt(a.match(/^(\d*)(.*)/)[1], 10), d = parseInt(b.match(/^(\d*)(.*)/)[1], 10);
  if (isFinite(c) && isFinite(d) && c !== d) return c > d ? 1 : -1;  // phần số đầu khác nhau
  return a > b ? 1 : a < b ? -1 : 0;                                    // còn lại so chuỗi
}
js

Toán tử ~ bỏ thành phần cuối rồi tăng thành phần kề cuối: ~1.2.3 thành >=1.2.3 <1.3, ~1.2 thành >=1.2 <2. Đó là toán tử ~> của Ruby gem, khác với ~ của npm.

#Bên trong: điều kiện đơn nuốt phần sau dấu cách

flowchart LR
  Q["range: >=1.2.0 <2.0.0"] --> S["split dấu chấm"]
  S --> P[">=1 | 2 | 0 <2 | 0 | 0"]
  P --> O["toán tử >=, thành phần 1, 2, '0 <2', 0, 0"]
  O --> C["so 3.0.0: 3 > 1"]
  C --> R["true"]

Vì regex toán tử chỉ chạy trên thành phần đầu, mọi thứ sau dấu cách trở thành một phần của các thành phần sau. Chạy chính module này trong Node với các ca trong demo:

Range Version VersionRange npm
^1.2.0 1.5.0 false true
>=1.2.0 <2.0.0 3.0.0 true false
>=1.0.0 1.0.0-rc.1 true false
~1.2 1.3.0 true false
1.x 1.5.3 false true
1.0.0 - 2.0 2.0.5 false true
<=1.2 1.2.5 false true
14 - 15 15.4 false true (sau semver.coerce)

Ba dòng cuối có cùng nguyên nhân: cận trên thiếu số được lấp 0 thay vì hiểu là "cả dòng 15.x". Với UserAgent thì isBrowser("Safari 14 - 15") trả false trên Safari 15.4, một lỗi rất dễ gặp. Ca ^ có lẽ không ảnh hưởng thực tế vì không ai viết isBrowser("Chrome ^100"), nhưng một hàm âm thầm trả false thay vì báo "không hiểu cú pháp" thì khó phát hiện.

#Viết lại

src/lib/semverRange.ts. So sánh version theo semver 2.0: ba số, rồi prerelease. Không có prerelease thì lớn hơn, định danh số nhỏ hơn định danh chữ, chữ so theo ASCII, danh sách ngắn hơn thì nhỏ hơn (1.0.0-alpha < 1.0.0-alpha.1).

Mỗi điều kiện viết tắt được đổi về một hoặc hai bộ so sánh đơn. -0 là prerelease nhỏ nhất, nên <2.0.0-0 loại cả 2.0.0-alpha:

src/lib/semverRange.ts
function desugar(token: string, incPre: boolean): Comparator[] {
  const m = /^(\^|~>?|<=|>=|<|>|=)?(.*)$/.exec(token)!;
  const op = m[1] ?? '';
  const { nums, pre } = partial(m[2]);
  const [M, m2, p] = nums;
  if (M === null) return op === '<' || op === '>' ? [{ op: '<', v: floor(0) }] : []; // "*" khớp tất cả, "<*" không gì
  const exact = p !== null;
  // includePrerelease: cận dưới của range thiếu số hạ xuống "-0" để ">=1.2" nhận cả 1.2.0-alpha
  const low = (major: number, minor = 0) => (incPre ? floor(major, minor) : ver(major, minor));
  const lower = exact ? ver(M, m2!, p, pre) : low(M, m2 ?? 0);
  // phần trên của một x-range: 1.2 → <1.3.0-0, 1 → <2.0.0-0
  const next = () => (m2 === null ? floor(M + 1) : floor(M, m2 + 1));

  if (op === '^') {
    const upper = M > 0 || m2 === null ? floor(M + 1) : m2 > 0 || p === null ? floor(0, m2 + 1) : floor(0, 0, p! + 1);
    return [
      { op: '>=', v: lower },
      { op: '<', v: upper },
    ];
  }
  if (op.startsWith('~')) {
    return [
      { op: '>=', v: exact ? lower : ver(M, m2 ?? 0) }, // npm không hạ "-0" cho ~
      { op: '<', v: next() },
    ];
  }
  switch (op) {
    case '':
    case '=':
      return exact
        ? [{ op: '=', v: lower }]
        : [
            { op: '>=', v: lower },
            { op: '<', v: next() },
          ];
    case '>': {
      if (exact) return [{ op: '>', v: lower }];
      const n = next();
      return [{ op: '>=', v: incPre ? n : ver(n.major, n.minor) }];
    }
    case '>=':
      return [{ op: '>=', v: lower }];
    case '<':
      return [{ op: '<', v: exact ? lower : floor(M, m2 ?? 0) }];
    case '<=':
      return [exact ? { op: '<=', v: lower } : { op: '<', v: next() }];
  }
  throw new SyntaxError(`Toán tử lạ: ${op}`);
}
ts
Xem toàn bộ src/lib/semverRange.ts
src/lib/semverRange.ts
/**
 * Kiểm tra "version có nằm trong range không", cú pháp giống npm (ý tưởng từ VersionRange của Facebook).
 *
 * Hỗ trợ: `||`, khoảng gạch nối `1.2 - 2.3`, nhiều điều kiện cách nhau bởi dấu cách (AND),
 * `^`, `~` (và `~>`), wildcard `x`/`*`/thiếu số, so sánh prerelease theo semver 2.0.
 *
 * Khác bản gốc: bản gốc không có `^` (luôn trả false, không báo lỗi), không có AND bằng dấu cách
 * (`">=1.2 <2"` thành `">=1.2"`), so prerelease bằng so chuỗi nên `1.0.0-rc.1` lớn hơn `1.0.0`,
 * và `~1.2` hiểu theo kiểu Ruby (`< 2`) thay vì npm (`< 1.3`).
 */

export interface Version {
  major: number;
  minor: number;
  patch: number;
  pre: (string | number)[];
}

const VERSION = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z.-]+)?$/;

export function parseVersion(text: string): Version {
  const m = VERSION.exec(text.trim());
  if (!m) throw new SyntaxError(`Version không hợp lệ: "${text}"`);
  const pre = m[4] ? m[4].split('.').map((id) => (/^\d+$/.test(id) ? Number(id) : id)) : [];
  return { major: +m[1], minor: +m[2], patch: +m[3], pre };
}

/**
 * Version "lỏng" kiểu trình duyệt/hệ điều hành: "15.4", "120.0.6099.109", "NT 10.0".
 * Đúng chuẩn semver thì giữ nguyên (kể cả prerelease), còn lại lấy tối đa ba số đầu, thiếu thì lấp 0.
 */
export function coerceVersion(text: string): Version {
  if (VERSION.test(text.trim())) return parseVersion(text);
  const m = /(\d+)(?:\.(\d+))?(?:\.(\d+))?/.exec(text);
  if (!m) throw new SyntaxError(`Không tìm thấy số version trong "${text}"`);
  return ver(+m[1], +(m[2] ?? 0), +(m[3] ?? 0));
}

function comparePre(a: Version['pre'], b: Version['pre']): number {
  if (!a.length || !b.length) return b.length - a.length; // không có prerelease thì lớn hơn
  for (let i = 0; i < Math.max(a.length, b.length); i++) {
    const x = a[i];
    const y = b[i];
    if (x === undefined) return -1; // 1.0.0-alpha < 1.0.0-alpha.1
    if (y === undefined) return 1;
    if (x === y) continue;
    if (typeof x === 'number' && typeof y === 'number') return x < y ? -1 : 1;
    if (typeof x === 'number') return -1; // số luôn nhỏ hơn chữ
    if (typeof y === 'number') return 1;
    return x < y ? -1 : 1; // so ASCII
  }
  return 0;
}

export function compareVersions(a: Version, b: Version): number {
  return a.major - b.major || a.minor - b.minor || a.patch - b.patch || comparePre(a.pre, b.pre);
}

type Op = '<' | '<=' | '>' | '>=' | '=';
interface Comparator {
  op: Op;
  v: Version;
}

/** "1.2" → [1, 2, null]; x, X, * và phần thiếu đều là null */
function partial(text: string): { nums: (number | null)[]; pre: Version['pre'] } {
  const m = /^v?(\d+|[xX*])?(?:\.(\d+|[xX*]))?(?:\.(\d+|[xX*]))?(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(text);
  if (!m) throw new SyntaxError(`Range không hợp lệ: "${text}"`);
  const nums = [m[1], m[2], m[3]].map((p) => (p === undefined || /^[xX*]$/.test(p) ? null : Number(p)));
  // sau một chỗ wildcard thì mọi phần sau cũng là wildcard: 1.x.3 = 1.x
  const firstWild = nums.indexOf(null);
  if (firstWild >= 0) nums.fill(null, firstWild);
  const pre = m[4] ? m[4].split('.').map((id) => (/^\d+$/.test(id) ? Number(id) : id)) : [];
  return { nums, pre };
}

const ver = (major: number, minor = 0, patch = 0, pre: Version['pre'] = []): Version => ({ major, minor, patch, pre });
/** "-0" là prerelease nhỏ nhất: <2.0.0-0 loại cả 2.0.0-alpha */
const floor = (major: number, minor = 0, patch = 0) => ver(major, minor, patch, [0]);

function desugar(token: string, incPre: boolean): Comparator[] {
  const m = /^(\^|~>?|<=|>=|<|>|=)?(.*)$/.exec(token)!;
  const op = m[1] ?? '';
  const { nums, pre } = partial(m[2]);
  const [M, m2, p] = nums;
  if (M === null) return op === '<' || op === '>' ? [{ op: '<', v: floor(0) }] : []; // "*" khớp tất cả, "<*" không gì
  const exact = p !== null;
  // includePrerelease: cận dưới của range thiếu số hạ xuống "-0" để ">=1.2" nhận cả 1.2.0-alpha
  const low = (major: number, minor = 0) => (incPre ? floor(major, minor) : ver(major, minor));
  const lower = exact ? ver(M, m2!, p, pre) : low(M, m2 ?? 0);
  // phần trên của một x-range: 1.2 → <1.3.0-0, 1 → <2.0.0-0
  const next = () => (m2 === null ? floor(M + 1) : floor(M, m2 + 1));

  if (op === '^') {
    const upper = M > 0 || m2 === null ? floor(M + 1) : m2 > 0 || p === null ? floor(0, m2 + 1) : floor(0, 0, p! + 1);
    return [
      { op: '>=', v: lower },
      { op: '<', v: upper },
    ];
  }
  if (op.startsWith('~')) {
    return [
      { op: '>=', v: exact ? lower : ver(M, m2 ?? 0) }, // npm không hạ "-0" cho ~
      { op: '<', v: next() },
    ];
  }
  switch (op) {
    case '':
    case '=':
      return exact
        ? [{ op: '=', v: lower }]
        : [
            { op: '>=', v: lower },
            { op: '<', v: next() },
          ];
    case '>': {
      if (exact) return [{ op: '>', v: lower }];
      const n = next();
      return [{ op: '>=', v: incPre ? n : ver(n.major, n.minor) }];
    }
    case '>=':
      return [{ op: '>=', v: lower }];
    case '<':
      return [{ op: '<', v: exact ? lower : floor(M, m2 ?? 0) }];
    case '<=':
      return [exact ? { op: '<=', v: lower } : { op: '<', v: next() }];
  }
  throw new SyntaxError(`Toán tử lạ: ${op}`);
}

/** "1.2 - 2.3": đầu thiếu số thì lấp 0, cuối thiếu số thì lấy "< phiên bản kế tiếp" */
function hyphen(from: string, to: string, incPre: boolean): Comparator[] {
  const out: Comparator[] = [];
  const a = partial(from);
  if (a.nums[0] !== null) {
    const [M, m, p] = a.nums as number[];
    if (p === null || a.pre.length) out.push(...desugar('>=' + from, incPre));
    else out.push({ op: '>=', v: incPre ? floor(M, m, p) : ver(M, m, p) });
  }
  const b = partial(to);
  if (b.nums[0] !== null) {
    const [M, m, p] = b.nums as number[];
    if (p === null || b.pre.length || !incPre) out.push(...desugar('<=' + to, incPre));
    else out.push({ op: '<', v: floor(M, m, p + 1) });
  }
  return out;
}

function parseSet(set: string, incPre: boolean): Comparator[] {
  const h = /^(\S+)\s+-\s+(\S+)$/.exec(set);
  if (h) return hyphen(h[1], h[2], incPre);
  // ">= 1.2" → ">=1.2" rồi tách theo khoảng trắng: mọi điều kiện phải cùng đúng
  const tokens = set.replace(/(\^|~>?|<=|>=|<|>|=)\s+/g, '$1').split(/\s+/).filter(Boolean);
  return tokens.flatMap((t) => desugar(t, incPre));
}

export function parseRange(range: string, includePrerelease = false): Comparator[][] {
  return range.split('||').map((s) => parseSet(s.trim(), includePrerelease));
}

const test = (v: Version, c: Comparator) => {
  const r = compareVersions(v, c.v);
  return c.op === '<' ? r < 0 : c.op === '<=' ? r <= 0 : c.op === '>' ? r > 0 : c.op === '>=' ? r >= 0 : r === 0;
};

export interface SatisfiesOptions {
  /** mặc định false như npm: 2.0.0-beta chỉ khớp range có nhắc prerelease của chính 2.0.0 */
  includePrerelease?: boolean;
  /** nhận version thiếu số hoặc dư số ("15.4", "120.0.6099.109") bằng coerceVersion */
  loose?: boolean;
}

export function satisfies(
  version: string,
  range: string,
  { includePrerelease = false, loose = false }: SatisfiesOptions = {},
) {
  const v = loose ? coerceVersion(version) : parseVersion(version);
  return parseRange(range, includePrerelease).some((set) => {
    if (!set.every((c) => test(v, c))) return false;
    if (!v.pre.length || includePrerelease) return true;
    // prerelease chỉ lọt qua khi chính range nhắc tới prerelease của cùng major.minor.patch
    return set.some(
      (c) => c.v.pre.length > 0 && c.v.major === v.major && c.v.minor === v.minor && c.v.patch === v.patch,
    );
  });
}
ts

Khác bản gốc:

  • Có ^, có AND bằng dấu cách, có >= 1.2 (dấu cách giữa toán tử và số).
  • Thiếu số được hiểu như npm: 1.2 là >=1.2.0 <1.3.0-0, <=1.2 là <1.3.0-0, 1.0 - 2.0 là >=1.0.0 <2.1.0-0.
  • Prerelease so theo semver 2.0, và mặc định chỉ khớp range có nhắc prerelease của cùng major.minor.patch (luật của npm). includePrerelease: true bỏ luật đó.
  • ~ theo npm. Bản gốc coi ~ và ~> là một và theo Ruby; bản viết lại cũng coi là một nhưng theo npm.
  • parseVersion mặc định đòi đủ ba số. loose: true dùng coerceVersion để nhận "15.4" hay "120.0.6099.109" (lấy ba số đầu).
  • Cú pháp sai thì ném SyntaxError, không trả false.

#Đi qua code

  • partial(text) đọc 1, 1.2, 1.2.x, * thành mảng ba phần tử, null cho phần thiếu hoặc wildcard. Sau null đầu tiên thì mọi phần sau cũng là null: 1.x.3 giống 1.x.
  • desugar xử lý theo toán tử. ^ tìm số khác 0 đầu tiên rồi tăng nó (^0.2.3 → <0.3.0-0, ^0.0.3 → <0.0.4-0). ~ tăng minor nếu có, không thì major.
  • >1.2 thành >=1.3.0: "lớn hơn mọi bản 1.2.x". <1.2 thành <1.2.0-0 để loại cả 1.2.0-alpha.
  • hyphen tách riêng vì hai vế có luật lấp số khác nhau: vế trái lấp 0, vế phải lấy "nhỏ hơn bản kế tiếp".
  • satisfies thử từng nhánh ||. Trong một nhánh, mọi bộ so sánh phải đúng. Version có prerelease thì cần thêm một bộ so sánh có prerelease cùng major.minor.patch.
  • Kiểm chứng: sinh ngẫu nhiên 200.000 cặp range/version (kết hợp ^ ~ ~> < <= > >= =, wildcard, khoảng gạch nối, ||, prerelease), chạy cả hai chế độ includePrerelease, so với semver@7.8.4. Mỗi lần chạy lệch 3 tới 5 ca trên 400.000, đều cùng một kiểu: khi một nhánh || là *, npm rút gọn cả range thành * rồi áp luật prerelease, nên x || <1.0.0-alpha.beta không khớp 1.0.0-alpha. Bản viết lại vẫn cho nhánh thứ hai khớp.

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

  • Dùng thư viện semver: nên dùng nếu được, nhất là ở server hay công cụ build. Nó đúng tới từng góc, đổi lại nặng hơn nhiều so với một file 200 dòng. Bản tự viết đáng khi cần vài KB trong bundle client, hoặc cần cú pháp riêng như UserAgent của Facebook.
  • So chuỗi "1.10" > "1.9": sai ("1.10" < "1.9" theo thứ tự chữ). Phải tách và so từng số.
  • localeCompare(b, undefined, { numeric: true }): so được 1.10 với 1.9, nhưng không hiểu prerelease (1.0.0-rc.1 vẫn lớn hơn 1.0.0).
  • So version trình duyệt bằng feature detection: cách được khuyên dùng nhất. isBrowser("Safari < 14") chỉ nên là đường lùi cho lỗi riêng của một phiên bản, không phải để đoán tính năng.

#Edge case và cạm bẫy

  • Build metadata (1.0.0+20260101) bị bỏ qua khi so sánh, theo semver 2.0.
  • Số rất lớn: thành phần version vượt 2^53 thì Number mất chính xác. npm giới hạn MAX_SAFE_INTEGER; bản viết lại không kiểm tra.
  • v1.2.3: chữ v đầu được chấp nhận ở cả version lẫn range, như npm.
  • 1.2.3 - 2.3.4 - 3 (hai dấu gạch): bản gốc ném lỗi invariant, bản viết lại báo SyntaxError vì - 3 không phải điều kiện hợp lệ.
  • Range rỗng ("") khớp mọi version không phải prerelease, giống npm.

#Dùng trong dự án

Kiểm tra phiên bản plugin hay API mà server báo về:

import { satisfies } from './lib/semverRange';

if (!satisfies(server.apiVersion, '^3.2.0')) {
  showBanner('Ứng dụng cần cập nhật để dùng tính năng này');
}
ts

Bật đường lùi cho một lỗi riêng của Safari, giống UserAgent.isBrowser:

const safari = /Version\/([\d.]+).*Safari/.exec(navigator.userAgent)?.[1];
const needsScrollFix = safari != null && satisfies(safari, '>=15.0 <15.4', { loose: true });
ts

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { coerceVersion, compareVersions, parseVersion, satisfies } from '../src/lib/semverRange';

describe('compareVersions', () => {
  it('theo thứ tự trong đặc tả semver 2.0', () => {
    const order = [
      '1.0.0-alpha',
      '1.0.0-alpha.1',
      '1.0.0-alpha.beta',
      '1.0.0-beta',
      '1.0.0-beta.2',
      '1.0.0-beta.11',
      '1.0.0-rc.1',
      '1.0.0',
    ].map(parseVersion);
    for (let i = 1; i < order.length; i++) expect(compareVersions(order[i - 1], order[i])).toBeLessThan(0);
  });
});

describe('satisfies', () => {
  it.each([
    ['^1.2.0', '1.5.0', true],
    ['^0.2.3', '0.3.0', false],
    ['>=1.2.0 <2.0.0', '3.0.0', false],
    ['>= 1.2.0', '1.3.0', true],
    ['~1.2', '1.3.0', false],
    ['1.x', '1.5.3', true],
    ['1.0.0 - 2.0', '2.0.5', true],
    ['<=1.2', '1.2.5', true],
    ['>1.2', '1.2.9', false],
    ['>=1.0.0', '1.0.0-rc.1', false],
    ['>=1.0.0-beta', '1.0.0-rc.1', true],
    ['<2.0.0', '2.0.0-beta.1', false],
    ['1.2.x || >=3', '3.1.0', true],
  ])('%s chứa %s → %s', (range, version, expected) => {
    expect(satisfies(version, range)).toBe(expected);
  });

  it('includePrerelease', () => {
    expect(satisfies('1.3.0-alpha', '^1.2.0')).toBe(false);
    expect(satisfies('1.3.0-alpha', '^1.2.0', { includePrerelease: true })).toBe(true);
  });

  it('loose cho version trình duyệt', () => {
    expect(coerceVersion('120.0.6099.109')).toMatchObject({ major: 120, minor: 0, patch: 6099 });
    expect(satisfies('15.4', '14 - 15', { loose: true })).toBe(true);
    expect(() => satisfies('15.4', '14 - 15')).toThrow(SyntaxError);
  });
});
ts

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

  • semver (npm): chuẩn thực tế, định nghĩa cú pháp range mà hầu hết công cụ JavaScript dùng. Bản 7.x có satisfies, coerce, minVersion, intersects, subset (tính tới 10/2026).
  • Cargo, Composer, Bundler mỗi nơi một cú pháp hơi khác: Cargo coi 1.2.3 là ^1.2.3, Bundler có ~> theo kiểu Ruby (đúng nghĩa ~ của VersionRange). Khi viết hàm range, ghi rõ theo quy ước của ai.
  • User-Agent Client Hints (navigator.userAgentData.getHighEntropyValues(['fullVersionList'])) cho phiên bản trình duyệt có cấu trúc thay vì phải parse chuỗi user agent. Mới có trên Chromium (tính tới 10/2026); Safari và Firefox vẫn chỉ có chuỗi user agent.

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