4cac09c9f3
CI / changes (pull_request) Successful in 3s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 21s
CI / ui (pull_request) Successful in 1m16s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m55s
A real test payment on the contour exposed both problems at once. YooKassa
delivered the notification five times; all five were rejected because the
backend saw the sender as 10.77.0.1 — the contour sits behind a tunnel and
cannot observe real client addresses, the same reason the IP bans in this
repository are prod-only. The chips were not lost (the reconcile sweep would
have credited them), but the primary path was dead and the customer was left
watching an unchanged balance.
The address check is removed rather than made conditional. It never was the
security boundary — the confirming GET /v3/payments/{id} is — and the one thing
it bought is already bought earlier and far more tightly: the order is resolved
from the notification's metadata *before* any provider call, so a notification
naming no known order costs a single indexed read and stops there. Guessing a
live order id means guessing a uuid. Against that, an address check adds nothing
and breaks every deployment that cannot see real client addresses, while turning
any future change to YooKassa's published ranges into a silent degradation.
The second problem was mine. The reconcile threshold was keyed off the order
lifetime, so a lost notification cost the customer the full 30-minute TTL before
the chips landed. Those are different questions: the lifetime governs how long a
customer may take to pay, the re-check governs how soon we notice a lost
callback. Split apart — `payments.ReconcileAfter`, one minute, swept on every
reaper tick. The bound D49 was chosen for survives: the calls one order can
cause are still its lifetime divided by the sweep interval, a handful, not an
open-ended poll. Worst case for a failed notification drops from ~30 minutes to
~5; an order the customer is still paying for is left alone.
Tests: the foreign-sender test is replaced by the two properties that now carry
the load — a notification naming an unknown order makes no provider call at all,
and a genuine notification is honoured whatever address it appears to come from.
Plus one pinning that a seconds-old order is not polled.
The shared bundle budget goes 31 -> 32 KB, with the reason recorded in the
script header: every user-visible string lands in that chunk and it had been
sitting 40 bytes under the cap.
Decisions D48 and D49 revised.
118 lines
7.0 KiB
JavaScript
118 lines
7.0 KiB
JavaScript
// Bundle-size budget gate. Measures the gzipped JS each built HTML entry actually loads and
|
|
// fails CI if any part overruns its budget — a guard against an accidental heavy dependency.
|
|
// The build has two entries (vite rollupOptions.input): the game app (index.html, served at
|
|
// /app/ + /telegram/) and the landing (landing.html, served at /). Rollup hoists the code
|
|
// shared by both (the Svelte runtime + i18n + aboutContent) into one chunk each entry
|
|
// preloads, so a page's real payload is its own entry chunk plus that shared chunk.
|
|
//
|
|
// Three independent gates on the natural chunk boundaries, each with realistic headroom:
|
|
// - app entry (main): the app's own code; grows with features.
|
|
// - shared (svelte+i18n): the framework runtime plus the flat i18n map; drifts on a dep/Svelte
|
|
// bump and, slightly, as feature copy adds i18n keys (raised to 31 for
|
|
// the rewarded-ad strings).
|
|
// - landing own: the landing's own code; kept minimal.
|
|
// Today ~74 KB (app entry) + ~23 KB (shared) = ~97 KB for the app; the landing's own chunk is
|
|
// ~2 KB. Lazy-loading was analysed and rejected (no total-size win — every chunk still
|
|
// ships and is summed — plus added request latency); the bulk is the Connect/FlatBuffers
|
|
// transport runtime + generated bindings + the Svelte runtime, irreducible within scope.
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { gzipSync } from 'node:zlib';
|
|
import { join } from 'node:path';
|
|
|
|
const DIST = 'dist';
|
|
|
|
// Per-chunk gzip budgets in KB. The app entry was raised to 110 for the local move-preview
|
|
// wiring, to 112 for the PWA install feature, to 113 for the offline-mode wiring, to 114 for
|
|
// the offline auto-detect (the cold-start reachability check and the "no connection" dialog live in
|
|
// the boot path), then to 115 for the vs_ai idle-hint gate — its monotonic clock, lock badge and
|
|
// countdown toast live in the always-loaded game screen (Game.svelte) — and to 120 for the offline
|
|
// pass-and-play (hotseat) mode: the on-screen PIN pad, the creation roster and the in-game host menu
|
|
// live in the always-loaded New Game / Game / Lobby screens (the offline engine and the tiny PIN
|
|
// hashing stay in lazy chunks / are negligible), then to 123 for the Wallet section — its screen,
|
|
// storefront logic and the catalog codec load with the always-mounted settings hub (its i18n lands
|
|
// in the shared chunk) — and to 125 for the payment intake rails: the wallet order flow, the
|
|
// Telegram Stars openInvoice / VK order-box launch and the per-button in-flight state ride the same
|
|
// always-loaded Wallet screen, then to 126 for the in-game board-feedback UX — the staged-play
|
|
// highlight geometry (lib/formed.ts), the on-board score badge and the bag / turn-strip / board-zoom
|
|
// wiring live in the always-loaded Game / Board / Rack screens, then to 127 for the wallet storefront
|
|
// redesign — its buy/spend toggle, compact balance and exchange-confirm dialog ride the same
|
|
// always-loaded Wallet screen — then to 130 for the offline-model redesign: the net-state machine
|
|
// (`netstate`), the two-tier version gate (the transport interceptor + the Update/Play-offline notice
|
|
// + the update-available nudge) and the unified-lobby / create-flow wiring ride the always-loaded
|
|
// transport / App / Lobby / New Game screens (the dict-availability guard's `getDawg` stays lazy). The
|
|
// heavy parts — the dict loader, the move generator and the preload orchestration — still stay in lazy
|
|
// chunks. Raised to 131 for the Erudit no-repeat-words rule: the per-game flag rides the decoded
|
|
// GameView (codec + model), and the always-loaded game screen has to name the word its preview
|
|
// rejected, since a repeated word is a real word and the board alone cannot say why the play is
|
|
// refused. The rule's own logic — the played-word set and the rescoring — sits with the evaluator in
|
|
// the lazy dict chunk. Scoped CSS lands in the CSS chunk, not this JS budget.
|
|
//
|
|
// The shared chunk (svelte runtime + i18n) was raised from 31 to 32 for the direct rail's email
|
|
// notice: the wallet has to explain, before the buy tap, that a money purchase needs a confirmed
|
|
// email — the server refuses the order otherwise, and a player signed in through VK or Telegram in a
|
|
// browser would otherwise meet a bare error. Every user-visible string lands here, so the chunk had
|
|
// been sitting 40 bytes under its cap; the raise restores a working margin rather than paying for
|
|
// this one string.
|
|
const BUDGET = { app: 131, shared: 32, landing: 5 };
|
|
|
|
// gzipped returns the gzipped byte size of a built asset, or 0 when the reference is not a
|
|
// local file (e.g. the Telegram SDK loaded from a CDN) or is missing.
|
|
function gzipped(file) {
|
|
return file && existsSync(file) ? gzipSync(readFileSync(file)).length : 0;
|
|
}
|
|
|
|
// attr reads a double-quoted HTML attribute from a single tag string.
|
|
function attr(tag, name) {
|
|
const m = tag.match(new RegExp(`\\s${name}="([^"]+)"`));
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
// localAsset maps an HTML asset URL to its path under dist/, or null for an external URL.
|
|
function localAsset(url) {
|
|
return !url || /^https?:/.test(url) ? null : join(DIST, url.replace(/^\.?\/+/, ''));
|
|
}
|
|
|
|
// entryAssets parses a built HTML entry and returns the local JS it eagerly loads: the module
|
|
// entry chunk (<script type="module" src>) and the preloaded chunks (<link rel="modulepreload"
|
|
// href>). Robust to attribute order; external and non-JS references drop out.
|
|
function entryAssets(html) {
|
|
const src = readFileSync(join(DIST, html), 'utf8');
|
|
const tags = [...src.matchAll(/<(?:script|link)\b[^>]*>/g)].map((m) => m[0]);
|
|
const entryTag = tags.find((t) => /type="module"/.test(t) && /\ssrc="/.test(t));
|
|
const entry = entryTag ? localAsset(attr(entryTag, 'src')) : null;
|
|
const preloads = tags
|
|
.filter((t) => /rel="modulepreload"/.test(t))
|
|
.map((t) => localAsset(attr(t, 'href')))
|
|
.filter((f) => f && f.endsWith('.js'));
|
|
return { entry: entry && entry.endsWith('.js') ? entry : null, preloads };
|
|
}
|
|
|
|
const kb = (bytes) => (bytes / 1024).toFixed(1);
|
|
|
|
const app = entryAssets('index.html');
|
|
const landing = entryAssets('landing.html');
|
|
|
|
const appEntry = gzipped(app.entry);
|
|
const shared = app.preloads.reduce((sum, f) => sum + gzipped(f), 0);
|
|
const landingEntry = gzipped(landing.entry);
|
|
const landingShared = landing.preloads.reduce((sum, f) => sum + gzipped(f), 0);
|
|
|
|
console.log(`app (index.html) : entry ${kb(appEntry)} + shared ${kb(shared)} = ${kb(appEntry + shared)} KB gzip`);
|
|
console.log(`landing (landing.html): entry ${kb(landingEntry)} + shared ${kb(landingShared)} = ${kb(landingEntry + landingShared)} KB gzip`);
|
|
console.log('gates (gzip):');
|
|
|
|
let failed = false;
|
|
function gate(label, bytes, budgetKb) {
|
|
const over = bytes > budgetKb * 1024;
|
|
console.log(` ${over ? 'FAIL' : ' ok '} ${label}: ${kb(bytes)} / ${budgetKb} KB`);
|
|
if (over) failed = true;
|
|
}
|
|
gate('app entry (main)', appEntry, BUDGET.app);
|
|
gate('shared (svelte + i18n)', shared, BUDGET.shared);
|
|
gate('landing own', landingEntry, BUDGET.landing);
|
|
|
|
if (failed) {
|
|
console.error('bundle exceeds size budget');
|
|
process.exit(1);
|
|
}
|