Skip to content

Commit c3a31ff

Browse files
committed
BridgeJS: Support @js protocol constraints on generic exports
The same protocol compositions now work on exported generics: @js public func storeNode<T: BridgedSwiftGenericBridgeable & GraphNode>(_ node: T) -> T { node } Unlike imports, the Swift compiler cannot check a JavaScript caller, so the constraint is enforced at the boundary: the link layer records every @js type's conformed @js protocols (declared on the type or via an extension) in a token-to-conformances map, and the generated wrapper resolves the caller's BridgeTypes token against the constraint before anything is lowered. A non-conforming token throws a catchable TypeError without entering wasm, keeping the shared value stack balanced; the Swift-side generic signature then guarantees the constraint holds inside the thunk. The d.ts wrapper signature carries 'T extends GraphNode', so TypeScript users get the check at compile time as well.
1 parent ec4cef3 commit c3a31ff

31 files changed

Lines changed: 2781 additions & 65 deletions

‎Plugins/BridgeJS/Sources/BridgeJSCore/ExportSwift.swift‎

Lines changed: 41 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -189,22 +189,24 @@ public class ExportSwift {
189189
/// When non-empty, `render(abiName:)` wraps the standard body in a
190190
/// generics prologue: the entry thunk takes one trailing type-ID ABI
191191
/// parameter per generic, recovers the concrete types from their
192-
/// `BridgeJSTypeHandle`s, and opens them into a chain of helper
192+
/// `BridgeJSTypeHandle`s (enforcing any `@JS protocol` constraints via
193+
/// a conditional metatype cast), and opens them into a chain of helper
193194
/// functions; the innermost helper runs the standard body with the
194195
/// generic parameters bound.
195-
let genericParameterNames: [String]
196+
let genericParameters: [GenericParameter]
197+
var genericParameterNames: [String] { genericParameters.map(\.name) }
196198

197199
/// The async return type settled through `_bjs_makePromise`'s `Promise_resolve_<mangled>`
198200
/// helper. Set for every `async` thunk.
199201
var asyncResolveReturnType: BridgeType?
200202

201203
var parameterBindings: [CodeBlockItemSyntax] = []
202204

203-
init(effects: Effects, returnType: BridgeType, genericParameterNames: [String] = []) throws {
205+
init(effects: Effects, returnType: BridgeType, genericParameters: [GenericParameter] = []) throws {
204206
self.effects = effects
205-
self.genericParameterNames = genericParameterNames
207+
self.genericParameters = genericParameters
206208
guard effects.isAsync else { return }
207-
guard genericParameterNames.isEmpty else {
209+
guard genericParameters.isEmpty else {
208210
throw BridgeJSCoreError("Generic exported functions cannot be 'async' yet")
209211
}
210212
guard returnType.isAsyncResolvable else {
@@ -528,16 +530,26 @@ public class ExportSwift {
528530
return abiReturnType?.swiftReturnPlaceholderStmt ?? "return"
529531
}
530532

533+
/// The `@JS protocol` constraints declared on a generic parameter.
534+
func protocolConstraints(_ genericName: String) -> [String] {
535+
genericParameters.first(where: { $0.name == genericName })?.constraints ?? []
536+
}
537+
531538
/// The constraint composition a generic parameter is opened under, e.g.
532-
/// `BridgedSwiftGenericBridgeable`. A hook for protocol constraints.
539+
/// `BridgedSwiftGenericBridgeable & GraphNode`.
533540
func constraintComposition(_ genericName: String) -> String {
534-
"BridgedSwiftGenericBridgeable"
541+
(["BridgedSwiftGenericBridgeable"] + protocolConstraints(genericName)).joined(separator: " & ")
535542
}
536543

537544
/// The existential metatype spelling used to thread a not-yet-opened
538-
/// generic parameter through the open chain.
545+
/// generic parameter through the open chain, e.g.
546+
/// `any (BridgedSwiftGenericBridgeable & GraphNode).Type`.
539547
func existentialMetatype(_ genericName: String) -> String {
540-
"any BridgedSwiftGenericBridgeable.Type"
548+
let constraints = protocolConstraints(genericName)
549+
if constraints.isEmpty {
550+
return "any BridgedSwiftGenericBridgeable.Type"
551+
}
552+
return "any (\(constraintComposition(genericName))).Type"
541553
}
542554

543555
/// Renders the wasm entry point for a generic exported function.
@@ -591,12 +603,29 @@ public class ExportSwift {
591603
// `BridgeJSTypeHandle`; recover the type from it. A raw wasm
592604
// caller passing a garbage ID is undefined behavior — the
593605
// generated JS wrapper validates tokens before calling in.
606+
let constraints = protocolConstraints(genericName)
607+
let recoveredName =
608+
constraints.isEmpty ? metatypeName(genericName) : "\(metatypeName(genericName))Base"
594609
printer.write(
595-
"let \(metatypeName(genericName)) = "
610+
"let \(recoveredName) = "
596611
+ "Unmanaged<BridgeJSTypeHandle>.fromOpaque("
597612
+ "UnsafeRawPointer(bitPattern: UInt(UInt32(bitPattern: \(typeIdName(genericName)))))!"
598613
+ ").takeUnretainedValue().type"
599614
)
615+
if !constraints.isEmpty {
616+
// Defense-in-depth: the generated JS wrapper already
617+
// rejects non-conforming tokens with a TypeError, so
618+
// this cast can only fail for a raw wasm caller.
619+
printer.write(
620+
"guard let \(metatypeName(genericName)) = \(recoveredName) as? \(existentialMetatype(genericName)) else {"
621+
)
622+
printer.indent {
623+
printer.write(
624+
"fatalError(\"BridgeJS: type '\\(\(recoveredName))' does not conform to required protocol(s): \(constraints.joined(separator: ", "))\")"
625+
)
626+
}
627+
printer.write("}")
628+
}
600629
}
601630
let open1Arguments = genericNames.map { metatypeName($0) } + concreteABINames
602631
printer.write("_\(abiName)_open1(\(open1Arguments.joined(separator: ", ")))")
@@ -776,7 +805,7 @@ public class ExportSwift {
776805
let builder = try ExportedThunkBuilder(
777806
effects: function.effects,
778807
returnType: function.returnType,
779-
genericParameterNames: function.genericParameterNames
808+
genericParameters: function.genericParameters ?? []
780809
)
781810
for param in function.parameters {
782811
try builder.liftParameter(param: param)
@@ -823,7 +852,7 @@ public class ExportSwift {
823852
let builder = try ExportedThunkBuilder(
824853
effects: method.effects,
825854
returnType: method.returnType,
826-
genericParameterNames: method.genericParameterNames
855+
genericParameters: method.genericParameters ?? []
827856
)
828857
if !method.effects.isStatic {
829858
try builder.liftParameter(param: Parameter(label: nil, name: "_self", type: instanceSelfType))

‎Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift‎

Lines changed: 90 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -302,6 +302,8 @@ public final class SwiftToSkeleton {
302302
collector.finalize(&exported)
303303
}
304304

305+
mergeExtensionDeclaredJSProtocolConformances(into: &exported)
306+
305307
perSourceErrors.append(contentsOf: diagnoseProtocolConformances(in: exported))
306308

307309
if !perSourceErrors.isEmpty {
@@ -420,6 +422,50 @@ public final class SwiftToSkeleton {
420422
return diagnostics
421423
}
422424

425+
/// Folds `extension Foo: SomeJSProtocol {}` conformances into the exported
426+
/// skeleton's `conformedJSProtocols` lists.
427+
///
428+
/// The per-declaration collectors only see the type's own inheritance
429+
/// clause, but declaring the conformance in an extension is common Swift
430+
/// style, and the JS-side generic constraint check
431+
/// (`__bjs_tokenConformances`) must not reject a type that genuinely
432+
/// conforms.
433+
private func mergeExtensionDeclaredJSProtocolConformances(into exported: inout ExportedSkeleton) {
434+
var protocolsByTarget: [String: [String]] = [:]
435+
for (sourceFile, _) in sourceFiles {
436+
let collector = ExtensionConformanceCollector(parent: self)
437+
collector.walk(sourceFile)
438+
for (target, protocols) in collector.protocolsByTarget {
439+
protocolsByTarget[target, default: []].append(contentsOf: protocols)
440+
}
441+
}
442+
guard !protocolsByTarget.isEmpty else { return }
443+
444+
func merge(_ existing: inout [String]?, additions: [String]) {
445+
var result = existing ?? []
446+
for name in additions where !result.contains(name) {
447+
result.append(name)
448+
}
449+
existing = result.isEmpty ? nil : result
450+
}
451+
452+
for index in exported.structs.indices {
453+
if let additions = protocolsByTarget[exported.structs[index].swiftCallName] {
454+
merge(&exported.structs[index].conformedJSProtocols, additions: additions)
455+
}
456+
}
457+
for index in exported.classes.indices {
458+
if let additions = protocolsByTarget[exported.classes[index].swiftCallName] {
459+
merge(&exported.classes[index].conformedJSProtocols, additions: additions)
460+
}
461+
}
462+
for index in exported.enums.indices {
463+
if let additions = protocolsByTarget[exported.enums[index].swiftCallName] {
464+
merge(&exported.enums[index].conformedJSProtocols, additions: additions)
465+
}
466+
}
467+
}
468+
423469
private static let jsTypedArrayTypealiasNames: [String: String] = [
424470
"Int8": "JSInt8Array",
425471
"UInt8": "JSUint8Array",
@@ -1553,6 +1599,20 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
15531599
}
15541600
}
15551601

1602+
/// Names of the `@JS protocol`s a type's inheritance clause declares
1603+
/// conformance to. Non-protocol entries (raw types, plain Swift protocols)
1604+
/// are ignored.
1605+
private func collectConformedJSProtocols(from inheritanceClause: InheritanceClauseSyntax?) -> [String]? {
1606+
guard let inheritanceClause else { return nil }
1607+
var names: [String] = []
1608+
for inherited in inheritanceClause.inheritedTypes {
1609+
if let name = parent.resolveJSProtocolConstraint(for: inherited.type), !names.contains(name) {
1610+
names.append(name)
1611+
}
1612+
}
1613+
return names.isEmpty ? nil : names
1614+
}
1615+
15561616
/// Shared parameter parsing logic used by functions, initializers, and protocol methods
15571617
private func parseParameters(
15581618
from parameterClause: FunctionParameterClauseSyntax,
@@ -1668,14 +1728,6 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
16681728
diagnose(node: node, message: message)
16691729
return nil
16701730
case .parsed(let genericParameter):
1671-
if !genericParameter.constraints.isEmpty {
1672-
diagnose(
1673-
node: node,
1674-
message:
1675-
"'@JS' protocol constraints on generic parameters are not yet supported on exports."
1676-
)
1677-
return nil
1678-
}
16791731
genericParameters.append(genericParameter)
16801732
}
16811733
}
@@ -2260,7 +2312,8 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
22602312
namespace: effectiveNamespace,
22612313
identityMode: classIdentityMode,
22622314
documentation: extractDocumentation(from: node),
2263-
isFinal: isFinal
2315+
isFinal: isFinal,
2316+
conformedJSProtocols: collectConformedJSProtocols(from: node.inheritanceClause)
22642317
)
22652318
let uniqueKey = makeKey(name: name, namespace: effectiveNamespace)
22662319

@@ -2426,7 +2479,8 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
24262479
emitStyle: emitStyle,
24272480
staticMethods: [],
24282481
staticProperties: [],
2429-
documentation: extractDocumentation(from: node)
2482+
documentation: extractDocumentation(from: node),
2483+
conformedJSProtocols: collectConformedJSProtocols(from: node.inheritanceClause)
24302484
)
24312485

24322486
let enumUniqueKey = makeKey(name: name, namespace: effectiveNamespace)
@@ -2653,7 +2707,8 @@ private final class ExportSwiftAPICollector: SyntaxAnyVisitor {
26532707
properties: properties,
26542708
methods: [],
26552709
namespace: effectiveNamespace,
2656-
documentation: extractDocumentation(from: node)
2710+
documentation: extractDocumentation(from: node),
2711+
conformedJSProtocols: collectConformedJSProtocols(from: node.inheritanceClause)
26572712
)
26582713

26592714
exportedStructByName[structUniqueKey] = exportedStruct
@@ -4051,3 +4106,27 @@ extension GenericArgumentListSyntax {
40514106
#endif
40524107
}
40534108
}
4109+
4110+
/// Collects `@JS protocol` conformances declared through extensions, keyed by
4111+
/// the extended type's (possibly qualified) name as written in source, which
4112+
/// matches the skeleton's `swiftCallName` for both top-level and nested types.
4113+
private final class ExtensionConformanceCollector: SyntaxVisitor {
4114+
private let parent: SwiftToSkeleton
4115+
var protocolsByTarget: [String: [String]] = [:]
4116+
4117+
init(parent: SwiftToSkeleton) {
4118+
self.parent = parent
4119+
super.init(viewMode: .sourceAccurate)
4120+
}
4121+
4122+
override func visit(_ node: ExtensionDeclSyntax) -> SyntaxVisitorContinueKind {
4123+
guard let inheritanceClause = node.inheritanceClause else { return .visitChildren }
4124+
let target = node.extendedType.trimmedDescription
4125+
for inherited in inheritanceClause.inheritedTypes {
4126+
if let name = parent.resolveJSProtocolConstraint(for: inherited.type) {
4127+
protocolsByTarget[target, default: []].append(name)
4128+
}
4129+
}
4130+
return .visitChildren
4131+
}
4132+
}

0 commit comments

Comments
 (0)