├── .github └── workflows │ ├── release.yml │ └── swift.yml ├── .gitignore ├── .swiftformat ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── contents.xcworkspacedata ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── Package.swift ├── README.md ├── SECURITY.md ├── Sample ├── AIStream │ ├── AIStreaming │ │ ├── AIStreaming.xcodeproj │ │ │ ├── project.pbxproj │ │ │ └── project.xcworkspace │ │ │ │ ├── contents.xcworkspacedata │ │ │ │ └── xcshareddata │ │ │ │ └── swiftpm │ │ │ │ └── Package.resolved │ │ └── AIStreaming │ │ │ ├── AIStreaming.entitlements │ │ │ ├── AIStreamingApp.swift │ │ │ ├── Assets.xcassets │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ │ ├── ContentView.swift │ │ │ ├── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ │ └── Contents.json │ │ │ └── ViewModel.swift │ └── Server │ │ ├── AIStreaming.csproj │ │ ├── AIStreaming.sln │ │ ├── GroupAccessor.cs │ │ ├── GroupHistoryStore.cs │ │ ├── Hubs │ │ └── GroupChatHub.cs │ │ ├── OpenAIExtensions.cs │ │ ├── OpenAIOptions.cs │ │ ├── Program.cs │ │ ├── Properties │ │ └── launchSettings.json │ │ ├── README.md │ │ ├── appsettings.Development.json │ │ ├── appsettings.json │ │ ├── images │ │ └── chat.jpg │ │ └── wwwroot │ │ └── index.html ├── AzureSignalRConsoleApp │ ├── .vscode │ │ └── launch.json │ ├── Package.resolved │ ├── Package.swift │ ├── Server │ │ ├── ChatHub.cs │ │ ├── Program.cs │ │ ├── Properties │ │ │ └── launchSettings.json │ │ ├── Server.csproj │ │ ├── appsettings.Development.json │ │ └── appsettings.json │ └── Sources │ │ └── Sample │ │ └── main.swift ├── ChatRoom │ ├── ChatRoom.xcodeproj │ │ ├── project.pbxproj │ │ └── project.xcworkspace │ │ │ ├── contents.xcworkspacedata │ │ │ └── xcshareddata │ │ │ └── swiftpm │ │ │ └── Package.resolved │ ├── ChatRoom │ │ ├── Assets.xcassets │ │ │ ├── AccentColor.colorset │ │ │ │ └── Contents.json │ │ │ ├── AppIcon.appiconset │ │ │ │ └── Contents.json │ │ │ └── Contents.json │ │ ├── ChatRoom.entitlements │ │ ├── ChatRoomApp.swift │ │ ├── ChatViewModel.swift │ │ ├── ContentView.swift │ │ └── Preview Content │ │ │ └── Preview Assets.xcassets │ │ │ └── Contents.json │ ├── README.md │ ├── Server │ │ ├── ChatHub.cs │ │ ├── Program.cs │ │ ├── Properties │ │ │ └── launchSettings.json │ │ ├── Server.csproj │ │ ├── appsettings.Development.json │ │ └── appsettings.json │ └── snapshort.png ├── ConsoleApp │ ├── Package.resolved │ ├── Package.swift │ ├── Server │ │ ├── ChatHub.cs │ │ ├── Program.cs │ │ ├── Properties │ │ │ └── launchSettings.json │ │ ├── Server.csproj │ │ ├── appsettings.Development.json │ │ └── appsettings.json │ └── Sources │ │ └── Sample │ │ └── main.swift └── README.md ├── Sources └── SignalRClient │ ├── AsyncLock.swift │ ├── AtomicState.swift │ ├── ConnectionProtocol.swift │ ├── HandshakeProtocol.swift │ ├── HttpClient.swift │ ├── HttpConnection.swift │ ├── HubConnection+On.swift │ ├── HubConnection+OnResult.swift │ ├── HubConnection.swift │ ├── HubConnectionBuilder.swift │ ├── InvocationBinder.swift │ ├── Logger.swift │ ├── MessageBuffer.swift │ ├── Protocols │ ├── BinaryMessageFormat.swift │ ├── HubMessage.swift │ ├── HubProtocol.swift │ ├── JsonHubProtocol.swift │ ├── MessagePackHubProtocol.swift │ ├── MessageType.swift │ ├── Msgpack │ │ ├── MsgpackCommon.swift │ │ ├── MsgpackDecoder.swift │ │ └── MsgpackEncoder.swift │ └── TextMessageFormat.swift │ ├── RetryPolicy.swift │ ├── SignalRError.swift │ ├── StatefulReconnectOptions.swift │ ├── StreamResult.swift │ ├── TaskCompletionSource.swift │ ├── TimeScheduler.swift │ ├── TransferFormat.swift │ ├── Transport │ ├── EventSource.swift │ ├── LongPollingTransport.swift │ ├── ServerSentEventTransport.swift │ ├── Transport.swift │ └── WebSocketTransport.swift │ ├── Utils.swift │ └── Version.swift └── Tests ├── IntegrationTestServer ├── .gitignore ├── Hubs │ └── TestHub.cs ├── IntegrationTestServer.csproj ├── Program.cs ├── Properties │ └── launchSettings.json ├── appsettings.Development.json └── appsettings.json ├── SignalRClientIntegrationTests └── IntegrationTests.swift └── SignalRClientTests ├── AsyncLockTest.swift ├── BinaryMessageFormatTests.swift ├── EventSourceTests.swift ├── HandshakeProtocolTests.swift ├── HttpClientTests.swift ├── HubConnection+OnResultTests.swift ├── HubConnection+OnTests.swift ├── HubConnectionTests.swift ├── JsonHubProtocolTests.swift ├── LoggerTests.swift ├── LongPollingTransportTests.swift ├── MessageBufferTests.swift ├── MessagePackHubProtocolTests.swift ├── Msgpack ├── MsgpackDecoderTests.swift └── MsgpackEncoderTests.swift ├── ServerSentEventTransportTests.swift ├── TaskCompletionSourceTests.swift ├── TextMessageFormatTests.swift ├── TimeSchedulerTests.swift ├── UtilsTest.swift └── WebSocketTransportTests.swift /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release version (tag) 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | tag-and-bump: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | contents: write 11 | pull-requests: write 12 | steps: 13 | - name: Checkout main 14 | uses: actions/checkout@v3 15 | with: 16 | ref: main 17 | 18 | - name: Get current version 19 | id: get_version 20 | run: | 21 | current_version=$(grep 'let PackageVersion' Sources/SignalRClient/Version.swift | sed -n 's/.*let PackageVersion *= *"\([^"]*\)".*/\1/p') 22 | echo "Current Version: $current_version" 23 | if [ -z "$current_version" ]; then 24 | echo "Error: current_version is empty. Exiting." 25 | exit 1 26 | fi 27 | echo "current_version=$current_version" >> "$GITHUB_OUTPUT" 28 | 29 | - name: Get next version 30 | id: calc_next 31 | run: | 32 | current=${{ steps.get_version.outputs.current_version }} 33 | if [[ "$current" == *"-preview."* ]]; then 34 | base=${current%-preview.*} 35 | preview_num=${current##*-preview.} 36 | next_preview=$((preview_num + 1)) 37 | next_version="${base}-preview.${next_preview}" 38 | else 39 | IFS='.' read -r major minor patch <<< "$current" 40 | next_patch=$((patch + 1)) 41 | next_version="${major}.${minor}.${next_patch}" 42 | fi 43 | echo "Next Version: $next_version" 44 | echo "next_version=$next_version" >> "$GITHUB_OUTPUT" 45 | 46 | - name: Tag and release 47 | run: | 48 | git config --global user.name "github-actions[bot]" 49 | git config --global user.email "github-actions[bot]@users.noreply.github.com" 50 | git fetch --tags 51 | version=${{ steps.get_version.outputs.current_version }} 52 | TAG="v$version" 53 | if git rev-parse "refs/tags/$TAG" >/dev/null 2>&1; then 54 | echo "Tag $TAG already exists. Skipping tag and release." 55 | else 56 | git tag "$TAG" 57 | git push origin "$TAG" 58 | echo "Released $TAG" 59 | fi 60 | 61 | - name: Create a branch for bumping version 62 | id: create_branch 63 | run: | 64 | next=${{ steps.calc_next.outputs.next_version }} 65 | branch="bump-version-$next" 66 | echo "branch_name=$branch" >> $GITHUB_OUTPUT 67 | if git ls-remote --exit-code --heads origin "$branch" >/dev/null 2>&1; then 68 | echo "Branch $branch already exists. Skipping creating branch." 69 | else 70 | sed -i "s/\(let PackageVersion = \"\).*\(\"\)/\1$next\2/" Sources/SignalRClient/Version.swift 71 | fi 72 | 73 | - name: Create Pull Request 74 | id: cpr 75 | uses: peter-evans/create-pull-request@v7 76 | with: 77 | token: ${{ secrets.GITHUB_TOKEN }} 78 | branch: ${{ steps.create_branch.outputs.branch_name }} 79 | delete-branch: true 80 | title: "Bump version to ${{ steps.calc_next.outputs.next_version }}" 81 | commit-message: "Bump version to ${{ steps.calc_next.outputs.next_version }}" 82 | body: "Bump version to ${{ steps.calc_next.outputs.next_version }} after release" 83 | 84 | - name: Check outputs 85 | if: ${{ steps.cpr.outputs.pull-request-number }} 86 | run: | 87 | echo "Pull Request Number - ${{ steps.cpr.outputs.pull-request-number }}" 88 | echo "Pull Request URL - ${{ steps.cpr.outputs.pull-request-url }}" 89 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | branches: [ "main", "dev" ] 9 | pull_request: 10 | branches: [ "main", "dev" ] 11 | 12 | jobs: 13 | build: 14 | name: Swift on ${{ matrix.os }} 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | matrix: 18 | os: [macos-latest] 19 | steps: 20 | - uses: actions/checkout@v4 21 | - uses: swift-actions/setup-swift@v2 22 | with: 23 | swift-version: "5.10" 24 | - name: Get swift version 25 | run: swift --version 26 | - name: Build and Run tests 27 | run: swift test --filter SignalRClientTests 28 | 29 | integrationTest: 30 | name: Integration Test on ${{ matrix.os }} 31 | runs-on: ${{ matrix.os }} 32 | strategy: 33 | matrix: 34 | os: [macos-latest] 35 | steps: 36 | - uses: actions/checkout@v4 37 | - uses: swift-actions/setup-swift@v2 38 | with: 39 | swift-version: "5.10" 40 | - name: Get swift version 41 | run: swift --version 42 | - name: Install .NET 43 | uses: actions/setup-dotnet@v3 44 | with: 45 | dotnet-version: '8.x' 46 | - name: Build .NET server 47 | working-directory: ./Tests/IntegrationTestServer 48 | run: dotnet build 49 | - name: Start IntegrationTestServer 50 | working-directory: ./Tests/IntegrationTestServer 51 | run: | 52 | dotnet run & 53 | continue-on-error: false 54 | - name: Wait for server to start 55 | run: sleep 5 56 | - name: Run integration tests 57 | env: 58 | SIGNALR_INTEGRATION_TEST_URL: http://localhost:8080/test 59 | run: swift test --filter SignalRClientIntegrationTests -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Xcode 2 | # 3 | # gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore 4 | 5 | ## User settings 6 | xcuserdata/ 7 | 8 | ## Idea 9 | **/.idea/* 10 | 11 | ## MacOS 12 | .DS_Store 13 | 14 | ## Obj-C/Swift specific 15 | *.hmap 16 | 17 | ## App packaging 18 | *.ipa 19 | *.dSYM.zip 20 | *.dSYM 21 | 22 | ## Playgrounds 23 | timeline.xctimeline 24 | playground.xcworkspace 25 | 26 | # Swift Package Manager 27 | # 28 | # Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. 29 | # Packages/ 30 | # Package.pins 31 | # Package.resolved 32 | # *.xcodeproj 33 | .swiftpm/ 34 | 35 | # Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata 36 | # hence it is not needed unless you have added a package configuration file to your project 37 | # .swiftpm 38 | 39 | .build/ 40 | 41 | # CocoaPods 42 | # 43 | # We recommend against adding the Pods directory to your .gitignore. However 44 | # you should judge for yourself, the pros and cons are mentioned at: 45 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 46 | # 47 | # Pods/ 48 | # 49 | # Add this line if you want to avoid checking in source code from the Xcode workspace 50 | # *.xcworkspace 51 | 52 | # Carthage 53 | # 54 | # Add this line if you want to avoid checking in source code from Carthage dependencies. 55 | # Carthage/Checkouts 56 | 57 | Carthage/Build/ 58 | 59 | # fastlane 60 | # 61 | # It is recommended to not store the screenshots in the git repo. 62 | # Instead, use fastlane to re-generate the screenshots whenever they are needed. 63 | # For more information about the recommended setup visit: 64 | # https://docs.fastlane.tools/best-practices/source-control/#source-control 65 | 66 | fastlane/report.xml 67 | fastlane/Preview.html 68 | fastlane/screenshots/**/*.png 69 | fastlane/test_output 70 | 71 | # General 72 | .DS_Store 73 | .AppleDouble 74 | .LSOverride 75 | 76 | # Icon must end with two \r 77 | Icon 78 | 79 | # Thumbnails 80 | ._* 81 | 82 | # Files that might appear in the root of a volume 83 | .DocumentRevisions-V100 84 | .fseventsd 85 | .Spotlight-V100 86 | .TemporaryItems 87 | .Trashes 88 | .VolumeIcon.icns 89 | .com.apple.timemachine.donotpresent 90 | 91 | # Directories potentially created on remote AFP share 92 | .AppleDB 93 | .AppleDesktop 94 | Network Trash Folder 95 | Temporary Items 96 | .apdisk 97 | 98 | # Visual Studio Code 99 | .vscode/* 100 | !.vscode/settings.json 101 | 102 | # Rider 103 | .idea/ 104 | 105 | # Visual Studio 106 | .vs/ 107 | 108 | # Fleet 109 | .fleet/ 110 | 111 | # Code Rush 112 | .cr/ 113 | 114 | # User-specific files 115 | *.suo 116 | *.user 117 | *.userosscache 118 | *.sln.docstates 119 | 120 | # Build results 121 | [Dd]ebug/ 122 | [Dd]ebugPublic/ 123 | [Rr]elease/ 124 | [Rr]eleases/ 125 | x64/ 126 | x86/ 127 | build/ 128 | bld/ 129 | [Bb]in/ 130 | [Oo]bj/ 131 | [Oo]ut/ 132 | msbuild.log 133 | msbuild.err 134 | msbuild.wrn -------------------------------------------------------------------------------- /.swiftformat: -------------------------------------------------------------------------------- 1 | # format options 2 | --indent 4 3 | --xcodeindentation true 4 | --hexliteralcase lowercase 5 | --header Licensed to the .NET Foundation under one or more agreements.\nThe .NET Foundation licenses this file to you under the MIT license. 6 | 7 | # rules 8 | --disable all 9 | --enable consecutiveBlankLines 10 | --enable consecutiveSpaces 11 | --enable duplicateImports 12 | --enable indent 13 | --enable modifierOrder 14 | --enable numberFormatting 15 | --enable semicolons 16 | --enable spaceAroundBraces 17 | --enable spaceAroundBrackets 18 | --enable spaceAroundComments 19 | --enable spaceAroundGenerics 20 | --enable spaceAroundOperators 21 | --enable spaceAroundParens 22 | --enable spaceInsideBraces 23 | --enable spaceInsideBrackets 24 | --enable spaceInsideComments 25 | --enable spaceInsideGenerics 26 | --enable spaceInsideParens 27 | --enable todos 28 | --enable blankLineAfterImports 29 | --enable braces 30 | --enable fileHeader 31 | --enable wrapArguments 32 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "lldb", 9 | "request": "launch", 10 | "name": "Launch", 11 | "sourceLanguages": ["swift"], 12 | "program": "${workspaceFolder}/Sample/.build/debug/Sample", 13 | "args": [], 14 | "cwd": "${workspaceFolder}" 15 | }, 16 | ] 17 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | {} -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Release History 2 | ## 1.0.0-preview.4 3 | 4 | ### Bugs Fixed 5 | 6 | - Fix the accessbility of HttpConnectionOptions 7 | 8 | ### Features Added 9 | 10 | - Support client to server streaming 11 | 12 | ## 1.0.0-preview.3 13 | 14 | ### Bugs Fixed 15 | 16 | - Fix the WebSocketTransport query issue 17 | 18 | ## 1.0.0-preview.2 19 | 20 | ### Bugs Fixed 21 | 22 | - Fix the hub protocol version to 1 23 | 24 | ## 1.0.0-preview.1 25 | 26 | ### Features Added 27 | - The initial preview release of the library 28 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing Guidelines 2 | 3 | ## 1. Code Format 4 | 5 | We use [SwiftFormat](https://github.com/nicklockwood/SwiftFormat) to ensure a consistent code style throughout the project. 6 | 7 | The most convenient way should be integrating it as [Xcode source editor extension](https://github.com/nicklockwood/SwiftFormat?tab=readme-ov-file#xcode-source-editor-extension). 8 | 9 | To format all swift files, run 10 | ` 11 | swiftformat . 12 | ` -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) .NET Foundation and Contributors 4 | 5 | All rights reserved. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "SignalRClient", 6 | platforms: [ 7 | .macOS(.v11), 8 | .iOS(.v14) 9 | ], 10 | products: [ 11 | .library(name: "SignalRClient", targets: ["SignalRClient"]) 12 | ], 13 | dependencies: [ 14 | ], 15 | targets: [ 16 | .target( 17 | name: "SignalRClient", 18 | dependencies: [ 19 | ] 20 | ), 21 | .testTarget( 22 | name: "SignalRClientTests", dependencies: ["SignalRClient"], 23 | swiftSettings: [ 24 | // .enableExperimentalFeature("StrictConcurrency") 25 | ] 26 | ), 27 | .testTarget( 28 | name: "SignalRClientIntegrationTests", dependencies: ["SignalRClient"] 29 | ), 30 | ] 31 | ) 32 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet) and [Xamarin](https://github.com/xamarin). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/security.md/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/security.md/msrc/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/security.md/msrc/pgp). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://www.microsoft.com/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/security.md/msrc/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/security.md/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /Sample/AIStream/AIStreaming/AIStreaming.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sample/AIStream/AIStreaming/AIStreaming.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "089e8a98d8934104d085ef56f4bf4fdd7a860571d17e10aca6bc14cea5ca4409", 3 | "pins" : [ 4 | { 5 | "identity" : "signalr-client-swift", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/dotnet/signalr-client-swift.git", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "2fcccf04cd420125b335931846ca14d8c14a1c44" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Sample/AIStream/AIStreaming/AIStreaming/AIStreaming.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Sample/AIStream/AIStreaming/AIStreaming/AIStreamingApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AIStreamingApp.swift 3 | // AIStreaming 4 | // 5 | // Created by Chenyang Liu on 2025/3/19. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct AIStreamingApp: App { 12 | var body: some Scene { 13 | WindowGroup { 14 | ContentView() 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sample/AIStream/AIStreaming/AIStreaming/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sample/AIStream/AIStreaming/AIStreaming/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | }, 30 | { 31 | "idiom" : "mac", 32 | "scale" : "1x", 33 | "size" : "16x16" 34 | }, 35 | { 36 | "idiom" : "mac", 37 | "scale" : "2x", 38 | "size" : "16x16" 39 | }, 40 | { 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "32x32" 44 | }, 45 | { 46 | "idiom" : "mac", 47 | "scale" : "2x", 48 | "size" : "32x32" 49 | }, 50 | { 51 | "idiom" : "mac", 52 | "scale" : "1x", 53 | "size" : "128x128" 54 | }, 55 | { 56 | "idiom" : "mac", 57 | "scale" : "2x", 58 | "size" : "128x128" 59 | }, 60 | { 61 | "idiom" : "mac", 62 | "scale" : "1x", 63 | "size" : "256x256" 64 | }, 65 | { 66 | "idiom" : "mac", 67 | "scale" : "2x", 68 | "size" : "256x256" 69 | }, 70 | { 71 | "idiom" : "mac", 72 | "scale" : "1x", 73 | "size" : "512x512" 74 | }, 75 | { 76 | "idiom" : "mac", 77 | "scale" : "2x", 78 | "size" : "512x512" 79 | } 80 | ], 81 | "info" : { 82 | "author" : "xcode", 83 | "version" : 1 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sample/AIStream/AIStreaming/AIStreaming/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sample/AIStream/AIStreaming/AIStreaming/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ContentView: View { 4 | var body: some View { 5 | ChatView(viewModel: ViewModel()) 6 | } 7 | } 8 | 9 | struct ChatView: View { 10 | @ObservedObject var viewModel: ViewModel 11 | @State private var inputText: String = "" 12 | @State private var isShowingEntrySheet: Bool = true 13 | 14 | var body: some View { 15 | VStack(spacing: 0) { 16 | Text("Group: \(viewModel.group)") 17 | .font(.headline) 18 | .padding() 19 | .frame(maxWidth: .infinity) 20 | .background(Color.blue.opacity(0.8)) 21 | .foregroundColor(.white) 22 | 23 | // Messages List 24 | ScrollViewReader { proxy in 25 | ScrollView { 26 | VStack(alignment: .leading, spacing: 10) { 27 | ForEach(viewModel.messages) { message in 28 | MessageView(message: message, selfUser: viewModel.username) 29 | } 30 | } 31 | .padding() 32 | } 33 | .background(Color.platformBackground) 34 | .onChange(of: viewModel.messages) { _, _ in 35 | scrollToBottom(proxy: proxy) 36 | } 37 | } 38 | 39 | Divider() 40 | 41 | // Input Field and Send Button 42 | HStack { 43 | TextField("Type your message here... Use @gpt to invoke in a LLM model", text: $inputText) 44 | .textFieldStyle(RoundedBorderTextFieldStyle()) 45 | .padding(8) 46 | 47 | Button("Send") { 48 | sendMessage() 49 | } 50 | .buttonStyle(.borderedProminent) 51 | .keyboardShortcut(.defaultAction) 52 | .padding(8) 53 | } 54 | .padding() 55 | } 56 | .sheet(isPresented: $isShowingEntrySheet) { 57 | UserEntryView(isPresented: $isShowingEntrySheet, viewModel: viewModel) 58 | } 59 | .frame(minWidth: 400, minHeight: 500) 60 | } 61 | 62 | // Scroll to the latest message 63 | private func scrollToBottom(proxy: ScrollViewProxy) { 64 | if let lastMessage = self.viewModel.messages.last { 65 | DispatchQueue.main.async { 66 | proxy.scrollTo(lastMessage.id, anchor: .bottom) 67 | } 68 | } 69 | } 70 | 71 | private func sendMessage() { 72 | guard !inputText.isEmpty else { return } 73 | Task { 74 | viewModel.addMessage(id: UUID().uuidString, sender: viewModel.username, content: inputText) 75 | try await viewModel.sendMessage(message: inputText) 76 | inputText = "" 77 | } 78 | } 79 | } 80 | 81 | struct MessageView: View { 82 | let message: Message 83 | let selfUser: String 84 | 85 | var body: some View { 86 | HStack { 87 | let isSelf = message.sender == selfUser 88 | if isSelf { 89 | Spacer() 90 | VStack(alignment: .trailing) { 91 | Text(message.sender) 92 | .font(.caption) 93 | .bold() 94 | .foregroundColor(.green) 95 | Text(message.content) 96 | .padding(8) 97 | .background(Color.green.opacity(0.2)) 98 | .cornerRadius(8) 99 | } 100 | .frame(maxWidth: 250, alignment: .trailing) 101 | } else { 102 | VStack(alignment: .leading) { 103 | Text(message.sender) 104 | .font(.caption) 105 | .bold() 106 | .foregroundColor(.blue) 107 | Text(message.content) 108 | .padding(8) 109 | .background(Color.platformBackground) 110 | .cornerRadius(8) 111 | } 112 | .frame(maxWidth: 250, alignment: .leading) 113 | Spacer() 114 | } 115 | } 116 | } 117 | } 118 | 119 | struct UserEntryView: View { 120 | @State var username: String = "" 121 | @State var group: String = "" 122 | @Binding var isPresented: Bool 123 | var viewModel: ViewModel 124 | 125 | var body: some View { 126 | VStack { 127 | Text("Enter your username") 128 | .font(.headline) 129 | .padding() 130 | 131 | TextField("Username", text: $username) 132 | .textFieldStyle(RoundedBorderTextFieldStyle()) 133 | .padding() 134 | 135 | TextField("Create or Join Group", text: $group) 136 | .textFieldStyle(RoundedBorderTextFieldStyle()) 137 | .padding() 138 | 139 | Button(action: { 140 | if !username.isEmpty && !group.isEmpty { 141 | isPresented = false 142 | viewModel.username = username 143 | viewModel.group = group 144 | 145 | Task { 146 | try await viewModel.setupConnection() 147 | } 148 | } 149 | }) { 150 | Text("Enter") 151 | } 152 | .keyboardShortcut(.defaultAction) 153 | .controlSize(.regular) 154 | .buttonStyle(.borderedProminent) 155 | .frame(width: 120) 156 | } 157 | .padding() 158 | } 159 | } 160 | 161 | #Preview { 162 | ContentView() 163 | } 164 | 165 | extension Color { 166 | static var platformBackground : Color { 167 | #if os(macOS) 168 | return Color(NSColor.windowBackgroundColor) 169 | #else 170 | return Color(UIColor.systemBackground) 171 | #endif 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /Sample/AIStream/AIStreaming/AIStreaming/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sample/AIStream/AIStreaming/AIStreaming/ViewModel.swift: -------------------------------------------------------------------------------- 1 | 2 | import SwiftUI 3 | import SignalRClient 4 | 5 | struct Message: Identifiable, Equatable { 6 | let id: String? 7 | let sender: String 8 | var content: String 9 | } 10 | 11 | @MainActor 12 | class ViewModel: ObservableObject { 13 | @Published var messages: [Message] = [] 14 | @Published var isConnected: Bool = false 15 | var username: String = "" 16 | var group: String = "" 17 | private var connection: HubConnection? 18 | 19 | func setupConnection() async throws { 20 | guard connection == nil else { 21 | return 22 | } 23 | 24 | connection = HubConnectionBuilder() 25 | .withUrl(url: "http://localhost:8080/groupChat") 26 | .withAutomaticReconnect() 27 | .build() 28 | 29 | await connection!.on("NewMessage") { (user: String, message: String) in 30 | self.addMessage(id: UUID().uuidString, sender: user, content: message) 31 | } 32 | 33 | await connection!.on("newMessageWithId") { (user: String, id: String, chunk: String) in 34 | self.addOrUpdateMessage(id: id, sender: user, chunk: chunk) 35 | } 36 | 37 | await connection!.onReconnected { [weak self] in 38 | guard let self = self else { return } 39 | do { 40 | try await self.joinGroup() 41 | } catch { 42 | print(error) 43 | } 44 | } 45 | 46 | try await connection!.start() 47 | try await joinGroup() 48 | isConnected = true 49 | } 50 | 51 | func sendMessage(message: String) async throws { 52 | try await connection?.send(method: "Chat", arguments: self.username, message) 53 | } 54 | 55 | func joinGroup() async throws { 56 | try await connection?.invoke(method: "JoinGroup", arguments: self.group) 57 | } 58 | 59 | func addMessage(id: String?, sender: String, content: String) { 60 | DispatchQueue.main.async { 61 | self.messages.append(Message(id: id, sender: sender, content: content)) 62 | } 63 | } 64 | 65 | func addOrUpdateMessage(id: String, sender: String, chunk: String) { 66 | DispatchQueue.main.async { 67 | if let index = self.messages.firstIndex(where: {$0.id == id}) { 68 | self.messages[index].content = chunk 69 | } else { 70 | self.messages.append(Message(id: id, sender: sender, content: chunk)) 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sample/AIStream/Server/AIStreaming.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Sample/AIStream/Server/AIStreaming.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.10.35201.131 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AIStreaming", "AIStreaming.csproj", "{6DC96F1B-8F65-471C-9676-0DD3E0A8A5BB}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {6DC96F1B-8F65-471C-9676-0DD3E0A8A5BB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {6DC96F1B-8F65-471C-9676-0DD3E0A8A5BB}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {6DC96F1B-8F65-471C-9676-0DD3E0A8A5BB}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {6DC96F1B-8F65-471C-9676-0DD3E0A8A5BB}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {7D19F711-78E2-46B8-B90B-33CF6F10724A} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /Sample/AIStream/Server/GroupAccessor.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | 3 | namespace AIStreaming 4 | { 5 | public class GroupAccessor 6 | { 7 | private readonly ConcurrentDictionary _store = new(); 8 | 9 | public void Join(string connectionId, string groupName) 10 | { 11 | _store.AddOrUpdate(connectionId, groupName, (key, value) => groupName); 12 | } 13 | 14 | public void Leave(string connectionId) 15 | { 16 | _store.TryRemove(connectionId, out _); 17 | } 18 | 19 | public bool TryGetGroup(string connectionId, out string? group) 20 | { 21 | return _store.TryGetValue(connectionId, out group); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sample/AIStream/Server/GroupHistoryStore.cs: -------------------------------------------------------------------------------- 1 | using OpenAI.Chat; 2 | using System.Collections.Concurrent; 3 | 4 | namespace AIStreaming 5 | { 6 | public class GroupHistoryStore 7 | { 8 | private readonly ConcurrentDictionary> _store = new(); 9 | 10 | public IReadOnlyList GetOrAddGroupHistory(string groupName, string userName, string message) 11 | { 12 | var chatMessages = _store.GetOrAdd(groupName, _ => InitiateChatMessages()); 13 | chatMessages.Add(new UserChatMessage(GenerateUserChatMessage(userName, message))); 14 | return chatMessages.AsReadOnly(); 15 | } 16 | 17 | public void UpdateGroupHistoryForAssistant(string groupName, string message) 18 | { 19 | var chatMessages = _store.GetOrAdd(groupName, _ => InitiateChatMessages()); 20 | chatMessages.Add(new AssistantChatMessage(message)); 21 | } 22 | 23 | private IList InitiateChatMessages() 24 | { 25 | var messages = new List 26 | { 27 | new SystemChatMessage("You are a friendly and knowledgeable assistant participating in a group discussion." + 28 | " Your role is to provide helpful, accurate, and concise information when addressed." + 29 | " Maintain a respectful tone, ensure your responses are clear and relevant to the group's ongoing conversation, and assist in facilitating productive discussions." + 30 | " Messages from users will be in the format 'UserName: chat messages'." + 31 | " Pay attention to the 'UserName' to understand who is speaking and tailor your responses accordingly."), 32 | }; 33 | return messages; 34 | } 35 | 36 | private string GenerateUserChatMessage(string userName, string message) 37 | { 38 | return $"{userName}: {message}"; 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /Sample/AIStream/Server/Hubs/GroupChatHub.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.SignalR; 2 | using Microsoft.Extensions.Options; 3 | using OpenAI; 4 | using System.Text; 5 | 6 | namespace AIStreaming.Hubs 7 | { 8 | public class GroupChatHub : Hub 9 | { 10 | private readonly GroupAccessor _groupAccessor; 11 | private readonly GroupHistoryStore _history; 12 | private readonly OpenAIClient _openAI; 13 | private readonly OpenAIOptions _options; 14 | 15 | public GroupChatHub(GroupAccessor groupAccessor, GroupHistoryStore history, OpenAIClient openAI, IOptions options) 16 | { 17 | _groupAccessor = groupAccessor ?? throw new ArgumentNullException(nameof(groupAccessor)); 18 | _history = history ?? throw new ArgumentNullException(nameof(history)); 19 | _openAI = openAI ?? throw new ArgumentNullException(nameof(openAI)); 20 | _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); 21 | } 22 | 23 | public async Task JoinGroup(string groupName) 24 | { 25 | await Groups.AddToGroupAsync(Context.ConnectionId, groupName); 26 | _groupAccessor.Join(Context.ConnectionId, groupName); 27 | } 28 | 29 | public override Task OnDisconnectedAsync(Exception? exception) 30 | { 31 | _groupAccessor.Leave(Context.ConnectionId); 32 | return Task.CompletedTask; 33 | } 34 | 35 | public async Task Chat(string userName, string message) 36 | { 37 | if (!_groupAccessor.TryGetGroup(Context.ConnectionId, out var groupName)) 38 | { 39 | throw new InvalidOperationException("Not in a group."); 40 | } 41 | 42 | if (message.StartsWith("@gpt")) 43 | { 44 | var id = Guid.NewGuid().ToString(); 45 | var actualMessage = message.Substring(4).Trim(); 46 | var messagesIncludeHistory = _history.GetOrAddGroupHistory(groupName, userName, actualMessage); 47 | await Clients.OthersInGroup(groupName).SendAsync("NewMessage", userName, message); 48 | 49 | var chatClient = _openAI.GetChatClient(_options.Model); 50 | var totalCompletion = new StringBuilder(); 51 | var lastSentTokenLength = 0; 52 | await foreach (var completion in chatClient.CompleteChatStreamingAsync(messagesIncludeHistory)) 53 | { 54 | foreach (var content in completion.ContentUpdate) 55 | { 56 | totalCompletion.Append(content); 57 | if (totalCompletion.Length - lastSentTokenLength > 20) 58 | { 59 | await Clients.Group(groupName).SendAsync("newMessageWithId", "ChatGPT", id, totalCompletion.ToString()); 60 | lastSentTokenLength = totalCompletion.Length; 61 | } 62 | } 63 | } 64 | _history.UpdateGroupHistoryForAssistant(groupName, totalCompletion.ToString()); 65 | await Clients.Group(groupName).SendAsync("newMessageWithId", "ChatGPT", id, totalCompletion.ToString()); 66 | } 67 | else 68 | { 69 | _history.GetOrAddGroupHistory(groupName, userName, message); 70 | await Clients.OthersInGroup(groupName).SendAsync("NewMessage", userName, message); 71 | } 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sample/AIStream/Server/OpenAIExtensions.cs: -------------------------------------------------------------------------------- 1 | using Azure.AI.OpenAI; 2 | using Microsoft.Extensions.Options; 3 | using OpenAI; 4 | using System.ClientModel; 5 | 6 | namespace AIStreaming 7 | { 8 | public static class OpenAIExtensions 9 | { 10 | public static IServiceCollection AddAzureOpenAI(this IServiceCollection services, IConfiguration configuration) 11 | { 12 | return services 13 | .Configure(configuration.GetSection("OpenAI")) 14 | .AddSingleton(provider => 15 | { 16 | var options = provider.GetRequiredService>().Value; 17 | return new AzureOpenAIClient(new Uri(options.Endpoint), new ApiKeyCredential(options.Key)); 18 | }); 19 | } 20 | 21 | public static IServiceCollection AddOpenAI(this IServiceCollection services, IConfiguration configuration) 22 | { 23 | return services 24 | .Configure(configuration.GetSection("OpenAI")) 25 | .AddSingleton(provider => 26 | { 27 | var options = provider.GetRequiredService>().Value; 28 | return new OpenAIClient(new ApiKeyCredential(options.Key)); 29 | }); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Sample/AIStream/Server/OpenAIOptions.cs: -------------------------------------------------------------------------------- 1 | namespace AIStreaming 2 | { 3 | public class OpenAIOptions 4 | { 5 | /// 6 | /// The endpoint of Azure OpenAI service. Only available for Azure OpenAI. 7 | /// 8 | public string? Endpoint { get; set; } 9 | 10 | /// 11 | /// The key of OpenAI service. 12 | /// 13 | public string? Key { get; set; } 14 | 15 | /// 16 | /// The model to use. 17 | /// 18 | public string? Model { get; set; } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Sample/AIStream/Server/Program.cs: -------------------------------------------------------------------------------- 1 | using AIStreaming; 2 | using AIStreaming.Hubs; 3 | 4 | var builder = WebApplication.CreateBuilder(args); 5 | 6 | // Add services to the container. 7 | builder.Services.AddSignalR(); 8 | builder.Services.AddSingleton() 9 | .AddSingleton() 10 | .AddAzureOpenAI(builder.Configuration); 11 | 12 | var app = builder.Build(); 13 | 14 | app.UseRouting(); 15 | 16 | app.MapHub("/groupChat"); 17 | app.Run(); 18 | -------------------------------------------------------------------------------- /Sample/AIStream/Server/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "AIStreaming": { 5 | "commandName": "Project", 6 | "launchBrowser": false, 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | }, 10 | "applicationUrl": "http://localhost:8080/" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Sample/AIStream/Server/README.md: -------------------------------------------------------------------------------- 1 | # AI Streaming with SignalR 2 | 3 | ## Introduction 4 | 5 | In the current landscape of digital communication, AI-powered chatbots and streaming technology have become increasingly popular. 6 | This project aims to combine these two trends into a seamless group chat application by leveraging SignalR for real-time communication and integrating ChatGPT. 7 | This project demonstrates SignalR group chats and ChatGPT integration. 8 | 9 | ## Features 10 | 11 | - Group Chat Functionality: Users can create or join groups and participate in a shared chat experience. 12 | - AI Integration: Users can interact with an AI chatbot by using the @gpt command, seamlessly integrating AI responses into the group chat. 13 | - Real-Time Streaming: The application supports real-time message streaming, ensuring that all participants receive updates instantly. 14 | 15 | ## Build and Run 16 | 17 | ### Prerequisites 18 | 19 | - [.NET 8.0](https://dotnet.microsoft.com/download/dotnet/8.0) 20 | - Azure OpenAI or OpenAI 21 | 22 | ### Steps 23 | 24 | - Edit `appsettings.json` and update the `Endpoint` and `Key` to be the endpoint and key of your Azure OpenAI instance. Find how to get endpoint and key [here](https://learn.microsoft.com/azure/ai-services/openai/chatgpt-quickstart?tabs=command-line%2Cpython-new&pivots=programming-language-csharp#retrieve-key-and-endpoint). 25 | Update the `model` to your deployment name of Azure OpenAI. 26 | 27 | - Edit `Azure.SignalR.ConnectionString` in `appsettings.json` to the connection string of Azure SignalR Service. For the security concern, we suggest using 28 | identity based connection string. 29 | 30 | ``` 31 | Endpoint=xxx;AuthType=azure 32 | ``` 33 | 34 | And then you need to grant your user the `SignalR App Server ` role. For more connection string details, please access to [Connection String](https://learn.microsoft.com/en-us/azure/azure-signalr/concept-connection-string), and for more details about permission, please access to [Assign Azure roles for access rights](https://learn.microsoft.com/azure/azure-signalr/signalr-concept-authorize-azure-active-directory#assign-azure-roles-for-access-rights). 35 | 36 | 37 | Run the project with: 38 | 39 | ```bash 40 | dotnet run 41 | ``` 42 | 43 | Open your browser and navigate to http://localhost:5000 to see the application in action. 44 | 45 | ![chat sample](./images/chat.jpg) 46 | 47 | ### Use OpenAI instead of Azure OpenAI 48 | 49 | You can also use OpenAI instead of Azure OpenAI with some minor changes. 50 | 51 | 1. Update the `appsettings.json`: 52 | 53 | ```json 54 | "OpenAI": { 55 | "Endpoint": null, // Leave it null 56 | "key": "", 57 | "Model": "gpt-4o" 58 | } 59 | ``` 60 | 61 | 2. Update the `Program.cs`: 62 | 63 | ```csharp 64 | builder.Services.AddSingleton() 65 | .AddSingleton() 66 | // .AddAzureOpenAI(builder.Configuration); // Comment this line and add the below line 67 | .AddOpenAI(builder.Configuration); 68 | ``` 69 | 70 | ## How It Works 71 | 72 | ### 1. Group Chat 73 | 74 | When a user sends a message in the chat, it is broadcast to all other members of the group using SignalR. If the message does not contain the `@gpt` prefix, it is treated as a regular message, stored in the group’s chat history, and use `Clients.OthersInGroup(groupName).SendAsync()` to send to all connected users. 75 | 76 | ### 2. AI Interaction and Streaming 77 | 78 | If a message begins with @gpt, the application interprets it as a request to involve the AI chatbot powered by OpenAI. Below are some key details on how this interaction works. 79 | 80 | #### Roles in chat completion 81 | 82 | The OpenAI Chat Completions API supports three distinct roles for generating responses: assistant, user, and system. 83 | 84 | - The assistant role stores previous AI responses. 85 | - The user role contains requests or comments by users. 86 | - The system role sets the guidelines for how the AI should respond. 87 | 88 | In this project, the system role is pre-configured to instruct the AI to act as a group chat assistant. This configuration, located in the GroupHistoryStore.cs file, ensures the AI responds in a manner that is friendly, knowledgeable, and contextually relevant to the ongoing group conversation. 89 | 90 | ```csharp 91 | new SystemChatMessage("You are a friendly and knowledgeable assistant participating in a group discussion." + 92 | " Your role is to provide helpful, accurate, and concise information when addressed." + 93 | " Maintain a respectful tone, ensure your responses are clear and relevant to the group's ongoing conversation, and assist in facilitating productive discussions." + 94 | " Messages from users will be in the format 'UserName: chat messages'." + 95 | " Pay attention to the 'UserName' to understand who is speaking and tailor your responses accordingly."), 96 | ``` 97 | 98 | All the messages from the user are in the `user` role, with the format `UserName: chat messages`. 99 | 100 | 101 | #### History content 102 | 103 | To enable the OpenAI model to generate context-aware responses, the chat history of the group is provided to the model. This history is managed by the GroupHistoryStore class, which maintains and updates the chat logs for each group. 104 | 105 | Both the user’s messages and the AI’s responses are stored in the chat history, with user messages labeled under the user role and AI responses under the assistant role. These history entries are then sent to the OpenAI model as a `IList`. 106 | 107 | ```csharp 108 | // GroupChatHub.cs 109 | chatClient.CompleteChatStreamingAsync(messagesIncludeHistory) 110 | ``` 111 | 112 | #### Workflow 113 | 114 | When a user sends a message starting with `@gpt`, the application sends the message together with the whole history to the OpenAI API for completion. The AI model generates a response based on the user's input and the group's chat history. 115 | 116 | The application uses the streaming capabilities of OpenAI to progressively send the AI's response back to the client as it is generated. The response is buffered and sent in chunks whenever the accumulated content exceeds a specific length, making the AI interaction feel more responsive. 117 | 118 | ```mermaid 119 | sequenceDiagram 120 | Client->>+Server: @gpt instructions? 121 | Server->>+OpenAI: instruction 122 | OpenAI->>Server: Partial Completion data token 123 | OpenAI->>Server: Partial Completion data token 124 | Server->>Client:Batch Partial Data 125 | OpenAI->>-Server: Partial Completion data token 126 | Server->>-Client:Batch Partial Data 127 | ``` 128 | -------------------------------------------------------------------------------- /Sample/AIStream/Server/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sample/AIStream/Server/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | }, 8 | "AllowedHosts": "*", 9 | "Azure": { 10 | "SignalR": { 11 | "ConnectionString": "" 12 | } 13 | }, 14 | "OpenAI": { 15 | "Endpoint": "", 16 | "key": "", 17 | "Model": "gpt-4o" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sample/AIStream/Server/images/chat.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotnet/signalr-client-swift/1e002ee858180d6ea85c480ea6b044774853f4fc/Sample/AIStream/Server/images/chat.jpg -------------------------------------------------------------------------------- /Sample/AIStream/Server/wwwroot/index.html: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | -------------------------------------------------------------------------------- /Sample/AzureSignalRConsoleApp/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "type": "lldb", 5 | "request": "launch", 6 | "args": [], 7 | "cwd": "${workspaceFolder:AzureSignalRConsoleApp}", 8 | "name": "Debug Sample", 9 | "program": "${workspaceFolder:AzureSignalRConsoleApp}/.build/debug/Sample", 10 | "preLaunchTask": "swift: Build Debug Sample" 11 | }, 12 | { 13 | "type": "lldb", 14 | "request": "launch", 15 | "args": [], 16 | "cwd": "${workspaceFolder:AzureSignalRConsoleApp}", 17 | "name": "Release Sample", 18 | "program": "${workspaceFolder:AzureSignalRConsoleApp}/.build/release/Sample", 19 | "preLaunchTask": "swift: Build Release Sample" 20 | } 21 | ] 22 | } -------------------------------------------------------------------------------- /Sample/AzureSignalRConsoleApp/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "ed0a05cf09d38e9df82cf80e85da7e1d26192274ce2dd61547acb663b131dc76", 3 | "pins" : [ 4 | { 5 | "identity" : "signalr-client-swift", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/dotnet/signalr-client-swift", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "ccb7cdd993fdeb95e2f0587e766e902a1bddd06a" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Sample/AzureSignalRConsoleApp/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Sample", 6 | platforms: [ 7 | .macOS(.v11) 8 | ], 9 | dependencies: [ 10 | .package(url: "https://github.com/dotnet/signalr-client-swift", branch: "main") 11 | ], 12 | targets: [ 13 | .executableTarget( 14 | name: "Sample", 15 | dependencies: [.product(name: "SignalRClient", package: "signalr-client-swift")] 16 | ) 17 | ] 18 | ) 19 | -------------------------------------------------------------------------------- /Sample/AzureSignalRConsoleApp/Server/ChatHub.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.SignalR; 2 | 3 | namespace ChatAppServer; 4 | 5 | public class Chat: Hub 6 | { 7 | public async Task Echo(string message) 8 | { 9 | await Clients.Caller.SendAsync("ReceiveMessage", message); 10 | } 11 | } -------------------------------------------------------------------------------- /Sample/AzureSignalRConsoleApp/Server/Program.cs: -------------------------------------------------------------------------------- 1 | using ChatAppServer; 2 | 3 | var builder = WebApplication.CreateBuilder(args); 4 | builder.Services.AddSignalR().AddAzureSignalR(); 5 | 6 | var app = builder.Build(); 7 | 8 | app.UseRouting(); 9 | app.MapHub("/chat"); 10 | app.Run(); 11 | -------------------------------------------------------------------------------- /Sample/AzureSignalRConsoleApp/Server/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": false, 8 | "applicationUrl": "http://localhost:8080", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sample/AzureSignalRConsoleApp/Server/Server.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /Sample/AzureSignalRConsoleApp/Server/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft.AspNetCore": "Debug" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sample/AzureSignalRConsoleApp/Server/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft.AspNetCore": "Debug" 6 | } 7 | }, 8 | "Azure":{ 9 | "SignalR": { 10 | "ConnectionString":"" 11 | } 12 | }, 13 | "AllowedHosts": "*" 14 | } 15 | -------------------------------------------------------------------------------- /Sample/AzureSignalRConsoleApp/Sources/Sample/main.swift: -------------------------------------------------------------------------------- 1 | import SignalRClient 2 | import Foundation 3 | 4 | let client = HubConnectionBuilder() 5 | .withUrl(url: String("http://localhost:8080/chat")) 6 | .withAutomaticReconnect() 7 | .build() 8 | 9 | await client.on("ReceiveMessage") { (message: String) in 10 | print("Received message: \(message)") 11 | } 12 | 13 | try await client.start() 14 | 15 | try await client.invoke(method: "Echo", arguments: "Hello") 16 | -------------------------------------------------------------------------------- /Sample/ChatRoom/ChatRoom.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Sample/ChatRoom/ChatRoom.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "089e8a98d8934104d085ef56f4bf4fdd7a860571d17e10aca6bc14cea5ca4409", 3 | "pins" : [ 4 | { 5 | "identity" : "signalr-client-swift", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/dotnet/signalr-client-swift.git", 8 | "state" : { 9 | "branch" : "main", 10 | "revision" : "0046cec26c8c0b252778373c4b1ad3b303ba7956" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Sample/ChatRoom/ChatRoom/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Sample/ChatRoom/ChatRoom/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | }, 8 | { 9 | "appearances" : [ 10 | { 11 | "appearance" : "luminosity", 12 | "value" : "dark" 13 | } 14 | ], 15 | "idiom" : "universal", 16 | "platform" : "ios", 17 | "size" : "1024x1024" 18 | }, 19 | { 20 | "appearances" : [ 21 | { 22 | "appearance" : "luminosity", 23 | "value" : "tinted" 24 | } 25 | ], 26 | "idiom" : "universal", 27 | "platform" : "ios", 28 | "size" : "1024x1024" 29 | }, 30 | { 31 | "idiom" : "mac", 32 | "scale" : "1x", 33 | "size" : "16x16" 34 | }, 35 | { 36 | "idiom" : "mac", 37 | "scale" : "2x", 38 | "size" : "16x16" 39 | }, 40 | { 41 | "idiom" : "mac", 42 | "scale" : "1x", 43 | "size" : "32x32" 44 | }, 45 | { 46 | "idiom" : "mac", 47 | "scale" : "2x", 48 | "size" : "32x32" 49 | }, 50 | { 51 | "idiom" : "mac", 52 | "scale" : "1x", 53 | "size" : "128x128" 54 | }, 55 | { 56 | "idiom" : "mac", 57 | "scale" : "2x", 58 | "size" : "128x128" 59 | }, 60 | { 61 | "idiom" : "mac", 62 | "scale" : "1x", 63 | "size" : "256x256" 64 | }, 65 | { 66 | "idiom" : "mac", 67 | "scale" : "2x", 68 | "size" : "256x256" 69 | }, 70 | { 71 | "idiom" : "mac", 72 | "scale" : "1x", 73 | "size" : "512x512" 74 | }, 75 | { 76 | "idiom" : "mac", 77 | "scale" : "2x", 78 | "size" : "512x512" 79 | } 80 | ], 81 | "info" : { 82 | "author" : "xcode", 83 | "version" : 1 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sample/ChatRoom/ChatRoom/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sample/ChatRoom/ChatRoom/ChatRoom.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.security.app-sandbox 6 | 7 | com.apple.security.files.user-selected.read-only 8 | 9 | com.apple.security.network.client 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Sample/ChatRoom/ChatRoom/ChatRoomApp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct ChatRoomApp: App { 5 | var body: some Scene { 6 | WindowGroup { 7 | ContentView() 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Sample/ChatRoom/ChatRoom/ChatViewModel.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | import SignalRClient 3 | 4 | @MainActor 5 | class ChatViewModel: ObservableObject { 6 | @Published var messages: [String] = [] 7 | @Published var isConnected: Bool = false 8 | var username: String = "" 9 | private var connection: HubConnection? 10 | 11 | func setupConnection() async throws { 12 | guard connection == nil else { 13 | return 14 | } 15 | 16 | connection = HubConnectionBuilder() 17 | .withUrl(url: "http://localhost:8080/chat") 18 | .withAutomaticReconnect() 19 | .build() 20 | 21 | await connection!.on("message") { (user: String, message: String) in 22 | DispatchQueue.main.async { 23 | self.messages.append("\(user): \(message)") 24 | } 25 | } 26 | 27 | try await connection!.start() 28 | isConnected = true 29 | } 30 | 31 | func sendMessage(user: String, message: String) async throws { 32 | try await connection?.invoke(method: "Broadcast", arguments: username, message) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sample/ChatRoom/ChatRoom/ContentView.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | struct ContentView: View { 4 | @StateObject private var viewModel = ChatViewModel() 5 | @State private var messageText: String = "" 6 | @State private var username: String = "" 7 | @State private var isShowingUsernameSheet: Bool = true 8 | 9 | var body: some View { 10 | VStack { 11 | Text(viewModel.isConnected ? "Connected" : "Disconnected") 12 | .font(.caption) 13 | .frame(maxWidth: .infinity) 14 | .padding(5) 15 | .background(viewModel.isConnected ? Color.green.opacity(0.8) : Color.red.opacity(0.8)) 16 | .foregroundColor(.white) 17 | 18 | Text("User: \(username)") 19 | .font(.headline) 20 | .frame(minHeight: 15) 21 | .padding() 22 | 23 | List(viewModel.messages, id: \.self) { message in 24 | Text(message) 25 | .padding() 26 | .background(Color.gray.opacity(0.2)) 27 | .cornerRadius(8) 28 | } 29 | 30 | HStack { 31 | TextField("Type your message here...", text: $messageText) 32 | .textFieldStyle(RoundedBorderTextFieldStyle()) 33 | .frame(minHeight: 15) 34 | .padding() 35 | 36 | Button(action: { 37 | Task { 38 | try await viewModel.sendMessage(user: "user", message: messageText) 39 | messageText = "" 40 | } 41 | }) { 42 | Text("Send") 43 | } 44 | .keyboardShortcut(.defaultAction) 45 | .controlSize(.regular) 46 | .buttonStyle(.borderedProminent) 47 | .padding() 48 | } 49 | .padding() 50 | } 51 | .sheet(isPresented: $isShowingUsernameSheet) { 52 | UsernameEntryView(username: $username, isPresented: $isShowingUsernameSheet, viewModel: viewModel) 53 | .frame(width: 300, height: 200) 54 | } 55 | } 56 | } 57 | 58 | struct UsernameEntryView: View { 59 | @Binding var username: String 60 | @Binding var isPresented: Bool 61 | var viewModel: ChatViewModel 62 | 63 | var body: some View { 64 | VStack { 65 | Text("Enter your username") 66 | .font(.headline) 67 | .padding() 68 | 69 | TextField("Username", text: $username) 70 | .textFieldStyle(RoundedBorderTextFieldStyle()) 71 | .padding() 72 | 73 | Button(action: { 74 | if !username.isEmpty { 75 | isPresented = false 76 | viewModel.username = username 77 | 78 | Task { 79 | try await viewModel.setupConnection() 80 | } 81 | } 82 | }) { 83 | Text("Enter") 84 | } 85 | .keyboardShortcut(.defaultAction) 86 | .controlSize(.regular) 87 | .buttonStyle(.borderedProminent) 88 | .frame(width: 120) 89 | } 90 | .padding() 91 | } 92 | } 93 | 94 | #Preview { 95 | ContentView() 96 | } 97 | -------------------------------------------------------------------------------- /Sample/ChatRoom/ChatRoom/Preview Content/Preview Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Sample/ChatRoom/README.md: -------------------------------------------------------------------------------- 1 | # ChatApp Sample with SignalR Swift Client 2 | 3 | A simple chat application built using SwiftUI and SignalR for real-time messaging. This project demonstrates how to establish SignalR client with a SignalR server, send and receive messages.count 4 | 5 | ## Prerequests 6 | 7 | - Swift >= 5.10 8 | - macOS >= 11.0 9 | - Dotnet >= 8 (Server needs dotnet) 10 | 11 | ## Run the sample 12 | 13 | SignalR is a client-server protocol and currently server side only has dotnet library. 14 | 15 | ### Run the Server 16 | 17 | ```bash 18 | cd Server 19 | dotnet run 20 | ``` 21 | 22 | ### Run the Client 23 | 24 | Open ChatRoom.xcodeproj with XCode and run the project 25 | 26 | You can see the app as the snapshot: 27 | 28 | ![alt text](snapshort.png) 29 | 30 | You can also open multiple clients and the message can be broadcasted to all clients. -------------------------------------------------------------------------------- /Sample/ChatRoom/Server/ChatHub.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.SignalR; 2 | 3 | namespace ChatAppServer; 4 | 5 | public class Chat: Hub 6 | { 7 | public async Task Broadcast(string userName, string message) 8 | { 9 | await Clients.All.SendAsync("message", userName, message); 10 | } 11 | } -------------------------------------------------------------------------------- /Sample/ChatRoom/Server/Program.cs: -------------------------------------------------------------------------------- 1 | using ChatAppServer; 2 | 3 | var builder = WebApplication.CreateBuilder(args); 4 | builder.Services.AddSignalR(); 5 | 6 | var app = builder.Build(); 7 | 8 | app.UseRouting(); 9 | app.MapHub("/chat"); 10 | app.Run(); 11 | -------------------------------------------------------------------------------- /Sample/ChatRoom/Server/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": false, 8 | "applicationUrl": "http://localhost:8080", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sample/ChatRoom/Server/Server.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Sample/ChatRoom/Server/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft.AspNetCore": "Debug" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sample/ChatRoom/Server/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft.AspNetCore": "Debug" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /Sample/ChatRoom/snapshort.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotnet/signalr-client-swift/1e002ee858180d6ea85c480ea6b044774853f4fc/Sample/ChatRoom/snapshort.png -------------------------------------------------------------------------------- /Sample/ConsoleApp/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "f7e7b7b4ae2809875a0feb4a5faf90213865363cd555001d858319eac79381c2", 3 | "pins" : [ 4 | { 5 | "identity" : "eventsource", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/inaka/EventSource.git", 8 | "state" : { 9 | "branch" : "78934b3", 10 | "revision" : "78934b361891c7d0fa3d399d128e959f0c94d267" 11 | } 12 | }, 13 | { 14 | "identity" : "messagepacker", 15 | "kind" : "remoteSourceControl", 16 | "location" : "https://github.com/hirotakan/MessagePacker.git", 17 | "state" : { 18 | "revision" : "4d8346c6bc579347e4df0429493760691c5aeca2", 19 | "version" : "0.4.7" 20 | } 21 | } 22 | ], 23 | "version" : 3 24 | } 25 | -------------------------------------------------------------------------------- /Sample/ConsoleApp/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.10 2 | import PackageDescription 3 | 4 | let package = Package( 5 | name: "Sample", 6 | platforms: [ 7 | .macOS(.v11) 8 | ], 9 | dependencies: [ 10 | .package(url: "https://github.com/dotnet/signalr-client-swift", branch: "main") 11 | ], 12 | targets: [ 13 | .executableTarget( 14 | name: "Sample", 15 | dependencies: [.product(name: "SignalRClient", package: "signalr-client-swift")] 16 | ) 17 | ] 18 | ) 19 | -------------------------------------------------------------------------------- /Sample/ConsoleApp/Server/ChatHub.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.SignalR; 2 | 3 | namespace ChatAppServer; 4 | 5 | public class Chat: Hub 6 | { 7 | public async Task Echo(string message) 8 | { 9 | await Clients.Caller.SendAsync("ReceiveMessage", message); 10 | } 11 | } -------------------------------------------------------------------------------- /Sample/ConsoleApp/Server/Program.cs: -------------------------------------------------------------------------------- 1 | using ChatAppServer; 2 | 3 | var builder = WebApplication.CreateBuilder(args); 4 | builder.Services.AddSignalR(); 5 | 6 | var app = builder.Build(); 7 | 8 | app.UseRouting(); 9 | app.MapHub("/chat"); 10 | app.Run(); 11 | -------------------------------------------------------------------------------- /Sample/ConsoleApp/Server/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": false, 8 | "applicationUrl": "http://localhost:8080", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Sample/ConsoleApp/Server/Server.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /Sample/ConsoleApp/Server/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft.AspNetCore": "Debug" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Sample/ConsoleApp/Server/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft.AspNetCore": "Debug" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /Sample/ConsoleApp/Sources/Sample/main.swift: -------------------------------------------------------------------------------- 1 | import SignalRClient 2 | import Foundation 3 | 4 | let client = HubConnectionBuilder() 5 | .withUrl(url: String("http://localhost:8080/chat")) 6 | .withAutomaticReconnect() 7 | .build() 8 | 9 | await client.on("ReceiveMessage") { (message: String) in 10 | print("Received message: \(message)") 11 | } 12 | 13 | try await client.start() 14 | 15 | try await client.invoke(method: "Echo", arguments: "Hello") 16 | -------------------------------------------------------------------------------- /Sample/README.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dotnet/signalr-client-swift/1e002ee858180d6ea85c480ea6b044774853f4fc/Sample/README.md -------------------------------------------------------------------------------- /Sources/SignalRClient/AsyncLock.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | class AsyncLock { 7 | let lock = DispatchSemaphore(value: 1) 8 | private var isLocked = false 9 | private var waitQueue: [CheckedContinuation] = [] 10 | 11 | func wait() async { 12 | lock.wait() 13 | 14 | if !isLocked { 15 | defer { 16 | lock.signal() 17 | } 18 | 19 | isLocked = true 20 | return 21 | } 22 | 23 | await withCheckedContinuation { continuation in 24 | defer { lock.signal() } 25 | waitQueue.append(continuation) 26 | } 27 | } 28 | 29 | func release() { 30 | lock.wait() 31 | defer { 32 | lock.signal() 33 | } 34 | 35 | if let continuation = waitQueue.first { 36 | waitQueue.removeFirst() 37 | continuation.resume() 38 | } else { 39 | isLocked = false 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sources/SignalRClient/AtomicState.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | actor AtomicState { 5 | init(initialState: T) { 6 | self.state = initialState 7 | } 8 | private var state: T 9 | 10 | func compareExchange(expected: T, desired: T) -> T { 11 | let origin = state 12 | if (expected == state) { 13 | state = desired 14 | } 15 | return origin 16 | } 17 | } -------------------------------------------------------------------------------- /Sources/SignalRClient/ConnectionProtocol.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | protocol ConnectionProtocol: AnyObject, Sendable { 5 | func onReceive(_ handler: @escaping Transport.OnReceiveHandler) async 6 | func onClose(_ handler: @escaping Transport.OnCloseHander) async 7 | func start(transferFormat: TransferFormat) async throws 8 | func send(_ data: StringOrData) async throws 9 | func stop(error: Error?) async 10 | var inherentKeepAlive: Bool { get async } 11 | } -------------------------------------------------------------------------------- /Sources/SignalRClient/HandshakeProtocol.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | struct HandshakeRequestMessage: Codable { 7 | let `protocol`: String 8 | let version: Int 9 | } 10 | 11 | struct HandshakeResponseMessage: Codable { 12 | let error: String? 13 | let minorVersion: Int? 14 | } 15 | 16 | // Implement the HandshakeProtocol class 17 | class HandshakeProtocol { 18 | // Handshake request is always JSON 19 | static func writeHandshakeRequest(handshakeRequest: HandshakeRequestMessage) throws -> String { 20 | let encoder = JSONEncoder() 21 | let data = try encoder.encode(handshakeRequest) 22 | guard let jsonString = String(data: data, encoding: .utf8) else { 23 | throw SignalRError.failedToEncodeHandshakeRequest 24 | } 25 | return TextMessageFormat.write(jsonString) 26 | } 27 | 28 | static func parseHandshakeResponse(data: StringOrData) throws -> (StringOrData?, HandshakeResponseMessage) { 29 | var messageData: String 30 | var remainingData: StringOrData? 31 | 32 | switch data { 33 | case .string(let textData): 34 | if let separatorIndex = textData.firstIndex(of: Character(TextMessageFormat.recordSeparator)) { 35 | let responseLength = textData.distance(from: textData.startIndex, to: separatorIndex) + 1 36 | let messageRange = textData.startIndex ..< textData.index(textData.startIndex, offsetBy: responseLength) 37 | messageData = String(textData[messageRange]) 38 | remainingData = (textData.count > responseLength) ? .string(String(textData[textData.index(textData.startIndex, offsetBy: responseLength)...])) : nil 39 | } else { 40 | throw SignalRError.incompleteMessage 41 | } 42 | case .data(let binaryData): 43 | if let separatorIndex = binaryData.firstIndex(of: TextMessageFormat.recordSeparatorCode) { 44 | let responseLength = separatorIndex + 1 45 | let responseData = binaryData.subdata(in: 0 ..< responseLength) 46 | guard let responseString = String(data: responseData, encoding: .utf8) else { 47 | throw SignalRError.failedToDecodeResponseData 48 | } 49 | messageData = responseString 50 | remainingData = (binaryData.count > responseLength) ? .data(binaryData.subdata(in: responseLength ..< binaryData.count)) : nil 51 | } else { 52 | throw SignalRError.incompleteMessage 53 | } 54 | } 55 | 56 | // At this point we should have just the single handshake message 57 | let messages = try TextMessageFormat.parse(messageData) 58 | guard let firstMessage = messages.first else { 59 | throw SignalRError.noHandshakeMessageReceived 60 | } 61 | 62 | // Parse JSON and check for unexpected "type" field 63 | guard let jsonData = firstMessage.data(using: .utf8) else { 64 | throw SignalRError.failedToDecodeResponseData 65 | } 66 | 67 | let jsonObject = try JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] 68 | if let jsonObject = jsonObject, jsonObject["type"] != nil { // contains type means a normal message 69 | throw SignalRError.expectedHandshakeResponse 70 | } 71 | 72 | // Decode the handshake response message 73 | let decoder = JSONDecoder() 74 | let responseMessage = try decoder.decode(HandshakeResponseMessage.self, from: jsonData) 75 | 76 | // Return the remaining data and the response message 77 | return (remainingData, responseMessage) 78 | } 79 | } -------------------------------------------------------------------------------- /Sources/SignalRClient/HttpClient.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | #if canImport(FoundationNetworking) 7 | import FoundationNetworking 8 | #endif 9 | 10 | // MARK: - HttpRequest and HttpResponse 11 | 12 | public enum HttpMethod: String, Sendable { 13 | case GET, PUT, PATCH, POST, DELETE 14 | } 15 | 16 | public struct HttpRequest: Sendable { 17 | var method: HttpMethod 18 | var url: String 19 | var content: StringOrData? 20 | var headers: [String: String] 21 | var timeout: TimeInterval 22 | var responseType: TransferFormat 23 | 24 | public init( 25 | method: HttpMethod, url: String, content: StringOrData? = nil, 26 | responseType: TransferFormat? = nil, 27 | headers: [String: String]? = nil, timeout: TimeInterval? = nil 28 | ) { 29 | self.method = method 30 | self.url = url 31 | self.content = content 32 | self.headers = headers ?? [:] 33 | self.timeout = timeout ?? 100 34 | if responseType != nil { 35 | self.responseType = responseType! 36 | } else { 37 | switch content { 38 | case .data(_): 39 | self.responseType = TransferFormat.binary 40 | default: 41 | self.responseType = TransferFormat.text 42 | } 43 | } 44 | } 45 | } 46 | 47 | public struct HttpResponse { 48 | public let statusCode: Int 49 | } 50 | 51 | // MARK: - HttpClient Protocol 52 | 53 | public protocol HttpClient: Sendable { 54 | // Don't throw if the http call returns a status code out of [200, 299] 55 | func send(request: HttpRequest) async throws -> (StringOrData, HttpResponse) 56 | } 57 | 58 | actor DefaultHttpClient: HttpClient { 59 | private let logger: Logger 60 | private let session: URLSession 61 | 62 | init(logger: Logger) { 63 | self.logger = logger 64 | self.session = URLSession(configuration: URLSessionConfiguration.default) 65 | } 66 | 67 | public func send(request: HttpRequest) async throws -> ( 68 | StringOrData, HttpResponse 69 | ) { 70 | do { 71 | let urlRequest = try request.buildURLRequest() 72 | let (data, response) = try await self.session.data( 73 | for: urlRequest) 74 | guard let httpURLResponse = response as? HTTPURLResponse else { 75 | throw SignalRError.invalidResponseType 76 | } 77 | let httpResponse = HttpResponse( 78 | statusCode: httpURLResponse.statusCode) 79 | let message = try data.convertToStringOrData( 80 | transferFormat: request.responseType) 81 | return (message, httpResponse) 82 | } catch { 83 | if let urlError = error as? URLError, 84 | urlError.code == URLError.timedOut { 85 | logger.log( 86 | level: .warning, message: "Timeout from HTTP request." 87 | ) 88 | throw SignalRError.httpTimeoutError 89 | } 90 | logger.log( 91 | level: .warning, message: "Error from HTTP request: \(error)" 92 | ) 93 | throw error 94 | } 95 | } 96 | } 97 | 98 | typealias AccessTokenFactory = () async throws -> String? 99 | 100 | actor AccessTokenHttpClient: HttpClient { 101 | var accessTokenFactory: AccessTokenFactory? 102 | var accessToken: String? 103 | private let innerClient: HttpClient 104 | 105 | public init( 106 | innerClient: HttpClient, 107 | accessTokenFactory: AccessTokenFactory? 108 | ) { 109 | self.innerClient = innerClient 110 | self.accessTokenFactory = accessTokenFactory 111 | } 112 | 113 | public func setAccessTokenFactory(factory: AccessTokenFactory?) { 114 | self.accessTokenFactory = factory 115 | } 116 | 117 | public func send(request: HttpRequest) async throws -> ( 118 | StringOrData, HttpResponse 119 | ) { 120 | var mutableRequest = request 121 | var allowRetry = true 122 | 123 | if let factory = accessTokenFactory, 124 | accessToken == nil || (request.url.contains("/negotiate?")) { 125 | // Don't retry if the request is a negotiate or if we just got a potentially new token from the access token factory 126 | allowRetry = false 127 | accessToken = try await factory() 128 | } 129 | 130 | setAuthorizationHeader(request: &mutableRequest) 131 | 132 | var (data, httpResponse) = try await innerClient.send( 133 | request: mutableRequest) 134 | 135 | if allowRetry && httpResponse.statusCode == 401, 136 | let factory = accessTokenFactory { 137 | accessToken = try await factory() 138 | setAuthorizationHeader(request: &mutableRequest) 139 | (data, httpResponse) = try await innerClient.send( 140 | request: mutableRequest) 141 | 142 | return (data, httpResponse) 143 | } 144 | 145 | return (data, httpResponse) 146 | } 147 | 148 | private func setAuthorizationHeader(request: inout HttpRequest) { 149 | if let token = accessToken { 150 | request.headers["Authorization"] = "Bearer \(token)" 151 | } else if accessTokenFactory != nil { 152 | request.headers.removeValue(forKey: "Authorization") 153 | } 154 | } 155 | } 156 | 157 | extension HttpRequest { 158 | fileprivate func buildURLRequest() throws -> URLRequest { 159 | guard let url = URL(string: self.url) else { 160 | throw SignalRError.invalidUrl(self.url) 161 | } 162 | var urlRequest = URLRequest(url: url) 163 | urlRequest.httpMethod = method.rawValue 164 | urlRequest.timeoutInterval = timeout 165 | for (key, value) in headers { 166 | urlRequest.setValue(value, forHTTPHeaderField: key) 167 | } 168 | switch content { 169 | case .data(let data): 170 | urlRequest.httpBody = data 171 | urlRequest.setValue( 172 | "application/octet-stream", forHTTPHeaderField: "Content-Type" 173 | ) 174 | case .string(let strData): 175 | urlRequest.httpBody = strData.data(using: .utf8) 176 | urlRequest.setValue( 177 | "text/plain;charset=UTF-8", forHTTPHeaderField: "Content-Type" 178 | ) 179 | case nil: 180 | break 181 | } 182 | return urlRequest 183 | } 184 | } 185 | 186 | extension HttpResponse { 187 | func ok() -> Bool { 188 | return statusCode >= 200 && statusCode < 300 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /Sources/SignalRClient/HubConnectionBuilder.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | public class HubConnectionBuilder { 7 | private var connection: HttpConnection? 8 | private var logHandler: LogHandler? 9 | private var logLevel: LogLevel? 10 | private var hubProtocol: HubProtocol? 11 | private var serverTimeout: TimeInterval? 12 | private var keepAliveInterval: TimeInterval? 13 | private var url: String? 14 | private var retryPolicy: RetryPolicy? 15 | private var statefulReconnectBufferSize: Int? 16 | private var httpConnectionOptions: HttpConnectionOptions = HttpConnectionOptions() 17 | 18 | public init() {} 19 | 20 | public func withLogLevel(logLevel: LogLevel) -> HubConnectionBuilder { 21 | self.logLevel = logLevel 22 | self.httpConnectionOptions.logLevel = logLevel 23 | return self 24 | } 25 | 26 | public func withLogHandler(logHandler: LogHandler) -> HubConnectionBuilder { 27 | self.logHandler = logHandler 28 | return self 29 | } 30 | 31 | public func withHubProtocol(hubProtocol: HubProtocolType) -> HubConnectionBuilder { 32 | switch hubProtocol { 33 | case .json: 34 | self.hubProtocol = JsonHubProtocol() 35 | case .messagePack: 36 | self.hubProtocol = MessagePackHubProtocol() 37 | } 38 | return self 39 | } 40 | 41 | public func withServerTimeout(serverTimeout: TimeInterval) -> HubConnectionBuilder { 42 | self.serverTimeout = serverTimeout 43 | return self 44 | } 45 | 46 | public func withKeepAliveInterval(keepAliveInterval: TimeInterval) -> HubConnectionBuilder { 47 | self.keepAliveInterval = keepAliveInterval 48 | return self 49 | } 50 | 51 | public func withUrl(url: String) -> HubConnectionBuilder { 52 | self.url = url 53 | return self 54 | } 55 | 56 | public func withUrl(url: String, transport: HttpTransportType) -> HubConnectionBuilder { 57 | self.url = url 58 | self.httpConnectionOptions.transport = transport 59 | return self 60 | } 61 | 62 | public func withUrl(url: String, options: HttpConnectionOptions) -> HubConnectionBuilder { 63 | self.url = url 64 | self.httpConnectionOptions = options 65 | return self 66 | } 67 | 68 | public func withAutomaticReconnect() -> HubConnectionBuilder { 69 | self.retryPolicy = DefaultRetryPolicy(retryDelays: [0, 2, 10, 30]) 70 | return self 71 | } 72 | 73 | public func withAutomaticReconnect(retryPolicy: RetryPolicy) -> HubConnectionBuilder { 74 | self.retryPolicy = retryPolicy 75 | return self 76 | } 77 | 78 | public func withAutomaticReconnect(retryDelays: [TimeInterval]) -> HubConnectionBuilder { 79 | self.retryPolicy = DefaultRetryPolicy(retryDelays: retryDelays) 80 | return self 81 | } 82 | 83 | // public func withStatefulReconnect() -> HubConnectionBuilder { 84 | // return withStatefulReconnect(options: StatefulReconnectOptions()) 85 | // } 86 | // 87 | // public func withStatefulReconnect(options: StatefulReconnectOptions) -> HubConnectionBuilder { 88 | // self.statefulReconnectBufferSize = options.bufferSize 89 | // self.httpConnectionOptions.useStatefulReconnect = true 90 | // return self 91 | // } 92 | 93 | public func build() -> HubConnection { 94 | guard let url = url else { 95 | fatalError("url must be set with .withUrl(String:)") 96 | } 97 | 98 | let connection = connection ?? HttpConnection(url: url, options: httpConnectionOptions) 99 | let logger = Logger(logLevel: logLevel, logHandler: logHandler ?? DefaultLogHandler()) 100 | let hubProtocol = hubProtocol ?? JsonHubProtocol() 101 | let retryPolicy = retryPolicy ?? DefaultRetryPolicy(retryDelays: []) // No retry by default 102 | 103 | return HubConnection(connection: connection, 104 | logger: logger, 105 | hubProtocol: hubProtocol, 106 | retryPolicy: retryPolicy, 107 | serverTimeout: serverTimeout, 108 | keepAliveInterval: keepAliveInterval, 109 | statefulReconnectBufferSize: statefulReconnectBufferSize) 110 | } 111 | } 112 | 113 | public enum HubProtocolType { 114 | case json 115 | case messagePack 116 | } 117 | -------------------------------------------------------------------------------- /Sources/SignalRClient/InvocationBinder.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | protocol InvocationBinder: Sendable { 7 | func getReturnType(invocationId: String) -> Any.Type? 8 | func getParameterTypes(methodName: String) -> [Any.Type] 9 | func getStreamItemType(streamId: String) -> Any.Type? 10 | } 11 | -------------------------------------------------------------------------------- /Sources/SignalRClient/Logger.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | #if canImport(os) 6 | import os 7 | #endif 8 | 9 | public enum LogLevel: Int, Sendable { 10 | case debug, information, warning, error 11 | } 12 | 13 | public protocol LogHandler: Sendable { 14 | func log( 15 | logLevel: LogLevel, message: LogMessage, file: String, function: String, 16 | line: UInt 17 | ) 18 | } 19 | 20 | // The current functionality is similar to String. It could be extended in the future. 21 | public struct LogMessage: ExpressibleByStringInterpolation, 22 | CustomStringConvertible { 23 | private var value: String 24 | 25 | public init(stringLiteral value: String) { 26 | self.value = value 27 | } 28 | 29 | public var description: String { 30 | return self.value 31 | } 32 | } 33 | 34 | struct Logger: Sendable { 35 | private var logHandler: LogHandler 36 | private let logLevel: LogLevel? 37 | 38 | init(logLevel: LogLevel?, logHandler: LogHandler) { 39 | self.logLevel = logLevel 40 | self.logHandler = logHandler 41 | } 42 | 43 | public func log( 44 | level: LogLevel, message: LogMessage, file: String = #fileID, 45 | function: String = #function, line: UInt = #line 46 | ) { 47 | guard let minLevel = self.logLevel, level.rawValue >= minLevel.rawValue 48 | else { 49 | return 50 | } 51 | logHandler.log( 52 | logLevel: level, message: message, file: file, 53 | function: function, line: line 54 | ) 55 | } 56 | } 57 | 58 | #if canImport(os) 59 | struct DefaultLogHandler: LogHandler { 60 | var logger: os.Logger 61 | init() { 62 | self.logger = os.Logger( 63 | subsystem: "com.microsoft.signalr.client", category: "" 64 | ) 65 | } 66 | 67 | public func log( 68 | logLevel: LogLevel, message: LogMessage, file: String, function: String, 69 | line: UInt 70 | ) { 71 | logger.log( 72 | level: logLevel.toOSLogType(), 73 | "[\(Date().description(with: Locale.current), privacy: .public)] [\(String(describing: logLevel), privacy: .public)] [\(file.fileNameWithoutPathAndSuffix(), privacy: .public):\(function, privacy: .public):\(line, privacy: .public)] - \(message, privacy: .public)" 74 | ) 75 | } 76 | } 77 | 78 | extension LogLevel { 79 | fileprivate func toOSLogType() -> OSLogType { 80 | switch self { 81 | case .debug: 82 | return .debug 83 | case .information: 84 | return .info 85 | case .warning: 86 | // OSLog has no warning type 87 | return .info 88 | case .error: 89 | return .error 90 | } 91 | } 92 | } 93 | #else 94 | struct DefaultLogHandler: LogHandler { 95 | public func log( 96 | logLevel: LogLevel, message: LogMessage, file: String, function: String, 97 | line: UInt 98 | ) { 99 | print( 100 | "[\(Date().description(with: Locale.current))] [\(String(describing: logLevel))] [\(file.fileNameWithoutPathAndSuffix()):\(function):\(line)] - \(message)" 101 | ) 102 | } 103 | } 104 | #endif 105 | 106 | extension String { 107 | fileprivate func fileNameWithoutPathAndSuffix() -> String { 108 | return self.components(separatedBy: "/").last!.components( 109 | separatedBy: "." 110 | ).first! 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Sources/SignalRClient/MessageBuffer.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | actor MessageBuffer { 7 | private var maxBufferSize: Int 8 | private var messages: [BufferedItem] = [] 9 | private var bufferedByteCount: Int = 0 10 | private var totalMessageCount: Int = 0 11 | private var lastSendSequenceId: Int = 0 12 | private var nextSendIdx = 0 13 | private var dequeueContinuations: [CheckedContinuation] = [] 14 | private var closed: Bool = false 15 | 16 | init(bufferSize: Int) { 17 | self.maxBufferSize = bufferSize 18 | } 19 | 20 | public func enqueue(content: StringOrData) async throws -> Void { 21 | if closed { 22 | throw SignalRError.invalidOperation("Message buffer has closed") 23 | } 24 | 25 | var size: Int 26 | switch content { 27 | case .string(let str): 28 | size = str.lengthOfBytes(using: .utf8) 29 | case .data(let data): 30 | size = data.count 31 | } 32 | 33 | bufferedByteCount = bufferedByteCount + size 34 | totalMessageCount = totalMessageCount + 1 35 | 36 | return await withCheckedContinuation{ continuation in 37 | if (bufferedByteCount > maxBufferSize) { 38 | // If buffer is full, we're tring to backpressure the sending 39 | // id start from 1 40 | messages.append(BufferedItem(content: content, size: size, id: totalMessageCount, continuation: continuation)) 41 | } else { 42 | messages.append(BufferedItem(content: content, size: size, id: totalMessageCount, continuation: nil)) 43 | continuation.resume() 44 | } 45 | 46 | while !dequeueContinuations.isEmpty { 47 | let continuation = dequeueContinuations.removeFirst() 48 | continuation.resume(returning: true) 49 | } 50 | } 51 | } 52 | 53 | public func ack(sequenceId: Int) throws -> Bool { 54 | // It might be wrong ack or the ack of previous connection 55 | if (sequenceId <= 0 || sequenceId > lastSendSequenceId) { 56 | return false 57 | } 58 | 59 | var ackedCount: Int = 0 60 | for item in messages { 61 | if (item.id <= sequenceId) { 62 | ackedCount = ackedCount + 1 63 | bufferedByteCount = bufferedByteCount - item.size 64 | if let ctu = item.continuation { 65 | ctu.resume() 66 | } 67 | } else if (bufferedByteCount <= maxBufferSize) { 68 | if let ctu = item.continuation { 69 | ctu.resume() 70 | } 71 | } else { 72 | break 73 | } 74 | } 75 | 76 | messages = Array(messages.dropFirst(ackedCount)) 77 | // sending idx will change because we changes the array 78 | nextSendIdx = nextSendIdx - ackedCount 79 | return true 80 | } 81 | 82 | public func WaitToDequeue() async throws -> Bool { 83 | if (nextSendIdx < messages.count) { 84 | return true 85 | } 86 | 87 | return await withCheckedContinuation { continuation in 88 | dequeueContinuations.append(continuation) 89 | } 90 | } 91 | 92 | public func TryDequeue() throws -> StringOrData? { 93 | if (nextSendIdx < messages.count) { 94 | let item = messages[nextSendIdx] 95 | nextSendIdx = nextSendIdx + 1 96 | lastSendSequenceId = item.id 97 | return item.content 98 | } 99 | return nil 100 | } 101 | 102 | public func ResetDequeue() async throws -> Void { 103 | nextSendIdx = 0 104 | lastSendSequenceId = messages.count > 0 ? messages[0].id : 0 105 | while !dequeueContinuations.isEmpty { 106 | let continuation = dequeueContinuations.removeFirst() 107 | continuation.resume(returning: true) 108 | } 109 | } 110 | 111 | public func close() { 112 | closed = true 113 | while !dequeueContinuations.isEmpty { 114 | let continuation = dequeueContinuations.removeFirst() 115 | continuation.resume(returning: false) 116 | } 117 | } 118 | 119 | private func isInvocationMessage(message: HubMessage) -> Bool { 120 | switch (message.type) { 121 | case .invocation, .streamItem, .completion, .streamInvocation, .cancelInvocation: 122 | return true 123 | case .close, .sequence, .ping, .ack: 124 | return false 125 | } 126 | } 127 | } 128 | 129 | private class BufferedItem { 130 | let content: StringOrData 131 | let size: Int 132 | let id: Int 133 | let continuation: CheckedContinuation? 134 | 135 | init(content: StringOrData, 136 | size: Int, 137 | id: Int, 138 | continuation: CheckedContinuation?) { 139 | self.content = content 140 | self.size = size 141 | self.id = id 142 | self.continuation = continuation 143 | } 144 | } -------------------------------------------------------------------------------- /Sources/SignalRClient/Protocols/BinaryMessageFormat.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | // TODO: Move messagepack to a separate package 7 | class BinaryMessageFormat { 8 | private static let MessageSize2GB: Int = 1 << 31 9 | 10 | static func parse(_ data: Data) throws -> [Data] { 11 | var messages: [Data] = [] 12 | var index = 0 13 | 14 | while index < data.count { 15 | var number: UInt64 = 0 16 | var numberBytes = 0 17 | while numberBytes <= 5 { 18 | guard index < data.count else { 19 | throw SignalRError.incompleteMessage 20 | } 21 | let byte: UInt64 = UInt64(data[index]) 22 | number = number | (byte & 0x7f) << (7 * numberBytes) 23 | numberBytes += 1 24 | index += 1 25 | if byte & 0x80 == 0 { 26 | break 27 | } 28 | } 29 | guard numberBytes <= 5 else { 30 | throw SignalRError.invalidData("Invalid message size") 31 | } 32 | guard number <= MessageSize2GB else { 33 | throw SignalRError.messageBiggerThan2GB 34 | } 35 | guard number > 0 else { 36 | continue 37 | } 38 | if index + Int(number) > data.count { 39 | throw SignalRError.incompleteMessage 40 | } 41 | let message = data.subdata(in: index ..< (index + Int(number))) 42 | messages.append(message) 43 | index += Int(number) 44 | } 45 | return messages 46 | } 47 | 48 | static func write(_ data: Data) throws -> Data { 49 | var number = data.count 50 | guard number <= MessageSize2GB else { 51 | throw SignalRError.messageBiggerThan2GB 52 | } 53 | var bytes: [UInt8] = [] 54 | repeat { 55 | var byte = (UInt8)(number & 0x7f) 56 | number >>= 7 57 | if number > 0 { 58 | byte |= 0x80 59 | } 60 | bytes.append(byte) 61 | } while number > 0 62 | return Data(bytes) + data 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/SignalRClient/Protocols/HubMessage.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | /// Defines properties common to all Hub messages. 5 | protocol HubMessage: Encodable { 6 | /// A value indicating the type of this message. 7 | var type: MessageType { get } 8 | } 9 | 10 | /// Defines properties common to all Hub messages relating to a specific invocation. 11 | protocol HubInvocationMessage: HubMessage { 12 | /// A dictionary containing headers attached to the message. 13 | var headers: [String: String]? { get } 14 | /// The ID of the invocation relating to this message. 15 | var invocationId: String? { get } 16 | } 17 | 18 | /// A hub message representing a non-streaming invocation. 19 | struct InvocationMessage: HubInvocationMessage { 20 | /// The type of this message. 21 | let type: MessageType = .invocation 22 | /// The target method name. 23 | let target: String 24 | /// The target method arguments. 25 | let arguments: AnyEncodableArray 26 | /// The target methods stream IDs. 27 | let streamIds: [String]? 28 | /// Headers attached to the message. 29 | let headers: [String: String]? 30 | /// The ID of the invocation relating to this message. 31 | let invocationId: String? 32 | } 33 | 34 | /// A hub message representing a streaming invocation. 35 | struct StreamInvocationMessage: HubInvocationMessage { 36 | /// The type of this message. 37 | let type: MessageType = .streamInvocation 38 | /// The invocation ID. 39 | let invocationId: String? 40 | /// The target method name. 41 | let target: String 42 | /// The target method arguments. 43 | let arguments: AnyEncodableArray 44 | /// The target methods stream IDs. 45 | let streamIds: [String]? 46 | /// Headers attached to the message. 47 | let headers: [String: String]? 48 | } 49 | 50 | /// A hub message representing a single item produced as part of a result stream. 51 | struct StreamItemMessage: HubInvocationMessage { 52 | /// The type of this message. 53 | let type: MessageType = .streamItem 54 | /// The invocation ID. 55 | let invocationId: String? 56 | /// The item produced by the server. 57 | let item: AnyEncodable 58 | /// Headers attached to the message. 59 | let headers: [String: String]? 60 | } 61 | 62 | /// A hub message representing the result of an invocation. 63 | struct CompletionMessage: HubInvocationMessage { 64 | /// The type of this message. 65 | let type: MessageType = .completion 66 | /// The invocation ID. 67 | let invocationId: String? 68 | /// The error produced by the invocation, if any. 69 | let error: String? 70 | /// The result produced by the invocation, if any. 71 | let result: AnyEncodable 72 | /// Headers attached to the message. 73 | let headers: [String: String]? 74 | } 75 | 76 | /// A hub message indicating that the sender is still active. 77 | struct PingMessage: HubMessage { 78 | /// The type of this message. 79 | let type: MessageType = .ping 80 | } 81 | 82 | /// A hub message indicating that the sender is closing the connection. 83 | struct CloseMessage: HubMessage { 84 | /// The type of this message. 85 | let type: MessageType = .close 86 | /// The error that triggered the close, if any. 87 | let error: String? 88 | /// If true, clients with automatic reconnects enabled should attempt to reconnect after receiving the CloseMessage. 89 | let allowReconnect: Bool? 90 | } 91 | 92 | /// A hub message sent to request that a streaming invocation be canceled. 93 | struct CancelInvocationMessage: HubInvocationMessage { 94 | /// The type of this message. 95 | let type: MessageType = .cancelInvocation 96 | /// The invocation ID. 97 | let invocationId: String? 98 | /// Headers attached to the message. 99 | let headers: [String: String]? 100 | } 101 | 102 | /// A hub message representing an acknowledgment. 103 | struct AckMessage: HubMessage { 104 | /// The type of this message. 105 | let type: MessageType = .ack 106 | /// The sequence ID. 107 | let sequenceId: Int64 108 | } 109 | 110 | /// A hub message representing a sequence. 111 | struct SequenceMessage: HubMessage { 112 | /// The type of this message. 113 | let type: MessageType = .sequence 114 | /// The sequence ID. 115 | let sequenceId: Int64 116 | } 117 | 118 | /// A type-erased Codable value. 119 | struct AnyEncodable: Encodable { 120 | public let value: Any? 121 | 122 | init(_ value: Any?) { 123 | self.value = value 124 | } 125 | 126 | func encode(to encoder: Encoder) throws { 127 | // Null 128 | guard let value = value else { 129 | var container = encoder.singleValueContainer() 130 | try container.encodeNil() 131 | return 132 | } 133 | 134 | // Primitives and Encodable custom class 135 | if let encodable = value as? Encodable { 136 | try encodable.encode(to: encoder) 137 | return 138 | } 139 | 140 | // Array 141 | if let array = value as? [Any] { 142 | try AnyEncodableArray(array).encode(to: encoder) 143 | return 144 | } 145 | 146 | // Dictionary 147 | if let dictionary = value as? [String: Any] { 148 | try AnyEncodableDictionary(dictionary).encode(to: encoder) 149 | return 150 | } 151 | 152 | // Unsupported type 153 | throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: encoder.codingPath, debugDescription: "Unsupported type")) 154 | } 155 | } 156 | 157 | struct AnyEncodableArray: Encodable { 158 | public let value: [Any?]? 159 | 160 | init(_ array: [Any?]?) { 161 | self.value = array 162 | } 163 | 164 | func encode(to encoder: Encoder) throws { 165 | guard let value = value else { 166 | var container = encoder.singleValueContainer() 167 | try container.encodeNil() 168 | return 169 | } 170 | 171 | var container = encoder.unkeyedContainer() 172 | for value in value { 173 | try AnyEncodable(value).encode(to: container.superEncoder()) 174 | } 175 | } 176 | } 177 | 178 | struct AnyEncodableDictionary: Encodable { 179 | public let value: [String: Any]? 180 | 181 | init(_ dictionary: [String: Any]?) { 182 | self.value = dictionary 183 | } 184 | 185 | func encode(to encoder: Encoder) throws { 186 | guard let value = value else { 187 | var container = encoder.singleValueContainer() 188 | try container.encodeNil() 189 | return 190 | } 191 | 192 | var container = encoder.container(keyedBy: AnyEncodableCodingKey.self) 193 | for (key, value) in value { 194 | let codingKey = AnyEncodableCodingKey(stringValue: key)! 195 | try AnyEncodable(value).encode(to: container.superEncoder(forKey: codingKey)) 196 | } 197 | } 198 | } 199 | 200 | struct AnyEncodableCodingKey: CodingKey { 201 | var stringValue: String 202 | var intValue: Int? 203 | 204 | init?(stringValue: String) { 205 | self.stringValue = stringValue 206 | self.intValue = nil 207 | } 208 | 209 | init?(intValue: Int) { 210 | self.stringValue = "\(intValue)" 211 | self.intValue = intValue 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /Sources/SignalRClient/Protocols/HubProtocol.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | protocol HubProtocol: Sendable { 7 | /// The name of the protocol. This is used by SignalR to resolve the protocol between the client and server. 8 | var name: String { get } 9 | /// The version of the protocol. 10 | var version: Int { get } 11 | /// The transfer format of the protocol. 12 | var transferFormat: TransferFormat { get } 13 | 14 | /** 15 | Creates an array of `HubMessage` objects from the specified serialized representation. 16 | 17 | If `transferFormat` is 'Text', the `input` parameter must be a String, otherwise it must be Data. 18 | 19 | - Parameters: 20 | - input: A Data containing the serialized representation. 21 | - Returns: An array of `HubMessage` objects. 22 | */ 23 | func parseMessages(input: StringOrData, binder: InvocationBinder) throws -> [HubMessage] 24 | 25 | /** 26 | Writes the specified `HubMessage` to a String or Data and returns it. 27 | 28 | If `transferFormat` is 'Text', the result of this method will be a String, otherwise it will be Data. 29 | 30 | - Parameter message: The message to write. 31 | - Returns: A Data containing the serialized representation of the message. 32 | */ 33 | func writeMessage(message: HubMessage) throws -> StringOrData 34 | } 35 | 36 | public enum StringOrData: Sendable, Equatable { 37 | case string(String) 38 | case data(Data) 39 | } 40 | -------------------------------------------------------------------------------- /Sources/SignalRClient/Protocols/MessageType.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | /// Defines the type of a Hub Message. 5 | public enum MessageType: Int, Codable { 6 | /// Indicates the message is an Invocation message. 7 | case invocation = 1 8 | /// Indicates the message is a StreamItem message. 9 | case streamItem = 2 10 | /// Indicates the message is a Completion message. 11 | case completion = 3 12 | /// Indicates the message is a Stream Invocation message. 13 | case streamInvocation = 4 14 | /// Indicates the message is a Cancel Invocation message. 15 | case cancelInvocation = 5 16 | /// Indicates the message is a Ping message. 17 | case ping = 6 18 | /// Indicates the message is a Close message. 19 | case close = 7 20 | /// Indicates the message is an Acknowledgment message. 21 | case ack = 8 22 | /// Indicates the message is a Sequence message. 23 | case sequence = 9 24 | } -------------------------------------------------------------------------------- /Sources/SignalRClient/Protocols/Msgpack/MsgpackCommon.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | // Messagepack protocol: https://github.com/msgpack/msgpack/blob/master/spec.md 7 | 8 | // MARK: Public 9 | // Predefined Timestamp Extension 10 | public struct MsgpackTimestamp: Equatable { 11 | public var seconds: Int64 12 | public var nanoseconds: UInt32 13 | 14 | public init(seconds: Int64, nanoseconds: UInt32) { 15 | self.seconds = seconds 16 | self.nanoseconds = nanoseconds 17 | } 18 | } 19 | 20 | // Those encoding extension methods are rarely used unless you want to encode to messagepack extension type 21 | extension Encoder { 22 | public func isMsgpackEncoder() -> Bool { 23 | return self is MsgpackEncoder 24 | } 25 | 26 | // This method should be used with MsgpackEncoder otherwise it panics. Use isMsgpackEncoder to check. 27 | public func encodeMsgpackExt(extType: Int8, extData: Data) throws { 28 | let msgpackEncoder = self as! MsgpackEncoder 29 | try msgpackEncoder.encodeMsgpackExt(extType: extType, extData: extData) 30 | } 31 | } 32 | 33 | // Those decoding extension methods are rarely used unless you want to decode from messagepack extension type 34 | extension Decoder { 35 | public func isMsgpackDecoder() -> Bool { 36 | return self is MsgpackDecoder 37 | } 38 | 39 | // This method should be used with MsgpackDecoder otherwise it panics. Use isMsgpackDecoder to check. 40 | public func getMsgpackExtType() throws -> Int8 { 41 | let msgpackDecoder = self as! MsgpackDecoder 42 | return try msgpackDecoder.getMsgpackExtType() 43 | } 44 | 45 | // This method should be used with MsgpackDecoder otherwise it panics. Use isMsgpackDecoder to check. 46 | public func getMsgpackExtData() throws -> Data { 47 | let msgpackDecoder = self as! MsgpackDecoder 48 | return try msgpackDecoder.getMsgpackExtData() 49 | } 50 | } 51 | 52 | // MARK: Internal 53 | enum MsgpackElement: Equatable { 54 | case int(Int64) 55 | case uint(UInt64) 56 | case float32(Float32) 57 | case float64(Float64) 58 | case string(String) 59 | case bin(Data) 60 | case bool(Bool) 61 | case map([String: MsgpackElement]) 62 | case array([MsgpackElement]) 63 | case null 64 | case ext(Int8, Data) 65 | 66 | var typeDescription: String { 67 | switch self { 68 | case .bool: 69 | return "Bool" 70 | case .int, .uint: 71 | return "Integer" 72 | case .float32, .float64: 73 | return "Float" 74 | case .string: 75 | return "String" 76 | case .bin: 77 | return "Binary" 78 | case .map: 79 | return "Map" 80 | case .array: 81 | return "Array" 82 | case .null: 83 | return "Null" 84 | case .ext(let type, _): 85 | return "Extension(type:\(type))" 86 | } 87 | } 88 | } 89 | 90 | struct MsgpackCodingKey: CodingKey, Equatable { 91 | var stringValue: String 92 | var intValue: Int? 93 | 94 | init(stringValue: String) { 95 | self.stringValue = stringValue 96 | } 97 | 98 | init(intValue: Int) { 99 | self.intValue = intValue 100 | self.stringValue = String("Index \(intValue)") 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sources/SignalRClient/Protocols/TextMessageFormat.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | class TextMessageFormat { 7 | static let recordSeparatorCode: UInt8 = 0x1e 8 | static let recordSeparator = String(UnicodeScalar(recordSeparatorCode)) 9 | 10 | static func write(_ output: String) -> String { 11 | return "\(output)\(recordSeparator)" 12 | } 13 | 14 | static func parse(_ input: String) throws -> [String] { 15 | guard input.last == Character(recordSeparator) else { 16 | throw SignalRError.incompleteMessage 17 | } 18 | 19 | var messages = input.split(separator: Character(recordSeparator)).map { String($0) } 20 | if let last = messages.last, last.isEmpty { 21 | messages.removeLast() 22 | } 23 | return messages 24 | } 25 | } -------------------------------------------------------------------------------- /Sources/SignalRClient/RetryPolicy.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | public protocol RetryPolicy: Sendable { 7 | // Return TimeInterval in seconds, and Nil means no more retry 8 | func nextRetryInterval(retryContext: RetryContext) -> TimeInterval? 9 | } 10 | 11 | public struct RetryContext { 12 | public let retryCount: Int 13 | public let elapsed: TimeInterval 14 | public let retryReason: Error? 15 | } 16 | 17 | final class DefaultRetryPolicy: RetryPolicy, @unchecked Sendable { 18 | private let retryDelays: [TimeInterval] 19 | private var currentRetryCount = 0 20 | 21 | init(retryDelays: [TimeInterval]) { 22 | self.retryDelays = retryDelays 23 | } 24 | 25 | func nextRetryInterval(retryContext: RetryContext) -> TimeInterval? { 26 | if retryContext.retryCount < retryDelays.count { 27 | return retryDelays[retryContext.retryCount] 28 | } 29 | 30 | return nil 31 | } 32 | } -------------------------------------------------------------------------------- /Sources/SignalRClient/SignalRError.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | // Define error types for better error handling 7 | public enum SignalRError: Error, Equatable, CustomStringConvertible { 8 | case incompleteMessage 9 | case invalidDataType 10 | case failedToEncodeHandshakeRequest 11 | case failedToDecodeResponseData 12 | case expectedHandshakeResponse 13 | case noHandshakeMessageReceived 14 | case unsupportedHandshakeVersion 15 | case handshakeError(String) 16 | case connectionAborted 17 | case negotiationError(String) 18 | case failedToStartConnection(String) 19 | case invalidOperation(String) 20 | case unexpectedResponseCode(Int) 21 | case invalidTextMessageEncoding 22 | case httpTimeoutError 23 | case invalidResponseType 24 | case cannotSentUntilTransportConnected 25 | case invalidData(String) 26 | case eventSourceFailedToConnect 27 | case eventSourceInvalidTransferFormat 28 | case invalidUrl(String) 29 | case invocationError(String) 30 | case unsupportedTransport(String) 31 | case messageBiggerThan2GB 32 | case unexpectedMessageType(String) 33 | case streamCancelled 34 | case serverTimeout(TimeInterval) 35 | 36 | public var description: String { 37 | switch self { 38 | case .incompleteMessage: 39 | return "Message is incomplete." 40 | case .invalidDataType: 41 | return "Invalid data type." 42 | case .failedToEncodeHandshakeRequest: 43 | return "Failed to encode handshake request to JSON string." 44 | case .failedToDecodeResponseData: 45 | return "Failed to decode response data." 46 | case .expectedHandshakeResponse: 47 | return "Expected a handshake response from the server." 48 | case .noHandshakeMessageReceived: 49 | return "No handshake message received." 50 | case .unsupportedHandshakeVersion: 51 | return "Unsupported handshake version" 52 | case .handshakeError(let message): 53 | return "Handshake error: \(message)" 54 | case .connectionAborted: 55 | return "Connection aborted." 56 | case .negotiationError(let message): 57 | return "Negotiation error: \(message)" 58 | case .failedToStartConnection(let message): 59 | return "Failed to start connection: \(message)" 60 | case .invalidOperation(let message): 61 | return "Invalid operation: \(message)" 62 | case .unexpectedResponseCode(let responseCode): 63 | return "Unexpected response code:\(responseCode)" 64 | case .invalidTextMessageEncoding: 65 | return "Invalide text messagge" 66 | case .httpTimeoutError: 67 | return "Http timeout" 68 | case .invalidResponseType: 69 | return "Invalid response type" 70 | case .cannotSentUntilTransportConnected: 71 | return "Cannot send until the transport is connected" 72 | case .invalidData(let message): 73 | return "Invalid data: \(message)" 74 | case .eventSourceFailedToConnect: 75 | return """ 76 | EventSource failed to connect. The connection could not be found on the server, 77 | either the connection ID is not present on the server, or a proxy is refusing/buffering the connection. 78 | If you have multiple servers check that sticky sessions are enabled. 79 | """ 80 | case .eventSourceInvalidTransferFormat: 81 | return "The Server-Sent Events transport only supports the 'Text' transfer format" 82 | case .invalidUrl(let url): 83 | return "Invalid url: \(url)" 84 | case .invocationError(let errorMessage): 85 | return "Invocation error: \(errorMessage)" 86 | case .unsupportedTransport(let message): 87 | return "The transport is not supported: \(message)" 88 | case .messageBiggerThan2GB: 89 | return "Messages bigger than 2GB are not supported." 90 | case .unexpectedMessageType(let messageType): 91 | return "Unexpected message type: \(messageType)." 92 | case .streamCancelled: 93 | return "Stream cancelled." 94 | case .serverTimeout(let timeout): 95 | return "Server timeout. Did not receive a message for \(timeout) seconds." 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sources/SignalRClient/StatefulReconnectOptions.swift: -------------------------------------------------------------------------------- 1 | public struct StatefulReconnectOptions { 2 | public var bufferSize: Int? 3 | } -------------------------------------------------------------------------------- /Sources/SignalRClient/StreamResult.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | public protocol StreamResult { 5 | associatedtype Element 6 | var stream: AsyncThrowingStream { get } 7 | func cancel() async 8 | } -------------------------------------------------------------------------------- /Sources/SignalRClient/TaskCompletionSource.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | actor TaskCompletionSource { 7 | private var continuation: CheckedContinuation<(), Never>? 8 | private var result: Result? 9 | 10 | func task() async throws -> T { 11 | if result == nil { 12 | await withCheckedContinuation { continuation in 13 | self.continuation = continuation 14 | } 15 | } 16 | return try result!.get() 17 | } 18 | 19 | func trySetResult(_ result: Result) -> Bool { 20 | if self.result == nil { 21 | self.result = result 22 | continuation?.resume() 23 | return true 24 | } 25 | return false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /Sources/SignalRClient/TimeScheduler.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | actor TimeScheduler { 7 | private let queue = DispatchQueue(label: "com.schduler.timer") 8 | private var timer: DispatchSourceTimer? 9 | private var interval: TimeInterval 10 | 11 | init(initialInterval: TimeInterval) { 12 | self.interval = initialInterval 13 | } 14 | 15 | func start(sendAction: @escaping () async -> Void) { 16 | stop() 17 | timer = DispatchSource.makeTimerSource(queue: queue) 18 | guard let timer = timer else { return } 19 | 20 | timer.schedule(deadline: .now() + interval, repeating: .infinity) // trigger only once here 21 | timer.setEventHandler { [weak self] in 22 | guard let self = self else { return } 23 | 24 | Task { 25 | await sendAction() 26 | await self.refreshSchduler() 27 | } 28 | } 29 | timer.resume() 30 | } 31 | 32 | func stop() { 33 | timer?.cancel() 34 | timer = nil 35 | } 36 | 37 | func updateInterval(to newInterval: TimeInterval) { 38 | interval = newInterval 39 | refreshSchduler() 40 | } 41 | 42 | func refreshSchduler() { 43 | guard let timer = timer else { return } 44 | timer.schedule(deadline: .now() + interval, repeating: .infinity) 45 | } 46 | } -------------------------------------------------------------------------------- /Sources/SignalRClient/TransferFormat.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | /// Specifies a specific HTTP transport type. 7 | public struct HttpTransportType: OptionSet { 8 | public let rawValue: Int 9 | 10 | public static let none = HttpTransportType([]) 11 | public static let webSockets = HttpTransportType(rawValue: 1 << 0) 12 | public static let serverSentEvents = HttpTransportType(rawValue: 1 << 1) 13 | public static let longPolling = HttpTransportType(rawValue: 1 << 2) 14 | 15 | public init(rawValue: Int) { 16 | self.rawValue = rawValue 17 | } 18 | 19 | static func from(_ transportString: String) -> HttpTransportType? { 20 | switch transportString.lowercased() { 21 | case "websockets": 22 | return .webSockets 23 | case "serversentevents": 24 | return .serverSentEvents 25 | case "longpolling": 26 | return .longPolling 27 | default: 28 | return nil 29 | } 30 | } 31 | } 32 | 33 | /// Specifies the transfer format for a connection. 34 | public enum TransferFormat: Int, Codable, Sendable { 35 | /// Specifies that only text data will be transmitted over the connection. 36 | case text = 1 37 | /// Specifies that binary data will be transmitted over the connection. 38 | case binary = 2 39 | 40 | init?(_ transferFormatString: String) { 41 | switch transferFormatString.lowercased() { 42 | case "text": 43 | self = .text 44 | case "binary": 45 | self = .binary 46 | default: 47 | return nil 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Sources/SignalRClient/Transport/EventSource.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | #if canImport(FoundationNetworking) 7 | import FoundationNetworking 8 | #endif 9 | 10 | // A SSE client implementation compatible with SignalR server. 11 | // Assumptions: 12 | // 1. No BOM charactor. 13 | // 2. Connect is only called once 14 | // Below features are not implemented as SignalR doesn't rely on them: 15 | // 1. Reconnect, last Id 16 | // 2. event name, event handlers 17 | class EventSource: NSObject, URLSessionDataDelegate { 18 | private let url: URL 19 | private let headers: [String: String] 20 | private let parser: EventParser 21 | private var openHandler: (() -> Void)? 22 | private var completeHandler: ((Int?, Error?) -> Void)? 23 | private var messageHandler: ((String) -> Void)? 24 | private var urlSession: URLSession? 25 | 26 | init(url: URL, headers: [String: String]?) { 27 | self.url = url 28 | var headers = headers ?? [:] 29 | headers["Accept"] = "text/event-stream" 30 | headers["Cache-Control"] = "no-cache" 31 | self.headers = headers 32 | self.parser = EventParser() 33 | } 34 | 35 | func connect() { 36 | let config = URLSessionConfiguration.default 37 | config.httpAdditionalHeaders = self.headers 38 | config.timeoutIntervalForRequest = TimeInterval.infinity 39 | config.timeoutIntervalForResource = TimeInterval.infinity 40 | self.urlSession = URLSession( 41 | configuration: config, delegate: self, delegateQueue: nil 42 | ) 43 | self.urlSession!.dataTask(with: url).resume() 44 | } 45 | 46 | func disconnect() { 47 | self.urlSession?.invalidateAndCancel() 48 | } 49 | 50 | func onOpen(openHandler: @escaping (() -> Void)) { 51 | self.openHandler = openHandler 52 | } 53 | 54 | func onComplete( 55 | completionHandler: @escaping (Int?, Error?) -> Void 56 | ) { 57 | self.completeHandler = completionHandler 58 | } 59 | 60 | func onMessage(messageHandler: @escaping (String) -> Void) { 61 | self.messageHandler = messageHandler 62 | } 63 | 64 | // MARK: redirect 65 | public func urlSession( 66 | _ session: URLSession, 67 | task: URLSessionTask, 68 | willPerformHTTPRedirection response: HTTPURLResponse, 69 | newRequest request: URLRequest, 70 | completionHandler: @escaping (URLRequest?) -> Void 71 | ) { 72 | var newRequest = request 73 | self.headers.forEach { key, value in 74 | newRequest.setValue(value, forHTTPHeaderField: key) 75 | } 76 | completionHandler(newRequest) 77 | } 78 | 79 | // MARK: open 80 | public func urlSession( 81 | _ session: URLSession, dataTask: URLSessionDataTask, 82 | didReceive response: URLResponse, 83 | completionHandler: @escaping @Sendable (URLSession.ResponseDisposition) 84 | -> Void 85 | ) { 86 | let statusCode = (response as? HTTPURLResponse)?.statusCode 87 | if statusCode == 200 { 88 | self.openHandler?() 89 | } 90 | // forward anyway 91 | completionHandler(URLSession.ResponseDisposition.allow) 92 | } 93 | 94 | // MARK: data 95 | public func urlSession( 96 | _ session: URLSession, dataTask: URLSessionDataTask, 97 | didReceive data: Data 98 | ) { 99 | parser.Parse(data: data).forEach { event in 100 | self.messageHandler?(event) 101 | } 102 | } 103 | 104 | // MARK: complete 105 | public func urlSession( 106 | _ session: URLSession, task: URLSessionTask, 107 | didCompleteWithError error: (any Error)? 108 | ) { 109 | let statusCode = (task.response as? HTTPURLResponse)?.statusCode 110 | self.completeHandler?(statusCode, error) 111 | } 112 | } 113 | 114 | // The parser supports both "\n" and "\r\n" as field separator. "\r" is rarely used practically thus not supported for simplicity. 115 | // Comments and fields other than "data" are silently dropped. 116 | class EventParser { 117 | static let cr = Character("\r").asciiValue! 118 | static let ln = Character("\n").asciiValue! 119 | static let dot = Character(":").asciiValue! 120 | static let space = Character(" ").asciiValue! 121 | static let data = "data".data(using: .utf8)! 122 | 123 | private var lines: [String] 124 | private var buffer: Data 125 | 126 | init() { 127 | self.lines = [] 128 | self.buffer = Data() 129 | } 130 | 131 | func Parse(data: Data) -> [String] { 132 | var events: [String] = [] 133 | var data = data 134 | while let index = data.firstIndex(of: EventParser.ln) { 135 | var segment = data[.. 0 { 148 | events.append(lines.joined(separator: "\n")) 149 | lines = [] 150 | } 151 | } else { 152 | guard line.starts(with: EventParser.data) else { 153 | continue 154 | } 155 | line = line[EventParser.data.count...] 156 | guard !line.isEmpty else { 157 | lines.append("") 158 | continue 159 | } 160 | guard line.first == EventParser.dot else { 161 | continue 162 | } 163 | line = line.dropFirst() 164 | if line.first == EventParser.space { 165 | line = line.dropFirst() 166 | } 167 | guard let line = String(data: line, encoding: .utf8) else { 168 | continue 169 | } 170 | lines.append(line) 171 | } 172 | } 173 | buffer.append(data) 174 | 175 | return events 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /Sources/SignalRClient/Transport/ServerSentEventTransport.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | actor ServerSentEventTransport: Transport { 7 | let httpClient: HttpClient 8 | let logger: Logger 9 | let accessToken: String? 10 | var options: HttpConnectionOptions 11 | 12 | var url: String? 13 | var closeError: Error? 14 | var receiving: Task? 15 | var receiveHandler: OnReceiveHandler? 16 | var closeHandler: OnCloseHander? 17 | var eventSource: EventSourceAdaptor? 18 | 19 | init( 20 | httpClient: HttpClient, accessToken: String?, logger: Logger, 21 | options: HttpConnectionOptions 22 | ) { 23 | self.httpClient = httpClient 24 | self.options = options 25 | self.accessToken = accessToken 26 | self.logger = logger 27 | } 28 | 29 | func connect(url: String, transferFormat: TransferFormat) async throws { 30 | // MARK: Here's an assumption that the connect won't be called twice 31 | guard transferFormat == .text else { 32 | throw SignalRError.eventSourceInvalidTransferFormat 33 | } 34 | 35 | logger.log( 36 | level: .debug, message: "(SSE transport) Connecting." 37 | ) 38 | 39 | self.url = url 40 | var url = url 41 | if let accessToken = self.accessToken { 42 | url = 43 | "\(url)\(url.contains("?") ? "&" : "?")access_token=\(accessToken.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" 44 | } 45 | 46 | let eventSource = options.eventSource ?? DefaultEventSourceAdaptor(logger: logger) 47 | 48 | await eventSource.onClose(closeHandler: self.close) 49 | 50 | await eventSource.onMessage { data in 51 | let message = StringOrData.string(data) 52 | self.logger.log( 53 | level: .debug, 54 | message: 55 | "(SSE) data received. \(message.getDataDetail(includeContent: self.options.logMessageContent ?? false))" 56 | ) 57 | await self.receiveHandler?(message) 58 | } 59 | 60 | try await eventSource.start(url: url, options: options) 61 | 62 | self.eventSource = eventSource 63 | logger.log( 64 | level: .information, message: "SSE connected to \(self.url!)" 65 | ) 66 | } 67 | 68 | func send(_ requestData: StringOrData) async throws { 69 | guard self.eventSource != nil else { 70 | throw SignalRError.cannotSentUntilTransportConnected 71 | } 72 | logger.log( 73 | level: .debug, 74 | message: 75 | "(SSE transport) sending data. \(requestData.getDataDetail(includeContent: options.logMessageContent ?? false))" 76 | ) 77 | let request = HttpRequest( 78 | method: .POST, url: self.url!, content: requestData, 79 | options: options 80 | ) 81 | let (_, response) = try await httpClient.send(request: request) 82 | logger.log( 83 | level: .debug, 84 | message: 85 | "(SSE transport) request complete. Response status: \(response.statusCode)." 86 | ) 87 | } 88 | 89 | func stop(error: (any Error)?) async throws { 90 | await self.close(err: error) 91 | } 92 | 93 | func onReceive(_ handler: OnReceiveHandler?) { 94 | self.receiveHandler = handler 95 | } 96 | 97 | func onClose(_ handler: OnCloseHander?) { 98 | self.closeHandler = handler 99 | } 100 | 101 | private func close(err: Error?) async { 102 | guard let eventSource = self.eventSource else { 103 | return 104 | } 105 | self.eventSource = nil 106 | await eventSource.stop(err: err) 107 | await closeHandler?(err) 108 | } 109 | } 110 | 111 | final class DefaultEventSourceAdaptor: EventSourceAdaptor, @unchecked Sendable { 112 | private let logger: Logger 113 | private var closeHandler: ((Error?) async -> Void)? 114 | private var messageHandler: ((String) async -> Void)? 115 | 116 | private var eventSource: EventSource? 117 | private var dispatchQueue: DispatchQueue 118 | private var messageTask: Task? 119 | private var messageStream: AsyncStream? 120 | 121 | init(logger: Logger) { 122 | self.logger = logger 123 | self.dispatchQueue = DispatchQueue(label: "DefaultEventSourceAdaptor") 124 | } 125 | 126 | func start(url: String, headers: [String: String]) async throws { 127 | guard let url = URL(string: url) else { 128 | throw SignalRError.invalidUrl(url) 129 | } 130 | let eventSource = EventSource(url: url, headers: headers) 131 | let openTcs = TaskCompletionSource() 132 | 133 | eventSource.onOpen { 134 | Task { 135 | _ = await openTcs.trySetResult(.success(())) 136 | self.eventSource = eventSource 137 | } 138 | } 139 | 140 | messageStream = AsyncStream { continuation in 141 | eventSource.onComplete { statusCode, err in 142 | Task { 143 | let connectFail = await openTcs.trySetResult( 144 | .failure(SignalRError.eventSourceFailedToConnect)) 145 | self.logger.log( 146 | level: .debug, 147 | message: 148 | "(Event Source) \(connectFail ? "Failed to open." : "Disconnected.").\(statusCode == nil ? "" : " StatusCode: \(statusCode!).") \(err == nil ? "" : " Error: \(err!).")" 149 | ) 150 | continuation.finish() 151 | await self.close(err: err) 152 | } 153 | } 154 | 155 | eventSource.onMessage { data in 156 | continuation.yield(data) 157 | } 158 | } 159 | 160 | eventSource.connect() 161 | try await openTcs.task() 162 | 163 | messageTask = Task { 164 | for await message in messageStream! { 165 | await self.messageHandler?(message) 166 | } 167 | } 168 | } 169 | 170 | func stop(err: Error?) async { 171 | await self.close(err: err) 172 | } 173 | 174 | func onClose(closeHandler: @escaping (Error?) async -> Void) async { 175 | self.closeHandler = closeHandler 176 | } 177 | 178 | func onMessage(messageHandler: @escaping (String) async -> Void) async { 179 | self.messageHandler = messageHandler 180 | } 181 | 182 | private func close(err: Error?) async { 183 | var eventSource: EventSource? 184 | dispatchQueue.sync { 185 | eventSource = self.eventSource 186 | self.eventSource = nil 187 | } 188 | guard let eventSource = eventSource else { 189 | return 190 | } 191 | eventSource.disconnect() 192 | await messageTask?.value 193 | await self.closeHandler?(err) 194 | } 195 | } 196 | 197 | extension EventSourceAdaptor { 198 | fileprivate func start( 199 | url: String, headers: [String: String] = [:], 200 | options: HttpConnectionOptions, 201 | includeUserAgent: Bool = true 202 | ) async throws { 203 | var headers = headers 204 | if includeUserAgent { 205 | headers["User-Agent"] = Utils.getUserAgent() 206 | } 207 | if let optionHeaders = options.headers { 208 | headers = headers.merging(optionHeaders) { (_, new) in new } 209 | } 210 | try await start(url: url, headers: headers) 211 | } 212 | } 213 | 214 | protocol EventSourceAdaptor: Sendable { 215 | func start(url: String, headers: [String: String]) async throws 216 | func stop(err: Error?) async 217 | func onClose(closeHandler: @escaping (Error?) async -> Void) async 218 | func onMessage(messageHandler: @escaping (String) async -> Void) async 219 | } 220 | -------------------------------------------------------------------------------- /Sources/SignalRClient/Transport/Transport.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | /// An abstraction over the behavior of transports. 5 | /// This is designed to support the framework and not intended for use by applications. 6 | protocol Transport: Sendable { 7 | /// Connects to the specified URL with the given transfer format. 8 | /// - Parameters: 9 | /// - url: The URL to connect to. 10 | /// - transferFormat: The transfer format to use. 11 | func connect(url: String, transferFormat: TransferFormat) async throws 12 | 13 | /// Sends data over the transport. 14 | /// - Parameter data: The data to send. 15 | func send(_ data: StringOrData) async throws 16 | 17 | /// Stops the transport. 18 | func stop(error: Error?) async throws 19 | 20 | /// A closure that is called when data is received. 21 | func onReceive(_ handler: OnReceiveHandler?) async 22 | 23 | /// A closure that is called when the transport is closed. 24 | func onClose(_ handler: OnCloseHander?) async 25 | 26 | typealias OnReceiveHandler = @Sendable (StringOrData) async -> Void 27 | 28 | typealias OnCloseHander = @Sendable (Error?) async -> Void 29 | } 30 | -------------------------------------------------------------------------------- /Sources/SignalRClient/Utils.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | 6 | class Utils { 7 | static func getUserAgent() -> String { 8 | return 9 | "Microsoft SignalR Client/Swift \(PackageVersion); \(currentOSVersion())" 10 | } 11 | 12 | static func currentOSVersion() -> String { 13 | #if os(macOS) 14 | let osName = "macOS" 15 | #elseif os(iOS) 16 | #if targetEnvironment(macCatalyst) 17 | let osName = "Mac Catalyst" 18 | #else 19 | let osName = "iOS" 20 | #endif 21 | #elseif os(tvOS) 22 | let osName = "tvOS" 23 | #elseif os(watchOS) 24 | let osName = "watchOS" 25 | #elseif os(Windows) 26 | return "Windows" 27 | #elseif os(Linux) 28 | return "Linux" 29 | #else 30 | return "Unknown OS" 31 | #endif 32 | 33 | #if os(macOS) || os(iOS) || os(tvOS) || os(watchOS) 34 | let version = ProcessInfo.processInfo.operatingSystemVersion 35 | let versionString = 36 | "\(version.majorVersion).\(version.minorVersion).\(version.patchVersion)" 37 | return "\(osName) \(versionString)" 38 | #endif 39 | } 40 | } 41 | 42 | extension HttpRequest { 43 | init( 44 | method: HttpMethod, url: String, content: StringOrData? = nil, 45 | responseType: TransferFormat? = nil, 46 | headers: [String: String]? = nil, timeout: TimeInterval? = nil, 47 | options: HttpConnectionOptions, includeUserAgent: Bool = true 48 | ) { 49 | self.init( 50 | method: method, url: url, content: content, 51 | responseType: responseType, headers: headers, 52 | timeout: timeout 53 | ) 54 | if includeUserAgent { 55 | self.headers["User-Agent"] = Utils.getUserAgent() 56 | } 57 | if let headers = options.headers { 58 | self.headers = self.headers.merging(headers) { (_, new) in new } 59 | } 60 | if let timeout = options.timeout { 61 | self.timeout = timeout 62 | } 63 | } 64 | } 65 | 66 | extension Data { 67 | func convertToStringOrData(transferFormat: TransferFormat) throws 68 | -> StringOrData { 69 | switch transferFormat { 70 | case .text: 71 | guard 72 | let message = String( 73 | data: self, encoding: .utf8 74 | ) 75 | else { 76 | throw SignalRError.invalidTextMessageEncoding 77 | } 78 | return .string(message) 79 | case .binary: 80 | return .data(self) 81 | } 82 | } 83 | } 84 | 85 | extension StringOrData { 86 | func getDataDetail(includeContent: Bool) -> String { 87 | switch self { 88 | case .string(let str): 89 | return 90 | "String data of length \(str.count)\(includeContent ? ". Content: \(str)" : "")" 91 | case .data(let data): 92 | // TODO: data format? 93 | return 94 | "Binary data of length \(data.count)\(includeContent ? ". Content: \(data)" : "")" 95 | } 96 | } 97 | 98 | func isEmpty() -> Bool { 99 | switch self { 100 | case .string(let str): 101 | return str.count == 0 102 | case .data(let data): 103 | return data.isEmpty 104 | } 105 | } 106 | 107 | func convertToString() -> String? { 108 | switch self { 109 | case .string(let str): 110 | return str 111 | case .data(let data): 112 | return String(data: data, encoding: .utf8) 113 | } 114 | } 115 | 116 | func converToData() -> Data { 117 | switch self { 118 | case .string(let str): 119 | return str.data(using: .utf8)! 120 | case .data(let data): 121 | return data 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Sources/SignalRClient/Version.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | let PackageVersion = "1.0.0-preview.4" 5 | -------------------------------------------------------------------------------- /Tests/IntegrationTestServer/.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.sln.docstates 8 | 9 | # Build results 10 | 11 | [Dd]ebug/ 12 | [Rr]elease/ 13 | x64/ 14 | [Bb]in/ 15 | [Oo]bj/ 16 | 17 | # MSTest test Results 18 | [Tt]est[Rr]esult*/ 19 | [Bb]uild[Ll]og.* 20 | 21 | *_i.c 22 | *_p.c 23 | *_i.h 24 | *.ilk 25 | *.meta 26 | *.obj 27 | *.pch 28 | *.pdb 29 | *.pgc 30 | *.pgd 31 | *.rsp 32 | *.sbr 33 | *.tlb 34 | *.tli 35 | *.tlh 36 | *.tmp 37 | *.tmp_proj 38 | *.log 39 | *.vspscc 40 | *.vssscc 41 | .builds 42 | *.pidb 43 | *.log 44 | *.svclog 45 | *.scc 46 | 47 | # Visual C++ cache files 48 | ipch/ 49 | *.aps 50 | *.ncb 51 | *.opensdf 52 | *.sdf 53 | *.cachefile 54 | 55 | # Visual Studio profiler 56 | *.psess 57 | *.vsp 58 | *.vspx 59 | 60 | # Guidance Automation Toolkit 61 | *.gpState 62 | 63 | # ReSharper is a .NET coding add-in 64 | _ReSharper*/ 65 | *.[Rr]e[Ss]harper 66 | *.DotSettings.user 67 | 68 | # Click-Once directory 69 | publish/ 70 | 71 | # Publish Web Output 72 | *.Publish.xml 73 | *.pubxml 74 | *.azurePubxml 75 | 76 | # NuGet Packages Directory 77 | ## TODO: If you have NuGet Package Restore enabled, uncomment the next line 78 | packages/ 79 | ## TODO: If the tool you use requires repositories.config, also uncomment the next line 80 | !packages/repositories.config 81 | 82 | # Windows Azure Build Output 83 | csx/ 84 | *.build.csdef 85 | 86 | # Windows Store app package directory 87 | AppPackages/ 88 | 89 | # Others 90 | sql/ 91 | *.Cache 92 | ClientBin/ 93 | [Ss]tyle[Cc]op.* 94 | ![Ss]tyle[Cc]op.targets 95 | ~$* 96 | *~ 97 | *.dbmdl 98 | *.[Pp]ublish.xml 99 | 100 | *.publishsettings 101 | 102 | # RIA/Silverlight projects 103 | Generated_Code/ 104 | 105 | # Backup & report files from converting an old project file to a newer 106 | # Visual Studio version. Backup files are not needed, because we have git ;-) 107 | _UpgradeReport_Files/ 108 | Backup*/ 109 | UpgradeLog*.XML 110 | UpgradeLog*.htm 111 | 112 | # SQL Server files 113 | App_Data/*.mdf 114 | App_Data/*.ldf 115 | 116 | # ========================= 117 | # Windows detritus 118 | # ========================= 119 | 120 | # Windows image file caches 121 | Thumbs.db 122 | ehthumbs.db 123 | 124 | # Folder config file 125 | Desktop.ini 126 | 127 | # Recycle Bin used on file shares 128 | $RECYCLE.BIN/ 129 | 130 | # Mac desktop service store files 131 | .DS_Store 132 | 133 | _NCrunch* -------------------------------------------------------------------------------- /Tests/IntegrationTestServer/Hubs/TestHub.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | using Microsoft.AspNetCore.SignalR; 3 | 4 | namespace IntegrationTest.Hubs; 5 | 6 | public class TestHub : Hub 7 | { 8 | public async Task Echo(string message1, object message2) 9 | => await Clients.Client(Context.ConnectionId).SendAsync("EchoBack", message1, message2); 10 | 11 | public object Invoke(string message1, object message2) => message2; 12 | 13 | public void InvokeWithoutReturn(string message1) 14 | { 15 | } 16 | 17 | public async Task InvokeWithClientResult(string message1) 18 | { 19 | var result = await Clients.Client(Context.ConnectionId).InvokeAsync("ClientResult", message1, CancellationToken.None); 20 | await Clients.Client(Context.ConnectionId).SendAsync("EchoBack", result); 21 | } 22 | 23 | public async Task invokeWithEmptyClientResult(string message1) 24 | { 25 | Console.WriteLine("ClientResult invoking"); 26 | var rst = await Clients.Client(Context.ConnectionId).InvokeAsync("ClientResult", message1, CancellationToken.None); 27 | Console.WriteLine("ClientResult invoked"); 28 | Console.WriteLine(rst); 29 | await Clients.Client(Context.ConnectionId).SendAsync("EchoBack", "received"); 30 | } 31 | 32 | public async Task AddNumbers(int basic, IAsyncEnumerable stream) 33 | { 34 | int sum = basic; 35 | await foreach (var number in stream) 36 | { 37 | sum += number; 38 | Console.WriteLine(sum); 39 | } 40 | return sum; 41 | } 42 | 43 | public async IAsyncEnumerable Count(int basic, IAsyncEnumerable stream) 44 | { 45 | int counter = basic; 46 | await foreach (var number in stream) 47 | { 48 | counter ++; 49 | Console.WriteLine(counter); 50 | yield return counter; 51 | } 52 | } 53 | 54 | public async IAsyncEnumerable Stream() 55 | { 56 | yield return "a"; 57 | yield return "b"; 58 | yield return "c"; 59 | } 60 | 61 | } -------------------------------------------------------------------------------- /Tests/IntegrationTestServer/IntegrationTestServer.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Tests/IntegrationTestServer/Program.cs: -------------------------------------------------------------------------------- 1 | using IntegrationTest.Hubs; 2 | 3 | var builder = WebApplication.CreateBuilder(args); 4 | builder.Services.AddSignalR().AddMessagePackProtocol(); 5 | var app = builder.Build(); 6 | 7 | app.UseRouting(); 8 | app.MapHub("/test"); 9 | 10 | app.Run(); -------------------------------------------------------------------------------- /Tests/IntegrationTestServer/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/launchsettings.json", 3 | "profiles": { 4 | "http": { 5 | "commandName": "Project", 6 | "dotnetRunMessages": true, 7 | "launchBrowser": false, 8 | "applicationUrl": "http://localhost:8080", 9 | "environmentVariables": { 10 | "ASPNETCORE_ENVIRONMENT": "Development" 11 | } 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Tests/IntegrationTestServer/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft.AspNetCore": "Debug" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Tests/IntegrationTestServer/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "Microsoft.AspNetCore": "Debug" 6 | } 7 | }, 8 | "AllowedHosts": "*" 9 | } 10 | -------------------------------------------------------------------------------- /Tests/SignalRClientTests/AsyncLockTest.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import XCTest 5 | @testable import SignalRClient 6 | 7 | class AsyncLockTests: XCTestCase { 8 | func testLock_WhenNotLocked_Succeeds() async { 9 | let asyncLock = AsyncLock() 10 | await asyncLock.wait() 11 | asyncLock.release() 12 | } 13 | 14 | func testLock_SecondLock_Waits() async throws { 15 | let expectation = XCTestExpectation(description: "wait() should be called") 16 | let asyncLock = AsyncLock() 17 | await asyncLock.wait() 18 | let t = Task { 19 | await asyncLock.wait() 20 | defer { 21 | asyncLock.release() 22 | } 23 | expectation.fulfill() 24 | } 25 | 26 | asyncLock.release() 27 | await fulfillment(of: [expectation], timeout: 2.0) 28 | t.cancel() 29 | } 30 | } -------------------------------------------------------------------------------- /Tests/SignalRClientTests/BinaryMessageFormatTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | import XCTest 6 | 7 | @testable import SignalRClient 8 | 9 | class BinaryMessageFormatTests: XCTestCase { 10 | // MARK: Parse 11 | func testParseZeroLength() throws { 12 | let data = Data([0x00]) 13 | let messages = try BinaryMessageFormat.parse(data) 14 | XCTAssertEqual(messages.count, 0) 15 | } 16 | 17 | func testParseVarInt8() throws { 18 | let data = Data([0x01, 0x01]) 19 | let messages = try BinaryMessageFormat.parse(data) 20 | XCTAssertEqual(messages.count, 1) 21 | XCTAssertEqual(messages[0], Data([0x01])) 22 | } 23 | 24 | func testParseVarInt16() throws { 25 | let data = Data([0x81, 0x00, 0x01]) 26 | let messages = try BinaryMessageFormat.parse(data) 27 | XCTAssertEqual(messages.count, 1) 28 | XCTAssertEqual(messages[0], Data([0x01])) 29 | } 30 | 31 | func testParseVarInt24() throws { 32 | let data = Data([0x81, 0x80, 0x00, 0x01]) 33 | let messages = try BinaryMessageFormat.parse(data) 34 | XCTAssertEqual(messages.count, 1) 35 | XCTAssertEqual(messages[0], Data([0x01])) 36 | } 37 | 38 | func testParseVarInt32() throws { 39 | let data = Data([0x81, 0x80, 0x80, 0x00, 0x01]) 40 | let messages = try BinaryMessageFormat.parse(data) 41 | XCTAssertEqual(messages.count, 1) 42 | XCTAssertEqual(messages[0], Data([0x01])) 43 | } 44 | 45 | func testParseVarInt40() throws { 46 | let data = Data([0x81, 0x80, 0x80, 0x80, 0x00, 0x01]) 47 | let messages = try BinaryMessageFormat.parse(data) 48 | XCTAssertEqual(messages.count, 1) 49 | XCTAssertEqual(messages[0], Data([0x01])) 50 | } 51 | 52 | func testMultipleMessages() throws { 53 | let data = Data([0x01, 0x02, 0x02, 0x01, 0x02, 0x00]) 54 | let messages = try BinaryMessageFormat.parse(data) 55 | XCTAssertEqual(messages.count, 2) 56 | XCTAssertEqual(messages[0], Data([0x02])) 57 | XCTAssertEqual(messages[1], Data([0x01, 0x02])) 58 | } 59 | 60 | func testIncompleteMessageSize() throws { 61 | let data = Data([0x81]) 62 | do { 63 | _ = try BinaryMessageFormat.parse(data) 64 | XCTFail("Should throw when paring incomplete message") 65 | } catch SignalRError.incompleteMessage { 66 | } 67 | } 68 | 69 | func testIncompleteMessage() throws { 70 | let data = Data([0x80, 0x80, 0x80, 0x80, 0x08, 0x01]) 71 | do { 72 | _ = try BinaryMessageFormat.parse(data) 73 | XCTFail("Should throw when paring incomplete message") 74 | } catch SignalRError.incompleteMessage { 75 | } 76 | } 77 | 78 | func testInvalidMessageSizeData() throws { 79 | let data = Data([0x81, 0x80, 0x80, 0x80, 0x80, 0x00, 0x01]) 80 | do { 81 | _ = try BinaryMessageFormat.parse(data) 82 | XCTFail("Should throw when paring invalid message size") 83 | } catch SignalRError.invalidData(_) { 84 | } 85 | } 86 | 87 | func testToLargeData() throws { 88 | let data = Data([0x81, 0x80, 0x80, 0x80, 0x08, 0x01]) 89 | do { 90 | _ = try BinaryMessageFormat.parse(data) 91 | XCTFail("Should throw when paring invalid message size") 92 | } catch SignalRError.messageBiggerThan2GB { 93 | } 94 | } 95 | 96 | // MARK: write 97 | func testWriteEmpty() throws { 98 | let data = Data() 99 | let tpData = try BinaryMessageFormat.write(data) 100 | XCTAssertEqual(tpData, Data([0x00])) 101 | } 102 | 103 | func testWriteVar8() throws { 104 | let data = Data([0x00]) 105 | let tpData = try BinaryMessageFormat.write(data) 106 | XCTAssertEqual(tpData, Data([0x01, 0x00])) 107 | } 108 | 109 | func testWriteVar16() throws { 110 | let data = Data(count: 0x81) 111 | let tpData = try BinaryMessageFormat.write(data) 112 | XCTAssertEqual(tpData, Data([0x81, 0x01]) + data) 113 | } 114 | 115 | func testWriteVar24() throws { 116 | let data = Data(count: 0x181) 117 | let tpData = try BinaryMessageFormat.write(data) 118 | XCTAssertEqual(tpData, Data([0x81, 0x03]) + data) 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /Tests/SignalRClientTests/EventSourceTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import Foundation 5 | import XCTest 6 | 7 | @testable import SignalRClient 8 | 9 | class EventSourceTests: XCTestCase { 10 | func testEventParser() async throws { 11 | let parser = EventParser() 12 | 13 | var content = "data:hello\n\n".data(using: .utf8)! 14 | var events = parser.Parse(data: content) 15 | XCTAssertEqual(events.count, 1) 16 | XCTAssertEqual(events[0], "hello") 17 | 18 | content = "data: hello\n\n".data(using: .utf8)! 19 | events = parser.Parse(data: content) 20 | XCTAssertEqual(events.count, 1) 21 | XCTAssertEqual(events[0], "hello") 22 | 23 | content = "data: hello\r\n\r\n".data(using: .utf8)! 24 | events = parser.Parse(data: content) 25 | XCTAssertEqual(events.count, 1) 26 | XCTAssertEqual(events[0], "hello") 27 | 28 | content = "data: hello\r\n\n".data(using: .utf8)! 29 | events = parser.Parse(data: content) 30 | XCTAssertEqual(events.count, 1) 31 | XCTAssertEqual(events[0], "hello") 32 | 33 | content = "data: hello\n\n".data(using: .utf8)! 34 | events = parser.Parse(data: content) 35 | XCTAssertEqual(events.count, 1) 36 | XCTAssertEqual(events[0], " hello") 37 | 38 | content = "data:\n\n".data(using: .utf8)! 39 | events = parser.Parse(data: content) 40 | XCTAssertEqual(events.count, 1) 41 | XCTAssertEqual(events[0], "") 42 | 43 | content = "data\n\n".data(using: .utf8)! 44 | events = parser.Parse(data: content) 45 | XCTAssertEqual(events.count, 1) 46 | XCTAssertEqual(events[0], "") 47 | 48 | content = "data\ndata\n\n".data(using: .utf8)! 49 | events = parser.Parse(data: content) 50 | XCTAssertEqual(events.count, 1) 51 | XCTAssertEqual(events[0], "\n") 52 | 53 | content = "data:\ndata\n\n".data(using: .utf8)! 54 | events = parser.Parse(data: content) 55 | XCTAssertEqual(events.count, 1) 56 | XCTAssertEqual(events[0], "\n") 57 | 58 | content = "dat".data(using: .utf8)! 59 | events = parser.Parse(data: content) 60 | XCTAssertEqual(events.count, 0) 61 | 62 | content = "a:e\n\n".data(using: .utf8)! 63 | events = parser.Parse(data: content) 64 | XCTAssertEqual(events.count, 1) 65 | XCTAssertEqual(events[0], "e") 66 | 67 | content = ":\n\n".data(using: .utf8)! 68 | events = parser.Parse(data: content) 69 | XCTAssertEqual(events.count, 0) 70 | 71 | content = "retry:abc\n\n".data(using: .utf8)! 72 | events = parser.Parse(data: content) 73 | XCTAssertEqual(events.count, 0) 74 | 75 | content = "dataa:abc\n\n".data(using: .utf8)! 76 | events = parser.Parse(data: content) 77 | XCTAssertEqual(events.count, 0) 78 | 79 | content = "Data:abc\n\n".data(using: .utf8)! 80 | events = parser.Parse(data: content) 81 | XCTAssertEqual(events.count, 0) 82 | 83 | content = "data:abc \ndata\n\n".data(using: .utf8)! 84 | events = parser.Parse(data: content) 85 | XCTAssertEqual(events.count, 1) 86 | XCTAssertEqual(events[0], "abc \n") 87 | 88 | content = "data:abc \ndata:efg\n\n".data(using: .utf8)! 89 | events = parser.Parse(data: content) 90 | XCTAssertEqual(events.count, 1) 91 | XCTAssertEqual(events[0], "abc \nefg") 92 | 93 | content = "data:abc \ndata:efg\n\nretry\ndata:h\n\n".data(using: .utf8)! 94 | events = parser.Parse(data: content) 95 | XCTAssertEqual(events.count, 2) 96 | XCTAssertEqual(events[0], "abc \nefg") 97 | XCTAssertEqual(events[1], "h") 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Tests/SignalRClientTests/HandshakeProtocolTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import XCTest 5 | @testable import SignalRClient 6 | 7 | class HandshakeProtocolTests: XCTestCase { 8 | 9 | func testWriteHandshakeRequest() throws { 10 | let handshakeRequest = HandshakeRequestMessage(protocol: "json", version: 1) 11 | let result: String = try HandshakeProtocol.writeHandshakeRequest(handshakeRequest: handshakeRequest) 12 | 13 | XCTAssertTrue(result.hasSuffix("\u{1e}")) 14 | 15 | let resultWithoutPrefix = result.dropLast() 16 | 17 | let resultJson = try JSONSerialization.jsonObject(with: resultWithoutPrefix.data(using: .utf8)!, options: []) as? [String: Any] 18 | XCTAssertEqual("json", resultJson?["protocol"] as? String) 19 | XCTAssertEqual(1, resultJson?["version"] as? Int) 20 | } 21 | 22 | func testParseHandshakeResponseWithValidString() throws { 23 | let responseString = "{\"error\":null,\"minorVersion\":1}\u{1e}" 24 | let data = StringOrData.string(responseString) 25 | let (remainingData, responseMessage) = try HandshakeProtocol.parseHandshakeResponse(data: data) 26 | 27 | XCTAssertNil(remainingData) 28 | XCTAssertNil(responseMessage.error) 29 | XCTAssertEqual(responseMessage.minorVersion, 1) 30 | } 31 | 32 | func testParseHandshakeResponseWithValidString2() throws { 33 | let responseString = "{}\u{1e}" 34 | let data = StringOrData.string(responseString) 35 | let (remainingData, responseMessage) = try HandshakeProtocol.parseHandshakeResponse(data: data) 36 | 37 | XCTAssertNil(remainingData) 38 | XCTAssertNil(responseMessage.error) 39 | XCTAssertNil(responseMessage.minorVersion) 40 | } 41 | 42 | func testParseHandshakeResponseWithValidData() throws { 43 | let responseString = "{\"error\":null,\"minorVersion\":1}\u{1e}" 44 | let responseData = responseString.data(using: .utf8)! 45 | let data = StringOrData.data(responseData) 46 | let (remainingData, responseMessage) = try HandshakeProtocol.parseHandshakeResponse(data: data) 47 | 48 | XCTAssertNil(remainingData) 49 | XCTAssertNil(responseMessage.error) 50 | XCTAssertEqual(responseMessage.minorVersion, 1) 51 | } 52 | 53 | func testParseHandshakeResponseWithRemainingStringData() throws { 54 | let responseString = "{\"error\":null,\"minorVersion\":1}\u{1e}remaining" 55 | let data = StringOrData.string(responseString) 56 | let (remainingData, responseMessage) = try HandshakeProtocol.parseHandshakeResponse(data: data) 57 | 58 | if case let .string(remainingData) = remainingData { 59 | XCTAssertEqual(remainingData, "remaining") 60 | } else { 61 | XCTFail("Remaining data should be string") 62 | } 63 | XCTAssertNil(responseMessage.error) 64 | XCTAssertEqual(responseMessage.minorVersion, 1) 65 | } 66 | 67 | func testParseHandshakeResponseWithRemainingBinaryData() throws { 68 | let responseString = "{\"error\":null,\"minorVersion\":1}\u{1e}remaining" 69 | let responseData = responseString.data(using: .utf8)! 70 | let data = StringOrData.data(responseData) 71 | let (remainingData, responseMessage) = try HandshakeProtocol.parseHandshakeResponse(data: data) 72 | 73 | if case let .data(remainingData) = remainingData { 74 | XCTAssertEqual(remainingData, "remaining".data(using: .utf8)!) 75 | } else { 76 | XCTFail("Remaining data should be data") 77 | } 78 | XCTAssertNil(responseMessage.error) 79 | XCTAssertEqual(responseMessage.minorVersion, 1) 80 | } 81 | 82 | func testParseHandshakeResponseWithError() throws { 83 | let responseString = "{\"error\":\"Some error\",\"minorVersion\":null}\u{1e}" 84 | let data = StringOrData.string(responseString) 85 | let (remainingData, responseMessage) = try HandshakeProtocol.parseHandshakeResponse(data: data) 86 | 87 | XCTAssertNil(remainingData) 88 | XCTAssertEqual(responseMessage.error, "Some error") 89 | XCTAssertNil(responseMessage.minorVersion) 90 | } 91 | 92 | func testParseHandshakeResponseWithIncompleteMessage() { 93 | let responseString = "{\"error\":null,\"minorVersion\":1}" 94 | let data = StringOrData.string(responseString) 95 | 96 | XCTAssertThrowsError(try HandshakeProtocol.parseHandshakeResponse(data: data)) { error in 97 | XCTAssertEqual(error as? SignalRError, SignalRError.incompleteMessage) 98 | } 99 | } 100 | 101 | func testParseHandshakeResponseWithNormalMessage() { 102 | let responseString = "{\"type\":1,\"target\":\"Send\",\"arguments\":[]}\u{1e}" 103 | let data = StringOrData.string(responseString) 104 | 105 | XCTAssertThrowsError(try HandshakeProtocol.parseHandshakeResponse(data: data)) { error in 106 | XCTAssertEqual(error as? SignalRError, SignalRError.expectedHandshakeResponse) 107 | } 108 | } 109 | } -------------------------------------------------------------------------------- /Tests/SignalRClientTests/HttpClientTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import XCTest 5 | 6 | @testable import SignalRClient 7 | 8 | let mockKey = "mock-key" 9 | 10 | extension HttpRequest { 11 | init( 12 | mockId: String, method: HttpMethod, url: String, 13 | content: StringOrData? = nil, 14 | headers: [String: String]? = nil, timeout: TimeInterval? = nil 15 | ) { 16 | self.init( 17 | method: method, url: url, content: content, headers: headers, 18 | timeout: timeout 19 | ) 20 | self.headers[mockKey] = mockId 21 | } 22 | } 23 | 24 | typealias RequestHandler = (HttpRequest) async throws -> ( 25 | StringOrData, HttpResponse 26 | ) 27 | 28 | enum MockClientError: Error { 29 | case MockIdNotFound 30 | case RequestHandlerNotFound 31 | } 32 | 33 | actor MockHttpClient: HttpClient { 34 | var requestHandlers: [String: RequestHandler] = [:] 35 | 36 | func send(request: SignalRClient.HttpRequest) async throws -> ( 37 | StringOrData, SignalRClient.HttpResponse 38 | ) { 39 | try Task.checkCancellation() 40 | guard let mockId = request.headers[mockKey] else { 41 | XCTFail("mock Id not found") 42 | throw MockClientError.MockIdNotFound 43 | } 44 | guard let requestHandler = requestHandlers[mockId] else { 45 | XCTFail("mock request handler not found") 46 | throw MockClientError.RequestHandlerNotFound 47 | } 48 | return try await requestHandler(request) 49 | } 50 | 51 | func mock(mockId: String, requestHandler: @escaping RequestHandler) { 52 | requestHandlers[mockId] = requestHandler 53 | } 54 | } 55 | 56 | class HttpRequestTests: XCTestCase { 57 | func testResponseType() { 58 | var request = HttpRequest(method: .GET, url: "url") 59 | XCTAssertEqual(request.responseType, TransferFormat.text) 60 | request = HttpRequest(method: .GET, url: "url", content: StringOrData.string("")) 61 | XCTAssertEqual(request.responseType, TransferFormat.text) 62 | request = HttpRequest(method: .GET, url: "url", content: StringOrData.data(Data())) 63 | XCTAssertEqual(request.responseType, TransferFormat.binary) 64 | } 65 | } 66 | 67 | class HttpClientTests: XCTestCase { 68 | func testDefaultHttpClient() async throws { 69 | let client = DefaultHttpClient(logger: dummyLogger) 70 | let request = HttpRequest(method: .GET, url: "https://www.bing.com") 71 | let (_, response) = try await client.send(request: request) 72 | XCTAssertEqual(response.statusCode, 200) 73 | } 74 | 75 | func testDefaultHttpClientFail() async throws { 76 | let logHandler = MockLogHandler() 77 | let logger = Logger(logLevel: .warning, logHandler: logHandler) 78 | let client = DefaultHttpClient(logger: logger) 79 | var request = HttpRequest(method: .GET, url: "htttps://www.bing.com") 80 | do { 81 | _ = try await client.send(request: request) 82 | XCTFail("Request should fail!") 83 | } catch { 84 | } 85 | logHandler.verifyLogged("Error") 86 | request = HttpRequest( 87 | method: .GET, url: "https://www.bing.com", timeout: 0.00001 88 | ) 89 | do { 90 | _ = try await client.send(request: request) 91 | XCTFail("Request should fail!") 92 | } catch SignalRError.httpTimeoutError { 93 | } 94 | logHandler.verifyLogged("Timeout") 95 | } 96 | 97 | func testAccessTokenHttpClientUseAccessTokenFactory() async throws { 98 | let mockClient = MockHttpClient() 99 | await mockClient.mock(mockId: "bing") { request in 100 | XCTAssertEqual(request.headers["Authorization"], "Bearer token") 101 | return ( 102 | .string("hello"), HttpResponse(statusCode: 200) 103 | ) 104 | } 105 | let request = HttpRequest( 106 | mockId: "bing", method: .GET, url: "https://www.bing.com" 107 | ) 108 | let client = AccessTokenHttpClient(innerClient: mockClient) { 109 | return "token" 110 | } 111 | let (data, response) = try await client.send(request: request) 112 | XCTAssertEqual(response.statusCode, 200) 113 | XCTAssertEqual(data.convertToString(), "hello") 114 | } 115 | 116 | func testAccessTokenHttpClientUseRetry() async throws { 117 | let mockClient = MockHttpClient() 118 | let expectation = XCTestExpectation( 119 | description: "Overdue token should be found") 120 | await mockClient.mock(mockId: "bing") { request in 121 | let authHeader = request.headers["Authorization"] 122 | 123 | if authHeader == nil { 124 | XCTFail("No auth header found") 125 | } 126 | 127 | if authHeader == "Bearer overdue" { 128 | expectation.fulfill() 129 | return (.string(""), HttpResponse(statusCode: 401)) 130 | } 131 | 132 | return ( 133 | .string("hello"), HttpResponse(statusCode: 200) 134 | ) 135 | } 136 | let request = HttpRequest( 137 | mockId: "bing", method: .GET, url: "https://www.bing.com" 138 | ) 139 | let client = AccessTokenHttpClient(innerClient: mockClient) { "token" } 140 | await client.setAccessToken(accessToekn: "overdue") 141 | let (message, response) = try await client.send(request: request) 142 | await fulfillment(of: [expectation], timeout: 1) 143 | XCTAssertEqual(response.statusCode, 200) 144 | switch message { 145 | case .data(_): 146 | XCTFail("Invalid response type") 147 | case .string(let str): 148 | XCTAssertEqual(str, "hello") 149 | } 150 | } 151 | } 152 | 153 | extension AccessTokenHttpClient { 154 | func setAccessToken(accessToekn: String) { 155 | self.accessToken = accessToekn 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /Tests/SignalRClientTests/HubConnection+OnResultTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import XCTest 5 | @testable import SignalRClient 6 | 7 | final class HubConnectionOnResultTests: XCTestCase { 8 | let successHandshakeResponse = """ 9 | {}\u{1e} 10 | """ 11 | let errorHandshakeResponse = """ 12 | {"error": "Sample error"}\u{1e} 13 | """ 14 | let resultValue = 42 15 | 16 | var mockConnection: MockConnection! 17 | var logHandler: LogHandler! 18 | var hubProtocol: HubProtocol! 19 | var hubConnection: HubConnection! 20 | 21 | var resultExpectation: XCTestExpectation = XCTestExpectation(description: "Result received") 22 | var result: CompletionMessage? 23 | 24 | override func setUp() async throws { 25 | mockConnection = MockConnection() 26 | logHandler = MockLogHandler() 27 | hubProtocol = JsonHubProtocol() 28 | hubConnection = HubConnection( 29 | connection: mockConnection, 30 | logger: Logger(logLevel: .debug, logHandler: logHandler), 31 | hubProtocol: hubProtocol, 32 | retryPolicy: DefaultRetryPolicy(retryDelays: []), // No retry 33 | serverTimeout: nil, 34 | keepAliveInterval: nil, 35 | statefulReconnectBufferSize: nil 36 | ) 37 | 38 | mockConnection.onSend = { data in 39 | Task { 40 | guard let hubConnection = self.hubConnection else { return } 41 | let messages = try self.hubProtocol.parseMessages(input: data, binder: TestInvocationBinder(binderTypes: [Int.self])) 42 | if messages.first is CompletionMessage { 43 | self.resultExpectation.fulfill() 44 | self.result = (messages.first as! CompletionMessage) 45 | } else { 46 | await hubConnection.processIncomingData(.string(self.successHandshakeResponse)) 47 | } 48 | } // only success the first time 49 | } 50 | 51 | try await hubConnection.start() 52 | } 53 | 54 | override func tearDown() { 55 | hubConnection = nil 56 | super.tearDown() 57 | } 58 | 59 | func testOnNoArgs() async throws { 60 | let expectation = self.expectation(description: "Handler called") 61 | await hubConnection.on("testMethod") { 62 | expectation.fulfill() 63 | return self.resultValue 64 | } 65 | 66 | await hubConnection.dispatchMessage(InvocationMessage(target: "testMethod", arguments: AnyEncodableArray([]), streamIds: nil, headers: nil, invocationId: "invocationId")) 67 | await fulfillment(of: [expectation, resultExpectation], timeout: 1) 68 | XCTAssertEqual(result?.result.value as? Int, self.resultValue) 69 | } 70 | 71 | func testOnNoArgs_VoidReturn() async throws { 72 | let expectation = self.expectation(description: "Handler called") 73 | await hubConnection.on("testMethod") { 74 | expectation.fulfill() 75 | return 76 | } 77 | 78 | await hubConnection.dispatchMessage(InvocationMessage(target: "testMethod", arguments: AnyEncodableArray([]), streamIds: nil, headers: nil, invocationId: "invocationId")) 79 | await fulfillment(of: [expectation, resultExpectation], timeout: 1) 80 | XCTAssertNil(result?.result.value) 81 | } 82 | 83 | func testOnAndOff() async throws { 84 | let expectation = self.expectation(description: "Handler called") 85 | expectation.isInverted = true 86 | await hubConnection.on("testMethod") { 87 | expectation.fulfill() 88 | return self.resultValue 89 | } 90 | await hubConnection.off(method: "testMethod") 91 | 92 | await hubConnection.dispatchMessage(InvocationMessage(target: "testMethod", arguments: AnyEncodableArray([]), streamIds: nil, headers: nil, invocationId: "invocationId")) 93 | await fulfillment(of: [expectation], timeout: 1) 94 | } 95 | 96 | func testOnOneArg() async throws { 97 | let expectation = self.expectation(description: "Handler called") 98 | await hubConnection.on("testMethod") { (arg: Int) in 99 | XCTAssertEqual(arg, 42) 100 | expectation.fulfill() 101 | return self.resultValue 102 | } 103 | await hubConnection.dispatchMessage(InvocationMessage(target: "testMethod", arguments: AnyEncodableArray([42]), streamIds: nil, headers: nil, invocationId: "invocationId")) 104 | await fulfillment(of: [expectation, resultExpectation], timeout: 1) 105 | XCTAssertEqual(result?.result.value as? Int, self.resultValue) 106 | } 107 | 108 | func testOnOneArg_WrongType() async throws { 109 | let expectation = self.expectation(description: "Handler called") 110 | expectation.isInverted = true 111 | await hubConnection.on("testMethod") { (arg: Int) in 112 | XCTAssertEqual(arg, 42) 113 | expectation.fulfill() 114 | return self.resultValue 115 | } 116 | await hubConnection.dispatchMessage(InvocationMessage(target: "testMethod", arguments: AnyEncodableArray(["42"]), streamIds: nil, headers: nil, invocationId: "invocationId")) 117 | 118 | await fulfillment(of: [expectation], timeout: 1) 119 | } 120 | } -------------------------------------------------------------------------------- /Tests/SignalRClientTests/LoggerTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import XCTest 5 | 6 | @testable import SignalRClient 7 | 8 | let dummyLogger = Logger(logLevel: nil, logHandler: MockLogHandler()) 9 | 10 | final class MockLogHandler: LogHandler, @unchecked Sendable { 11 | private let queue: DispatchQueue 12 | private let innerLogHandler: LogHandler? 13 | private var logs: [String] 14 | 15 | // set showLog to true for debug 16 | init(showLog: Bool = false) { 17 | queue = DispatchQueue(label: "MockLogHandler") 18 | logs = [] 19 | innerLogHandler = showLog ? DefaultLogHandler() : nil 20 | } 21 | 22 | func log( 23 | logLevel: SignalRClient.LogLevel, message: SignalRClient.LogMessage, 24 | file: String, function: String, line: UInt 25 | ) { 26 | queue.sync { 27 | logs.append("\(message)") 28 | } 29 | innerLogHandler?.log( 30 | logLevel: logLevel, message: message, file: file, 31 | function: function, line: line 32 | ) 33 | } 34 | 35 | } 36 | 37 | extension MockLogHandler { 38 | func clear() { 39 | queue.sync { 40 | logs.removeAll() 41 | } 42 | } 43 | 44 | func verifyLogged( 45 | _ message: String, file: StaticString = #filePath, line: UInt = #line 46 | ) { 47 | queue.sync { 48 | for log in logs { 49 | if log.contains(message) { 50 | return 51 | } 52 | } 53 | XCTFail( 54 | "Expected log not found: \"\(message)\"", file: file, line: line 55 | ) 56 | } 57 | } 58 | 59 | func verifyNotLogged( 60 | _ message: String, file: StaticString = #filePath, line: UInt = #line 61 | ) { 62 | queue.sync { 63 | for log in logs { 64 | if log.contains(message) { 65 | XCTFail( 66 | "Unexpected Log found: \"\(message)\"", file: file, 67 | line: line 68 | ) 69 | } 70 | } 71 | } 72 | } 73 | } 74 | 75 | class LoggerTests: XCTestCase { 76 | func testOSLogHandler() { 77 | let logger = Logger(logLevel: .debug, logHandler: DefaultLogHandler()) 78 | logger.log(level: .debug, message: "Hello world") 79 | logger.log(level: .information, message: "Hello world \(true)") 80 | } 81 | 82 | func testMockHandler() { 83 | let mockLogHandler = MockLogHandler() 84 | let logger = Logger(logLevel: .information, logHandler: mockLogHandler) 85 | logger.log(level: .error, message: "error") 86 | logger.log(level: .information, message: "info") 87 | logger.log(level: .debug, message: "debug") 88 | mockLogHandler.verifyLogged("error") 89 | mockLogHandler.verifyLogged("info") 90 | mockLogHandler.verifyNotLogged("debug") 91 | mockLogHandler.clear() 92 | mockLogHandler.verifyNotLogged("error") 93 | mockLogHandler.verifyNotLogged("info") 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Tests/SignalRClientTests/MessageBufferTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | @testable import SignalRClient 4 | 5 | class MessageBufferTest: XCTestCase { 6 | func testSendWithinBufferSize() async throws { 7 | let buffer = MessageBuffer(bufferSize: 100) 8 | let expectation = XCTestExpectation(description: "Should enqueue") 9 | Task { 10 | try await buffer.enqueue(content: .string("data")) 11 | expectation.fulfill() 12 | } 13 | await fulfillment(of: [expectation], timeout: 1.0) 14 | } 15 | 16 | func testSendTriggersBackpressure() async throws { 17 | let buffer = MessageBuffer(bufferSize: 5) 18 | let expectation1 = XCTestExpectation(description: "Should not enqueue") 19 | expectation1.isInverted = true 20 | let expectation2 = XCTestExpectation(description: "Should enqueue") 21 | Task { 22 | try await buffer.enqueue(content: .string("123456")) 23 | expectation1.fulfill() 24 | expectation2.fulfill() 25 | } 26 | 27 | await fulfillment(of: [expectation1], timeout: 0.5) 28 | let content = try await buffer.TryDequeue() // Only after dequeue, the ack takes effect 29 | XCTAssertEqual("123456", content?.convertToString()) 30 | let rst = try await buffer.ack(sequenceId: 1) 31 | XCTAssertEqual(true, rst) 32 | await fulfillment(of: [expectation2], timeout: 1) 33 | } 34 | 35 | func testBackPressureAndRelease() async throws { 36 | let buffer = MessageBuffer(bufferSize: 10) 37 | try await buffer.enqueue(content: .string("1234567890")) 38 | async let eq1 = buffer.enqueue(content: .string("1")) 39 | async let eq2 = buffer.enqueue(content: .string("2")) 40 | 41 | try await Task.sleep(for: .microseconds(10)) 42 | try await buffer.TryDequeue() // 1234567890 43 | try await buffer.TryDequeue() // 1 44 | try await buffer.TryDequeue() // 2 45 | 46 | // ack 1 and all should be below 47 | try await buffer.ack(sequenceId: 1) 48 | 49 | try await eq1 50 | try await eq2 51 | } 52 | 53 | func testBackPressureAndRelease2() async throws { 54 | let buffer = MessageBuffer(bufferSize: 10) 55 | let expect1 = XCTestExpectation(description: "Should not release 1") 56 | expect1.isInverted = true 57 | let expect2 = XCTestExpectation(description: "Should not release 2") 58 | expect2.isInverted = true 59 | let expect3 = XCTestExpectation(description: "Should not release 3") 60 | expect3.isInverted = true 61 | 62 | try await buffer.enqueue(content: .string("1234567890")) //10 63 | try await Task.sleep(for: .microseconds(10)) 64 | let t1 = Task { 65 | try await buffer.enqueue(content: .string("1")) 66 | expect1.fulfill() 67 | }// 11 68 | try await Task.sleep(for: .microseconds(10)) 69 | let t2 = Task { 70 | try await buffer.enqueue(content: .string("2")) 71 | expect2.fulfill() 72 | }// 12 73 | try await Task.sleep(for: .microseconds(10)) 74 | let t3 = Task { 75 | try await buffer.enqueue(content: .string("123456789")) 76 | expect3.fulfill() 77 | }// 21 78 | try await Task.sleep(for: .microseconds(10)) 79 | 80 | try await buffer.TryDequeue() // 1234567890 81 | try await buffer.TryDequeue() // 1 82 | try await buffer.TryDequeue() // 2 83 | try await buffer.TryDequeue() // 1234567890 84 | 85 | // ack 1 and all should be below 86 | try await buffer.ack(sequenceId: 1) // remain 11, nothing will release 87 | 88 | await fulfillment(of: [expect1, expect2, expect3], timeout: 0.5) 89 | try await buffer.ack(sequenceId: 2) // remain 10, all released 90 | await t1.result 91 | await t2.result 92 | await t3.result 93 | } 94 | 95 | func testAckInvalidSequenceIdIgnored() async throws { 96 | let buffer = MessageBuffer(bufferSize: 100) 97 | let rst = try await buffer.ack(sequenceId: 1) // without any send 98 | XCTAssertEqual(false, rst) 99 | 100 | // Enqueue but not send 101 | try await buffer.enqueue(content: .string("abc")) 102 | let rst2 = try await buffer.ack(sequenceId: 1) 103 | XCTAssertEqual(false, rst2) 104 | } 105 | 106 | func testWaitToDequeueReturnsImmediatelyIfAvailable() async throws { 107 | let buffer = MessageBuffer(bufferSize: 100) 108 | _ = try await buffer.enqueue(content: .string("msg")) 109 | let result = try await buffer.WaitToDequeue() 110 | XCTAssertTrue(result) 111 | let content = try await buffer.TryDequeue() 112 | XCTAssertEqual("msg", content?.convertToString()) 113 | } 114 | 115 | func testWaitToDequeueFirst() async throws { 116 | let buffer = MessageBuffer(bufferSize: 100) 117 | async let dqueue: Bool = try await buffer.WaitToDequeue() 118 | try await Task.sleep(for: .milliseconds(10)) 119 | 120 | try await buffer.enqueue(content: .string("test")) 121 | try await buffer.enqueue(content: .string("test2")) 122 | 123 | let rst = try await dqueue 124 | XCTAssertTrue(rst) 125 | let content = try await buffer.TryDequeue() 126 | XCTAssertEqual("test", content?.convertToString()) 127 | } 128 | 129 | func testMultipleDequeueWait() async throws { 130 | let buffer = MessageBuffer(bufferSize: 100) 131 | async let dqueue1: Bool = try await buffer.WaitToDequeue() 132 | async let dqueue2: Bool = try await buffer.WaitToDequeue() 133 | try await Task.sleep(for: .milliseconds(10)) 134 | 135 | try await buffer.enqueue(content: .string("test")) 136 | 137 | let rst = try await dqueue1 138 | XCTAssertTrue(rst) 139 | let rst2 = try await dqueue2 140 | XCTAssertTrue(rst2) 141 | let content = try await buffer.TryDequeue() 142 | XCTAssertEqual("test", content?.convertToString()) 143 | } 144 | 145 | func testTryDequeueReturnsNilIfEmpty() async throws { 146 | let buffer = MessageBuffer(bufferSize: 100) 147 | let result = try await buffer.TryDequeue() 148 | XCTAssertNil(result) 149 | } 150 | 151 | func testResetDequeueResetsCorrectly() async throws { 152 | let buffer = MessageBuffer(bufferSize: 100) 153 | try await buffer.enqueue(content: .string("test1")) 154 | try await buffer.enqueue(content: .string("test2")) 155 | let t1 = try await buffer.TryDequeue() 156 | XCTAssertEqual("test1", t1?.convertToString()) 157 | let t2 = try await buffer.TryDequeue() 158 | XCTAssertEqual("test2", t2?.convertToString()) 159 | 160 | // wait here 161 | async let dq = try await buffer.WaitToDequeue() 162 | try await Task.sleep(for: .milliseconds(10)) 163 | Task { 164 | try await buffer.ResetDequeue() 165 | } 166 | 167 | try await dq 168 | let t3 = try await buffer.TryDequeue() 169 | XCTAssertEqual("test1", t3?.convertToString()) 170 | let t4 = try await buffer.TryDequeue() 171 | XCTAssertEqual("test2", t4?.convertToString()) 172 | } 173 | 174 | func testContinuousBackPressure() async throws { 175 | let buffer = MessageBuffer(bufferSize: 5) 176 | var tasks: [Task] = [] 177 | for i in 0..<100 { 178 | let task = Task { 179 | try await buffer.enqueue(content: .string("123456")) 180 | } 181 | tasks.append(task) 182 | } 183 | 184 | Task { 185 | while (try await buffer.WaitToDequeue()) { 186 | try await buffer.TryDequeue() 187 | } 188 | } 189 | 190 | for i in 0..<100 { 191 | await tasks[i] 192 | } 193 | 194 | await buffer.close() 195 | } 196 | } -------------------------------------------------------------------------------- /Tests/SignalRClientTests/ServerSentEventTransportTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | #if canImport(EventSource) 5 | import XCTest 6 | 7 | @testable import SignalRClient 8 | 9 | actor MockEventSourceAdaptor: EventSourceAdaptor { 10 | let canConnect: Bool 11 | let sendMessage: Bool 12 | let disconnect: Bool 13 | 14 | var messageHandler: ((String) async -> Void)? 15 | var closeHandler: (((any Error)?) async -> Void)? 16 | 17 | init(canConnect: Bool, sendMessage: Bool, disconnect: Bool) { 18 | self.canConnect = canConnect 19 | self.sendMessage = sendMessage 20 | self.disconnect = disconnect 21 | } 22 | 23 | func start(url: String, headers: [String: String]) async throws { 24 | guard self.canConnect else { 25 | throw SignalRError.eventSourceFailedToConnect 26 | } 27 | Task { 28 | try await Task.sleep(for: .milliseconds(100)) 29 | if sendMessage { 30 | await self.messageHandler?("123") 31 | } 32 | try await Task.sleep(for: .milliseconds(200)) 33 | if disconnect { 34 | await self.closeHandler?(SignalRError.connectionAborted) 35 | } 36 | } 37 | } 38 | 39 | func stop(err: Error?) async { 40 | await self.closeHandler?(err) 41 | } 42 | 43 | func onClose(closeHandler: @escaping ((any Error)?) async -> Void) async { 44 | self.closeHandler = closeHandler 45 | } 46 | 47 | func onMessage(messageHandler: @escaping (String) async -> Void) async { 48 | self.messageHandler = messageHandler 49 | 50 | } 51 | } 52 | 53 | class ServerSentEventTransportTests: XCTestCase { 54 | // MARK: connect 55 | func testConnectSucceed() async throws { 56 | let client = MockHttpClient() 57 | let logHandler = MockLogHandler() 58 | let logger = Logger(logLevel: .debug, logHandler: logHandler) 59 | var options = HttpConnectionOptions() 60 | let eventSource = MockEventSourceAdaptor(canConnect: true, sendMessage: false, disconnect: false) 61 | options.eventSource = eventSource 62 | let sse = ServerSentEventTransport( 63 | httpClient: client, accessToken: "", logger: logger, options: options 64 | ) 65 | try await sse.connect(url: "https://www.bing.com/signalr", transferFormat: .text) 66 | logHandler.verifyLogged("Connecting") 67 | logHandler.verifyLogged("connected") 68 | } 69 | 70 | func testConnectWrongTranferformat() async throws { 71 | let client = MockHttpClient() 72 | let logHandler = MockLogHandler() 73 | let logger = Logger(logLevel: .debug, logHandler: logHandler) 74 | var options = HttpConnectionOptions() 75 | let eventSource = MockEventSourceAdaptor(canConnect: true, sendMessage: false, disconnect: false) 76 | options.eventSource = eventSource 77 | let sse = ServerSentEventTransport( 78 | httpClient: client, accessToken: "", logger: logger, options: options 79 | ) 80 | await sse.SetEventSource(eventSource: eventSource) 81 | do { 82 | try await sse.connect(url: "https://abc", transferFormat: .binary) 83 | XCTFail("SSE connect should fail") 84 | } catch SignalRError.eventSourceInvalidTransferFormat { 85 | } 86 | logHandler.verifyNotLogged("connected") 87 | } 88 | 89 | func testConnectFail() async throws { 90 | let client = MockHttpClient() 91 | let logHandler = MockLogHandler() 92 | let logger = Logger(logLevel: .debug, logHandler: logHandler) 93 | var options = HttpConnectionOptions() 94 | let eventSource = MockEventSourceAdaptor(canConnect: false, sendMessage: false, disconnect: false) 95 | options.eventSource = eventSource 96 | let sse = ServerSentEventTransport( 97 | httpClient: client, accessToken: "", logger: logger, options: options 98 | ) 99 | do { 100 | try await sse.connect(url: "https://abc", transferFormat: .text) 101 | XCTFail("SSE connect should fail") 102 | } catch SignalRError.eventSourceFailedToConnect { 103 | } 104 | logHandler.verifyNotLogged("connected") 105 | } 106 | 107 | func testConnectAndReceiveMessage() async throws { 108 | let client = MockHttpClient() 109 | let logHandler = MockLogHandler() 110 | let logger = Logger(logLevel: .debug, logHandler: logHandler) 111 | var options = HttpConnectionOptions() 112 | let eventSource = MockEventSourceAdaptor(canConnect: true, sendMessage: true, disconnect: false) 113 | options.eventSource = eventSource 114 | let sse = ServerSentEventTransport( 115 | httpClient: client, accessToken: "", logger: logger, options: options 116 | ) 117 | let expectation = XCTestExpectation(description: "Message should be received") 118 | await sse.onReceive() { message in 119 | switch message { 120 | case .string(let str): 121 | if str == "123" { 122 | expectation.fulfill() 123 | } 124 | default: 125 | break 126 | } 127 | } 128 | try await sse.connect(url: "https://abc", transferFormat: .text) 129 | logHandler.verifyLogged("connected") 130 | await fulfillment(of: [expectation], timeout: 1) 131 | } 132 | 133 | func testConnectAndDisconnect() async throws { 134 | let client = MockHttpClient() 135 | let logHandler = MockLogHandler() 136 | let logger = Logger(logLevel: .debug, logHandler: logHandler) 137 | var options = HttpConnectionOptions() 138 | let eventSource = MockEventSourceAdaptor(canConnect: true, sendMessage: false, disconnect: true) 139 | options.eventSource = eventSource 140 | let sse = ServerSentEventTransport( 141 | httpClient: client, accessToken: "", logger: logger, options: options 142 | ) 143 | let expectation = XCTestExpectation(description: "SSE should be disconnected") 144 | await sse.onClose() { err in 145 | let err = err as? SignalRError 146 | if err == SignalRError.connectionAborted { 147 | expectation.fulfill() 148 | } 149 | } 150 | try await sse.connect(url: "https://abc", transferFormat: .text) 151 | logHandler.verifyLogged("connected") 152 | await fulfillment(of: [expectation], timeout: 1) 153 | } 154 | 155 | // MARK: send 156 | func testSend() async throws { 157 | let client = MockHttpClient() 158 | let logHandler = MockLogHandler() 159 | let logger = Logger(logLevel: .debug, logHandler: logHandler) 160 | let options = HttpConnectionOptions() 161 | let sse = ServerSentEventTransport( 162 | httpClient: client, accessToken: "", logger: logger, options: options 163 | ) 164 | let eventSource = MockEventSourceAdaptor(canConnect: false, sendMessage: false, disconnect: false) 165 | await sse.SetEventSource(eventSource: eventSource) 166 | await sse.SetUrl(url: "http://abc") 167 | await client.mock(mockId: "string") { request in 168 | XCTAssertEqual(request.content, StringOrData.string("stringbody")) 169 | try await Task.sleep(for: .milliseconds(100)) 170 | return ( 171 | StringOrData.string(""), 172 | HttpResponse(statusCode: 200) 173 | ) 174 | } 175 | await sse.SetMockId(mockId: "string") 176 | try await sse.send(.string("stringbody")) 177 | logHandler.verifyLogged("200") 178 | } 179 | 180 | // MARK: asyncStream 181 | func testAsyncStream() async { 182 | let stream: AsyncStream = AsyncStream { continuition in 183 | Task { 184 | for i in 0 ... 99 { 185 | try await Task.sleep(for: .microseconds(100)) 186 | continuition.yield(i) 187 | } 188 | continuition.finish() 189 | } 190 | } 191 | var count = 0 192 | for await _ in stream { 193 | count += 1 194 | } 195 | XCTAssertEqual(count, 100) 196 | } 197 | } 198 | 199 | extension ServerSentEventTransport { 200 | fileprivate func SetEventSource(eventSource: EventSourceAdaptor) { 201 | self.eventSource = eventSource 202 | } 203 | 204 | fileprivate func SetUrl(url: String) { 205 | self.url = url 206 | } 207 | 208 | fileprivate func SetMockId(mockId: String) { 209 | if self.options.headers == nil { 210 | self.options.headers = [:] 211 | } 212 | self.options.headers![mockKey] = mockId 213 | } 214 | } 215 | #endif -------------------------------------------------------------------------------- /Tests/SignalRClientTests/TaskCompletionSourceTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import XCTest 5 | 6 | @testable import SignalRClient 7 | 8 | class TaskCompletionSourceTests: XCTestCase { 9 | func testSetVarAfterWait() async throws { 10 | let tcs = TaskCompletionSource() 11 | let t = Task { 12 | try await Task.sleep(for: .seconds(1)) 13 | let set = await tcs.trySetResult(.success(true)) 14 | XCTAssertTrue(set) 15 | } 16 | let start = Date() 17 | let value = try await tcs.task() 18 | let elapsed = Date().timeIntervalSince(start) 19 | XCTAssertTrue(value) 20 | XCTAssertLessThan(abs(elapsed - 1), 0.5) 21 | try await t.value 22 | } 23 | 24 | func testSetVarBeforeWait() async throws { 25 | let tcs = TaskCompletionSource() 26 | let set = await tcs.trySetResult(.success(true)) 27 | XCTAssertTrue(set) 28 | let start = Date() 29 | let value = try await tcs.task() 30 | let elapsed = Date().timeIntervalSince(start) 31 | XCTAssertTrue(value) 32 | XCTAssertLessThan(elapsed, 0.1) 33 | } 34 | 35 | func testSetException() async throws { 36 | let tcs = TaskCompletionSource() 37 | let t = Task { 38 | try await Task.sleep(for: .seconds(1)) 39 | let set = await tcs.trySetResult( 40 | .failure(SignalRError.noHandshakeMessageReceived)) 41 | XCTAssertTrue(set) 42 | } 43 | let start = Date() 44 | do { 45 | _ = try await tcs.task() 46 | } catch { 47 | XCTAssertEqual( 48 | error as? SignalRError, SignalRError.noHandshakeMessageReceived 49 | ) 50 | } 51 | let elapsed = Date().timeIntervalSince(start) 52 | XCTAssertLessThan(abs(elapsed - 1), 0.5) 53 | try await t.value 54 | } 55 | 56 | func testMultiSetAndMultiWait() async throws { 57 | let tcs = TaskCompletionSource() 58 | 59 | let t = Task { 60 | try await Task.sleep(for: .seconds(1)) 61 | var set = await tcs.trySetResult(.success(true)) 62 | XCTAssertTrue(set) 63 | set = await tcs.trySetResult(.success(false)) 64 | XCTAssertFalse(set) 65 | } 66 | 67 | let start = Date() 68 | let value = try await tcs.task() 69 | let elapsed = Date().timeIntervalSince(start) 70 | XCTAssertTrue(value) 71 | XCTAssertLessThan(abs(elapsed - 1), 0.5) 72 | 73 | let start2 = Date() 74 | let value2 = try await tcs.task() 75 | let elapsed2 = Date().timeIntervalSince(start2) 76 | XCTAssertTrue(value2) 77 | XCTAssertLessThan(elapsed2, 0.1) 78 | 79 | try await t.value 80 | } 81 | 82 | func testBench() async { 83 | let total = 10000 84 | var tcss: [TaskCompletionSource] = [] 85 | tcss.reserveCapacity(total) 86 | for _ in 1 ... total { 87 | tcss.append(TaskCompletionSource()) 88 | } 89 | let start = Date() 90 | let expectation = expectation(description: "Tcss should all complete") 91 | let counter = Counter(value: 0) 92 | for tcs in tcss { 93 | Task { 94 | try await Task.sleep(for: .microseconds(10)) 95 | try await tcs.task() 96 | let c = await counter.increase(delta: 1) 97 | if c == total { 98 | expectation.fulfill() 99 | print(Date().timeIntervalSince(start)) 100 | } 101 | } 102 | } 103 | 104 | for (i, tcs) in tcss.enumerated() { 105 | Task { 106 | try await Task.sleep( 107 | for: .microseconds(i % 2 == 0 ? 5 : 15)) 108 | _ = await tcs.trySetResult(.success(())) 109 | } 110 | } 111 | 112 | await fulfillment(of: [expectation], timeout: 1) 113 | } 114 | } 115 | 116 | actor Counter { 117 | var value: Int 118 | init(value: Int) { 119 | self.value = value 120 | } 121 | func increase(delta: Int) -> Int { 122 | value += delta 123 | return value 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Tests/SignalRClientTests/TextMessageFormatTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import XCTest 5 | @testable import SignalRClient 6 | 7 | class TextMessageFormatTests: XCTestCase { 8 | 9 | func testWrite() { 10 | let message = "Hello, World!" 11 | let expectedOutput = "Hello, World!\u{1e}" 12 | let result = TextMessageFormat.write(message) 13 | XCTAssertEqual(result, expectedOutput) 14 | } 15 | 16 | func testParseSingleMessage() { 17 | let input = "Hello, World!\u{1e}" 18 | do { 19 | let result = try TextMessageFormat.parse(input) 20 | XCTAssertEqual(result, ["Hello, World!"]) 21 | } catch { 22 | XCTFail("Parsing failed with error: \(error)") 23 | } 24 | } 25 | 26 | func testParseMultipleMessages() { 27 | let input = "Hello\u{1e}World\u{1e}" 28 | do { 29 | let result = try TextMessageFormat.parse(input) 30 | XCTAssertEqual(result, ["Hello", "World"]) 31 | } catch { 32 | XCTFail("Parsing failed with error: \(error)") 33 | } 34 | } 35 | 36 | func testParseIncompleteMessage() { 37 | let input = "Hello, World!" 38 | XCTAssertThrowsError(try TextMessageFormat.parse(input)) { error in 39 | XCTAssertEqual(error as? SignalRError, SignalRError.incompleteMessage) 40 | } 41 | } 42 | 43 | func testParseEmptyMessage() { 44 | let input = "\u{1e}" 45 | do { 46 | let result = try TextMessageFormat.parse(input) 47 | XCTAssertEqual(result, []) 48 | } catch { 49 | XCTFail("Parsing failed with error: \(error)") 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /Tests/SignalRClientTests/TimeSchedulerTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import XCTest 5 | @testable import SignalRClient 6 | 7 | class TimeSchedulerrTests: XCTestCase { 8 | var scheduler: TimeScheduler! 9 | var sendActionCalled: Bool! 10 | 11 | override func setUp() { 12 | super.setUp() 13 | scheduler = TimeScheduler(initialInterval: 0.1) 14 | sendActionCalled = false 15 | } 16 | 17 | override func tearDown() async throws { 18 | await scheduler.stop() 19 | scheduler = nil 20 | sendActionCalled = nil 21 | try await super.tearDown() 22 | } 23 | 24 | func testStart() async { 25 | let expectations = [ 26 | self.expectation(description: "sendAction called"), 27 | self.expectation(description: "sendAction called"), 28 | self.expectation(description: "sendAction called") 29 | ] 30 | 31 | var counter = 0 32 | await scheduler.start { 33 | if counter <= 2 { 34 | expectations[counter].fulfill() 35 | } 36 | counter += 1 37 | } 38 | 39 | await fulfillment(of: [expectations[0], expectations[1], expectations[2]], timeout: 1) 40 | } 41 | 42 | func testStop() async { 43 | let stopExpectation = self.expectation(description: "sendAction not called") 44 | stopExpectation.isInverted = true 45 | 46 | await scheduler.start { 47 | stopExpectation.fulfill() 48 | } 49 | 50 | await scheduler.stop() 51 | 52 | await fulfillment(of: [stopExpectation], timeout: 0.5) 53 | } 54 | 55 | func testUpdateInterval() async { 56 | let invertedExpectation = self.expectation(description: "Should not called") 57 | invertedExpectation.isInverted = true 58 | let expectation = self.expectation(description: "sendAction called") 59 | await scheduler.updateInterval(to: 5) 60 | 61 | await scheduler.start { 62 | invertedExpectation.fulfill() 63 | expectation.fulfill() 64 | } 65 | 66 | await fulfillment(of: [invertedExpectation], timeout: 0.5) 67 | await scheduler.updateInterval(to: 0.1) 68 | 69 | await fulfillment(of: [expectation], timeout: 1) 70 | } 71 | } -------------------------------------------------------------------------------- /Tests/SignalRClientTests/UtilsTest.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | import XCTest 5 | 6 | @testable import SignalRClient 7 | 8 | class UtilsTest: XCTestCase { 9 | func testHttpRequestExtention() { 10 | var options = HttpConnectionOptions() 11 | options.timeout = 123 12 | options.headers = ["a": "b", "h": "i"] 13 | let request = HttpRequest(method: .GET, url: "http://abc", headers: ["a": "c", "d": "e"], options: options) 14 | XCTAssertEqual(request.timeout, 123) 15 | XCTAssertEqual(request.headers["a"], "b") 16 | XCTAssertEqual(request.headers["d"], "e") 17 | XCTAssertEqual(request.headers["h"], "i") 18 | } 19 | 20 | func testStringOrDataIsEmpty() { 21 | XCTAssertTrue(StringOrData.string("").isEmpty()) 22 | XCTAssertFalse(StringOrData.string("1").isEmpty()) 23 | XCTAssertTrue(StringOrData.data(Data()).isEmpty()) 24 | XCTAssertFalse(StringOrData.data(Data(repeating: .max, count: 1)).isEmpty()) 25 | } 26 | 27 | func testUserAgent() { 28 | _ = Utils.getUserAgent() 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Tests/SignalRClientTests/WebSocketTransportTests.swift: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | 4 | #if canImport(FoundationNetworking) 5 | import FoundationNetworking 6 | #endif 7 | import XCTest 8 | @testable import SignalRClient 9 | 10 | class WebSocketTransportTests: XCTestCase { 11 | private var logger: Logger! 12 | private var mockWebSocketConnection: MockWebSocketConnection! 13 | private var webSocketTransport: WebSocketTransport! 14 | 15 | override func setUp() { 16 | super.setUp() 17 | logger = Logger(logLevel: .debug, logHandler: DefaultLogHandler()) 18 | mockWebSocketConnection = MockWebSocketConnection() 19 | webSocketTransport = WebSocketTransport(accessTokenFactory: nil, logger: logger, headers: [:], websocket: mockWebSocketConnection) 20 | } 21 | 22 | func testConnect() async throws { 23 | let url = "http://example.com" 24 | try await webSocketTransport.connect(url: url, transferFormat: .text) 25 | XCTAssertTrue(mockWebSocketConnection.connectCalled) 26 | } 27 | 28 | func testSend() async throws { 29 | let data = StringOrData.string("test message") 30 | try await webSocketTransport.send(data) 31 | XCTAssertEqual(mockWebSocketConnection.sentData, data) 32 | } 33 | 34 | func testStop() async throws { 35 | try await webSocketTransport.stop(error: nil) 36 | XCTAssertTrue(mockWebSocketConnection.stopCalled) 37 | } 38 | 39 | func testOnReceive() async { 40 | let expectation = XCTestExpectation(description: "onReceive handler called") 41 | await webSocketTransport.onReceive { data in 42 | expectation.fulfill() 43 | } 44 | await mockWebSocketConnection.triggerReceive(.string("test message")) 45 | await fulfillment(of: [expectation], timeout: 1.0) 46 | } 47 | 48 | func testOnClose() async { 49 | let expectation = XCTestExpectation(description: "onClose handler called") 50 | await webSocketTransport.onClose { error in 51 | expectation.fulfill() 52 | } 53 | await mockWebSocketConnection.triggerClose(nil) 54 | await fulfillment(of: [expectation], timeout: 1.0) 55 | } 56 | 57 | func testConnectWithHttpUrl() async throws { 58 | let url = "http://example.com" 59 | try await webSocketTransport.connect(url: url, transferFormat: .text) 60 | XCTAssertTrue(mockWebSocketConnection.connectCalled) 61 | XCTAssertEqual(mockWebSocketConnection.request?.url?.scheme, "ws") 62 | } 63 | 64 | func testConnectWithHttpsUrl() async throws { 65 | let url = "https://example.com" 66 | try await webSocketTransport.connect(url: url, transferFormat: .text) 67 | XCTAssertTrue(mockWebSocketConnection.connectCalled) 68 | XCTAssertEqual(mockWebSocketConnection.request?.url?.scheme, "wss") 69 | } 70 | 71 | func testConnectWithHeaders() async throws { 72 | let headers = ["Authorization": "Bearer token"] 73 | webSocketTransport = WebSocketTransport(accessTokenFactory: nil, logger: logger, headers: headers, websocket: mockWebSocketConnection) 74 | let url = "http://example.com" 75 | try await webSocketTransport.connect(url: url, transferFormat: .text) 76 | XCTAssertTrue(mockWebSocketConnection.connectCalled) 77 | XCTAssertEqual(mockWebSocketConnection.request?.allHTTPHeaderFields?["Authorization"], "Bearer token") 78 | } 79 | 80 | func testConnectWithAccessToken() async throws { 81 | let accessTokenFactory: @Sendable () async throws -> String? = { return "test_token" } 82 | webSocketTransport = WebSocketTransport(accessTokenFactory: accessTokenFactory, logger: logger, headers: [:], websocket: mockWebSocketConnection) 83 | let url = "http://example.com" 84 | try await webSocketTransport.connect(url: url, transferFormat: .text) 85 | XCTAssertTrue(mockWebSocketConnection.connectCalled) 86 | XCTAssertEqual(mockWebSocketConnection.request?.value(forHTTPHeaderField: "Authorization"), "Bearer test_token") 87 | } 88 | } 89 | 90 | class MockWebSocketConnection: WebSocketTransport.WebSocketConnection { 91 | var connectCalled = false 92 | var sentData: StringOrData? 93 | var stopCalled = false 94 | var onReceiveHandler: Transport.OnReceiveHandler? 95 | var onCloseHandler: Transport.OnCloseHander? 96 | var request: URLRequest? 97 | 98 | func connect(request: URLRequest, transferFormat: TransferFormat) async throws { 99 | self.request = request 100 | connectCalled = true 101 | } 102 | 103 | func send(_ data: StringOrData) async throws { 104 | sentData = data 105 | } 106 | 107 | func stop(error: Error?) async { 108 | stopCalled = true 109 | } 110 | 111 | func onReceive(_ handler: Transport.OnReceiveHandler?) async { 112 | onReceiveHandler = handler 113 | } 114 | 115 | func onClose(_ handler: Transport.OnCloseHander?) async { 116 | onCloseHandler = handler 117 | } 118 | 119 | func triggerReceive(_ data: StringOrData) async { 120 | await onReceiveHandler?(data) 121 | } 122 | 123 | func triggerClose(_ error: Error?) async { 124 | await onCloseHandler?(error) 125 | } 126 | } 127 | --------------------------------------------------------------------------------