◐ Shell
clean mode source ↗

BridgeJS: Export types using a separate JS representation by wfltaylor · Pull Request #750 · swiftwasm/JavaScriptKit

Note that this PR is much smaller than it looks! The vast majority of the lines added are tests/generated code.

Adds @JS(as:) to let types provide an alternative JavaScript representation, providing an escape hatch when BridgeJS’s defaults don't fit.

Motivation

BridgeJS provides sensible defaults when exporting Swift code to JavaScript. For example, structs are exported using copy semantics: each field in the Swift struct is copied when crossing the boundary. BridgeJS also only supports exporting a subset of Swift features, which makes sense given there are many things in the Swift language which would be difficult to expose to JavaScript.

While this approach makes sense, sometimes the defaults used by BridgeJS won’t work well in a specific situation. For example, a Swift struct Polygon { var vertices: [Point] } will be exported to JavaScript using copy semantics. In a project where polygons could contain thousands of points, copying at the boundary every time provides unacceptable performance. Currently, there is no escape hatch for situations like these. Either the Swift code has to be modified to be less idiomatic (e.g. in this case by making Polygon a class), or a duplicate type could be created (say a JSPolygon) and exposed to JavaScript that wraps the underlying Swift type. A similar situation could be encountered when using a feature which BridgeJS doesn’t support, for example dictionaries with integer keys. The same two options are available: less idiomatic Swift code (using String keys everywhere), or duplicating types.

At first, the “duplicated types” approach sounds reasonable. Unfortunately, it starts to break down as the project scales. For example, a large existing codebase could use Polygon in hundreds of types and hundreds of methods. Creating a duplicate JS hierarchy here introduces a huge maintenance cost, where most of the duplication is providing no value since BridgeJS’s defaults likely make sense the majority of the time. The same situation occurs with unsupported features: a leaf type might introduce a dictionary with integer keys, requiring the creation of a vast hierarchy of duplicated JS-safe types.

Solution

Allow types to provide an alternative JS representation. This works as follows:

@JS(as: JSPolygon.self) public struct Polygon {

    public var vertices: [Point]

    public consuming func bridgeToJS() -> JSPolygon {
        JSPolygon(underlying: self)
    }

    public static func bridgeFromJS(_ value: consuming JSPolygon) -> Polygon {
        value.underlying
    }

}

@JS public final class JSPolygon {

    var underlying: Polygon

    @JS public init(underlying: Polygon) {
        self.underlying = underlying
    }

    @JS var boundingBox: Rect { underlying.boundingBox }

}

Now, the existing Swift code can continue to use the Swift-idiomatic Polygon while JavaScript gets the appropriate JSPolygon type.

Design

This is implemented by inserting calls to bridgeToJS() and bridgeFromJS at the first and last possible opportunity respectively. This means that code generation changes are minimal, everything just uses the ABI of the JavaScript type. I’ve added a new case to BridgeType indirect case alias(name: String, underlying: BridgeType). I’m very much not sold on the name alias, but I couldn’t think of anything better.

Alternatives

  • PR BridgeJS: Add support for exporting structs using a box #740 aimed to solve a similar problem, but was more narrow (and required more complex changes). While this approach requires more boilerplate for something like the Polygon case, it is also more general.
  • In Support exporting structs as a reference #737, @krodak suggested using a custom code-generation tool. While this is a possible approach, implementing this correctly is tricky and it makes sense to me for BridgeJS to have built-in support for what is likely going to be a common problem.
  • Not doing anything: the status quo of requiring users to modify their Swift code to be less idiomatic or create a duplicate hierarchy of types. This would likely be an impediment to adopting BridgeJS, especially in larger projects who may have more consumers than just JavaScript.