diff --git a/CLAUDE.md b/CLAUDE.md index fda889d..b2fc4df 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,13 +9,19 @@ Task-based async (`Task`, `IAsyncEnumerable`, `Channel`) rather than the even .NET Socket.IO clients. Prefer an `await`-able or `await foreach`-able API over an `event` when adding surface area. The Engine.IO layer is implemented (HTTP polling transport, WebSocket transport with upgrade, plain-text and binary -packets). The Socket.IO layer on top of it — namespaces, payload send/receive — is not yet written; see the TODOs in -`README.md`. +packets). The Socket.IO layer on top of it is half written: `src/SocketIO.Client` encodes packets and sends them, but +nothing parses an inbound one yet, so namespaces and acknowledgements are still open. See the TODOs in `README.md`. Protocol references: - Engine.IO: https://socket.io/docs/v4/engine-io-protocol - Socket.IO: https://socket.io/docs/v4/socket-io-protocol +The reference implementation is in `node_modules` once `npm install` has run, and it is the arbiter when a question is +about what the wire actually accepts: `node_modules/socket.io-parser` decodes a string the C# side produced, which is a +faster and surer check than reading the spec. Its rules are stricter than they look — a binary packet must announce at +least one attachment, an event may not be named after a reserved one, and a decode error costs the whole connection +rather than the one packet. + ## Commands ```bash @@ -33,6 +39,7 @@ Running the sample end to end needs the Node test server, which lives in an npm ```bash npm install # once, from the repo root npm run start:server # Engine.IO server on http://127.0.0.1:9854 (scripts/run-server.sh wraps this) +npm run start:socket-server # Socket.IO server on http://127.0.0.1:9855 dotnet run --project samples/PingPong ``` @@ -78,9 +85,11 @@ values being the *ASCII byte* of the digit (`Open = 0x30`, i.e. `'0'`) so parsin - `.editorconfig` is authoritative and unusually strict: `end_of_line = crlf`, `insert_final_newline = false`, `var` is disallowed where the type is not apparent, and `using` groups are separated with `System` first. Run `dotnet format` rather than hand-matching it. -- `AssemblyInfo.cs` grants `InternalsVisibleTo("EngineIO.Client.Tests")`. Transports expose `internal` constructors that - accept an injected `HttpClient` purely so tests can supply a mocked `HttpMessageHandler` — follow that pattern for new - transports instead of adding public seams. +- `AssemblyInfo.cs` grants `InternalsVisibleTo` to the matching test project, and `EngineIO.Client` also grants it to + `SocketIO.Client` so the layer above can reach the same seam. `Engine`, `IO` and the transports expose `internal` + constructors that accept an injected `HttpClient` purely so tests can supply a stubbed handler — follow that pattern + instead of adding public seams. `EngineIO.Client.Tests` mocks the handler with Moq; `SocketIO.Client.Tests` takes no + such dependency and uses the hand-rolled `FakePollingServer`. - Logging is optional throughout: `ILoggerFactory` is nullable everywhere and callers may pass nothing. ## Other agent configs present diff --git a/README.md b/README.md index ab592f7..eba9b85 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,11 @@ The client implements `Engine.IO` and `Socket.IO` core protocols. - **Socket.IO Client** +- [x] Packet model and wire-format serialization (events, acks, binary attachments) +- [x] Send plain text, JSON and binary payloads +- [ ] Packet parsing (receiving) - [ ] Namespaces support -- [ ] Send and receive plain text and binary payloads +- [ ] Acknowledgement correlation ## Resources diff --git a/SocketIO.sln b/SocketIO.sln index aec678e..0397db5 100644 --- a/SocketIO.sln +++ b/SocketIO.sln @@ -15,6 +15,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{D8B5DE1E EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EngineIO.Client.Tests", "tests\EngineIO.Client.Tests\EngineIO.Client.Tests.csproj", "{5D824F05-5793-4768-99DF-E4D993F16B75}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SocketIO.Client", "src\SocketIO.Client\SocketIO.Client.csproj", "{222889F5-6A36-4A8A-8C78-A910E40D4F82}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SocketIO.Client.Tests", "tests\SocketIO.Client.Tests\SocketIO.Client.Tests.csproj", "{E0C636E7-91B0-44AE-9752-F08326C7C5F2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -36,10 +40,20 @@ Global {5D824F05-5793-4768-99DF-E4D993F16B75}.Debug|Any CPU.Build.0 = Debug|Any CPU {5D824F05-5793-4768-99DF-E4D993F16B75}.Release|Any CPU.ActiveCfg = Release|Any CPU {5D824F05-5793-4768-99DF-E4D993F16B75}.Release|Any CPU.Build.0 = Release|Any CPU + {222889F5-6A36-4A8A-8C78-A910E40D4F82}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {222889F5-6A36-4A8A-8C78-A910E40D4F82}.Debug|Any CPU.Build.0 = Debug|Any CPU + {222889F5-6A36-4A8A-8C78-A910E40D4F82}.Release|Any CPU.ActiveCfg = Release|Any CPU + {222889F5-6A36-4A8A-8C78-A910E40D4F82}.Release|Any CPU.Build.0 = Release|Any CPU + {E0C636E7-91B0-44AE-9752-F08326C7C5F2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E0C636E7-91B0-44AE-9752-F08326C7C5F2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E0C636E7-91B0-44AE-9752-F08326C7C5F2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E0C636E7-91B0-44AE-9752-F08326C7C5F2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {D1C745FE-52AF-4145-8243-9FCA91BE7916} = {80C612B0-98E1-4E6B-AF10-5BB3087F0864} {4F6177C5-3234-4897-B61C-49C44D3E2EF8} = {577735D4-BC7E-464A-BC9B-93AD8B5B6738} {5D824F05-5793-4768-99DF-E4D993F16B75} = {D8B5DE1E-C83F-40A9-9D27-D2FBB3866F0A} + {222889F5-6A36-4A8A-8C78-A910E40D4F82} = {577735D4-BC7E-464A-BC9B-93AD8B5B6738} + {E0C636E7-91B0-44AE-9752-F08326C7C5F2} = {D8B5DE1E-C83F-40A9-9D27-D2FBB3866F0A} EndGlobalSection EndGlobal diff --git a/package-lock.json b/package-lock.json index 5bed53d..29f1ccd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,40 +9,47 @@ "version": "1.0.0", "license": "ISC", "workspaces": [ - "samples/simple-engine-io-server" + "samples/simple-engine-io-server", + "samples/simple-socket-io-server" ] }, - "node_modules/simple-engine-io-server": { - "resolved": "samples/simple-engine-io-server", - "link": true + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" }, - "samples/simple-engine-io-server": { - "version": "1.0.0", - "license": "ISC", + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", "dependencies": { - "engine.io": "^6.5.4" + "@types/node": "*" } }, - "samples/simple-engine-io-server/node_modules/@types/cookie": { - "version": "0.4.1", - "license": "MIT" - }, - "samples/simple-engine-io-server/node_modules/@types/cors": { - "version": "2.8.17", + "node_modules/@types/node": { + "version": "26.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.5.0.tgz", + "integrity": "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A==", "license": "MIT", "dependencies": { - "@types/node": "*" + "undici-types": "~8.9.0" } }, - "samples/simple-engine-io-server/node_modules/@types/node": { - "version": "20.11.20", + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", "license": "MIT", "dependencies": { - "undici-types": "~5.26.4" + "@types/node": "*" } }, - "samples/simple-engine-io-server/node_modules/accepts": { + "node_modules/accepts": { "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", "license": "MIT", "dependencies": { "mime-types": "~2.1.34", @@ -52,22 +59,28 @@ "node": ">= 0.6" } }, - "samples/simple-engine-io-server/node_modules/base64id": { + "node_modules/base64id": { "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", "license": "MIT", "engines": { "node": "^4.5.0 || >= 5.9" } }, - "samples/simple-engine-io-server/node_modules/cookie": { - "version": "0.4.2", + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { "node": ">= 0.6" } }, - "samples/simple-engine-io-server/node_modules/cors": { - "version": "2.8.5", + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", "license": "MIT", "dependencies": { "object-assign": "^4", @@ -75,13 +88,19 @@ }, "engines": { "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, - "samples/simple-engine-io-server/node_modules/debug": { - "version": "4.3.4", + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -92,41 +111,48 @@ } } }, - "samples/simple-engine-io-server/node_modules/engine.io": { - "version": "6.5.4", + "node_modules/engine.io": { + "version": "6.6.10", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.10.tgz", + "integrity": "sha512-9/lX2bdlizlCXMHRMOIm03VBQHQYC7VvydcxtTAUJRxNW1QzM/2PMFSmr6h/lCiMHcyCP6abK+t9Q+j4vekk8Q==", "license": "MIT", "dependencies": { - "@types/cookie": "^0.4.1", "@types/cors": "^2.8.12", "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", "accepts": "~1.3.4", - "base64id": "2.0.0", - "cookie": "~0.4.1", + "cookie": "~0.7.2", "cors": "~2.8.5", - "debug": "~4.3.1", + "debug": "~4.4.1", "engine.io-parser": "~5.2.1", - "ws": "~8.11.0" + "ws": "~8.21.0" }, "engines": { "node": ">=10.2.0" } }, - "samples/simple-engine-io-server/node_modules/engine.io-parser": { - "version": "5.2.2", + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", "license": "MIT", "engines": { "node": ">=10.0.0" } }, - "samples/simple-engine-io-server/node_modules/mime-db": { + "node_modules/mime-db": { "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", "license": "MIT", "engines": { "node": ">= 0.6" } }, - "samples/simple-engine-io-server/node_modules/mime-types": { + "node_modules/mime-types": { "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", "license": "MIT", "dependencies": { "mime-db": "1.52.0" @@ -135,44 +161,105 @@ "node": ">= 0.6" } }, - "samples/simple-engine-io-server/node_modules/ms": { - "version": "2.1.2", + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "samples/simple-engine-io-server/node_modules/negotiator": { + "node_modules/negotiator": { "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", "license": "MIT", "engines": { "node": ">= 0.6" } }, - "samples/simple-engine-io-server/node_modules/object-assign": { + "node_modules/object-assign": { "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "samples/simple-engine-io-server/node_modules/undici-types": { - "version": "5.26.5", + "node_modules/simple-engine-io-server": { + "resolved": "samples/simple-engine-io-server", + "link": true + }, + "node_modules/simple-socket-io-server": { + "resolved": "samples/simple-socket-io-server", + "link": true + }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.8.tgz", + "integrity": "sha512-6Oy52pbg+kvdCVvjcN+FnY7BvxZ7cIHNScbvztT/It5d0vbwoJoVZmF2gjJmnV0/4WlXRfG15zc45ySk9Ah8bw==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.21.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.7.tgz", + "integrity": "sha512-IH/iSeO9T6gz1KkFleGDWkG9N3dl4jXVYUtMhIqH10Md0ttMer8nUNWiP1DKuNrybD2xBrixLJdCC9J6ECoYkg==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "license": "MIT" }, - "samples/simple-engine-io-server/node_modules/vary": { + "node_modules/vary": { "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", "license": "MIT", "engines": { "node": ">= 0.8" } }, - "samples/simple-engine-io-server/node_modules/ws": { - "version": "8.11.0", + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "license": "MIT", "engines": { "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -182,6 +269,20 @@ "optional": true } } + }, + "samples/simple-engine-io-server": { + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "engine.io": "^6.5.4" + } + }, + "samples/simple-socket-io-server": { + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "socket.io": "^4.8.1" + } } } } diff --git a/package.json b/package.json index 4ec4c88..9f42cec 100644 --- a/package.json +++ b/package.json @@ -4,11 +4,13 @@ "description": "Socket.IO .NET client samples test server and tools", "directories": {}, "scripts": { - "start:server": "npm start -w samples/simple-engine-io-server" + "start:server": "npm start -w samples/simple-engine-io-server", + "start:socket-server": "npm start -w samples/simple-socket-io-server" }, "author": "Redhouane Sobaihi", "license": "ISC", "workspaces": [ - "samples/simple-engine-io-server" + "samples/simple-engine-io-server", + "samples/simple-socket-io-server" ] } diff --git a/samples/simple-socket-io-server/index.js b/samples/simple-socket-io-server/index.js new file mode 100644 index 0000000..c0008d0 --- /dev/null +++ b/samples/simple-socket-io-server/index.js @@ -0,0 +1,17 @@ +const { Server } = require('socket.io'); + +const io = new Server({ + cors: { + origin: '*', + methods: ['GET', 'POST'] + }, +}); + +io.on('connection', (socket) => { + socket.on('message', (data, callback) => { + console.log(data); + callback?.apply(this, ['OK']); + }); +}); + +io.listen(Number(process.env.PORT) || 9855); diff --git a/samples/simple-socket-io-server/package.json b/samples/simple-socket-io-server/package.json new file mode 100644 index 0000000..dda9720 --- /dev/null +++ b/samples/simple-socket-io-server/package.json @@ -0,0 +1,15 @@ +{ + "name": "simple-socket-io-server", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "start": "DEBUG=engine,socket.io* node index.js" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "Socket.IO test server for the .NET client samples", + "dependencies": { + "socket.io": "^4.8.1" + } +} diff --git a/socketio-packet-parsing.md b/socketio-packet-parsing.md new file mode 100644 index 0000000..bbcdd13 --- /dev/null +++ b/socketio-packet-parsing.md @@ -0,0 +1,88 @@ +`Engine.ListenAsync` already hands you exactly the right input: `Message` packets in arrival order, each flagged `PlainText` or `Binary`. So the parser's input is a stream, not a buffer — and that's the one place the Engine.IO mirror breaks down. + +```mermaid +sequenceDiagram + participant E as Engine.ListenAsync + participant D as Decoder (stateful) + participant IO as IO.ListenAsync + E-->>D: Message "51-[\"blob\",{_placeholder,num:0}]" + Note over D: header parsed, 1 attachment owed → hold + E-->>D: Message + Note over D: owed 0 → complete + D-->>IO: Packet(BinaryEvent, "blob", [bytes]) +``` + +`Packet.TryParse` on the Engine.IO side is a cast and a slice because an Engine.IO packet is self-contained. A Socket.IO binary packet spans several of them, so the job splits in two: a **pure** `TryParse` for one text frame, and a **stateful** `Decoder` that owns the "waiting for N attachments" state. Putting the second inside `TryParse` is the trap — it would make a parse call order-dependent. + +```diff + src/SocketIO.Client/ + ├── IO.cs # ListenAsync stops throwing; drains Engine + routes + ├── Packets/ + │ ├── Packet.cs # + static TryParse, + read-side payload accessors + │ ├── PacketData.cs + │ └── PacketType.cs ++│ └── Decoder.cs # holds a binary header until its attachments land + └── Exceptions/ ++ └── PacketFormatException.cs +``` + +## The header scan + +Straight left-to-right, each part optional but strictly ordered — `[-][,][]`: + +```text +TryParse(bytes) -> bool + type = bytes[0] reject unless 0x30..0x36 + + if type is BinaryEvent or BinaryAck + read digits up to '-' reject if no '-' or count < 1 + attachments = count + + if next byte is '/' + read up to ',' or end of buffer namespace, else "/" + + if next byte is a digit + read digits ackId + + payload = rest empty for Connect/Disconnect +``` + +The `count < 1` rejection is the same rule that bit the encoder — worth mirroring so a malformed inbound frame fails here rather than three layers up. + +## The stateful half + +```text +Decoder.Add(enginePacket) -> Packet? + if packet is binary + if not reconstructing -> throw "binary with no header" + pending.Attach(bytes) + return pending.Complete ? Take() : null + + if reconstructing -> throw "header while reconstructing" + + if not Packet.TryParse(bytes, out p) -> throw + if p.Attachments == 0 -> return p + pending = p; return null +``` + +Both throws matter: they're precisely the two errors socket.io's own decoder raises, and they're what the send-side lock exists to avoid producing. Treat them the way the server does — fatal to the connection, not to the message. + +## The decision worth making first + +`Packet` is currently write-only: `List` accumulates items *to serialize*. Reading needs the inverse, and placeholders can't simply be substituted into JSON — bytes aren't JSON. So parse the payload array once into positional slots: + +```csharp +// read side, alongside the existing write side +private JsonDocument? _payload; // parsed once, owned by the packet +private readonly List _binarySlots = []; // arg index -> attachment index + +public string? Event { get; } // already there; args[0] for events +public int Count { get; } +public bool IsBinary(int index); +public T? GetItem(int index); // deserialize on demand +public ReadOnlyMemory GetAttachment(int index); +``` + +Deserializing lazily is what lets the packet exist before its attachments do — and it keeps the `JsonTypeInfo` seam (remaining item 9) a one-line change later instead of a rewrite. + +Two things I'd settle before writing code: whether `Packet` carries both directions or reading gets its own type (mirroring Engine.IO says one type, but that `Packet` is a 3-field struct, not this), and whether `IO.ListenAsync` filters by namespace in the enumerator or fans out to a channel per namespace — item 4 leans on whichever you pick. Want me to draft either? \ No newline at end of file diff --git a/src/EngineIO.Client/AssemblyInfo.cs b/src/EngineIO.Client/AssemblyInfo.cs index 8ac9362..e2be62a 100644 --- a/src/EngineIO.Client/AssemblyInfo.cs +++ b/src/EngineIO.Client/AssemblyInfo.cs @@ -1,3 +1,6 @@ using System.Runtime.CompilerServices; -[assembly: InternalsVisibleTo("EngineIO.Client.Tests")] \ No newline at end of file +[assembly: InternalsVisibleTo("EngineIO.Client.Tests")] + +// The Socket.IO layer needs the same stubbed-transport seam its tests do. +[assembly: InternalsVisibleTo("SocketIO.Client")] \ No newline at end of file diff --git a/src/EngineIO.Client/ClientOptions.cs b/src/EngineIO.Client/ClientOptions.cs index 789c965..f3b6347 100644 --- a/src/EngineIO.Client/ClientOptions.cs +++ b/src/EngineIO.Client/ClientOptions.cs @@ -1,3 +1,5 @@ +using EngineIO.Client.Transports; + namespace EngineIO.Client; public class ClientOptions @@ -7,6 +9,15 @@ public class ClientOptions /// public string BaseAddress { get; set; } = null!; + /// + /// Path the Engine.io endpoint is served from. + /// + /// + /// A Socket.IO server carries Engine.io under its own path — "/socket.io" by + /// default — so the client has to be told where to look. + /// + public string Path { get; set; } = TransportPath.Default; + /// /// Flag indicating whether client should automatically update from HTTP polling to websocket transport. /// diff --git a/src/EngineIO.Client/Engine.cs b/src/EngineIO.Client/Engine.cs index f7876d2..fa90804 100644 --- a/src/EngineIO.Client/Engine.cs +++ b/src/EngineIO.Client/Engine.cs @@ -27,8 +27,14 @@ public sealed class Engine : IDisposable, IAsyncDisposable private readonly HttpClient? _httpClient; private readonly IWebSocket? _webSocket; private readonly ILogger? _logger; - private readonly Channel _packetsChannel = Channel.CreateUnbounded(); - private readonly CancellationTokenSource _pollingCancellationTokenSource = new(); + + /// + /// Replaced on a retry: a failed attempt completes the stream and cancels + /// polling, so reusing either would end the next connection before it began. + /// + private Channel _packetsChannel = Channel.CreateUnbounded(); + + private CancellationTokenSource _pollingCancellationTokenSource = new(); /// /// How long the connection may go without a server ping before it is @@ -82,7 +88,22 @@ internal Engine(Action configure, HttpClient httpClient, _webSocket = webSocket; } - public bool Connected => _transport.Connected; + /// + /// Whether a transport is currently connected. False before + /// has succeeded, and false again once the + /// connection has gone away. + /// + public bool Connected => _transport?.Connected ?? false; + + /// + /// Why the last connection attempt failed, or null if none has. + /// + /// + /// reports failure through the packet stream rather + /// than by throwing, so a caller that does not listen — a protocol layered on + /// top, deciding whether it may send — has no other way to see the reason. + /// + public Exception? ConnectionError { get; private set; } /// /// Name of the transport currently in use, so tests can tell whether the @@ -142,9 +163,11 @@ private void DisposeCore() public async Task ConnectAsync(CancellationToken cancellationToken = default) { + ResetPreviousConnection(); + _transport = _httpTransport = _httpClient is null - ? new HttpPollingTransport(_clientOptions.BaseAddress) - : new HttpPollingTransport(_httpClient); + ? new HttpPollingTransport(_clientOptions.BaseAddress, _clientOptions.Path) + : new HttpPollingTransport(_httpClient, _clientOptions.Path); try { await _httpTransport.ConnectAsync(cancellationToken).ConfigureAwait(false); @@ -157,17 +180,24 @@ public async Task ConnectAsync(CancellationToken cancellationToken = default) if (_clientOptions.AutoUpgrade && _httpTransport.Upgrades!.Contains("websocket")) { + // The upgrade is an optimisation, not a requirement: a probe the server + // never answers leaves the polling transport connected and in charge, so + // it is repointed only once the WebSocket has taken over. + WebSocketTransport? wsTransport = null; try { - _transport = _wsTransport = _webSocket is null - ? new WebSocketTransport(_clientOptions.BaseAddress, _httpTransport.Sid!) - : new WebSocketTransport(_webSocket, _clientOptions.BaseAddress, _httpTransport.Sid!); - await _wsTransport.ConnectAsync(cancellationToken).ConfigureAwait(false); + wsTransport = _webSocket is null + ? new WebSocketTransport(_clientOptions.BaseAddress, _httpTransport.Sid!, _clientOptions.Path) + : new WebSocketTransport(_webSocket, _clientOptions.BaseAddress, _httpTransport.Sid!, + _clientOptions.Path); + await wsTransport.ConnectAsync(cancellationToken).ConfigureAwait(false); + + _transport = _wsTransport = wsTransport; } catch (Exception exception) { - HandleException(exception); - return; + _logger?.LogWarning(exception, "Upgrade to websocket failed; staying on HTTP long-polling."); + wsTransport?.Dispose(); } } @@ -257,9 +287,34 @@ private void ResetHeartbeat() _heartbeatCts?.CancelAfter(_heartbeatTimeoutMs); } + /// + /// Give a reconnection the clean state it needs. + /// + /// + /// Whatever ended the last connection — a handshake that failed, a close the + /// server sent, a heartbeat that ran out — cancelled polling and completed the + /// packet stream. Both are one-shot, so a second + /// would otherwise hand back a connection whose receive loop exits immediately. + /// + private void ResetPreviousConnection() + { + // Nothing to reset before the first attempt, and nothing to reset while a + // connection is still up. + if (_transport is null || Connected) + { + return; + } + + ConnectionError = null; + _pollingCancellationTokenSource.Dispose(); + _pollingCancellationTokenSource = new CancellationTokenSource(); + _packetsChannel = Channel.CreateUnbounded(); + } + private void HandleException(Exception exception) { _logger?.LogError(exception, exception.Message); + ConnectionError = exception; // End the stream with the reason it ended. A listener can then tell a // connection that died from one the server closed by agreement, which @@ -310,6 +365,21 @@ public async IAsyncEnumerable ListenAsync([EnumeratorCancellation] Cance } } + /// + /// Send a packet as it stands, leaving its framing to the current transport. + /// + /// + /// A protocol layered on top of Engine.io does its own encoding and has to say + /// which Engine.io packet carries the result — a decision the text and binary + /// overloads make on the caller's behalf. + /// + /// Packet to send + /// + public async Task SendAsync(Packet packet, CancellationToken cancellationToken = default) + { + await _transport.SendAsync(packet, cancellationToken).ConfigureAwait(false); + } + /// /// Send plain text message. /// diff --git a/src/EngineIO.Client/Packets/Packet.cs b/src/EngineIO.Client/Packets/Packet.cs index 0332d49..69bb75d 100644 --- a/src/EngineIO.Client/Packets/Packet.cs +++ b/src/EngineIO.Client/Packets/Packet.cs @@ -57,6 +57,19 @@ public static Packet CreateMessagePacket(string text) return new Packet(PacketFormat.PlainText, PacketType.Message, body); } + /// + /// Wrap an already UTF-8 encoded body in a plain-text message packet. + /// + /// + /// A protocol layered on top of Engine.io — Socket.IO — encodes straight to + /// bytes, so routing it through + /// would transcode the same payload twice for nothing. + /// + public static Packet CreateMessagePacket(ReadOnlyMemory body) + { + return new Packet(PacketFormat.PlainText, PacketType.Message, body); + } + public static Packet CreateBinaryPacket(ReadOnlyMemory body) { return new Packet(PacketFormat.Binary, PacketType.Message, body); diff --git a/src/EngineIO.Client/Transports/HttpPollingTransport.cs b/src/EngineIO.Client/Transports/HttpPollingTransport.cs index ca07dcb..8f31521 100644 --- a/src/EngineIO.Client/Transports/HttpPollingTransport.cs +++ b/src/EngineIO.Client/Transports/HttpPollingTransport.cs @@ -42,17 +42,17 @@ public sealed class HttpPollingTransport : ITransport, IDisposable /// private Uri _requestUri = null!; - public HttpPollingTransport(string baseAddress) + public HttpPollingTransport(string baseAddress, string path = TransportPath.Default) { _httpClient = new HttpClient(); _httpClient.BaseAddress = new Uri(baseAddress); - SetPath($"/engine.io?EIO={_protocol}&transport={Name}"); + SetPath($"{TransportPath.Normalize(path)}?EIO={_protocol}&transport={Name}"); } - internal HttpPollingTransport(HttpClient httpClient) + internal HttpPollingTransport(HttpClient httpClient, string path = TransportPath.Default) { _httpClient = httpClient; - SetPath($"/engine.io?EIO={_protocol}&transport={Name}"); + SetPath($"{TransportPath.Normalize(path)}?EIO={_protocol}&transport={Name}"); } public string Path => _path; diff --git a/src/EngineIO.Client/Transports/TransportPath.cs b/src/EngineIO.Client/Transports/TransportPath.cs new file mode 100644 index 0000000..23131b7 --- /dev/null +++ b/src/EngineIO.Client/Transports/TransportPath.cs @@ -0,0 +1,31 @@ +using System; + +namespace EngineIO.Client.Transports; + +/// +/// The path an Engine.IO endpoint is served from. +/// +internal static class TransportPath +{ + /// + /// Where a bare Engine.IO server listens. A Socket.IO server speaks the same + /// protocol but serves it from "/socket.io" instead, which is why the path is + /// configurable at all. + /// + public const string Default = "/engine.io"; + + /// + /// Bring a caller-supplied path to the form a server actually matches on: a + /// leading and a trailing slash, with the query string appended straight after. + /// + /// + /// The trailing slash is not cosmetic. Both servers normalize their configured + /// path the same way and compare it against the start of the request, so a + /// Socket.IO server answers "/socket.io/?EIO=4" and 404s "/socket.io?EIO=4". + /// + public static string Normalize(string? path) + { + var trimmed = (path ?? Default).Trim().Trim('/'); + return trimmed.Length == 0 ? "/" : $"/{trimmed}/"; + } +} \ No newline at end of file diff --git a/src/EngineIO.Client/Transports/WebSocketTransport.cs b/src/EngineIO.Client/Transports/WebSocketTransport.cs index 5f16d03..b6dded4 100644 --- a/src/EngineIO.Client/Transports/WebSocketTransport.cs +++ b/src/EngineIO.Client/Transports/WebSocketTransport.cs @@ -33,12 +33,13 @@ public sealed class WebSocketTransport : ITransport, IDisposable private bool _connected; - public WebSocketTransport(string baseAddress, string sid) - : this(new ClientWebSocketAdapter(), baseAddress, sid) + public WebSocketTransport(string baseAddress, string sid, string path = TransportPath.Default) + : this(new ClientWebSocketAdapter(), baseAddress, sid, path) { } - internal WebSocketTransport(IWebSocket client, string baseAddress, string sid) + internal WebSocketTransport(IWebSocket client, string baseAddress, string sid, + string path = TransportPath.Default) { if (string.IsNullOrEmpty(baseAddress)) { @@ -62,7 +63,11 @@ internal WebSocketTransport(IWebSocket client, string baseAddress, string sid) baseAddress = baseAddress.Replace("https://", "wss://"); } - var uri = $"{baseAddress}/engine.io?EIO={_protocol}&transport={Name}&sid={sid}"; + // The normalized path already opens with a slash, so one left on the base + // address would produce "//engine.io/", which a server matching on the start + // of the request path does not recognise. + var uri = $"{baseAddress.TrimEnd('/')}{TransportPath.Normalize(path)}" + + $"?EIO={_protocol}&transport={Name}&sid={sid}"; _uri = new Uri(uri); } diff --git a/src/SocketIO.Client/AssemblyInfo.cs b/src/SocketIO.Client/AssemblyInfo.cs new file mode 100644 index 0000000..ad574f7 --- /dev/null +++ b/src/SocketIO.Client/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("SocketIO.Client.Tests")] \ No newline at end of file diff --git a/src/SocketIO.Client/Exceptions/IOConnectionException.cs b/src/SocketIO.Client/Exceptions/IOConnectionException.cs new file mode 100644 index 0000000..7c66f25 --- /dev/null +++ b/src/SocketIO.Client/Exceptions/IOConnectionException.cs @@ -0,0 +1,24 @@ +using System; + +namespace SocketIO.Client.Exceptions; + +/// +/// Thrown when an operation needs a connection that is not there. +/// +/// +/// Engine.io reports a failed handshake by completing its packet stream with the +/// reason rather than by throwing, so the reason is carried here as the inner +/// exception instead of being lost to a caller that only sends. +/// +public class IOConnectionException : Exception +{ + public IOConnectionException(string message) + : base(message) + { + } + + public IOConnectionException(string message, Exception? innerException) + : base(message, innerException) + { + } +} \ No newline at end of file diff --git a/src/SocketIO.Client/Exceptions/PacketFormatException.cs b/src/SocketIO.Client/Exceptions/PacketFormatException.cs new file mode 100644 index 0000000..6a91a84 --- /dev/null +++ b/src/SocketIO.Client/Exceptions/PacketFormatException.cs @@ -0,0 +1,19 @@ +using System; + +namespace SocketIO.Client.Exceptions; + +/// +/// Thrown when the bytes that arrived are not a packet the protocol allows. +/// +/// +/// Fatal to the connection rather than to the packet: a server answers a packet it +/// cannot decode by closing the transport, so a client that carried on after one +/// would be talking to nobody. +/// +public class PacketFormatException : Exception +{ + public PacketFormatException(string message) + : base(message) + { + } +} \ No newline at end of file diff --git a/src/SocketIO.Client/IO.cs b/src/SocketIO.Client/IO.cs new file mode 100644 index 0000000..1a88bc2 --- /dev/null +++ b/src/SocketIO.Client/IO.cs @@ -0,0 +1,403 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Net.Http; +using System.Threading; +using System.Threading.Channels; +using System.Threading.Tasks; + +using EngineIO.Client; + +using Microsoft.Extensions.Logging; + +using SocketIO.Client.Exceptions; +using SocketIO.Client.Packets; + +using EnginePacket = EngineIO.Client.Packets.Packet; + +namespace SocketIO.Client; + +/// +/// Socket.IO client, multiplexing namespaces over a single Engine.io connection. +/// +public sealed class IO : IAsyncDisposable +{ + /// + /// Path a Socket.IO server serves Engine.io from. + /// + public const string DefaultPath = "/socket.io"; + + private readonly Engine _client; + + /// + /// Map namespace with its corresponding sid. + /// + private readonly Dictionary _namespaces = new(); + + /// + /// Serializes connection attempts, so that two callers joining a namespace at + /// once open one Engine.io connection between them rather than one each. + /// + private readonly SemaphoreSlim _connectLock = new(1, 1); + + /// + /// Holds a packet and its attachments together on the wire. + /// + /// + /// A binary packet is several Engine.io packets that a decoder reads as one + /// run: the header, then exactly as many binary packets as it announced. + /// Another packet landing in the middle of that run is a protocol error, and + /// the transports only serialize sends one at a time — not in groups. + /// + private readonly SemaphoreSlim _sendLock = new(1, 1); + + /// + /// One queue per namespace being listened to, created the first time somebody + /// asks for it. + /// + /// + /// A single loop drains the Engine.io stream and fans out from it. Letting each + /// listener drain that stream itself would not work: it is one channel, so two + /// listeners would compete for packets and each would see roughly half of them. + /// Creating the queue on demand also means a listener that subscribed before + /// connecting misses nothing. + /// + private readonly ConcurrentDictionary> _listeners = new(); + + /// + /// Ends the receive loop when the client is disposed. + /// + private readonly CancellationTokenSource _receiveCancellation = new(); + + private readonly ILogger? _logger; + + /// + /// The loop that turns Engine.io messages into packets, started on connect. + /// + private Task? _receiveTask; + + /// + /// Set once the receive loop has ended, before any queue is completed, so that + /// a listener arriving afterwards can see that it has missed the stream. + /// + private volatile bool _receiveEnded; + + /// + /// Why the receive loop ended, if it ended badly. Written before + /// , which publishes it. + /// + private Exception? _receiveError; + + public IO(string baseAddress, string path = DefaultPath, ILoggerFactory? loggerFactory = null) + { + Path = path; + _logger = loggerFactory?.CreateLogger(); + + _client = new Engine(Configure(baseAddress, path), loggerFactory); + } + + /// + /// Drives the connection from a supplied , so the + /// protocol behaviour can be exercised against a stubbed server. + /// + internal IO(HttpClient httpClient, string baseAddress, string path = DefaultPath, + ILoggerFactory? loggerFactory = null) + { + Path = path; + _logger = loggerFactory?.CreateLogger(); + + _client = new Engine(Configure(baseAddress, path), httpClient, loggerFactory: loggerFactory); + } + + private static Action Configure(string baseAddress, string path) + { + return options => + { + options.BaseAddress = baseAddress; + options.Path = path; + options.AutoUpgrade = true; + // TODO: allow passing custom headers and queries + }; + } + + /// + /// Path the server is served from. + /// + public string Path { get; } + + /// + /// Whether the underlying Engine.io connection is established. + /// + public bool Connected => _client.Connected; + + public async ValueTask DisposeAsync() + { + // Stop receiving before the engine goes away, so the loop unwinds on its own + // token rather than on whatever the teardown happens to throw at it. + await _receiveCancellation.CancelAsync().ConfigureAwait(false); + + if (_receiveTask is not null) + { + await _receiveTask.ConfigureAwait(false); + } + + await _client.DisposeAsync().ConfigureAwait(false); + + _receiveCancellation.Dispose(); + _connectLock.Dispose(); + _sendLock.Dispose(); + } + + /// + /// Connect to a namespace, opening the Engine.io connection if it is the first. + /// + /// Namespace to join, or the default one + /// + public async Task ConnectAsync(string? @namespace = default, CancellationToken cancellationToken = default) + { + // Built before the connection is touched, so a namespace the protocol refuses + // does not leave a connection open behind it. + var packet = new PacketBuilder(PacketType.Connect, @namespace); + + await _connectLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + if (!_client.Connected) + { + await _client.ConnectAsync(cancellationToken).ConfigureAwait(false); + + // Engine.io reports a failed handshake through its packet stream + // rather than by throwing, so a caller that only ever sends would + // otherwise go on writing to a transport that never came up. + if (!_client.Connected) + { + throw new IOConnectionException( + "The Engine.io connection could not be established.", _client.ConnectionError); + } + } + + // Restarted after a reconnection: the previous loop ended with the + // connection that fed it. + if (_receiveTask is null or { IsCompleted: true }) + { + _receiveTask = Task.Run(ReceiveAsync, CancellationToken.None); + } + } + finally + { + _connectLock.Release(); + } + + // TODO: the server answers with `0{"sid":"..."}` for the namespace, which is + // what _namespaces is waiting for. Recording it needs the inbound parser. + await SendPacketAsync(packet, cancellationToken).ConfigureAwait(false); + } + + /// + /// Leave a namespace. + /// + /// Namespace to leave, or the default one + /// + public async Task DisconnectAsync(string? @namespace = default, CancellationToken cancellationToken = default) + { + var packet = new PacketBuilder(PacketType.Disconnect, @namespace); + await SendPacketAsync(packet, cancellationToken).ConfigureAwait(false); + _namespaces.Remove(packet.Namespace); + } + + /// + /// Listen for incoming packets on a namespace. + /// + /// Namespace to listen on, or the default one + /// IAsyncEnumerable cancellation token + /// Packets + public IAsyncEnumerable ListenAsync( + string? @namespace = default, CancellationToken cancellationToken = default) + { + var queue = _listeners.GetOrAdd( + PacketBuilder.NormalizeNamespace(@namespace), _ => Channel.CreateUnbounded()); + + // The stream may already have ended — the client disposed, the connection + // gone. A queue created after that is one nothing will ever complete, so its + // listener would wait for a packet that cannot arrive. + if (_receiveEnded) + { + queue.Writer.TryComplete(_receiveError); + } + + return queue.Reader.ReadAllAsync(cancellationToken); + } + + /// + /// Drain the Engine.io message stream, decode it, and hand each packet to the + /// namespace that is listening for it. + /// + private async Task ReceiveAsync() + { + var decoder = new Decoder(); + + try + { + await foreach (var message in _client.ListenAsync(_receiveCancellation.Token).ConfigureAwait(false)) + { + var packet = decoder.Add(message); + + if (packet is not null) + { + Route(packet); + } + } + + EndListeners(null); + } + catch (OperationCanceledException) + { + // Shutting down through DisposeAsync is not a failure. + EndListeners(null); + } + catch (Exception exception) + { + _logger?.LogError(exception, "The receive loop ended."); + EndListeners(exception); + } + } + + /// + /// Split protocol concerns from consumer concerns, the way + /// Engine.PollAsync does for the heartbeat. + /// + private void Route(Packet packet) + { + // TODO: Connect carries the namespace's own sid, which is what _namespaces is + // waiting for; Disconnect ends it, and ConnectError has to reach whoever asked + // to join rather than being dropped here. + if (packet.Type is PacketType.Connect or PacketType.Disconnect or PacketType.ConnectError) + { + _logger?.LogDebug("Namespace {Namespace} sent {Type}, which is not routed yet.", + packet.Namespace, packet.Type); + return; + } + + if (_listeners.TryGetValue(packet.Namespace, out var queue)) + { + // The channel is unbounded, so this never fails or blocks. + queue.Writer.TryWrite(packet); + return; + } + + // Buffering for a namespace nobody listens to would grow without limit. + _logger?.LogDebug("Dropped a {Type} packet for {Namespace}, which has no listener.", + packet.Type, packet.Namespace); + } + + /// + /// End every listener's enumeration, with the reason if there was one. + /// + private void EndListeners(Exception? exception) + { + // Published before anything is completed, so a listener subscribing alongside + // this either is completed by the loop below or completes itself. Both may + // happen; completing a queue twice is harmless. + _receiveError = exception; + _receiveEnded = true; + + foreach (var queue in _listeners.Values) + { + queue.Writer.TryComplete(exception); + } + } + + /// + /// Send plain text data. + /// + /// Plain text data + /// Event name, or the default one + /// Namespace to send on, or the default one + /// + public Task SendAsync(string text, string? @event = default, string? @namespace = default, + CancellationToken cancellationToken = default) + { + var packet = new PacketBuilder(PacketType.Event, @namespace, @event); + packet.AddItem(text); + return SendPacketAsync(packet, cancellationToken); + } + + /// + /// Send a Json serializable POCO. + /// + /// Data instance + /// Event name, or the default one + /// Namespace to send on, or the default one + /// + /// Data type + public Task SendAsync(T data, string? @event = default, string? @namespace = default, + CancellationToken cancellationToken = default) where T : class + { + var packet = new PacketBuilder(PacketType.Event, @namespace, @event); + packet.AddItem(data); + return SendPacketAsync(packet, cancellationToken); + } + + /// + /// Send binary data. + /// + /// + /// Present so that a byte array reaches the binary overload rather than + /// , which would quietly encode it as a base64 string. + /// + /// Binary data + /// Event name, or the default one + /// Namespace to send on, or the default one + /// + public Task SendAsync(byte[] data, string? @event = default, string? @namespace = default, + CancellationToken cancellationToken = default) + { + return SendAsync(new ReadOnlyMemory(data), @event, @namespace, cancellationToken); + } + + /// + /// Send binary data. + /// + /// Binary data + /// Event name, or the default one + /// Namespace to send on, or the default one + /// + public Task SendAsync(ReadOnlyMemory data, string? @event = default, string? @namespace = default, + CancellationToken cancellationToken = default) + { + var packet = new PacketBuilder(PacketType.BinaryEvent, @namespace, @event); + packet.AddItem(data); + return SendPacketAsync(packet, cancellationToken); + } + + private async Task SendPacketAsync(PacketBuilder packet, CancellationToken cancellationToken) + { + if (!_client.Connected) + { + throw new IOConnectionException( + $"Not connected. Call {nameof(ConnectAsync)} before sending.", _client.ConnectionError); + } + + await _sendLock.WaitAsync(cancellationToken).ConfigureAwait(false); + + try + { + // The header travels as a plain-text Engine.io message; each attachment + // then follows as its own binary message, in the order its placeholder + // named it. Nothing may come between them. + await _client.SendAsync(EnginePacket.CreateMessagePacket(packet.Serialize()), cancellationToken) + .ConfigureAwait(false); + + foreach (var attachment in packet.Attachments) + { + await _client.SendAsync(EnginePacket.CreateBinaryPacket(attachment), cancellationToken) + .ConfigureAwait(false); + } + } + finally + { + _sendLock.Release(); + } + } +} \ No newline at end of file diff --git a/src/SocketIO.Client/Packets/Decoder.cs b/src/SocketIO.Client/Packets/Decoder.cs new file mode 100644 index 0000000..775bd3b --- /dev/null +++ b/src/SocketIO.Client/Packets/Decoder.cs @@ -0,0 +1,96 @@ +using System; + +using EngineIO.Client.Packets; + +using SocketIO.Client.Exceptions; + +using EnginePacket = EngineIO.Client.Packets.Packet; + +namespace SocketIO.Client.Packets; + +/// +/// Turns a stream of Engine.io messages into Socket.IO packets. +/// +/// +/// +/// A text packet is one Engine.io message and is done when it is parsed. A +/// binary one is a header followed by exactly as many binary messages as it +/// announced, so the decoder holds it back until they have all arrived. That +/// waiting is the whole reason this type exists rather than the work living in +/// , which stays a pure function of one buffer. +/// +/// +/// Both errors it raises are the ones the reference decoder raises, and they +/// mean the stream is no longer trustworthy: the run of messages that make up +/// a packet has been broken into. Neither is recoverable by skipping a packet. +/// +/// +internal sealed class Decoder +{ + /// + /// The header still owed attachments, if any. + /// + private Packet? _pending; + + /// + /// Whether a packet is part-way through arriving. + /// + public bool IsReconstructing => _pending is not null; + + /// + /// Take the next Engine.io message. + /// + /// An Engine.io message packet + /// + /// The packet, once it is whole, or null while one is still arriving. + /// + public Packet? Add(EnginePacket message) + { + return message.Format == PacketFormat.Binary + ? AddAttachment(message.Body) + : AddHeader(message.Body); + } + + private Packet? AddHeader(ReadOnlyMemory data) + { + if (_pending is not null) + { + throw new PacketFormatException( + "A packet header arrived while another was still waiting for its attachments."); + } + + if (!Packet.TryParse(data, out var packet)) + { + throw new PacketFormatException("The packet could not be decoded."); + } + + // A packet that announced no attachments is whole as soon as it is parsed, + // which is every packet that is not a binary one. + if (packet!.IsComplete) + { + return packet; + } + + _pending = packet; + return null; + } + + private Packet? AddAttachment(ReadOnlyMemory data) + { + if (_pending is null) + { + throw new PacketFormatException("A binary attachment arrived with no packet waiting for one."); + } + + _pending.Attach(data); + + if (!_pending.IsComplete) + { + return null; + } + + var completed = _pending; + _pending = null; + return completed; + } +} \ No newline at end of file diff --git a/src/SocketIO.Client/Packets/Packet.cs b/src/SocketIO.Client/Packets/Packet.cs new file mode 100644 index 0000000..d7e1a5e --- /dev/null +++ b/src/SocketIO.Client/Packets/Packet.cs @@ -0,0 +1,132 @@ +using System; + +namespace SocketIO.Client.Packets; + +/// +/// A Socket.IO packet that arrived from the server. +/// see: https://socket.io/docs/v4/socket-io-protocol +/// +/// +/// +/// The counterpart of , which accumulates the +/// arguments of a packet to send. Two types rather than one because the two +/// sets of operations are never both valid, and because a merged type would +/// carry half its fields dead on every instance. +/// +/// +/// The payload is kept as the bytes it arrived in and read only when an +/// argument is asked for, so a packet nobody inspects costs no more than its +/// header. Holding the memory is safe: both transports copy a message out of +/// their receive buffer before handing it up, so nothing else owns it. +/// +/// +/// Indices address the data arguments alone. An event names itself in the +/// first element of the payload array, and that element is surfaced as +/// rather than as argument zero. +/// +/// +public sealed class Packet +{ + /// + /// The namespace every connection starts in, and the one a packet belongs to + /// when its header names none. + /// + public const string DefaultNamespace = "/"; + + /// + /// Represents packet type. + /// + public PacketType Type => throw new NotImplementedException(); + + /// + /// Namespace this packet belongs to, always in its leading-slash form. + /// + public string Namespace => throw new NotImplementedException(); + + /// + /// Acknowledgement id this packet carries, when it takes part in one. + /// + /// + /// On an event it is the id the server expects an acknowledgement under; on an + /// acknowledgement it is the id of the event being answered. + /// + public int? AckId => throw new NotImplementedException(); + + /// + /// Event name for the types that carry one, otherwise null. + /// + public string? Event => throw new NotImplementedException(); + + /// + /// Number of data arguments, not counting the event name. + /// + public int Count => throw new NotImplementedException(); + + /// + /// Parse the text part of a packet: everything up to and including the JSON + /// payload, but not the binary attachments it may announce. + /// + /// + /// Deliberately unaware of attachments, so that parsing stays a pure function + /// of one buffer. Reassembling a packet that spans several Engine.io messages + /// is the 's job, and keeping it there is what stops a + /// parse call from depending on the order it was made in. + /// + /// The packet as it came off the wire + /// Parsed packet instance + /// Boolean indicating success or failure of parse operation + public static bool TryParse(ReadOnlyMemory data, out Packet? packet) + { + throw new NotImplementedException(); + } + + /// + /// Whether the argument at arrived as a binary + /// attachment rather than as a Json value. + /// + public bool IsBinary(int index) + { + throw new NotImplementedException(); + } + + /// + /// Deserialize the argument at . + /// + /// Type to deserialize the argument as + public T? GetItem(int index) + { + throw new NotImplementedException(); + } + + /// + /// The bytes of the argument at , which has to be one + /// reports as binary. + /// + public ReadOnlyMemory GetAttachment(int index) + { + throw new NotImplementedException(); + } + + /// + /// How many attachments the header announced. + /// + internal int AttachmentCount => throw new NotImplementedException(); + + /// + /// Whether every attachment the header announced has arrived. + /// + /// + /// True from the outset for a packet that announced none, which is every + /// packet that is not a binary one. + /// + internal bool IsComplete => throw new NotImplementedException(); + + /// + /// Take delivery of the next attachment, in the order the placeholders in the + /// payload refer to them. + /// + internal void Attach(ReadOnlyMemory attachment) + { + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/src/SocketIO.Client/Packets/PacketBuilder.cs b/src/SocketIO.Client/Packets/PacketBuilder.cs new file mode 100644 index 0000000..b974fb5 --- /dev/null +++ b/src/SocketIO.Client/Packets/PacketBuilder.cs @@ -0,0 +1,371 @@ +using System; +using System.Buffers; +using System.Collections.Generic; +using System.Text; +using System.Text.Json; + +namespace SocketIO.Client.Packets; + +/// +/// Builds a Socket.IO packet to send. see: https://socket.io/docs/v4/socket-io-protocol +/// +/// +/// +/// The wire format is a header followed by a JSON payload: +/// <type>[<# of binary attachments>-][<namespace>,][<ack id>]<JSON payload>. +/// +/// +/// Binary arguments never appear in that JSON. Each leaves a placeholder behind +/// and travels as its own packet after the header — see . +/// +/// +/// The counterpart of , which reads the arguments that +/// arrived. Two types rather than one because the two sets of operations are +/// never both valid: there is nothing to read on a packet being built, and +/// nothing to add to one that came off the wire. +/// +/// +public sealed class PacketBuilder +{ + /// + /// The event name used when the caller does not name one. + /// + public const string DefaultEventName = "message"; + + public static readonly PacketBuilder Connect = new(PacketType.Connect); + + public static readonly PacketBuilder Disconnect = new(PacketType.Disconnect); + + private readonly List _data = new(); + + private readonly List> _attachments = new(); + + public PacketBuilder(PacketType type) + : this(type, null, null, null) + { + } + + public PacketBuilder(PacketType type, string? @namespace) + : this(type, @namespace, null, null) + { + } + + public PacketBuilder(PacketType type, string? @namespace, string? @event) + : this(type, @namespace, @event, null) + { + } + + public PacketBuilder(PacketType type, int ackId, string? @namespace, string? @event) + : this(type, @namespace, @event, ackId) + { + } + + /// + /// The one constructor that validates, so that no combination reaches the + /// wire without having been checked. + /// + private PacketBuilder(PacketType type, string? @namespace, string? @event, int? ackId) + { + if (!Enum.IsDefined(type)) + { + throw new ArgumentOutOfRangeException(nameof(type), type, "Unknown packet type."); + } + + if (@event is not null && !type.CarriesEventName()) + { + throw new ArgumentException($"A {type} packet does not carry an event name.", nameof(@event)); + } + + if (ackId.HasValue && !type.CarriesAckId()) + { + throw new ArgumentException($"A {type} packet cannot carry an acknowledgement id.", nameof(type)); + } + + if (ackId is < 0) + { + throw new ArgumentOutOfRangeException(nameof(ackId), ackId, + "An acknowledgement id is a non-negative number; a sign would be read as the start of the payload."); + } + + // An acknowledgement that names no id answers nothing: the server looks the id + // up among the callbacks it is waiting on and discards the packet when it is + // missing, so it is refused here rather than sent into the void. + if (!ackId.HasValue && type is PacketType.Ack or PacketType.BinaryAck) + { + throw new ArgumentException($"A {type} packet has to name the acknowledgement it answers.", + nameof(ackId)); + } + + Type = type; + Namespace = NormalizeNamespace(@namespace); + AckId = ackId; + + if (!type.CarriesEventName()) + { + return; + } + + // The event name is not header material: it is the first argument of the + // payload array, which is why it is seeded as an item like any other. + Event = @event ?? DefaultEventName; + + if (IsReservedEventName(Event)) + { + throw new ArgumentException( + $"\"{Event}\" is reserved by the protocol; a packet naming it is rejected by the server.", + nameof(@event)); + } + + _data.Add(new TextPacketData(Event)); + } + + /// + /// Represents packet type. + /// + public PacketType Type { get; } + + /// + /// Namespace this packet belongs to, always in its leading-slash form. + /// + public string Namespace { get; } + + /// + /// Acknowledgement id correlating an event with its acknowledgement, when the + /// packet takes part in one. + /// + public int? AckId { get; } + + /// + /// Event name for the types that carry one, otherwise null. + /// + /// + /// An acknowledgement answers an event rather than naming one, so its payload + /// holds the response arguments alone. + /// + public string? Event { get; } + + /// + /// The binary arguments, in the order their placeholders reference them. Each + /// is sent as a separate binary packet after this one. + /// + public IReadOnlyList> Attachments => _attachments; + + /// + /// Add plain text data to packet. + /// + /// Plain text data + public void AddItem(string data) + { + AddPacketData(new TextPacketData(data)); + } + + /// + /// Add Json serializable POCO. + /// + /// Data instance + /// Data type + public void AddItem(T data) where T : class + { + AddPacketData(new JsonPacketData(data)); + } + + /// + /// Add binary data. + /// + /// + /// Present so that a byte array reaches the binary overload rather than + /// , which would quietly encode it as a base64 string. + /// + /// Binary data + public void AddItem(byte[] data) + { + AddItem(new ReadOnlyMemory(data)); + } + + /// + /// Add binary data. + /// + /// Binary data + public void AddItem(ReadOnlyMemory data) + { + if (!Type.CarriesAttachments()) + { + throw new InvalidOperationException( + $"A {Type} packet cannot carry binary data; use {nameof(PacketType.BinaryEvent)} " + + $"or {nameof(PacketType.BinaryAck)}."); + } + + AddPacketData(new BinaryPacketData(_attachments.Count, data)); + _attachments.Add(data); + } + + /// + /// Append an argument to the payload. + /// + /// + /// Named apart from the public AddItem overloads on purpose: an + /// is a class, so an overload by that name would + /// bind to and recurse into itself. + /// + private void AddPacketData(IPacketData data) + { + if (Type is PacketType.Connect or PacketType.Disconnect) + { + throw new InvalidOperationException($"A {Type} packet does not carry a payload."); + } + + _data.Add(data); + } + + /// + /// Serialize the packet header and payload to their wire representation. + /// + /// + /// The result is the text part only. Anything in + /// follows it as separate packets. + /// + /// The encoded packet + internal ReadOnlyMemory Serialize() + { + var buffer = new ArrayBufferWriter(); + Serialize(buffer); + return buffer.WrittenMemory; + } + + /// + /// Serialize the packet into a caller-owned buffer. + /// + /// + /// Writing rather than returning keeps the packet stateless, so the same + /// instance — among them — can be sent repeatedly. + /// + internal void Serialize(IBufferWriter writer) + { + // A decoder reads the announced count and refuses anything below one, so a + // binary packet with nothing attached is not an empty packet — it is one the + // server drops the connection over. It is caught here rather than in the + // constructor because the attachments arrive after it. + if (Type.CarriesAttachments() && _attachments.Count == 0) + { + throw new InvalidOperationException( + $"A {Type} packet has to carry at least one binary argument; " + + $"use {nameof(PacketType.Event)} or {nameof(PacketType.Ack)} for a payload that has none."); + } + + WriteHeader(writer); + WritePayload(writer); + } + + private void WriteHeader(IBufferWriter writer) + { + WriteByte(writer, (byte)Type); + + // The count and its dash are what mark a type 5 or 6 packet as binary, so they + // are written for the type rather than for the attachments happening to be + // there — Serialize has already refused the packet if they are not. + if (Type.CarriesAttachments()) + { + WriteInt32(writer, _attachments.Count); + WriteByte(writer, (byte)'-'); + } + + // The default namespace is implied by its absence. + if (!string.Equals(Namespace, Packet.DefaultNamespace, StringComparison.Ordinal)) + { + var length = Encoding.UTF8.GetByteCount(Namespace); + var span = writer.GetSpan(length + 1); + Encoding.UTF8.GetBytes(Namespace, span); + span[length] = (byte)','; + writer.Advance(length + 1); + } + + if (AckId.HasValue) + { + WriteInt32(writer, AckId.Value); + } + } + + private void WritePayload(IBufferWriter writer) + { + if (Type is PacketType.Connect or PacketType.Disconnect) + { + return; + } + + using var json = new Utf8JsonWriter(writer); + + // CONNECT_ERROR is the one payload that is not an argument list: it is the + // error object on its own. + if (Type == PacketType.ConnectError) + { + if (_data.Count > 0) + { + _data[0].Serialize(json); + } + + json.Flush(); + return; + } + + json.WriteStartArray(); + + foreach (var item in _data) + { + item.Serialize(json); + } + + json.WriteEndArray(); + json.Flush(); + } + + private static void WriteByte(IBufferWriter writer, byte value) + { + writer.GetSpan(1)[0] = value; + writer.Advance(1); + } + + private static void WriteInt32(IBufferWriter writer, int value) + { + // Room for every digit an int can produce, sign included. + var span = writer.GetSpan(11); + value.TryFormat(span, out var written); + writer.Advance(written); + } + + /// + /// Bring a namespace to the single form the wire uses, so that "admin", + /// "/admin" and a missing namespace do not encode three different ways. + /// + internal static string NormalizeNamespace(string? @namespace) + { + if (string.IsNullOrWhiteSpace(@namespace)) + { + return Packet.DefaultNamespace; + } + + var trimmed = @namespace!.Trim(); + + // The comma is what ends the namespace in the header, so one inside it would + // truncate the name and leave the remainder to be parsed as the payload. + if (trimmed.Contains(',')) + { + throw new ArgumentException("A namespace cannot contain a comma; it is the header's separator.", + nameof(@namespace)); + } + + return trimmed.StartsWith('/') ? trimmed : "/" + trimmed; + } + + /// + /// Whether an event name is one the protocol keeps for itself. + /// + /// + /// A server rejects a packet whose first argument is one of these, and a + /// rejected packet costs the whole connection rather than just the message. + /// + private static bool IsReservedEventName(string @event) + { + return @event is "connect" or "connect_error" or "disconnect" or "disconnecting" + or "newListener" or "removeListener"; + } +} \ No newline at end of file diff --git a/src/SocketIO.Client/Packets/PacketData.cs b/src/SocketIO.Client/Packets/PacketData.cs new file mode 100644 index 0000000..aa05329 --- /dev/null +++ b/src/SocketIO.Client/Packets/PacketData.cs @@ -0,0 +1,87 @@ +using System; +using System.Text.Json; + +namespace SocketIO.Client.Packets; + +/// +/// One argument of a packet payload, able to write itself into the payload array. +/// +/// +/// The payload is a JSON array of mixed arguments, so each argument owns how it is +/// written rather than the packet switching over shapes it does not know about. +/// +internal interface IPacketData +{ + void Serialize(Utf8JsonWriter writer); +} + +/// +/// A plain text argument. Also carries the event name, which is nothing more than +/// the first argument of an event payload. +/// +internal sealed class TextPacketData : IPacketData +{ + public TextPacketData(string data) + { + Data = data; + } + + public string Data { get; } + + public void Serialize(Utf8JsonWriter writer) + { + writer.WriteStringValue(Data); + } +} + +/// +/// An argument serialized from a POCO. +/// +internal sealed class JsonPacketData : IPacketData where T : class +{ + public JsonPacketData(T data) + { + Data = data; + } + + public T Data { get; } + + public void Serialize(Utf8JsonWriter writer) + { + // TODO: take a JsonTypeInfo so callers can supply a source-generated + // context, the way the Engine.io handshake already does. + JsonSerializer.Serialize(writer, Data); + } +} + +/// +/// The placeholder a binary argument leaves behind in the payload. +/// +/// +/// Binary never travels inside the JSON: the payload holds +/// {"_placeholder":true,"num":N} and the bytes follow the header as the +/// Nth separate binary packet. +/// +internal sealed class BinaryPacketData : IPacketData +{ + public BinaryPacketData(int id, ReadOnlyMemory data) + { + Id = id; + Data = data; + } + + public ReadOnlyMemory Data { get; } + + /// + /// Position of this attachment among the packet's binary arguments. + /// + public int Id { get; } + + public void Serialize(Utf8JsonWriter writer) + { + writer.WriteStartObject(); + writer.WriteBoolean("_placeholder", true); + writer.WriteNumber("num", Id); + writer.WriteEndObject(); + } +} \ No newline at end of file diff --git a/src/SocketIO.Client/Packets/PacketType.cs b/src/SocketIO.Client/Packets/PacketType.cs new file mode 100644 index 0000000..99186f8 --- /dev/null +++ b/src/SocketIO.Client/Packets/PacketType.cs @@ -0,0 +1,81 @@ +namespace SocketIO.Client.Packets; + +/// +/// Represent Socket.IO protocol packet types. see: https://socket.io/docs/v4/socket-io-protocol +/// +/// +/// As with , each value is the +/// ASCII byte of the digit it is written as on the wire, so encoding a type is a +/// cast rather than a lookup. +/// +public enum PacketType : byte +{ + /// + /// Connect packet type. + /// + Connect = 0x30, + + /// + /// Disconnect packet type. + /// + Disconnect = 0x31, + + /// + /// Event packet type with plain text/JSON data. + /// + Event = 0x32, + + /// + /// Acknowledgement packet type with plain text/JSON data. + /// + Ack = 0x33, + + /// + /// Connection error packet type. + /// + ConnectError = 0x34, + + /// + /// Event packet type with binary data. + /// + BinaryEvent = 0x35, + + /// + /// Acknowledgement packet type with binary data. + /// + BinaryAck = 0x36 +} + +/// +/// What each packet type is allowed to carry. +/// +/// +/// The rules belong to the type rather than to either packet class, so that the +/// builder and the parser cannot drift apart on what a header may hold. +/// +internal static class PacketTypeExtensions +{ + /// + /// Whether the first payload argument is an event name. + /// + public static bool CarriesEventName(this PacketType type) + { + return type is PacketType.Event or PacketType.BinaryEvent; + } + + /// + /// Whether the header may hold an acknowledgement id. + /// + public static bool CarriesAckId(this PacketType type) + { + return type is PacketType.Event or PacketType.Ack or PacketType.BinaryEvent or PacketType.BinaryAck; + } + + /// + /// Whether the packet announces attachments and is followed by them. + /// + public static bool CarriesAttachments(this PacketType type) + { + return type is PacketType.BinaryEvent or PacketType.BinaryAck; + } +} \ No newline at end of file diff --git a/src/SocketIO.Client/SocketIO.Client.csproj b/src/SocketIO.Client/SocketIO.Client.csproj new file mode 100644 index 0000000..f9ae9c2 --- /dev/null +++ b/src/SocketIO.Client/SocketIO.Client.csproj @@ -0,0 +1,11 @@ + + + + net10.0 + + + + + + + \ No newline at end of file diff --git a/tests/EngineIO.Client.Tests/EngineTests.cs b/tests/EngineIO.Client.Tests/EngineTests.cs index 78cddb4..6570c05 100644 --- a/tests/EngineIO.Client.Tests/EngineTests.cs +++ b/tests/EngineIO.Client.Tests/EngineTests.cs @@ -192,6 +192,81 @@ async Task Should_Send_Over_The_Websocket_After_Upgrading() await engine.DisconnectAsync(); } + [Fact] + void Connected_Should_Be_False_Before_Connecting() + { + var (engine, _) = CreateEngine(Handshake()); + + Assert.False(engine.Connected); + Assert.Null(engine.ConnectionError); + } + + [Fact(DisplayName = "A handshake that fails is reported rather than thrown")] + async Task ConnectionError_Should_Report_Why_The_Handshake_Failed() + { + var (engine, _) = CreateEngine(Packet("4Hello")); + + await engine.ConnectAsync(); + + Assert.False(engine.Connected); + var exception = Assert.IsType(engine.ConnectionError); + Assert.Equal(ErrorReason.InvalidPacket, exception.ErrorReason); + } + + [Fact(DisplayName = "A failed attempt does not poison the connection that follows")] + async Task Should_Connect_After_A_Failed_Attempt() + { + var (engine, _) = CreateEngine(Packet("4Hello"), Handshake(), Packet("4Hi"), Packet("1")); + + await engine.ConnectAsync(); + Assert.False(engine.Connected); + + await engine.ConnectAsync(); + + Assert.True(engine.Connected); + Assert.Null(engine.ConnectionError); + + // The first attempt completed the packet stream with its reason; a retry that + // reused it would hand back a connection that never delivers anything. + var received = await Drain(engine); + Assert.Equal("Hi", Encoding.UTF8.GetString(Assert.Single(received).Body.Span)); + } + + [Fact(DisplayName = "A connection the server closed can be opened again")] + async Task Should_Connect_Again_After_The_Server_Closed_The_Connection() + { + var (engine, _) = CreateEngine(Handshake(), Packet("1"), Handshake(), Packet("4Hi"), Packet("1")); + + await engine.ConnectAsync(); + await Drain(engine); + Assert.False(engine.Connected); + + await engine.ConnectAsync(); + + Assert.True(engine.Connected); + Assert.Equal("Hi", Encoding.UTF8.GetString(Assert.Single(await Drain(engine)).Body.Span)); + } + + [Fact(DisplayName = "An upgrade that fails leaves the polling transport in charge")] + async Task Should_Stay_On_Polling_When_The_Upgrade_Fails() + { + // A pong without the "probe" payload is an unrelated pong, so the upgrade is + // never acknowledged. + var socket = new FakeWebSocket(); + socket.QueueText("3"); + var (engine, _) = CreateEngine(socket, Handshake(), Packet("4Hello"), Packet("1")); + + await engine.ConnectAsync(); + + Assert.True(engine.Connected); + Assert.Equal("polling", engine.TransportName); + Assert.Null(engine.ConnectionError); + + // The upgrade is an optimisation; losing it must not cost the connection. + var received = await Drain(engine); + Assert.Equal("Hello", Encoding.UTF8.GetString(Assert.Single(received).Body.Span)); + } + private static (Engine Engine, List Requests) CreateEngine( FakeWebSocket socket, params byte[][] responses) { diff --git a/tests/EngineIO.Client.Tests/Transports/HttpPollingTransportTests.cs b/tests/EngineIO.Client.Tests/Transports/HttpPollingTransportTests.cs index 7bdc76a..4764729 100644 --- a/tests/EngineIO.Client.Tests/Transports/HttpPollingTransportTests.cs +++ b/tests/EngineIO.Client.Tests/Transports/HttpPollingTransportTests.cs @@ -15,7 +15,18 @@ public class HttpPollingTransportTests void Should_Create_Transport() { var transport = new HttpPollingTransport("http://127.0.0.1:3000"); - Assert.Equal($"/engine.io?EIO=4&transport=polling", transport.Path); + Assert.Equal($"/engine.io/?EIO=4&transport=polling", transport.Path); + } + + [Theory(DisplayName = "The endpoint is served from the configured path")] + [InlineData("/socket.io", "/socket.io/")] + [InlineData("socket.io", "/socket.io/")] + [InlineData("/socket.io/", "/socket.io/")] + [InlineData("", "/")] + void Should_Serve_From_The_Configured_Path(string path, string expectedPath) + { + var transport = new HttpPollingTransport("http://127.0.0.1:3000", path); + Assert.Equal($"{expectedPath}?EIO=4&transport=polling", transport.Path); } [Fact] @@ -101,7 +112,7 @@ async Task Should_Connect() Assert.Equal(pingInterval, transport.PingInterval); Assert.Equal(pingTimeout, transport.PingTimeout); Assert.Equal(upgrades, transport.Upgrades); - Assert.Equal($"/engine.io?EIO=4&transport=polling&sid={sid}", transport.Path); + Assert.Equal($"/engine.io/?EIO=4&transport=polling&sid={sid}", transport.Path); } [Theory(DisplayName = "Separators that enclose no payload are skipped")] diff --git a/tests/EngineIO.Client.Tests/Transports/WebSocketTransportTests.cs b/tests/EngineIO.Client.Tests/Transports/WebSocketTransportTests.cs index e67ba10..d5df949 100644 --- a/tests/EngineIO.Client.Tests/Transports/WebSocketTransportTests.cs +++ b/tests/EngineIO.Client.Tests/Transports/WebSocketTransportTests.cs @@ -19,10 +19,35 @@ void Should_Map_Http_Scheme_To_WebSocket_Scheme(string baseAddress, string expec using var transport = new WebSocketTransport(baseAddress, sid); Assert.Equal(expectedScheme, transport.Uri.Scheme); - Assert.Equal("/engine.io", transport.Uri.AbsolutePath); + Assert.Equal("/engine.io/", transport.Uri.AbsolutePath); Assert.Equal($"?EIO=4&transport=websocket&sid={sid}", transport.Uri.Query); } + [Theory(DisplayName = "The endpoint is served from the configured path")] + [InlineData("/socket.io", "/socket.io/")] + [InlineData("socket.io", "/socket.io/")] + [InlineData("/socket.io/", "/socket.io/")] + [InlineData("", "/")] + void Should_Serve_From_The_Configured_Path(string path, string expectedPath) + { + using var transport = new WebSocketTransport("http://example.com", "1NkM2QzZGMjEyMTIxCg", path); + + Assert.Equal(expectedPath, transport.Uri.AbsolutePath); + } + + [Theory(DisplayName = "A slash on the base address is not doubled by the path")] + [InlineData("http://example.com", "/socket.io/")] + [InlineData("http://example.com/", "/socket.io/")] + [InlineData("http://example.com//", "/socket.io/")] + void Should_Not_Double_The_Slash_Between_Base_Address_And_Path(string baseAddress, string expectedPath) + { + // A server matches on the start of the request path, so "//socket.io/" is a + // path it does not recognise rather than a tidier spelling of the same one. + using var transport = new WebSocketTransport(baseAddress, "1NkM2QzZGMjEyMTIxCg", "/socket.io"); + + Assert.Equal(expectedPath, transport.Uri.AbsolutePath); + } + [Theory(DisplayName = "Required constructor arguments are rejected when missing")] [InlineData(null, "1NkM2QzZGMjEyMTIxCg")] [InlineData("", "1NkM2QzZGMjEyMTIxCg")] diff --git a/tests/SocketIO.Client.Tests/FakePollingServer.cs b/tests/SocketIO.Client.Tests/FakePollingServer.cs new file mode 100644 index 0000000..d40546b --- /dev/null +++ b/tests/SocketIO.Client.Tests/FakePollingServer.cs @@ -0,0 +1,118 @@ +using System.Net; +using System.Text; + +namespace SocketIO.Client.Tests; + +/// +/// A request the fake server received. +/// +public sealed record CapturedRequest(HttpMethod Method, Uri Uri, byte[] Body) +{ + public string Text => Encoding.UTF8.GetString(Body); +} + +/// +/// Stands in for an Engine.io server over HTTP long-polling: answers each GET with +/// the next scripted payload, answers every POST the way the protocol requires, and +/// records every request in the order it arrived. +/// +/// +/// Hand-rolled rather than mocked because the ordering tests need the requests as a +/// sequence, and because this project takes no test-double dependency. +/// +public sealed class FakePollingServer : HttpMessageHandler +{ + private readonly List _requests = new(); + private readonly byte[][] _pollResponses; + private int _polls; + + public FakePollingServer(params byte[][] pollResponses) + { + _pollResponses = pollResponses; + } + + /// + /// How long a GET takes to answer, the way a real long poll does. Keeps the + /// receive loop from spinning while a test is doing something else. + /// + public TimeSpan PollDelay { get; init; } = TimeSpan.FromMilliseconds(20); + + /// + /// How long a POST takes. A send that overlaps another is what makes an + /// interleaving visible, so the ordering tests widen this window. + /// + public TimeSpan PostDelay { get; init; } = TimeSpan.Zero; + + public IReadOnlyList Requests + { + get + { + lock (_requests) + { + return _requests.ToArray(); + } + } + } + + /// + /// The bodies the client posted, in order. + /// + public IReadOnlyList Posts + { + get { return Requests.Where(request => request.Method == HttpMethod.Post).Select(r => r.Text).ToArray(); } + } + + public static byte[] Handshake(string upgrades = "") + { + return Encoding.UTF8.GetBytes( + $$"""0{"sid":"1NkM2QzZGMjEyMTIxCg","maxPayload":1000000,"pingTimeout":20000,"pingInterval":25000,"upgrades":[{{upgrades}}]}"""); + } + + public static byte[] Payload(string payload) + { + return Encoding.UTF8.GetBytes(payload); + } + + protected override async Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var body = request.Content is null + ? Array.Empty() + : await request.Content.ReadAsByteArrayAsync(cancellationToken); + + lock (_requests) + { + _requests.Add(new CapturedRequest(request.Method, request.RequestUri!, body)); + } + + if (request.Method != HttpMethod.Get) + { + if (PostDelay > TimeSpan.Zero) + { + await Task.Delay(PostDelay, cancellationToken); + } + + return Ok("ok"u8.ToArray()); + } + + if (PollDelay > TimeSpan.Zero) + { + await Task.Delay(PollDelay, cancellationToken); + } + + if (_pollResponses.Length == 0) + { + return Ok(Array.Empty()); + } + + // The last scripted response is repeated once the script runs out, so a poll + // loop never starves. + var index = Math.Min(Interlocked.Increment(ref _polls) - 1, _pollResponses.Length - 1); + return Ok(_pollResponses[index]); + } + + private static HttpResponseMessage Ok(byte[] body) + { + return new HttpResponseMessage(HttpStatusCode.OK) { Content = new ByteArrayContent(body) }; + } +} \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/GlobalUsings.cs b/tests/SocketIO.Client.Tests/GlobalUsings.cs new file mode 100644 index 0000000..8c927eb --- /dev/null +++ b/tests/SocketIO.Client.Tests/GlobalUsings.cs @@ -0,0 +1 @@ +global using Xunit; \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/IOTests.cs b/tests/SocketIO.Client.Tests/IOTests.cs new file mode 100644 index 0000000..f5b3fa7 --- /dev/null +++ b/tests/SocketIO.Client.Tests/IOTests.cs @@ -0,0 +1,200 @@ +using SocketIO.Client.Exceptions; +using SocketIO.Client.Packets; + +namespace SocketIO.Client.Tests; + +public class IOTests +{ + [Fact] + async Task Should_Send_Connect_Packet_After_The_Handshake() + { + var (io, server) = CreateClient(FakePollingServer.Handshake()); + + await using (io) + { + await io.ConnectAsync(); + + Assert.True(io.Connected); + Assert.Equal("40", Assert.Single(server.Posts)); + } + } + + [Fact] + async Task Should_Send_Connect_Packet_For_A_Namespace() + { + var (io, server) = CreateClient(FakePollingServer.Handshake()); + + await using (io) + { + await io.ConnectAsync("admin"); + + Assert.Equal("40/admin,", Assert.Single(server.Posts)); + } + } + + [Fact] + async Task Should_Serve_From_The_Socket_IO_Path() + { + var (io, server) = CreateClient(FakePollingServer.Handshake()); + + await using (io) + { + await io.ConnectAsync(); + + // The trailing slash is what a Socket.IO server matches on. + Assert.StartsWith("/socket.io/?EIO=4", server.Requests[0].Uri.PathAndQuery); + } + } + + [Fact(DisplayName = "A handshake that never completed is not reported as a connection")] + async Task ConnectAsync_Should_Throw_When_The_Handshake_Fails() + { + // Engine.io answers a handshake with an Open packet; anything else is a + // protocol error rather than a session. + var (io, server) = CreateClient(FakePollingServer.Payload("4nonsense")); + + await using (io) + { + var exception = await Assert.ThrowsAsync(() => io.ConnectAsync()); + + Assert.NotNull(exception.InnerException); + Assert.False(io.Connected); + Assert.Empty(server.Posts); + } + } + + [Fact(DisplayName = "A failed attempt does not leave the client thinking it is connected")] + async Task ConnectAsync_Should_Handshake_Again_After_A_Failed_Attempt() + { + var (io, server) = CreateClient(FakePollingServer.Payload("4nonsense"), FakePollingServer.Handshake()); + + await using (io) + { + await Assert.ThrowsAsync(() => io.ConnectAsync()); + + await io.ConnectAsync(); + + Assert.True(io.Connected); + Assert.Equal("40", Assert.Single(server.Posts)); + } + } + + [Fact] + async Task SendAsync_Should_Throw_When_Not_Connected() + { + var (io, _) = CreateClient(FakePollingServer.Handshake()); + + await using (io) + { + await Assert.ThrowsAsync(() => io.SendAsync("Hello!")); + } + } + + [Fact] + async Task Should_Send_A_Text_Event() + { + var (io, server) = CreateClient(FakePollingServer.Handshake()); + + await using (io) + { + await io.ConnectAsync(); + await io.SendAsync("Hello!", "greeting"); + + Assert.Equal("""42["greeting","Hello!"]""", server.Posts[1]); + } + } + + [Fact(DisplayName = "A binary event is a header followed by its attachment")] + async Task Should_Send_A_Binary_Event_As_Header_Then_Attachment() + { + var (io, server) = CreateClient(FakePollingServer.Handshake()); + + await using (io) + { + await io.ConnectAsync(); + await io.SendAsync(new byte[] { 1, 2, 3 }); + + Assert.Equal("""451-["message",{"_placeholder":true,"num":0}]""", server.Posts[1]); + + // Long-polling carries text only, so the attachment travels base64-encoded + // behind a 'b' prefix. + Assert.Equal("b" + Convert.ToBase64String([1, 2, 3]), server.Posts[2]); + } + } + + [Fact(DisplayName = "Concurrent binary sends do not interleave their attachments")] + async Task Should_Keep_A_Binary_Packet_And_Its_Attachments_Together() + { + const int senders = 16; + + // A send that overlaps another is what would let a second header land between + // a first header and the attachment it announced. + var (io, server) = CreateClient(TimeSpan.FromMilliseconds(5), FakePollingServer.Handshake()); + + await using (io) + { + await io.ConnectAsync(); + + await Task.WhenAll(Enumerable.Range(0, senders) + .Select(i => io.SendAsync(new byte[] { (byte)i })) + .ToArray()); + + // The CONNECT packet, then one header and one attachment per sender. + var posts = server.Posts.Skip(1).ToArray(); + Assert.Equal(senders * 2, posts.Length); + + for (var i = 0; i < senders; i++) + { + Assert.StartsWith("451-", posts[i * 2]); + Assert.StartsWith("b", posts[(i * 2) + 1]); + } + } + } + + [Fact] + void ListenAsync_Should_Return_A_Stream_Rather_Than_Throwing() + { + var (io, _) = CreateClient(FakePollingServer.Handshake()); + + Assert.NotNull(io.ListenAsync()); + Assert.NotNull(io.ListenAsync("admin")); + } + + [Fact(DisplayName = "Disposing the client ends every listener's enumeration")] + async Task ListenAsync_Should_End_When_The_Client_Is_Disposed() + { + var (io, _) = CreateClient(FakePollingServer.Handshake()); + await io.ConnectAsync(); + + var received = new List(); + var listening = Task.Run(async () => + { + await foreach (var packet in io.ListenAsync()) + { + received.Add(packet); + } + }); + + await io.DisposeAsync(); + + // Without the listeners being completed this would never return. + await listening.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Empty(received); + } + + private static (IO Client, FakePollingServer Server) CreateClient(params byte[][] pollResponses) + { + return CreateClient(TimeSpan.Zero, pollResponses); + } + + private static (IO Client, FakePollingServer Server) CreateClient( + TimeSpan postDelay, params byte[][] pollResponses) + { + // No websocket among the advertised upgrades, so the client stays on polling + // and every packet it sends is a POST this server can be asked about. + var server = new FakePollingServer(pollResponses) { PostDelay = postDelay }; + var httpClient = new HttpClient(server) { BaseAddress = new Uri("http://foo.bar") }; + + return (new IO(httpClient, "http://foo.bar"), server); + } +} \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs new file mode 100644 index 0000000..957878b --- /dev/null +++ b/tests/SocketIO.Client.Tests/Packets/BinaryEventPacketTests.cs @@ -0,0 +1,163 @@ +using System.Text; + +using SocketIO.Client.Packets; + +namespace SocketIO.Client.Tests.Packets; + +public class BinaryEventPacketTests +{ + [Fact] + void Should_Create_Binary_Event_Packet() + { + var packet = new PacketBuilder(PacketType.BinaryEvent); + packet.AddItem(new ReadOnlyMemory([1, 2, 3])); + + Assert.Equal(PacketType.BinaryEvent, packet.Type); + Assert.Equal("/", packet.Namespace); + } + + [Fact] + void Should_Create_Binary_Packet_With_Namespace() + { + var @namespace = "test"; + + var packet = new PacketBuilder(PacketType.BinaryEvent, @namespace); + + Assert.Equal($"/{@namespace}", packet.Namespace); + } + + [Fact] + void Should_Create_Binary_Packet_With_Event_Name() + { + var eventName = "test"; + + var packet = new PacketBuilder(PacketType.BinaryEvent, null, eventName); + + Assert.Equal(eventName, packet.Event); + } + + [Fact] + void Should_Create_Binary_Packet_With_Ack_Id() + { + var ackId = 42; + + var packet = new PacketBuilder(PacketType.BinaryAck, ackId, null, null); + + Assert.Equal(ackId, packet.AckId); + } + + [Fact] + void Should_Serialize_Binary_Event_Packet() + { + var packet = new PacketBuilder(PacketType.BinaryEvent); + packet.AddItem(new ReadOnlyMemory([1, 2, 3])); + + var encodedPacket = Encoding.UTF8.GetString(packet.Serialize().Span); + + Assert.Equal("""51-["message",{"_placeholder":true,"num":0}]""", encodedPacket); + } + + [Fact] + void Should_Serialize_Binary_Packet_With_Namespace() + { + var @namespace = "test"; + var expectedEncodedPacket = $$"""51-/{{@namespace}},["message",{"_placeholder":true,"num":0}]"""; + + var packet = new PacketBuilder(PacketType.BinaryEvent, @namespace); + packet.AddItem(new ReadOnlyMemory([1, 2, 3])); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact] + void Should_Serialize_Binary_Packet_With_Event_Name() + { + var eventName = "test"; + var expectedEncodedPacket = $$"""51-["{{eventName}}",{"_placeholder":true,"num":0}]"""; + + var packet = new PacketBuilder(PacketType.BinaryEvent, null, eventName); + packet.AddItem(new ReadOnlyMemory([1, 2, 3])); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact] + void Should_Serialize_Binary_Packet_With_Ack_Id() + { + var ackId = 42; + + // An acknowledgement answers an event rather than naming one. + var expectedEncodedPacket = $$"""61-{{ackId}}[{"_placeholder":true,"num":0}]"""; + + var packet = new PacketBuilder(PacketType.BinaryAck, ackId, null, null); + packet.AddItem(new ReadOnlyMemory([1, 2, 3])); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Theory(DisplayName = "A decoder refuses a binary packet that announces nothing to attach")] + [InlineData(PacketType.BinaryEvent)] + [InlineData(PacketType.BinaryAck)] + void Should_Reject_A_Binary_Packet_Without_An_Attachment(PacketType type) + { + var packet = type == PacketType.BinaryAck + ? new PacketBuilder(type, 1, null, null) + : new PacketBuilder(type); + packet.AddItem("Hello!"); + + Assert.Throws(() => packet.Serialize()); + } + + [Fact(DisplayName = "Attachments are numbered in the order they were added")] + void Should_Number_Attachments_In_Order() + { + var first = new ReadOnlyMemory([1, 2, 3]); + var second = new ReadOnlyMemory([4, 5, 6]); + var expectedEncodedPacket = + """52-["message",{"_placeholder":true,"num":0},{"_placeholder":true,"num":1}]"""; + + var packet = new PacketBuilder(PacketType.BinaryEvent); + packet.AddItem(first); + packet.AddItem(second); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + Assert.Equal([first, second], packet.Attachments); + } + + [Fact(DisplayName = "Binary and text arguments can be mixed in one payload")] + void Should_Mix_Text_And_Binary_Arguments() + { + var expectedEncodedPacket = """51-["message","Hello!",{"_placeholder":true,"num":0}]"""; + + var packet = new PacketBuilder(PacketType.BinaryEvent); + packet.AddItem("Hello!"); + packet.AddItem(new ReadOnlyMemory([1, 2, 3])); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact(DisplayName = "A byte array is treated as binary, not as a Json value")] + void Should_Treat_A_Byte_Array_As_Binary() + { + byte[] attachment = [1, 2, 3]; + + var packet = new PacketBuilder(PacketType.BinaryEvent); + packet.AddItem(attachment); + + Assert.Equal("""51-["message",{"_placeholder":true,"num":0}]""", + Encoding.UTF8.GetString(packet.Serialize().Span)); + Assert.Equal(attachment, Assert.Single(packet.Attachments)); + } + + [Fact(DisplayName = "The attachments are kept out of the encoded header")] + void Should_Keep_Attachments_Out_Of_The_Header() + { + var attachment = new ReadOnlyMemory([1, 2, 3]); + + var packet = new PacketBuilder(PacketType.BinaryEvent); + packet.AddItem(attachment); + + Assert.Equal(attachment, Assert.Single(packet.Attachments)); + Assert.DoesNotContain((byte)0x01, packet.Serialize().Span.ToArray()); + } +} \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/Packets/ConnectPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/ConnectPacketTests.cs new file mode 100644 index 0000000..d4858d3 --- /dev/null +++ b/tests/SocketIO.Client.Tests/Packets/ConnectPacketTests.cs @@ -0,0 +1,69 @@ +using System.Text; + +using SocketIO.Client.Packets; + +namespace SocketIO.Client.Tests.Packets; + +public class ConnectPacketTests +{ + [Fact] + void Should_Create_Connect_Packet() + { + var packet = PacketBuilder.Connect; + + Assert.Equal(PacketType.Connect, packet.Type); + Assert.Equal("/", packet.Namespace); + } + + [Fact] + void Should_Create_Connect_Packet_With_Namespace() + { + var @namespace = "test"; + + var packet = new PacketBuilder(PacketType.Connect, @namespace); + + Assert.Equal(PacketType.Connect, packet.Type); + Assert.Equal($"/{@namespace}", packet.Namespace); + } + + [Fact] + void Should_Serialize_Connect_Packet() + { + var connectPacket = PacketBuilder.Connect; + + var encodedPacket = Encoding.UTF8.GetString(connectPacket.Serialize().Span); + + Assert.Equal(PacketType.Connect, connectPacket.Type); + Assert.Equal("0", encodedPacket); + } + + [Fact] + void Should_Serialize_Connect_Packet_With_Namespace() + { + var @namespace = "test"; + var connectPacket = new PacketBuilder(PacketType.Connect, @namespace); + + var encodedPacket = Encoding.UTF8.GetString(connectPacket.Serialize().Span); + + Assert.Equal(PacketType.Connect, connectPacket.Type); + Assert.Equal($"0/{@namespace},", encodedPacket); + } + + [Fact] + void Should_Reject_A_Payload_On_A_Connect_Packet() + { + var packet = PacketBuilder.Connect; + + Assert.Throws(() => packet.AddItem("World")); + } + + [Fact(DisplayName = "The shared Connect packet can be serialized more than once")] + void Should_Serialize_The_Shared_Connect_Packet_Repeatedly() + { + var first = Encoding.UTF8.GetString(PacketBuilder.Connect.Serialize().Span); + var second = Encoding.UTF8.GetString(PacketBuilder.Connect.Serialize().Span); + + Assert.Equal("0", first); + Assert.Equal(first, second); + } +} \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/Packets/DecoderTests.cs b/tests/SocketIO.Client.Tests/Packets/DecoderTests.cs new file mode 100644 index 0000000..9615c3e --- /dev/null +++ b/tests/SocketIO.Client.Tests/Packets/DecoderTests.cs @@ -0,0 +1,26 @@ +using SocketIO.Client.Exceptions; +using SocketIO.Client.Packets; + +using EnginePacket = EngineIO.Client.Packets.Packet; + +namespace SocketIO.Client.Tests.Packets; + +public class DecoderTests +{ + [Fact] + void Should_Not_Be_Reconstructing_Before_Anything_Arrives() + { + var decoder = new Decoder(); + + Assert.False(decoder.IsReconstructing); + } + + [Fact(DisplayName = "An attachment with no header before it means the stream is out of step")] + void Should_Reject_An_Attachment_With_No_Packet_Waiting() + { + var decoder = new Decoder(); + + Assert.Throws( + () => decoder.Add(EnginePacket.CreateBinaryPacket(new byte[] { 1, 2, 3 }))); + } +} \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/Packets/DisconnectPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/DisconnectPacketTests.cs new file mode 100644 index 0000000..a0735bf --- /dev/null +++ b/tests/SocketIO.Client.Tests/Packets/DisconnectPacketTests.cs @@ -0,0 +1,59 @@ +using System.Text; + +using SocketIO.Client.Packets; + +namespace SocketIO.Client.Tests.Packets; + +public class DisconnectPacketTests +{ + [Fact] + void Should_Create_Disconnect_Packet() + { + var packet = PacketBuilder.Disconnect; + + Assert.Equal(PacketType.Disconnect, packet.Type); + Assert.Equal("/", packet.Namespace); + } + + [Fact] + void Should_Create_Disconnect_Packet_With_Namespace() + { + var @namespace = "test"; + + var packet = new PacketBuilder(PacketType.Disconnect, @namespace); + + Assert.Equal(PacketType.Disconnect, packet.Type); + Assert.Equal($"/{@namespace}", packet.Namespace); + } + + [Fact] + void Should_Serialize_Disconnect_Packet() + { + var packet = PacketBuilder.Disconnect; + + var encodedPacket = Encoding.UTF8.GetString(packet.Serialize().Span); + + Assert.Equal(PacketType.Disconnect, packet.Type); + Assert.Equal("1", encodedPacket); + } + + [Fact] + void Should_Serialize_Disconnect_Packet_With_Namespace() + { + var @namespace = "test"; + var packet = new PacketBuilder(PacketType.Disconnect, @namespace); + + var encodedPacket = Encoding.UTF8.GetString(packet.Serialize().Span); + + Assert.Equal(PacketType.Disconnect, packet.Type); + Assert.Equal($"1/{@namespace},", encodedPacket); + } + + [Fact] + void Should_Reject_A_Payload_On_A_Disconnect_Packet() + { + var packet = PacketBuilder.Disconnect; + + Assert.Throws(() => packet.AddItem("World")); + } +} \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs b/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs new file mode 100644 index 0000000..7558dd6 --- /dev/null +++ b/tests/SocketIO.Client.Tests/Packets/EventPacketTests.cs @@ -0,0 +1,257 @@ +using System.Text; + +using SocketIO.Client.Packets; + +namespace SocketIO.Client.Tests.Packets; + +// class used for packet with json payload tests +class Foo +{ + public string? Value { get; set; } +} + +public class EventPacketTests +{ + [Fact] + void Should_Create_Event_Packet() + { + var packet = new PacketBuilder(PacketType.Event); + + Assert.Equal(PacketType.Event, packet.Type); + Assert.Equal("/", packet.Namespace); + Assert.Equal("message", packet.Event); + } + + [Fact] + void Should_Create_Event_Packet_With_Namespace() + { + var @namespace = "test"; + + var packet = new PacketBuilder(PacketType.Event, @namespace); + + Assert.Equal(PacketType.Event, packet.Type); + Assert.Equal($"/{@namespace}", packet.Namespace); + Assert.Equal("message", packet.Event); + } + + [Theory(DisplayName = "A namespace encodes the same however it was spelled")] + [InlineData("test")] + [InlineData("/test")] + void Should_Normalize_Namespace(string @namespace) + { + var packet = new PacketBuilder(PacketType.Event, @namespace); + packet.AddItem("Hello!"); + + Assert.Equal("/test", packet.Namespace); + Assert.Equal("""2/test,["message","Hello!"]""", Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact(DisplayName = "A comma would end the namespace early, so one is refused")] + void Should_Reject_A_Namespace_Containing_A_Comma() + { + Assert.Throws(() => new PacketBuilder(PacketType.Event, "a,b")); + } + + [Fact] + void Should_Create_Event_Packet_With_Event_Name() + { + var eventName = "test"; + + var packet = new PacketBuilder(PacketType.Event, null, eventName); + + Assert.Equal(PacketType.Event, packet.Type); + Assert.Equal("/", packet.Namespace); + Assert.Equal(eventName, packet.Event); + } + + [Theory(DisplayName = "An event name the protocol keeps for itself is refused")] + [InlineData("connect")] + [InlineData("connect_error")] + [InlineData("disconnect")] + [InlineData("disconnecting")] + [InlineData("newListener")] + [InlineData("removeListener")] + void Should_Reject_A_Reserved_Event_Name(string eventName) + { + Assert.Throws(() => new PacketBuilder(PacketType.Event, null, eventName)); + } + + [Fact] + void Should_Create_Ack_Packet_With_Ack_Id() + { + var ackId = 42; + + var packet = new PacketBuilder(PacketType.Ack, ackId, null, null); + + Assert.Equal(PacketType.Ack, packet.Type); + Assert.Equal(ackId, packet.AckId); + } + + [Fact(DisplayName = "An event can request an acknowledgement")] + void Should_Request_An_Acknowledgement_On_An_Event() + { + var ackId = 7; + + var packet = new PacketBuilder(PacketType.Event, ackId, null, null); + packet.AddItem("Hello!"); + + Assert.Equal(ackId, packet.AckId); + Assert.Equal("""27["message","Hello!"]""", Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact(DisplayName = "An acknowledgement does not carry an event name")] + void Should_Reject_An_Event_Name_On_An_Acknowledgement() + { + Assert.Throws(() => new PacketBuilder(PacketType.Ack, 1, null, "test")); + } + + [Theory(DisplayName = "An acknowledgement has to name the event it answers")] + [InlineData(PacketType.Ack)] + [InlineData(PacketType.BinaryAck)] + void Should_Reject_An_Acknowledgement_Without_An_Ack_Id(PacketType type) + { + Assert.Throws(() => new PacketBuilder(type, null, null)); + } + + [Theory(DisplayName = "A sign would be read as the start of the payload")] + [InlineData(-1)] + [InlineData(int.MinValue)] + void Should_Reject_A_Negative_Ack_Id(int ackId) + { + Assert.Throws(() => new PacketBuilder(PacketType.Event, ackId, null, null)); + } + + [Theory(DisplayName = "Only the types that take part in one carry an ack id")] + [InlineData(PacketType.Connect)] + [InlineData(PacketType.Disconnect)] + [InlineData(PacketType.ConnectError)] + void Should_Reject_An_Ack_Id_On_A_Type_That_Cannot_Carry_One(PacketType type) + { + Assert.Throws(() => new PacketBuilder(type, 1, null, null)); + } + + [Fact] + void Should_Reject_An_Unknown_Packet_Type() + { + Assert.Throws(() => new PacketBuilder((PacketType)0x39)); + } + + [Fact] + void Should_Serialize_Plaintext_Event_Packet() + { + var packet = new PacketBuilder(PacketType.Event); + packet.AddItem("Hello!"); + + var encodedPacket = Encoding.UTF8.GetString(packet.Serialize().Span); + + Assert.Equal("""2["message","Hello!"]""", encodedPacket); + } + + [Fact] + void Should_Reject_Binary_On_A_Plaintext_Event() + { + var packet = new PacketBuilder(PacketType.Event); + var invalidPayload = new ReadOnlyMemory(new byte[] { 1, 2, 3 }); + + Assert.Throws(() => packet.AddItem(invalidPayload)); + } + + [Fact] + void Should_Serialize_Plaintext_Event_With_Namespace() + { + var @namespace = "test"; + var expectedEncodedPacket = $"""2/{@namespace},["message","Hello!"]"""; + + var packet = new PacketBuilder(PacketType.Event, @namespace); + packet.AddItem("Hello!"); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact] + void Should_Serialize_Plaintext_Event_With_Event_Name() + { + var eventName = "test"; + var expectedEncodedPacket = $$"""2["{{eventName}}","Hello!"]"""; + + var packet = new PacketBuilder(PacketType.Event, null, eventName); + packet.AddItem("Hello!"); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact] + void Should_Serialize_Plaintext_Ack_Packet() + { + var ackId = 42; + + // An acknowledgement answers an event rather than naming one, so its payload + // is the response arguments alone. + var expectedEncodedPacket = $$"""3{{ackId}}["Hello!"]"""; + + var packet = new PacketBuilder(PacketType.Ack, ackId, null, null); + packet.AddItem("Hello!"); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact] + void Should_Serialize_Json_Event_Packet() + { + var packet = new PacketBuilder(PacketType.Event); + packet.AddItem(new Foo { Value = "bar" }); + + var encodedPacket = Encoding.UTF8.GetString(packet.Serialize().Span); + + Assert.Equal("""2["message",{"Value":"bar"}]""", encodedPacket); + } + + [Fact] + void Should_Serialize_Json_Event_With_Namespace() + { + var @namespace = "test"; + var expectedEncodedPacket = $$"""2/{{@namespace}},["message",{"Value":"bar"}]"""; + + var packet = new PacketBuilder(PacketType.Event, @namespace); + packet.AddItem(new Foo { Value = "bar" }); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact] + void Should_Serialize_Json_Event_With_Event_Name() + { + var eventName = "test"; + var expectedEncodedPacket = $$"""2["{{eventName}}",{"Value":"bar"}]"""; + + var packet = new PacketBuilder(PacketType.Event, null, eventName); + packet.AddItem(new Foo { Value = "bar" }); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact] + void Should_Serialize_Json_Ack_Packet() + { + var ackId = 42; + var expectedEncodedPacket = $$"""3{{ackId}}[{"Value":"bar"}]"""; + + var packet = new PacketBuilder(PacketType.Ack, ackId, null, null); + packet.AddItem(new Foo { Value = "bar" }); + + Assert.Equal(expectedEncodedPacket, Encoding.UTF8.GetString(packet.Serialize().Span)); + } + + [Fact(DisplayName = "The same packet encodes identically every time it is sent")] + void Should_Serialize_The_Same_Packet_Repeatedly() + { + var packet = new PacketBuilder(PacketType.Event); + packet.AddItem("Hello!"); + + var first = Encoding.UTF8.GetString(packet.Serialize().Span); + var second = Encoding.UTF8.GetString(packet.Serialize().Span); + + Assert.Equal("""2["message","Hello!"]""", first); + Assert.Equal(first, second); + } +} \ No newline at end of file diff --git a/tests/SocketIO.Client.Tests/SocketIO.Client.Tests.csproj b/tests/SocketIO.Client.Tests/SocketIO.Client.Tests.csproj new file mode 100644 index 0000000..49e680f --- /dev/null +++ b/tests/SocketIO.Client.Tests/SocketIO.Client.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + SocketIO.Client.Tests + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + \ No newline at end of file