Skip to content

BridgeJS: Export types using a separate JS representation#750

Open
wfltaylor wants to merge 1 commit into
swiftwasm:mainfrom
wfltaylor:js-as
Open

BridgeJS: Export types using a separate JS representation#750
wfltaylor wants to merge 1 commit into
swiftwasm:mainfrom
wfltaylor:js-as

Conversation

@wfltaylor
Copy link
Copy Markdown
Contributor

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.

Copy link
Copy Markdown
Member

@kateinoigakukun kateinoigakukun left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for trying to tackle this and showing your idea with actual code! This is very interesting to me, and I just realized the ability to have different transferring representations solves some other problems like #496

@_cdecl("bjs_roundtripPolygon")
public func _bjs_roundtripPolygon(_ polygon: UnsafeMutableRawPointer) -> UnsafeMutableRawPointer {
#if arch(wasm32)
let ret = roundtripPolygon(_: Polygon.bridgeFromJS(PolygonReference.bridgeJSLiftParameter(polygon)))
Copy link
Copy Markdown
Member

@kateinoigakukun kateinoigakukun May 27, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of calling bridgeFromJS / bridgeToJS in a call sequence directly, I think we can define Polygon.bridgeJSLiftParameter, then we don't need to add special handling in ExportedThunkBuilder and CallJSEmission, and also we can remove special conversions for container types (Dictionary, Array, Optional).

case swiftProtocol(String)
case swiftStruct(String)
indirect case closure(ClosureSignature, useJSTypedClosure: Bool)
indirect case alias(name: String, underlying: BridgeType)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we really need to add alias case in BridgeType? If we generate conventional methods like bridgeJSLiftParameter and bridgeJSLowerParameter, we might be able to make BridgeType consumers agnostic to aliasness.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants