Files
rplace/src/lib/cookie.js
T
tiennm99 42d1ca19ee feat(canvas): cookie+IP rate-limit identity and broadcast sequence numbers
- resolveIdentity prefers an opaque rplace_id cookie; falls back to a
  cf-connecting-ip hash; in production a request with neither now returns
  500 no_identity instead of bucketing all such traffic together
- /api/canvas issues Set-Cookie when no cookie is present so subsequent
  requests escape NAT-shared IP buckets (mobile/CGNAT users)
- DO maintains an in-memory monotonic broadcast counter; broadcast frames
  carry { seq } so the client can detect missed pixels and refetch
- client tracks lastSeq, refetches on gap, resets on every (re)connect

NAT/CGNAT users previously shared a single 1Hz bucket per egress IP. With
cookie identity they each get their own bucket. Cookie is HttpOnly, Secure,
SameSite=Lax, 1y Max-Age. Stripped/cleared cookies fall through to IP.

The seq counter resets on DO hibernation rehydrate; client always refetches
on reconnect, so a reset is indistinguishable from a fresh connect.

Plan: plans/260510-0232-fix-do-migration-followups/phase-02-cookie-ip-identity.md
2026-05-10 03:00:39 +07:00

46 lines
1.4 KiB
JavaScript

/**
* Minimal cookie helpers — no external dependency.
* Used to issue and read the opaque rplace_id rate-limit identity cookie.
*/
/**
* Parse a Cookie header into a Map<name, value>. Tolerant of missing header,
* malformed pairs, and surrounding whitespace.
* @param {string|null|undefined} header
* @returns {Map<string, string>}
*/
export function parseCookie(header) {
const out = new Map();
if (!header) return out;
for (const part of header.split(';')) {
const eq = part.indexOf('=');
if (eq <= 0) continue;
const name = part.slice(0, eq).trim();
const value = part.slice(eq + 1).trim();
if (name) out.set(name, value);
}
return out;
}
/**
* Format a Set-Cookie header value.
* @param {string} name
* @param {string} value
* @param {object} [opts]
* @param {boolean} [opts.httpOnly]
* @param {boolean} [opts.secure]
* @param {'Strict'|'Lax'|'None'} [opts.sameSite]
* @param {string} [opts.path]
* @param {number} [opts.maxAge] — seconds
* @returns {string}
*/
export function formatSetCookie(name, value, opts = {}) {
const parts = [`${name}=${value}`];
if (opts.path) parts.push(`Path=${opts.path}`);
if (opts.maxAge != null) parts.push(`Max-Age=${opts.maxAge}`);
if (opts.httpOnly) parts.push('HttpOnly');
if (opts.secure) parts.push('Secure');
if (opts.sameSite) parts.push(`SameSite=${opts.sameSite}`);
return parts.join('; ');
}