Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support Upgrade requests (Socket.IO) #682

Closed
kettanaito opened this issue Nov 29, 2024 · 5 comments · Fixed by #683
Closed

Support Upgrade requests (Socket.IO) #682

kettanaito opened this issue Nov 29, 2024 · 5 comments · Fixed by #683
Assignees
Labels
help wanted Extra attention is needed

Comments

@kettanaito
Copy link
Member

We don't seem to passthrough Socket.IO Upgrade HTTP request correctly. It gets stuck forever.

@kettanaito
Copy link
Member Author

Here's the net.Socket emit/write log comparison between the working (no interceptor) and broken (ClientRequest interceptor applied) versions:

No interceptor

WRITE! GET /socket.io/?EIO=4&transport=websocket HTTP/1.1
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: EJ2fmeWdlAdTAA6wxdBcIg==
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Host: localhost:51678


EMIT! resume

EMIT! lookup

EMIT! resume
EMIT! connect
EMIT! ready

WRITE! HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 9y6umGlFvZE0sLuAn73qjWjwkiw=


EMIT! resume
WRITE! �k
WRITE! 0{"sid":"Kz0Rh53n7Ogm3NUyAAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}

EMIT! data
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 9y6umGlFvZE0sLuAn73qjWjwkiw=

�k0{"sid":"Kz0Rh53n7Ogm3NUyAAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000} 


EMIT! agentRemove
EMIT! readable
EMIT! data
�k0{"sid":"Kz0Rh53n7Ogm3NUyAAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000} 


WRITE! �����
WRITE! ��
EMIT! resume
EMIT! data
������� 


WRITE! � 
WRITE! 40{"sid":"f3xP3tuJ8TKnj00bAAAB"}
EMIT! data
� 40{"sid":"f3xP3tuJ8TKnj00bAAAB"} 


---- (connection open, test done)

WRITE! �
WRITE! 41
WRITE! �
WRITE! 

�41� 




WRITE! ���Ī�
WRITE! 


���Ī� 




EMIT! data
EMIT! prefinish
EMIT! finish
EMIT! data
EMIT! prefinish
EMIT! finish
EMIT! readable
EMIT! end
EMIT! readable
EMIT! end
EMIT! close
EMIT! close

Interceptor applied

EMIT! resume

WRITE! GET /socket.io/?EIO=4&transport=websocket HTTP/1.1
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: u0avYGhp4h9fN8N4E8QsDw==
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Host: localhost:51678


EMIT! resume
EMIT! resume

EMIT! lookup
EMIT! lookup
EMIT! connect
EMIT! connect
EMIT! ready
EMIT! ready
EMIT! resume
WRITE! HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 08kvjVAwS1LE6zG3pNIG5uFpeys=


EMIT! resume
WRITE! �k
WRITE! 0{"sid":"eUD8fqOV9Sf6nzTMAAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}

EMIT! data
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 08kvjVAwS1LE6zG3pNIG5uFpeys=

�k0{"sid":"eUD8fqOV9Sf6nzTMAAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000} 


EMIT! data
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: 08kvjVAwS1LE6zG3pNIG5uFpeys=

�k0{"sid":"eUD8fqOV9Sf6nzTMAAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000} 


EMIT! agentRemove
EMIT! readable
EMIT! data
�k0{"sid":"eUD8fqOV9Sf6nzTMAAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000} 


EMIT! resume

WRITE! �
WRITE! 

EMIT! data
EMIT! data

� 


� 


There are clearly differences, mostly:

  • Interceptor does not receive 40/41 payloads from the server, which is Socket.IO's way of acknowledging the connection.
  • Ignore the prefinish/finish/end/close sequence missing at the end of the interceptor scenario. It's missing because the connection is never open, so it won't close either (test times out).

@kettanaito
Copy link
Member Author

How we handle request passthrough:

public passthrough(): void {
if (this.destroyed) {
return
}
const socket = this.createConnection()
// If the developer destroys the socket, destroy the original connection.
this.once('error', (error) => {
socket.destroy(error)
})
this.address = socket.address.bind(socket)
// Flush the buffered "socket.write()" calls onto
// the original socket instance (i.e. write request body).
// Exhaust the "requestBuffer" in case this Socket
// gets reused for different requests.
let writeArgs: NormalizedSocketWriteArgs | undefined
let headersWritten = false
while ((writeArgs = this.writeBuffer.shift())) {
if (writeArgs !== undefined) {
if (!headersWritten) {
const [chunk, encoding, callback] = writeArgs
const chunkString = chunk.toString()
const chunkBeforeRequestHeaders = chunkString.slice(
0,
chunkString.indexOf('\r\n') + 2
)
const chunkAfterRequestHeaders = chunkString.slice(
chunk.indexOf('\r\n\r\n')
)
const rawRequestHeaders = getRawFetchHeaders(this.request!.headers)
const requestHeadersString = rawRequestHeaders
// Skip the internal request ID deduplication header.
.filter(([name]) => {
return name.toLowerCase() !== INTERNAL_REQUEST_ID_HEADER_NAME
})
.map(([name, value]) => `${name}: ${value}`)
.join('\r\n')
// Modify the HTTP request message headers
// to reflect any changes to the request headers
// from the "request" event listener.
const headersChunk = `${chunkBeforeRequestHeaders}${requestHeadersString}${chunkAfterRequestHeaders}`
socket.write(headersChunk, encoding, callback)
headersWritten = true
continue
}
socket.write(...writeArgs)
}
}
// Forward TLS Socket properties onto this Socket instance
// in the case of a TLS/SSL connection.
if (Reflect.get(socket, 'encrypted')) {
const tlsProperties = [
'encrypted',
'authorized',
'getProtocol',
'getSession',
'isSessionReused',
]
tlsProperties.forEach((propertyName) => {
Object.defineProperty(this, propertyName, {
enumerable: true,
get: () => {
const value = Reflect.get(socket, propertyName)
return typeof value === 'function' ? value.bind(socket) : value
},
})
})
}
socket
.on('lookup', (...args) => this.emit('lookup', ...args))
.on('connect', () => {
this.connecting = socket.connecting
this.emit('connect')
})
.on('secureConnect', () => this.emit('secureConnect'))
.on('secure', () => this.emit('secure'))
.on('session', (session) => this.emit('session', session))
.on('ready', () => this.emit('ready'))
.on('drain', () => this.emit('drain'))
.on('data', (chunk) => {
// Push the original response to this socket
// so it triggers the HTTP response parser. This unifies
// the handling pipeline for original and mocked response.
this.push(chunk)
})
.on('error', (error) => {
Reflect.set(this, '_hadError', Reflect.get(socket, '_hadError'))
this.emit('error', error)
})
.on('resume', () => this.emit('resume'))
.on('timeout', () => this.emit('timeout'))
.on('prefinish', () => this.emit('prefinish'))
.on('finish', () => this.emit('finish'))
.on('close', (hadError) => this.emit('close', hadError))
.on('end', () => this.emit('end'))
}

@kettanaito
Copy link
Member Author

kettanaito commented Dec 2, 2024

Discovery

The connection hands forever because EngineIO never receives the message event on the underlying WebSocket transport, which, in turn, would trigger doConnect -> connect on the server client.

https://github.com/socketio/socket.io/blob/7427109658591e7ce677a183a664d1f5327f37ea/packages/engine.io/lib/transports/websocket.ts#L19-L23

SocketIO expects the 40 special message to be received from the client, acknowledging the namespace and confirming the connection.

Since we are using ws for the server, it will be ws who should receive the said message and forward it to the SocketIO WebSocket instance. Here's how that message is received:

  1. When the server receives data, the socketOnData function is triggered.
  2. That triggers receiver._write function that starts the loop to parse the incoming message.
  3. Once the loop gets the info it needs, it falls into the GET_DATA clause, starting the message parsing.
  4. getData function tries to consume the bytes equal to the payload's size from the buffered chunks.

The messages the server receives in the working scenario are as follows (message (byteLength)):

0{"sid":"CYbBa1cxknpXtcf5AAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000} (107)
�' (2)
40{"sid":"8EsUVwivGEhxflmhAAAB"} (32)

--- TEST IS DONE

41 (2)

In the case of a broken, interceptor scenario, the server only receives the initial 0 message, then hangs forever:

0{"sid":"yT7sSLEmNCVF4qV6AAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}

The callstack that leads to the 40 message is this:

Receiver.getData (mswjs/interceptors/node_modules/.pnpm/ws@8.11.0/node_modules/ws/lib/receiver.js:421)
Receiver.startLoop (mswjs/interceptors/node_modules/.pnpm/ws@8.11.0/node_modules/ws/lib/receiver.js:148)
Receiver._write (mswjs/interceptors/node_modules/.pnpm/ws@8.11.0/node_modules/ws/lib/receiver.js:83)
writeOrBuffer (<node_internals>/internal/streams/writable:392)
_write (<node_internals>/internal/streams/writable:333)
Writable.write (<node_internals>/internal/streams/writable:337)
Socket.socketOnData (mswjs/interceptors/node_modules/.pnpm/ws@8.11.0/node_modules/ws/lib/websocket.js:1272)
Socket.emit (<node_internals>/events:517)
addChunk (<node_internals>/internal/streams/readable:368)
readableAddChunk (<node_internals>/internal/streams/readable:341)
Readable.push (<node_internals>/internal/streams/readable:278)
TCP.onStreamRead (<node_internals>/internal/stream_base_commons:190)
TCP.callbackTrampoline (<node_internals>/internal/async_hooks:128)
TCPWRAP (Unknown Source:0)
init (<node_internals>/internal/inspector_async_hook:25)
emitInitNative (<node_internals>/internal/async_hooks:200)
Socket.connect (<node_internals>/net:1219)
connect (<node_internals>/net:249)
createSocket (<node_internals>/_http_agent:341)
addRequest (<node_internals>/_http_agent:288)
ClientRequest (<node_internals>/_http_client:342)
request (<node_internals>/http:100)
initAsClient (mswjs/interceptors/node_modules/.pnpm/ws@8.11.0/node_modules/ws/lib/websocket.js:841)
WebSocket (mswjs/interceptors/node_modules/.pnpm/ws@8.11.0/node_modules/ws/lib/websocket.js:85)
doOpen (mswjs/interceptors/node_modules/.pnpm/engine.io-client@6.5.3/node_modules/engine.io-client/build/esm-debug/transports/websocket.js:46)
open (mswjs/interceptors/node_modules/.pnpm/engine.io-client@6.5.3/node_modules/engine.io-client/build/esm-debug/transport.js:48)
open (mswjs/interceptors/node_modules/.pnpm/engine.io-client@6.5.3/node_modules/engine.io-client/build/esm-debug/socket.js:175)
Socket (mswjs/interceptors/node_modules/.pnpm/engine.io-client@6.5.3/node_modules/engine.io-client/build/esm-debug/socket.js:113)
open (mswjs/interceptors/node_modules/.pnpm/socket.io-client@4.7.4/node_modules/socket.io-client/build/esm-debug/manager.js:112)
Manager (mswjs/interceptors/node_modules/.pnpm/socket.io-client@4.7.4/node_modules/socket.io-client/build/esm-debug/manager.js:41)
lookup (mswjs/interceptors/node_modules/.pnpm/socket.io-client@4.7.4/node_modules/socket.io-client/build/esm-debug/index.js:33)
<anonymous> (mswjs/interceptors/test/modules/http/compliance/http-upgrade.test.ts:63)
<anonymous>

Here's a log of the receiver/sender events for the working scenario:

[node] net.Socket.write: GET /socket.io/?EIO=4&transport=websocket HTTP/1.1
Sec-WebSocket-Version: 13
Sec-WebSocket-Key: vTbfmk+6UcTzEMmcedysVw==
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Host: localhost:51678



Trace: [ws] Sender.send: 0{"sid":"6s27N0nXkYG7VT2fAAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}
    at Sender.send (/mswjs/interceptors/node_modules/.pnpm/ws@8.11.0/node_modules/ws/lib/sender.js:305:13)
    at WebSocket.send (/mswjs/interceptors/node_modules/.pnpm/ws@8.11.0/node_modules/ws/lib/websocket.js:468:18)
    at send (/mswjs/interceptors/node_modules/.pnpm/engine.io@6.5.4/node_modules/engine.io/build/transports/websocket.js:84:29)
    at Object.encodePacket (/mswjs/interceptors/node_modules/.pnpm/engine.io-parser@5.2.1/node_modules/engine.io-parser/build/cjs/encodePacket.js:10:12)
    at WebSocket.send (/mswjs/interceptors/node_modules/.pnpm/engine.io@6.5.4/node_modules/engine.io/build/transports/websocket.js:95:29)
    at Socket.flush (/mswjs/interceptors/node_modules/.pnpm/engine.io@6.5.4/node_modules/engine.io/build/socket.js:417:28)
    at Socket.sendPacket (/mswjs/interceptors/node_modules/.pnpm/engine.io@6.5.4/node_modules/engine.io/build/socket.js:393:18)
    at Socket.onOpen (/mswjs/interceptors/node_modules/.pnpm/engine.io@6.5.4/node_modules/engine.io/build/socket.js:61:14)
    at new Socket (/mswjs/interceptors/node_modules/.pnpm/engine.io@6.5.4/node_modules/engine.io/build/socket.js:43:14)
    at Server.handshake (/mswjs/interceptors/node_modules/.pnpm/engine.io@6.5.4/node_modules/engine.io/build/server.js:301:24)
Trace: [ws] Sender.send: 40
    at Sender.send (/mswjs/interceptors/node_modules/.pnpm/ws@8.11.0/node_modules/ws/lib/sender.js:305:13)
    at WebSocket.send (/mswjs/interceptors/node_modules/.pnpm/ws@8.11.0/node_modules/ws/lib/websocket.js:468:18)
    at file:///mswjs/interceptors/node_modules/.pnpm/engine.io-client@6.5.3/node_modules/engine.io-client/build/esm-debug/transports/websocket.js:105:33
    at encodePacket (file:///mswjs/interceptors/node_modules/.pnpm/engine.io-parser@5.2.1/node_modules/engine.io-parser/build/esm/encodePacket.js:7:12)
    at WS.write (file:///mswjs/interceptors/node_modules/.pnpm/engine.io-client@6.5.3/node_modules/engine.io-client/build/esm-debug/transports/websocket.js:80:13)
    at WS.send (file:///mswjs/interceptors/node_modules/.pnpm/engine.io-client@6.5.3/node_modules/engine.io-client/build/esm-debug/transport.js:68:18)
    at Socket.flush (file:///mswjs/interceptors/node_modules/.pnpm/engine.io-client@6.5.3/node_modules/engine.io-client/build/esm-debug/socket.js:433:28)
    at Socket.sendPacket (file:///mswjs/interceptors/node_modules/.pnpm/engine.io-client@6.5.3/node_modules/engine.io-client/build/esm-debug/socket.js:516:14)
    at Socket.write (file:///mswjs/interceptors/node_modules/.pnpm/engine.io-client@6.5.3/node_modules/engine.io-client/build/esm-debug/socket.js:477:14)
    at Manager._packet (file:///mswjs/interceptors/node_modules/.pnpm/socket.io-client@4.7.4/node_modules/socket.io-client/build/esm-debug/manager.js:268:25)

stdout | modules/http/compliance/http-upgrade.test.ts > bypasses a WebSocket upgrade request
* completeUpdate true true
* writing socket headers: [
  'HTTP/1.1 101 Switching Protocols',
  'Upgrade: websocket',
  'Connection: Upgrade',
  'Sec-WebSocket-Accept: IF1pZBRQ+gapyXA79W80LpD9iMk='
]
[node] net.Socket.write: HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: IF1pZBRQ+gapyXA79W80LpD9iMk=


* UPGRADE DONE!
[node] net.Socket.write: �k
[node] net.Socket.write: 0{"sid":"6s27N0nXkYG7VT2fAAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}
[node] net.Socket.push: HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: IF1pZBRQ+gapyXA79W80LpD9iMk=

�k0{"sid":"6s27N0nXkYG7VT2fAAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000} 

[ws] WebSocket.socketOnData (socket.emit("data")): �k0{"sid":"6s27N0nXkYG7VT2fAAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}
[ws] Receiver._write �k0{"sid":"6s27N0nXkYG7VT2fAAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000}
0{"sid":"6s27N0nXkYG7VT2fAAAA","upgrades":[],"pingInterval":25000,"pingTimeout":20000,"maxPayload":1000000} (107)
[node] net.Socket.write: ��l��
[node] net.Socket.write: X*

stdout | modules/http/compliance/http-upgrade.test.ts > bypasses a WebSocket upgrade request
[node] net.Socket.push: ��l��X* 

[ws] WebSocket.socketOnData (socket.emit("data")): ��l��X*
[ws] Receiver._write ��l��X*
X* (2)
[node] net.Socket.write: � 
[node] net.Socket.write: 40{"sid":"BXcfd2meyC18HUhVAAAB"}
[node] net.Socket.push: � 40{"sid":"BXcfd2meyC18HUhVAAAB"} 

[ws] WebSocket.socketOnData (socket.emit("data")): � 40{"sid":"BXcfd2meyC18HUhVAAAB"}
[ws] Receiver._write � 40{"sid":"BXcfd2meyC18HUhVAAAB"}
40{"sid":"BXcfd2meyC18HUhVAAAB"} (32)

Trace: [ws] Sender.send: 40{"sid":"BXcfd2meyC18HUhVAAAB"}
    at Sender.send (/mswjs/interceptors/node_modules/.pnpm/ws@8.11.0/node_modules/ws/lib/sender.js:305:13)
    at WebSocket.send (/mswjs/interceptors/node_modules/.pnpm/ws@8.11.0/node_modules/ws/lib/websocket.js:468:18)
    at send (/mswjs/interceptors/node_modules/.pnpm/engine.io@6.5.4/node_modules/engine.io/build/transports/websocket.js:84:29)
    at Object.encodePacket (/mswjs/interceptors/node_modules/.pnpm/engine.io-parser@5.2.1/node_modules/engine.io-parser/build/cjs/encodePacket.js:10:12)
    at WebSocket.send (/mswjs/interceptors/node_modules/.pnpm/engine.io@6.5.4/node_modules/engine.io/build/transports/websocket.js:95:29)
    at Socket.flush (/mswjs/interceptors/node_modules/.pnpm/engine.io@6.5.4/node_modules/engine.io/build/socket.js:417:28)
    at Socket.sendPacket (/mswjs/interceptors/node_modules/.pnpm/engine.io@6.5.4/node_modules/engine.io/build/socket.js:393:18)
    at Socket.write (/mswjs/interceptors/node_modules/.pnpm/engine.io@6.5.4/node_modules/engine.io/build/socket.js:359:14)
    at Client.writeToEngine (/mswjs/interceptors/node_modules/.pnpm/socket.io@4.7.4/node_modules/socket.io/dist/client.js:171:23)
    at Client._packet (/mswjs/interceptors/node_modules/.pnpm/socket.io@4.7.4/node_modules/socket.io/dist/client.js:160:14)

@kettanaito
Copy link
Member Author

kettanaito commented Dec 2, 2024

Root cause and the solution

The root cause for this issue was that the mock socket never forwarded writes to the original socket. It only forwarded the initial writeBuffer that opens the connection, but nothing else.

The solution can be respect _write in the underlying MockSocket class during .write(), and then provide a custom ._write function on the MockHttpSocket in the passthrough case to forward writes to the original socket. That fixes the test! 🎉

Edit: The actual solution implemented writes forwarding a bit differently so it doesn't interfere with writing to a regular HTTP request.

@kettanaito kettanaito self-assigned this Dec 2, 2024
@kettanaito
Copy link
Member Author

Released: v0.37.3 🎉

This has been released in v0.37.3!

Make sure to always update to the latest version (npm i @mswjs/interceptors@latest) to get the newest features and bug fixes.


Predictable release automation by @ossjs/release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
help wanted Extra attention is needed
Projects
None yet
Development

Successfully merging a pull request may close this issue.

1 participant