Web Totals

Học từ Facebook #37: Cookie theo bảng chính sách, hash lỗi và chuột dừng

CookieCore của Facebook khai báo path, TTL, Secure, SameSite cho mọi cookie ở một chỗ, tên lạ bị từ chối. Thêm góc mới về getSimpleHash và CometMouseActivity.

hunghg255hunghg25524 min read
#facebook#cookie#security#javascript#infra
Học từ Facebook #37: Cookie theo bảng chính sách, hash lỗi và chuột dừng

Code set cookie trong một dự án lâu năm thường trông thế này: chỗ này document.cookie = 'lang=vi; path=/', chỗ kia một helper với max-age=31536000, chỗ khác quên Secure, cookie mới thêm năm ngoái không ai nhớ đặt SameSite. Đến lúc trình duyệt đổi mặc định SameSite hay đội bảo mật hỏi "trang mình đang set những cookie nào, sống bao lâu", không ai trả lời được.

Facebook giải quyết bằng một module nhỏ: mọi cookie phải được khai báo trong một bảng chính sách, tên không có trong bảng thì không set được. Bài này đi qua CookieCore, rồi bổ sung hai góc chưa nói ở các bài trước cho getSimpleHash (xem bài #6 ErrorGuard) và CometMouseActivity (xem bài #3 preload khi hover).

Tip

Tóm tắt

  • CookieCore đọc path, TTL, Secure, SameSite của từng cookie từ CookieCoreConfig. Cookie _js_<x> dùng chính sách của <x>. Tên không khai báo thì set im lặng bỏ qua.
  • Mặc định của bản gốc rất chặt: Secure và SameSite=None; domain được rút về domain gốc (www.facebook.com → .facebook.com) bằng một regex.
  • TTL bằng đúng 1 (ms) được hiểu là "xoá cookie"; giá trị lớn hơn Date.now() được hiểu là mốc hết hạn tuyệt đối. Bản viết lại tách thành API rõ ràng.
  • getSimpleHash nối các tham số liền nhau nên hash('ab','c') === hash('a','bc'); CometMouseActivity có một hàm huỷ làm hỏng luôn việc đăng ký lại.

#Facebook làm thế nào?

CookieCore có trong bundle 22.js của facebook-codebase (cùng nhiều bản trùng ở các bundle khác). Đã đổi tên biến:

const DOMAIN_RE = /^.*(\.(facebook|messenger|oculus|instagram|facebookcorewwwi|workplace|fbaddins)\..*)$/i;
const JS_PREFIX = /_js_(.*)/;

function policyOf(name) {
  if (CookieCoreConfig[name] !== undefined) {
    const p = CookieCoreConfig[name];
    return { insecure: p.i || false, path: p.p || '/', ttlSeconds: p.t || 0, sameSite: p.s || 'None' };
  }
  const match = name.match(JS_PREFIX);
  return match && match.length > 1 ? policyOf(match[1]) : null;   // _js_datr -> datr
}
const isAllowed = (name) => policyOf(name) !== null;

function set(name, value) {
  if (!isAllowed(name)) return;                                     // không có trong bảng: bỏ qua
  setWithoutChecks(name, value, ttlMs(name), path(name), secure(name), sameSite(name));
}

function setWithoutChecks(name, value, ttl, path, secure, sameSite) {
  const now = Date.now();
  if (ttl != null) {
    if (ttl > now) ttl -= now;                                      // mốc tuyệt đối -> thời lượng
    else if (ttl == 1) { clear(name, path); return; }               // 1 = xoá
  }
  store().setCookie(now, name, value, path, location.hostname.replace(DOMAIN_RE, '$1'), ttl, secure, sameSite);
}

function setIfFirstPartyContext(name, value) {
  if (window.self != window.top) return;                            // đang trong iframe: không set
  set(name, value);
}

function get(name) { return !isAllowed(name) ? null : store().getCookie(name); }
js

Vài điều đáng học:

  • Bảng cấu hình là nguồn sự thật duy nhất. CookieCoreConfig không nằm trong code mà được server gửi kèm dữ liệu trang (trong 000.js / Bootloader/SiteData.js của bản dump, dạng ["CookieCoreConfig", [], {…}]), khoá rút gọn i/p/t/s. Bản dump có 18 mục, ví dụ:

    Cookie t (giây) s
    locale 604800 (7 ngày) None
    presence 2592000 (30 ngày) None
    wd (kích thước cửa sổ) 604800 Lax
    fbl_st 31536000 (1 năm) Strict
    c_user (cookie phiên) None
    x-src 1 None

    Muốn thêm cookie mới phải thêm vào bảng, tức là phải qua review. Cũng nhờ đó trả lời được ngay câu "trang set những cookie nào".

  • Tên lạ bị từ chối, kể cả khi đọc. get('abc') trả null dù cookie abc có tồn tại: code client không phụ thuộc được vào cookie chưa khai báo.

  • Mặc định an toàn. Không khai báo gì thì cookie là Secure và SameSite=None (gửi kèm cả request khác site, vì Facebook có nhiều domain và plugin nhúng). Muốn cookie gửi qua http phải ghi rõ i: true.

  • Domain gốc bằng regex. www.facebook.com → .facebook.com, m.facebook.com → .facebook.com: cookie dùng chung cho mọi subdomain. Host không khớp regex thì giữ nguyên hostname.

  • Không set trong iframe với setIfFirstPartyContext: khi trang Facebook bị nhúng vào site khác, nó không ghi đè cookie của chính người dùng.

#Hai chỗ "ma thuật"

Tham số ttl của setWithoutChecks mang ba nghĩa tuỳ giá trị:

Giá trị Nghĩa
> Date.now() mốc hết hạn tuyệt đối (ms), đổi ra thời lượng
== 1 xoá cookie
còn lại thời lượng (ms); 0 hoặc null là cookie phiên

Chạy được, nhưng ai đọc setWithoutChecks('x', '', 1) mà không biết quy ước sẽ tưởng đặt TTL 1ms. Để ý set() nhân t với 1000 trước khi truyền xuống, nên cookie x-src với t: 1 trong bảng là cookie sống 1 giây chứ không bị xoá: "1 = xoá" chỉ xảy ra khi ai đó gọi thẳng setWithoutChecks với 1. Hai quy ước chồng lên cùng một con số là chỗ rất dễ nhầm. Bản viết lại tách thành clear(name) và { ttlSeconds } / { expiresAt }.

Regex tiền tố /_js_(.*)/ không neo đầu chuỗi, nên abc_js_datr cũng ăn chính sách của datr. Có lẽ không ai đặt tên như vậy, nhưng neo ^ thì chắc chắn hơn.

flowchart TD
  A["set name, value"] --> B{"name có trong CookieCoreConfig?"}
  B -- "có" --> D["lấy path, ttl, secure, sameSite"]
  B -- "không" --> C{"name dạng _js_x?"}
  C -- "có" --> B2["tra lại với x"] --> B
  C -- "không" --> X["bỏ qua, không set"]
  D --> E{"ttl"}
  E -- "lớn hơn Date.now" --> F["đổi mốc tuyệt đối sang thời lượng"]
  E -- "bằng 1" --> G["xoá cookie"]
  E -- "khác" --> H["giữ nguyên"]
  F --> I["ghi cookie, domain rút về domain gốc"]
  H --> I
  • path giới hạn URL mà cookie được gửi kèm và đọc được qua document.cookie. Demo đặt path bằng thư mục của trang demo để không chạm phần còn lại của blog.
  • max-age tính bằng giây; thiếu cả max-age lẫn expires thì là cookie phiên (mất khi đóng trình duyệt, tuy trình duyệt có tính năng khôi phục phiên có thể giữ lại).
  • Secure chỉ gửi qua HTTPS. Trình duyệt coi localhost là ngữ cảnh an toàn nên vẫn set được khi dev.
  • SameSite=None bắt buộc đi kèm Secure, nếu không cookie bị từ chối.
  • domain=.facebook.com cho mọi subdomain; không ghi domain thì cookie chỉ thuộc đúng host hiện tại (host-only).
  • Xoá cookie = ghi lại cùng tên, cùng path, cùng domain với max-age=0. Sai path hoặc domain thì trình duyệt coi là cookie khác, cookie cũ vẫn còn. Đây là lý do bảng chính sách giúp cả việc xoá: path và domain luôn lấy từ cùng một chỗ.

#Viết lại

src/lib/smallInfra.ts
export function resolvePolicy(config: CookieConfig, name: string): ResolvedPolicy | null {
  let key = name;
  // bản gốc dùng regex /_js_(.*)/ không neo đầu: "abc_js_x" cũng khớp. Ở đây neo ^.
  while (!(key in config)) {
    const match = key.match(JS_PREFIX);
    if (!match) return null;
    key = match[1];
  }
  const p = config[key];
  return {
    name,
    policyKey: key,
    ttlSeconds: p.ttlSeconds ?? 0,
    path: p.path ?? '/',
    secure: !p.insecure,
    sameSite: p.sameSite ?? 'Lax',
  };
}

/**
 * Domain để set cookie: rút về domain gốc nếu host thuộc danh sách (vd www.facebook.com ->
 * .facebook.com) để mọi subdomain đọc được; còn lại để host-only.
 */
export function cookieDomain(hostname: string, baseDomains: string[]): string | null {
  for (const base of baseDomains) {
    if (hostname === base || hostname.endsWith(`.${base}`)) return `.${base}`;
  }
  return null;
}
ts

createCookieStore ghép các phần: set từ chối tên không có chính sách (hoặc đang trong iframe nếu bật firstPartyOnly), dựng chuỗi bằng serializeCookie, rồi ghi qua hàm write (mặc định là document.cookie).

Xem toàn bộ src/lib/smallInfra.ts
src/lib/smallInfra.ts
/* -------------------------------------------------------------------------- */
/* 1. Cookie theo chính sách khai báo một chỗ (ý tưởng từ CookieCore)         */
/* -------------------------------------------------------------------------- */

/**
 * Facebook không rải path/TTL/secure/SameSite khắp nơi mỗi lần set cookie. Mọi cookie được
 * khai báo trong một bảng cấu hình (CookieCoreConfig); tên không có trong bảng thì KHÔNG
 * được set. Cookie tên `_js_<x>` dùng chung chính sách với `<x>`.
 */
export interface CookiePolicy {
  /** thời gian sống, giây; 0 = cookie phiên */
  ttlSeconds?: number;
  path?: string;
  /** cho phép gửi qua http (mặc định false = luôn Secure) */
  insecure?: boolean;
  sameSite?: 'Strict' | 'Lax' | 'None';
}

export type CookieConfig = Record<string, CookiePolicy>;

export interface ResolvedPolicy {
  name: string;
  /** tên khoá chính sách thực sự được dùng (có thể khác name nếu đi qua tiền tố _js_) */
  policyKey: string;
  ttlSeconds: number;
  path: string;
  secure: boolean;
  sameSite: 'Strict' | 'Lax' | 'None';
}

const JS_PREFIX = /^_js_(.+)$/;

export function resolvePolicy(config: CookieConfig, name: string): ResolvedPolicy | null {
  let key = name;
  // bản gốc dùng regex /_js_(.*)/ không neo đầu: "abc_js_x" cũng khớp. Ở đây neo ^.
  while (!(key in config)) {
    const match = key.match(JS_PREFIX);
    if (!match) return null;
    key = match[1];
  }
  const p = config[key];
  return {
    name,
    policyKey: key,
    ttlSeconds: p.ttlSeconds ?? 0,
    path: p.path ?? '/',
    secure: !p.insecure,
    sameSite: p.sameSite ?? 'Lax',
  };
}

/**
 * Domain để set cookie: rút về domain gốc nếu host thuộc danh sách (vd www.facebook.com ->
 * .facebook.com) để mọi subdomain đọc được; còn lại để host-only.
 */
export function cookieDomain(hostname: string, baseDomains: string[]): string | null {
  for (const base of baseDomains) {
    if (hostname === base || hostname.endsWith(`.${base}`)) return `.${base}`;
  }
  return null;
}

export interface SetOptions {
  /** ghi đè TTL của chính sách, giây */
  ttlSeconds?: number;
  /** mốc hết hạn tuyệt đối (ms). Bản gốc nhận chung một tham số: > Date.now() thì hiểu là mốc tuyệt đối */
  expiresAt?: number;
}

export function serializeCookie(policy: ResolvedPolicy, value: string, domain: string | null, opts: SetOptions = {}, now = Date.now()) {
  const parts = [`${encodeURIComponent(policy.name)}=${encodeURIComponent(value)}`, `path=${policy.path}`];
  const maxAge = opts.expiresAt != null ? Math.round((opts.expiresAt - now) / 1000) : (opts.ttlSeconds ?? policy.ttlSeconds);
  if (maxAge > 0) parts.push(`max-age=${maxAge}`);
  if (domain) parts.push(`domain=${domain}`);
  if (policy.secure) parts.push('secure');
  parts.push(`samesite=${policy.sameSite.toLowerCase()}`);
  return parts.join('; ');
}

export interface CookieStoreOptions {
  config: CookieConfig;
  baseDomains?: string[];
  /** để test: ghi/đọc vào chỗ khác thay vì document.cookie */
  write?: (cookie: string) => void;
  read?: () => string;
  hostname?: string;
  /** chỉ set khi trang không nằm trong iframe (setIfFirstPartyContext của bản gốc) */
  firstPartyOnly?: boolean;
}

export function createCookieStore({
  config,
  baseDomains = [],
  write = (c) => (document.cookie = c),
  read = () => document.cookie,
  hostname = location.hostname,
  firstPartyOnly = false,
}: CookieStoreOptions) {
  const domain = cookieDomain(hostname, baseDomains);
  const inIframe = typeof window !== 'undefined' && window.self !== window.top;

  return {
    /** trả về chuỗi đã ghi, hoặc null nếu bị từ chối (không có chính sách / đang trong iframe) */
    set(name: string, value: string, opts?: SetOptions): string | null {
      const policy = resolvePolicy(config, name);
      if (!policy || (firstPartyOnly && inIframe)) return null;
      const cookie = serializeCookie(policy, value, domain, opts);
      write(cookie);
      return cookie;
    },
    get(name: string): string | null {
      if (!resolvePolicy(config, name)) return null;
      const prefix = `${encodeURIComponent(name)}=`;
      const hit = read()
        .split('; ')
        .find((c) => c.startsWith(prefix));
      return hit ? decodeURIComponent(hit.slice(prefix.length)) : null;
    },
    /** xoá rõ ràng. Bản gốc dùng "TTL == 1" làm tín hiệu xoá. */
    clear(name: string): string | null {
      const policy = resolvePolicy(config, name);
      if (!policy) return null;
      const cookie = `${encodeURIComponent(name)}=; path=${policy.path}; max-age=0${domain ? `; domain=${domain}` : ''}`;
      write(cookie);
      return cookie;
    },
  };
}

/* -------------------------------------------------------------------------- */
/* 2. Hash 6 ký tự để gom lỗi giống nhau (getSimpleHash)                       */
/* -------------------------------------------------------------------------- */

const BASE32 = 'abcdefghijklmnopqrstuvwxyz012345';

/**
 * Hash đa thức kiểu Java (h = h*31 + c), lấy 30 bit thấp mã hoá thành 6 ký tự base32.
 * Bản gốc nối các tham số liền nhau nên hash('ab','c') === hash('a','bc');
 * `separate: true` chèn ký tự \u0000 giữa các phần để tránh va chạm kiểu đó.
 */
export function simpleHash(parts: Array<string | null | undefined>, { separate = true } = {}): string {
  let h = 0;
  parts.forEach((part, index) => {
    if (part == null) return;
    if (separate && index > 0) h = (Math.imul(h, 31) + 0) | 0;
    for (let i = 0; i < part.length; i++) h = (Math.imul(h, 31) + part.charCodeAt(i)) | 0;
  });
  let out = '';
  for (let i = 0; i < 6; i++) {
    out = BASE32.charAt(h & 31) + out;
    h >>= 5;
  }
  return out;
}

/* -------------------------------------------------------------------------- */
/* 3. "Chuột vừa dừng" (CometMouseActivity)                                   */
/* -------------------------------------------------------------------------- */

/**
 * Một listener mousemove dùng chung cho cả trang; không có mousemove trong `delay` ms thì
 * gọi mọi callback. Khác bản gốc: listener được gỡ khi không còn ai đăng ký (bản gốc giữ
 * listener mãi, và hàm huỷ của init() gỡ listener nhưng không reset cờ, nên sau đó không
 * đăng ký lại được nữa).
 */
const stopCallbacks = new Set<() => void>();
let stopTimer: ReturnType<typeof setTimeout> | null = null;
let stopDelay = 50;

function onMove() {
  if (stopTimer != null) clearTimeout(stopTimer);
  stopTimer = setTimeout(() => {
    stopTimer = null;
    stopCallbacks.forEach((fn) => fn());
  }, stopDelay);
}

export function onMouseStop(callback: () => void, { delay = 50 } = {}) {
  stopDelay = delay;
  if (stopCallbacks.size === 0) window.addEventListener('mousemove', onMove, { passive: true });
  stopCallbacks.add(callback);
  return () => {
    stopCallbacks.delete(callback);
    if (stopCallbacks.size === 0) {
      window.removeEventListener('mousemove', onMove);
      if (stopTimer != null) clearTimeout(stopTimer);
      stopTimer = null;
    }
  };
}

/** số callback đang đăng ký (để demo) */
export const mouseStopSubscribers = () => stopCallbacks.size;
ts

Khác bản gốc: mặc định SameSite=Lax (hợp với site thông thường; Facebook cần None vì có nhiều domain và nhúng chéo), domain gốc truyền vào dạng danh sách thay vì regex cứng, clear() tách riêng thay cho TTL = 1, regex tiền tố có neo, và hàm ghi/đọc tiêm vào được để test không cần document.cookie.

#getSimpleHash: góc mới

Bài #6 đã nói getSimpleHash(name, stack, type, project, source) tạo "dấu vân tay" 6 ký tự để gom các lỗi giống nhau. Nhìn kỹ code (ErrorGuard/getSimpleHash.js, cũng có bản trong FBLogger):

const ALPHABET = 'abcdefghijklmnopqrstuvwxyz012345';
function getSimpleHash(...parts) {
  let h = 0;
  for (const part of parts) {
    if (part != null) for (let i = 0; i < part.length; i++) h = (h << 5) - h + part.charCodeAt(i);
  }
  let out = '';
  for (let k = 0; k < 6; k++) { out = ALPHABET.charAt(h & 31) + out; h >>= 5; }
  return out;
}
js
  • (h << 5) - h là h * 31: hash đa thức kiểu String.hashCode của Java.
  • 6 ký tự × 5 bit = 30 bit, tức khoảng 1,07 tỉ giá trị. Đủ để gom lỗi, nhưng không dùng làm khoá duy nhất được.
  • h không được ép về 32 bit sau mỗi bước (| 0), nên có lúc vượt khoảng int32. Không sai: lần << tiếp theo tự cắt về int32, và & 31, >>= 5 cũng cắt trước khi tính. Mình đã so 4 bộ đầu vào (có cả chữ có dấu và emoji) với bản dùng Math.imul(h, 31) | 0: kết quả trùng hoàn toàn.
  • Các phần được nối liền nhau, không có ký tự phân cách. hash('ab', 'c') và hash('a', 'bc') ra cùng aac4dc. Với lỗi thật, name + stack ít khi vô tình trùng kiểu này, nhưng thêm một ký tự phân cách (bản viết lại dùng \u0000) là loại hẳn rủi ro.
src/lib/smallInfra.ts
export function simpleHash(parts: Array<string | null | undefined>, { separate = true } = {}): string {
  let h = 0;
  parts.forEach((part, index) => {
    if (part == null) return;
    if (separate && index > 0) h = (Math.imul(h, 31) + 0) | 0;
    for (let i = 0; i < part.length; i++) h = (Math.imul(h, 31) + part.charCodeAt(i)) | 0;
  });
  let out = '';
  for (let i = 0; i < 6; i++) {
    out = BASE32.charAt(h & 31) + out;
    h >>= 5;
  }
  return out;
}
ts

#CometMouseActivity: góc mới

Bài #3 đã nói Facebook preload thêm một lần khi chuột dừng trên nút (CometMouseActivity: một listener mousemove toàn cục, 50ms không di chuyển là "dừng"). Toàn bộ module:

const DELAY = 50, callbacks = [];
let timer = null, installed = false;
function fire() { callbacks.length > 0 && callbacks.forEach((fn) => fn()); timer = null; }
function onMove() { clearTimeout(timer); timer = setTimeout(fire, DELAY); }
function init() {
  installed || (window.addEventListener('mousemove', onMove), (installed = true));
  return () => window.removeEventListener('mousemove', onMove);   // không đặt lại installed = false
}
function addOnMouseStopCallback(fn) {
  installed || init();
  callbacks.push(fn);
  return () => removeFromArray(callbacks, fn);
}
js

Hai điểm yếu:

  • Hàm huỷ mà init() trả về gỡ listener nhưng không đặt lại cờ installed. Ai đó gọi hàm huỷ này một lần thì mọi addOnMouseStopCallback về sau chỉ đẩy callback vào mảng, không còn listener nào bắn nữa.
  • Listener và timer chạy mãi, kể cả khi không còn callback nào: mỗi lần chuột nhích lại clearTimeout + setTimeout.

Bản viết lại (onMouseStop trong file trên) đếm số người đăng ký: người đầu tiên gắn listener, người cuối cùng rời đi thì gỡ listener và huỷ timer.

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

Sao không dùng thư viện cookie (js-cookie) là đủ? js-cookie giải quyết việc mã hoá/giải mã chuỗi cookie. Nó không trả lời "cookie này được phép tồn tại không, sống bao lâu". Bảng chính sách là một lớp quản trị đặt trên đó.

Sao không để server set mọi cookie? Cookie quan trọng (phiên đăng nhập) nên do server set với HttpOnly, JavaScript không đọc được. Những cookie còn lại do client set (ngôn ngữ, trạng thái giao diện, id thiết bị cho chống gian lận) vẫn cần cùng kỷ luật.

Sao không dùng crypto hash (SHA-1) cho dấu vân tay lỗi? crypto.subtle.digest là bất đồng bộ; code xử lý lỗi cần chạy đồng bộ, đôi khi ngay lúc trang sắp đóng. Hash 30 bit đồng bộ, vài dòng code, là đủ cho việc gom nhóm.

#Edge case và cạm bẫy

  • Cookie trong iframe khác site. Từ khi trình duyệt chặn cookie bên thứ ba, cookie set trong iframe khác site có thể bị bỏ hoặc bị phân vùng (CHIPS, thuộc tính Partitioned). setIfFirstPartyContext tránh luôn vấn đề này.
  • Giới hạn dung lượng. Mỗi cookie khoảng 4KB, và tổng header Cookie gửi đi có thể vượt giới hạn của server/CDN (xem cách InVision dọn cookie).
  • Giá trị có dấu ;, = hoặc chữ có dấu. Phải encodeURIComponent, nếu không chuỗi cookie bị cắt sai.
  • Mouse stop trên cảm ứng. Không có mousemove, callback không bao giờ chạy. Đó là lý do Facebook preload ở pointerdown cho cảm ứng.

#Dùng trong dự án

// cookies.ts: nơi duy nhất trong codebase được động vào document.cookie
export const cookies = createCookieStore({
  config: {
    locale: { ttlSeconds: 365 * 24 * 3600 },
    theme: { ttlSeconds: 365 * 24 * 3600 },
    consent: { ttlSeconds: 180 * 24 * 3600, sameSite: 'Strict' },
  },
  baseDomains: ['example.com'],
});

cookies.set('locale', 'vi');   // ok
cookies.set('trackingId', 'x'); // null: chưa khai báo, bị từ chối
ts

Thêm một luật ESLint no-restricted-properties cấm document.cookie ở mọi file trừ cookies.ts là đủ để bảng chính sách luôn được tôn trọng.

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { describe, expect, it } from 'vitest';
import { createCookieStore, resolvePolicy, simpleHash } from './smallInfra';

const config = { datr: { ttlSeconds: 100, sameSite: 'None' as const }, locale: {} };

describe('cookie policy', () => {
  it('tiền tố _js_ dùng chính sách của tên gốc, tên lạ bị từ chối', () => {
    expect(resolvePolicy(config, '_js_datr')?.policyKey).toBe('datr');
    expect(resolvePolicy(config, 'x_js_datr')).toBeNull();
    expect(resolvePolicy(config, 'tracking')).toBeNull();
  });

  it('ghi chuỗi cookie đúng thuộc tính', () => {
    const jar: string[] = [];
    const store = createCookieStore({ config, baseDomains: ['facebook.com'], hostname: 'www.facebook.com', write: (c) => jar.push(c), read: () => '' });
    expect(store.set('datr', 'abc')).toBe('datr=abc; path=/; max-age=100; domain=.facebook.com; secure; samesite=none');
    expect(store.set('tracking', '1')).toBeNull();
    expect(store.clear('locale')).toBe('locale=; path=/; max-age=0; domain=.facebook.com');
    expect(jar).toHaveLength(2);
  });
});

describe('simpleHash', () => {
  it('6 ký tự; nối liền như bản gốc thì va chạm, có phân cách thì không', () => {
    expect(simpleHash(['TypeError', 'x'])).toMatch(/^[a-z0-5]{6}$/);
    expect(simpleHash(['ab', 'c'], { separate: false })).toBe(simpleHash(['a', 'bc'], { separate: false }));
    expect(simpleHash(['ab', 'c'])).not.toBe(simpleHash(['a', 'bc']));
  });
});
ts

#So với cách khác

  • Cookie Store API (cookieStore.set({ name, value, expires, sameSite })) là API bất đồng bộ, có sự kiện change, rõ ràng hơn document.cookie; tính tới 10/2026 đã có ở Chromium và Safari, Firefox thì xem caniuse. Nó vẫn không có khái niệm "bảng chính sách", lớp đó vẫn phải tự viết.
  • Consent manager (OneTrust, Cookiebot) phân loại cookie theo mục đích để xin đồng ý; bảng chính sách như CookieCore là chỗ tự nhiên để gắn thêm trường "mục đích".

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