返回 functions/api

_engine.js

functions/api/_engine.js · 15 KB · 363 行
functions/api/_engine.js 15 KB · 363 行 GitHub
/* =====================================================================   GuessWord online engine — Cloudflare Pages Function shared module.   The semantic ranking runs here so browsers never download the vector   banks (per language: ~6-10MB of quantized Numberbatch vectors). The   client only fetches a lightweight word list (words + levels) for   autocomplete/validation, and one small request per guess.   The ranking math is a 1:1 port of buildRanks() in js/app.js, including   the deterministic per-word tie-break jitter, so ranks and distances are   identical to the offline build (verified by test/test-rank-parity.mjs).   Routes (wired by sibling files new.js / guess.js / hint.js / def.js):     POST /api/new    {lang, lo, hi, recent[]}  -> {t: token}     POST /api/guess  {t, words[]}              -> {items: [{r, d} | null]}     POST /api/hint   {t, r}                    -> {word, d}     GET  /api/def    ?lang=&word=              -> {entry | null}   The token carries the secret encrypted (AES-GCM), so the answer never   reaches the browser before the game ends, and no server-side session   storage is needed.   ===================================================================== */"use strict";export const VER = "__GW_VER__";   // injected by build-variant.mjs; busts asset cachesconst LANGS = ["en", "ja", "ko", "fr", "de", "es"];const LV_COUNT = { en: 6, ja: 5, ko: 6, fr: 6, de: 6, es: 6 };const CHUNK_LIMIT = 64 * 1024 * 1024;      // sanity cap for vec assets/* ---------- byte / json helpers ---------- */const te = new TextEncoder(), td = new TextDecoder();function b64u(bytes) {  let s = "";  for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]);  return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");}function unb64u(s) {  s = s.replace(/-/g, "+").replace(/_/g, "/");  while (s.length % 4) s += "=";  const raw = atob(s);  const out = new Uint8Array(raw.length);  for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);  return out;}function json(body, status = 200, t0) {  const headers = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };  if (t0) headers["server-timing"] = `total;dur=${Date.now() - t0}`;  return new Response(JSON.stringify(body), { status, headers });}async function gunzip(bytes) {  const s = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("gzip"));  return new Uint8Array(await new Response(s).arrayBuffer());}/* ---------- deterministic PRNG (port of js/app.js rng) ---------- */function rng(seedStr) {  let h = 1779033703 ^ seedStr.length;  for (let i = 0; i < seedStr.length; i++) { h = Math.imul(h ^ seedStr.charCodeAt(i), 3432918353); h = h << 13 | h >>> 19; }  let t = (h = Math.imul(h ^ h >>> 16, 2246822507), h = Math.imul(h ^ h >>> 13, 3266489909), (h ^= h >>> 16) >>> 0);  return function () {    t |= 0; t = t + 0x6D2B79F5 | 0;    let r = Math.imul(t ^ t >>> 15, 1 | t);    r = r + Math.imul(r ^ r >>> 7, 61 | r) ^ r;    return ((r ^ r >>> 14) >>> 0) / 4294967296;  };}/* ---------- language state (int8 vectors -> normalized float32) ---------- */function decodeUnit(N, D, bytes, min, max) {  const unit = new Float32Array(N * D);  for (let i = 0; i < N; i++) for (let d = 0; d < D; d++) unit[i * D + d] = min[d] + (bytes[i * D + d] / 255) * (max[d] - min[d]);  for (let i = 0; i < N; i++) {    let n2 = 0;    for (let d = 0; d < D; d++) { const v = unit[i * D + d]; n2 += v * v; }    const inv = 1 / Math.sqrt(n2) || 0;    for (let d = 0; d < D; d++) unit[i * D + d] *= inv;  }  return unit;}function distTable(N) {  const K = 3000 / N, out = new Int32Array(N);  let prev = 0;  for (let r = 0; r < N; r++) { const d = Math.max(prev + 1, 1 + Math.round(r * K)); out[r] = d; prev = d; }  return out;}const STATE = { langs: new Map(), metas: new Map(), buckets: new Map() };  // per-isolate cachesexport function createEngine(fetchAsset) {  const langs = STATE.langs;         // lang -> state (LRU, byte-budgeted)  const metas = STATE.metas;         // lang -> words + levels only (cheap, for /api/new)  const dictBuckets = STATE.buckets; // "lang:bucket" -> parsed bucket object (LRU)  function evict() {    let total = 0;    for (const st of langs.values()) total += st.UNIT.byteLength;    while (langs.size && (langs.size > 2 || total > 80 * 1024 * 1024)) {      const k = langs.keys().next().value;      total -= langs.get(k).UNIT.byteLength;      langs.delete(k);    }    while (metas.size > 3) metas.delete(metas.keys().next().value);    while (dictBuckets.size > 24) dictBuckets.delete(dictBuckets.keys().next().value);  }  // word list + levels only (~200KB): enough to pick a secret for /api/new  async function loadMeta(L) {    if (metas.has(L)) { const v = metas.get(L); metas.delete(L); metas.set(L, v); return v; }    const meta = JSON.parse(td.decode(await fetchAsset(`/data/gw-${L}-meta.json?v=${VER}`)));    const words = meta.words.split("|");    const lv = new Uint8Array(meta.level.length);    for (let i = 0; i < lv.length; i++) lv[i] = +meta.level[i];    const st = { L, N: meta.N, D: meta.D, WORDS: words, LVIDX: lv, chunks: meta.chunks, min: meta.min, max: meta.max };    metas.set(L, st);    evict();    return st;  }  async function loadLang(L) {    if (langs.has(L)) { const v = langs.get(L); langs.delete(L); langs.set(L, v); return v; }    // a warm request and the first guess can land together: share one load    if (!STATE.pending) STATE.pending = new Map();    if (STATE.pending.has(L)) return STATE.pending.get(L);    const job = (async () => {      const M = await loadMeta(L);      const parts = await Promise.all(Array.from({ length: M.chunks }, (_, i) => fetchAsset(`/data/gw-${L}-vec-${i}.bin?v=${VER}`)));      let total = 0;      for (const p of parts) total += p.byteLength;      if (total !== M.N * M.D || total > CHUNK_LIMIT) throw new Error(`bad vec size for ${L}`);      const vec = new Uint8Array(total);      { let off = 0; for (const p of parts) { vec.set(p, off); off += p.byteLength; } }      const st = {        L, N: M.N, D: M.D, WORDS: M.WORDS, LVIDX: M.LVIDX,        UNIT: decodeUnit(M.N, M.D, vec, M.min, M.max),        DIST: distTable(M.N),        IDX: new Map(M.WORDS.map((w, i) => [w, i])),      };      langs.set(L, st);      evict();      return st;    })();    STATE.pending.set(L, job);    try { return await job; } finally { STATE.pending.delete(L); }  }  // score pass: same math & tie-break jitter as the client (identical results,  // including Float64 storage — Float32 would round away the 1e-6 tie jitter)  function scorePass(P, si, secret) {    const { N, D, UNIT: U, WORDS: W, L } = P;    const scores = new Float64Array(N);    const pfx = L + "|" + secret + "|";    for (let i = 0; i < N; i++) {      if (i === si) { scores[i] = 2; continue; }      let dot = 0, off = i * D, soff = si * D;      for (let d = 0; d < D; d++) dot += U[off + d] * U[soff + d];      scores[i] = dot + rng(pfx + W[i])() * 1e-6;    }    return scores;  }  function cmpIdx(scores, W) { return (a, b) => scores[b] - scores[a] || (W[a] < W[b] ? -1 : (W[a] > W[b] ? 1 : 0)); }  // 1-based rank of word index wi in the (score desc, word asc) order  function rankAt(P, scores, wi) {    const sg = scores[wi], W = P.WORDS;    let r = 1;    for (let i = 0; i < P.N; i++) {      const s = scores[i];      if (s > sg) r++;      else if (s === sg && W[i] < W[wi]) r++;    }    return r;  }  // word index sitting at 1-based rank k  function indexAtRank(P, scores, k) {    const N = P.N, W = P.WORDS;    const idx = new Int32Array(N);    for (let i = 0; i < N; i++) idx[i] = i;    const cmp = cmpIdx(scores, W);    let lo = 0, hi = N - 1, target = k - 1;    while (lo < hi) {      const pivot = idx[(lo + hi) >> 1];      let i = lo, j = hi;      while (i <= j) {        while (cmp(idx[i], pivot) < 0) i++;        while (cmp(idx[j], pivot) > 0) j--;        if (i <= j) { const t = idx[i]; idx[i] = idx[j]; idx[j] = t; i++; j--; }      }      if (target <= j) hi = j; else if (target >= i) lo = i; else break;    }    return idx[target];  }  async function dictEntry(lang, word) {    const b = fnv1a(word) % 256;    const key = lang + ":" + b;    let bucket = dictBuckets.get(key);    if (bucket) { dictBuckets.delete(key); dictBuckets.set(key, bucket); }    else {      const gz = await fetchAsset(`/data/gwdb-${lang}-${b}.bin?v=${VER}`);      bucket = JSON.parse(td.decode(await gunzip(gz)));      dictBuckets.set(key, bucket);      evict();    }    return bucket[word] || null;  }  return { loadMeta, loadLang, scorePass, rankAt, indexAtRank, dictEntry, langs, metas };}function fnv1a(s) {  let h = 0x811c9dc5;  for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193); }  return (h >>> 0);}/* ---------- token: AES-GCM encrypted {l, lo, hi, s, i} ---------- */let keyP = null;function keyOf(secretKey) {  if (!keyP) keyP = crypto.subtle.digest("SHA-256", te.encode(secretKey))    .then((raw) => crypto.subtle.importKey("raw", raw, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]))    .catch(() => null);  return keyP;}async function makeToken(secretKey, payload) {  const key = await keyOf(secretKey);  if (!key) return null;  const iv = crypto.getRandomValues(new Uint8Array(12));  const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, te.encode(JSON.stringify(payload))));  const out = new Uint8Array(iv.length + ct.length);  out.set(iv, 0); out.set(ct, iv.length);  return b64u(out);}async function readToken(secretKey, token) {  try {    const key = await keyOf(secretKey);    if (!key || typeof token !== "string" || token.length > 4096) return null;    const raw = unb64u(token);    const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: raw.subarray(0, 12) }, key, raw.subarray(12));    const p = JSON.parse(td.decode(pt));    if (!LANGS.includes(p.l) || typeof p.s !== "string") return null;    return p;  } catch (e) { return null; }}/* ---------- request bodies ---------- */async function body(req) {  try {    const t = await req.text();    if (t.length > 200000) return null;    return JSON.parse(t);  } catch (e) { return null; }}const DEFAULT_KEY = "guessword-2026-online-engine-v1";/* ---------- route handlers ---------- */export async function handle(context, kind) {  const { request, env } = context;  const key = (env && env.GW_KEY) || DEFAULT_KEY;  const eng = createEngine((p) => fetchAssetVia(env, request, p));  const t0 = Date.now();  const J = (b, s = 200) => json(b, s, t0);   // adds a server-timing header  try {    if (kind === "new") {      if (request.method !== "POST") return J({ err: "method" }, 405);      const b = await body(request);      if (!b || !LANGS.includes(b.lang)) return J({ err: "bad request" }, 400);      const lo = Math.max(0, Math.min(LV_COUNT[b.lang] - 1, b.lo | 0));      const hi = Math.max(lo, Math.min(LV_COUNT[b.lang] - 1, b.hi | 0));      const M = await eng.loadMeta(b.lang);   // word list only: picking a secret needs no vectors      const pool = [];      for (let i = 0; i < M.N; i++) if (M.LVIDX[i] >= lo && M.LVIDX[i] <= hi) pool.push(i);      if (!pool.length) return J({ err: "empty pool" }, 400);      const recent = new Set(Array.isArray(b.recent) ? b.recent.slice(-160).filter((w) => typeof w === "string") : []);      const fresh = pool.filter((i) => !recent.has(M.WORDS[i]));      const use = fresh.length > 30 ? fresh : pool;      const si = use[Math.floor(Math.random() * use.length)];      const t = await makeToken(key, { l: b.lang, lo, hi, s: M.WORDS[si], i: Date.now() });      if (!t) return J({ err: "token" }, 500);      return J({ t, pool: pool.length });    }    if (kind === "warm") {      // preload a language into this isolate (vectors + index) so the first      // /api/guess of a game does not pay the decode cost on the critical path      if (request.method !== "POST") return J({ err: "method" }, 405);      const b = await body(request);      if (!b || !LANGS.includes(b.lang)) return J({ err: "bad request" }, 400);      const P = await eng.loadLang(b.lang);      return J({ ok: true, n: P.N, vectors: true });    }    if (kind === "guess") {      if (request.method !== "POST") return J({ err: "method" }, 405);      const b = await body(request);      const p = b && await readToken(key, b.t);      if (!p) return J({ err: "bad token" }, 400);      const words = Array.isArray(b.words) ? b.words.slice(0, 50).filter((w) => typeof w === "string") : [];      if (!words.length) return J({ err: "no words" }, 400);      const P = await eng.loadLang(p.l);      const si = P.IDX.get(p.s);      if (si === undefined) return J({ err: "secret gone" }, 500);      const scores = eng.scorePass(P, si, p.s);      const items = words.map((w) => {        const wi = P.IDX.get(w);        if (wi === undefined) return null;        const r = wi === si ? 1 : eng.rankAt(P, scores, wi);        return { r, d: P.DIST[r - 1] };      });      return J({ items });    }    if (kind === "hint") {      if (request.method !== "POST") return J({ err: "method" }, 405);      const b = await body(request);      const p = b && await readToken(key, b.t);      if (!p) return J({ err: "bad token" }, 400);      const P = await eng.loadLang(p.l);      const si = P.IDX.get(p.s);      if (si === undefined) return J({ err: "secret gone" }, 500);      let r = b.r | 0;      if (!(r >= 1 && r <= P.N)) return J({ err: "bad rank" }, 400);      const scores = eng.scorePass(P, si, p.s);      const wi = r === 1 ? si : eng.indexAtRank(P, scores, r);      const rr = wi === si ? 1 : eng.rankAt(P, scores, wi);      return J({ word: P.WORDS[wi], r: rr, d: P.DIST[rr - 1] });    }    if (kind === "def") {      const u = new URL(request.url);      const lang = u.searchParams.get("lang") || "";      const word = (u.searchParams.get("word") || "").toLowerCase();      if (!LANGS.includes(lang) || !word || word.length > 60) return J({ err: "bad request" }, 400);      const entry = await eng.dictEntry(lang, word);      return J({ entry });    }    return J({ err: "not found" }, 404);  } catch (e) {    return J({ err: "engine error", msg: String((e && e.message) || e) }, 500);  }}function fetchAssetVia(env, request, path) {  const url = new URL(path, request.url);  if (env && env.ASSETS && typeof env.ASSETS.fetch === "function") {    return env.ASSETS.fetch(new Request(url.toString())).then((r) => {      if (!r.ok) throw new Error(`asset ${path}: HTTP ${r.status}`);      return r.arrayBuffer().then((b) => new Uint8Array(b));    });  }  // local `wrangler pages dev` without ASSETS binding: same-origin fetch  return fetch(url.toString()).then((r) => {    if (!r.ok) throw new Error(`asset ${path}: HTTP ${r.status}`);    return r.arrayBuffer().then((b) => new Uint8Array(b));  });}