Skip to content
Open
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
166 changes: 154 additions & 12 deletions Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,75 @@ public class ExportSwift {
decls.append(contentsOf: try renderSingleExportedClass(klass: klass))
}
}

try withSpan("Render Async Promise Helpers") { [self] in
let asyncResolveTypes = skeleton.asyncPromiseResolveReturnTypes
if !asyncResolveTypes.isEmpty {
decls.append(contentsOf: try renderPromiseRejectHelper())
for type in asyncResolveTypes {
decls.append(contentsOf: try renderPromiseResolveHelper(type))
}
}
}
return withSpan("Format Export Glue") {
return decls.map { $0.description }.joined(separator: "\n\n")
}
}

/// Generates the per-type `Promise_resolve_<mangled>` settlement helper.
private func renderPromiseResolveHelper(_ type: BridgeType) throws -> [DeclSyntax] {
try renderPromiseSettleHelper(
functionName: "Promise_resolve_\(type.mangleTypeName)",
externName: "promise_resolve_\(moduleName)_\(type.mangleTypeName)",
valueType: type
)
}

/// Generates the shared `Promise_reject` settlement helper.
private func renderPromiseRejectHelper() throws -> [DeclSyntax] {
try renderPromiseSettleHelper(
functionName: "Promise_reject",
externName: "promise_reject_\(moduleName)",
valueType: .jsValue
)
}

/// Generates a `@JSFunction func <functionName>(_ promise: JSObject, _ value: T)` and its
/// glue, lowering `value` through the standard imported-parameter ABI.
private func renderPromiseSettleHelper(
functionName: String,
externName: String,
valueType: BridgeType
) throws -> [DeclSyntax] {
let effects = Effects(isAsync: false, isThrows: true)
let parameters = [
Parameter(label: nil, name: "promise", type: .jsObject(nil)),
Parameter(label: nil, name: "value", type: valueType),
]
let builder = try ImportTS.CallJSEmission(
moduleName: "bjs",
abiName: externName,
effects: effects,
returnType: .void,
context: .importTS
)
for parameter in parameters {
try builder.lowerParameter(param: parameter)
}
try builder.call()
try builder.liftReturnValue()

let macroDecl: DeclSyntax =
"@JSFunction func \(raw: functionName)(_ promise: JSObject, _ value: \(raw: valueType.swiftType)) throws(JSException)"
let glueDecl = builder.renderThunkDecl(
name: "_$\(functionName)",
parameters: parameters,
returnType: .void,
effects: effects
)
return [macroDecl, builder.renderImportDecl(), glueDecl]
}

class ExportedThunkBuilder {
var body: [CodeBlockItemSyntax] = []
var liftedParameterExprs: [ExprSyntax] = []
Expand All @@ -104,6 +168,12 @@ public class ExportSwift {
var externDecls: [DeclSyntax] = []
let effects: Effects

/// When set, the async thunk settles via `_bjs_makePromise` rather than the `.jsValue` path.
var asyncResolveReturnType: BridgeType?

/// Stack-using parameter lifts hoisted ahead of the deferred async closure.
var asyncHoistedBindings: [CodeBlockItemSyntax] = []

init(effects: Effects) {
self.effects = effects
}
Expand Down Expand Up @@ -200,6 +270,10 @@ public class ExportSwift {
}

if effects.isAsync, returnType != .void {
if asyncResolveReturnType != nil {
// `_bjs_makePromise` lowers the awaited value via its injected `resolve` closure.
return CodeBlockItemSyntax(item: .init(StmtSyntax("return \(raw: callExpr)")))
}
return CodeBlockItemSyntax(item: .init(StmtSyntax("return \(raw: callExpr).jsValue")))
}

Expand Down Expand Up @@ -244,6 +318,23 @@ public class ExportSwift {
param.type.isStackUsingParameter ? index : nil
}

if effects.isAsync {
// The async body runs on a deferred `Task`, so drain stack parameters now and
// capture them; the shared bridge stack would otherwise be corrupted. Reverse for LIFO.
for index in stackParamIndices.reversed() {
let param = parameters[index]
let expr = liftedParameterExprs[index]
let varName = "_tmp_\(param.name)"
var binding: CodeBlockItemSyntax = "let \(raw: varName) = \(expr)"
if !asyncHoistedBindings.isEmpty {
binding = binding.with(\.leadingTrivia, .newline)
}
asyncHoistedBindings.append(binding)
liftedParameterExprs[index] = ExprSyntax(DeclReferenceExprSyntax(baseName: .identifier(varName)))
}
return
}

guard stackParamIndices.count > 1 else { return }

for index in stackParamIndices.reversed() {
Expand Down Expand Up @@ -328,21 +419,32 @@ public class ExportSwift {
}
}

/// A throwing async body needs an explicit closure type, otherwise Swift infers
/// `throws(any Error)` instead of `throws(JSException)`.
/// See: https://github.com/swiftlang/swift/issues/76165
private func asyncThrowsClosureHead(returnSpelling: String?) -> String {
guard effects.isThrows else { return "" }
let returns = returnSpelling.map { " -> \($0)" } ?? ""
return " () async throws(JSException)\(returns) in"
}

func render(abiName: String) -> DeclSyntax {
let body: CodeBlockItemListSyntax
if effects.isAsync {
// Explicit closure type annotation needed when throws is present
// so Swift infers throws(JSException) instead of throws(any Error)
// See: https://github.com/swiftlang/swift/issues/76165
let closureHead: String
if effects.isThrows {
let hasReturn = self.body.contains { $0.description.contains("return ") }
let ret = hasReturn ? " -> JSValue" : ""
closureHead = " () async throws(JSException)\(ret) in"
} else {
closureHead = ""
}
if effects.isAsync, let resolveType = asyncResolveReturnType {
// Not `ConvertibleToJSValue`: settle the promise through `_bjs_makePromise`.
let resolveName = "Promise_resolve_\(resolveType.mangleTypeName)"
let closureHead = asyncThrowsClosureHead(returnSpelling: resolveType.swiftType)
body = """
\(CodeBlockItemListSyntax(asyncHoistedBindings))
return _bjs_makePromise(resolve: \(raw: resolveName), reject: Promise_reject) {\(raw: closureHead)
\(CodeBlockItemListSyntax(self.body))
}
"""
} else if effects.isAsync {
let hasReturn = self.body.contains { $0.description.contains("return ") }
let closureHead = asyncThrowsClosureHead(returnSpelling: hasReturn ? "JSValue" : nil)
body = """
\(CodeBlockItemListSyntax(asyncHoistedBindings))
let ret = JSPromise.async {\(raw: closureHead)
\(CodeBlockItemListSyntax(self.body))
}.jsObject
Expand Down Expand Up @@ -506,8 +608,47 @@ public class ExportSwift {
return decls
}

private func configureAsyncResolve(
_ builder: ExportedThunkBuilder,
returnType: BridgeType,
effects: Effects
) throws {
guard effects.isAsync else { return }
if returnType.usesAsyncPromiseResolve {
builder.asyncResolveReturnType = returnType
return
}
// Otherwise the `.jsValue` path is used, which only compiles for `ConvertibleToJSValue`
// types. Diagnose the types that have neither a `.jsValue` nor a `_bjs_makePromise`
// representation rather than emitting uncompilable code.
if Self.asyncReturnLacksBridging(returnType) {
throw BridgeJSCoreError(
"Returning '\(returnType.swiftType)' from an async exported function is not yet supported"
)
}
}

/// Whether an `async` return type can be neither lowered through `_bjs_makePromise`
/// (`usesAsyncPromiseResolve`) nor through the `.jsValue` path. Recurses through
/// `Optional`/`Array`/`Dictionary` to catch unsupported element/value types.
private static func asyncReturnLacksBridging(_ type: BridgeType) -> Bool {
switch type {
case .associatedValueEnum, .swiftProtocol, .namespaceEnum:
return true
case .nullable(let wrapped, _):
return asyncReturnLacksBridging(wrapped)
case .array(let element):
return asyncReturnLacksBridging(element)
case .dictionary(let value):
return asyncReturnLacksBridging(value)
default:
return false
}
}

func renderSingleExportedFunction(function: ExportedFunction) throws -> DeclSyntax {
let builder = ExportedThunkBuilder(effects: function.effects)
try configureAsyncResolve(builder, returnType: function.returnType, effects: function.effects)
for param in function.parameters {
try builder.liftParameter(param: param)
}
Expand Down Expand Up @@ -551,6 +692,7 @@ public class ExportSwift {
instanceSelfType: BridgeType
) throws -> DeclSyntax {
let builder = ExportedThunkBuilder(effects: method.effects)
try configureAsyncResolve(builder, returnType: method.returnType, effects: method.effects)
if !method.effects.isStatic {
try builder.liftParameter(param: Parameter(label: nil, name: "_self", type: instanceSelfType))
}
Expand Down
60 changes: 60 additions & 0 deletions Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift
Original file line number Diff line number Diff line change
Expand Up @@ -356,6 +356,27 @@ public struct BridgeJSLink {
]
}

/// Renders a `bjs[...]` settlement handler that lifts `(promise, value)` and calls
/// `promise.__bjs_resolve` / `__bjs_reject`.
private func renderPromiseSettleHandler(
externName: String,
valueType: BridgeType,
settle: String,
into printer: CodeFragmentPrinter
) throws {
let builder = ImportedThunkBuilder(
effects: Effects(isAsync: false, isThrows: true),
returnType: .void,
intrinsicRegistry: intrinsicRegistry
)
try builder.liftParameter(param: Parameter(label: nil, name: "promise", type: .jsObject(nil)))
try builder.liftParameter(param: Parameter(label: nil, name: "value", type: valueType))
builder.body.write("\(builder.parameterForwardings[0]).\(settle)(\(builder.parameterForwardings[1]));")
var lines = builder.renderFunction(name: nil)
lines[0] = "bjs[\"\(externName)\"] = \(lines[0])"
printer.write(lines: lines)
}

private func generateAddImports(needsImportsObject: Bool) throws -> CodeFragmentPrinter {
let printer = CodeFragmentPrinter()
let allStructs = skeletons.compactMap { $0.exported?.structs }.flatMap { $0 }
Expand Down Expand Up @@ -526,6 +547,45 @@ public struct BridgeJSLink {
}
}

// Settlement handlers for `async` exports returning a non-`ConvertibleToJSValue`
// type: a `promise_resolve_<mangled>` per return type, a shared `promise_reject_*`,
// and `swift_js_make_promise` once when any module needs it.
let needsPromiseHelpers = skeletons.contains {
($0.exported.map { !$0.asyncPromiseResolveReturnTypes.isEmpty }) ?? false
}
if needsPromiseHelpers {
printer.write("bjs[\"swift_js_make_promise\"] = function() {")
printer.indent {
printer.write("let resolve, reject;")
printer.write("const promise = new Promise((res, rej) => { resolve = res; reject = rej; });")
printer.write("promise.__bjs_resolve = resolve;")
printer.write("promise.__bjs_reject = reject;")
printer.write(
"return \(JSGlueVariableScope.reservedSwift).\(JSGlueVariableScope.reservedMemory).retain(promise);"
)
}
printer.write("}")
}
for skeleton in skeletons {
guard let exported = skeleton.exported else { continue }
let asyncResolveTypes = exported.asyncPromiseResolveReturnTypes
guard !asyncResolveTypes.isEmpty else { continue }
for type in asyncResolveTypes {
try renderPromiseSettleHandler(
externName: "promise_resolve_\(skeleton.moduleName)_\(type.mangleTypeName)",
valueType: type,
settle: "__bjs_resolve",
into: printer
)
}
try renderPromiseSettleHandler(
externName: "promise_reject_\(skeleton.moduleName)",
valueType: .jsValue,
settle: "__bjs_reject",
into: printer
)
}

printer.write("bjs[\"swift_js_return_optional_bool\"] = function(isSome, value) {")
printer.indent {
printer.write("if (isSome === 0) {")
Expand Down
43 changes: 43 additions & 0 deletions Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1027,6 +1027,31 @@ public struct ExportedSkeleton: Codable {
public var isEmpty: Bool {
functions.isEmpty && classes.isEmpty && enums.isEmpty && structs.isEmpty && protocols.isEmpty
}

/// The distinct non-`ConvertibleToJSValue` return types of `async` exported functions,
/// each needing a `Promise_resolve_<mangled>` helper. Deduplicated by mangled type name
/// and shared by the Swift codegen and JS link so they stay in sync.
public var asyncPromiseResolveReturnTypes: [BridgeType] {
var seen = Set<String>()
var result: [BridgeType] = []
func consider(_ returnType: BridgeType, _ effects: Effects) {
guard effects.isAsync, returnType.usesAsyncPromiseResolve,
seen.insert(returnType.mangleTypeName).inserted
else { return }
result.append(returnType)
}
for function in functions { consider(function.returnType, function.effects) }
for klass in classes {
for method in klass.methods { consider(method.returnType, method.effects) }
}
for structDef in structs {
for method in structDef.methods { consider(method.returnType, method.effects) }
}
for enumDef in enums {
for method in enumDef.staticMethods { consider(method.returnType, method.effects) }
}
return result
}
}

// MARK: - Imported Skeleton
Expand Down Expand Up @@ -1584,6 +1609,24 @@ extension BridgeType {
return false
}

/// Whether an `async` exported return of this type settles through `_bjs_makePromise`
/// rather than the `.jsValue` path. True for the non-`ConvertibleToJSValue` types the
/// bridged-parameter ABI can transfer; extend as that ABI gains support for more types.
public var usesAsyncPromiseResolve: Bool {
switch self {
case .swiftStruct, .rawValueEnum, .caseEnum:
return true
case .nullable(let wrapped, _):
return wrapped.usesAsyncPromiseResolve
case .array(let element):
return element.usesAsyncPromiseResolve
case .dictionary(let value):
return value.usesAsyncPromiseResolve
default:
return false
}
}

/// Simplified Swift ABI-style mangled name
/// https://github.com/swiftlang/swift/blob/main/docs/ABI/Mangling.rst#types
public var mangleTypeName: String {
Expand Down
Loading
Loading