-
Notifications
You must be signed in to change notification settings - Fork 3.5k
perf(mobile): compressed websocket frames for iOS #4794
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
t3dotgg
wants to merge
5
commits into
main
Choose a base branch
from
t3code/improve-mobile-data-loading
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
c010fc3
perf(mobile): negotiate permessage-deflate on iOS websockets
t3dotgg 843adad
fix(mobile): deliver iOS websocket close events and load expo lazily
t3dotgg 926ff9b
fix(mobile): bypass injected URLProtocol classes on iOS websockets
t3dotgg 95f6b12
fix(mobile): invalidate the iOS websocket URLSession on teardown
t3dotgg a7adbc1
fix(mobile): ignore a late iOS websocket open after close
t3dotgg File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| { | ||
| "platforms": ["apple"], | ||
| "apple": { | ||
| "modules": ["T3WebSocketModule"] | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| Pod::Spec.new do |s| | ||
| s.name = 'T3WebSocket' | ||
| s.version = '1.0.0' | ||
| s.summary = 'URLSession-backed WebSocket for T3 Code mobile.' | ||
| s.description = 'WebSocket client backed by URLSessionWebSocketTask so iOS negotiates permessage-deflate, which SocketRocket (React Native default) cannot.' | ||
| s.author = 'T3 Tools' | ||
| s.homepage = 'https://t3tools.com' | ||
| s.platforms = { | ||
| :ios => '18.0', | ||
| } | ||
| s.source = { :path => '.' } | ||
| s.static_framework = true | ||
|
|
||
| s.dependency 'ExpoModulesCore' | ||
| s.pod_target_xcconfig = { | ||
| 'DEFINES_MODULE' => 'YES', | ||
| } | ||
| s.source_files = '**/*.{h,m,mm,swift,hpp,cpp}' | ||
| end |
210 changes: 210 additions & 0 deletions
210
apps/mobile/modules/t3-websocket/ios/T3WebSocketModule.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,210 @@ | ||
| import ExpoModulesCore | ||
| import Foundation | ||
|
|
||
| // WebSocket client backed by URLSessionWebSocketTask. React Native's built-in | ||
| // iOS WebSocket (SocketRocket) never offers permessage-deflate, so frames from | ||
| // the T3 server arrive uncompressed. URLSessionWebSocketTask offers the | ||
| // extension in its handshake by default, letting the server (which negotiates | ||
| // permessage-deflate since #4705) compress the JSON RPC stream. | ||
| // | ||
| // Only the surface Effect's `Socket.fromWebSocket` needs is exposed: connect, | ||
| // send text/binary, close, and open/message/close/error events routed by a | ||
| // JS-assigned connection id. | ||
| public final class T3WebSocketModule: Module { | ||
| private let connections = T3WebSocketConnections() | ||
|
|
||
| public func definition() -> ModuleDefinition { | ||
| Name("T3WebSocket") | ||
|
|
||
| Events("onOpen", "onMessage", "onClose", "onError") | ||
|
|
||
| Function("connect") { (id: Int, url: String, protocols: [String]) throws in | ||
| guard let parsedUrl = URL(string: url) else { | ||
| throw Exception(name: "InvalidUrl", description: "Invalid WebSocket URL: \(url)") | ||
| } | ||
| self.connections.open(id: id, url: parsedUrl, protocols: protocols) { [weak self] event, payload in | ||
| self?.sendEvent(event, payload) | ||
| } | ||
| } | ||
|
|
||
| Function("sendText") { (id: Int, text: String) in | ||
| self.connections.send(id: id, message: .string(text)) | ||
| } | ||
|
|
||
| Function("sendBinary") { (id: Int, base64: String) in | ||
| guard let data = Data(base64Encoded: base64) else { return } | ||
| self.connections.send(id: id, message: .data(data)) | ||
| } | ||
|
|
||
| Function("close") { (id: Int, code: Int, reason: String) in | ||
| self.connections.close(id: id, code: code, reason: reason) | ||
| } | ||
|
|
||
| OnDestroy { | ||
| self.connections.cancelAll() | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private typealias T3WebSocketEmitter = (_ event: String, _ payload: [String: Any]) -> Void | ||
|
|
||
| private final class T3WebSocketConnections: NSObject, URLSessionWebSocketDelegate { | ||
| // Snapshot frames on the socket-fallback path can reach tens of MB; the | ||
| // URLSession default receive cap is 1 MB, which would abort the connection. | ||
| private static let maximumMessageSize = 128 * 1024 * 1024 | ||
|
|
||
| private let queue = DispatchQueue(label: "com.t3tools.t3code.websocket") | ||
| private var tasksById: [Int: URLSessionWebSocketTask] = [:] | ||
| private var idsByTaskIdentifier: [Int: Int] = [:] | ||
| private var emittersById: [Int: T3WebSocketEmitter] = [:] | ||
|
|
||
| // Held so teardown can invalidate it: URLSession retains its delegate (self) | ||
| // until invalidated, so skipping that leaks this object and the session on | ||
| // every module destroy/recreate cycle. Only touched from `queue`. | ||
| private var activeSession: URLSession? | ||
|
|
||
| private var session: URLSession { | ||
| if let activeSession { return activeSession } | ||
| let configuration = URLSessionConfiguration.default | ||
| // Dev clients (and network debuggers) register custom URLProtocol classes | ||
| // that intercept URLSession traffic. They do not understand the WebSocket | ||
| // upgrade and drop the connection right after the 101 (NSURLErrorDomain | ||
| // -1005). WebSocket traffic has no reason to go through them. | ||
| configuration.protocolClasses = [] | ||
| let session = URLSession(configuration: configuration, delegate: self, delegateQueue: nil) | ||
| activeSession = session | ||
| return session | ||
| } | ||
|
|
||
| func open(id: Int, url: URL, protocols: [String], emitter: @escaping T3WebSocketEmitter) { | ||
| queue.async { | ||
| let task = protocols.isEmpty | ||
| ? self.session.webSocketTask(with: url) | ||
| : self.session.webSocketTask(with: url, protocols: protocols) | ||
| task.maximumMessageSize = Self.maximumMessageSize | ||
| self.tasksById[id] = task | ||
| self.idsByTaskIdentifier[task.taskIdentifier] = id | ||
| self.emittersById[id] = emitter | ||
| task.resume() | ||
| self.receiveLoop(id: id, task: task) | ||
| } | ||
| } | ||
|
|
||
| func send(id: Int, message: URLSessionWebSocketTask.Message) { | ||
| queue.async { | ||
| guard let task = self.tasksById[id] else { return } | ||
| task.send(message) { [weak self] error in | ||
| guard let self, let error else { return } | ||
| self.queue.async { | ||
| self.emitLocked(id: id, event: "onError", payload: ["message": error.localizedDescription]) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func close(id: Int, code: Int, reason: String) { | ||
| queue.async { | ||
| guard let task = self.tasksById[id] else { return } | ||
| let closeCode = URLSessionWebSocketTask.CloseCode(rawValue: code) ?? .normalClosure | ||
| task.cancel(with: closeCode, reason: reason.data(using: .utf8)) | ||
| } | ||
| } | ||
|
|
||
| func cancelAll() { | ||
| queue.async { | ||
| for task in self.tasksById.values { | ||
| task.cancel(with: .goingAway, reason: nil) | ||
| } | ||
| self.tasksById.removeAll() | ||
| self.idsByTaskIdentifier.removeAll() | ||
| self.emittersById.removeAll() | ||
| // Releases the session's reference to this delegate. A later `open` call | ||
| // builds a fresh session rather than using an invalidated one. | ||
| self.activeSession?.invalidateAndCancel() | ||
| self.activeSession = nil | ||
| } | ||
| } | ||
|
|
||
| private func receiveLoop(id: Int, task: URLSessionWebSocketTask) { | ||
| task.receive { [weak self] result in | ||
| guard let self else { return } | ||
| self.queue.async { | ||
| switch result { | ||
| case .success(let message): | ||
| switch message { | ||
| case .string(let text): | ||
| self.emitLocked(id: id, event: "onMessage", payload: ["data": text, "binary": false]) | ||
| case .data(let data): | ||
| self.emitLocked(id: id, event: "onMessage", payload: ["data": data.base64EncodedString(), "binary": true]) | ||
| @unknown default: | ||
| break | ||
| } | ||
| guard self.tasksById[id] != nil else { return } | ||
| self.receiveLoop(id: id, task: task) | ||
| case .failure: | ||
| // The delegate close/error callbacks own failure reporting; the loop | ||
| // just stops so it does not spin on a dead task. | ||
| break | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Must be called on `queue`. | ||
| private func emitLocked(id: Int, event: String, payload: [String: Any]) { | ||
| guard let emitter = emittersById[id] else { return } | ||
| var enriched = payload | ||
| enriched["id"] = id | ||
| emitter(event, enriched) | ||
| } | ||
|
cursor[bot] marked this conversation as resolved.
|
||
|
|
||
| private func remove(id: Int, taskIdentifier: Int) { | ||
| tasksById.removeValue(forKey: id) | ||
| idsByTaskIdentifier.removeValue(forKey: taskIdentifier) | ||
| emittersById.removeValue(forKey: id) | ||
| } | ||
|
|
||
| // MARK: - URLSessionWebSocketDelegate | ||
|
|
||
| func urlSession( | ||
| _ session: URLSession, | ||
| webSocketTask: URLSessionWebSocketTask, | ||
| didOpenWithProtocol protocol: String? | ||
| ) { | ||
| queue.async { | ||
| guard let id = self.idsByTaskIdentifier[webSocketTask.taskIdentifier] else { return } | ||
| self.emitLocked(id: id, event: "onOpen", payload: ["protocol": `protocol` ?? ""]) | ||
| } | ||
| } | ||
|
|
||
| func urlSession( | ||
| _ session: URLSession, | ||
| webSocketTask: URLSessionWebSocketTask, | ||
| didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, | ||
| reason: Data? | ||
| ) { | ||
| queue.async { | ||
| guard let id = self.idsByTaskIdentifier[webSocketTask.taskIdentifier] else { return } | ||
| let reasonText = reason.flatMap { String(data: $0, encoding: .utf8) } ?? "" | ||
| self.emitLocked(id: id, event: "onClose", payload: ["code": closeCode.rawValue, "reason": reasonText]) | ||
| self.remove(id: id, taskIdentifier: webSocketTask.taskIdentifier) | ||
| } | ||
| } | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
|
|
||
| func urlSession( | ||
| _ session: URLSession, | ||
| task: URLSessionTask, | ||
| didCompleteWithError error: Error? | ||
| ) { | ||
| queue.async { | ||
| guard let id = self.idsByTaskIdentifier[task.taskIdentifier] else { return } | ||
| if let error { | ||
| self.emitLocked(id: id, event: "onError", payload: ["message": error.localizedDescription]) | ||
| // Abnormal termination: no close frame arrived, mirror the browser's | ||
| // 1006 so Effect's close-code classification treats it as an error. | ||
| self.emitLocked(id: id, event: "onClose", payload: ["code": 1006, "reason": error.localizedDescription]) | ||
| } | ||
| self.remove(id: id, taskIdentifier: task.taskIdentifier) | ||
| } | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.