How to add Swift functions as polyfills in JavaScriptCore

Even if you are not developing cross-platform applications, JavaScript might come in handy for implementing some calculations that need to work the same way on all platforms, or you might want to reuse an existing JavaScript implementation from your web app. In my case this was code generating graphical output based on plain data.

There are a lot of tutorials available on the web on how to invoke JavaScript from your iOS code with Apple’s JavaScriptCore framework. A good overview of the basics can be found in this article.

In JavaScriptCore a JSContext object is your execution environment for the JavaScript code. It corresponds to a single global object and is similar to the window object of a web browser. It’s a sandbox and the space where all global variables and functions that you add become accessible by any other object in the same context.

When you start running JavaScript code in a JSContext you will most likely face a problem soon: Anything that is not part of the pure JavaScript language won’t be available to you. This includes the common functions setInterval and setTimeout. Most environments like browsers provide these methods but since JavaScriptCore is a barebones engine, none of these are defined.

In this blog post I will show how these functions can be implemented in Swift on the native side and how to make them available in the JSContext object.

The first step is to provide native functionality for intervals and timeouts. We create a new Swift class JSPolyfill for this and define functions to allow creation and removal of repeating and non-repeating timers. The callback value is of type JSValue and references the JavaScript function which is given as parameter to setInterval and setTimeout in the JavaScript code.

import JavaScriptCore

class JSPolyfill {
    static let shared = JSPolyfill()

    var timers = [String: Timer]()

    func removeTimer(identifier: String) {
        let timer = self.timers.removeValue(forKey: identifier)

        timer?.invalidate()
    }

    func createTimer(callback: JSValue, ms: Double, repeats : Bool) -> String {
        let timeInterval  = ms/1000.0

        let uuid = NSUUID().uuidString

        DispatchQueue.main.async(execute: {
            let timer = Timer.scheduledTimer(timeInterval: timeInterval,
                                             target: self,
                                             selector: #selector(self.callJsCallback),
                                             userInfo: callback,
                                             repeats: repeats)
            self.timers[uuid] = timer
        })

        return uuid
    }

    @objc func callJsCallback(_ timer: Timer) {
        let callback = (timer.userInfo as! JSValue)

        callback.call(withArguments: nil)
    }
}

Next we need to implement the actual bridge between JavaScript and Swift. Calling setObject(_:forKeyedSubscript:) of JSContext lets us load code blocks as polyfills into the context. However this only works with Objective-C blocks, not with Swift closures. In order to export a closure we annotate the closure with the @convention(block) attribute to bridge it to an Objective-C block. Add the following method to our JSPolyfill class which creates the four missing functions for supporting timeouts and intervals.

func registerInContext(_ context: JSContext) {
    let clearInterval: @convention(block) (String) -> () = { identifier in
        self.removeTimer(identifier: identifier)
    }

    let clearTimeout: @convention(block) (String) -> () = { identifier in
        self.removeTimer(identifier: identifier)
    }

    let setInterval: @convention(block) (JSValue, Double) -> String = { (callback, ms) in
        return self.createTimer(callback: callback, ms: ms, repeats: true)
    }

    let setTimeout: @convention(block) (JSValue, Double) -> String = { (callback, ms) in
        return self.createTimer(callback: callback, ms: ms, repeats: false)
    }

    context.setObject(clearInterval,
                      forKeyedSubscript: "clearInterval" as NSString)

    context.setObject(clearTimeout,
                      forKeyedSubscript: "clearTimeout" as NSString)

    context.setObject(setInterval,
                      forKeyedSubscript: "setInterval" as NSString)

    context.setObject(setTimeout,
                      forKeyedSubscript: "setTimeout" as NSString)
}

Now all that’s left to do is to call the registerInContext method after we instantiate JSContext.

let context = JSContext()

JSPolyfill.shared.registerInContext(context)

When running our JavaScript code in this context the native methods will be called every time there is a call to one of the functions. You can check this by setting breakpoints, for example.

Feel free to provide feedback and ask questions.

TAGS

Comments

Please accept our cookie agreement to see full comments functionality. Read more