Skip to content

Commit

Permalink
Return to using URLSessionWebSocketTask (#13)
Browse files Browse the repository at this point in the history
- Replace NWConnection with URLSessionWebSocketTask because it was too hard to know what the correct NWConnection behavior should be given different server responses
- Update to Swift 5.8

Note: The CI tests do not pass because GitHub Actions do not currently support Xcode 14.3.
  • Loading branch information
atdrendel committed Apr 10, 2023
1 parent b2090da commit 67a0bab
Show file tree
Hide file tree
Showing 23 changed files with 978 additions and 979 deletions.
16 changes: 7 additions & 9 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,16 +1,14 @@
name: Test
name: CI

on:
push:
on: push

jobs:
test:
name: Test
library:
runs-on: macos-latest

steps:
- uses: actions/checkout@v2
- name: Build
run: swift build -v
- uses: actions/checkout@v3
- name: Select Xcode 14
run: sudo xcode-select -s /Applications/Xcode_14.3.app
- name: Test
run: swift test -v
run: swift test
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

.swiftpm

/.vscode

# Xcode
#
# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore
Expand Down Expand Up @@ -62,4 +64,3 @@ Carthage/Build

fastlane/report.xml
fastlane/screenshots

2 changes: 1 addition & 1 deletion .swift-version
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
5.3.0
5.8.0

6 changes: 5 additions & 1 deletion .swiftformat
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
--disable \
hoistAwait, \
hoistTry

--decimalgrouping 3,5
--funcattributes prev-line
--minversion 0.47.2
--maxwidth 96
--typeattributes prev-line
--wraparguments before-first
--wrapparameters before-first
--wrapcollections before-first
--xcodeindentation enabled
86 changes: 86 additions & 0 deletions Package.resolved
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
{
"pins" : [
{
"identity" : "async-extensions",
"kind" : "remoteSourceControl",
"location" : "https://github.com/shareup/async-extensions.git",
"state" : {
"revision" : "59504194f84b8c66a27503b5fd0640ac9b01f42a",
"version" : "4.1.0"
}
},
{
"identity" : "dispatch-timer",
"kind" : "remoteSourceControl",
"location" : "https://github.com/shareup/dispatch-timer.git",
"state" : {
"revision" : "2d8c304aa6f382a7a362cd5a814884f3930c5662",
"version" : "3.0.1"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "6c89474e62719ddcc1e9614989fff2f68208fe10",
"version" : "1.1.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "937e904258d22af6e447a0b72c0bc67583ef64a2",
"version" : "1.0.4"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "9b2848d76f5caad08b97e71a04345aa5bdb23a06",
"version" : "2.49.0"
}
},
{
"identity" : "swift-nio-ssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-ssl.git",
"state" : {
"revision" : "4fb7ead803e38949eb1d6fabb849206a72c580f3",
"version" : "2.23.0"
}
},
{
"identity" : "swift-nio-transport-services",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-transport-services.git",
"state" : {
"revision" : "c0d9a144cfaec8d3d596aadde3039286a266c15c",
"version" : "1.15.0"
}
},
{
"identity" : "synchronized",
"kind" : "remoteSourceControl",
"location" : "https://github.com/shareup/synchronized.git",
"state" : {
"revision" : "85653e23270ec88ae19f8d494157769487e34aed",
"version" : "4.0.1"
}
},
{
"identity" : "websocket-kit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/vapor/websocket-kit.git",
"state" : {
"revision" : "2b8885974e8d9f522e787805000553f4f7cce8a0",
"version" : "2.7.0"
}
}
],
"version" : 2
}
43 changes: 38 additions & 5 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,26 +1,59 @@
// swift-tools-version:5.3
// swift-tools-version:5.8

import PackageDescription

let package = Package(
name: "WebSocket",
platforms: [
.macOS(.v11), .iOS(.v14), .tvOS(.v14), .watchOS(.v7),
.macOS(.v12), .iOS(.v15), .tvOS(.v15), .watchOS(.v8),
],
products: [
.library(
name: "WebSocket",
targets: ["WebSocket"]
),
],
dependencies: [],
dependencies: [
.package(
url: "https://github.com/shareup/async-extensions.git",
from: "4.1.0"
),
.package(
url: "https://github.com/shareup/dispatch-timer.git",
from: "3.0.0"
),
.package(
url: "https://github.com/vapor/websocket-kit.git",
from: "2.6.1"
),
.package(
url: "https://github.com/apple/swift-nio.git",
from: "2.0.0"
),
],
targets: [
.target(
name: "WebSocket",
dependencies: []
dependencies: [
.product(name: "AsyncExtensions", package: "async-extensions"),
.product(name: "DispatchTimer", package: "dispatch-timer"),
],
swiftSettings: [
.unsafeFlags([
"-Xfrontend", "-warn-concurrency",
"-Xfrontend", "-enable-actor-data-race-checks",
]),
]
),
.testTarget(
name: "WebSocketTests",
dependencies: ["WebSocket"]
dependencies: [
.product(name: "NIO", package: "swift-nio"),
.product(name: "NIOHTTP1", package: "swift-nio"),
.product(name: "NIOWebSocket", package: "swift-nio"),
"WebSocket",
.product(name: "WebSocketKit", package: "websocket-kit"),
]
),
]
)
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,26 @@

A concrete implementation of a WebSocket client implemented by wrapping Apple's [`NWConnection`](https://developer.apple.com/documentation/network/nwconnection).

The public "interface" of `WebSocket` is a simple struct whose public "methods" are exposed as closures. The reason for this design is to make it easy to inject fake `WebSocket`s into your code for testing purposes.
The public interface of `WebSocket` is a simple struct whose public methods are exposed as closures. The reason for this design is to make it easy to inject fake WebSockets into your code for testing purposes.

The actual implementation is `SystemWebSocket`, but this type is not publicly accessible. Instead, you can access it via `WebSocket.system(url:)`. `SystemWebSocket` tries its best to mirror the documented behavior of web browsers' [`WebSocket`](http://developer.mozilla.org/en-US/docs/Web/API/WebSocket). Please report any deviations as bugs.

`WebSocket` exposes a simple API, makes heavy use of [Swift Concurrency](https://developer.apple.com/documentation/swift/swift_standard_library/concurrency), and conforms to Apple's Combine [`Publisher`](https://developer.apple.com/documentation/combine/publisher).
`WebSocket` exposes a simple API and makes heavy use of [Swift Concurrency](https://developer.apple.com/documentation/swift/swift_standard_library/concurrency).

## Installation

To use WebSocket, add a dependency to your Package.swift file:

```swift
let package = Package(
dependencies: [
.package(
url: "https://github.com/shareup/websocket-apple.git",
from: "4.0.0"
)
]
)
```

## Usage

Expand Down
6 changes: 3 additions & 3 deletions Sources/WebSocket/OSLog+WebSocket.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import Foundation
import os.log

extension OSLog {
private static var subsystem =
Bundle.main.bundleIdentifier ?? "app.shareup.websocket-apple"

static let webSocket = OSLog(subsystem: subsystem, category: "websocket")
}

private let subsystem =
Bundle.main.bundleIdentifier ?? "app.shareup.websocket-apple"
120 changes: 120 additions & 0 deletions Sources/WebSocket/SystemURLSession.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Foundation
import Synchronized

func webSocketTask(
for url: URL,
options: WebSocketOptions,
onOpen: @escaping @Sendable () async -> Void,
onClose: @escaping @Sendable (WebSocketCloseCode, Data?) async -> Void
) -> URLSessionWebSocketTask {
let session = session(for: options)

let task = session.webSocketTask(with: url)
task.maximumMessageSize = options.maximumMessageSize

let delegate = session.delegate as! Delegate
delegate.set(onOpen: onOpen, onClose: onClose, for: ObjectIdentifier(task))

return task
}

func cancelAndInvalidateAllTasks() {
sessions.access { sessions in
sessions.forEach { $0.value.invalidateAndCancel() }
sessions.removeAll()
}
}

private let sessions = Locked<[WebSocketOptions: URLSession]>([:])

private func session(for options: WebSocketOptions) -> URLSession {
sessions.access { sessions in
if let session = sessions[options] {
return session
} else {
let session = URLSession(
configuration: configuration(with: options),
delegate: Delegate(),
delegateQueue: nil
)

sessions[options] = session

return session
}
}
}

private func configuration(with options: WebSocketOptions) -> URLSessionConfiguration {
let config = URLSessionConfiguration.default
config.waitsForConnectivity = false
config.timeoutIntervalForRequest = options.timeoutIntervalForRequest
config.timeoutIntervalForResource = options.timeoutIntervalForResource
return config
}

private final class Delegate: NSObject, URLSessionWebSocketDelegate, Sendable {
private struct Callbacks: Sendable {
let onOpen: @Sendable () async -> Void
let onClose: @Sendable (WebSocketCloseCode, Data?) async -> Void
}

// `Dictionary<ObjectIdentifier(URLWebSocketTask): Callbacks>`
private let state: Locked<[ObjectIdentifier: Callbacks]> = .init([:])

func set(
onOpen: @escaping @Sendable () async -> Void,
onClose: @escaping @Sendable (WebSocketCloseCode, Data?) async -> Void,
for taskID: ObjectIdentifier
) {
state.access { $0[taskID] = .init(onOpen: onOpen, onClose: onClose) }
}

func urlSession(
_: URLSession,
webSocketTask: URLSessionWebSocketTask,
didOpenWithProtocol _: String?
) {
let taskID = ObjectIdentifier(webSocketTask)

if let onOpen = state.access({ $0[taskID]?.onOpen }) {
Task { await onOpen() }
}
}

func urlSession(
_: URLSession,
webSocketTask: URLSessionWebSocketTask,
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
reason: Data?
) {
let taskID = ObjectIdentifier(webSocketTask)

if let onClose = state.access({ $0[taskID]?.onClose }) {
Task { await onClose(WebSocketCloseCode(closeCode), reason) }
}
}

func urlSession(
_: URLSession,
task: URLSessionTask,
didCompleteWithError error: Error?
) {
let taskID = ObjectIdentifier(task)

if let onClose = state.access({ $0[taskID]?.onClose }) {
Task { [weak self] in
if let error {
await onClose(
.abnormalClosure,
Data(error.localizedDescription.utf8)
)
} else {
await onClose(.normalClosure, nil)
}

self?.state.access { _ = $0.removeValue(forKey: taskID) }
}
}
}
}
Loading

0 comments on commit 67a0bab

Please sign in to comment.