From 7d83dec089a8d59b4544b0bb47cd346f305077d4 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Thu, 30 Jul 2026 13:08:46 +0100 Subject: [PATCH 1/2] BridgeJS: support importing from external ECMAScript modules Extend `from: .module(...)` to accept bare specifiers like `node:path` or an npm package, add `jsName: .default` for default exports, and emit named imports so a wrong export name now fails at module-link time instead of at call time. --- .../BridgeJSCore/SwiftToSkeleton.swift | 175 ++++++++-- .../Sources/BridgeJSLink/BridgeJSLink.swift | 53 +-- .../ImportedJSModuleRegistry.swift | 185 ++++++++-- .../BridgeJSSkeleton/BridgeJSSkeleton.swift | 63 +++- .../BridgeJSCodegenTests.swift | 55 ++- .../BridgeJSToolTests/BridgeJSLinkTests.swift | 19 +- .../BridgeJSToolTests/DiagnosticsTests.swift | 77 ++++- .../ImportedJSModuleRegistryTests.swift | 162 +++++++++ .../MacroSwift/JSImportBareModule.swift | 22 ++ .../JSImportBareModuleFallback.swift | 12 + .../JSImportBareModule.json | 229 +++++++++++++ .../JSImportBareModule.swift | 192 +++++++++++ .../JSImportBareModuleFallback.json | 95 ++++++ .../JSImportBareModuleFallback.swift | 66 ++++ .../BridgeJSLinkTests/JSImportBareModule.d.ts | 21 ++ .../BridgeJSLinkTests/JSImportBareModule.js | 315 ++++++++++++++++++ .../JSImportBareModuleFallback.d.ts | 17 + .../JSImportBareModuleFallback.js | 259 ++++++++++++++ .../BridgeJSLinkTests/JSImportModule.js | 14 +- Plugins/PackageToJS/Sources/PackageToJS.swift | 5 +- .../Tests/PackagingPlannerTests.swift | 161 +++++++++ .../Importing-JS-Class.md | 12 + .../Importing-JS-Function.md | 12 + .../Importing-JS-Variable.md | 12 + .../Articles/BridgeJS/Unsupported-Features.md | 19 +- Sources/JavaScriptKit/Macros.swift | 51 ++- .../Generated/BridgeJS.swift | 113 +++++++ .../Generated/JavaScript/BridgeJS.json | 130 ++++++++ .../JSImportBareModuleTests.swift | 40 +++ .../Modules/DefaultExport.mjs | 6 + 30 files changed, 2462 insertions(+), 130 deletions(-) create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportedJSModuleRegistryTests.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportBareModule.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportBareModuleFallback.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModule.json create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModule.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModuleFallback.json create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModuleFallback.swift create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModule.d.ts create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModule.js create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModuleFallback.d.ts create mode 100644 Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModuleFallback.js create mode 100644 Tests/BridgeJSRuntimeTests/JSImportBareModuleTests.swift create mode 100644 Tests/BridgeJSRuntimeTests/Modules/DefaultExport.mjs diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift index 2d2a3ab6f..852012fb1 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift @@ -98,22 +98,16 @@ public final class SwiftToSkeleton { importCollector.importedFunctions.compactMap(\.from) + importCollector.importedTypes.compactMap(\.from) + importCollector.importedGlobalGetters.compactMap(\.from) - let modulePaths = Set(importOrigins.compactMap(\.modulePath)) + // Only target-local module paths are validated here. Bare specifiers are + // resolved by the JavaScript host (a bundler, an import map, or Node's + // `node_modules` lookup), so there is nothing we can check without + // rejecting setups that legitimately work. + let modulePaths = Set(importOrigins.compactMap(\.localModulePath)) for path in modulePaths.sorted() { if validatedJavaScriptModulePaths.contains(path) { continue } let pathNode = importCollector.importedModulePathNodes[path] ?? Syntax(sourceFile) - guard path.hasPrefix("/") else { - importCollector.errors.append( - DiagnosticError( - node: pathNode, - message: "JavaScript module paths must start with '/' to indicate the Swift target root: " - + "'\(path)'." - ) - ) - continue - } guard !path.split(separator: "/").contains("..") else { importCollector.errors.append( DiagnosticError( @@ -2548,21 +2542,101 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { } } - /// Extracts the `jsName` argument value from an attribute, if present. - static func extractJSName(from attribute: AttributeSyntax) -> String? { - guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else { - return nil - } - for argument in arguments { - if argument.label?.text == "jsName", - let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self), - let segment = stringLiteral.segments.first?.as(StringSegmentSyntax.self) - { - return segment.content.text - } - } + } + + /// The result of reading a `jsName:` argument. + struct ExtractedJSName { + /// The JavaScript member name to look up. + /// + /// `.default` normalizes to `"default"`: in ECMAScript a module's default + /// export *is* its `default` named export, so no separate representation + /// is needed downstream. + let memberName: String + /// True when the source spelled `.default` rather than a string literal. + let isDefaultExportSpelling: Bool + } + + /// Extracts the `jsName` argument value from an attribute, if present. + private func extractJSName(from attribute: AttributeSyntax) -> ExtractedJSName? { + guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self), + let argument = arguments.first(where: { $0.label?.text == "jsName" }) + else { return nil } + + if let stringLiteral = argument.expression.as(StringLiteralExprSyntax.self), + let value = stringLiteral.representedLiteralValue + { + return ExtractedJSName(memberName: value, isDefaultExportSpelling: false) + } + + // Accept `.default`, `JSName.default`, and the backticked spellings. + let description = argument.expression.trimmedDescription + let caseName = description.split(separator: ".").last.map(String.init) ?? description + if caseName == "default" || caseName == "`default`" { + return ExtractedJSName(memberName: "default", isDefaultExportSpelling: true) + } + + errors.append( + DiagnosticError( + node: argument.expression, + message: "jsName must be a string literal or '.default'." + ) + ) + return nil + } + + /// Validates that a `jsName: .default` spelling appears somewhere it can mean something. + /// + /// `.default` names the default export of an ECMAScript module, so it only makes + /// sense on a top-level declaration that has a `from: .module(...)` origin. + private func validateDefaultExportUsage( + _ extracted: ExtractedJSName?, + from: JSImportFrom?, + node: some SyntaxProtocol, + isSetter: Bool = false + ) { + guard let extracted, extracted.isDefaultExportSpelling else { return } + + if isSetter { + errors.append( + DiagnosticError( + node: node, + message: "'jsName: .default' is not supported on @JSSetter; " + + "ECMAScript module bindings are read-only." + ) + ) + return + } + if case .jsClassBody = state { + errors.append( + DiagnosticError( + node: node, + message: "'jsName: .default' is not supported on a class member; " + + "members have no module origin. Did you mean jsName: \"default\"?" + ) + ) + return + } + switch from { + case .module: + return + case .global: + errors.append( + DiagnosticError( + node: node, + message: "'jsName: .default' requires 'from: .module(...)'; " + + "globalThis has no default export." + ) + ) + case nil: + errors.append( + DiagnosticError( + node: node, + message: "'jsName: .default' requires 'from: .module(...)'." + ) + ) + } } private func extractJSImportFrom(from attribute: AttributeSyntax) -> JSImportFrom? { @@ -2588,6 +2662,26 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { ) return nil } + guard !path.isEmpty else { + errors.append( + DiagnosticError( + node: literal, + message: "JavaScript module specifier must not be empty." + ) + ) + return nil + } + guard !path.hasPrefix("./"), !path.hasPrefix("../"), path != ".", path != ".." else { + errors.append( + DiagnosticError( + node: literal, + message: "Relative JavaScript module specifiers are not supported: '\(path)'. " + + "Use a '/'-prefixed path for a file in this target (e.g. '/Modules/utils.mjs'), " + + "or a bare specifier for an external module (e.g. 'node:path')." + ) + ) + return nil + } if importedModulePathNodes[path] == nil { importedModulePathNodes[path] = Syntax(literal) } @@ -2645,7 +2739,9 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { return nil } - let jsName = AttributeChecker.extractJSName(from: jsSetter) + let extractedJSName = extractJSName(from: jsSetter) + validateDefaultExportUsage(extractedJSName, from: nil, node: node, isSetter: true) + let jsName = extractedJSName?.memberName let parameters = node.signature.parameterClause.parameters guard let firstParam = parameters.first else { @@ -2779,10 +2875,16 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { override func visit(_ node: StructDeclSyntax) -> SyntaxVisitorContinueKind { if AttributeChecker.hasJSClassAttribute(node.attributes) { let attribute = AttributeChecker.firstJSClassAttribute(node.attributes) - let jsName = attribute.flatMap(AttributeChecker.extractJSName) + let extractedJSName = attribute.flatMap { extractJSName(from: $0) } let from = attribute.flatMap { extractJSImportFrom(from: $0) } + validateDefaultExportUsage(extractedJSName, from: from, node: node) let accessLevel = Self.bridgeAccessLevel(from: node.modifiers) - enterJSClass(node.name.text, jsName: jsName, from: from, accessLevel: accessLevel) + enterJSClass( + node.name.text, + jsName: extractedJSName?.memberName, + from: from, + accessLevel: accessLevel + ) } return .visitChildren } @@ -2796,10 +2898,16 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { override func visit(_ node: ClassDeclSyntax) -> SyntaxVisitorContinueKind { if AttributeChecker.hasJSClassAttribute(node.attributes) { let attribute = AttributeChecker.firstJSClassAttribute(node.attributes) - let jsName = attribute.flatMap(AttributeChecker.extractJSName) + let extractedJSName = attribute.flatMap { extractJSName(from: $0) } let from = attribute.flatMap { extractJSImportFrom(from: $0) } + validateDefaultExportUsage(extractedJSName, from: from, node: node) let accessLevel = Self.bridgeAccessLevel(from: node.modifiers) - enterJSClass(node.name.text, jsName: jsName, from: from, accessLevel: accessLevel) + enterJSClass( + node.name.text, + jsName: extractedJSName?.memberName, + from: from, + accessLevel: accessLevel + ) } return .visitChildren } @@ -2990,8 +3098,10 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { } let baseName = SwiftToSkeleton.normalizeIdentifier(node.name.text) - let jsName = AttributeChecker.extractJSName(from: jsFunction) + let extractedJSName = extractJSName(from: jsFunction) let from = extractJSImportFrom(from: jsFunction) + validateDefaultExportUsage(extractedJSName, from: from, node: node) + let jsName = extractedJSName?.memberName let name = baseName let parameters = parseParameters(from: node.signature.parameterClause) @@ -3043,12 +3153,13 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { return nil } let propertyName = SwiftToSkeleton.normalizeIdentifier(identifier.identifier.text) - let jsName = AttributeChecker.extractJSName(from: jsGetter) + let extractedJSName = extractJSName(from: jsGetter) let from = extractJSImportFrom(from: jsGetter) + validateDefaultExportUsage(extractedJSName, from: from, node: node) let accessLevel = Self.bridgeAccessLevel(from: node.modifiers) return ImportedGetterSkeleton( name: propertyName, - jsName: jsName, + jsName: extractedJSName?.memberName, from: from, type: propertyType, documentation: nil, diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift index d8a1ac780..1b6300595 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift @@ -2515,7 +2515,13 @@ extension BridgeJSLink { } func callConstructor(jsName: String, swiftTypeName: String, fromObjectExpr: String) throws { - let ctorExpr = Self.propertyAccessExpr(objectExpr: fromObjectExpr, propertyName: jsName) + try callConstructor( + ctorExpr: Self.propertyAccessExpr(objectExpr: fromObjectExpr, propertyName: jsName), + swiftTypeName: swiftTypeName + ) + } + + func callConstructor(ctorExpr: String, swiftTypeName: String) throws { let call = "new \(ctorExpr)(\(parameterForwardings.joined(separator: ", ")))" let type: BridgeType = .jsObject(swiftTypeName) let loweringFragment = try IntrinsicJSFragment.lowerReturn(type: type, context: context) @@ -2580,12 +2586,18 @@ extension BridgeJSLink { } func getImportProperty(name: String, fromObjectExpr: String, returnType: BridgeType) throws { + try getImportProperty( + accessExpr: Self.propertyAccessExpr(objectExpr: fromObjectExpr, propertyName: name), + returnType: returnType + ) + } + + func getImportProperty(accessExpr expr: String, returnType: BridgeType) throws { if returnType == .void { throw BridgeJSLinkError(message: "Void is not supported for imported JS properties") } let loweringFragment = try IntrinsicJSFragment.lowerReturn(type: returnType, context: context) - let expr = Self.propertyAccessExpr(objectExpr: fromObjectExpr, propertyName: name) let returnExpr: String? if loweringFragment.parameters.count == 0 { @@ -2623,8 +2635,7 @@ extension BridgeJSLink { } static func propertyAccessExpr(objectExpr: String, propertyName: String) -> String { - if propertyName.range(of: #"^[$A-Z_][0-9A-Z_$]*$"#, options: [.regularExpression, .caseInsensitive]) != nil - { + if ImportedJSModuleRegistry.isValidJSIdentifier(propertyName) { return "\(objectExpr).\(propertyName)" } let escapedName = BridgeJSLink.escapeForJavaScriptStringLiteral(propertyName) @@ -3469,12 +3480,13 @@ extension BridgeJSLink { try thunkBuilder.liftParameter(param: param) } let jsName = function.jsName ?? function.name - let importRootExpr = try importedModuleRegistry.namespaceExpression( + let calleeExpr = try importedModuleRegistry.memberExpression( swiftModuleName: importObjectBuilder.moduleName, - from: function.from + from: function.from, + memberName: jsName ) - try thunkBuilder.call(name: jsName, fromObjectExpr: importRootExpr) + try thunkBuilder.call(calleeExpr: calleeExpr) let funcLines = thunkBuilder.renderFunction(name: function.abiName(context: nil)) if function.from == nil { importObjectBuilder.appendDts( @@ -3496,13 +3508,13 @@ extension BridgeJSLink { intrinsicRegistry: intrinsicRegistry ) let jsName = getter.jsName ?? getter.name - let importRootExpr = try importedModuleRegistry.namespaceExpression( + let accessExpr = try importedModuleRegistry.memberExpression( swiftModuleName: importObjectBuilder.moduleName, - from: getter.from + from: getter.from, + memberName: jsName ) try thunkBuilder.getImportProperty( - name: jsName, - fromObjectExpr: importRootExpr, + accessExpr: accessExpr, returnType: getter.type ) let abiName = getter.abiName(context: nil) @@ -3602,14 +3614,14 @@ extension BridgeJSLink { for param in constructor.parameters { try thunkBuilder.liftParameter(param: param) } - let importRootExpr = try importedModuleRegistry.namespaceExpression( + let ctorExpr = try importedModuleRegistry.memberExpression( swiftModuleName: importObjectBuilder.moduleName, - from: type.from + from: type.from, + memberName: type.jsName ?? type.name ) try thunkBuilder.callConstructor( - jsName: type.jsName ?? type.name, - swiftTypeName: type.name, - fromObjectExpr: importRootExpr + ctorExpr: ctorExpr, + swiftTypeName: type.name ) let abiName = constructor.abiName(context: type) let funcLines = thunkBuilder.renderFunction(name: abiName) @@ -3661,13 +3673,10 @@ extension BridgeJSLink { for param in method.parameters { try thunkBuilder.liftParameter(param: param) } - let importRootExpr = try importedModuleRegistry.namespaceExpression( + let constructorExpr = try importedModuleRegistry.memberExpression( swiftModuleName: swiftModuleName, - from: context.from - ) - let constructorExpr = ImportedThunkBuilder.propertyAccessExpr( - objectExpr: importRootExpr, - propertyName: context.jsName ?? context.name + from: context.from, + memberName: context.jsName ?? context.name ) try thunkBuilder.callStaticMethod(on: constructorExpr, name: method.jsName ?? method.name) diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift index 2c5716030..c9b8f679c 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift @@ -2,8 +2,24 @@ import BridgeJSSkeleton #endif +import Foundation + final class ImportedJSModuleRegistry { - struct Reference: Hashable { + /// A JavaScript module that imported declarations are read from. + /// + /// A `local` reference is a file inside a Swift target, which packaging copies + /// into the generated output. A `bare` reference is a specifier resolved by the + /// JavaScript host (a bundler, an import map, or Node's `node_modules` lookup), + /// so it has no file and nothing to copy. Because a bare specifier names the + /// same module no matter which Swift module mentions it — and ECMAScript caches + /// module instances — it is keyed by specifier alone and shared across targets. + enum Reference: Hashable { + case local(swiftModuleName: String, path: String) + case bare(specifier: String) + } + + /// A target-local JavaScript file that packaging must copy into the output. + struct LocalModule: Hashable { let swiftModuleName: String let path: String @@ -12,56 +28,175 @@ final class ImportedJSModuleRegistry { } } - private var aliases: [Reference: String] = [:] + private struct Binding { + let index: Int + /// Member names looked up on this module, sorted for stable output. + let members: [String] + /// Whether every member name is a valid JavaScript identifier, and so can be + /// reached with a named import instead of a namespace property lookup. + let usesNamedImports: Bool + } + + private var bindings: [Reference: Binding] = [:] private(set) var references: [Reference] = [] + /// The target-local files packaging must copy, in deterministic order. + var localModules: [LocalModule] { + references.compactMap { reference in + guard case .local(let swiftModuleName, let path) = reference else { return nil } + return LocalModule(swiftModuleName: swiftModuleName, path: path) + } + } + func configure(skeletons: [BridgeJSSkeleton]) { - aliases.removeAll(keepingCapacity: true) + bindings.removeAll(keepingCapacity: true) references = Self.collectReferences(skeletons: skeletons) + + var membersByReference: [Reference: Set] = [:] + for skeleton in skeletons { + Self.forEachMemberLookup(skeleton: skeleton) { reference, memberName in + membersByReference[reference, default: []].insert(memberName) + } + } + for (index, reference) in references.enumerated() { - aliases[reference] = "__bjs_imported_module_\(index)" + let members = (membersByReference[reference] ?? []).sorted() + bindings[reference] = Binding( + index: index, + members: members, + usesNamedImports: members.allSatisfy(Self.isValidJSIdentifier) + ) } } static func collectReferences(skeletons: [BridgeJSSkeleton]) -> [Reference] { var references = Set() for skeleton in skeletons { - for file in skeleton.imported?.children ?? [] { - let origins = - file.functions.compactMap(\.from) - + file.globalGetters.compactMap(\.from) - + file.types.compactMap(\.from) - for case .module(let path) in origins { - references.insert(Reference(swiftModuleName: skeleton.moduleName, path: path)) - } + forEachMemberLookup(skeleton: skeleton) { reference, _ in + references.insert(reference) + } + } + return references.sorted(by: isOrderedBefore) + } + + static func collectLocalModules(skeletons: [BridgeJSSkeleton]) -> [LocalModule] { + collectReferences(skeletons: skeletons).compactMap { reference in + guard case .local(let swiftModuleName, let path) = reference else { return nil } + return LocalModule(swiftModuleName: swiftModuleName, path: path) + } + } + + /// Visits every module member lookup that code generation will emit. + /// + /// The member name taken here must match what the corresponding emitter in + /// `BridgeJSLink` looks up: a class contributes a single binding that serves both + /// its constructor and its static methods, and instance methods contribute nothing + /// because they call through an already-constructed instance. + private static func forEachMemberLookup( + skeleton: BridgeJSSkeleton, + _ body: (Reference, String) -> Void + ) { + func visit(from: JSImportFrom?, memberName: String) { + guard let reference = Self.reference(swiftModuleName: skeleton.moduleName, from: from) else { return } + body(reference, memberName) + } + for file in skeleton.imported?.children ?? [] { + for function in file.functions { + visit(from: function.from, memberName: function.jsName ?? function.name) + } + for getter in file.globalGetters { + visit(from: getter.from, memberName: getter.jsName ?? getter.name) } + for type in file.types { + visit(from: type.from, memberName: type.jsName ?? type.name) + } + } + } + + private static func reference(swiftModuleName: String, from: JSImportFrom?) -> Reference? { + guard let specifier = from?.moduleSpecifier else { return nil } + if let path = from?.localModulePath { + return .local(swiftModuleName: swiftModuleName, path: path) } - return references.sorted { - ($0.swiftModuleName, $0.path) < ($1.swiftModuleName, $1.path) + return .bare(specifier: specifier) + } + + private static func isOrderedBefore(_ lhs: Reference, _ rhs: Reference) -> Bool { + switch (lhs, rhs) { + case (.local(let lhsModule, let lhsPath), .local(let rhsModule, let rhsPath)): + return (lhsModule, lhsPath) < (rhsModule, rhsPath) + case (.bare(let lhsSpecifier), .bare(let rhsSpecifier)): + return lhsSpecifier < rhsSpecifier + case (.local, .bare): + return true + case (.bare, .local): + return false } } - func namespaceExpression(swiftModuleName: String, from: JSImportFrom?) throws -> String { + /// Whether `name` can appear as a bare identifier in generated JavaScript. + static func isValidJSIdentifier(_ name: String) -> Bool { + name.range(of: #"^[$A-Z_][0-9A-Z_$]*$"#, options: [.regularExpression, .caseInsensitive]) != nil + } + + /// Returns the JavaScript expression that evaluates to `memberName` of the given origin. + func memberExpression( + swiftModuleName: String, + from: JSImportFrom?, + memberName: String + ) throws -> String { switch from { case nil: - return "imports" + return BridgeJSLink.ImportedThunkBuilder.propertyAccessExpr(objectExpr: "imports", propertyName: memberName) case .global: - return "globalThis" - case .module(let path): - let reference = Reference(swiftModuleName: swiftModuleName, path: path) - guard let alias = aliases[reference] else { + return BridgeJSLink.ImportedThunkBuilder.propertyAccessExpr( + objectExpr: "globalThis", + propertyName: memberName + ) + case .module(let specifier): + guard let reference = Self.reference(swiftModuleName: swiftModuleName, from: from), + let binding = bindings[reference] + else { throw BridgeJSLinkError( - message: "Missing JavaScript module \(swiftModuleName)\(path)" + message: "Missing JavaScript module \(swiftModuleName)\(specifier)" ) } - return alias + if binding.usesNamedImports { + return Self.namedImportBinding(index: binding.index, memberName: memberName) + } + return BridgeJSLink.ImportedThunkBuilder.propertyAccessExpr( + objectExpr: Self.namespaceAlias(index: binding.index), + propertyName: memberName + ) } } + private static func namespaceAlias(index: Int) -> String { + "__bjs_imported_module_\(index)" + } + + private static func namedImportBinding(index: Int, memberName: String) -> String { + "__bjs_import_\(index)_\(memberName)" + } + var importLines: [String] { - references.enumerated().map { index, reference in - let path = BridgeJSLink.escapeForJavaScriptStringLiteral(reference.relativeOutputPath) - return "import * as __bjs_imported_module_\(index) from \"./\(path)\";" + references.compactMap { reference in + guard let binding = bindings[reference] else { return nil } + let specifier: String + switch reference { + case .local(let swiftModuleName, let path): + let output = LocalModule(swiftModuleName: swiftModuleName, path: path).relativeOutputPath + specifier = "./" + BridgeJSLink.escapeForJavaScriptStringLiteral(output) + case .bare(let bareSpecifier): + specifier = BridgeJSLink.escapeForJavaScriptStringLiteral(bareSpecifier) + } + guard binding.usesNamedImports else { + return "import * as \(Self.namespaceAlias(index: binding.index)) from \"\(specifier)\";" + } + let clauses = binding.members.map { + "\($0) as \(Self.namedImportBinding(index: binding.index, memberName: $0))" + } + return "import { \(clauses.joined(separator: ", ")) } from \"\(specifier)\";" } } } diff --git a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift index c70ccdd8b..73ab23736 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift @@ -1126,34 +1126,71 @@ private struct AsyncClosureReturnTypeCollector: BridgeSkeletonVisitor { /// Controls where BridgeJS reads imported JS values from. /// /// - `global`: Read from `globalThis`. -/// - `module`: Read from a target-local ECMAScript module. +/// - `module`: Read from an ECMAScript module. A `/`-prefixed payload is a file +/// rooted at the Swift target directory; any other payload is a bare specifier +/// resolved by the JavaScript host (e.g. `node:path`, `lodash/fp`). public enum JSImportFrom: Codable, Equatable, Sendable { case global case module(String) + private enum CodingKeys: String, CodingKey { + case kind, specifier + } + public init(from decoder: any Decoder) throws { - let container = try decoder.singleValueContainer() - let value = try container.decode(String.self) - if value == "global" { - self = .global - } else if value.hasPrefix("/") && !value.split(separator: "/").contains("..") { + if let container = try? decoder.singleValueContainer(), let value = try? container.decode(String.self) { + if value == "global" { + self = .global + return + } + guard value.hasPrefix("/"), !value.split(separator: "/").contains("..") else { + throw DecodingError.dataCorruptedError( + in: container, + debugDescription: "Unknown import origin '\(value)'. Expected \"global\" or a rooted module path." + ) + } self = .module(value) - } else { + return + } + let container = try decoder.container(keyedBy: CodingKeys.self) + let kind = try container.decode(String.self, forKey: .kind) + guard kind == "module" else { throw DecodingError.dataCorruptedError( + forKey: .kind, in: container, - debugDescription: "Unknown import origin '\(value)'. Expected \"global\" or a rooted module path." + debugDescription: "Unknown import origin kind '\(kind)'. Expected \"module\"." ) } + self = .module(try container.decode(String.self, forKey: .specifier)) } public func encode(to encoder: any Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(modulePath ?? "global") + guard let specifier = bareModuleSpecifier else { + var container = encoder.singleValueContainer() + try container.encode(localModulePath ?? "global") + return + } + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode("module", forKey: .kind) + try container.encode(specifier, forKey: .specifier) + } + + /// The raw module specifier, whatever form it takes. + public var moduleSpecifier: String? { + guard case .module(let specifier) = self else { return nil } + return specifier + } + + /// The specifier when it denotes a file rooted at the Swift target directory. + public var localModulePath: String? { + guard let specifier = moduleSpecifier, specifier.hasPrefix("/") else { return nil } + return specifier } - public var modulePath: String? { - guard case .module(let path) = self else { return nil } - return path + /// The specifier when it is a bare specifier resolved by the JavaScript host. + public var bareModuleSpecifier: String? { + guard let specifier = moduleSpecifier, !specifier.hasPrefix("/") else { return nil } + return specifier } } diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift index 263d796a6..87b486fca 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift @@ -52,6 +52,42 @@ import Testing } } + @Test(arguments: [ + JSImportFrom.global, + JSImportFrom.module("/Modules/utils.mjs"), + JSImportFrom.module("node:path"), + JSImportFrom.module("@scope/package/sub"), + // A package literally named "global" is why bare specifiers are encoded as a + // tagged object rather than a plain string: a plain string would be + // indistinguishable from the `.global` sentinel. + JSImportFrom.module("global"), + JSImportFrom.module("#internal"), + JSImportFrom.module("https://esm.sh/lodash@4"), + ]) + func jsImportFromRoundTrips(origin: JSImportFrom) throws { + let encoded = try JSONEncoder().encode(origin) + #expect(try JSONDecoder().decode(JSImportFrom.self, from: encoded) == origin) + } + + @Test + func legacyStringFormIsPreservedForGlobalAndLocalPaths() throws { + let encoder = JSONEncoder() + #expect(String(data: try encoder.encode(JSImportFrom.global), encoding: .utf8) == #""global""#) + #expect( + String(data: try encoder.encode(JSImportFrom.module("/a.js")), encoding: .utf8) == #""\/a.js""# + ) + } + + @Test + func unknownJSImportFromKindFailsToDecode() { + #expect(throws: DecodingError.self) { + try JSONDecoder().decode( + JSImportFrom.self, + from: Data(#"{"kind": "somethingElse", "specifier": "x"}"#.utf8) + ) + } + } + private func snapshotCodegen( skeleton: BridgeJSSkeleton, name: String, @@ -105,6 +141,17 @@ import Testing ) } + /// Target-local JavaScript module files that each input pretends to have on disk. + static let existingModulePaths: [String: Set] = [ + "JSImportModule.swift": [ + "/Modules/JSImportModule.mjs", + "/Modules/ModuleCounter.mjs", + ], + "JSImportBareModule.swift": [ + "/Modules/DefaultExport.mjs" + ], + ] + static func collectInputs() -> [String] { let fileManager = FileManager.default let inputs = try! fileManager.contentsOfDirectory(atPath: Self.inputsDirectory.path) @@ -116,13 +163,7 @@ import Testing let url = Self.inputsDirectory.appendingPathComponent(input) let name = url.deletingPathExtension().lastPathComponent let sourceFile = Parser.parse(source: try String(contentsOf: url, encoding: .utf8)) - let modulePaths: Set = - input == "JSImportModule.swift" - ? [ - "/Modules/JSImportModule.mjs", - "/Modules/ModuleCounter.mjs", - ] - : [] + let modulePaths = Self.existingModulePaths[input] ?? [] let swiftAPI = SwiftToSkeleton( progress: .silent, moduleName: "TestModule", diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift index 0445fe5e1..642debedc 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift @@ -37,6 +37,17 @@ import Testing "Inputs" ).appendingPathComponent("MacroSwift") + /// Target-local JavaScript module files that each input pretends to have on disk. + static let existingModulePaths: [String: Set] = [ + "JSImportModule.swift": [ + "/Modules/JSImportModule.mjs", + "/Modules/ModuleCounter.mjs", + ], + "JSImportBareModule.swift": [ + "/Modules/DefaultExport.mjs" + ], + ] + static func collectInputs(extension: String) -> [String] { let fileManager = FileManager.default let inputs = try! fileManager.contentsOfDirectory(atPath: Self.inputsDirectory.path) @@ -49,13 +60,7 @@ import Testing let name = url.deletingPathExtension().lastPathComponent let sourceFile = Parser.parse(source: try String(contentsOf: url, encoding: .utf8)) - let modulePaths: Set = - input == "JSImportModule.swift" - ? [ - "/Modules/JSImportModule.mjs", - "/Modules/ModuleCounter.mjs", - ] - : [] + let modulePaths = Self.existingModulePaths[input] ?? [] let importSwift = SwiftToSkeleton( progress: .silent, moduleName: "TestModule", diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift index e8cf963e3..15cc8c58a 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift @@ -38,16 +38,87 @@ import Testing } @Test - func javaScriptModulePathMustStartAtTargetRoot() throws { + func bareJavaScriptModuleSpecifierIsAccepted() throws { let source = """ let unrelated = 0 - @JSFunction(from: .module("missing.js")) func imported() throws(JSException) + @JSFunction(jsName: "basename", from: .module("node:path")) func imported() throws(JSException) + """ + #expect(moduleDiagnostics(source: source) == nil) + } + + @Test + func relativeJavaScriptModuleSpecifierIsRejected() throws { + let source = """ + let unrelated = 0 + @JSFunction(from: .module("./missing.js")) func imported() throws(JSException) """ let diagnostics = try #require(moduleDiagnostics(source: source)) - #expect(diagnostics.description.contains("JavaScript module paths must start with '/'")) + #expect(diagnostics.description.contains("Relative JavaScript module specifiers are not supported")) #expect(diagnostics.description.contains("test.swift:2:27:")) } + @Test + func emptyJavaScriptModuleSpecifierIsRejected() throws { + let source = """ + let unrelated = 0 + @JSFunction(from: .module("")) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("JavaScript module specifier must not be empty.")) + } + + @Test + func defaultExportRequiresModuleOrigin() throws { + let source = """ + let unrelated = 0 + @JSFunction(jsName: .default) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("'jsName: .default' requires 'from: .module(...)'.")) + } + + @Test + func defaultExportIsRejectedForGlobalOrigin() throws { + let source = """ + let unrelated = 0 + @JSFunction(jsName: .default, from: .global) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("globalThis has no default export")) + } + + @Test + func defaultExportIsRejectedForSetter() throws { + let source = """ + @JSClass(from: .module("node:fs")) struct Wrapper { + @JSSetter(jsName: .default) func setValue(_ value: Int) throws(JSException) + } + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("ECMAScript module bindings are read-only")) + } + + @Test + func defaultExportIsRejectedForClassMember() throws { + let source = """ + @JSClass(from: .module("node:fs")) struct Wrapper { + @JSFunction(jsName: .default) func value() throws(JSException) -> Int + } + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("is not supported on a class member")) + } + + @Test + func jsNameMustBeStringLiteralOrDefault() throws { + let source = """ + let name = "basename" + @JSFunction(jsName: name, from: .module("node:path")) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("jsName must be a string literal or '.default'.")) + } + @Test func javaScriptModulePathMustNotTraverse() throws { let source = """ diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportedJSModuleRegistryTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportedJSModuleRegistryTests.swift new file mode 100644 index 000000000..25589f93c --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportedJSModuleRegistryTests.swift @@ -0,0 +1,162 @@ +import Foundation +import Testing + +@testable import BridgeJSLink +@testable import BridgeJSSkeleton + +/// Covers module-origin behavior that the single-Swift-module snapshot tests cannot reach: +/// how references from *several* Swift modules are deduplicated, ordered, and named. +@Suite struct ImportedJSModuleRegistryTests { + private func skeleton( + moduleName: String, + functions: [ImportedFunctionSkeleton] = [], + types: [ImportedTypeSkeleton] = [] + ) -> BridgeJSSkeleton { + BridgeJSSkeleton( + moduleName: moduleName, + imported: ImportedModuleSkeleton( + children: [ImportedFileSkeleton(functions: functions, types: types)] + ) + ) + } + + private func function( + _ name: String, + jsName: String? = nil, + from: JSImportFrom + ) -> ImportedFunctionSkeleton { + ImportedFunctionSkeleton( + name: name, + jsName: jsName, + from: from, + parameters: [], + returnType: .void + ) + } + + private func importLines(_ skeletons: [BridgeJSSkeleton]) throws -> [String] { + var link = BridgeJSLink(sharedMemory: false) + let encoder = JSONEncoder() + for skeleton in skeletons { + _ = try link.addSkeletonFile(data: try encoder.encode(skeleton)) + } + let js = try link.link().outputJs + return js.split(separator: "\n").map(String.init).filter { $0.hasPrefix("import ") } + } + + /// A bare specifier names the same module regardless of which Swift module mentions it, + /// so two Swift modules must share one import and one binding. + @Test func bareSpecifierIsSharedAcrossSwiftModules() throws { + let lines = try importLines([ + skeleton(moduleName: "Alpha", functions: [function("a", jsName: "basename", from: .module("node:path"))]), + skeleton(moduleName: "Beta", functions: [function("b", jsName: "dirname", from: .module("node:path"))]), + ]) + #expect(lines.count == 1) + #expect(lines[0].contains("from \"node:path\"")) + #expect(lines[0].contains("basename as ")) + #expect(lines[0].contains("dirname as ")) + } + + /// A local path is only meaningful relative to its Swift target, so the same path in two + /// Swift modules must stay two separate copies with two separate imports. + @Test func localPathIsNotSharedAcrossSwiftModules() throws { + let lines = try importLines([ + skeleton(moduleName: "Alpha", functions: [function("a", from: .module("/utils.mjs"))]), + skeleton(moduleName: "Beta", functions: [function("b", from: .module("/utils.mjs"))]), + ]) + #expect(lines.count == 2) + #expect(lines.contains { $0.contains("bridge-js-modules/Alpha/utils.mjs") }) + #expect(lines.contains { $0.contains("bridge-js-modules/Beta/utils.mjs") }) + } + + /// Local references are emitted before bare ones, each group sorted, so that the numbered + /// aliases in generated JavaScript are stable across runs. + @Test func referencesAreOrderedDeterministically() throws { + let lines = try importLines([ + skeleton( + moduleName: "Alpha", + functions: [ + function("z", jsName: "zeta", from: .module("zzz-package")), + function("a", jsName: "alpha", from: .module("aaa-package")), + function("l", from: .module("/local.mjs")), + ] + ) + ]) + #expect(lines.count == 3) + #expect(lines[0].contains("/local.mjs")) + #expect(lines[1].contains("aaa-package")) + #expect(lines[2].contains("zzz-package")) + } + + /// A class and a free function on the same specifier share one import line. + @Test func classAndFunctionOnSameSpecifierShareOneImport() throws { + let lines = try importLines([ + skeleton( + moduleName: "Alpha", + functions: [function("a", jsName: "helper", from: .module("pkg"))], + types: [ + ImportedTypeSkeleton( + name: "Widget", + from: .module("pkg"), + constructor: ImportedConstructorSkeleton(parameters: []) + ) + ] + ) + ]) + #expect(lines.count == 1) + #expect(lines[0].contains("helper as ")) + #expect(lines[0].contains("Widget as ")) + } + + /// One non-identifier export name degrades its own origin to a namespace import without + /// affecting any other origin in the same program. + @Test func namespaceFallbackIsScopedToOneOrigin() throws { + let lines = try importLines([ + skeleton( + moduleName: "Alpha", + functions: [ + function("a", jsName: "kebab-case", from: .module("weird-package")), + function("b", jsName: "join", from: .module("node:path")), + ] + ) + ]) + let weird = try #require(lines.first { $0.contains("weird-package") }) + let node = try #require(lines.first { $0.contains("node:path") }) + #expect(weird.hasPrefix("import * as ")) + #expect(node.hasPrefix("import { join as ")) + } + + /// Because a bare specifier is shared across Swift modules, its member names form a union. + /// A non-identifier name contributed by one Swift module therefore degrades the shared + /// import for the other module too. That is intended — one specifier means one import + /// statement — but it is worth pinning so the behavior is not changed by accident. + @Test func nonIdentifierNameInOneSwiftModuleDegradesTheSharedImport() throws { + let lines = try importLines([ + skeleton(moduleName: "Alpha", functions: [function("a", jsName: "join", from: .module("node:path"))]), + skeleton(moduleName: "Beta", functions: [function("b", jsName: "odd-name", from: .module("node:path"))]), + ]) + #expect(lines.count == 1) + #expect(lines[0].hasPrefix("import * as ")) + } + + /// A reserved word is a legal ECMAScript export name, and `default` is how the default + /// export is spelled, so both must survive as named imports. + @Test(arguments: ["default", "class", "import"]) + func reservedWordExportNamesUseNamedImports(memberName: String) throws { + let lines = try importLines([ + skeleton(moduleName: "Alpha", functions: [function("a", jsName: memberName, from: .module("pkg"))]) + ]) + #expect(lines.count == 1) + #expect(lines[0].hasPrefix("import { \(memberName) as ")) + } + + /// A specifier is embedded in a JavaScript string literal, so quotes and backslashes in it + /// must be escaped rather than terminating the literal. + @Test func specifierIsEscapedInTheImportLine() throws { + let lines = try importLines([ + skeleton(moduleName: "Alpha", functions: [function("a", jsName: "x", from: .module("pk\"g\\y"))]) + ]) + #expect(lines.count == 1) + #expect(lines[0].hasSuffix(#"from "pk\"g\\y";"#)) + } +} diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportBareModule.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportBareModule.swift new file mode 100644 index 000000000..543699508 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportBareModule.swift @@ -0,0 +1,22 @@ +@JSFunction(jsName: "basename", from: .module("node:path")) +func nodeBasename(_ path: String) throws(JSException) -> String + +@JSFunction(jsName: "dirname", from: .module("node:path")) +func nodeDirname(_ path: String) throws(JSException) -> String + +@JSGetter(jsName: "version", from: .module("some-package")) +var packageVersion: String + +@JSFunction(jsName: .default, from: .module("default-export-package")) +func callDefaultExport(_ value: Int) throws(JSException) -> Int + +@JSGetter(jsName: .default, from: .module("/Modules/DefaultExport.mjs")) +var localDefaultExport: JSObject + +@JSClass(jsName: "File", from: .module("@scope/package")) +struct ScopedFile { + @JSFunction init(_ value: Int) throws(JSException) + @JSFunction static func create(_ value: Int) throws(JSException) -> ScopedFile + @JSFunction func read() throws(JSException) -> Int + @JSGetter var size: Int +} diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportBareModuleFallback.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportBareModuleFallback.swift new file mode 100644 index 000000000..78671e33e --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportBareModuleFallback.swift @@ -0,0 +1,12 @@ +// `weird-package` needs a member name that is not a valid JavaScript identifier, so +// the whole origin falls back to a namespace import. `node:path` in the same file +// keeps using named imports, proving the fallback is per-origin. + +@JSFunction(jsName: "kebab-case-function", from: .module("weird-package")) +func kebabCaseFunction() throws(JSException) -> Int + +@JSGetter(jsName: "dashed-property", from: .module("weird-package")) +var dashedProperty: String + +@JSFunction(jsName: "join", from: .module("node:path")) +func joinPaths(_ lhs: String, _ rhs: String) throws(JSException) -> String diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModule.json b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModule.json new file mode 100644 index 000000000..5e005d21f --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModule.json @@ -0,0 +1,229 @@ +{ + "imported" : { + "children" : [ + { + "functions" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : { + "kind" : "module", + "specifier" : "node:path" + }, + "jsName" : "basename", + "name" : "nodeBasename", + "parameters" : [ + { + "name" : "path", + "type" : { + "string" : { + + } + } + } + ], + "returnType" : { + "string" : { + + } + } + }, + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : { + "kind" : "module", + "specifier" : "node:path" + }, + "jsName" : "dirname", + "name" : "nodeDirname", + "parameters" : [ + { + "name" : "path", + "type" : { + "string" : { + + } + } + } + ], + "returnType" : { + "string" : { + + } + } + }, + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : { + "kind" : "module", + "specifier" : "default-export-package" + }, + "jsName" : "default", + "name" : "callDefaultExport", + "parameters" : [ + { + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "globalGetters" : [ + { + "accessLevel" : "internal", + "from" : { + "kind" : "module", + "specifier" : "some-package" + }, + "jsName" : "version", + "name" : "packageVersion", + "type" : { + "string" : { + + } + } + }, + { + "accessLevel" : "internal", + "from" : "\/Modules\/DefaultExport.mjs", + "jsName" : "default", + "name" : "localDefaultExport", + "type" : { + "jsObject" : { + + } + } + } + ], + "types" : [ + { + "accessLevel" : "internal", + "constructor" : { + "accessLevel" : "internal", + "parameters" : [ + { + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ] + }, + "from" : { + "kind" : "module", + "specifier" : "@scope\/package" + }, + "getters" : [ + { + "accessLevel" : "internal", + "name" : "size", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "jsName" : "File", + "methods" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "name" : "read", + "parameters" : [ + + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "name" : "ScopedFile", + "setters" : [ + + ], + "staticMethods" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "name" : "create", + "parameters" : [ + { + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "returnType" : { + "jsObject" : { + "_0" : "ScopedFile" + } + } + } + ] + } + ] + } + ] + }, + "moduleName" : "TestModule", + "usedExternalModules" : [ + + ] +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModule.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModule.swift new file mode 100644 index 000000000..4baf8d0f0 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModule.swift @@ -0,0 +1,192 @@ +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_packageVersion_get") +fileprivate func bjs_packageVersion_get_extern() -> Int32 +#else +fileprivate func bjs_packageVersion_get_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_packageVersion_get() -> Int32 { + return bjs_packageVersion_get_extern() +} + +func _$packageVersion_get() throws(JSException) -> String { + let ret = bjs_packageVersion_get() + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_localDefaultExport_get") +fileprivate func bjs_localDefaultExport_get_extern() -> Int32 +#else +fileprivate func bjs_localDefaultExport_get_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_localDefaultExport_get() -> Int32 { + return bjs_localDefaultExport_get_extern() +} + +func _$localDefaultExport_get() throws(JSException) -> JSObject { + let ret = bjs_localDefaultExport_get() + if let error = _swift_js_take_exception() { + throw error + } + return JSObject.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_nodeBasename") +fileprivate func bjs_nodeBasename_extern(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 +#else +fileprivate func bjs_nodeBasename_extern(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_nodeBasename(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 { + return bjs_nodeBasename_extern(pathBytes, pathLength) +} + +func _$nodeBasename(_ path: String) throws(JSException) -> String { + let ret0 = path.bridgeJSWithLoweredParameter { (pathBytes, pathLength) in + let ret = bjs_nodeBasename(pathBytes, pathLength) + return ret + } + let ret = ret0 + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_nodeDirname") +fileprivate func bjs_nodeDirname_extern(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 +#else +fileprivate func bjs_nodeDirname_extern(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_nodeDirname(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 { + return bjs_nodeDirname_extern(pathBytes, pathLength) +} + +func _$nodeDirname(_ path: String) throws(JSException) -> String { + let ret0 = path.bridgeJSWithLoweredParameter { (pathBytes, pathLength) in + let ret = bjs_nodeDirname(pathBytes, pathLength) + return ret + } + let ret = ret0 + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_callDefaultExport") +fileprivate func bjs_callDefaultExport_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_callDefaultExport_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_callDefaultExport(_ value: Int32) -> Int32 { + return bjs_callDefaultExport_extern(value) +} + +func _$callDefaultExport(_ value: Int) throws(JSException) -> Int { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_callDefaultExport(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ScopedFile_init") +fileprivate func bjs_ScopedFile_init_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ScopedFile_init_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ScopedFile_init(_ value: Int32) -> Int32 { + return bjs_ScopedFile_init_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ScopedFile_create_static") +fileprivate func bjs_ScopedFile_create_static_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ScopedFile_create_static_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ScopedFile_create_static(_ value: Int32) -> Int32 { + return bjs_ScopedFile_create_static_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ScopedFile_size_get") +fileprivate func bjs_ScopedFile_size_get_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ScopedFile_size_get_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ScopedFile_size_get(_ self: Int32) -> Int32 { + return bjs_ScopedFile_size_get_extern(self) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ScopedFile_read") +fileprivate func bjs_ScopedFile_read_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ScopedFile_read_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ScopedFile_read(_ self: Int32) -> Int32 { + return bjs_ScopedFile_read_extern(self) +} + +func _$ScopedFile_init(_ value: Int) throws(JSException) -> JSObject { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ScopedFile_init(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return JSObject.bridgeJSLiftReturn(ret) +} + +func _$ScopedFile_create(_ value: Int) throws(JSException) -> ScopedFile { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ScopedFile_create_static(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return ScopedFile.bridgeJSLiftReturn(ret) +} + +func _$ScopedFile_size_get(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ScopedFile_size_get(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +func _$ScopedFile_read(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ScopedFile_read(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModuleFallback.json b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModuleFallback.json new file mode 100644 index 000000000..451d21402 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModuleFallback.json @@ -0,0 +1,95 @@ +{ + "imported" : { + "children" : [ + { + "functions" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : { + "kind" : "module", + "specifier" : "weird-package" + }, + "jsName" : "kebab-case-function", + "name" : "kebabCaseFunction", + "parameters" : [ + + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + }, + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : { + "kind" : "module", + "specifier" : "node:path" + }, + "jsName" : "join", + "name" : "joinPaths", + "parameters" : [ + { + "name" : "lhs", + "type" : { + "string" : { + + } + } + }, + { + "name" : "rhs", + "type" : { + "string" : { + + } + } + } + ], + "returnType" : { + "string" : { + + } + } + } + ], + "globalGetters" : [ + { + "accessLevel" : "internal", + "from" : { + "kind" : "module", + "specifier" : "weird-package" + }, + "jsName" : "dashed-property", + "name" : "dashedProperty", + "type" : { + "string" : { + + } + } + } + ], + "types" : [ + + ] + } + ] + }, + "moduleName" : "TestModule", + "usedExternalModules" : [ + + ] +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModuleFallback.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModuleFallback.swift new file mode 100644 index 000000000..7780b5eb0 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportBareModuleFallback.swift @@ -0,0 +1,66 @@ +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_dashedProperty_get") +fileprivate func bjs_dashedProperty_get_extern() -> Int32 +#else +fileprivate func bjs_dashedProperty_get_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_dashedProperty_get() -> Int32 { + return bjs_dashedProperty_get_extern() +} + +func _$dashedProperty_get() throws(JSException) -> String { + let ret = bjs_dashedProperty_get() + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_kebabCaseFunction") +fileprivate func bjs_kebabCaseFunction_extern() -> Int32 +#else +fileprivate func bjs_kebabCaseFunction_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_kebabCaseFunction() -> Int32 { + return bjs_kebabCaseFunction_extern() +} + +func _$kebabCaseFunction() throws(JSException) -> Int { + let ret = bjs_kebabCaseFunction() + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_joinPaths") +fileprivate func bjs_joinPaths_extern(_ lhsBytes: Int32, _ lhsLength: Int32, _ rhsBytes: Int32, _ rhsLength: Int32) -> Int32 +#else +fileprivate func bjs_joinPaths_extern(_ lhsBytes: Int32, _ lhsLength: Int32, _ rhsBytes: Int32, _ rhsLength: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_joinPaths(_ lhsBytes: Int32, _ lhsLength: Int32, _ rhsBytes: Int32, _ rhsLength: Int32) -> Int32 { + return bjs_joinPaths_extern(lhsBytes, lhsLength, rhsBytes, rhsLength) +} + +func _$joinPaths(_ lhs: String, _ rhs: String) throws(JSException) -> String { + let ret0 = lhs.bridgeJSWithLoweredParameter { (lhsBytes, lhsLength) in + let ret1 = rhs.bridgeJSWithLoweredParameter { (rhsBytes, rhsLength) in + let ret = bjs_joinPaths(lhsBytes, lhsLength, rhsBytes, rhsLength) + return ret + } + return ret1 + } + let ret = ret0 + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModule.d.ts b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModule.d.ts new file mode 100644 index 000000000..a6267bd31 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModule.d.ts @@ -0,0 +1,21 @@ +// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// `swift package bridge-js`. + +export interface ScopedFile { + read(): number; + readonly size: number; +} +export type Exports = { +} +export type Imports = { +} +export function createInstantiator(options: { + imports: Imports; +}, swift: any): Promise<{ + addImports: (importObject: WebAssembly.Imports) => void; + setInstance: (instance: WebAssembly.Instance) => void; + createExports: (instance: WebAssembly.Instance) => Exports; +}>; \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModule.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModule.js new file mode 100644 index 000000000..1c995923a --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModule.js @@ -0,0 +1,315 @@ +// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// `swift package bridge-js`. + +import { default as __bjs_import_0_default } from "./bridge-js-modules/TestModule/Modules/DefaultExport.mjs"; +import { File as __bjs_import_1_File } from "@scope/package"; +import { default as __bjs_import_2_default } from "default-export-package"; +import { basename as __bjs_import_3_basename, dirname as __bjs_import_3_dirname } from "node:path"; +import { version as __bjs_import_4_version } from "some-package"; + +export async function createInstantiator(options, swift) { + let instance; + let memory; + let setException; + let decodeString; + const textDecoder = new TextDecoder("utf-8"); + const textEncoder = new TextEncoder("utf-8"); + let tmpRetString; + let tmpRetBytes; + let tmpRetException; + let tmpRetOptionalBool; + let tmpRetOptionalInt; + let tmpRetOptionalFloat; + let tmpRetOptionalDouble; + let tmpRetOptionalHeapObject; + let strStack = []; + let i32Stack = []; + let i64Stack = []; + let f32Stack = []; + let f64Stack = []; + let ptrStack = []; + let taStack = []; + const enumHelpers = {}; + const structHelpers = {}; + + let _exports = null; + let bjs = null; + + return { + /** + * @param {WebAssembly.Imports} importObject + */ + addImports: (importObject, importsContext) => { + bjs = {}; + importObject["bjs"] = bjs; + bjs["swift_js_return_string"] = function(ptr, len) { + tmpRetString = decodeString(ptr, len); + } + bjs["swift_js_init_memory"] = function(sourceId, bytesPtr) { + const source = swift.memory.getObject(sourceId); + swift.memory.release(sourceId); + const bytes = new Uint8Array(memory.buffer, bytesPtr >>> 0); + bytes.set(source); + } + bjs["swift_js_make_js_string"] = function(ptr, len) { + return swift.memory.retain(decodeString(ptr, len)); + } + bjs["swift_js_init_memory_with_result"] = function(ptr, len) { + const target = new Uint8Array(memory.buffer, ptr >>> 0, len >>> 0); + 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); + } + bjs["swift_js_release"] = function(id) { + swift.memory.release(id); + } + bjs["swift_js_push_i32"] = function(v) { + i32Stack.push(v | 0); + } + bjs["swift_js_push_f32"] = function(v) { + f32Stack.push(Math.fround(v)); + } + bjs["swift_js_push_f64"] = function(v) { + f64Stack.push(v); + } + bjs["swift_js_push_string"] = function(ptr, len) { + const value = decodeString(ptr, len); + strStack.push(value); + } + bjs["swift_js_pop_i32"] = function() { + return i32Stack.pop(); + } + bjs["swift_js_pop_f32"] = function() { + return f32Stack.pop(); + } + bjs["swift_js_pop_f64"] = function() { + return f64Stack.pop(); + } + bjs["swift_js_push_pointer"] = function(pointer) { + ptrStack.push(pointer); + } + bjs["swift_js_pop_pointer"] = function() { + return ptrStack.pop(); + } + bjs["swift_js_push_i64"] = function(v) { + i64Stack.push(v); + } + bjs["swift_js_pop_i64"] = function() { + return i64Stack.pop(); + } + const taCtors = [Int8Array, Uint8Array, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array]; + bjs["swift_js_push_typed_array"] = function(kind, ptr, count) { + const Ctor = taCtors[kind]; + const byteLen = count * Ctor.BYTES_PER_ELEMENT; + const copy = memory.buffer.slice(ptr, ptr + byteLen); + taStack.push(Array.from(new Ctor(copy))); + } + const __bjs_promiseSettlers = Symbol("JavaScriptKit.promiseSettlers"); + bjs["swift_js_make_promise"] = function() { + let resolve, reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + promise[__bjs_promiseSettlers] = { resolve, reject }; + return swift.memory.retain(promise); + } + bjs["swift_js_return_optional_bool"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalBool = null; + } else { + tmpRetOptionalBool = value !== 0; + } + } + bjs["swift_js_return_optional_int"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalInt = null; + } else { + tmpRetOptionalInt = value | 0; + } + } + bjs["swift_js_return_optional_float"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalFloat = null; + } else { + tmpRetOptionalFloat = Math.fround(value); + } + } + bjs["swift_js_return_optional_double"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalDouble = null; + } else { + tmpRetOptionalDouble = value; + } + } + bjs["swift_js_return_optional_string"] = function(isSome, ptr, len) { + if (isSome === 0) { + tmpRetString = null; + } else { + tmpRetString = decodeString(ptr, len); + } + } + bjs["swift_js_return_optional_object"] = function(isSome, objectId) { + if (isSome === 0) { + tmpRetString = null; + } else { + tmpRetString = swift.memory.getObject(objectId); + } + } + bjs["swift_js_return_optional_heap_object"] = function(isSome, pointer) { + if (isSome === 0) { + tmpRetOptionalHeapObject = null; + } else { + tmpRetOptionalHeapObject = pointer; + } + } + bjs["swift_js_get_optional_int_presence"] = function() { + return tmpRetOptionalInt != null ? 1 : 0; + } + bjs["swift_js_get_optional_int_value"] = function() { + const value = tmpRetOptionalInt; + tmpRetOptionalInt = undefined; + return value; + } + bjs["swift_js_get_optional_string"] = function() { + const str = tmpRetString; + tmpRetString = undefined; + if (str == null) { + return -1; + } else { + const bytes = textEncoder.encode(str); + tmpRetBytes = bytes; + return bytes.length; + } + } + bjs["swift_js_get_optional_float_presence"] = function() { + return tmpRetOptionalFloat != null ? 1 : 0; + } + bjs["swift_js_get_optional_float_value"] = function() { + const value = tmpRetOptionalFloat; + tmpRetOptionalFloat = undefined; + return value; + } + bjs["swift_js_get_optional_double_presence"] = function() { + return tmpRetOptionalDouble != null ? 1 : 0; + } + bjs["swift_js_get_optional_double_value"] = function() { + const value = tmpRetOptionalDouble; + tmpRetOptionalDouble = undefined; + return value; + } + bjs["swift_js_get_optional_heap_object_pointer"] = function() { + const pointer = tmpRetOptionalHeapObject; + tmpRetOptionalHeapObject = undefined; + return pointer || 0; + } + bjs["swift_js_closure_unregister"] = function(funcRef) {} + const TestModule = importObject["TestModule"] = importObject["TestModule"] || {}; + TestModule["bjs_packageVersion_get"] = function bjs_packageVersion_get() { + try { + let ret = __bjs_import_4_version; + tmpRetBytes = textEncoder.encode(ret); + return tmpRetBytes.length; + } catch (error) { + setException(error); + } + } + TestModule["bjs_localDefaultExport_get"] = function bjs_localDefaultExport_get() { + try { + let ret = __bjs_import_0_default; + return swift.memory.retain(ret); + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_nodeBasename"] = function bjs_nodeBasename(pathBytes, pathCount) { + try { + const string = decodeString(pathBytes, pathCount); + let ret = __bjs_import_3_basename(string); + tmpRetBytes = textEncoder.encode(ret); + return tmpRetBytes.length; + } catch (error) { + setException(error); + } + } + TestModule["bjs_nodeDirname"] = function bjs_nodeDirname(pathBytes, pathCount) { + try { + const string = decodeString(pathBytes, pathCount); + let ret = __bjs_import_3_dirname(string); + tmpRetBytes = textEncoder.encode(ret); + return tmpRetBytes.length; + } catch (error) { + setException(error); + } + } + TestModule["bjs_callDefaultExport"] = function bjs_callDefaultExport(value) { + try { + let ret = __bjs_import_2_default(value); + return ret; + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ScopedFile_init"] = function bjs_ScopedFile_init(value) { + try { + return swift.memory.retain(new __bjs_import_1_File(value)); + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ScopedFile_size_get"] = function bjs_ScopedFile_size_get(self) { + try { + let ret = swift.memory.getObject(self).size; + return ret; + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ScopedFile_create_static"] = function bjs_ScopedFile_create_static(value) { + try { + let ret = __bjs_import_1_File.create(value); + return swift.memory.retain(ret); + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ScopedFile_read"] = function bjs_ScopedFile_read(self) { + try { + let ret = swift.memory.getObject(self).read(); + return ret; + } catch (error) { + setException(error); + return 0 + } + } + }, + setInstance: (i) => { + instance = i; + memory = instance.exports.memory; + + decodeString = (ptr, len) => { const bytes = new Uint8Array(memory.buffer, ptr >>> 0, len >>> 0); return textDecoder.decode(bytes); } + + setException = (error) => { + instance.exports._swift_js_exception.value = swift.memory.retain(error) + } + }, + /** @param {WebAssembly.Instance} instance */ + createExports: (instance) => { + const js = swift.memory.heap; + const exports = { + }; + _exports = exports; + return exports; + }, + } +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModuleFallback.d.ts b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModuleFallback.d.ts new file mode 100644 index 000000000..818d57a9d --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModuleFallback.d.ts @@ -0,0 +1,17 @@ +// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// `swift package bridge-js`. + +export type Exports = { +} +export type Imports = { +} +export function createInstantiator(options: { + imports: Imports; +}, swift: any): Promise<{ + addImports: (importObject: WebAssembly.Imports) => void; + setInstance: (instance: WebAssembly.Instance) => void; + createExports: (instance: WebAssembly.Instance) => Exports; +}>; \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModuleFallback.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModuleFallback.js new file mode 100644 index 000000000..038374240 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportBareModuleFallback.js @@ -0,0 +1,259 @@ +// NOTICE: This is auto-generated code by BridgeJS from JavaScriptKit, +// DO NOT EDIT. +// +// To update this file, just rebuild your project or run +// `swift package bridge-js`. + +import { join as __bjs_import_0_join } from "node:path"; +import * as __bjs_imported_module_1 from "weird-package"; + +export async function createInstantiator(options, swift) { + let instance; + let memory; + let setException; + let decodeString; + const textDecoder = new TextDecoder("utf-8"); + const textEncoder = new TextEncoder("utf-8"); + let tmpRetString; + let tmpRetBytes; + let tmpRetException; + let tmpRetOptionalBool; + let tmpRetOptionalInt; + let tmpRetOptionalFloat; + let tmpRetOptionalDouble; + let tmpRetOptionalHeapObject; + let strStack = []; + let i32Stack = []; + let i64Stack = []; + let f32Stack = []; + let f64Stack = []; + let ptrStack = []; + let taStack = []; + const enumHelpers = {}; + const structHelpers = {}; + + let _exports = null; + let bjs = null; + + return { + /** + * @param {WebAssembly.Imports} importObject + */ + addImports: (importObject, importsContext) => { + bjs = {}; + importObject["bjs"] = bjs; + bjs["swift_js_return_string"] = function(ptr, len) { + tmpRetString = decodeString(ptr, len); + } + bjs["swift_js_init_memory"] = function(sourceId, bytesPtr) { + const source = swift.memory.getObject(sourceId); + swift.memory.release(sourceId); + const bytes = new Uint8Array(memory.buffer, bytesPtr >>> 0); + bytes.set(source); + } + bjs["swift_js_make_js_string"] = function(ptr, len) { + return swift.memory.retain(decodeString(ptr, len)); + } + bjs["swift_js_init_memory_with_result"] = function(ptr, len) { + const target = new Uint8Array(memory.buffer, ptr >>> 0, len >>> 0); + 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); + } + bjs["swift_js_release"] = function(id) { + swift.memory.release(id); + } + bjs["swift_js_push_i32"] = function(v) { + i32Stack.push(v | 0); + } + bjs["swift_js_push_f32"] = function(v) { + f32Stack.push(Math.fround(v)); + } + bjs["swift_js_push_f64"] = function(v) { + f64Stack.push(v); + } + bjs["swift_js_push_string"] = function(ptr, len) { + const value = decodeString(ptr, len); + strStack.push(value); + } + bjs["swift_js_pop_i32"] = function() { + return i32Stack.pop(); + } + bjs["swift_js_pop_f32"] = function() { + return f32Stack.pop(); + } + bjs["swift_js_pop_f64"] = function() { + return f64Stack.pop(); + } + bjs["swift_js_push_pointer"] = function(pointer) { + ptrStack.push(pointer); + } + bjs["swift_js_pop_pointer"] = function() { + return ptrStack.pop(); + } + bjs["swift_js_push_i64"] = function(v) { + i64Stack.push(v); + } + bjs["swift_js_pop_i64"] = function() { + return i64Stack.pop(); + } + const taCtors = [Int8Array, Uint8Array, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array]; + bjs["swift_js_push_typed_array"] = function(kind, ptr, count) { + const Ctor = taCtors[kind]; + const byteLen = count * Ctor.BYTES_PER_ELEMENT; + const copy = memory.buffer.slice(ptr, ptr + byteLen); + taStack.push(Array.from(new Ctor(copy))); + } + const __bjs_promiseSettlers = Symbol("JavaScriptKit.promiseSettlers"); + bjs["swift_js_make_promise"] = function() { + let resolve, reject; + const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); + promise[__bjs_promiseSettlers] = { resolve, reject }; + return swift.memory.retain(promise); + } + bjs["swift_js_return_optional_bool"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalBool = null; + } else { + tmpRetOptionalBool = value !== 0; + } + } + bjs["swift_js_return_optional_int"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalInt = null; + } else { + tmpRetOptionalInt = value | 0; + } + } + bjs["swift_js_return_optional_float"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalFloat = null; + } else { + tmpRetOptionalFloat = Math.fround(value); + } + } + bjs["swift_js_return_optional_double"] = function(isSome, value) { + if (isSome === 0) { + tmpRetOptionalDouble = null; + } else { + tmpRetOptionalDouble = value; + } + } + bjs["swift_js_return_optional_string"] = function(isSome, ptr, len) { + if (isSome === 0) { + tmpRetString = null; + } else { + tmpRetString = decodeString(ptr, len); + } + } + bjs["swift_js_return_optional_object"] = function(isSome, objectId) { + if (isSome === 0) { + tmpRetString = null; + } else { + tmpRetString = swift.memory.getObject(objectId); + } + } + bjs["swift_js_return_optional_heap_object"] = function(isSome, pointer) { + if (isSome === 0) { + tmpRetOptionalHeapObject = null; + } else { + tmpRetOptionalHeapObject = pointer; + } + } + bjs["swift_js_get_optional_int_presence"] = function() { + return tmpRetOptionalInt != null ? 1 : 0; + } + bjs["swift_js_get_optional_int_value"] = function() { + const value = tmpRetOptionalInt; + tmpRetOptionalInt = undefined; + return value; + } + bjs["swift_js_get_optional_string"] = function() { + const str = tmpRetString; + tmpRetString = undefined; + if (str == null) { + return -1; + } else { + const bytes = textEncoder.encode(str); + tmpRetBytes = bytes; + return bytes.length; + } + } + bjs["swift_js_get_optional_float_presence"] = function() { + return tmpRetOptionalFloat != null ? 1 : 0; + } + bjs["swift_js_get_optional_float_value"] = function() { + const value = tmpRetOptionalFloat; + tmpRetOptionalFloat = undefined; + return value; + } + bjs["swift_js_get_optional_double_presence"] = function() { + return tmpRetOptionalDouble != null ? 1 : 0; + } + bjs["swift_js_get_optional_double_value"] = function() { + const value = tmpRetOptionalDouble; + tmpRetOptionalDouble = undefined; + return value; + } + bjs["swift_js_get_optional_heap_object_pointer"] = function() { + const pointer = tmpRetOptionalHeapObject; + tmpRetOptionalHeapObject = undefined; + return pointer || 0; + } + bjs["swift_js_closure_unregister"] = function(funcRef) {} + const TestModule = importObject["TestModule"] = importObject["TestModule"] || {}; + TestModule["bjs_dashedProperty_get"] = function bjs_dashedProperty_get() { + try { + let ret = __bjs_imported_module_1["dashed-property"]; + tmpRetBytes = textEncoder.encode(ret); + return tmpRetBytes.length; + } catch (error) { + setException(error); + } + } + TestModule["bjs_kebabCaseFunction"] = function bjs_kebabCaseFunction() { + try { + let ret = __bjs_imported_module_1["kebab-case-function"](); + return ret; + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_joinPaths"] = function bjs_joinPaths(lhsBytes, lhsCount, rhsBytes, rhsCount) { + try { + const string = decodeString(lhsBytes, lhsCount); + const string1 = decodeString(rhsBytes, rhsCount); + let ret = __bjs_import_0_join(string, string1); + tmpRetBytes = textEncoder.encode(ret); + return tmpRetBytes.length; + } catch (error) { + setException(error); + } + } + }, + setInstance: (i) => { + instance = i; + memory = instance.exports.memory; + + decodeString = (ptr, len) => { const bytes = new Uint8Array(memory.buffer, ptr >>> 0, len >>> 0); return textDecoder.decode(bytes); } + + setException = (error) => { + instance.exports._swift_js_exception.value = swift.memory.retain(error) + } + }, + /** @param {WebAssembly.Instance} instance */ + createExports: (instance) => { + const js = swift.memory.heap; + const exports = { + }; + _exports = exports; + return exports; + }, + } +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js index cb4767f03..e03dcbab4 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js @@ -4,8 +4,8 @@ // To update this file, just rebuild your project or run // `swift package bridge-js`. -import * as __bjs_imported_module_0 from "./bridge-js-modules/TestModule/Modules/JSImportModule.mjs"; -import * as __bjs_imported_module_1 from "./bridge-js-modules/TestModule/Modules/ModuleCounter.mjs"; +import { moduleAdd as __bjs_import_0_moduleAdd, renamedFunction as __bjs_import_0_renamedFunction, version as __bjs_import_0_version } from "./bridge-js-modules/TestModule/Modules/JSImportModule.mjs"; +import { ModuleCounter as __bjs_import_1_ModuleCounter } from "./bridge-js-modules/TestModule/Modules/ModuleCounter.mjs"; export async function createInstantiator(options, swift) { let instance; @@ -209,7 +209,7 @@ export async function createInstantiator(options, swift) { const TestModule = importObject["TestModule"] = importObject["TestModule"] || {}; TestModule["bjs_moduleVersion_get"] = function bjs_moduleVersion_get() { try { - let ret = __bjs_imported_module_0.version; + let ret = __bjs_import_0_version; tmpRetBytes = textEncoder.encode(ret); return tmpRetBytes.length; } catch (error) { @@ -218,7 +218,7 @@ export async function createInstantiator(options, swift) { } TestModule["bjs_moduleAdd"] = function bjs_moduleAdd(lhs, rhs) { try { - let ret = __bjs_imported_module_0.moduleAdd(lhs, rhs); + let ret = __bjs_import_0_moduleAdd(lhs, rhs); return ret; } catch (error) { setException(error); @@ -227,7 +227,7 @@ export async function createInstantiator(options, swift) { } TestModule["bjs_moduleRenamed"] = function bjs_moduleRenamed() { try { - let ret = __bjs_imported_module_0.renamedFunction(); + let ret = __bjs_import_0_renamedFunction(); tmpRetBytes = textEncoder.encode(ret); return tmpRetBytes.length; } catch (error) { @@ -236,7 +236,7 @@ export async function createInstantiator(options, swift) { } TestModule["bjs_ModuleCounter_init"] = function bjs_ModuleCounter_init(value) { try { - return swift.memory.retain(new __bjs_imported_module_1.ModuleCounter(value)); + return swift.memory.retain(new __bjs_import_1_ModuleCounter(value)); } catch (error) { setException(error); return 0 @@ -260,7 +260,7 @@ export async function createInstantiator(options, swift) { } TestModule["bjs_ModuleCounter_create_static"] = function bjs_ModuleCounter_create_static(value) { try { - let ret = __bjs_imported_module_1.ModuleCounter.create(value); + let ret = __bjs_import_1_ModuleCounter.create(value); return swift.memory.retain(ret); } catch (error) { setException(error); diff --git a/Plugins/PackageToJS/Sources/PackageToJS.swift b/Plugins/PackageToJS/Sources/PackageToJS.swift index d86a34fa1..aab6aa256 100644 --- a/Plugins/PackageToJS/Sources/PackageToJS.swift +++ b/Plugins/PackageToJS/Sources/PackageToJS.swift @@ -698,7 +698,10 @@ struct PackagingPlanner { for input in skeletons { let skeleton = try link.addSkeletonFile(data: Data(contentsOf: input.source)) - for reference in ImportedJSModuleRegistry.collectReferences(skeletons: [skeleton]) { + // Only target-local modules are files we copy. Bare specifiers (e.g. "node:path", + // "lodash") are resolved by the JavaScript host at load time, so there is + // nothing to find on disk and nothing to place in the output. + for reference in ImportedJSModuleRegistry.collectLocalModules(skeletons: [skeleton]) { guard let sourceURL = JavaScriptModulePath.resolve( reference.path, diff --git a/Plugins/PackageToJS/Tests/PackagingPlannerTests.swift b/Plugins/PackageToJS/Tests/PackagingPlannerTests.swift index e5f04402a..583fb211e 100644 --- a/Plugins/PackageToJS/Tests/PackagingPlannerTests.swift +++ b/Plugins/PackageToJS/Tests/PackagingPlannerTests.swift @@ -208,4 +208,165 @@ import Testing #expect(system.writtenFiles.filter { $0.hasSuffix("/bridge-js.js") }.count == initialLinkCount) } } + + /// A bare specifier must not suppress copying of local modules that appear alongside it. + @Test func mixedLocalAndBareModulesBothWork() throws { + try withTemporaryDirectory { temporaryDirectory, _ in + let skeleton = temporaryDirectory.appending(path: "BridgeJS.json") + let module = temporaryDirectory.appending(path: "module.mjs") + let wasm = temporaryDirectory.appending(path: "main.wasm") + let plannerSource = temporaryDirectory.appending(path: "PackageToJS.swift") + let output = temporaryDirectory.appending(path: "output") + let intermediates = temporaryDirectory.appending(path: "intermediates") + + let bridgeSkeleton = BridgeJSSkeleton( + moduleName: "TestModule", + imported: ImportedModuleSkeleton( + children: [ + ImportedFileSkeleton( + functions: [ + ImportedFunctionSkeleton( + name: "value", + from: .module("/module.mjs"), + parameters: [], + returnType: .void + ), + ImportedFunctionSkeleton( + name: "basename", + from: .module("node:path"), + parameters: [], + returnType: .void + ), + ], + types: [] + ) + ] + ) + ) + try JSONEncoder().encode(bridgeSkeleton).write(to: skeleton) + try Data("export const value = 1;\n".utf8).write(to: module) + try Data([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]).write(to: wasm) + try Data().write(to: plannerSource) + + let system = TestPackagingSystem() + let planner = PackagingPlanner( + options: PackageToJS.PackageOptions(), + packageId: "test", + intermediatesDir: BuildPath(absolute: intermediates.path), + selfPackageDir: BuildPath( + absolute: URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .path + ), + skeletons: [.init(source: skeleton, targetDirectory: temporaryDirectory)], + outputDir: BuildPath(absolute: output.path), + wasmProductArtifact: BuildPath(absolute: wasm.path), + wasmFilename: "main.wasm", + configuration: "debug", + triple: "wasm32-unknown-wasi", + selfPath: BuildPath(absolute: plannerSource.path), + system: system + ) + var make = MiniMake(printProgress: { _, _ in }) + let root = try planner.planBuild( + make: &make, + buildOptions: PackageToJS.BuildOptions( + product: "test", + noOptimize: false, + debugInfoFormat: .none, + packageOptions: PackageToJS.PackageOptions() + ) + ) + try make.build(output: root, scope: MiniMake.VariableScope(variables: [:])) + + let copiedModule = output.appending(path: "bridge-js-modules/TestModule/module.mjs") + #expect(try String(contentsOf: copiedModule, encoding: .utf8) == "export const value = 1;\n") + let generated = try String(contentsOf: output.appending(path: "bridge-js.js"), encoding: .utf8) + #expect(generated.contains("from \"node:path\"")) + #expect(generated.contains("bridge-js-modules/TestModule/module.mjs")) + } + } + + /// A bare specifier such as "node:path" is resolved by the JavaScript host at load + /// time, so packaging must not look for a file on disk or copy anything for it. + @Test func bareJavaScriptModuleIsNotCopied() throws { + try withTemporaryDirectory { temporaryDirectory, _ in + let skeleton = temporaryDirectory.appending(path: "BridgeJS.json") + let wasm = temporaryDirectory.appending(path: "main.wasm") + let plannerSource = temporaryDirectory.appending(path: "PackageToJS.swift") + let output = temporaryDirectory.appending(path: "output") + let intermediates = temporaryDirectory.appending(path: "intermediates") + + let bridgeSkeleton = BridgeJSSkeleton( + moduleName: "TestModule", + imported: ImportedModuleSkeleton( + children: [ + ImportedFileSkeleton( + functions: [ + ImportedFunctionSkeleton( + name: "basename", + from: .module("node:path"), + parameters: [], + returnType: .void + ) + ], + types: [] + ) + ] + ) + ) + try JSONEncoder().encode(bridgeSkeleton).write(to: skeleton) + try Data([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]).write(to: wasm) + try Data().write(to: plannerSource) + + let system = TestPackagingSystem() + let planner = PackagingPlanner( + options: PackageToJS.PackageOptions(), + packageId: "test", + intermediatesDir: BuildPath(absolute: intermediates.path), + selfPackageDir: BuildPath( + absolute: URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .path + ), + skeletons: [ + .init( + source: skeleton, + targetDirectory: temporaryDirectory + ) + ], + outputDir: BuildPath(absolute: output.path), + wasmProductArtifact: BuildPath(absolute: wasm.path), + wasmFilename: "main.wasm", + configuration: "debug", + triple: "wasm32-unknown-wasi", + selfPath: BuildPath(absolute: plannerSource.path), + system: system + ) + var make = MiniMake(printProgress: { _, _ in }) + let root = try planner.planBuild( + make: &make, + buildOptions: PackageToJS.BuildOptions( + product: "test", + noOptimize: false, + debugInfoFormat: .none, + packageOptions: PackageToJS.PackageOptions() + ) + ) + try make.build(output: root, scope: MiniMake.VariableScope(variables: [:])) + + #expect(!FileManager.default.fileExists(atPath: output.appending(path: "bridge-js-modules").path)) + let generated = try String( + contentsOf: output.appending(path: "bridge-js.js"), + encoding: .utf8 + ) + #expect(generated.contains("from \"node:path\"")) + } + } } diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Class.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Class.md index 185b47d1f..dcbe2a5c6 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Class.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Class.md @@ -44,6 +44,18 @@ struct Greeter { } ``` +A specifier without a leading `/` is passed to the JavaScript module resolver verbatim, so you can wrap a class exported by an installed npm package. Pass `jsName: .default` when the class is the module's default export: + +```swift +@JSClass(jsName: "File", from: .module("@bjorn3/browser_wasi_shim")) +struct WasiFile { + @JSFunction init(_ data: JSObject) throws(JSException) + @JSGetter var size: Int64 +} +``` + +Nothing is copied for these, and you are responsible for making them resolvable at load time — see . + The path's leading `/` denotes the Swift target root, not the filesystem root. The module's named class export is the root for construction and static methods. Instance methods, getters, and setters operate on the wrapped object and must not specify their own `from:` argument. Use `jsName` on `@JSClass` to select a differently named class export. JavaScript inheritance may be implemented normally in the module; the Swift declaration describes the API visible on the exported class and its instances. ### 2. Wire the JavaScript side diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Function.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Function.md index c57aeda03..63e31c6bf 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Function.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Function.md @@ -31,6 +31,18 @@ func add(_ a: Double, _ b: Double) throws(JSException) -> Double The leading `/` denotes the Swift target root, not the filesystem root. BridgeJS copies explicitly referenced modules into the generated PackageToJS package. Multiple declarations may reference the same file; it is copied and imported only once. `jsName` selects a differently named export, otherwise BridgeJS uses the normalized Swift name. +Any specifier *without* a leading `/` is passed to the JavaScript module resolver verbatim, which is how you call a Node builtin or an installed npm package: + +```swift +@JSFunction(jsName: "basename", from: .module("node:path")) +func basename(_ path: String) throws(JSException) -> String + +@JSFunction(jsName: "chunk", from: .module("lodash/fp")) +func chunk(_ input: JSObject, _ size: Int) throws(JSException) -> JSObject +``` + +Nothing is copied for these, and you are responsible for making them resolvable at load time — see for what that entails. To call the module's default export instead of a named one, pass `jsName: .default`. + SwiftPM does not know what to do with `.js`/`.mjs` files inside a target, so exclude the directory holding them to avoid an "unhandled files" warning: ```swift diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Variable.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Variable.md index 6825875dd..39f56652a 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Variable.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript/Importing-JS-Variable.md @@ -31,6 +31,18 @@ var currentEnvironment: String The path's leading `/` denotes the Swift target root, not the filesystem root. Module exports are read-only through this API. Top-level `@JSSetter` remains unsupported. +A specifier without a leading `/` is passed to the JavaScript module resolver verbatim, so a getter can also read from a Node builtin or an installed npm package. Pass `jsName: .default` to read the module's default export, which is how many npm packages expose their main value: + +```swift +@JSGetter(jsName: "version", from: .module("some-package")) +var packageVersion: String + +@JSGetter(jsName: .default, from: .module("some-package")) +var packageDefault: JSObject +``` + +Nothing is copied for these, and you are responsible for making them resolvable at load time — see . + ### 2. Add a setter for writable variables (optional) If the JavaScript property is writable and you need to set it from Swift, add a corresponding `@JSSetter` function. Property setters are exposed as functions (e.g. `setMyConfig(_:)`) because Swift property setters cannot `throw`. diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md index 238bc8687..09aa79959 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md @@ -6,13 +6,24 @@ Limitations and unsupported patterns when using BridgeJS. BridgeJS generates glue code per Swift target (module). Some patterns that are valid in Swift or TypeScript are not supported across the bridge today. This article summarizes the main limitations so you can design your APIs accordingly. -## File-backed JavaScript modules +## JavaScript modules -Files referenced by `JSImportFrom.module` must be nonempty `.js` or `.mjs` paths beginning with `/`. This leading slash denotes the Swift target root, not the filesystem root. Files must remain within that Swift target. Only explicitly referenced files are copied. BridgeJS does not discover or rewrite an imported module's dependency graph, so referenced files should currently be self-contained. +`JSImportFrom.module` accepts two forms, distinguished by a leading `/`. -Generated packages use static ECMAScript module imports. This works with the existing PackageToJS browser and Node ESM entry points. CommonJS and classic non-module script output are not generated or translated. +A **target-local file** is a `/`-prefixed `.js` or `.mjs` path, such as `.module("/Modules/utils.mjs")`. The leading slash denotes the Swift target root, not the filesystem root, and the file must remain within that Swift target. Only explicitly referenced files are copied into the generated package. BridgeJS does not discover or rewrite an imported module's dependency graph, so referenced files should currently be self-contained. -Module origins apply to top-level `@JSFunction`, top-level `@JSGetter`, and an entire `@JSClass`. Per-member origins, top-level setters, inline JavaScript source, package-root-relative paths, and per-member module overrides are not supported. +An **external module** is any other value, passed to the JavaScript module resolver verbatim — for example `.module("node:path")`, `.module("lodash/fp")`, or `.module("@scope/package")`. Because resolution is host-defined, BridgeJS deliberately performs no build-time validation of these specifiers beyond rejecting the empty string and relative specifiers (`./x`, `../x`); use the `/`-prefixed form to reference your own files. This has several consequences you are responsible for: + +- Importing a `node:`-prefixed builtin makes the generated package Node-only. It will fail to load in a browser. +- An npm package must be resolvable at load time — either from the generated output directory (Node walks up to the nearest `node_modules`), or through your bundler's aliasing or an import map. +- BridgeJS does not add anything to the generated `package.json`. Installing the dependency is up to you, and no version is inferred. +- A specifier is not verified to exist until the JavaScript host loads the generated module, so a typo surfaces as a resolution error from your JavaScript toolchain rather than a Swift compile error. + +Generated packages use static ECMAScript module imports. This works with the existing PackageToJS browser and Node ESM entry points. CommonJS and classic non-module script output are not generated or translated. Note that named exports of a CommonJS package are only importable when Node can statically detect them; when in doubt, use `jsName: .default` and reach members through the default export. + +Module origins apply to top-level `@JSFunction`, top-level `@JSGetter`, and an entire `@JSClass`. Per-member origins, top-level setters, inline JavaScript source, package-root-relative paths, and per-member module overrides are not supported. `jsName: .default` is likewise only valid on those three declaration forms and only together with `from: .module(...)`; it cannot be used on `@JSSetter`, because ECMAScript module bindings are read-only. + +The TypeScript-definition workflow (`bridge-js.d.ts`) always imports from `globalThis` and cannot yet target a module origin. To import from a module, declare the API with the macros instead. ## Type usage crossing module boundary diff --git a/Sources/JavaScriptKit/Macros.swift b/Sources/JavaScriptKit/Macros.swift index 7a1bb4091..96f942875 100644 --- a/Sources/JavaScriptKit/Macros.swift +++ b/Sources/JavaScriptKit/Macros.swift @@ -9,13 +9,40 @@ public enum JSEnumStyle: String { /// Controls where BridgeJS reads imported JS values from. /// /// - `global`: Read from `globalThis`. -/// - `module`: Read a named export from an ECMAScript module file rooted at the Swift target. +/// - `module`: Read from an ECMAScript module. public enum JSImportFrom { case global - /// Read from an ECMAScript module file using a `/`-prefixed path rooted at the Swift target directory. + /// Read from an ECMAScript module. + /// + /// A `/`-prefixed value is a path to a JavaScript file rooted at the Swift + /// target directory, e.g. `.module("/Modules/utils.mjs")`. Any other value + /// is passed to the JavaScript module resolver verbatim, which covers + /// Node built-in modules and installed packages, e.g. `.module("node:path")` + /// or `.module("lodash/fp")`. + /// + /// Relative specifiers such as `"./utils.mjs"` are not supported; use the + /// `/`-prefixed form to reference a file in your own target. case module(String) } +/// Names the JavaScript member that an imported declaration refers to. +/// +/// A string literal is accepted directly, so `jsName: "basename"` means +/// ``JSName/name(_:)`` with that value. +public enum JSName: ExpressibleByStringLiteral { + /// A member looked up by name. + case name(String) + /// The default export of an ECMAScript module. + /// + /// Only valid on a top-level `@JSFunction`, `@JSGetter`, or `@JSClass` + /// that also specifies `from: .module(...)`. + case `default` + + public init(stringLiteral value: String) { + self = .name(value) + } +} + /// A macro that exposes Swift functions, classes, and methods to JavaScript. /// /// Apply this macro to Swift declarations that you want to make callable from JavaScript: @@ -144,9 +171,11 @@ public macro JS( /// /// - Parameter from: Selects where the property is read from. /// Use `.global` to read from `globalThis` (e.g. `console`, `document`). -/// Use `.module("/path/to/module.js")` to read a named export from a file rooted at the Swift target. +/// Use `.module("/path/to/module.js")` to read a named export from a file rooted at the Swift target, +/// or `.module("node:os")` to read a named export from an external module. +/// Pass `jsName: .default` to read the module's default export. @attached(accessor) -public macro JSGetter(jsName: String? = nil, from: JSImportFrom? = nil) = +public macro JSGetter(jsName: JSName? = nil, from: JSImportFrom? = nil) = #externalMacro(module: "BridgeJSMacros", type: "JSGetterMacro") /// A macro that generates a Swift function body that writes a value to JavaScript. @@ -164,7 +193,7 @@ public macro JSGetter(jsName: String? = nil, from: JSImportFrom? = nil) = /// @JSSetter func setName(_ value: String) throws (JSException) /// ``` @attached(body) -public macro JSSetter(jsName: String? = nil, from: JSImportFrom? = nil) = +public macro JSSetter(jsName: JSName? = nil, from: JSImportFrom? = nil) = #externalMacro(module: "BridgeJSMacros", type: "JSSetterMacro") /// A macro that generates a Swift function body that calls a JavaScript function. @@ -184,9 +213,11 @@ public macro JSSetter(jsName: String? = nil, from: JSImportFrom? = nil) = /// If not provided, the Swift function name is used. /// - Parameter from: Selects where the function is looked up from. /// Use `.global` to call a function on `globalThis` (e.g. `setTimeout`). -/// Use `.module("/path/to/module.js")` to call a named export from a file rooted at the Swift target. +/// Use `.module("/path/to/module.js")` to call a named export from a file rooted at the Swift target, +/// or `.module("node:path")` to call a named export from an external module. +/// Pass `jsName: .default` to call the module's default export. @attached(body) -public macro JSFunction(jsName: String? = nil, from: JSImportFrom? = nil) = +public macro JSFunction(jsName: JSName? = nil, from: JSImportFrom? = nil) = #externalMacro(module: "BridgeJSMacros", type: "JSFunctionMacro") /// A macro that adds bridging members for a Swift type that represents a JavaScript class. @@ -209,8 +240,10 @@ public macro JSFunction(jsName: String? = nil, from: JSImportFrom? = nil) = /// /// - Parameter from: Selects where the constructor is looked up from. /// Use `.global` to construct globals like `WebSocket` via `globalThis`. -/// Use `.module("/path/to/module.js")` to construct a named class export from a file rooted at the Swift target. +/// Use `.module("/path/to/module.js")` to construct a named class export from a file rooted at the Swift target, +/// or `.module("@scope/package")` to construct a named class export from an external module. +/// Pass `jsName: .default` to construct the module's default export. @attached(member, names: named(jsObject), named(init(unsafelyWrapping:))) @attached(extension, conformances: _JSBridgedClass) -public macro JSClass(jsName: String? = nil, from: JSImportFrom? = nil) = +public macro JSClass(jsName: JSName? = nil, from: JSImportFrom? = nil) = #externalMacro(module: "BridgeJSMacros", type: "JSClassMacro") diff --git a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift index 39de49ca0..5337e923a 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift +++ b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift @@ -17010,6 +17010,119 @@ func _$JSClassSupportImports_makeJSClassWithArrayMembers(_ numbers: [Int], _ lab return JSClassWithArrayMembers.bridgeJSLiftReturn(ret) } +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_defaultExport_get") +fileprivate func bjs_defaultExport_get_extern() -> Int32 +#else +fileprivate func bjs_defaultExport_get_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_defaultExport_get() -> Int32 { + return bjs_defaultExport_get_extern() +} + +func _$defaultExport_get() throws(JSException) -> JSObject { + let ret = bjs_defaultExport_get() + if let error = _swift_js_take_exception() { + throw error + } + return JSObject.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_nodeBasename") +fileprivate func bjs_nodeBasename_extern(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 +#else +fileprivate func bjs_nodeBasename_extern(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_nodeBasename(_ pathBytes: Int32, _ pathLength: Int32) -> Int32 { + return bjs_nodeBasename_extern(pathBytes, pathLength) +} + +func _$nodeBasename(_ path: String) throws(JSException) -> String { + let ret0 = path.bridgeJSWithLoweredParameter { (pathBytes, pathLength) in + let ret = bjs_nodeBasename(pathBytes, pathLength) + return ret + } + let ret = ret0 + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_nodeJoin") +fileprivate func bjs_nodeJoin_extern(_ lhsBytes: Int32, _ lhsLength: Int32, _ rhsBytes: Int32, _ rhsLength: Int32) -> Int32 +#else +fileprivate func bjs_nodeJoin_extern(_ lhsBytes: Int32, _ lhsLength: Int32, _ rhsBytes: Int32, _ rhsLength: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_nodeJoin(_ lhsBytes: Int32, _ lhsLength: Int32, _ rhsBytes: Int32, _ rhsLength: Int32) -> Int32 { + return bjs_nodeJoin_extern(lhsBytes, lhsLength, rhsBytes, rhsLength) +} + +func _$nodeJoin(_ lhs: String, _ rhs: String) throws(JSException) -> String { + let ret0 = lhs.bridgeJSWithLoweredParameter { (lhsBytes, lhsLength) in + let ret1 = rhs.bridgeJSWithLoweredParameter { (rhsBytes, rhsLength) in + let ret = bjs_nodeJoin(lhsBytes, lhsLength, rhsBytes, rhsLength) + return ret + } + return ret1 + } + let ret = ret0 + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_WasiFile_init") +fileprivate func bjs_WasiFile_init_extern(_ data: Int32) -> Int32 +#else +fileprivate func bjs_WasiFile_init_extern(_ data: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_WasiFile_init(_ data: Int32) -> Int32 { + return bjs_WasiFile_init_extern(data) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_WasiFile_size_get") +fileprivate func bjs_WasiFile_size_get_extern(_ self: Int32) -> Int64 +#else +fileprivate func bjs_WasiFile_size_get_extern(_ self: Int32) -> Int64 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_WasiFile_size_get(_ self: Int32) -> Int64 { + return bjs_WasiFile_size_get_extern(self) +} + +func _$WasiFile_init(_ data: JSObject) throws(JSException) -> JSObject { + let dataValue = data.bridgeJSLowerParameter() + let ret = bjs_WasiFile_init(dataValue) + if let error = _swift_js_take_exception() { + throw error + } + return JSObject.bridgeJSLiftReturn(ret) +} + +func _$WasiFile_size_get(_ self: JSObject) throws(JSException) -> Int64 { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_WasiFile_size_get(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int64.bridgeJSLiftReturn(ret) +} + #if arch(wasm32) @_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_moduleVersion_get") fileprivate func bjs_moduleVersion_get_extern() -> Int32 diff --git a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json index e0c30c428..dbab1c0ea 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json +++ b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json @@ -24174,6 +24174,136 @@ } ] }, + { + "functions" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : { + "kind" : "module", + "specifier" : "node:path" + }, + "jsName" : "basename", + "name" : "nodeBasename", + "parameters" : [ + { + "name" : "path", + "type" : { + "string" : { + + } + } + } + ], + "returnType" : { + "string" : { + + } + } + }, + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : { + "kind" : "module", + "specifier" : "node:path" + }, + "jsName" : "join", + "name" : "nodeJoin", + "parameters" : [ + { + "name" : "lhs", + "type" : { + "string" : { + + } + } + }, + { + "name" : "rhs", + "type" : { + "string" : { + + } + } + } + ], + "returnType" : { + "string" : { + + } + } + } + ], + "globalGetters" : [ + { + "accessLevel" : "internal", + "from" : "\/Modules\/DefaultExport.mjs", + "jsName" : "default", + "name" : "defaultExport", + "type" : { + "jsObject" : { + + } + } + } + ], + "types" : [ + { + "accessLevel" : "internal", + "constructor" : { + "accessLevel" : "internal", + "parameters" : [ + { + "name" : "data", + "type" : { + "jsObject" : { + + } + } + } + ] + }, + "from" : { + "kind" : "module", + "specifier" : "@bjorn3\/browser_wasi_shim" + }, + "getters" : [ + { + "accessLevel" : "internal", + "name" : "size", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "w64" + } + } + } + } + ], + "jsName" : "File", + "methods" : [ + + ], + "name" : "WasiFile", + "setters" : [ + + ], + "staticMethods" : [ + + ] + } + ] + }, { "functions" : [ { diff --git a/Tests/BridgeJSRuntimeTests/JSImportBareModuleTests.swift b/Tests/BridgeJSRuntimeTests/JSImportBareModuleTests.swift new file mode 100644 index 000000000..04be8530c --- /dev/null +++ b/Tests/BridgeJSRuntimeTests/JSImportBareModuleTests.swift @@ -0,0 +1,40 @@ +import JavaScriptKit +import XCTest + +// A Node built-in module. Nothing is copied into the generated package for this; +// the generated JavaScript imports "node:path" directly and Node resolves it. +@JSFunction(jsName: "basename", from: .module("node:path")) +func nodeBasename(_ path: String) throws(JSException) -> String + +@JSFunction(jsName: "join", from: .module("node:path")) +func nodeJoin(_ lhs: String, _ rhs: String) throws(JSException) -> String + +// An npm package, resolved through node_modules rather than being built into the runtime. +@JSClass(jsName: "File", from: .module("@bjorn3/browser_wasi_shim")) +struct WasiFile { + @JSFunction init(_ data: JSObject) throws(JSException) + @JSGetter var size: Int64 +} + +// The default export of a module, reached with `jsName: .default`. +@JSGetter(jsName: .default, from: .module("/Modules/DefaultExport.mjs")) +var defaultExport: JSObject + +final class JSImportBareModuleTests: XCTestCase { + func testNodeBuiltinModule() throws { + XCTAssertEqual(try nodeBasename("/a/b/c.txt"), "c.txt") + XCTAssertEqual(try nodeJoin("a", "b"), "a/b") + } + + func testNpmPackageClass() throws { + let bytes = JSObject.global.Uint8Array.function!.new(3) + let file = try WasiFile(bytes) + XCTAssertEqual(try file.size, 3) + } + + func testDefaultExport() throws { + let module = try defaultExport + XCTAssertEqual(module.label.string, "from the default export") + XCTAssertEqual(module.triple!(7).number, 21) + } +} diff --git a/Tests/BridgeJSRuntimeTests/Modules/DefaultExport.mjs b/Tests/BridgeJSRuntimeTests/Modules/DefaultExport.mjs new file mode 100644 index 000000000..295129c36 --- /dev/null +++ b/Tests/BridgeJSRuntimeTests/Modules/DefaultExport.mjs @@ -0,0 +1,6 @@ +export default { + label: "from the default export", + triple(value) { + return value * 3; + }, +}; From 02b8bce33b4547a96aebcbf08861c1700cd8c6cf Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Thu, 30 Jul 2026 13:37:09 +0100 Subject: [PATCH 2/2] BridgeJS: fix named-import regressions found in review Do not require a module export for a wrapper-only `@JSClass`, since a named import is a link-time requirement and nothing looks that name up; accept `jsName: nil` and the explicit `.name(...)` spelling; and validate the tagged `from` form like the plain-string form. --- .../BridgeJSCore/SwiftToSkeleton.swift | 24 ++++++++ .../ImportedJSModuleRegistry.swift | 42 +++++++++++-- .../BridgeJSSkeleton/BridgeJSSkeleton.swift | 29 ++++++++- .../BridgeJSCodegenTests.swift | 13 ++++ .../BridgeJSToolTests/DiagnosticsTests.swift | 20 +++++++ .../ImportedJSModuleRegistryTests.swift | 60 +++++++++++++++++++ .../Articles/BridgeJS/Unsupported-Features.md | 2 + 7 files changed, 184 insertions(+), 6 deletions(-) diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift index 852012fb1..70a2f0525 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift @@ -2570,6 +2570,30 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { return ExtractedJSName(memberName: value, isDefaultExportSpelling: false) } + // An explicit `jsName: nil` means the same as omitting the argument. + if argument.expression.is(NilLiteralExprSyntax.self) { + return nil + } + + // Accept the explicit `.name("...")` spelling of a plain member name. + if let call = argument.expression.as(FunctionCallExprSyntax.self), + call.calledExpression.trimmedDescription.split(separator: ".").last == "name" + { + guard call.arguments.count == 1, + let literal = call.arguments.first?.expression.as(StringLiteralExprSyntax.self), + let value = literal.representedLiteralValue + else { + errors.append( + DiagnosticError( + node: call.arguments.first?.expression ?? argument.expression, + message: "jsName must be a string literal or '.default'." + ) + ) + return nil + } + return ExtractedJSName(memberName: value, isDefaultExportSpelling: false) + } + // Accept `.default`, `JSName.default`, and the backticked spellings. let description = argument.expression.trimmedDescription let caseName = description.split(separator: ".").last.map(String.init) ?? description diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift index c9b8f679c..a5641f1c8 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift @@ -61,10 +61,13 @@ final class ImportedJSModuleRegistry { for (index, reference) in references.enumerated() { let members = (membersByReference[reference] ?? []).sorted() + // A reference with no member lookups keeps the namespace form: a named import + // is a hard link-time requirement, so importing a name nothing references + // would fail the whole module load if the module does not export it. bindings[reference] = Binding( index: index, members: members, - usesNamedImports: members.allSatisfy(Self.isValidJSIdentifier) + usesNamedImports: !members.isEmpty && members.allSatisfy(Self.isValidJSIdentifier) ) } } @@ -72,7 +75,7 @@ final class ImportedJSModuleRegistry { static func collectReferences(skeletons: [BridgeJSSkeleton]) -> [Reference] { var references = Set() for skeleton in skeletons { - forEachMemberLookup(skeleton: skeleton) { reference, _ in + forEachOrigin(skeleton: skeleton) { reference in references.insert(reference) } } @@ -86,12 +89,40 @@ final class ImportedJSModuleRegistry { } } + /// Visits every module origin mentioned by the skeleton, whether or not code + /// generation looks a member up on it. + /// + /// This is what decides which modules are imported at all, and for local paths + /// which files packaging copies. It stays broader than `forEachMemberLookup` so + /// that a module mentioned only by a wrapper-only `@JSClass` is still imported, + /// preserving its side effects. + private static func forEachOrigin( + skeleton: BridgeJSSkeleton, + _ body: (Reference) -> Void + ) { + func visit(from: JSImportFrom?) { + guard let reference = Self.reference(swiftModuleName: skeleton.moduleName, from: from) else { return } + body(reference) + } + for file in skeleton.imported?.children ?? [] { + for function in file.functions { visit(from: function.from) } + for getter in file.globalGetters { visit(from: getter.from) } + for type in file.types { visit(from: type.from) } + } + } + /// Visits every module member lookup that code generation will emit. /// /// The member name taken here must match what the corresponding emitter in - /// `BridgeJSLink` looks up: a class contributes a single binding that serves both - /// its constructor and its static methods, and instance methods contribute nothing - /// because they call through an already-constructed instance. + /// `BridgeJSLink` looks up, and must not include names it never emits: a named + /// import is a hard link-time requirement, so recording a member that no + /// generated code references would make the module fail to load whenever the + /// module does not happen to export that name. + /// + /// A class contributes a single binding that serves both its constructor and its + /// static methods, and only when it has one of those. Instance methods, getters, + /// and setters contribute nothing because they go through an already-constructed + /// instance, so a wrapper-only `@JSClass` needs no export from the module at all. private static func forEachMemberLookup( skeleton: BridgeJSSkeleton, _ body: (Reference, String) -> Void @@ -108,6 +139,7 @@ final class ImportedJSModuleRegistry { visit(from: getter.from, memberName: getter.jsName ?? getter.name) } for type in file.types { + guard type.constructor != nil || !type.staticMethods.isEmpty else { continue } visit(from: type.from, memberName: type.jsName ?? type.name) } } diff --git a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift index 73ab23736..6560cd87a 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift @@ -1161,7 +1161,34 @@ public enum JSImportFrom: Codable, Equatable, Sendable { debugDescription: "Unknown import origin kind '\(kind)'. Expected \"module\"." ) } - self = .module(try container.decode(String.self, forKey: .specifier)) + let specifier = try container.decode(String.self, forKey: .specifier) + // Apply the same rules as the single-value form above, so a specifier cannot + // reach code generation through this path that the string path would reject. + guard !specifier.isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: .specifier, + in: container, + debugDescription: "Module specifier must not be empty." + ) + } + guard !specifier.hasPrefix(".") else { + throw DecodingError.dataCorruptedError( + forKey: .specifier, + in: container, + debugDescription: "Module specifier '\(specifier)' must not be relative." + ) + } + // A rooted path names a file we resolve inside the Swift target, so it must not + // traverse out of it. A bare specifier is resolved by the JavaScript host, where + // path segments carry no such meaning, so it is left alone. + guard !specifier.hasPrefix("/") || !specifier.split(separator: "/").contains("..") else { + throw DecodingError.dataCorruptedError( + forKey: .specifier, + in: container, + debugDescription: "Local module path '\(specifier)' must not contain '..'." + ) + } + self = .module(specifier) } public func encode(to encoder: any Encoder) throws { diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift index 87b486fca..fa44970f2 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift @@ -88,6 +88,19 @@ import Testing } } + /// The keyed form must reject what the string form rejects, so a specifier cannot reach + /// code generation through the tagged object that the plain-string path would refuse. + @Test(arguments: [ + #"{"kind": "module", "specifier": ""}"#, + #"{"kind": "module", "specifier": "./relative.mjs"}"#, + #"{"kind": "module", "specifier": "/../../escape.mjs"}"#, + ]) + func invalidKeyedJSImportFromFailsToDecode(json: String) { + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(JSImportFrom.self, from: Data(json.utf8)) + } + } + private func snapshotCodegen( skeleton: BridgeJSSkeleton, name: String, diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift index 15cc8c58a..0f3ebcaf8 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift @@ -109,6 +109,26 @@ import Testing #expect(diagnostics.description.contains("is not supported on a class member")) } + /// `jsName: nil` is valid Swift and means the same as omitting the argument. + @Test + func explicitNilJSNameIsAccepted() throws { + let source = """ + let unrelated = 0 + @JSFunction(jsName: nil, from: .global) func imported() throws(JSException) + """ + #expect(moduleDiagnostics(source: source) == nil) + } + + /// `JSName.name(_:)` is public and documented, so its explicit spelling must work. + @Test + func explicitNameCaseSpellingIsAccepted() throws { + let source = """ + let unrelated = 0 + @JSFunction(jsName: .name("basename"), from: .module("node:path")) func imported() throws(JSException) + """ + #expect(moduleDiagnostics(source: source) == nil) + } + @Test func jsNameMustBeStringLiteralOrDefault() throws { let source = """ diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportedJSModuleRegistryTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportedJSModuleRegistryTests.swift index 25589f93c..60d5e32b2 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportedJSModuleRegistryTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/ImportedJSModuleRegistryTests.swift @@ -69,6 +69,66 @@ import Testing #expect(lines.contains { $0.contains("bridge-js-modules/Beta/utils.mjs") }) } + /// A named import is a hard link-time requirement, so a class whose module export is + /// never looked up must not produce one. A wrapper-only `@JSClass` has no constructor + /// and no static methods, so nothing references the module's export of that name and + /// the module need not export it at all; requiring it would fail the whole module load. + @Test func wrapperOnlyClassDoesNotRequireANamedExport() throws { + let lines = try importLines([ + skeleton( + moduleName: "Alpha", + types: [ + ImportedTypeSkeleton( + name: "Wrapper", + from: .module("some-pkg"), + methods: [ + ImportedFunctionSkeleton(name: "read", parameters: [], returnType: .void) + ] + ) + ] + ) + ]) + #expect(lines.count == 1) + #expect(lines[0].hasPrefix("import * as ")) + #expect(!lines[0].contains("Wrapper as ")) + } + + /// A class with a constructor does have its export looked up, so it keeps a named import. + @Test func classWithConstructorUsesANamedImport() throws { + let lines = try importLines([ + skeleton( + moduleName: "Alpha", + types: [ + ImportedTypeSkeleton( + name: "Wrapper", + from: .module("some-pkg"), + constructor: ImportedConstructorSkeleton(parameters: []) + ) + ] + ) + ]) + #expect(lines == [#"import { Wrapper as __bjs_import_0_Wrapper } from "some-pkg";"#]) + } + + /// A class with only static methods also looks its export up. + @Test func classWithOnlyStaticMethodsUsesANamedImport() throws { + let lines = try importLines([ + skeleton( + moduleName: "Alpha", + types: [ + ImportedTypeSkeleton( + name: "Wrapper", + from: .module("some-pkg"), + staticMethods: [ + ImportedFunctionSkeleton(name: "create", parameters: [], returnType: .void) + ] + ) + ] + ) + ]) + #expect(lines == [#"import { Wrapper as __bjs_import_0_Wrapper } from "some-pkg";"#]) + } + /// Local references are emitted before bare ones, each group sorted, so that the numbered /// aliases in generated JavaScript are stable across runs. @Test func referencesAreOrderedDeterministically() throws { diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md index 09aa79959..49907d6d2 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md @@ -21,6 +21,8 @@ An **external module** is any other value, passed to the JavaScript module resol Generated packages use static ECMAScript module imports. This works with the existing PackageToJS browser and Node ESM entry points. CommonJS and classic non-module script output are not generated or translated. Note that named exports of a CommonJS package are only importable when Node can statically detect them; when in doubt, use `jsName: .default` and reach members through the default export. +A module export is called through a named import, so `this` is `undefined` inside the called function rather than the module namespace object. A function that reaches sibling exports through `this` — which happens in CommonJS packages consumed through Node's ESM interop — will fail. Import the default export and call the member through it when a package needs that receiver. + Module origins apply to top-level `@JSFunction`, top-level `@JSGetter`, and an entire `@JSClass`. Per-member origins, top-level setters, inline JavaScript source, package-root-relative paths, and per-member module overrides are not supported. `jsName: .default` is likewise only valid on those three declaration forms and only together with `from: .module(...)`; it cannot be used on `@JSSetter`, because ECMAScript module bindings are read-only. The TypeScript-definition workflow (`bridge-js.d.ts`) always imports from `globalThis` and cannot yet target a module origin. To import from a module, declare the API with the macros instead.