Skip to content

BridgeJS: Add support for throwing JSException from Swift #373

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Jun 16, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Plugins/BridgeJS/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,3 +135,4 @@ TBD
declare var Foo: FooConstructor;
```
- [ ] Use `externref` once it's widely available
- [ ] Test SwiftObject roundtrip
40 changes: 34 additions & 6 deletions Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ struct BridgeJSLink {

let tmpRetString;
let tmpRetBytes;
let tmpRetException;
return {
/** @param {WebAssembly.Imports} importObject */
addImports: (importObject) => {
Expand All @@ -134,6 +135,9 @@ struct BridgeJSLink {
target.set(tmpRetBytes);
tmpRetBytes = undefined;
}
bjs["swift_js_throw"] = function(id) {
tmpRetException = swift.memory.retainByRef(id);
}
bjs["swift_js_retain"] = function(id) {
return swift.memory.retainByRef(id);
}
Expand Down Expand Up @@ -188,6 +192,11 @@ struct BridgeJSLink {
var bodyLines: [String] = []
var cleanupLines: [String] = []
var parameterForwardings: [String] = []
let effects: Effects

init(effects: Effects) {
self.effects = effects
}

func lowerParameter(param: Parameter) {
switch param.type {
Expand Down Expand Up @@ -245,7 +254,24 @@ struct BridgeJSLink {
}

func callConstructor(abiName: String) -> String {
return "instance.exports.\(abiName)(\(parameterForwardings.joined(separator: ", ")))"
let call = "instance.exports.\(abiName)(\(parameterForwardings.joined(separator: ", ")))"
bodyLines.append("const ret = \(call);")
return "ret"
}

func checkExceptionLines() -> [String] {
guard effects.isThrows else {
return []
}
return [
"if (tmpRetException) {",
// TODO: Implement "take" operation
" const error = swift.memory.getObject(tmpRetException);",
" swift.memory.release(tmpRetException);",
" tmpRetException = undefined;",
" throw error;",
"}",
]
}

func renderFunction(
Expand All @@ -261,6 +287,7 @@ struct BridgeJSLink {
)
funcLines.append(contentsOf: bodyLines.map { $0.indent(count: 4) })
funcLines.append(contentsOf: cleanupLines.map { $0.indent(count: 4) })
funcLines.append(contentsOf: checkExceptionLines().map { $0.indent(count: 4) })
if let returnExpr = returnExpr {
funcLines.append("return \(returnExpr);".indent(count: 4))
}
Expand All @@ -274,7 +301,7 @@ struct BridgeJSLink {
}

func renderExportedFunction(function: ExportedFunction) -> (js: [String], dts: [String]) {
let thunkBuilder = ExportedThunkBuilder()
let thunkBuilder = ExportedThunkBuilder(effects: function.effects)
for param in function.parameters {
thunkBuilder.lowerParameter(param: param)
}
Expand Down Expand Up @@ -304,16 +331,17 @@ struct BridgeJSLink {
jsLines.append("class \(klass.name) extends SwiftHeapObject {")

if let constructor: ExportedConstructor = klass.constructor {
let thunkBuilder = ExportedThunkBuilder()
let thunkBuilder = ExportedThunkBuilder(effects: constructor.effects)
for param in constructor.parameters {
thunkBuilder.lowerParameter(param: param)
}
let returnExpr = thunkBuilder.callConstructor(abiName: constructor.abiName)
var funcLines: [String] = []
funcLines.append("constructor(\(constructor.parameters.map { $0.name }.joined(separator: ", "))) {")
let returnExpr = thunkBuilder.callConstructor(abiName: constructor.abiName)
funcLines.append(contentsOf: thunkBuilder.bodyLines.map { $0.indent(count: 4) })
funcLines.append("super(\(returnExpr), instance.exports.bjs_\(klass.name)_deinit);".indent(count: 4))
funcLines.append(contentsOf: thunkBuilder.cleanupLines.map { $0.indent(count: 4) })
funcLines.append(contentsOf: thunkBuilder.checkExceptionLines().map { $0.indent(count: 4) })
funcLines.append("super(\(returnExpr), instance.exports.bjs_\(klass.name)_deinit);".indent(count: 4))
funcLines.append("}")
jsLines.append(contentsOf: funcLines.map { $0.indent(count: 4) })

Expand All @@ -324,7 +352,7 @@ struct BridgeJSLink {
}

for method in klass.methods {
let thunkBuilder = ExportedThunkBuilder()
let thunkBuilder = ExportedThunkBuilder(effects: method.effects)
thunkBuilder.lowerSelf()
for param in method.parameters {
thunkBuilder.lowerParameter(param: param)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,19 @@ struct Parameter: Codable {
let type: BridgeType
}

struct Effects: Codable {
var isAsync: Bool
var isThrows: Bool
}

// MARK: - Exported Skeleton

struct ExportedFunction: Codable {
var name: String
var abiName: String
var parameters: [Parameter]
var returnType: BridgeType
var effects: Effects
}

struct ExportedClass: Codable {
Expand All @@ -34,6 +40,7 @@ struct ExportedClass: Codable {
struct ExportedConstructor: Codable {
var abiName: String
var parameters: [Parameter]
var effects: Effects
}

struct ExportedSkeleton: Codable {
Expand Down
141 changes: 111 additions & 30 deletions Plugins/BridgeJS/Sources/BridgeJSTool/ExportSwift.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,14 +155,43 @@ class ExportSwift {
abiName = "bjs_\(className)_\(name)"
}

guard let effects = collectEffects(signature: node.signature) else {
return nil
}

return ExportedFunction(
name: name,
abiName: abiName,
parameters: parameters,
returnType: returnType
returnType: returnType,
effects: effects
)
}

private func collectEffects(signature: FunctionSignatureSyntax) -> Effects? {
let isAsync = signature.effectSpecifiers?.asyncSpecifier != nil
var isThrows = false
if let throwsClause: ThrowsClauseSyntax = signature.effectSpecifiers?.throwsClause {
// Limit the thrown type to JSException for now
guard let thrownType = throwsClause.type else {
diagnose(
node: throwsClause,
message: "Thrown type is not specified, only JSException is supported for now"
)
return nil
}
guard thrownType.trimmedDescription == "JSException" else {
diagnose(
node: throwsClause,
message: "Only JSException is supported for thrown type, got \(thrownType.trimmedDescription)"
)
return nil
}
isThrows = true
}
return Effects(isAsync: isAsync, isThrows: isThrows)
}

override func visit(_ node: InitializerDeclSyntax) -> SyntaxVisitorContinueKind {
guard node.attributes.hasJSAttribute() else { return .skipChildren }
guard case .classBody(let name) = state else {
Expand All @@ -180,9 +209,14 @@ class ExportSwift {
parameters.append(Parameter(label: label, name: name, type: type))
}

guard let effects = collectEffects(signature: node.signature) else {
return .skipChildren
}

let constructor = ExportedConstructor(
abiName: "bjs_\(name)_init",
parameters: parameters
parameters: parameters,
effects: effects
)
exportedClasses[name]?.constructor = constructor
return .skipChildren
Expand Down Expand Up @@ -245,6 +279,8 @@ class ExportSwift {

@_extern(wasm, module: "bjs", name: "swift_js_retain")
private func _swift_js_retain(_ ptr: Int32) -> Int32
@_extern(wasm, module: "bjs", name: "swift_js_throw")
private func _swift_js_throw(_ id: Int32)
"""

func renderSwiftGlue() -> String? {
Expand All @@ -268,6 +304,11 @@ class ExportSwift {
var abiParameterForwardings: [LabeledExprSyntax] = []
var abiParameterSignatures: [(name: String, type: WasmCoreType)] = []
var abiReturnType: WasmCoreType?
let effects: Effects

init(effects: Effects) {
self.effects = effects
}

func liftParameter(param: Parameter) {
switch param.type {
Expand Down Expand Up @@ -350,35 +391,40 @@ class ExportSwift {
}
}

func call(name: String, returnType: BridgeType) {
private func renderCallStatement(callee: ExprSyntax, returnType: BridgeType) -> StmtSyntax {
var callExpr: ExprSyntax =
"\(raw: callee)(\(raw: abiParameterForwardings.map { $0.description }.joined(separator: ", ")))"
if effects.isAsync {
callExpr = ExprSyntax(AwaitExprSyntax(awaitKeyword: .keyword(.await), expression: callExpr))
}
if effects.isThrows {
callExpr = ExprSyntax(
TryExprSyntax(
tryKeyword: .keyword(.try).with(\.trailingTrivia, .space),
expression: callExpr
)
)
}
let retMutability = returnType == .string ? "var" : "let"
let callExpr: ExprSyntax =
"\(raw: name)(\(raw: abiParameterForwardings.map { $0.description }.joined(separator: ", ")))"
if returnType == .void {
body.append("\(raw: callExpr)")
return StmtSyntax("\(raw: callExpr)")
} else {
body.append(
"""
\(raw: retMutability) ret = \(raw: callExpr)
"""
)
return StmtSyntax("\(raw: retMutability) ret = \(raw: callExpr)")
}
}

func call(name: String, returnType: BridgeType) {
let stmt = renderCallStatement(callee: "\(raw: name)", returnType: returnType)
body.append(CodeBlockItemSyntax(item: .stmt(stmt)))
}

func callMethod(klassName: String, methodName: String, returnType: BridgeType) {
let _selfParam = self.abiParameterForwardings.removeFirst()
let retMutability = returnType == .string ? "var" : "let"
let callExpr: ExprSyntax =
"\(raw: _selfParam).\(raw: methodName)(\(raw: abiParameterForwardings.map { $0.description }.joined(separator: ", ")))"
if returnType == .void {
body.append("\(raw: callExpr)")
} else {
body.append(
"""
\(raw: retMutability) ret = \(raw: callExpr)
"""
)
}
let stmt = renderCallStatement(
callee: "\(raw: _selfParam).\(raw: methodName)",
returnType: returnType
)
body.append(CodeBlockItemSyntax(item: .stmt(stmt)))
}

func lowerReturnValue(returnType: BridgeType) {
Expand Down Expand Up @@ -440,19 +486,54 @@ class ExportSwift {
}

func render(abiName: String) -> DeclSyntax {
let body: CodeBlockItemListSyntax
if effects.isThrows {
body = """
do {
\(CodeBlockItemListSyntax(self.body))
} catch let error {
if let error = error.thrownValue.object {
withExtendedLifetime(error) {
_swift_js_throw(Int32(bitPattern: $0.id))
}
} else {
let jsError = JSError(message: String(describing: error))
withExtendedLifetime(jsError.jsObject) {
_swift_js_throw(Int32(bitPattern: $0.id))
}
}
\(raw: returnPlaceholderStmt())
}
"""
} else {
body = CodeBlockItemListSyntax(self.body)
}
return """
@_expose(wasm, "\(raw: abiName)")
@_cdecl("\(raw: abiName)")
public func _\(raw: abiName)(\(raw: parameterSignature())) -> \(raw: returnSignature()) {
\(CodeBlockItemListSyntax(body))
\(body)
}
"""
}

private func returnPlaceholderStmt() -> String {
switch abiReturnType {
case .i32: return "return 0"
case .i64: return "return 0"
case .f32: return "return 0.0"
case .f64: return "return 0.0"
case .pointer: return "return UnsafeMutableRawPointer(bitPattern: -1)"
case .none: return "return"
}
}

func parameterSignature() -> String {
abiParameterSignatures.map { "\($0.name): \($0.type.swiftType)" }.joined(
separator: ", "
)
var nameAndType: [(name: String, abiType: String)] = []
for (name, type) in abiParameterSignatures {
nameAndType.append((name, type.swiftType))
}
return nameAndType.map { "\($0.name): \($0.abiType)" }.joined(separator: ", ")
}

func returnSignature() -> String {
Expand All @@ -461,7 +542,7 @@ class ExportSwift {
}

func renderSingleExportedFunction(function: ExportedFunction) -> DeclSyntax {
let builder = ExportedThunkBuilder()
let builder = ExportedThunkBuilder(effects: function.effects)
for param in function.parameters {
builder.liftParameter(param: param)
}
Expand Down Expand Up @@ -520,7 +601,7 @@ class ExportSwift {
func renderSingleExportedClass(klass: ExportedClass) -> [DeclSyntax] {
var decls: [DeclSyntax] = []
if let constructor = klass.constructor {
let builder = ExportedThunkBuilder()
let builder = ExportedThunkBuilder(effects: constructor.effects)
for param in constructor.parameters {
builder.liftParameter(param: param)
}
Expand All @@ -529,7 +610,7 @@ class ExportSwift {
decls.append(builder.render(abiName: constructor.abiName))
}
for method in klass.methods {
let builder = ExportedThunkBuilder()
let builder = ExportedThunkBuilder(effects: method.effects)
builder.liftParameter(
param: Parameter(label: nil, name: "_self", type: .swiftHeapObject(klass.name))
)
Expand Down
3 changes: 3 additions & 0 deletions Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/Throws.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@JS func throwsSomething() throws(JSException) {
throw JSException(JSError(message: "TestError").jsValue)
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export async function createInstantiator(options, swift) {

let tmpRetString;
let tmpRetBytes;
let tmpRetException;
return {
/** @param {WebAssembly.Imports} importObject */
addImports: (importObject) => {
Expand All @@ -35,6 +36,9 @@ export async function createInstantiator(options, swift) {
target.set(tmpRetBytes);
tmpRetBytes = undefined;
}
bjs["swift_js_throw"] = function(id) {
tmpRetException = swift.memory.retainByRef(id);
}
bjs["swift_js_retain"] = function(id) {
return swift.memory.retainByRef(id);
}
Expand Down
Loading