@@ -38,6 +38,149 @@ describe('ws auth token in URL', () => {
3838 } )
3939} )
4040
41+ describe ( 'ws client post on a non-open socket' , ( ) => {
42+ class FakeWS {
43+ static OPEN = 1
44+ static CONNECTING = 0
45+ static CLOSING = 2
46+ static CLOSED = 3
47+
48+ readyState : number
49+ listeners = new Map < string , Set < ( ) => void > > ( )
50+ sent : string [ ] = [ ]
51+
52+ constructor ( public url : string , readyState : number ) {
53+ this . readyState = readyState
54+ }
55+
56+ addEventListener ( type : string , handler : ( ) => void ) {
57+ let set = this . listeners . get ( type )
58+ if ( ! set ) {
59+ set = new Set ( )
60+ this . listeners . set ( type , set )
61+ }
62+ set . add ( handler )
63+ }
64+
65+ removeEventListener ( type : string , handler : ( ) => void ) {
66+ this . listeners . get ( type ) ?. delete ( handler )
67+ }
68+
69+ send ( data : string ) {
70+ this . sent . push ( data )
71+ }
72+
73+ dispatch ( type : string ) {
74+ for ( const handler of this . listeners . get ( type ) ?? [ ] )
75+ handler ( )
76+ }
77+ }
78+
79+ // Builds a `WebSocket` constructor fixed at the given `readyState`,
80+ // stashing every instance it creates into `instances` (avoids aliasing
81+ // `this` inside the constructor).
82+ function fakeWsCtor ( readyState : number , instances : FakeWS [ ] ) {
83+ return class extends FakeWS {
84+ constructor ( url : string ) {
85+ super ( url , readyState )
86+ instances . push ( this )
87+ }
88+ }
89+ }
90+
91+ it ( 'does not queue an open listener when posting on a CLOSED socket, and notifies onError' , ( ) => {
92+ const errors : Error [ ] = [ ]
93+ const instances : FakeWS [ ] = [ ]
94+ vi . stubGlobal ( 'WebSocket' , fakeWsCtor ( FakeWS . CLOSED , instances ) )
95+ try {
96+ // Baseline: `createWsRpcChannel` itself registers one 'open' listener
97+ // (for `onConnected`) regardless of readyState — that one is expected
98+ // to stick around; `post` must not add another on top of it.
99+ const channel = createWsRpcChannel ( { url : 'ws://127.0.0.1:1' , onError : e => errors . push ( e ) } )
100+ const ws = instances [ 0 ] !
101+ const baseline = ws . listeners . get ( 'open' ) ?. size ?? 0
102+ channel . post ! ( 'hello' )
103+ expect ( ws . listeners . get ( 'open' ) ?. size ?? 0 ) . toBe ( baseline )
104+ expect ( ws . sent ) . toEqual ( [ ] )
105+ expect ( errors ) . toHaveLength ( 1 )
106+ }
107+ finally {
108+ vi . stubGlobal ( 'WebSocket' , WebSocket )
109+ }
110+ } )
111+
112+ it ( 'does not queue an open listener when posting on a CLOSING socket, and notifies onError' , ( ) => {
113+ const errors : Error [ ] = [ ]
114+ const instances : FakeWS [ ] = [ ]
115+ vi . stubGlobal ( 'WebSocket' , fakeWsCtor ( FakeWS . CLOSING , instances ) )
116+ try {
117+ const channel = createWsRpcChannel ( { url : 'ws://127.0.0.1:1' , onError : e => errors . push ( e ) } )
118+ const ws = instances [ 0 ] !
119+ const baseline = ws . listeners . get ( 'open' ) ?. size ?? 0
120+ channel . post ! ( 'hello' )
121+ expect ( ws . listeners . get ( 'open' ) ?. size ?? 0 ) . toBe ( baseline )
122+ expect ( ws . sent ) . toEqual ( [ ] )
123+ expect ( errors ) . toHaveLength ( 1 )
124+ }
125+ finally {
126+ vi . stubGlobal ( 'WebSocket' , WebSocket )
127+ }
128+ } )
129+
130+ it ( 'queues exactly one open listener on CONNECTING and cleans it up (and the close listener) after open fires' , ( ) => {
131+ const errors : Error [ ] = [ ]
132+ const instances : FakeWS [ ] = [ ]
133+ vi . stubGlobal ( 'WebSocket' , fakeWsCtor ( FakeWS . CONNECTING , instances ) )
134+ try {
135+ // Baseline: `createWsRpcChannel` already registers one 'open' listener
136+ // (`onConnected`) and one 'close' listener (`onDisconnected`) up front;
137+ // `post`'s queued listeners are on top of those and must clean up back
138+ // down to this baseline.
139+ const channel = createWsRpcChannel ( { url : 'ws://127.0.0.1:1' , onError : e => errors . push ( e ) } )
140+ const ws = instances [ 0 ] !
141+ const openBaseline = ws . listeners . get ( 'open' ) ?. size ?? 0
142+ const closeBaseline = ws . listeners . get ( 'close' ) ?. size ?? 0
143+ channel . post ! ( 'hello' )
144+ expect ( ws . listeners . get ( 'open' ) ?. size ?? 0 ) . toBe ( openBaseline + 1 )
145+ expect ( ws . listeners . get ( 'close' ) ?. size ?? 0 ) . toBe ( closeBaseline + 1 )
146+
147+ ws . readyState = FakeWS . OPEN
148+ ws . dispatch ( 'open' )
149+
150+ expect ( ws . sent ) . toEqual ( [ 'hello' ] )
151+ expect ( ws . listeners . get ( 'open' ) ?. size ?? 0 ) . toBe ( openBaseline )
152+ expect ( ws . listeners . get ( 'close' ) ?. size ?? 0 ) . toBe ( closeBaseline )
153+ expect ( errors ) . toHaveLength ( 0 )
154+ }
155+ finally {
156+ vi . stubGlobal ( 'WebSocket' , WebSocket )
157+ }
158+ } )
159+
160+ it ( 'drops a queued post (no send) if the socket closes before it opens' , ( ) => {
161+ const errors : Error [ ] = [ ]
162+ const instances : FakeWS [ ] = [ ]
163+ vi . stubGlobal ( 'WebSocket' , fakeWsCtor ( FakeWS . CONNECTING , instances ) )
164+ try {
165+ const channel = createWsRpcChannel ( { url : 'ws://127.0.0.1:1' , onError : e => errors . push ( e ) } )
166+ const ws = instances [ 0 ] !
167+ const openBaseline = ws . listeners . get ( 'open' ) ?. size ?? 0
168+ const closeBaseline = ws . listeners . get ( 'close' ) ?. size ?? 0
169+ channel . post ! ( 'hello' )
170+
171+ ws . readyState = FakeWS . CLOSED
172+ ws . dispatch ( 'close' )
173+
174+ expect ( ws . sent ) . toEqual ( [ ] )
175+ expect ( ws . listeners . get ( 'open' ) ?. size ?? 0 ) . toBe ( openBaseline )
176+ expect ( ws . listeners . get ( 'close' ) ?. size ?? 0 ) . toBe ( closeBaseline )
177+ }
178+ finally {
179+ vi . stubGlobal ( 'WebSocket' , WebSocket )
180+ }
181+ } )
182+ } )
183+
41184describe ( 'devframe rpc' , ( ) => {
42185 it ( 'should work w/ ws transport' , async ( ) => {
43186 // Use 127.0.0.1 on both client and server so they agree on the
0 commit comments