Skip to content

feat(realtime): policy issuance stream, clock-skew normalization, bundle budget, payload size guards - #431

Open
Lucky-Ekpa-800 wants to merge 1 commit into
Refract-Protocol:mainfrom
Lucky-Ekpa-800:feat/246-249-realtime-policy-stream-clock-budget-size-guards
Open

Lucky-Ekpa-800 wants to merge 1 commit into
Refract-Protocol:mainfrom
Lucky-Ekpa-800:feat/246-249-realtime-policy-stream-clock-budget-size-guards

Conversation

@Lucky-Ekpa-800

Copy link
Copy Markdown

Closes #246
Closes #247
Closes #248
Closes #249

Full design notes: docs/realtime.md.

#246: Stream policy issuance into /cover

  • New topic: policy.<address>, keyed by wallet. Event types: accepted, issued, premium_charged, cancelled, failed. zod schemas live in src/lib/realtime/policyEvents.ts.
  • Source of truth: the tx hash from the REST purchase call. /cover adds an optimistic entry keyed by that hash to a persisted store (pendingPolicies.ts).
  • State transitions: usePolicyStream(address) moves the entry submitted → accepted → active, or rolls it back to failed. Events never move an entry backwards, so duplicates and out-of-order delivery are harmless.
  • Missing events: if no event arrives within 90 s, the REST result stands. The entry becomes active with confirmedBy: "rest".
  • Navigation: pending purchases survive leaving /cover. On return the hook re-subscribes and the same row updates; nothing is duplicated.
  • Multiple tabs: a storage listener keeps tabs in sync. Adding the same tx hash merges into one row, and an identical in-flight purchase blocks the Buy button.
  • Policies issued elsewhere: they appear as external rows instead of being dropped.
  • UI: PolicyProgress shows the progression, failure reason, and premium charged. A "Your recent purchases" panel lists other pending purchases.

#247: Clock-skew detection and timestamp normalization

  • Estimator: ClockOffsetEstimator takes NTP-style mid-point samples from heartbeat ping/pong. It has no transport dependency, so SSE and long-poll can feed it too.
    • Estimate: median offset of the lowest-RTT samples in an 8-sample window, which resists outliers.
    • Uncertainty: half the best RTT.
    • Clock jumps (sleep/resume, manual change): detected by comparing wall vs monotonic time; all samples are invalidated.
    • Offsets over 30 s set skewed: true as a diagnostic, not an error.
  • Helpers: normalizeServerTs, serverEventAgeMs, isServerTsStale, and the getClockSync() read-only accessor.
  • Rendering: useServerRelativeTime re-renders only when the displayed text changes. Subscribers are only notified when the offset moves by 250 ms or more.

#248: Bundle-size and runtime budget

  • Lazy loading: the client and validators load only through loadRealtime(), a named dynamic import that builds to .next/static/chunks/realtime.<hash>.js.
  • Budget: 8 kB gzipped in .size-limit.json. The chunk's own code measures about 3.1 kB gzipped when bundled in isolation. zod is shared with /cover's form validation, so it isn't counted. The headroom is for the SSE and long-poll transports; revisit the number when the transport matrix is final. The shared-bundle budget now excludes the lazy chunk.
  • CI assertion: scripts/check-realtime-bundle.mjs (npm run size:realtime, added to CI after npm run size) fails if:
    • dev/test code (src/lib/realtime/dev) appears anywhere in .next/static, or
    • the client appears in any chunk a route loads up front.
  • Lint: ESLint no-restricted-imports blocks static imports of the client, the lazy entry, or dev/* outside the realtime module and tests.
  • package.json had its own size-limit key, which takes precedence and meant .size-limit.json was silently ignored. It's removed so .size-limit.json is the single config.

#249: permessage-deflate and payload size guards

  • Size guard: one shared decodeFrame() for all transports. It rejects frames over MAX_MESSAGE_BYTES (256 KiB, measured in UTF-8 bytes) before JSON.parse. The limit is sized against the snapshot contract documented in docs/realtime.md.
  • Error path: a rejected frame is dropped and counted (oversizeDrops / malformedDrops), recorded as lastError, and passed to onProtocolError listeners. The connection stays open.
  • Compression: PERMESSAGE_DEFLATE_OPTIONS asks for no context takeover. Browsers negotiate the extension themselves, so this mostly documents the expected backend config. Graceful decline is tested.
  • Diagnostics:
    • negotiatedExtensions and compressionActive (from WebSocket.extensions)
    • bytesReceived (decoded bytes)
    • wireStats (compressed vs uncompressed bytes, reported by the server, since browsers don't expose compressed sizes to script)

Repo fixes needed along the way

  • src/app/cover/page.tsx: the JSX had been truncated since update #161 and didn't parse. Steps 3–4 of the wizard were never finished. I rebuilt the cut-off sections, fixed an undefined holder in submitBuy, and stringified toStroops() results (a bigint makes JSON.stringify throw). The hard-coded colours that pre-commit lint flags are now pm-* tokens.
  • vitest.config.ts: an empty file that shadowed vitest.config.mts, so the @/ alias was missing in every test. Removed. The real config now also compiles JSX.
  • .eslintrc.json: Literal[value=1e7] is an invalid selector and crashed ESLint on every file. Changed to 10000000.
  • src/components/ui/index.ts: added the missing re-exports for AsyncState, SuccessBurst, QuickAmountChips and Tour.

Known pre-existing breakage on main (not addressed here)

next build and tsc still fail on main for reasons outside these issues:

  • 15 source files were emptied by earlier merges, including src/app/layout.tsx, src/components/ui/Button.tsx, src/components/layout/Navbar.tsx, src/components/wallet/index.ts, useCoverageTypes, useCoverageBounds, useOracleStatus and useRecentClaims. Because of this:
    • The size budgets can't run in CI until a build succeeds. Budgets were measured by bundling the realtime entry in isolation.
    • useOracleStatus / useRecentClaims can't adopt the clock normalization yet; they should use useServerRelativeTime once restored.
  • Flight-delay purchases: the wizard sends { flightNumber }, but BuyPolicyParams requires { coverageType: 4, params: { flightNumber, departureDate } }.
  • Other tests: 46 tests in 9 unrelated files (format, stellar, wallet adapters, and others) already fail on main.

Tests

54 new tests, all passing:

src/lib/realtime/codec.test.ts           size boundary (exact/+1 byte), UTF-8 & surrogate counting, binary frames, malformed
src/lib/realtime/client.test.ts          oversize drop + counter + error path, deflate accepted/declined, wire stats, heartbeat, pong → clock, reconnect
src/lib/realtime/clockOffset.test.ts     symmetric/asymmetric RTT, slow + wild outliers, window, clock jump, notify threshold, skewed device clock → correct relative time
src/lib/realtime/pendingPolicies.test.ts optimistic → confirmed, optimistic → failed, no backwards moves, dedupe, external issuance, REST fallback, duplicate detection
src/lib/realtime/policyEvents.test.ts    schema accept/reject
src/hooks/usePolicyStream.test.ts        integration: events through a fake WebSocket → client → hook → store → rendered progress (incl. navigate-away/back and bounded-wait fallback)

The bundle-check script was verified against synthetic .next output, both passing and failing (dev marker shipped, client in an eager chunk).

🤖 Generated with Claude Code

…dle budget, payload size guards

Closes Refract-Protocol#246, Refract-Protocol#247, Refract-Protocol#248, Refract-Protocol#249.

- Refract-Protocol#246: `policy.<address>` topic (accepted/issued/premium_charged/cancelled/failed),
  persisted pending-purchase store keyed by tx hash, usePolicyStream hook with
  bounded-wait REST fallback and cross-tab sync, PolicyProgress UI on /cover.
- Refract-Protocol#247: transport-independent ClockOffsetEstimator (min-RTT median, clock-jump
  invalidation, skew diagnostic), normalizeServerTs helpers, useServerRelativeTime.
- Refract-Protocol#248: lazy `realtime` chunk via loadRealtime(), 8 kB size-limit budget,
  scripts/check-realtime-bundle.mjs CI assertion, ESLint import restrictions.
- Refract-Protocol#249: shared decodeFrame with a 256 KiB pre-parse size guard, protocol-error
  path and counters, permessage-deflate options and negotiation diagnostics.

Also repairs the truncated JSX in src/app/cover/page.tsx (and moves its
hard-coded colours onto design tokens), removes the empty vitest.config.ts that
shadowed vitest.config.mts, fixes the invalid ESLint selector that crashed lint,
and drops the duplicate size-limit config from package.json that hid
.size-limit.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant