react-nitro-networking 0.0.0-stage → 0.0.1-alpha.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/Package.swift +102 -0
- package/README.md +58 -2
- package/Sources/ReactNitroNetworking/HybridNetworking.swift +17 -0
- package/Sources/ReactNitroNetworking/HybridResponse.swift +87 -0
- package/Sources/ReactNitroNetworking/HybridWebSocket.swift +188 -0
- package/Sources/ReactNitroNetworking/NetworkingTaskDelegate.swift +16 -0
- package/Sources/ReactNitroNetworking/WebSocketListeners.swift +49 -0
- package/Sources/ReactNitroNetworking/WebSocketTaskDelegate.swift +26 -0
- package/Sources/ReactNitroNetworking/WebSocketURL.swift +18 -0
- package/nitrogen/generated/.gitattributes +1 -0
- package/nitrogen/generated/apple/ReactNitroNetworking-Swift-Cxx-Bridge.cpp +171 -0
- package/nitrogen/generated/apple/ReactNitroNetworkingAppleCxx/ReactNitroNetworkingAppleCxx.cpp +9 -0
- package/nitrogen/generated/apple/ReactNitroNetworkingAppleCxx/include/ReactNitroNetworkingAppleCxx/ReactNitroNetworkingAppleCxx.h +97 -0
- package/nitrogen/generated/apple/ReactNitroNetworkingAutolinking.cpp +24 -0
- package/nitrogen/generated/apple/ReactNitroNetworkingAutolinking.mm +24 -0
- package/nitrogen/generated/apple/c++/HybridNetworkingSpecSwift.cpp +42 -0
- package/nitrogen/generated/apple/c++/HybridNetworkingSpec_cxx-Swift.cpp +28 -0
- package/nitrogen/generated/apple/c++/HybridResponseSpecSwift.cpp +102 -0
- package/nitrogen/generated/apple/c++/HybridResponseSpec_cxx-Swift.cpp +28 -0
- package/nitrogen/generated/apple/c++/HybridWebSocketSpecSwift.cpp +111 -0
- package/nitrogen/generated/apple/c++/HybridWebSocketSpec_cxx-Swift.cpp +28 -0
- package/nitrogen/generated/apple/c++/ListenerSubscription-Swift.cpp +37 -0
- package/nitrogen/generated/apple/c++/Record_swift__String_swift__String-Swift.cpp +26 -0
- package/nitrogen/generated/apple/c++/ResponseType-Swift.cpp +58 -0
- package/nitrogen/generated/apple/c++/Variant_ArrayBuffer_String-Swift.cpp +41 -0
- package/nitrogen/generated/apple/c++/include/ReactNitroNetworking/ReactNitroNetworkingAutolinking.hpp +13 -0
- package/nitrogen/generated/apple/c++/include/ReactNitroNetworking/private/HybridNetworkingSpecSwift.hpp +72 -0
- package/nitrogen/generated/apple/c++/include/ReactNitroNetworking/private/HybridNetworkingSpec_cxx-Swift.hpp +23 -0
- package/nitrogen/generated/apple/c++/include/ReactNitroNetworking/private/HybridResponseSpecSwift.hpp +81 -0
- package/nitrogen/generated/apple/c++/include/ReactNitroNetworking/private/HybridResponseSpec_cxx-Swift.hpp +23 -0
- package/nitrogen/generated/apple/c++/include/ReactNitroNetworking/private/HybridWebSocketSpecSwift.hpp +78 -0
- package/nitrogen/generated/apple/c++/include/ReactNitroNetworking/private/HybridWebSocketSpec_cxx-Swift.hpp +23 -0
- package/nitrogen/generated/apple/c++/include/ReactNitroNetworking/private/ListenerSubscription-Swift.hpp +23 -0
- package/nitrogen/generated/apple/c++/include/ReactNitroNetworking/private/ReactNitroNetworking-Swift-Cxx-Umbrella.hpp +44 -0
- package/nitrogen/generated/apple/c++/include/ReactNitroNetworking/private/Record_swift__String_swift__String-Swift.hpp +23 -0
- package/nitrogen/generated/apple/c++/include/ReactNitroNetworking/private/ResponseType-Swift.hpp +23 -0
- package/nitrogen/generated/apple/c++/include/ReactNitroNetworking/private/Variant_ArrayBuffer_String-Swift.hpp +23 -0
- package/nitrogen/generated/apple/c++/include/module.modulemap +12 -0
- package/nitrogen/generated/apple/swift/Callback_void.swift +60 -0
- package/nitrogen/generated/apple/swift/Callback_void___ReactNitroNetworking__Variant_ArrayBuffer_String_cxx.swift +66 -0
- package/nitrogen/generated/apple/swift/Callback_void_std__exception_ptr.swift +60 -0
- package/nitrogen/generated/apple/swift/HybridNetworkingSpec.swift +65 -0
- package/nitrogen/generated/apple/swift/HybridNetworkingSpec_cxx.swift +143 -0
- package/nitrogen/generated/apple/swift/HybridResponseSpec.swift +74 -0
- package/nitrogen/generated/apple/swift/HybridResponseSpec_cxx.swift +201 -0
- package/nitrogen/generated/apple/swift/HybridWebSocketSpec.swift +70 -0
- package/nitrogen/generated/apple/swift/HybridWebSocketSpec_cxx.swift +201 -0
- package/nitrogen/generated/apple/swift/ListenerSubscription.swift +29 -0
- package/nitrogen/generated/apple/swift/Promise_ArrayBuffer.swift +100 -0
- package/nitrogen/generated/apple/swift/Promise_NitroModules__AnyJSON.swift +102 -0
- package/nitrogen/generated/apple/swift/Promise___ReactNitroNetworking__HybridResponseSpec_cxx.swift +102 -0
- package/nitrogen/generated/apple/swift/Promise_swift__String.swift +102 -0
- package/nitrogen/generated/apple/swift/Promise_void.swift +100 -0
- package/nitrogen/generated/apple/swift/Record_swift__String_swift__String.swift +48 -0
- package/nitrogen/generated/apple/swift/ResponseType.swift +74 -0
- package/nitrogen/generated/apple/swift/Variant_ArrayBuffer_String.swift +69 -0
- package/nitrogen/generated/hybrid-classes.json +29 -0
- package/nitrogen/generated/shared/c++/ReactNitroNetworkingCxxModule.cpp +2 -0
- package/nitrogen/generated/shared/c++/include/ReactNitroNetworking/ReactNitroNetworkingCxxModule.hpp +2 -0
- package/nitrogen/generated/shared/javascript/Networking.cjs +19 -0
- package/nitrogen/generated/shared/javascript/Networking.d.cts +9 -0
- package/nitrogen/generated/shared/javascript/Response.cjs +19 -0
- package/nitrogen/generated/shared/javascript/Response.d.cts +9 -0
- package/nitrogen/generated/shared/javascript/WebSocket.cjs +19 -0
- package/nitrogen/generated/shared/javascript/WebSocket.d.cts +9 -0
- package/package.json +34 -4
- package/src/ListenerSubscription.ts +7 -0
- package/src/ResponseType.ts +8 -0
- package/src/index.ts +5 -0
- package/src/specs/Networking.nitro.ts +18 -0
- package/src/specs/Response.nitro.ts +37 -0
- package/src/specs/WebSocket.nitro.ts +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Margelo
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
|
|
9
|
+
of the Software, and to permit persons to whom the Software is furnished to do
|
|
10
|
+
so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/Package.swift
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// swift-tools-version: 6.4
|
|
2
|
+
|
|
3
|
+
import Foundation
|
|
4
|
+
import PackageDescription
|
|
5
|
+
|
|
6
|
+
let name = "ReactNitroNetworking"
|
|
7
|
+
let generated = "nitrogen/generated"
|
|
8
|
+
let nitroDependencies: [Target.Dependency] = [
|
|
9
|
+
.product(name: "NitroModules", package: "nitro-core")
|
|
10
|
+
]
|
|
11
|
+
let cxxSettings: [CXXSetting] = [
|
|
12
|
+
.define("DEBUG", .when(configuration: .debug)),
|
|
13
|
+
.define("NDEBUG", .when(configuration: .release)),
|
|
14
|
+
.disableWarning("nullability-extension"),
|
|
15
|
+
]
|
|
16
|
+
let swiftSources = ["Sources/ReactNitroNetworking", "\(generated)/apple/swift"]
|
|
17
|
+
let bridgeSources = [
|
|
18
|
+
"c++", "ReactNitroNetworking-Swift-Cxx-Bridge.cpp", "ReactNitroNetworkingAutolinking.cpp",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
// Bare-host integration. Nitrogen generates the bindings; this manifest owns the targets.
|
|
22
|
+
let package = Package(
|
|
23
|
+
name: name,
|
|
24
|
+
platforms: [.iOS(.v16), .macOS(.v13)],
|
|
25
|
+
products: [
|
|
26
|
+
.library(name: name, targets: ["\(name)Cxx", name, "\(name)SwiftBridge"])
|
|
27
|
+
],
|
|
28
|
+
dependencies: [
|
|
29
|
+
.package(path: "../nitro-core")
|
|
30
|
+
],
|
|
31
|
+
targets: [
|
|
32
|
+
.target(
|
|
33
|
+
name: "\(name)Cxx",
|
|
34
|
+
dependencies: nitroDependencies,
|
|
35
|
+
path: "\(generated)/shared/c++",
|
|
36
|
+
exclude: ["include"],
|
|
37
|
+
publicHeadersPath: "include",
|
|
38
|
+
cxxSettings: cxxSettings
|
|
39
|
+
),
|
|
40
|
+
.target(
|
|
41
|
+
name: "\(name)AppleCxx",
|
|
42
|
+
dependencies: [.target(name: "\(name)Cxx")] + nitroDependencies,
|
|
43
|
+
path: "\(generated)/apple/\(name)AppleCxx",
|
|
44
|
+
cxxSettings: cxxSettings
|
|
45
|
+
),
|
|
46
|
+
.target(
|
|
47
|
+
name: name,
|
|
48
|
+
dependencies: [.target(name: "\(name)AppleCxx")] + nitroDependencies,
|
|
49
|
+
path: ".",
|
|
50
|
+
exclude: excludes(in: ".", keeping: swiftSources),
|
|
51
|
+
sources: swiftSources,
|
|
52
|
+
cxxSettings: cxxSettings,
|
|
53
|
+
swiftSettings: [
|
|
54
|
+
// UIKit implementations run synchronously on the host's main thread.
|
|
55
|
+
.swiftLanguageMode(.v5),
|
|
56
|
+
.unsafeFlags(["-strict-concurrency=minimal"]),
|
|
57
|
+
.enableUpcomingFeature("InternalImportsByDefault"),
|
|
58
|
+
.enableUpcomingFeature("MemberImportVisibility"),
|
|
59
|
+
.interoperabilityMode(.Cxx),
|
|
60
|
+
.unsafeFlags(["-Xfrontend", "-clang-header-expose-decls=has-expose-attr-or-stdlib"]),
|
|
61
|
+
.unsafeFlags([
|
|
62
|
+
"-Xfrontend", "-clang-header-expose-module",
|
|
63
|
+
"-Xfrontend", "NitroModules=NitroModules-Swift.h",
|
|
64
|
+
]),
|
|
65
|
+
]
|
|
66
|
+
),
|
|
67
|
+
.target(
|
|
68
|
+
name: "\(name)SwiftBridge",
|
|
69
|
+
dependencies: [.target(name: name), .target(name: "\(name)AppleCxx")] + nitroDependencies,
|
|
70
|
+
path: "\(generated)/apple",
|
|
71
|
+
exclude: excludes(in: "\(generated)/apple", keeping: bridgeSources) + ["c++/include"],
|
|
72
|
+
sources: bridgeSources,
|
|
73
|
+
publicHeadersPath: "c++/include",
|
|
74
|
+
cxxSettings: cxxSettings + [
|
|
75
|
+
.unsafeFlags(["-I${GENERATED_MODULEMAP_DIR}", "-fno-modules"])
|
|
76
|
+
]
|
|
77
|
+
),
|
|
78
|
+
],
|
|
79
|
+
cxxLanguageStandard: .cxx20
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
func excludes(in path: String, keeping sources: [String]) -> [String] {
|
|
83
|
+
var parents: Set<String> = [""]
|
|
84
|
+
for source in sources {
|
|
85
|
+
var components = source.split(separator: "/").dropLast()
|
|
86
|
+
while !components.isEmpty {
|
|
87
|
+
parents.insert(components.joined(separator: "/"))
|
|
88
|
+
components = components.dropLast()
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
let root = URL(fileURLWithPath: Context.packageDirectory).appendingPathComponent(path)
|
|
92
|
+
return parents.sorted().flatMap { parent in
|
|
93
|
+
let entries =
|
|
94
|
+
(try? FileManager.default.contentsOfDirectory(
|
|
95
|
+
atPath: root.appendingPathComponent(parent).path)) ?? []
|
|
96
|
+
return entries.sorted().compactMap { entry in
|
|
97
|
+
let entryPath = parent.isEmpty ? entry : "\(parent)/\(entry)"
|
|
98
|
+
return entry.hasPrefix(".") || sources.contains(entryPath) || parents.contains(entryPath)
|
|
99
|
+
? nil : entryPath
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
package/README.md
CHANGED
|
@@ -1,3 +1,59 @@
|
|
|
1
|
-
#
|
|
1
|
+
# React Nitro Networking
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
## Fetch
|
|
4
|
+
|
|
5
|
+
Call `Networking.fetch()` directly on the imported class:
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { Networking, Response } from 'react-nitro-networking'
|
|
9
|
+
|
|
10
|
+
const response = await Networking.fetch('https://example.com')
|
|
11
|
+
console.log(response instanceof Response) // true
|
|
12
|
+
console.log(await response.text())
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## WebSockets
|
|
16
|
+
|
|
17
|
+
`new WebSocket(url)` starts connecting immediately. Each socket supports text and
|
|
18
|
+
`ArrayBuffer` messages. Listeners are additive and return a subscription with an idempotent `remove()`.
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
import { WebSocket } from 'react-nitro-networking'
|
|
22
|
+
|
|
23
|
+
const socket = new WebSocket('wss://example.com/socket')
|
|
24
|
+
const messages = socket.addOnMessageReceivedListener((data) => {
|
|
25
|
+
console.log(data)
|
|
26
|
+
})
|
|
27
|
+
socket.addOnOpenedListener(() => console.log('Connected'))
|
|
28
|
+
socket.addOnErrorListener((error) => console.error(error))
|
|
29
|
+
socket.addOnClosedListener(() => console.log('Closed'))
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
// send() waits for the handshake if the connection is still opening.
|
|
33
|
+
await socket.send('Hello')
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error('Could not send:', error)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// When the connection is no longer needed:
|
|
39
|
+
messages.remove()
|
|
40
|
+
socket.close()
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Binary messages use the same methods. Await sends to preserve their order:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
await socket.send(new Uint8Array([0, 1, 255]).buffer)
|
|
47
|
+
await socket.send(new ArrayBuffer(0))
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Invalid URLs throw from the constructor. Handshake and transport failures notify error listeners,
|
|
51
|
+
then closed listeners. Late opened listeners are notified if the socket is still open; late error
|
|
52
|
+
and closed listeners receive the terminal outcome. `close()` is idempotent, releases listeners,
|
|
53
|
+
and reports local shutdown without waiting for a peer acknowledgement. Reconnecting requires a new socket.
|
|
54
|
+
|
|
55
|
+
Receiving starts immediately. Until the first message listener is registered, up to 64 messages
|
|
56
|
+
or 1 MiB are buffered. Exceeding either limit closes the connection with an error. After the first
|
|
57
|
+
subscription, messages without listeners are discarded. Closing also discards buffered messages.
|
|
58
|
+
|
|
59
|
+
The current implementation supports Apple platforms through `URLSessionWebSocketTask`.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
internal import NitroModules
|
|
2
|
+
internal import Foundation
|
|
3
|
+
|
|
4
|
+
final class HybridNetworking: HybridNetworkingSpec {
|
|
5
|
+
static let session = URLSession(configuration: .default)
|
|
6
|
+
|
|
7
|
+
static func fetch(url: String) throws -> any Promise<any HybridResponseSpec> {
|
|
8
|
+
guard let url = URL(string: url) else {
|
|
9
|
+
throw RuntimeError("Invalid URL: \"\(url)\"")
|
|
10
|
+
}
|
|
11
|
+
return .async { [session] in
|
|
12
|
+
let delegate = NetworkingTaskDelegate()
|
|
13
|
+
let (bytes, response) = try await session.bytes(from: url, delegate: delegate)
|
|
14
|
+
return try HybridResponse(bytes: bytes, response: response, redirected: delegate.redirected)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
//
|
|
2
|
+
// HybridResponse.swift
|
|
3
|
+
// ReactNitroNetworking
|
|
4
|
+
//
|
|
5
|
+
// Created by Marc Rousavy on 18.09.26.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
internal import NitroModules
|
|
9
|
+
internal import Foundation
|
|
10
|
+
|
|
11
|
+
final class HybridResponse: HybridResponseSpec {
|
|
12
|
+
private let bytes: URLSession.AsyncBytes
|
|
13
|
+
private let response: URLResponse
|
|
14
|
+
|
|
15
|
+
var headers: [String: String] {
|
|
16
|
+
guard let response = response as? HTTPURLResponse else { return [:] }
|
|
17
|
+
var headers: [String: String] = [:]
|
|
18
|
+
for (name, value) in response.allHeaderFields {
|
|
19
|
+
guard let name = name as? String else { continue }
|
|
20
|
+
headers[name.lowercased()] = String(describing: value)
|
|
21
|
+
}
|
|
22
|
+
return headers
|
|
23
|
+
}
|
|
24
|
+
var status: Double { Double((response as? HTTPURLResponse)?.statusCode ?? 0) }
|
|
25
|
+
var ok: Bool { (200..<300).contains(status) }
|
|
26
|
+
var statusText: String { "" }
|
|
27
|
+
var url: String { response.url?.absoluteString ?? "" }
|
|
28
|
+
let redirected: Bool
|
|
29
|
+
var type: ResponseType { .basic }
|
|
30
|
+
private(set) var bodyUsed = false
|
|
31
|
+
|
|
32
|
+
private lazy var materializedData = Task<Data, Error> { [bytes, response] in
|
|
33
|
+
var data = Data()
|
|
34
|
+
|
|
35
|
+
let expected = response.expectedContentLength
|
|
36
|
+
if expected > 0 {
|
|
37
|
+
// Cap initial reservation size at 8MB to not explode memory
|
|
38
|
+
let reservation = Int(min(expected, 8 * 1024 * 1024))
|
|
39
|
+
data.reserveCapacity(reservation)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for try await byte in bytes {
|
|
43
|
+
data.append(byte)
|
|
44
|
+
}
|
|
45
|
+
return data
|
|
46
|
+
}
|
|
47
|
+
private var data: Data {
|
|
48
|
+
get async throws {
|
|
49
|
+
bodyUsed = true
|
|
50
|
+
let data = try await materializedData.value
|
|
51
|
+
return data
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
init(bytes: URLSession.AsyncBytes, response: URLResponse, redirected: Bool) throws {
|
|
56
|
+
self.bytes = bytes
|
|
57
|
+
self.response = response
|
|
58
|
+
self.redirected = redirected
|
|
59
|
+
try super.init()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
func arrayBuffer() -> any Promise<ArrayBuffer> {
|
|
63
|
+
return .async {
|
|
64
|
+
// TODO: Maybe we can figure out a way to avoid copying `Data` here and instead map the `ArrayBuffer` zero-copy?
|
|
65
|
+
let data = try await self.data
|
|
66
|
+
return try ArrayBuffer.copy(data: data)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
func json() -> any Promise<AnyJSON> {
|
|
71
|
+
return .async {
|
|
72
|
+
let data = try await self.data
|
|
73
|
+
// Like text(), skip a leading UTF-8 BOM.
|
|
74
|
+
let content = data.starts(with: [0xEF, 0xBB, 0xBF]) ? data.dropFirst(3) : data
|
|
75
|
+
return AnyJSON(data: content)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
func text() -> any Promise<String> {
|
|
80
|
+
return .async {
|
|
81
|
+
let data = try await self.data
|
|
82
|
+
// Fetch decodes UTF-8 with replacement and strips a leading UTF-8 BOM.
|
|
83
|
+
let content = data.starts(with: [0xEF, 0xBB, 0xBF]) ? data.dropFirst(3) : data
|
|
84
|
+
return String(decoding: content, as: UTF8.self)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
internal import Darwin
|
|
2
|
+
internal import Foundation
|
|
3
|
+
internal import NitroModules
|
|
4
|
+
internal import NitroModulesCxx
|
|
5
|
+
|
|
6
|
+
final class HybridWebSocket: HybridWebSocketSpec, @unchecked Sendable {
|
|
7
|
+
private let task: URLSessionWebSocketTask
|
|
8
|
+
private let delegate = WebSocketTaskDelegate()
|
|
9
|
+
private let queue = DispatchQueue(label: "com.margelo.nitro.websocket")
|
|
10
|
+
private let openedListeners: WebSocketListeners<Void>
|
|
11
|
+
private let messageListeners: WebSocketListeners<Variant_ArrayBuffer_String>
|
|
12
|
+
private let closedListeners: WebSocketListeners<Void>
|
|
13
|
+
private let errorListeners: WebSocketListeners<Error>
|
|
14
|
+
|
|
15
|
+
// Accessed only on queue.
|
|
16
|
+
private var isClosed = false
|
|
17
|
+
private var receiveTask: Task<Void, Never>?
|
|
18
|
+
private var hasMessageListener = false
|
|
19
|
+
private var pendingMessages: [URLSessionWebSocketTask.Message] = []
|
|
20
|
+
private var pendingByteCount = 0
|
|
21
|
+
|
|
22
|
+
convenience init(url: String) throws {
|
|
23
|
+
try self.init(session: HybridNetworking.session, url: try WebSocketURL.parse(url))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
init(session: URLSession, url: URL) throws {
|
|
27
|
+
task = session.webSocketTask(with: url)
|
|
28
|
+
openedListeners = WebSocketListeners(queue: queue)
|
|
29
|
+
messageListeners = WebSocketListeners(queue: queue)
|
|
30
|
+
closedListeners = WebSocketListeners(queue: queue)
|
|
31
|
+
errorListeners = WebSocketListeners(queue: queue)
|
|
32
|
+
try super.init()
|
|
33
|
+
delegate.socket = self
|
|
34
|
+
task.delegate = delegate
|
|
35
|
+
task.resume()
|
|
36
|
+
queue.async { self.startReceiving() }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
deinit {
|
|
40
|
+
receiveTask?.cancel()
|
|
41
|
+
task.cancel(with: .goingAway, reason: nil)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func send(data: Variant_ArrayBuffer_String) throws -> any Promise<Void> {
|
|
45
|
+
let message: URLSessionWebSocketTask.Message
|
|
46
|
+
switch data {
|
|
47
|
+
case .first(let buffer):
|
|
48
|
+
message = .data(buffer.toData(copyIfNeeded: true))
|
|
49
|
+
case .second(let text):
|
|
50
|
+
message = .string(text)
|
|
51
|
+
}
|
|
52
|
+
return .async { [task] in
|
|
53
|
+
try await task.send(message)
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
func close() {
|
|
58
|
+
queue.async { self.finish() }
|
|
59
|
+
// Cancel before returning so a subsequent send cannot race local shutdown.
|
|
60
|
+
// The queued finish runs before any delegate notification caused by cancellation.
|
|
61
|
+
task.cancel(with: .normalClosure, reason: nil)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func dispose() {
|
|
65
|
+
close()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
func addOnOpenedListener(listener: any Callback<(), Void>) -> ListenerSubscription {
|
|
69
|
+
return openedListeners.add(listener)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
func addOnMessageReceivedListener(
|
|
73
|
+
listener: any Callback<(Variant_ArrayBuffer_String), Void>
|
|
74
|
+
) -> ListenerSubscription {
|
|
75
|
+
return messageListeners.add(
|
|
76
|
+
listener,
|
|
77
|
+
onAdded: { [weak self] in
|
|
78
|
+
guard let self, !self.hasMessageListener else { return }
|
|
79
|
+
self.hasMessageListener = true
|
|
80
|
+
for message in self.pendingMessages {
|
|
81
|
+
self.emitMessage(message)
|
|
82
|
+
}
|
|
83
|
+
self.pendingMessages.removeAll()
|
|
84
|
+
self.pendingByteCount = 0
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func addOnClosedListener(listener: any Callback<(), Void>) -> ListenerSubscription {
|
|
89
|
+
return closedListeners.add(listener)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func addOnErrorListener(listener: any Callback<(Error), Void>) -> ListenerSubscription {
|
|
93
|
+
return errorListeners.add(listener)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
func didOpen() {
|
|
97
|
+
queue.async {
|
|
98
|
+
guard !self.isClosed else { return }
|
|
99
|
+
self.openedListeners.finish(replaying: ())
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func didComplete(error: Error?) {
|
|
104
|
+
queue.async {
|
|
105
|
+
// Once receiving, let the receive loop deliver preceding messages before closing.
|
|
106
|
+
if self.receiveTask == nil {
|
|
107
|
+
self.finish(error: error)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private func startReceiving() {
|
|
113
|
+
guard !isClosed, receiveTask == nil else { return }
|
|
114
|
+
receiveTask = Task { [weak self, task] in
|
|
115
|
+
do {
|
|
116
|
+
while !Task.isCancelled {
|
|
117
|
+
let message = try await task.receive()
|
|
118
|
+
self?.didReceive(message)
|
|
119
|
+
}
|
|
120
|
+
} catch {
|
|
121
|
+
// A peer close can also end receive() with an error. Its close code distinguishes
|
|
122
|
+
// a received close frame from a transport failure.
|
|
123
|
+
let failure = task.closeCode == .invalid || task.closeCode == .abnormalClosure ? error : nil
|
|
124
|
+
self?.queue.async { [weak self] in self?.finish(error: failure) }
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private func didReceive(_ message: URLSessionWebSocketTask.Message) {
|
|
130
|
+
queue.async {
|
|
131
|
+
guard !self.isClosed else { return }
|
|
132
|
+
if !self.hasMessageListener {
|
|
133
|
+
let byteCount: Int
|
|
134
|
+
switch message {
|
|
135
|
+
case .string(let text): byteCount = text.utf8.count
|
|
136
|
+
case .data(let data): byteCount = data.count
|
|
137
|
+
@unknown default:
|
|
138
|
+
self.finish(error: RuntimeError("Unsupported WebSocket message type"))
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
guard self.pendingMessages.count < 64, self.pendingByteCount + byteCount <= 1_048_576 else {
|
|
142
|
+
self.finish(
|
|
143
|
+
error: RuntimeError(
|
|
144
|
+
"WebSocket receive buffer is full. Register a message listener before receiving more messages."
|
|
145
|
+
))
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
self.pendingMessages.append(message)
|
|
149
|
+
self.pendingByteCount += byteCount
|
|
150
|
+
} else {
|
|
151
|
+
self.emitMessage(message)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private func emitMessage(_ message: URLSessionWebSocketTask.Message) {
|
|
157
|
+
guard !isClosed else { return }
|
|
158
|
+
do {
|
|
159
|
+
let value: Variant_ArrayBuffer_String
|
|
160
|
+
switch message {
|
|
161
|
+
case .string(let text):
|
|
162
|
+
value = .second(text)
|
|
163
|
+
case .data(let data):
|
|
164
|
+
let buffer = data.isEmpty ? ArrayBuffer.allocate(0) : try ArrayBuffer.copy(data: data)
|
|
165
|
+
value = .first(buffer)
|
|
166
|
+
@unknown default:
|
|
167
|
+
throw RuntimeError("Unsupported WebSocket message type")
|
|
168
|
+
}
|
|
169
|
+
messageListeners.emit(value)
|
|
170
|
+
} catch {
|
|
171
|
+
finish(error: error)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private func finish(error: Error? = nil) {
|
|
176
|
+
guard !isClosed else { return }
|
|
177
|
+
isClosed = true
|
|
178
|
+
receiveTask?.cancel()
|
|
179
|
+
receiveTask = nil
|
|
180
|
+
pendingMessages.removeAll()
|
|
181
|
+
pendingByteCount = 0
|
|
182
|
+
task.cancel(with: .normalClosure, reason: nil)
|
|
183
|
+
openedListeners.finish()
|
|
184
|
+
messageListeners.finish()
|
|
185
|
+
errorListeners.finish(replaying: error)
|
|
186
|
+
closedListeners.finish(replaying: ())
|
|
187
|
+
}
|
|
188
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
internal import Foundation
|
|
2
|
+
|
|
3
|
+
final class NetworkingTaskDelegate: NSObject, URLSessionTaskDelegate {
|
|
4
|
+
private(set) var redirected = false
|
|
5
|
+
|
|
6
|
+
func urlSession(
|
|
7
|
+
_ session: URLSession,
|
|
8
|
+
task: URLSessionTask,
|
|
9
|
+
willPerformHTTPRedirection response: HTTPURLResponse,
|
|
10
|
+
newRequest request: URLRequest,
|
|
11
|
+
completionHandler: @escaping (URLRequest?) -> Void
|
|
12
|
+
) {
|
|
13
|
+
redirected = true
|
|
14
|
+
completionHandler(request)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
internal import Foundation
|
|
2
|
+
internal import NitroModules
|
|
3
|
+
|
|
4
|
+
/// All registry state and emissions belong to the socket's serial queue.
|
|
5
|
+
final class WebSocketListeners<Value> {
|
|
6
|
+
private let queue: DispatchQueue
|
|
7
|
+
private var listeners: [UUID: any Callback<(Value), Void>] = [:]
|
|
8
|
+
private var replay: Value?
|
|
9
|
+
private var isFinished = false
|
|
10
|
+
|
|
11
|
+
init(queue: DispatchQueue) {
|
|
12
|
+
self.queue = queue
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
func add(
|
|
16
|
+
_ listener: any Callback<(Value), Void>,
|
|
17
|
+
onAdded: @escaping () -> Void = {}
|
|
18
|
+
) -> ListenerSubscription {
|
|
19
|
+
let id = UUID()
|
|
20
|
+
queue.async {
|
|
21
|
+
if let value = self.replay {
|
|
22
|
+
listener.callAndForget(value)
|
|
23
|
+
}
|
|
24
|
+
if !self.isFinished {
|
|
25
|
+
self.listeners[id] = listener
|
|
26
|
+
onAdded()
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return ListenerSubscription(remove: .native { [weak self] in
|
|
30
|
+
guard let self else { return }
|
|
31
|
+
self.queue.async { self.listeners.removeValue(forKey: id) }
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func emit(_ value: Value) {
|
|
36
|
+
for listener in listeners.values {
|
|
37
|
+
listener.callAndForget(value)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
func finish(replaying value: Value? = nil) {
|
|
42
|
+
if let value {
|
|
43
|
+
emit(value)
|
|
44
|
+
}
|
|
45
|
+
replay = value
|
|
46
|
+
isFinished = true
|
|
47
|
+
listeners.removeAll()
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
internal import Foundation
|
|
2
|
+
|
|
3
|
+
final class WebSocketTaskDelegate: NSObject, URLSessionWebSocketDelegate {
|
|
4
|
+
weak var socket: HybridWebSocket?
|
|
5
|
+
|
|
6
|
+
func urlSession(
|
|
7
|
+
_ session: URLSession,
|
|
8
|
+
webSocketTask: URLSessionWebSocketTask,
|
|
9
|
+
didOpenWithProtocol protocol: String?
|
|
10
|
+
) {
|
|
11
|
+
socket?.didOpen()
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
func urlSession(
|
|
15
|
+
_ session: URLSession,
|
|
16
|
+
webSocketTask: URLSessionWebSocketTask,
|
|
17
|
+
didCloseWith closeCode: URLSessionWebSocketTask.CloseCode,
|
|
18
|
+
reason: Data?
|
|
19
|
+
) {
|
|
20
|
+
socket?.didComplete(error: nil)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
24
|
+
socket?.didComplete(error: error)
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
internal import Foundation
|
|
2
|
+
internal import NitroModules
|
|
3
|
+
|
|
4
|
+
enum WebSocketURL {
|
|
5
|
+
static func parse(_ value: String) throws -> URL {
|
|
6
|
+
guard let url = URL(string: value),
|
|
7
|
+
let scheme = url.scheme?.lowercased(),
|
|
8
|
+
scheme == "ws" || scheme == "wss",
|
|
9
|
+
let host = url.host, !host.isEmpty,
|
|
10
|
+
url.fragment == nil
|
|
11
|
+
else {
|
|
12
|
+
throw RuntimeError(
|
|
13
|
+
"Invalid WebSocket URL: \"\(value)\". Expected an absolute ws:// or wss:// URL without a fragment."
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
return url
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
** linguist-generated=true
|