228 lines
6.7 KiB
Swift
228 lines
6.7 KiB
Swift
// Copyright 2015-present 650 Industries. All rights reserved.
|
|
|
|
import ExpoModulesCore
|
|
|
|
/**
|
|
A SharedObject for response.
|
|
*/
|
|
internal final class NativeResponse: SharedObject, ExpoURLSessionTaskDelegate, @unchecked Sendable {
|
|
internal let sink: ResponseSink
|
|
|
|
private let dispatchQueue: DispatchQueue
|
|
|
|
private(set) var state: ResponseState = .intialized {
|
|
didSet {
|
|
dispatchQueue.async { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.stateChangeOnceListeners.removeAll { $0(self.state) == true }
|
|
}
|
|
}
|
|
}
|
|
private typealias StateChangeListener = (ResponseState) -> Bool
|
|
private var stateChangeOnceListeners: [StateChangeListener] = []
|
|
|
|
private(set) var responseInit: NativeResponseInit?
|
|
private(set) var redirected = false
|
|
private(set) var error: Error?
|
|
var redirectMode: NativeRequestRedirect = .follow
|
|
|
|
var bodyUsed: Bool {
|
|
return self.sink.bodyUsed
|
|
}
|
|
|
|
init(dispatchQueue: DispatchQueue) {
|
|
self.sink = ResponseSink()
|
|
self.dispatchQueue = dispatchQueue
|
|
}
|
|
|
|
func startStreaming() -> Data? {
|
|
if isInvalidState(.responseReceived, .bodyCompleted) {
|
|
return nil
|
|
}
|
|
if state == .responseReceived {
|
|
state = .bodyStreamingStarted
|
|
let queuedData = self.sink.finalize()
|
|
emit(event: "didReceiveResponseData", arguments: queuedData)
|
|
} else if state == .bodyCompleted {
|
|
let queuedData = self.sink.finalize()
|
|
return queuedData
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func cancelStreaming() {
|
|
if isInvalidState(.bodyStreamingStarted) {
|
|
return
|
|
}
|
|
state = .bodyStreamingCanceled
|
|
}
|
|
|
|
func emitRequestCanceled() {
|
|
let error = FetchRequestCanceledException()
|
|
self.error = error
|
|
if state == .bodyStreamingStarted {
|
|
emit(event: "didFailWithError", arguments: error.localizedDescription)
|
|
}
|
|
state = .errorReceived
|
|
emit(event: "readyForJSFinalization")
|
|
}
|
|
|
|
/**
|
|
Waits for given states and when it meets the requirement, executes the callback.
|
|
*/
|
|
func waitFor(states: [ResponseState], callback: @escaping @Sendable (ResponseState) -> Void) {
|
|
if states.contains(state) {
|
|
callback(state)
|
|
return
|
|
}
|
|
dispatchQueue.async { [weak self] () in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.stateChangeOnceListeners.append { newState in
|
|
if states.contains(newState) {
|
|
callback(newState)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
Check valid state machine
|
|
*/
|
|
private func isInvalidState(_ validStates: ResponseState...) -> Bool {
|
|
if validStates.contains(state) {
|
|
return false
|
|
}
|
|
|
|
let validStatesString = validStates.map { "\($0.rawValue)" }.joined(separator: ",")
|
|
log.error("Invalid state - currentState[\(state.rawValue)] validStates[\(validStatesString)]")
|
|
return true
|
|
}
|
|
|
|
/**
|
|
Factory of NativeResponseInit
|
|
*/
|
|
private static func createResponseInit(response: URLResponse) -> NativeResponseInit? {
|
|
guard let httpResponse = response as? HTTPURLResponse else {
|
|
return NativeResponseInit(
|
|
headers: [], status: 200, statusText: "", url: response.url?.absoluteString ?? ""
|
|
)
|
|
}
|
|
|
|
let status = httpResponse.statusCode
|
|
let statusText = HTTPURLResponse.localizedString(forStatusCode: status)
|
|
let headers = httpResponse.allHeaderFields.reduce(into: [[String]]()) { result, header in
|
|
if let key = header.key as? String, let value = header.value as? String {
|
|
result.append([key, value])
|
|
}
|
|
}
|
|
let url = httpResponse.url?.absoluteString ?? ""
|
|
return NativeResponseInit(
|
|
headers: headers, status: status, statusText: statusText, url: url
|
|
)
|
|
}
|
|
|
|
// MARK: - ExpoURLSessionTaskDelegate implementations
|
|
|
|
func urlSessionDidStart(_ session: ExpoURLSessionTask) {
|
|
if isInvalidState(.intialized) {
|
|
return
|
|
}
|
|
state = .started
|
|
}
|
|
|
|
func urlSession(_ session: ExpoURLSessionTask, didReceive response: URLResponse) {
|
|
if isInvalidState(.started) {
|
|
return
|
|
}
|
|
responseInit = Self.createResponseInit(response: response)
|
|
state = .responseReceived
|
|
}
|
|
|
|
func urlSession(_ session: ExpoURLSessionTask, didReceive data: Data) {
|
|
if isInvalidState(.responseReceived, .bodyStreamingStarted, .bodyStreamingCanceled) {
|
|
return
|
|
}
|
|
|
|
if state == .responseReceived {
|
|
self.sink.appendBufferBody(data: data)
|
|
} else if state == .bodyStreamingStarted {
|
|
emit(event: "didReceiveResponseData", arguments: data)
|
|
}
|
|
// no-op in .bodyStreamingCanceled state
|
|
}
|
|
|
|
func urlSession(
|
|
_ session: ExpoURLSessionTask,
|
|
task: URLSessionTask,
|
|
willPerformHTTPRedirection response: HTTPURLResponse,
|
|
newRequest request: URLRequest,
|
|
completionHandler: @escaping (URLRequest?) -> Void
|
|
) {
|
|
let shouldFollowRedirects = self.redirectMode == .follow
|
|
completionHandler(shouldFollowRedirects ? request : nil)
|
|
self.redirected = shouldFollowRedirects
|
|
|
|
if self.redirectMode == .error {
|
|
let error = FetchRedirectException()
|
|
self.error = error
|
|
if state == .bodyStreamingStarted {
|
|
emit(event: "didFailWithError", arguments: error.localizedDescription)
|
|
}
|
|
state = .errorReceived
|
|
emit(event: "readyForJSFinalization")
|
|
}
|
|
}
|
|
|
|
func urlSession(_ session: ExpoURLSessionTask, task: URLSessionTask, didCompleteWithError error: (any Error)?) {
|
|
if isInvalidState(.started, .responseReceived, .bodyStreamingStarted, .bodyStreamingCanceled) {
|
|
return
|
|
}
|
|
|
|
if state == .started,
|
|
let urlError = error as? URLError,
|
|
urlError.code.rawValue == CFNetworkErrors.cfurlErrorFileDoesNotExist.rawValue,
|
|
let url = task.currentRequest?.url,
|
|
url.scheme == "file" {
|
|
// When requesting a local file that does not exist,
|
|
// the `urlSession(_:didReceive:)` method will not be called.
|
|
// Instead of throwing an exception, we generate a 404 response.
|
|
responseInit = NativeResponseInit(
|
|
headers: [], status: 404, statusText: "File not found", url: url.absoluteString)
|
|
|
|
// First, set the state to .responseReceived, and then to .errorReceived in the next loop.
|
|
// This simulates the state transition similar to HTTP requests.
|
|
state = .responseReceived
|
|
dispatchQueue.async { [weak self] in
|
|
guard let self else {
|
|
return
|
|
}
|
|
self.urlSession(session, task: task, didCompleteWithError: error)
|
|
}
|
|
return
|
|
}
|
|
|
|
if state == .bodyStreamingStarted {
|
|
if let error {
|
|
emit(event: "didFailWithError", arguments: error.localizedDescription)
|
|
} else {
|
|
emit(event: "didComplete")
|
|
}
|
|
}
|
|
|
|
if let error {
|
|
self.error = error
|
|
state = .errorReceived
|
|
} else {
|
|
state = .bodyCompleted
|
|
}
|
|
|
|
emit(event: "readyForJSFinalization")
|
|
}
|
|
}
|