Search code examples
swiftasync-awaitvaporjavascriptcoreswift-concurrency

How to pause an asynchronous Swift function until a callback is called when a result is available from another library(JavaScriptCore)


I want to use Vapor(a Web framework made with Swift) and JavaScriptCore(Apple's JS engine) to process some requests. Vapor supports async/await, which makes handling HTTP requests very easy, like this:

func routes(_ app: Application) throws {
    
    app.get("myrequestpath") { req async throws -> String in

        let result = await myAsyncFunction()        

        return result
    }

}

myAsyncFunction() handles the processing to return the desired result and some of the processing I do is in JavaScript, executed in JavaScriptCore. I have no problem with interacting with JavaScriptCore, I can call the JS functions and JS can call the Swift functions I give access to.

I want to be able to initiate a JS function from Swift, wait for the async code in JS to finish its job and then pass the result to Swift async code for further processing.

The problem is, JavaScript itself has async/await that works slightly differently than the one in Swift.

In JavaScript, async functions return a Promise object that later resolves once the code block inside it calls the resolve or reject functions.

As a result, I can't simply await a JS function because it will return a Promise object right away and Swift will continue execution:

func myAsyncFunction() async -> Bool {
    
    let jsContext = JSEngine().evaluateScript(myJSCode)
    let result = await jsContext.objectForKeyedSubscript("myAsyncJSFunction").call(withArguments: [data])
    return result
}

I would love to do that but I can't, the result will be a Promise object that doesn't mean anything in Swift(right).

As a result, what I need is, to write an async Swift function that pauses until the JS function calls back the resolve or reject function.

Something like this maybe?:

func myAsyncFunction() async -> Bool {
    
    let jsContext = JSEngine().evaluateScript(myJSCode)
    let result = await {
        /*
         A function that I can pass to JS and the JS can call it when it's ready, return the result to Swift and continue execution
         */
        
    }
    return result
}

Any ideas how to achieve that?


Solution

  • Okay, as it turns out, Swift async/await concurrency does have a support for continuation. It's not mentioned on the main article on Swift documentation and most 3rd party articles bill this feature as a way to integrate the old callback centric concurrency with the new async/await API, however this is much more useful than simply integrating the old code with the new one.

    Continuation provides you with an asynchronous function that can be called from within your async function to pause execution with await until you explicitly resume it. That means, you can await input from the user or another library that uses callback to deliver the results. That also means, you are entirely responsible to correctly resume the function.

    In my case, I'm providing the JavaScriptCore library with a callback that resumes execution from Swift, which is called by an async JavaScript code upon completion.

    Let me show you. This is a JS code that asynchronously processes a request:

    async function processRequest(data, callback){
      try {
          let result = await myAsyncJSProcessing(data)
          callback(result, null)
        } catch(err) {
          callback(null, err)
        }
    }
    

    To be able to pause the Swift execution until the JS code finishes processing and continue once the JS calls the callback with the data, I can use continuation like this:

    //proccessRequestSync is a synchronous Swift function that provides the callback that the JS will call.
    // This is also the function with a callback that Swift will wait until the callback is called
    // self.engine is the initiated JavaScriptCore context where the JS runs.
    // We create the callback that we will pass to the JS engine as JSValue, then pass it to the JS function as a parameter. Once the JS is done, calls this function.
    func proccessRequestSync(_ completion : @escaping (Result<JSValue, Error>) -> Void){
                let callback : @convention(block) (JSValue, JSValue) -> Void = { success, failure in
                    completion(.success(success))
                }
                let jsCallback = JSValue(object: callback, in: self.engine)
                self.engine?.objectForKeyedSubscript("processRequest").call(withArguments: ["some_data", jsCallback])
            }
    // Then we create the async function that will be paused using continuation.
    // We can integrate the function into the rest of our async Swift.
    func proccessRequestAsync() async throws -> JSValue {
                //Continuation happens right here. 
                return try await withCheckedThrowingContinuation({ continuation in
                    proccessRequestSync { result in
                        // This is the closure that will be called by the JS once processing is finished
                        // We will explicitly resume the execution of the Swift code by calling continuation.resume()
                        switch result {
                                    case .success(let val):
                                        continuation.resume(returning: val)
                                    case .failure(let error):
                                        continuation.resume(throwing: error)
                                    }
                    }
                })
            }
            
    if let result = try? await proccessRequestAsync()