├── .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 | 
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 | 
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