Skip to content
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
ab19ba9
BridgeJS: support imports of `Promise` JS as `async` Swift
MaxDesiatov Mar 23, 2026
d741dce
E2e testing of bridging Promise<interface> returns
MaxDesiatov Mar 23, 2026
d0b207c
fix formatting
MaxDesiatov Mar 23, 2026
2b8bcaf
`JSTypedClosure`-based approach
MaxDesiatov Mar 24, 2026
7864617
Clean up `BridgeJSLink`
MaxDesiatov Mar 24, 2026
0e84003
Fix missing `import _Concurrency`
MaxDesiatov Mar 24, 2026
3044b3a
Fix formatting
MaxDesiatov Mar 24, 2026
8d4472e
Use `JSTypedClosure` without wrapping the result value
MaxDesiatov Mar 24, 2026
4244627
Merge branch 'main' into maxd/async-bridgejs
MaxDesiatov Mar 24, 2026
61fe95e
Make closure parameters as `sending`
MaxDesiatov Mar 24, 2026
cdb8013
Check more stack ABI types
MaxDesiatov Mar 26, 2026
695a715
Add support for `async` in `@JSFunction`
MaxDesiatov Mar 26, 2026
67ff8e6
Use namespaced import
MaxDesiatov Mar 26, 2026
ae831f1
Fix missing `fetchWeatherData`
MaxDesiatov Mar 26, 2026
a250ab3
Bring back `fetchWeatherData`
MaxDesiatov Mar 26, 2026
6d36763
Regenerate `fetchWeatherData` bridging code
MaxDesiatov Mar 26, 2026
b472133
BridgeJS: Centralize closure sig collection in BridgeSkeletonWalker
kateinoigakukun Mar 26, 2026
14561d4
BridgeJS: Stop spreading isAsync handling outside of CallJSEmission
kateinoigakukun Mar 26, 2026
4bfdfe7
BridgeJS: Remove error-prone default effects in thunk generation
kateinoigakukun Mar 26, 2026
a03b585
BridgeJSLink: Centralize async handling in ImportedThunkBuilder
kateinoigakukun Mar 26, 2026
22e7434
BridgeJS: Remove reundant returnType from `call` family of methods in…
kateinoigakukun Mar 26, 2026
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
15 changes: 15 additions & 0 deletions Benchmarks/Sources/Generated/JavaScript/BridgeJS.json
Original file line number Diff line number Diff line change
Expand Up @@ -2839,6 +2839,11 @@
{
"functions" : [
{
"effects" : {
"isAsync" : false,
"isStatic" : false,
"isThrows" : true
},
"name" : "benchmarkHelperNoop",
"parameters" : [

Expand All @@ -2850,6 +2855,11 @@
}
},
{
"effects" : {
"isAsync" : false,
"isStatic" : false,
"isThrows" : true
},
"name" : "benchmarkHelperNoopWithNumber",
"parameters" : [
{
Expand All @@ -2868,6 +2878,11 @@
}
},
{
"effects" : {
"isAsync" : false,
"isStatic" : false,
"isThrows" : true
},
"name" : "benchmarkRunner",
"parameters" : [
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,11 @@
{
"functions" : [
{
"effects" : {
"isAsync" : false,
"isStatic" : false,
"isThrows" : true
},
"name" : "createTS2Swift",
"parameters" : [

Expand All @@ -260,6 +265,11 @@
],
"methods" : [
{
"effects" : {
"isAsync" : false,
"isStatic" : false,
"isThrows" : true
},
"name" : "convert",
"parameters" : [
{
Expand Down
78 changes: 76 additions & 2 deletions Plugins/BridgeJS/Sources/BridgeJSCore/ClosureCodegen.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ public struct ClosureCodegen {
public init() {}

private func swiftClosureType(for signature: ClosureSignature) -> String {
let closureParams = signature.parameters.map { "\($0.closureSwiftType)" }.joined(separator: ", ")
let sendingPrefix = signature.sendingParameters ? "sending " : ""
let closureParams = signature.parameters.map { "\(sendingPrefix)\($0.closureSwiftType)" }.joined(
separator: ", "
)
let swiftEffects = (signature.isAsync ? " async" : "") + (signature.isThrows ? " throws" : "")
let swiftReturnType = signature.returnType.closureSwiftType
return "(\(closureParams))\(swiftEffects) -> \(swiftReturnType)"
Expand Down Expand Up @@ -188,7 +191,78 @@ public struct ClosureCodegen {
let collector = ClosureSignatureCollectorVisitor()
var walker = BridgeTypeWalker(visitor: collector)
walker.walk(skeleton)
let closureSignatures = walker.visitor.signatures
var closureSignatures = walker.visitor.signatures

// When async imports exist, inject closure signatures for the typed resolve
// and reject callbacks used by _bjs_awaitPromise.
// - Reject always uses (sending JSValue) -> Void
// - Resolve uses a typed closure matching the return type (or () -> Void for void)
// All async callback closures use `sending` parameters so values can be
// transferred through the checked continuation without Sendable constraints.
if let imported = skeleton.imported {
for file in imported.children {
for function in file.functions where function.effects.isAsync {
// Reject callback
closureSignatures.insert(
ClosureSignature(
parameters: [.jsValue],
returnType: .void,
moduleName: skeleton.moduleName,
sendingParameters: true
)
)
// Resolve callback (typed per return type)
if function.returnType == .void {
closureSignatures.insert(
ClosureSignature(
parameters: [],
returnType: .void,
moduleName: skeleton.moduleName
)
)
} else {
closureSignatures.insert(
ClosureSignature(
parameters: [function.returnType],
returnType: .void,
moduleName: skeleton.moduleName,
sendingParameters: true
)
)
}
}
for type in file.types {
for method in type.methods where method.effects.isAsync {
closureSignatures.insert(
ClosureSignature(
parameters: [.jsValue],
returnType: .void,
moduleName: skeleton.moduleName,
sendingParameters: true
)
)
if method.returnType == .void {
closureSignatures.insert(
ClosureSignature(
parameters: [],
returnType: .void,
moduleName: skeleton.moduleName
)
)
} else {
closureSignatures.insert(
ClosureSignature(
parameters: [method.returnType],
returnType: .void,
moduleName: skeleton.moduleName,
sendingParameters: true
)
)
}
}
}
}
}

guard !closureSignatures.isEmpty else { return nil }

Expand Down
107 changes: 91 additions & 16 deletions Plugins/BridgeJS/Sources/BridgeJSCore/ImportTS.swift
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,16 @@ public struct ImportTS {
}
}

func call() throws {
/// Prepends `resolveRef: Int32, rejectRef: Int32` parameters to the ABI parameter list.
///
/// Used for async imports where the JS side receives closure-backed
/// resolve/reject callbacks as object references.
func prependClosureCallbackParams() {
abiParameterSignatures.insert(contentsOf: [("resolveRef", .i32), ("rejectRef", .i32)], at: 0)
abiParameterForwardings.insert(contentsOf: ["resolveRef", "rejectRef"], at: 0)
}

func call(skipExceptionCheck: Bool = false) throws {
for stmt in stackLoweringStmts {
body.write(stmt.description)
}
Expand Down Expand Up @@ -243,8 +252,9 @@ public struct ImportTS {
}
}

// Add exception check for ImportTS context
if context == .importTS {
// Add exception check for ImportTS context (skipped for async, where
// errors are funneled through the JS-side reject path)
if !skipExceptionCheck && context == .importTS {
body.write("if let error = _swift_js_take_exception() { throw error }")
}
}
Expand Down Expand Up @@ -278,6 +288,41 @@ public struct ImportTS {
}
}

func liftAsyncReturnValue(originalReturnType: BridgeType) {
// For async imports, the extern function takes leading `resolveRef: Int32, rejectRef: Int32`
// and returns void. The JS side calls the resolve/reject closures when the Promise settles.
// The resolve closure is typed to match the return type, so the ABI conversion is handled
// by the existing closure codegen infrastructure — no manual JSValue-to-type switch needed.
abiReturnType = nil

// Wrap the existing body (parameter lowering + extern call) in _bjs_awaitPromise
let innerBody = body
body = CodeFragmentPrinter()

let rejectFactory = "makeRejectClosure: { JSTypedClosure<(sending JSValue) -> Void>($0) }"
if originalReturnType == .void {
let resolveFactory = "makeResolveClosure: { JSTypedClosure<() -> Void>($0) }"
body.write(
"try await _bjs_awaitPromise(\(resolveFactory), \(rejectFactory)) { resolveRef, rejectRef in"
)
} else {
let resolveSwiftType = originalReturnType.closureSwiftType
let resolveFactory =
"makeResolveClosure: { JSTypedClosure<(sending \(resolveSwiftType)) -> Void>($0) }"
body.write(
"let resolved = try await _bjs_awaitPromise(\(resolveFactory), \(rejectFactory)) { resolveRef, rejectRef in"
)
}
body.indent {
body.write(lines: innerBody.lines)
}
body.write("}")

if originalReturnType != .void {
body.write("return resolved")
}
}

func assignThis(returnType: BridgeType) {
guard case .jsObject = returnType else {
preconditionFailure("assignThis can only be called with a jsObject return type")
Expand All @@ -299,9 +344,13 @@ public struct ImportTS {
return "\(raw: printer.lines.joined(separator: "\n"))"
}

func renderThunkDecl(name: String, parameters: [Parameter], returnType: BridgeType) -> DeclSyntax {
func renderThunkDecl(
name: String,
parameters: [Parameter],
returnType: BridgeType,
effects: Effects = Effects(isAsync: false, isThrows: true)
) -> DeclSyntax {
let printer = CodeFragmentPrinter()
let effects = Effects(isAsync: false, isThrows: true)
let signature = SwiftSignatureBuilder.buildFunctionSignature(
parameters: parameters,
returnType: returnType,
Expand Down Expand Up @@ -359,22 +408,33 @@ public struct ImportTS {
_ function: ImportedFunctionSkeleton,
topLevelDecls: inout [DeclSyntax]
) throws -> [DeclSyntax] {
// For async functions, the extern returns void (the JS side resolves/rejects
// via continuation callbacks). For sync functions, use the actual return type.
let abiReturnType: BridgeType = function.effects.isAsync ? .void : function.returnType
let builder = try CallJSEmission(
moduleName: moduleName,
abiName: function.abiName(context: nil),
returnType: function.returnType
returnType: abiReturnType
)
if function.effects.isAsync {
builder.prependClosureCallbackParams()
}
for param in function.parameters {
try builder.lowerParameter(param: param)
}
try builder.call()
try builder.liftReturnValue()
try builder.call(skipExceptionCheck: function.effects.isAsync)
if function.effects.isAsync {
builder.liftAsyncReturnValue(originalReturnType: function.returnType)
} else {
try builder.liftReturnValue()
}
topLevelDecls.append(builder.renderImportDecl())
return [
builder.renderThunkDecl(
name: Self.thunkName(function: function),
parameters: function.parameters,
returnType: function.returnType
returnType: function.returnType,
effects: function.effects
)
.with(\.leadingTrivia, Self.renderDocumentation(documentation: function.documentation))
]
Expand All @@ -385,41 +445,56 @@ public struct ImportTS {
var decls: [DeclSyntax] = []

func renderMethod(method: ImportedFunctionSkeleton) throws -> [DeclSyntax] {
let abiReturnType: BridgeType = method.effects.isAsync ? .void : method.returnType
let builder = try CallJSEmission(
moduleName: moduleName,
abiName: method.abiName(context: type),
returnType: method.returnType
returnType: abiReturnType
)
if method.effects.isAsync {
builder.prependClosureCallbackParams()
}
try builder.lowerParameter(param: selfParameter)
for param in method.parameters {
try builder.lowerParameter(param: param)
}
try builder.call()
try builder.liftReturnValue()
try builder.call(skipExceptionCheck: method.effects.isAsync)
if method.effects.isAsync {
builder.liftAsyncReturnValue(originalReturnType: method.returnType)
} else {
try builder.liftReturnValue()
}
topLevelDecls.append(builder.renderImportDecl())
return [
builder.renderThunkDecl(
name: Self.thunkName(type: type, method: method),
parameters: [selfParameter] + method.parameters,
returnType: method.returnType
returnType: method.returnType,
effects: method.effects
)
]
}

func renderStaticMethod(method: ImportedFunctionSkeleton) throws -> [DeclSyntax] {
let abiName = method.abiName(context: type, operation: "static")
let builder = try CallJSEmission(moduleName: moduleName, abiName: abiName, returnType: method.returnType)
let abiReturnType: BridgeType = method.effects.isAsync ? .jsObject(nil) : method.returnType
let builder = try CallJSEmission(moduleName: moduleName, abiName: abiName, returnType: abiReturnType)
for param in method.parameters {
try builder.lowerParameter(param: param)
}
try builder.call()
try builder.liftReturnValue()
if method.effects.isAsync {
builder.liftAsyncReturnValue(originalReturnType: method.returnType)
} else {
try builder.liftReturnValue()
}
topLevelDecls.append(builder.renderImportDecl())
return [
builder.renderThunkDecl(
name: Self.thunkName(type: type, method: method),
parameters: method.parameters,
returnType: method.returnType
returnType: method.returnType,
effects: method.effects
)
]
}
Expand Down
12 changes: 9 additions & 3 deletions Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2137,7 +2137,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
let valueType: BridgeType
}

/// Validates effects (throws required, async not supported)
/// Validates effects (throws required, async only supported for @JSFunction)
private func validateEffects(
_ effects: FunctionEffectSpecifiersSyntax?,
node: some SyntaxProtocol,
Expand All @@ -2153,7 +2153,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
)
return nil
}
if effects.isAsync {
if effects.isAsync && attributeName != "JSFunction" {
errors.append(
DiagnosticError(
node: node,
Expand Down Expand Up @@ -2490,7 +2490,12 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
_ jsFunction: AttributeSyntax,
_ node: FunctionDeclSyntax,
) -> ImportedFunctionSkeleton? {
guard validateEffects(node.signature.effectSpecifiers, node: node, attributeName: "JSFunction") != nil
guard
let effects = validateEffects(
node.signature.effectSpecifiers,
node: node,
attributeName: "JSFunction"
)
else {
return nil
}
Expand All @@ -2516,6 +2521,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor {
from: from,
parameters: parameters,
returnType: returnType,
effects: effects,
documentation: nil
)
}
Expand Down
Loading
Loading