55 parseServerMessage ,
66 ServerMessage ,
77 Transition ,
8+ TransitionChunk ,
89} from "./protocol.js" ;
910
1011const CLOSE_NORMAL = 1000 ;
@@ -162,6 +163,13 @@ export class WebSocketManager {
162163 | ( string & { } ) // a full serverErrorReason (not just the prefix) or a new one
163164 | null ;
164165
166+ // State for assembling the split-up Transition currently being received.
167+ private transitionChunkBuffer : {
168+ chunks : string [ ] ;
169+ totalParts : number ;
170+ messageLength : number ;
171+ } | null = null ;
172+
165173 /** Upon HTTPS/WSS failure, the first jittered backoff duration, in ms. */
166174 private readonly defaultInitialBackoff : number ;
167175
@@ -239,6 +247,60 @@ export class WebSocketManager {
239247 this . markConnectionStateDirty ( ) ;
240248 }
241249
250+ private assembleTransition ( chunk : TransitionChunk ) : Transition | null {
251+ if (
252+ chunk . partNumber < 0 ||
253+ chunk . partNumber >= chunk . totalParts ||
254+ chunk . totalParts === 0 ||
255+ ( this . transitionChunkBuffer &&
256+ ( this . transitionChunkBuffer . totalParts !== chunk . totalParts ||
257+ this . transitionChunkBuffer . messageLength !== chunk . messageLength ) )
258+ ) {
259+ // Throwing an error doesn't crash the client, so clear the buffer.
260+ this . transitionChunkBuffer = null ;
261+ throw new Error ( "Invalid TransitionChunk" ) ;
262+ }
263+
264+ if ( this . transitionChunkBuffer === null ) {
265+ this . transitionChunkBuffer = {
266+ chunks : [ ] ,
267+ totalParts : chunk . totalParts ,
268+ messageLength : chunk . messageLength ,
269+ } ;
270+ }
271+
272+ if ( chunk . partNumber !== this . transitionChunkBuffer . chunks . length ) {
273+ // Throwing an error doesn't crash the client, so clear the buffer.
274+ const expectedLength = this . transitionChunkBuffer . chunks . length ;
275+ this . transitionChunkBuffer = null ;
276+ throw new Error (
277+ `TransitionChunk received out of order: expected part ${ expectedLength } , got ${ chunk . partNumber } ` ,
278+ ) ;
279+ }
280+
281+ this . transitionChunkBuffer . chunks . push ( chunk . chunk ) ;
282+
283+ if ( this . transitionChunkBuffer . chunks . length === chunk . totalParts ) {
284+ const fullJson = this . transitionChunkBuffer . chunks . join ( "" ) ;
285+ this . transitionChunkBuffer = null ;
286+
287+ if ( fullJson . length !== chunk . messageLength ) {
288+ throw new Error (
289+ `Assembled Transition length mismatch: expected ${ chunk . messageLength } , got ${ fullJson . length } ` ,
290+ ) ;
291+ }
292+ const transition = parseServerMessage ( JSON . parse ( fullJson ) ) ;
293+ if ( transition . type !== "Transition" ) {
294+ throw new Error (
295+ `Expected Transition, got ${ transition . type } after assembling chunks` ,
296+ ) ;
297+ }
298+ return transition ;
299+ }
300+
301+ return null ;
302+ }
303+
242304 private connect ( ) {
243305 if ( this . socket . state === "terminated" ) {
244306 return ;
@@ -304,6 +366,7 @@ export class WebSocketManager {
304366 } ;
305367 // NB: The WebSocket API calls `onclose` even if connection fails, so we can route all error paths through `onclose`.
306368 ws . onerror = ( error ) => {
369+ this . transitionChunkBuffer = null ;
307370 const message = ( error as ErrorEvent ) . message ;
308371 if ( message ) {
309372 this . logger . log ( `WebSocket error message: ${ message } ` ) ;
@@ -312,8 +375,32 @@ export class WebSocketManager {
312375 ws . onmessage = ( message ) => {
313376 this . resetServerInactivityTimeout ( ) ;
314377 const messageLength = message . data . length ;
315- const serverMessage = parseServerMessage ( JSON . parse ( message . data ) ) ;
378+ let serverMessage = parseServerMessage ( JSON . parse ( message . data ) ) ;
316379 this . _logVerbose ( `received ws message with type ${ serverMessage . type } ` ) ;
380+
381+ // Ping's only purpose is to reset the server inactivity timer.
382+ if ( serverMessage . type === "Ping" ) {
383+ return ;
384+ }
385+
386+ // TransitionChunks never reach the main client logic.
387+ if ( serverMessage . type === "TransitionChunk" ) {
388+ const transition = this . assembleTransition ( serverMessage ) ;
389+ if ( ! transition ) {
390+ return ;
391+ }
392+ serverMessage = transition ;
393+ this . _logVerbose (
394+ `assembled full ws message of type ${ serverMessage . type } ` ,
395+ ) ;
396+ }
397+
398+ if ( this . transitionChunkBuffer !== null ) {
399+ throw new Error (
400+ `Received unexpected ${ serverMessage . type } while buffering TransitionChunks` ,
401+ ) ;
402+ }
403+
317404 if ( serverMessage . type === "Transition" ) {
318405 this . reportLargeTransition ( {
319406 messageLength,
@@ -329,6 +416,7 @@ export class WebSocketManager {
329416 } ;
330417 ws . onclose = ( event ) => {
331418 this . _logVerbose ( "begin ws.onclose" ) ;
419+ this . transitionChunkBuffer = null ;
332420 if ( this . lastCloseReason === null ) {
333421 // event.reason is often an empty string
334422 this . lastCloseReason = event . reason || `closed with code ${ event . code } ` ;
0 commit comments