Skip to content

Commit 30a9c43

Browse files
committed
BridgeJS: Support throws and async for closures
1 parent 453b841 commit 30a9c43

32 files changed

Lines changed: 4018 additions & 97 deletions

Plugins/BridgeJS/Sources/BridgeJSCore/ClosureCodegen.swift

Lines changed: 96 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ public struct ClosureCodegen {
1616
let closureParams = signature.parameters.map { "\(sendingPrefix)\($0.closureSwiftType)" }.joined(
1717
separator: ", "
1818
)
19-
let swiftEffects = (signature.isAsync ? " async" : "") + (signature.isThrows ? " throws" : "")
19+
let swiftEffects = (signature.isAsync ? " async" : "") + (signature.isThrows ? " throws(JSException)" : "")
2020
let swiftReturnType = signature.returnType.closureSwiftType
2121
return "(\(closureParams))\(swiftEffects) -> \(swiftReturnType)"
2222
}
@@ -73,7 +73,17 @@ public struct ClosureCodegen {
7373
helperEnumDeclPrinter.indent {
7474
helperEnumDeclPrinter.write("let callback = JSObject.bridgeJSLiftParameter(callbackId)")
7575
let parameters: String
76-
if signature.parameters.isEmpty {
76+
if signature.isThrows || signature.isAsync {
77+
let sendingPrefix = signature.sendingParameters ? "sending " : ""
78+
let typedParams =
79+
signature.parameters.enumerated().map { index, paramType in
80+
"param\(index): \(sendingPrefix)\(paramType.closureSwiftType)"
81+
}.joined(separator: ", ")
82+
let returnType = signature.returnType.closureSwiftType
83+
let effects =
84+
(signature.isAsync ? " async" : "") + (signature.isThrows ? " throws(JSException)" : "")
85+
parameters = " (\(typedParams))\(effects) -> \(returnType)"
86+
} else if signature.parameters.isEmpty {
7787
parameters = ""
7888
} else if signature.parameters.count == 1 {
7989
parameters = " param0"
@@ -146,22 +156,25 @@ public struct ClosureCodegen {
146156
liftedParams.append("\(paramType.swiftType).bridgeJSLiftParameter(\(argNames.joined(separator: ", ")))")
147157
}
148158

149-
let closureCallExpr = ExprSyntax("closure(\(raw: liftedParams.joined(separator: ", ")))")
159+
let tryPrefix = signature.isThrows ? "try " : ""
160+
let closureCallExpr = ExprSyntax("\(raw: tryPrefix)closure(\(raw: liftedParams.joined(separator: ", ")))")
161+
let asyncTryPrefix = (signature.isThrows ? "try " : "") + "await "
162+
let asyncClosureCallExpr = ExprSyntax(
163+
"\(raw: asyncTryPrefix)closure(\(raw: liftedParams.joined(separator: ", ")))"
164+
)
150165

151-
let abiReturnWasmType = try signature.returnType.loweringReturnInfo().returnType
166+
let abiReturnWasmType =
167+
signature.isAsync
168+
? try BridgeType.jsObject(nil).loweringReturnInfo().returnType
169+
: try signature.returnType.loweringReturnInfo().returnType
152170

153171
// Build signature using SwiftSignatureBuilder
154172
let funcSignature = SwiftSignatureBuilder.buildABIFunctionSignature(
155173
abiParameters: abiParams,
156174
returnType: abiReturnWasmType
157175
)
158176

159-
// Build function declaration using helper
160-
let funcDecl = SwiftCodePattern.buildExposedFunctionDecl(
161-
abiName: abiName,
162-
signature: funcSignature
163-
) { printer in
164-
printer.write("let closure = Unmanaged<\(boxType)>.fromOpaque(boxPtr).takeUnretainedValue().closure")
177+
let emitCallAndLower: (CodeFragmentPrinter) -> Void = { printer in
165178
if signature.returnType == .void {
166179
printer.write(closureCallExpr.description)
167180
} else {
@@ -189,6 +202,79 @@ public struct ClosureCodegen {
189202
}
190203
}
191204

205+
let emitAsyncCallAndLower: (CodeFragmentPrinter) -> Void = { printer in
206+
printer.write("let closure = Unmanaged<\(boxType)>.fromOpaque(boxPtr).takeUnretainedValue().closure")
207+
let resolveType = signature.returnType
208+
let resolveName = "Promise_resolve_\(resolveType.mangleTypeName)"
209+
let rejectName = "Promise_reject"
210+
let closureHead: String
211+
if signature.isThrows {
212+
let returnSpelling = resolveType == .void ? "" : " -> \(resolveType.closureSwiftType)"
213+
closureHead = " () async throws(JSException)\(returnSpelling) in"
214+
} else {
215+
closureHead = ""
216+
}
217+
printer.write("return _bjs_makePromise(resolve: \(resolveName), reject: \(rejectName)) {\(closureHead)")
218+
printer.indent {
219+
if resolveType == .void {
220+
printer.write(asyncClosureCallExpr.description)
221+
} else {
222+
printer.write("return \(asyncClosureCallExpr)")
223+
}
224+
}
225+
printer.write("}")
226+
}
227+
228+
let catchPlaceholderStmt = abiReturnWasmType?.swiftReturnPlaceholderStmt
229+
230+
// Build function declaration using helper
231+
let funcDecl = SwiftCodePattern.buildExposedFunctionDecl(
232+
abiName: abiName,
233+
signature: funcSignature
234+
) { printer in
235+
if signature.isAsync {
236+
emitAsyncCallAndLower(printer)
237+
} else if signature.isThrows {
238+
printer.write(
239+
"let closure = Unmanaged<\(boxType)>.fromOpaque(boxPtr).takeUnretainedValue().closure"
240+
)
241+
printer.write("do {")
242+
printer.indent {
243+
emitCallAndLower(printer)
244+
}
245+
printer.write("} catch let error {")
246+
printer.indent {
247+
printer.write("if let error = error.thrownValue.object {")
248+
printer.indent {
249+
printer.write("withExtendedLifetime(error) {")
250+
printer.indent {
251+
printer.write("_swift_js_throw(Int32(bitPattern: $0.id))")
252+
}
253+
printer.write("}")
254+
}
255+
printer.write("} else {")
256+
printer.indent {
257+
printer.write("let jsError = JSError(message: error.description)")
258+
printer.write("withExtendedLifetime(jsError.jsObject) {")
259+
printer.indent {
260+
printer.write("_swift_js_throw(Int32(bitPattern: $0.id))")
261+
}
262+
printer.write("}")
263+
}
264+
printer.write("}")
265+
if let catchPlaceholderStmt {
266+
printer.write(catchPlaceholderStmt)
267+
}
268+
}
269+
printer.write("}")
270+
} else {
271+
printer.write(
272+
"let closure = Unmanaged<\(boxType)>.fromOpaque(boxPtr).takeUnretainedValue().closure"
273+
)
274+
emitCallAndLower(printer)
275+
}
276+
}
277+
192278
return DeclSyntax(funcDecl)
193279
}
194280

Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -272,9 +272,7 @@ public struct ImportTS {
272272
}
273273
}
274274

275-
// Add exception check for ImportTS context (skipped for async, where
276-
// errors are funneled through the JS-side reject path)
277-
if !effects.isAsync && context == .importTS {
275+
if !effects.isAsync && (context == .importTS || effects.isThrows) {
278276
body.write("if let error = _swift_js_take_exception() { throw error }")
279277
}
280278
}
@@ -323,18 +321,19 @@ public struct ImportTS {
323321
let innerBody = body
324322
body = CodeFragmentPrinter()
325323

324+
let tryKeyword = effects.isThrows ? "try" : "try!"
326325
let rejectFactory = "makeRejectClosure: { JSTypedClosure<(sending JSValue) -> Void>($0) }"
327326
if returnType == .void {
328327
let resolveFactory = "makeResolveClosure: { JSTypedClosure<() -> Void>($0) }"
329328
body.write(
330-
"try await _bjs_awaitPromise(\(resolveFactory), \(rejectFactory)) { resolveRef, rejectRef in"
329+
"\(tryKeyword) await _bjs_awaitPromise(\(resolveFactory), \(rejectFactory)) { resolveRef, rejectRef in"
331330
)
332331
} else {
333332
let resolveSwiftType = returnType.closureSwiftType
334333
let resolveFactory =
335334
"makeResolveClosure: { JSTypedClosure<(sending \(resolveSwiftType)) -> Void>($0) }"
336335
body.write(
337-
"let resolved = try await _bjs_awaitPromise(\(resolveFactory), \(rejectFactory)) { resolveRef, rejectRef in"
336+
"let resolved = \(tryKeyword) await _bjs_awaitPromise(\(resolveFactory), \(rejectFactory)) { resolveRef, rejectRef in"
338337
)
339338
}
340339
body.indent {

Plugins/BridgeJS/Sources/BridgeJSCore/Misc.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -137,14 +137,21 @@ import SwiftSyntax
137137
import class Foundation.ProcessInfo
138138

139139
public struct DiagnosticError: Error {
140+
public enum Severity: String, Sendable {
141+
case error
142+
case warning
143+
}
144+
140145
public let node: Syntax
141146
public let message: String
142147
public let hint: String?
148+
public let severity: Severity
143149

144-
public init(node: some SyntaxProtocol, message: String, hint: String? = nil) {
150+
public init(node: some SyntaxProtocol, message: String, hint: String? = nil, severity: Severity = .error) {
145151
self.node = Syntax(node)
146152
self.message = message
147153
self.hint = hint
154+
self.severity = severity
148155
}
149156

150157
/// Formats the diagnostic error as a string.
@@ -166,12 +173,14 @@ public struct DiagnosticError: Error {
166173

167174
let lineNumberWidth = max(3, String(lines.count).count)
168175

176+
let severityLabel = severity.rawValue
177+
let severityColor = severity == .warning ? ANSI.boldYellow : ANSI.boldRed
169178
let header: String = {
170179
guard colorize else {
171-
return "\(displayFileName):\(startLocation.line):\(startLocation.column): error: \(message)"
180+
return "\(displayFileName):\(startLocation.line):\(startLocation.column): \(severityLabel): \(message)"
172181
}
173182
return
174-
"\(displayFileName):\(startLocation.line):\(startLocation.column): \(ANSI.boldRed)error: \(ANSI.boldDefault)\(message)\(ANSI.reset)"
183+
"\(displayFileName):\(startLocation.line):\(startLocation.column): \(severityColor)\(severityLabel): \(ANSI.boldDefault)\(message)\(ANSI.reset)"
175184
}()
176185

177186
let highlightStartColumn = min(max(1, startLocation.column), mainLine.utf8.count + 1)
@@ -227,8 +236,8 @@ public struct DiagnosticError: Error {
227236
let pointerSpacing = max(0, highlightStartColumn - 1)
228237
let pointerMessage: String = {
229238
let pointer = String(repeating: " ", count: pointerSpacing) + "`- "
230-
guard colorize else { return pointer + "error: \(message)" }
231-
return pointer + "\(ANSI.boldRed)error: \(ANSI.boldDefault)\(message)\(ANSI.reset)"
239+
guard colorize else { return pointer + "\(severityLabel): \(message)" }
240+
return pointer + "\(severityColor)\(severityLabel): \(ANSI.boldDefault)\(message)\(ANSI.reset)"
232241
}()
233242
descriptionParts.append(
234243
Self.formatSourceLine(
@@ -304,6 +313,7 @@ public struct BridgeJSCoreDiagnosticError: Swift.Error, CustomStringConvertible
304313
private enum ANSI {
305314
static let reset = "\u{001B}[0;0m"
306315
static let boldRed = "\u{001B}[1;31m"
316+
static let boldYellow = "\u{001B}[1;33m"
307317
static let boldDefault = "\u{001B}[1;39m"
308318
static let cyan = "\u{001B}[0;36m"
309319
static let underline = "\u{001B}[4;39m"

Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift

Lines changed: 84 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ public final class SwiftToSkeleton {
2424
private var sourceFiles: [(sourceFile: SourceFileSyntax, inputFilePath: String)] = []
2525
private var usedExternalModules = Set<String>()
2626

27+
/// Non-fatal diagnostics collected during `finalize()`. These do not fail the build.
28+
public private(set) var warnings: [(file: String, diagnostic: DiagnosticError)] = []
29+
2730
public init(
2831
progress: ProgressReporting,
2932
moduleName: String,
@@ -87,10 +90,15 @@ public final class SwiftToSkeleton {
8790
)
8891
importCollector.walk(sourceFile)
8992

90-
let importErrorsFatal = importCollector.errors.filter { !$0.message.contains("Unsupported type '") }
91-
if !exportCollector.errors.isEmpty || !importErrorsFatal.isEmpty {
93+
let exportErrors = exportCollector.errors.filter { $0.severity == .error }
94+
let importErrorsFatal = importCollector.errors.filter {
95+
$0.severity == .error && !$0.message.contains("Unsupported type '")
96+
}
97+
let fileWarnings = (exportCollector.errors + importCollector.errors).filter { $0.severity == .warning }
98+
warnings.append(contentsOf: fileWarnings.map { (file: inputFilePath, diagnostic: $0) })
99+
if !exportErrors.isEmpty || !importErrorsFatal.isEmpty {
92100
perSourceErrors.append(
93-
(inputFilePath: inputFilePath, errors: exportCollector.errors + importErrorsFatal)
101+
(inputFilePath: inputFilePath, errors: exportErrors + importErrorsFatal)
94102
)
95103
}
96104

@@ -191,7 +199,40 @@ public final class SwiftToSkeleton {
191199
}
192200

193201
let isAsync = functionType.effectSpecifiers?.asyncSpecifier != nil
194-
let isThrows = functionType.effectSpecifiers?.throwsClause != nil
202+
203+
if isAsync, !returnType.isAsyncResolvable {
204+
errors.append(
205+
DiagnosticError(
206+
node: functionType,
207+
message:
208+
"Returning '\(returnType.swiftType)' from an async closure is not yet supported",
209+
hint:
210+
"Return a type lowerable through the async resolve ABI "
211+
+ "(String/Int/Bool/Double/Float/raw-value or case-only enum/@JS struct/JSObject/Optional/Array/Dictionary), "
212+
+ "or make the closure non-async."
213+
)
214+
)
215+
return nil
216+
}
217+
218+
var isThrows = false
219+
if let throwsClause = functionType.effectSpecifiers?.throwsClause {
220+
guard let thrownType = throwsClause.type,
221+
thrownType.trimmedDescription == "JSException"
222+
else {
223+
errors.append(
224+
DiagnosticError(
225+
node: throwsClause,
226+
message:
227+
"Only JSException is supported for thrown type of Swift closures, "
228+
+ "got \(throwsClause.type?.trimmedDescription ?? "unspecified")",
229+
hint: "Annotate the closure as `throws(JSException)`"
230+
)
231+
)
232+
return nil
233+
}
234+
isThrows = true
235+
}
195236

196237
return .closure(
197238
ClosureSignature(
@@ -569,6 +610,37 @@ private enum ExportSwiftConstants {
569610
static let supportedRawTypes = SwiftEnumRawType.supportedTypeNames
570611
}
571612

613+
/// Warns about Swift closures handed to JavaScript with an `async throws(JSException)` signature.
614+
/// Captureless closure values lose their thrown error at runtime due to a Swift compiler bug.
615+
private func asyncThrowsClosureWarning(node: some SyntaxProtocol) -> DiagnosticError {
616+
DiagnosticError(
617+
node: node,
618+
message:
619+
"async throwing closures passed to JavaScript may lose thrown errors due to a Swift compiler bug "
620+
+ "(swiftlang/swift#89320) unless the closure value captures state",
621+
hint:
622+
"Pass a closure that captures state, or see the BridgeJS closure documentation for details",
623+
severity: .warning
624+
)
625+
}
626+
627+
extension BridgeType {
628+
fileprivate var containsAsyncThrowsClosure: Bool {
629+
switch self {
630+
case .closure(let signature, _):
631+
return signature.isAsync && signature.isThrows
632+
case .nullable(let wrapped, _):
633+
return wrapped.containsAsyncThrowsClosure
634+
case .array(let element):
635+
return element.containsAsyncThrowsClosure
636+
case .dictionary(let value):
637+
return value.containsAsyncThrowsClosure
638+
default:
639+
return false
640+
}
641+
}
642+
}
643+
572644
extension AttributeSyntax {
573645
/// The attribute name as text when it is a simple identifier (e.g. "JS", "JSFunction").
574646
/// Prefer this over `attributeName.trimmedDescription` for name checks to avoid unnecessary string work.
@@ -1028,22 +1100,6 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
10281100
guard let type = resolvedType else {
10291101
continue // Skip unsupported types
10301102
}
1031-
if case .closure(let signature, _) = type {
1032-
if signature.isAsync {
1033-
diagnose(
1034-
node: param.type,
1035-
message: "Async is not supported for Swift closures yet."
1036-
)
1037-
continue
1038-
}
1039-
if signature.isThrows {
1040-
diagnose(
1041-
node: param.type,
1042-
message: "Throws is not supported for Swift closures yet."
1043-
)
1044-
continue
1045-
}
1046-
}
10471103
if case .nullable(let wrappedType, _) = type, wrappedType.isOptional {
10481104
diagnoseNestedOptional(node: param.type, type: param.type.trimmedDescription)
10491105
continue
@@ -1177,6 +1233,9 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
11771233

11781234
guard let type = resolvedType else { return nil }
11791235
returnType = type
1236+
if returnType.containsAsyncThrowsClosure {
1237+
errors.append(asyncThrowsClosureWarning(node: returnClause.type))
1238+
}
11801239
} else {
11811240
returnType = .void
11821241
}
@@ -2836,6 +2895,11 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
28362895
guard let bridgeType = withLookupErrors({ parent.lookupType(for: type, errors: &$0) }) else {
28372896
return nil
28382897
}
2898+
if case .closure(let signature, useJSTypedClosure: true) = bridgeType,
2899+
signature.isAsync, signature.isThrows
2900+
{
2901+
errors.append(asyncThrowsClosureWarning(node: type))
2902+
}
28392903
let nameToken = param.secondName ?? param.firstName
28402904
let name = SwiftToSkeleton.normalizeIdentifier(nameToken.text)
28412905
let labelToken = param.secondName == nil ? nil : param.firstName

0 commit comments

Comments
 (0)