返回 guessword

build-variant.mjs

build-variant.mjs · 15 KB · 314 行
build-variant.mjs 15 KB · 314 行 GitHub
// Build the split/parallel-load MULTILINGUAL deploy variant of GuessWord from the split sources// (index.html + css/app.css + js/data-*.js + js/app.js, composed by build/compose.mjs).// Sources stay untouched; output goes to site/ (index.html + data/* + functions/* + _headers).// Usage: node build-variant.mjs [secondary-data-origin]//   Secondary origin (e.g. https://<hash>.guessword-173.pages.dev) gives the browser a second//   TCP flow for the fallback chunk downloads (~2x cold-boot throughput on lossy links).// Online mode ranks words server-side (functions/api/*, copied from functions/), so the// browser only fetches the lightweight word lists. The vector/dict chunks stay on /data as// the offline fallback path (window.__loadLang) when the API is unavailable.// Per language L: data/gw-<L>-meta.json + data/gw-<L>-vec-<i>.bin + data/gwdict-<L>-<i>.bin//               + data/gwdb-<L>-<b>.bin (dictionary buckets, 256 per language, for /api/def)// en/ja/ko packs live in js/data-*.js; fr/de/es come from D:/gw-dl/lang/pack-<L>.json + dict-<L>.json.import fs from 'node:fs';import path from 'node:path';import zlib from 'node:zlib';import { compose } from './build/compose.mjs';const ROOT = 'D:/workspace/guessword';const OUT = 'D:/workspace/guessword/site';const WRANGLER_TOML = 'D:/workspace/guessword/wrangler.toml';const LANGDIR = 'D:/gw-dl/lang';const VER = 'v20261014b';   // bump on data change to bust immutable cachesconst SECHOST = (process.argv[2] || '').replace(/\/$/, '');const CHUNK_BYTES = 900000; // ~0.9MB per chunkconst CHUNK_LANGS = ['en', 'ja', 'ko', 'fr', 'de', 'es'];try {  fs.rmSync(OUT, { recursive: true, force: true });} catch (e) {  // site/ can still be some other process's working directory (e.g. an interrupted deploy):  // Windows refuses to delete the directory itself, but its contents can go.  fs.mkdirSync(OUT, { recursive: true });  for (const f of fs.readdirSync(OUT, { withFileTypes: true })) {    try { fs.rmSync(path.join(OUT, f.name), { recursive: true, force: true }); } catch (e2) { /* keep going */ }  }}fs.mkdirSync(path.join(OUT, 'data'), { recursive: true });const src = compose(ROOT);const lines = src.split('\n');const find = (pred, from = 0) => {  for (let i = from; i < lines.length; i++) if (pred(lines[i])) return i;  throw new Error('marker not found: ' + pred);};// ---- collect packs and dicts ----const PACKS = {}, DICTS = {};const readConstJson = (name, lenTrim = 1) => {  const i = find(l => l.startsWith('const ' + name + '='));  return { i, val: JSON.parse(lines[i].slice(('const ' + name + '=').length, lines[i].length - lenTrim)) };};PACKS.en = readConstJson('GWD');PACKS.ja = readConstJson('GW_JA');PACKS.ko = readConstJson('GW_KO');for (const [name, L] of [['GWDICT_B64', 'en'], ['GWDICT_JA_B64', 'ja'], ['GWDICT_KO_B64', 'ko']]) {  const i = find(l => l.startsWith('const ' + name + '='));  const m = new RegExp('^const ' + name + '="([A-Za-z0-9+/=]+)";?\\s*$').exec(lines[i]);  if (!m) throw new Error(name + ' line format unexpected');  DICTS[L] = Buffer.from(m[1], 'base64');  if (DICTS[L][0] !== 0x1f || DICTS[L][1] !== 0x8b) throw new Error(L + ' dict payload is not gzip');}for (const L of ['fr', 'de', 'es']) {  PACKS[L] = { i: -1, val: JSON.parse(fs.readFileSync(`${LANGDIR}/pack-${L}.json`, 'utf8')) };  DICTS[L] = zlib.gzipSync(fs.readFileSync(`${LANGDIR}/dict-${L}.json`), { level: 9 });}// ---- surgery 1: en GWD keeps metadata only; ja/ko packs become null stubs (chunk-loaded) ----const gi = PACKS.en.i, GWD = PACKS.en.val;const vecEn = Buffer.from(GWD.vec, 'base64');if (vecEn.length !== GWD.N * GWD.D) throw new Error(`vec size ${vecEn.length} != ${GWD.N * GWD.D}`);lines[gi] = 'const GWD=' + JSON.stringify({ N: GWD.N, D: GWD.D, words: GWD.words, level: GWD.level, min: GWD.min, max: GWD.max }) + ';';lines[PACKS.ja.i] = 'let GW_JA=null;';lines[PACKS.ko.i] = 'let GW_KO=null;';// ---- surgery 2: drop embedded dict payloads (all dicts fetched lazily as chunks) ----for (const name of ['const GWDICT_B64=', 'const GWDICT_JA_B64=', 'const GWDICT_KO_B64=']) {  const i = find(l => l.startsWith(name));  lines.splice(i, 1);}// ---- surgery 3: multi-language chunked loadDict ----const li = find(l => l.startsWith('function loadDict(lang){'));const le = find(l => l === '}', li);const NEW_LOAD = [  'function loadDict(lang){',  '  lang = lang || LANG;',  '  if(DICT_CACHE[lang]) return Promise.resolve(DICT_CACHE[lang]);',  '  if(DICT_CACHE["!"+lang]) return Promise.resolve(null);',  '  const n = (window.__gwDictChunks||{})[lang] || 0;',  '  if(!n) return Promise.resolve(null);',  '  const jobs=[]; for(let i=0;i<n;i++) jobs.push(window.__gwFetchFile("/data/gwdict-"+lang+"-"+i+".bin?"+window.__gwVER));',  '  return Promise.all(jobs)',  '    .then(parts => {',  '      let total=0; for(const p of parts) total+=p.byteLength;',  '      const all=new Uint8Array(total); let off=0;',  '      for(const p of parts){ all.set(new Uint8Array(p), off); off+=p.byteLength; }',  '      return new Response(new Blob([all]).stream().pipeThrough(new DecompressionStream("gzip"))).text();',  '    })',  '    .then(t => { DICT_CACHE[lang] = JSON.parse(t); return DICT_CACHE[lang]; })',  '    .catch(() => { DICT_CACHE["!"+lang] = true; return null; });',  '}',].join('\n');lines.splice(li, le - li + 1, NEW_LOAD);// ---- surgery 4: remove boot-time activation (the loader orchestrates after chunks arrive) ----{  const i = find(l => l.trim() === 'activateLang(store.settings.lang);');  lines.splice(i, 1);}// ---- surgery 5: defer boot until the initial pack is ready ----const boi = find(l => l.includes('---------- boot ----------'));lines.splice(boi + 1, 0, 'function __boot(){');{  const ldi = find(l => l === 'if(!REMOTE) loadDict();', boi);  lines.splice(ldi, 1);}const sc = find(l => l === '</script>');lines.splice(sc, 0, '}');// ---- write chunks + meta ----const dictChunks = {};for (const L of CHUNK_LANGS) {  const P = PACKS[L].val;  const vec = Buffer.from(P.vec, 'base64');  if (vec.length !== P.N * P.D) throw new Error(`${L}: vec size mismatch`);  const nv = Math.max(1, Math.ceil(vec.length / CHUNK_BYTES));  for (let i = 0; i < nv; i++) {    fs.writeFileSync(path.join(OUT, 'data', `gw-${L}-vec-${i}.bin`), vec.subarray(i * CHUNK_BYTES, Math.min((i + 1) * CHUNK_BYTES, vec.length)));  }  const gz = DICTS[L];  const nd = Math.max(1, Math.ceil(gz.length / CHUNK_BYTES));  dictChunks[L] = nd;  for (let i = 0; i < nd; i++) {    fs.writeFileSync(path.join(OUT, 'data', `gwdict-${L}-${i}.bin`), gz.subarray(i * CHUNK_BYTES, Math.min((i + 1) * CHUNK_BYTES, gz.length)));  }  // meta for every language: the browser uses it as the lightweight word list,  // and the server-side engine (/api) loads words + vectors from the same file.  fs.writeFileSync(path.join(OUT, 'data', `gw-${L}-meta.json`),    JSON.stringify({ N: P.N, D: P.D, words: P.words, level: P.level, min: P.min, max: P.max, chunks: nv }));  console.log(`${L}: vec ${(vec.length / 1048576).toFixed(2)}MB/${nv} chunks | dict ${(gz.length / 1048576).toFixed(2)}MB/${nd} chunks`);}// ---- dictionary buckets for /api/def (per-word lookups without downloading whole dicts) ----const BUCKETS = 256;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;}let bucketCount = 0;for (const L of CHUNK_LANGS) {  const dict = JSON.parse(zlib.gunzipSync(DICTS[L]));  const buckets = Array.from({ length: BUCKETS }, () => ({}));  for (const [w, e] of Object.entries(dict)) buckets[fnv1a(w) % BUCKETS][w] = e;  for (let b = 0; b < BUCKETS; b++) {    fs.writeFileSync(path.join(OUT, 'data', `gwdb-${L}-${b}.bin`), zlib.gzipSync(JSON.stringify(buckets[b]), { level: 9 }));  }  bucketCount += BUCKETS;  console.log(`${L}: dict buckets ${BUCKETS} (${(Object.keys(dict).length / 1000).toFixed(1)}k entries)`);}// ---- loader overlay + script ----const OVERLAY = [  '<div id="bootOv" style="position:fixed;inset:0;z-index:9999;background:#0b0e14;display:flex;align-items:center;justify-content:center;font-family:system-ui,-apple-system,\'Segoe UI\',sans-serif">',  '  <div style="width:min(320px,80vw);text-align:center;color:#c9d4e3">',  '    <div style="font-size:22px;letter-spacing:.14em;margin-bottom:18px">GUESSWORD</div>',  '    <div style="height:3px;background:#232a36;border-radius:2px;overflow:hidden"><i id="bootBar" style="display:block;height:100%;width:2%;background:#5b8cff;transition:width .25s"></i></div>',  '    <div id="bootTxt" style="margin-top:12px;font-size:13px;opacity:.75">正在加载词库…</div>',  '    <button id="bootRetry" type="button" style="display:none;margin-top:14px;padding:8px 22px;border:1px solid #3a4353;background:transparent;color:#c9d4e3;border-radius:8px;font-size:13px;cursor:pointer">重试</button>',  '  </div>',  '</div>',  '<script>',  '"use strict";',  '(function(){',  `  window.__gwVER=${JSON.stringify(VER)}; window.__gwDictChunks=${JSON.stringify(dictChunks)};`,  `  var CN="gwd-data-v2", SECHOST=${JSON.stringify(SECHOST)}, LANGS=["en","ja","ko","fr","de","es"];`,  '  var BASES=[location.origin];',  '  if(SECHOST && SECHOST!==location.origin) BASES.push(SECHOST);',  '  var bar=document.getElementById("bootBar"), txt=document.getElementById("bootTxt"), retry=document.getElementById("bootRetry"), ov=document.getElementById("bootOv");',  '  function prog(f,m){ bar.style.width=Math.max(2,Math.round(f*100))+"%"; if(m) txt.textContent=m; }',  '  function copen(){ return caches.open(CN); }',  '  function attempt(base,path){',  '    var ctl=new AbortController(); var timer=setTimeout(function(){ ctl.abort(); },40000);',  '    return fetch(base+path,{signal:ctl.signal}).then(function(r){',  '      clearTimeout(timer);',  '      if(!r.ok) throw new Error("HTTP "+r.status);',  '      return r.arrayBuffer();',  '    },function(e){ clearTimeout(timer); throw e; });',  '  }',  '  window.__gwFetchFile = function(path){',  '    return copen().then(function(c){ return c.match(path); }).then(function(m){',  '      if(m) return m.arrayBuffer();',  '      var tries=0;',  '      function next(){',  '        var base=BASES[tries%BASES.length]; tries++;',  '        return attempt(base,path).then(function(b){',  '          try{ copen().then(function(c){ c.put(path, new Response(b.slice(0))); }); }catch(e){}',  '          return b;',  '        },function(e){',  '          if(tries>=BASES.length+2) throw e;',  '          return new Promise(function(res){ setTimeout(res,700); }).then(next);',  '        });',  '      }',  '      return next();',  '    });',  '  };',  '  function fetchJson(path){ return window.__gwFetchFile(path).then(function(b){ return JSON.parse(new TextDecoder().decode(b)); }); }',  '  function setPack(L, obj){',  '    if(L==="en"){ GWD.vec=obj.vec; }',  '    else if(L==="ja"){ GW_JA=obj; }',  '    else if(L==="ko"){ GW_KO=obj; }',  '    else if(L==="fr"){ GW_FR=obj; }',  '    else if(L==="de"){ GW_DE=obj; }',  '    else if(L==="es"){ GW_ES=obj; }',  '  }',  '  function packRef(L){',  '    if(L==="en") return GWD;',  '    if(L==="ja") return GW_JA; if(L==="ko") return GW_KO;',  '    if(L==="fr") return GW_FR; if(L==="de") return GW_DE; if(L==="es") return GW_ES;',  '    return null;',  '  }',  '  window.__gwRemote = true;',  '  window.__gwPackRef = packRef;',  '  window.__loadMeta = function(L){',  '    if(LANGS.indexOf(L)<0) return Promise.reject(new Error("unknown lang"));',  '    if(L==="en") return Promise.resolve(GWD);',  '    var cur = packRef(L);',  '    if(cur && cur.vec === undefined) return Promise.resolve(cur);',  '    return fetchJson("/data/gw-"+L+"-meta.json?"+window.__gwVER).then(function(m){ setPack(L, m); return m; });',  '  };',  '  function loadPack(L, onProg){',  '    var metaP = (L==="en") ? Promise.resolve(null) : fetchJson("/data/gw-"+L+"-meta.json?"+window.__gwVER);',  '    return metaP.then(function(meta){',  '      var CH = meta ? meta.chunks : ' + Math.max(1, Math.ceil(vecEn.length / CHUNK_BYTES)) + ';',  '      var done=0, parts=new Array(CH), jobs=[];',  '      for(var i=0;i<CH;i++) jobs.push(i);',  '      return Promise.all(jobs.map(function(i){',  '        return window.__gwFetchFile("/data/gw-"+L+"-vec-"+i+".bin?"+window.__gwVER).then(function(b){',  '          parts[i]=new Uint8Array(b); done++;',  '          if(onProg) onProg(done/CH);',  '        });',  '      })).then(function(){',  '        var total=0; for(var i=0;i<CH;i++) total+=parts[i].length;',  '        var all=new Uint8Array(total), off=0;',  '        for(var i=0;i<CH;i++){ all.set(parts[i],off); off+=parts[i].length; }',  '        var obj = meta ? Object.assign({}, meta, {vec: all}) : {vec: all};',  '        setPack(L, obj);',  '        return L;',  '      });',  '    });',  '  }',  '  window.__loadLang = function(L, onProg){',  '    if(LANGS.indexOf(L)<0) return Promise.reject(new Error("unknown lang"));',  '    if((L!=="en" && (window["__gwp_"+L])) ) return Promise.resolve(L);',  '    return loadPack(L, onProg).then(function(){ window["__gwp_"+L]=1; return L; });',  '  };',  '  function initialLang(){',  '    try{ var st=JSON.parse(localStorage.getItem("guessword-v2")||"{}");',  '      if(st && st.settings && LANGS.indexOf(st.settings.lang)>=0) return st.settings.lang;',  '    }catch(e){}',  '    return "en";',  '  }',  '  function fail(){',  '    var n=0;',  '    try{ n=parseInt(sessionStorage.getItem("gwdReloads")||"0",10)||0; }catch(e){}',  '    if(n<2){',  '      try{ sessionStorage.setItem("gwdReloads", String(n+1)); }catch(e){}',  '      txt.textContent="网络波动,自动重试…";',  '      setTimeout(function(){ location.reload(); }, 1200);',  '    }else{',  '      retry.style.display="inline-block";',  '      txt.textContent="加载失败,请检查网络后重试";',  '    }',  '  }',  '  function run(L){',  '    retry.style.display="none"; prog(0.02,"正在启动…");',  '    window.__loadMeta(L).then(function(){',  '      prog(0.9,"启动中…");',  '      activateLang(L);',  '      __boot();',  '      prog(1,"完成");',  '      try{ sessionStorage.removeItem("gwdReloads"); }catch(e){}',  '      setTimeout(function(){ ov.style.display="none"; }, 150);',  '    }).catch(function(e){ console.error(e); fail(); });',  '  }',  '  retry.addEventListener("click", function(){ try{ sessionStorage.removeItem("gwdReloads"); }catch(e){} run(initialLang()); });',  '  run(initialLang());',  '})();',  '<\/script>',].join('\n');const bbody = find(l => l === '</body>');lines.splice(bbody, 0, OVERLAY);fs.writeFileSync(path.join(OUT, 'index.html'), lines.join('\n'));fs.writeFileSync(path.join(OUT, '_headers'),  '/data/*\n  Cache-Control: public, max-age=31536000, immutable\n  Access-Control-Allow-Origin: *\n');fs.copyFileSync(WRANGLER_TOML, path.join(OUT, 'wrangler.toml'));// ---- Pages Functions: the online ranking/dictionary engine (/api/*) ----{  const SRC_FN = path.join(ROOT, 'functions');  const dst = path.join(OUT, 'functions');  fs.mkdirSync(path.join(dst, 'api'), { recursive: true });  for (const f of fs.readdirSync(path.join(SRC_FN, 'api'))) {    const text = fs.readFileSync(path.join(SRC_FN, 'api', f), 'utf8').replaceAll('__GW_VER__', VER);    fs.writeFileSync(path.join(dst, 'api', f), text);  }}const st = p => (fs.statSync(p).size / 1048576).toFixed(2) + 'MB';console.log('index.html:', st(path.join(OUT, 'index.html')), '| SECHOST:', SECHOST || '(same-origin)');console.log('data files:', fs.readdirSync(path.join(OUT, 'data')).length, `(${bucketCount} dict buckets)`);