9.9 KiB
Payload And Serialization Refactor Plan
Objective
Reduce the risk of future Blazor Server circuit disconnects by shrinking payloads, removing unnecessary serialization hops, and making live refreshes more granular.
The most expensive transport pattern is:
- browser
fetch - JSON parse in JavaScript
- JS interop result marshalled over the Blazor circuit
- JSON deserialization in .NET
That path means every read model still competes with the SignalR hub message ceiling and pays serialization cost twice.
Current Baseline
GET /api/campaigns: about222 BGET /api/campaigns/{id}: about1.0 KBGET /api/characters/{id}/sheet: about11.3 KBGET /api/campaigns/{id}/log: about13.8 KB- Workspace refresh currently reloads roster, selected character sheet, and log together when the state SSE reports a version change.
- The API client still uses JS interop for all reads and writes through
rpgRollerApi.request.
Target Outcomes
- Keep normal interactive responses well below the default Blazor circuit limit without depending on hub-size increases.
- Eliminate double JSON handling for the workspace read path.
- Avoid retransmitting unchanged roster, sheet, and log data after every state change.
- Establish payload and allocation guardrails so regressions are detected in tests.
Recommended Delivery Order
- Remove JS interop from workspace API reads.
- Split live refresh into change-specific reloads.
- Make campaign log loading incremental instead of retransmitting the latest 100 entries.
- Trim DTO shape and serialization overhead.
- Add measurement, tests, and payload budgets.
Phase 1: Remove The JS Interop API Bottleneck
Goal
Move workspace data reads off the fetch -> JS -> SignalR -> .NET path.
Recommendation
Introduce a server-side workspace query facade and call it directly from Blazor components instead of routing workspace reads through RpgRollerApiClient.
Why This First
- Highest impact on serialization overhead.
- Removes the hub-size ceiling from normal workspace query results.
- Simplifies error handling and reduces duplicate parsing logic.
Implementation Tasks
- Add a scoped server-side query service for the authenticated workspace.
- Resolve the session token from the current
HttpContextor a dedicated session abstraction. - Move these read flows from
RpgRollerApiClientto the query service: GetMeGetCampaignsGetCharacterCampaignOptionsGetCampaignGetCharacterSheetGetCampaignLog
Keep browser JS interop only for browser-only concerns:
- session storage
- SSE wiring
- DOM scrolling helpers
- Keep HTTP API endpoints for external callers and integration tests.
- Leave mutation endpoints in place initially, then decide whether mutations should also move server-side or stay as HTTP calls.
File Areas
RpgRoller/Components/RpgRollerApiClient.csRpgRoller/Components/Pages/Workspace.razor.csRpgRoller/Api/SessionTokenHttpContextExtensions.cs- new workspace query service under
RpgRoller/ComponentsorRpgRoller/Services
Acceptance Criteria
- Workspace reads no longer call
rpgRollerApi.request. - Opening play and management screens does not depend on JS interop payload size.
- Existing API integration tests still pass.
Phase 2: Replace Full Scope Refreshes With Targeted Refreshes
Goal
Stop reloading roster, selected sheet, and log together for every state change.
Recommendation
Replace the single campaign version event with typed change notifications or multiple independent versions.
Options
- Preferred: emit typed SSE events such as
roster-changed,character-sheet-changed, andlog-appended. - Acceptable: keep one event stream but include separate version counters for roster, character state, and log state.
Implementation Tasks
- Extend the server-side state event model to expose change categories.
- Update mutation paths in
GameServiceto mark the relevant change areas. - Update
Workspace.razor.csso the handler refreshes only the affected slice: - roster changes reload
CampaignRoster - skill and group changes reload
CharacterSheet - roll events append or refresh only the log
- avoid reloading the selected character sheet when another character changes
- avoid reloading the log when only roster metadata changes
File Areas
RpgRoller/Api/StateEventEndpoints.csRpgRoller/Services/GameService.csRpgRoller/Components/Pages/Workspace.razor.csRpgRoller/wwwroot/js/rpgroller-api.js
Acceptance Criteria
- A new roll does not trigger a roster reload.
- Renaming a character does not trigger a log reload unless log labels depend on that mutation.
- State refresh traffic is materially lower in browser and server traces.
Phase 3: Make Campaign Log Loading Incremental
Goal
Stop retransmitting the same log entries after every roll.
Recommendation
Add incremental log APIs and append on the client.
Implementation Tasks
- Add query parameters such as:
afterRollIdsinceTimestamplimit- retain an initial bounded load for first render
- add an incremental mode for live updates
- keep server ordering stable and deterministic
- update the workspace to append new entries instead of replacing the whole log
- trim old entries client-side to a fixed window
- preserve the visibility rules for GM, owner, and observers
Contract Changes
- Introduce a dedicated log page result:
- entries
- cursor or last seen roll id
- optional
hasMore
Acceptance Criteria
- A new roll causes only the new log entry or entries to cross the wire.
- Reconnect can rebuild log state without downloading unnecessary history.
- Existing visibility behavior remains unchanged.
Phase 4: Split Log Summary From Log Detail
Goal
Reduce the size of the hottest payload even further.
Recommendation
Do not send full dice arrays and long breakdown strings for every log row by default.
Implementation Tasks
- Introduce
CampaignLogListEntryfor the list view. - Keep only fields needed to render the collapsed row:
- roll id
- roller label
- skill label
- character label
- result
- visibility
- timestamp
- compact summary text
- add
GET /api/rolls/{rollId}or equivalent detail lookup for expanded inspection - update the log UI to lazy-load detail when a row is expanded
Expected Benefit
This should cut the log list payload materially because Dice and Breakdown are currently repeated for every row and are the least compressible fields in the list.
Phase 5: Trim DTO Shape To Match The View
Goal
Remove repeated fields that are not needed by the consuming UI.
Recommendations
- Replace
CampaignSummary.GmandCampaignRoster.GmfullUserSummaryusage with a slimmer campaign GM DTO if the UI only needsIdandDisplayName. - Remove parent-scope identifiers from child records where the endpoint already provides that scope.
- candidate examples:
CampaignLogEntry.CampaignIdSkillSummary.CharacterIdinsideCharacterSheetSkillGroupSummary.CharacterIdinsideCharacterSheet- review whether owner ids are needed in all list views or whether some can be replaced with display labels and booleans
Guardrail
Do not over-optimize DTOs until the consuming components have been made explicit. Only remove a field after all consumers are verified.
Phase 6: Reduce Serializer CPU And Allocation Overhead
Goal
Lower per-request CPU and allocation cost after the major transport fixes are in place.
Recommendations
- Introduce source-generated
System.Text.Jsoncontexts for the hot contracts. - Reuse serializer options consistently rather than relying on repeated default metadata discovery.
- Review whether any list contracts can be exposed as arrays end-to-end to reduce intermediate allocations.
- If HTTP remains in the path for some calls, ensure response compression is enabled for normal API responses to reduce browser transfer cost.
Note
This phase is worthwhile, but it should follow the transport refactor. Serializer tuning alone will not solve circuit-size problems.
Phase 7: Add Payload Budgets And Regression Tests
Goal
Prevent a future regression from silently reintroducing oversized read models.
Implementation Tasks
- Add integration tests that serialize representative contracts and assert upper bounds.
- Add service or API tests for log pagination and incremental fetch semantics.
- Add workspace tests for targeted refresh behavior.
- Add a small benchmark or diagnostic test for hot payload serialization if practical.
- Document soft payload budgets for any remaining JS interop responses.
Suggested Budgets
- Any remaining JS interop response: prefer under
16 KB - initial character sheet response: target under
12 KB - initial log list response: target under
8 KBafter summary/detail split - incremental live update response: target under
2 KB
Delivery Notes
- Do not raise the Blazor hub message limit again as the primary fix.
- Keep the existing HTTP API stable where possible so tests and external tooling do not break.
- Prefer introducing new, view-specific contracts instead of reusing broad aggregate models.
- Measure payload size with representative admin and non-admin datasets after each phase.
Proposed Milestones
Milestone A
Move workspace reads off JS interop and keep behavior unchanged.
Milestone B
Introduce targeted SSE-driven refreshes without yet changing log contract shape.
Milestone C
Add incremental log loading and client append behavior.
Milestone D
Split log summary from log detail and trim DTOs.
Milestone E
Add serializer optimizations, payload budget tests, and final documentation updates.
Definition Of Done
- Workspace read models are no longer limited by Blazor JS interop payload size.
- Live updates no longer reload unrelated slices.
- The campaign log is loaded incrementally.
- The hottest contracts are explicitly sized for their views.
- Payload budgets are enforced by tests.
- The default Blazor hub receive limit remains unchanged.