diff --git a/Package.swift b/Package.swift index 3d0f1e943..63eacf6be 100644 --- a/Package.swift +++ b/Package.swift @@ -198,6 +198,7 @@ let package = Package( "bridge-js.global.d.ts", "Generated/JavaScript", "JavaScript", + "Modules", ], swiftSettings: [ .enableExperimentalFeature("Extern") diff --git a/Plugins/BridgeJS/Package.swift b/Plugins/BridgeJS/Package.swift index 2074a8a66..a6d4c0c54 100644 --- a/Plugins/BridgeJS/Package.swift +++ b/Plugins/BridgeJS/Package.swift @@ -57,6 +57,7 @@ let package = Package( dependencies: [ "BridgeJSCore", "BridgeJSLink", + "BridgeJSBuildPlugin", "TS2Swift", .product(name: "SwiftParser", package: "swift-syntax"), .product(name: "SwiftSyntax", package: "swift-syntax"), diff --git a/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift b/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift index b78f5f9a6..da1e84309 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSBuildPlugin/BridgeJSBuildPlugin.swift @@ -27,7 +27,8 @@ struct BridgeJSBuildPlugin: BuildToolPlugin { .map(\.url) let configFile = pathToConfigFile(target: target) - var inputFiles: [URL] = inputSwiftFiles + let inputJavaScriptFiles = discoverJavaScriptModuleFiles(in: target.directoryURL) + var inputFiles: [URL] = inputSwiftFiles + inputJavaScriptFiles if FileManager.default.fileExists(atPath: configFile.path) { inputFiles.append(configFile) } @@ -77,7 +78,7 @@ struct BridgeJSBuildPlugin: BuildToolPlugin { } let allSwiftFiles = inputSwiftFiles + pluginGeneratedSwiftFiles - arguments.append(contentsOf: allSwiftFiles.map(\.path)) + arguments.append(contentsOf: (allSwiftFiles + inputJavaScriptFiles).map(\.path)) return .buildCommand( displayName: "Generate BridgeJS code", diff --git a/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSCommandPlugin.swift b/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSCommandPlugin.swift index 24b96f53d..b69950a90 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSCommandPlugin.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSCommandPlugin.swift @@ -184,6 +184,7 @@ extension BridgeJSCommandPlugin.Context { !$0.url.path.hasPrefix(generatedDirectory.path + "/") }.map(\.url.path) ) + generateArguments.append(contentsOf: discoverJavaScriptModuleFiles(in: target.directoryURL).map(\.path)) generateArguments.append(contentsOf: remainingArguments) try runBridgeJSTool(arguments: generateArguments) diff --git a/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSPluginUtilities b/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSPluginUtilities new file mode 120000 index 000000000..2daaa446e --- /dev/null +++ b/Plugins/BridgeJS/Sources/BridgeJSCommandPlugin/BridgeJSPluginUtilities @@ -0,0 +1 @@ +../BridgeJSPluginUtilities \ No newline at end of file diff --git a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift index 5b5155fdc..b11e115f6 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSCore/SwiftToSkeleton.swift @@ -23,6 +23,8 @@ public final class SwiftToSkeleton { private var sourceFiles: [(sourceFile: SourceFileSyntax, inputFilePath: String)] = [] private var usedExternalModules = Set() + private let javaScriptModuleSource: (String) throws -> String? + private var importedJSModules: [String: ImportedJSModule] = [:] /// Non-fatal diagnostics collected during `finalize()`. These do not fail the build. public private(set) var warnings: [(file: String, diagnostic: DiagnosticError)] = [] @@ -32,12 +34,14 @@ public final class SwiftToSkeleton { moduleName: String, exposeToGlobal: Bool, externalModuleIndex: ExternalModuleIndex, - identityMode: String? = nil + identityMode: String? = nil, + javaScriptModuleSource: @escaping (String) throws -> String? = { _ in nil } ) { self.progress = progress self.moduleName = moduleName self.exposeToGlobal = exposeToGlobal self.identityMode = identityMode + self.javaScriptModuleSource = javaScriptModuleSource self.typeDeclResolver = TypeDeclResolver() self.externalModuleIndex = externalModuleIndex @@ -90,6 +94,38 @@ public final class SwiftToSkeleton { ) importCollector.walk(sourceFile) + let importOrigins = + importCollector.importedFunctions.compactMap(\.from) + + importCollector.importedTypes.compactMap(\.from) + + importCollector.importedGlobalGetters.compactMap(\.from) + let modulePaths = Set(importOrigins.compactMap(\.modulePath)) + for path in modulePaths.sorted() { + if importedJSModules[path] != nil { + 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 let source = try javaScriptModuleSource(path) else { + importCollector.errors.append( + DiagnosticError( + node: pathNode, + message: "JavaScript module file was not found at '\(path)'." + ) + ) + continue + } + importedJSModules[path] = ImportedJSModule(path: path, source: source) + } + let exportErrors = exportCollector.errors.filter { $0.severity == .error } let importErrorsFatal = importCollector.errors.filter { $0.severity == .error && !$0.message.contains("Unsupported type '") @@ -128,7 +164,11 @@ public final class SwiftToSkeleton { throw BridgeJSCoreDiagnosticError(diagnostics: diagnostics) } let importedSkeleton: ImportedModuleSkeleton? = { - let module = ImportedModuleSkeleton(children: importedFiles) + let modules = importedJSModules.values.sorted { $0.path < $1.path } + let module = ImportedModuleSkeleton( + children: importedFiles, + modules: modules.isEmpty ? nil : modules + ) if module.children.allSatisfy({ $0.functions.isEmpty && $0.types.isEmpty && $0.globalGetters.isEmpty }) { return nil } @@ -2413,6 +2453,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { var importedFunctions: [ImportedFunctionSkeleton] = [] var importedTypes: [ImportedTypeSkeleton] = [] var importedGlobalGetters: [ImportedGetterSkeleton] = [] + var importedModulePathNodes: [String: Syntax] = [:] var errors: [DiagnosticError] = [] private let inputFilePath: String @@ -2507,22 +2548,41 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { } return nil } + } - /// Extracts the `from` argument value from an attribute, if present. - static func extractJSImportFrom(from attribute: AttributeSyntax) -> JSImportFrom? { - guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self) else { + private func extractJSImportFrom(from attribute: AttributeSyntax) -> JSImportFrom? { + guard let arguments = attribute.arguments?.as(LabeledExprListSyntax.self), + let argument = arguments.first(where: { $0.label?.text == "from" }) + else { + return nil + } + + if let call = argument.expression.as(FunctionCallExprSyntax.self), + call.calledExpression.trimmedDescription.split(separator: ".").last == "module" + { + guard call.arguments.count == 1, + let pathExpression = call.arguments.first?.expression, + let literal = pathExpression.as(StringLiteralExprSyntax.self), + let path = literal.representedLiteralValue + else { + errors.append( + DiagnosticError( + node: call.arguments.first?.expression ?? argument.expression, + message: "JavaScript module path must be a string literal." + ) + ) return nil } - for argument in arguments { - guard argument.label?.text == "from" else { continue } - - // Accept `.global`, `JSImportFrom.global`, etc. - let description = argument.expression.trimmedDescription - let caseName = description.split(separator: ".").last.map(String.init) ?? description - return JSImportFrom(rawValue: caseName) + if importedModulePathNodes[path] == nil { + importedModulePathNodes[path] = Syntax(literal) } - return nil + return .module(path) } + + // Accept `.global`, `JSImportFrom.global`, etc. + let description = argument.expression.trimmedDescription + let caseName = description.split(separator: ".").last.map(String.init) ?? description + return caseName == "global" ? .global : nil } // MARK: - Validation Helpers @@ -2705,7 +2765,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { if AttributeChecker.hasJSClassAttribute(node.attributes) { let attribute = AttributeChecker.firstJSClassAttribute(node.attributes) let jsName = attribute.flatMap(AttributeChecker.extractJSName) - let from = attribute.flatMap(AttributeChecker.extractJSImportFrom) + let from = attribute.flatMap { extractJSImportFrom(from: $0) } let accessLevel = Self.bridgeAccessLevel(from: node.modifiers) enterJSClass(node.name.text, jsName: jsName, from: from, accessLevel: accessLevel) } @@ -2722,7 +2782,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { if AttributeChecker.hasJSClassAttribute(node.attributes) { let attribute = AttributeChecker.firstJSClassAttribute(node.attributes) let jsName = attribute.flatMap(AttributeChecker.extractJSName) - let from = attribute.flatMap(AttributeChecker.extractJSImportFrom) + let from = attribute.flatMap { extractJSImportFrom(from: $0) } let accessLevel = Self.bridgeAccessLevel(from: node.modifiers) enterJSClass(node.name.text, jsName: jsName, from: from, accessLevel: accessLevel) } @@ -2916,7 +2976,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { let baseName = SwiftToSkeleton.normalizeIdentifier(node.name.text) let jsName = AttributeChecker.extractJSName(from: jsFunction) - let from = AttributeChecker.extractJSImportFrom(from: jsFunction) + let from = extractJSImportFrom(from: jsFunction) let name = baseName let parameters = parseParameters(from: node.signature.parameterClause) @@ -2969,7 +3029,7 @@ private final class ImportSwiftMacrosAPICollector: SyntaxAnyVisitor { } let propertyName = SwiftToSkeleton.normalizeIdentifier(identifier.identifier.text) let jsName = AttributeChecker.extractJSName(from: jsGetter) - let from = AttributeChecker.extractJSImportFrom(from: jsGetter) + let from = extractJSImportFrom(from: jsGetter) let accessLevel = Self.bridgeAccessLevel(from: node.modifiers) return ImportedGetterSkeleton( name: propertyName, diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift index 4706b14a4..b6bfe1121 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLink.swift @@ -16,6 +16,7 @@ public struct BridgeJSLink { let enableLifetimeTracking: Bool = false private let namespaceBuilder = NamespaceBuilder() private let intrinsicRegistry = JSIntrinsicRegistry() + private let importedModuleRegistry = ImportedJSModuleRegistry() public init( skeletons: [BridgeJSSkeleton] = [], @@ -1077,6 +1078,11 @@ public struct BridgeJSLink { let printer = CodeFragmentPrinter(header: header) printer.nextLine() + printer.write(lines: importedModuleRegistry.importLines) + if !importedModuleRegistry.importLines.isEmpty { + printer.nextLine() + } + printer.write(lines: data.topLevelTypeLines) let exportedSkeletons = skeletons.compactMap(\.exported) @@ -1220,8 +1226,9 @@ public struct BridgeJSLink { return printer.lines.joined(separator: "\n") } - public func link() throws -> (outputJs: String, outputDts: String) { + public func link() throws -> BridgeJSLinkOutput { intrinsicRegistry.reset() + try importedModuleRegistry.configure(skeletons: skeletons) intrinsicRegistry.classNamespaces = skeletons.reduce(into: [:]) { result, unified in guard let skeleton = unified.exported else { return } for klass in skeleton.classes { @@ -1233,7 +1240,11 @@ public struct BridgeJSLink { let data = try collectLinkData() let outputJs = try generateJavaScript(data: data) let outputDts = generateTypeScript(data: data) - return (outputJs, outputDts) + return BridgeJSLinkOutput( + outputJs: outputJs, + outputDts: outputDts, + modules: importedModuleRegistry.artifacts + ) } private func enumHelperAssignments() -> CodeFragmentPrinter { @@ -1586,7 +1597,7 @@ public struct BridgeJSLink { return "\"\(Self.escapeForJavaScriptStringLiteral(name))\"" } - fileprivate static func escapeForJavaScriptStringLiteral(_ string: String) -> String { + static func escapeForJavaScriptStringLiteral(_ string: String) -> String { string .replacingOccurrences(of: "\\", with: "\\\\") .replacingOccurrences(of: "\"", with: "\\\"") @@ -3460,7 +3471,10 @@ extension BridgeJSLink { try thunkBuilder.liftParameter(param: param) } let jsName = function.jsName ?? function.name - let importRootExpr = function.from == .global ? "globalThis" : "imports" + let importRootExpr = try importedModuleRegistry.namespaceExpression( + swiftModuleName: importObjectBuilder.moduleName, + from: function.from + ) try thunkBuilder.call(name: jsName, fromObjectExpr: importRootExpr) let funcLines = thunkBuilder.renderFunction(name: function.abiName(context: nil)) @@ -3484,7 +3498,10 @@ extension BridgeJSLink { intrinsicRegistry: intrinsicRegistry ) let jsName = getter.jsName ?? getter.name - let importRootExpr = getter.from == .global ? "globalThis" : "imports" + let importRootExpr = try importedModuleRegistry.namespaceExpression( + swiftModuleName: importObjectBuilder.moduleName, + from: getter.from + ) try thunkBuilder.getImportProperty( name: jsName, fromObjectExpr: importRootExpr, @@ -3539,7 +3556,11 @@ extension BridgeJSLink { } for method in type.staticMethods { let abiName = method.abiName(context: type, operation: "static") - let (js, dts) = try renderImportedStaticMethod(context: type, method: method) + let (js, dts) = try renderImportedStaticMethod( + swiftModuleName: importObjectBuilder.moduleName, + context: type, + method: method + ) importObjectBuilder.assignToImportObject(name: abiName, function: js) importObjectBuilder.appendDts(dts) } @@ -3583,7 +3604,10 @@ extension BridgeJSLink { for param in constructor.parameters { try thunkBuilder.liftParameter(param: param) } - let importRootExpr = type.from == .global ? "globalThis" : "imports" + let importRootExpr = try importedModuleRegistry.namespaceExpression( + swiftModuleName: importObjectBuilder.moduleName, + from: type.from + ) try thunkBuilder.callConstructor( jsName: type.jsName ?? type.name, swiftTypeName: type.name, @@ -3627,6 +3651,7 @@ extension BridgeJSLink { } func renderImportedStaticMethod( + swiftModuleName: String, context: ImportedTypeSkeleton, method: ImportedFunctionSkeleton ) throws -> (js: [String], dts: [String]) { @@ -3638,7 +3663,10 @@ extension BridgeJSLink { for param in method.parameters { try thunkBuilder.liftParameter(param: param) } - let importRootExpr = context.from == .global ? "globalThis" : "imports" + let importRootExpr = try importedModuleRegistry.namespaceExpression( + swiftModuleName: swiftModuleName, + from: context.from + ) let constructorExpr = ImportedThunkBuilder.propertyAccessExpr( objectExpr: importRootExpr, propertyName: context.jsName ?? context.name diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLinkOutput.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLinkOutput.swift new file mode 100644 index 000000000..ac95235af --- /dev/null +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/BridgeJSLinkOutput.swift @@ -0,0 +1,21 @@ +public struct BridgeJSLinkOutput: Sendable { + public struct Module: Equatable, Sendable { + public let relativePath: String + public let source: String + + init(relativePath: String, source: String) { + self.relativePath = relativePath + self.source = source + } + } + + public let outputJs: String + public let outputDts: String + public let modules: [Module] + + init(outputJs: String, outputDts: String, modules: [Module]) { + self.outputJs = outputJs + self.outputDts = outputDts + self.modules = modules + } +} diff --git a/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift new file mode 100644 index 000000000..ebc715eac --- /dev/null +++ b/Plugins/BridgeJS/Sources/BridgeJSLink/ImportedJSModuleRegistry.swift @@ -0,0 +1,68 @@ +#if canImport(BridgeJSSkeleton) +import BridgeJSSkeleton +#endif + +final class ImportedJSModuleRegistry { + private struct Key: Hashable { + let swiftModuleName: String + let path: String + } + + private var aliases: [Key: String] = [:] + private(set) var artifacts: [BridgeJSLinkOutput.Module] = [] + + func configure(skeletons: [BridgeJSSkeleton]) throws { + aliases.removeAll(keepingCapacity: true) + artifacts.removeAll(keepingCapacity: true) + + var sources: [Key: String] = [:] + for skeleton in skeletons { + for module in skeleton.imported?.modules ?? [] { + guard module.path.hasPrefix("/") else { + throw BridgeJSLinkError( + message: "JavaScript module path must start with '/': \(module.path)" + ) + } + let key = Key(swiftModuleName: skeleton.moduleName, path: module.path) + if let existing = sources[key], existing != module.source { + throw BridgeJSLinkError( + message: "Conflicting JavaScript module contents for \(skeleton.moduleName)/\(module.path)" + ) + } + sources[key] = module.source + } + } + + for (index, entry) in sources.sorted(by: { + ($0.key.swiftModuleName, $0.key.path) < ($1.key.swiftModuleName, $1.key.path) + }).enumerated() { + let relativePath = "bridge-js-modules/\(entry.key.swiftModuleName)\(entry.key.path)" + aliases[entry.key] = "__bjs_imported_module_\(index)" + artifacts.append(.init(relativePath: relativePath, source: entry.value)) + } + } + + func namespaceExpression(swiftModuleName: String, from: JSImportFrom?) throws -> String { + switch from { + case nil: + return "imports" + case .global: + return "globalThis" + case .module(let path): + let key = Key(swiftModuleName: swiftModuleName, path: path) + guard let alias = aliases[key] else { + throw BridgeJSLinkError( + message: "Missing embedded JavaScript module for \(swiftModuleName)/\(path)" + ) + } + return alias + } + } + + var importLines: [String] { + artifacts.enumerated().map { index, artifact in + let path = BridgeJSLink.escapeForJavaScriptStringLiteral(artifact.relativePath) + return "import * as __bjs_imported_module_\(index) from \"./\(path)\";" + } + } +} diff --git a/Plugins/BridgeJS/Sources/BridgeJSPluginUtilities/InputDiscovery.swift b/Plugins/BridgeJS/Sources/BridgeJSPluginUtilities/InputDiscovery.swift new file mode 100644 index 000000000..f5ddff956 --- /dev/null +++ b/Plugins/BridgeJS/Sources/BridgeJSPluginUtilities/InputDiscovery.swift @@ -0,0 +1,19 @@ +import Foundation + +/// JavaScript files must be declared as build-command inputs so SwiftPM reruns +/// BridgeJS when a referenced module changes. +func discoverJavaScriptModuleFiles(in directory: URL) -> [URL] { + guard + let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) + else { return [] } + + return enumerator.compactMap { $0 as? URL }.filter(isJavaScriptModuleFile).sorted { $0.path < $1.path } +} + +func isJavaScriptModuleFile(_ file: URL) -> Bool { + ["js", "mjs"].contains(file.pathExtension.lowercased()) +} diff --git a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift index 7f45b6c39..76b998adf 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSSkeleton/BridgeJSSkeleton.swift @@ -1101,7 +1101,7 @@ public struct ExportedSkeleton: Codable { } private var asyncClosureResolveReturnTypes: [BridgeType] { - var collector = AsyncClosureReturnTypeCollector() + let collector = AsyncClosureReturnTypeCollector() var walker = BridgeSkeletonWalker(visitor: collector) walker.walk(self) return walker.visitor.returnTypes @@ -1126,8 +1126,35 @@ private struct AsyncClosureReturnTypeCollector: BridgeSkeletonVisitor { /// Controls where BridgeJS reads imported JS values from. /// /// - `global`: Read from `globalThis`. -public enum JSImportFrom: String, Codable { +public enum JSImportFrom: Codable, Equatable, Sendable { case global + case module(String) + + public init(from decoder: any Decoder) throws { + let value = try decoder.singleValueContainer().decode(String.self) + self = value == "global" ? .global : .module(value) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(modulePath ?? "global") + } + + public var modulePath: String? { + guard case .module(let path) = self else { return nil } + return path + } +} + +/// A target-local ECMAScript module embedded into a BridgeJS skeleton. +public struct ImportedJSModule: Codable, Equatable, Sendable { + public let path: String + public let source: String + + public init(path: String, source: String) { + self.path = path + self.source = source + } } public struct ImportedFunctionSkeleton: Codable { @@ -1455,9 +1482,32 @@ public struct ImportedFileSkeleton: Codable { public struct ImportedModuleSkeleton: Codable { public var children: [ImportedFileSkeleton] + public var modules: [ImportedJSModule]? - public init(children: [ImportedFileSkeleton]) { + public init(children: [ImportedFileSkeleton], modules: [ImportedJSModule]? = nil) { self.children = children + self.modules = modules + } + + /// Deterministic non-cryptographic content fingerprint used to invalidate + /// build-plugin outputs when only an embedded JavaScript module changes. + public var moduleContentFingerprint: String? { + guard let modules, !modules.isEmpty else { return nil } + var hash: UInt64 = 0xcbf29ce484222325 + func combine(_ bytes: S) where S.Element == UInt8 { + for byte in bytes { + hash ^= UInt64(byte) + hash &*= 0x100000001b3 + } + } + for module in modules.sorted(by: { $0.path < $1.path }) { + combine(module.path.utf8) + combine(CollectionOfOne(UInt8(0))) + combine(module.source.utf8) + combine(CollectionOfOne(UInt8(0xff))) + } + let hexadecimal = String(hash, radix: 16) + return String(repeating: "0", count: 16 - hexadecimal.count) + hexadecimal } } diff --git a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift index fa8a0a273..8597a5519 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSTool/BridgeJSTool.swift @@ -160,6 +160,10 @@ import BridgeJSUtilities var inputFiles = withSpan("Collecting Swift files") { return inputSwiftFiles(targetDirectory: targetDirectory, positionalArguments: positionalArguments) } + let javaScriptModuleFilesByPath = try makeJavaScriptModuleFileLookup( + targetDirectory: targetDirectory, + positionalArguments: positionalArguments + ) // BridgeJS.Macros.swift contains imported declarations (@JSFunction, @JSClass, etc.) that need // to be processed by SwiftToSkeleton to populate the imported skeleton. The command plugin @@ -176,7 +180,17 @@ import BridgeJSUtilities moduleName: moduleName, exposeToGlobal: config.exposeToGlobal, externalModuleIndex: externalModuleIndex, - identityMode: config.identityMode + identityMode: config.identityMode, + javaScriptModuleSource: { path in + guard let fileURL = javaScriptModuleFilesByPath[path] else { + return nil + } + do { + return try String(contentsOf: fileURL, encoding: .utf8) + } catch { + throw BridgeJSToolError("Failed to read JavaScript module '\(path)': \(error)") + } + } ) for inputFile in inputFiles.sorted() { try withSpan("Parsing \(inputFile)") { @@ -236,7 +250,10 @@ import BridgeJSUtilities // Combine and write unified Swift output let outputSwiftURL = outputDirectory.appending(path: "BridgeJS.swift") - let combinedSwift = [closureSupport, exportResult, importResult].compactMap { $0 } + let moduleFingerprint = skeleton.imported?.moduleContentFingerprint.map { + "// BridgeJS JavaScript module fingerprint: \($0)" + } + let combinedSwift = [moduleFingerprint, closureSupport, exportResult, importResult].compactMap { $0 } let outputSwift = combineGeneratedSwift( combinedSwift, importingExternalModules: skeleton.usedExternalModules @@ -396,7 +413,32 @@ private func inputSwiftFiles(targetDirectory: URL, positionalArguments: [String] if positionalArguments.isEmpty { return recursivelyCollectSwiftFiles(from: targetDirectory).map(\.path) } - return positionalArguments + return positionalArguments.filter { URL(fileURLWithPath: $0).pathExtension == "swift" } +} + +private func makeJavaScriptModuleFileLookup( + targetDirectory: URL, + positionalArguments: [String] +) throws -> [String: URL] { + let files = + positionalArguments.isEmpty + ? discoverJavaScriptModuleFiles(in: targetDirectory) + : positionalArguments.map { URL(fileURLWithPath: $0) }.filter(isJavaScriptModuleFile) + + let targetPath = targetDirectory.standardizedFileURL.path + let targetPrefix = targetPath.hasSuffix("/") ? targetPath : targetPath + "/" + var modules: [String: URL] = [:] + + for file in files { + let fileURL = file.standardizedFileURL + guard fileURL.path.hasPrefix(targetPrefix) else { + throw BridgeJSToolError("JavaScript module is outside the target directory: \(fileURL.path)") + } + let targetRootedPath = "/\(fileURL.path.dropFirst(targetPrefix.count))" + modules[targetRootedPath] = fileURL + } + + return modules } extension Profiling { diff --git a/Plugins/BridgeJS/Sources/BridgeJSTool/InputDiscovery.swift b/Plugins/BridgeJS/Sources/BridgeJSTool/InputDiscovery.swift new file mode 120000 index 000000000..c524f2eab --- /dev/null +++ b/Plugins/BridgeJS/Sources/BridgeJSTool/InputDiscovery.swift @@ -0,0 +1 @@ +../BridgeJSPluginUtilities/InputDiscovery.swift \ No newline at end of file diff --git a/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift b/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift index 4a58f1972..e23beffdf 100644 --- a/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift +++ b/Plugins/BridgeJS/Sources/BridgeJSToolInternal/BridgeJSToolInternal.swift @@ -103,7 +103,7 @@ import ArgumentParser } } - static func linkSkeletons(skeletonFiles: [String]) throws -> (outputJs: String, outputDts: String) { + static func linkSkeletons(skeletonFiles: [String]) throws -> BridgeJSLinkOutput { var skeletons: [BridgeJSSkeleton] = [] for skeletonFile in skeletonFiles.sorted() { let skeletonData = try readData(from: skeletonFile) @@ -122,8 +122,7 @@ import ArgumentParser var skeletonFiles: [String] func run() throws { - let (outputJs, _) = try linkSkeletons(skeletonFiles: skeletonFiles) - print(outputJs) + print(try linkSkeletons(skeletonFiles: skeletonFiles).outputJs) } } @@ -136,8 +135,7 @@ import ArgumentParser var skeletonFiles: [String] func run() throws { - let (_, outputDts) = try linkSkeletons(skeletonFiles: skeletonFiles) - print(outputDts) + print(try linkSkeletons(skeletonFiles: skeletonFiles).outputDts) } } } diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift index 8b8e8b8a2..4b09831f1 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSCodegenTests.swift @@ -4,6 +4,7 @@ import SwiftParser import Testing @testable import BridgeJSCore +@testable import BridgeJSLink @testable import BridgeJSSkeleton @Suite struct BridgeJSCodegenTests { @@ -13,6 +14,65 @@ import Testing static let multifileInputsDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() .appendingPathComponent("Inputs").appendingPathComponent("MacroSwift").appendingPathComponent("Multifile") + @Test + func legacyImportedModuleSkeletonDecodesWithoutModules() throws { + let decoded = try JSONDecoder().decode( + ImportedModuleSkeleton.self, + from: Data(#"{"children":[]}"#.utf8) + ) + #expect(decoded.children.isEmpty) + #expect(decoded.modules == nil) + } + + @Test + func changingOnlyJavaScriptModuleContentsUpdatesGeneratedArtifacts() throws { + let modulePath = "/Modules/math.mjs" + let swiftSource = """ + @JSFunction(from: .module("/Modules/math.mjs")) + func add(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int + + @JSGetter(jsName: "version", from: .module("/Modules/math.mjs")) + var moduleVersion: String + """ + + func generate(source: String) throws -> (BridgeJSSkeleton, String, BridgeJSLinkOutput) { + var moduleLoadCount = 0 + let generator = SwiftToSkeleton( + progress: .silent, + moduleName: "TestModule", + exposeToGlobal: false, + externalModuleIndex: .empty, + javaScriptModuleSource: { + moduleLoadCount += 1 + return $0 == modulePath ? source : nil + } + ) + generator.addSourceFile(Parser.parse(source: swiftSource), inputFilePath: "Imports.swift") + let skeleton = try generator.finalize() + let imported = try #require(skeleton.imported) + let generatedThunks = try ImportTS( + progress: .silent, + moduleName: skeleton.moduleName, + skeleton: imported + ).finalize() + let swiftThunks = try #require(generatedThunks) + let linked = try BridgeJSLink(skeletons: [skeleton]).link() + #expect(moduleLoadCount == 1) + return (skeleton, swiftThunks, linked) + } + + let firstSource = "export const version = 'one'; export function add(a, b) { return a + b; }" + let secondSource = "export const version = 'two'; export function add(a, b) { return a + b; }" + let first = try generate(source: firstSource) + let second = try generate(source: secondSource) + + #expect(first.1 == second.1) + #expect(first.0.imported?.moduleContentFingerprint != second.0.imported?.moduleContentFingerprint) + #expect(first.2.modules.map(\.relativePath) == second.2.modules.map(\.relativePath)) + #expect(first.2.modules.map(\.source) == [firstSource]) + #expect(second.2.modules.map(\.source) == [secondSource]) + } + private func snapshotCodegen( skeleton: BridgeJSSkeleton, name: String, @@ -77,11 +137,25 @@ 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 moduleSources = + input == "JSImportModule.swift" + ? [ + "/Modules/JSImportModule.mjs": try String( + contentsOf: Self.inputsDirectory.appending(path: "Modules/JSImportModule.mjs"), + encoding: .utf8 + ), + "/Modules/ModuleCounter.mjs": try String( + contentsOf: Self.inputsDirectory.appending(path: "Modules/ModuleCounter.mjs"), + encoding: .utf8 + ), + ] + : [:] let swiftAPI = SwiftToSkeleton( progress: .silent, moduleName: "TestModule", exposeToGlobal: false, - externalModuleIndex: .empty + externalModuleIndex: .empty, + javaScriptModuleSource: { moduleSources[$0] } ) swiftAPI.addSourceFile(sourceFile, inputFilePath: input) let skeleton = try swiftAPI.finalize() diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift index 2f3f46fdb..3df96c3ff 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BridgeJSLinkTests.swift @@ -14,13 +14,13 @@ import Testing function: String = #function, sourceLocation: Testing.SourceLocation = #_sourceLocation ) throws { - let (outputJs, outputDts) = try bridgeJSLink.link() + let output = try bridgeJSLink.link() try assertSnapshot( name: name, filePath: filePath, function: function, sourceLocation: sourceLocation, - input: outputJs.data(using: .utf8)!, + input: output.outputJs.data(using: .utf8)!, fileExtension: "js" ) try assertSnapshot( @@ -28,9 +28,22 @@ import Testing filePath: filePath, function: function, sourceLocation: sourceLocation, - input: outputDts.data(using: .utf8)!, + input: output.outputDts.data(using: .utf8)!, fileExtension: "d.ts" ) + if !output.modules.isEmpty { + let moduleDump = output.modules.map { module in + "// \(module.relativePath)\n\(module.source)" + }.joined(separator: "\n") + try assertSnapshot( + name: name, + filePath: filePath, + function: function, + sourceLocation: sourceLocation, + input: Data(moduleDump.utf8), + fileExtension: "modules.js" + ) + } } static let inputsDirectory = URL(fileURLWithPath: #filePath).deletingLastPathComponent().appendingPathComponent( @@ -43,17 +56,45 @@ import Testing return inputs.filter { $0.hasSuffix(`extension`) } } + @Test + func rejectsNonRootedJavaScriptModulePath() { + let skeleton = BridgeJSSkeleton( + moduleName: "TestModule", + imported: ImportedModuleSkeleton( + children: [], + modules: [ImportedJSModule(path: "Modules/module.js", source: "")] + ) + ) + #expect(throws: BridgeJSLinkError.self) { + try BridgeJSLink(skeletons: [skeleton]).link() + } + } + @Test(arguments: collectInputs(extension: ".swift")) func snapshot(input: String) throws { let url = Self.inputsDirectory.appendingPathComponent(input) let name = url.deletingPathExtension().lastPathComponent let sourceFile = Parser.parse(source: try String(contentsOf: url, encoding: .utf8)) + let moduleSources = + input == "JSImportModule.swift" + ? [ + "/Modules/JSImportModule.mjs": try String( + contentsOf: Self.inputsDirectory.appending(path: "Modules/JSImportModule.mjs"), + encoding: .utf8 + ), + "/Modules/ModuleCounter.mjs": try String( + contentsOf: Self.inputsDirectory.appending(path: "Modules/ModuleCounter.mjs"), + encoding: .utf8 + ), + ] + : [:] let importSwift = SwiftToSkeleton( progress: .silent, moduleName: "TestModule", exposeToGlobal: false, - externalModuleIndex: .empty + externalModuleIndex: .empty, + javaScriptModuleSource: { moduleSources[$0] } ) importSwift.addSourceFile(sourceFile, inputFilePath: "\(name).swift") let importResult = try importSwift.finalize() diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/BuildPluginInputDiscoveryTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BuildPluginInputDiscoveryTests.swift new file mode 100644 index 000000000..c2921f7c1 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/BuildPluginInputDiscoveryTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing + +@testable import BridgeJSBuildPlugin + +@Suite struct BuildPluginInputDiscoveryTests { + @Test + func discoversJavaScriptModuleInputsInStableOrder() throws { + let targetDirectory = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString) + try FileManager.default.createDirectory(at: targetDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: targetDirectory) } + + let nested = targetDirectory.appending(path: "Nested") + let hidden = targetDirectory.appending(path: ".hidden") + try FileManager.default.createDirectory(at: nested, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: hidden, withIntermediateDirectories: true) + + for path in ["z.js", "Nested/a.mjs", "Nested/b.JS", "ignored.swift", ".hidden/hidden.js"] { + #expect( + FileManager.default.createFile( + atPath: targetDirectory.appending(path: path).path, + contents: Data("input".utf8) + ) + ) + } + + let resolvedTargetPath = targetDirectory.resolvingSymlinksInPath().path + let relativePaths = discoverJavaScriptModuleFiles(in: targetDirectory).map { + String($0.resolvingSymlinksInPath().path.dropFirst(resolvedTargetPath.count + 1)) + } + #expect(relativePaths == ["Nested/a.mjs", "Nested/b.JS", "z.js"]) + } +} diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift index 79ea47ebd..1c6ecffaf 100644 --- a/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/DiagnosticsTests.swift @@ -1,3 +1,4 @@ +import Foundation import SwiftParser import SwiftSyntax import Testing @@ -6,6 +7,69 @@ import Testing @testable import BridgeJSSkeleton @Suite struct DiagnosticsTests { + private func moduleDiagnostics(source: String) -> BridgeJSCoreDiagnosticError? { + let swiftAPI = SwiftToSkeleton( + progress: .silent, + moduleName: "TestModule", + exposeToGlobal: false, + externalModuleIndex: .empty + ) + swiftAPI.addSourceFile(Parser.parse(source: source), inputFilePath: "test.swift") + do { + _ = try swiftAPI.finalize() + return nil + } catch let error as BridgeJSCoreDiagnosticError { + return error + } catch { + Issue.record("Unexpected error: \(error)") + return nil + } + } + + @Test + func missingJavaScriptModuleProducesDiagnostic() 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 file was not found at '/missing.js'")) + #expect(diagnostics.description.contains("test.swift:2:27:")) + } + + @Test + func javaScriptModulePathMustStartAtTargetRoot() 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("test.swift:2:27:")) + } + + @Test + func missingJavaScriptModuleWithTraversalProducesDiagnosticAtPath() 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 file was not found at '/../missing.js'")) + #expect(diagnostics.description.contains("test.swift:2:27:")) + } + + @Test + func javaScriptModulePathMustBeStringLiteral() throws { + let source = """ + let modulePath = "/module.js" + @JSFunction(from: .module(modulePath)) func imported() throws(JSException) + """ + let diagnostics = try #require(moduleDiagnostics(source: source)) + #expect(diagnostics.description.contains("JavaScript module path must be a string literal.")) + #expect(diagnostics.description.contains("test.swift:2:27:")) + } + /// Returns the first parameter's type node from a function in the source (the first `@JS func`-like decl), for pinpointing diagnostics. private func firstParameterTypeNode(source: String) -> TypeSyntax? { let tree = Parser.parse(source: source) diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift new file mode 100644 index 000000000..3fd9d79e7 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/JSImportModule.swift @@ -0,0 +1,17 @@ +@JSFunction(from: .module("/Modules/JSImportModule.mjs")) +func moduleAdd(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int + +@JSFunction(jsName: "renamedFunction", from: .module("/Modules/JSImportModule.mjs")) +func moduleRenamed() throws(JSException) -> String + +@JSGetter(jsName: "version", from: .module("/Modules/JSImportModule.mjs")) +var moduleVersion: String + +@JSClass(from: .module("/Modules/ModuleCounter.mjs")) +struct ModuleCounter { + @JSFunction init(_ value: Int) throws(JSException) + @JSFunction static func create(_ value: Int) throws(JSException) -> ModuleCounter + @JSFunction func increment() throws(JSException) -> Int + @JSGetter var value: Int + @JSSetter func setValue(_ value: Int) throws(JSException) +} diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/JSImportModule.mjs b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/JSImportModule.mjs new file mode 100644 index 000000000..3107e222f --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/JSImportModule.mjs @@ -0,0 +1,9 @@ +export function moduleAdd(lhs, rhs) { + return lhs + rhs; +} + +export function renamedFunction() { + return "renamed"; +} + +export const version = "1.0"; diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/ModuleCounter.mjs b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/ModuleCounter.mjs new file mode 100644 index 000000000..5a33b7ffa --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/Inputs/MacroSwift/Modules/ModuleCounter.mjs @@ -0,0 +1,17 @@ +export class ModuleCounter { + constructor(value) { + this.value = value; + } + + static create(value) { + return new ModuleCounter(value); + } + + increment() { + return ++this.value; + } + + setValue(value) { + this.value = value; + } +} diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json new file mode 100644 index 000000000..084cae56a --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.json @@ -0,0 +1,201 @@ +{ + "imported" : { + "children" : [ + { + "functions" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : "\/Modules\/JSImportModule.mjs", + "name" : "moduleAdd", + "parameters" : [ + { + "name" : "lhs", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + }, + { + "name" : "rhs", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + }, + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : "\/Modules\/JSImportModule.mjs", + "jsName" : "renamedFunction", + "name" : "moduleRenamed", + "parameters" : [ + + ], + "returnType" : { + "string" : { + + } + } + } + ], + "globalGetters" : [ + { + "accessLevel" : "internal", + "from" : "\/Modules\/JSImportModule.mjs", + "jsName" : "version", + "name" : "moduleVersion", + "type" : { + "string" : { + + } + } + } + ], + "types" : [ + { + "accessLevel" : "internal", + "constructor" : { + "accessLevel" : "internal", + "parameters" : [ + { + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ] + }, + "from" : "\/Modules\/ModuleCounter.mjs", + "getters" : [ + { + "accessLevel" : "internal", + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "methods" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "name" : "increment", + "parameters" : [ + + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "name" : "ModuleCounter", + "setters" : [ + { + "accessLevel" : "internal", + "functionName" : "value_set", + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "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" : "ModuleCounter" + } + } + } + ] + } + ] + } + ], + "modules" : [ + { + "path" : "\/Modules\/JSImportModule.mjs", + "source" : "export function moduleAdd(lhs, rhs) {\n return lhs + rhs;\n}\n\nexport function renamedFunction() {\n return \"renamed\";\n}\n\nexport const version = \"1.0\";\n" + }, + { + "path" : "\/Modules\/ModuleCounter.mjs", + "source" : "export class ModuleCounter {\n constructor(value) {\n this.value = value;\n }\n\n static create(value) {\n return new ModuleCounter(value);\n }\n\n increment() {\n return ++this.value;\n }\n\n setValue(value) {\n this.value = value;\n }\n}\n" + } + ] + }, + "moduleName" : "TestModule", + "usedExternalModules" : [ + + ] +} \ No newline at end of file diff --git a/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift new file mode 100644 index 000000000..de63eec20 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSCodegenTests/JSImportModule.swift @@ -0,0 +1,166 @@ +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_moduleVersion_get") +fileprivate func bjs_moduleVersion_get_extern() -> Int32 +#else +fileprivate func bjs_moduleVersion_get_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_moduleVersion_get() -> Int32 { + return bjs_moduleVersion_get_extern() +} + +func _$moduleVersion_get() throws(JSException) -> String { + let ret = bjs_moduleVersion_get() + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_moduleAdd") +fileprivate func bjs_moduleAdd_extern(_ lhs: Int32, _ rhs: Int32) -> Int32 +#else +fileprivate func bjs_moduleAdd_extern(_ lhs: Int32, _ rhs: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_moduleAdd(_ lhs: Int32, _ rhs: Int32) -> Int32 { + return bjs_moduleAdd_extern(lhs, rhs) +} + +func _$moduleAdd(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int { + let lhsValue = lhs.bridgeJSLowerParameter() + let rhsValue = rhs.bridgeJSLowerParameter() + let ret = bjs_moduleAdd(lhsValue, rhsValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_moduleRenamed") +fileprivate func bjs_moduleRenamed_extern() -> Int32 +#else +fileprivate func bjs_moduleRenamed_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_moduleRenamed() -> Int32 { + return bjs_moduleRenamed_extern() +} + +func _$moduleRenamed() throws(JSException) -> String { + let ret = bjs_moduleRenamed() + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ModuleCounter_init") +fileprivate func bjs_ModuleCounter_init_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_init_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_init(_ value: Int32) -> Int32 { + return bjs_ModuleCounter_init_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ModuleCounter_create_static") +fileprivate func bjs_ModuleCounter_create_static_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_create_static_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_create_static(_ value: Int32) -> Int32 { + return bjs_ModuleCounter_create_static_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ModuleCounter_value_get") +fileprivate func bjs_ModuleCounter_value_get_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_value_get_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_value_get(_ self: Int32) -> Int32 { + return bjs_ModuleCounter_value_get_extern(self) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ModuleCounter_value_set") +fileprivate func bjs_ModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void +#else +fileprivate func bjs_ModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_value_set(_ self: Int32, _ newValue: Int32) -> Void { + return bjs_ModuleCounter_value_set_extern(self, newValue) +} + +#if arch(wasm32) +@_extern(wasm, module: "TestModule", name: "bjs_ModuleCounter_increment") +fileprivate func bjs_ModuleCounter_increment_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_increment_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_increment(_ self: Int32) -> Int32 { + return bjs_ModuleCounter_increment_extern(self) +} + +func _$ModuleCounter_init(_ value: Int) throws(JSException) -> JSObject { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_init(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return JSObject.bridgeJSLiftReturn(ret) +} + +func _$ModuleCounter_create(_ value: Int) throws(JSException) -> ModuleCounter { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_create_static(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return ModuleCounter.bridgeJSLiftReturn(ret) +} + +func _$ModuleCounter_value_get(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_value_get(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +func _$ModuleCounter_value_set(_ self: JSObject, _ newValue: Int) throws(JSException) -> Void { + let selfValue = self.bridgeJSLowerParameter() + let newValueValue = newValue.bridgeJSLowerParameter() + bjs_ModuleCounter_value_set(selfValue, newValueValue) + if let error = _swift_js_take_exception() { + throw error + } +} + +func _$ModuleCounter_increment(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_increment(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__/BridgeJSLinkTests/JSImportModule.d.ts b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.d.ts new file mode 100644 index 000000000..624691d83 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.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 ModuleCounter { + increment(): number; + value: 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/JSImportModule.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js new file mode 100644 index 000000000..cb4767f03 --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.js @@ -0,0 +1,299 @@ +// 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 * 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"; + +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_moduleVersion_get"] = function bjs_moduleVersion_get() { + try { + let ret = __bjs_imported_module_0.version; + tmpRetBytes = textEncoder.encode(ret); + return tmpRetBytes.length; + } catch (error) { + setException(error); + } + } + TestModule["bjs_moduleAdd"] = function bjs_moduleAdd(lhs, rhs) { + try { + let ret = __bjs_imported_module_0.moduleAdd(lhs, rhs); + return ret; + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_moduleRenamed"] = function bjs_moduleRenamed() { + try { + let ret = __bjs_imported_module_0.renamedFunction(); + tmpRetBytes = textEncoder.encode(ret); + return tmpRetBytes.length; + } catch (error) { + setException(error); + } + } + TestModule["bjs_ModuleCounter_init"] = function bjs_ModuleCounter_init(value) { + try { + return swift.memory.retain(new __bjs_imported_module_1.ModuleCounter(value)); + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ModuleCounter_value_get"] = function bjs_ModuleCounter_value_get(self) { + try { + let ret = swift.memory.getObject(self).value; + return ret; + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ModuleCounter_value_set"] = function bjs_ModuleCounter_value_set(self, newValue) { + try { + swift.memory.getObject(self).value = newValue; + } catch (error) { + setException(error); + } + } + TestModule["bjs_ModuleCounter_create_static"] = function bjs_ModuleCounter_create_static(value) { + try { + let ret = __bjs_imported_module_1.ModuleCounter.create(value); + return swift.memory.retain(ret); + } catch (error) { + setException(error); + return 0 + } + } + TestModule["bjs_ModuleCounter_increment"] = function bjs_ModuleCounter_increment(self) { + try { + let ret = swift.memory.getObject(self).increment(); + 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/JSImportModule.modules.js b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.modules.js new file mode 100644 index 000000000..78325f2bc --- /dev/null +++ b/Plugins/BridgeJS/Tests/BridgeJSToolTests/__Snapshots__/BridgeJSLinkTests/JSImportModule.modules.js @@ -0,0 +1,29 @@ +// bridge-js-modules/TestModule/Modules/JSImportModule.mjs +export function moduleAdd(lhs, rhs) { + return lhs + rhs; +} + +export function renamedFunction() { + return "renamed"; +} + +export const version = "1.0"; + +// bridge-js-modules/TestModule/Modules/ModuleCounter.mjs +export class ModuleCounter { + constructor(value) { + this.value = value; + } + + static create(value) { + return new ModuleCounter(value); + } + + increment() { + return ++this.value; + } + + setValue(value) { + this.value = value; + } +} diff --git a/Plugins/PackageToJS/Sources/PackageToJS.swift b/Plugins/PackageToJS/Sources/PackageToJS.swift index ff3e2ce5a..ef697fe7a 100644 --- a/Plugins/PackageToJS/Sources/PackageToJS.swift +++ b/Plugins/PackageToJS/Sources/PackageToJS.swift @@ -281,6 +281,13 @@ protocol PackagingSystem { } extension PackagingSystem { + func recreateDirectory(atPath: String) throws { + if FileManager.default.fileExists(atPath: atPath) { + try FileManager.default.removeItem(atPath: atPath) + } + try createDirectory(atPath: atPath) + } + func createDirectory(atPath: String) throws { guard !FileManager.default.fileExists(atPath: atPath) else { return } try FileManager.default.createDirectory( @@ -597,21 +604,40 @@ struct PackagingPlanner { if skeletons.count > 0 { let bridgeJs = outputDir.appending(path: "bridge-js.js") let bridgeDts = outputDir.appending(path: "bridge-js.d.ts") + let bridgeModules = outputDir.appending(path: "bridge-js-modules") + let linkBridgeJS: (MiniMake.VariableScope) throws -> BridgeJSLinkOutput = { scope in + var link = BridgeJSLink( + sharedMemory: Self.isSharedMemoryEnabled(triple: triple) + ) + for skeletonPath in skeletons { + let url = URL(fileURLWithPath: scope.resolve(path: skeletonPath).path) + try link.addSkeletonFile(data: Data(contentsOf: url)) + } + return try link.link() + } packageInputs.append( make.addTask(inputFiles: skeletons + [selfPath], output: bridgeJs) { _, scope in - var link = BridgeJSLink( - sharedMemory: Self.isSharedMemoryEnabled(triple: triple) + let output = try linkBridgeJS(scope) + try system.writeFile( + atPath: scope.resolve(path: bridgeJs).path, + content: Data(output.outputJs.utf8) ) - - // Decode skeleton format - for skeletonPath in skeletons { - let data = try Data(contentsOf: URL(fileURLWithPath: scope.resolve(path: skeletonPath).path)) - try link.addSkeletonFile(data: data) + try system.writeFile( + atPath: scope.resolve(path: bridgeDts).path, + content: Data(output.outputDts.utf8) + ) + } + ) + packageInputs.append( + make.addTask(inputFiles: skeletons + [selfPath], output: bridgeModules) { _, scope in + let output = try linkBridgeJS(scope) + let modulesDirectory = scope.resolve(path: bridgeModules) + try system.recreateDirectory(atPath: modulesDirectory.path) + for module in output.modules { + let destination = scope.resolve(path: outputDir.appending(path: module.relativePath)) + try system.createDirectory(atPath: destination.deletingLastPathComponent().path) + try system.writeFile(atPath: destination.path, content: Data(module.source.utf8)) } - - let (outputJs, outputDts) = try link.link() - try system.writeFile(atPath: scope.resolve(path: bridgeJs).path, content: Data(outputJs.utf8)) - try system.writeFile(atPath: scope.resolve(path: bridgeDts).path, content: Data(outputDts.utf8)) } ) } diff --git a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript-into-Swift.md b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript-into-Swift.md index 14fccbfc1..daf031008 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript-into-Swift.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Importing-JavaScript-into-Swift.md @@ -25,10 +25,11 @@ Add the BridgeJS plugin and enable the Extern feature as described in - -- \ No newline at end of file +- 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 4302e0e49..185b47d1f 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 @@ -24,6 +24,28 @@ import JavaScriptKit If the class is on `globalThis`, add `from: .global` to `@JSClass` and omit the type from `getImports()` in the next step. +For a class shipped as an ECMAScript module, put the origin on `@JSClass`: + +```javascript +// JavaScript/greeter.js +export class Greeter { + constructor(name) { this.name = name; } + static named(name) { return new Greeter(name); } + greet() { return `Hello, ${this.name}!`; } +} +``` + +```swift +@JSClass(from: .module("/JavaScript/greeter.js")) +struct Greeter { + @JSFunction init(_ name: String) throws(JSException) + @JSFunction static func named(_ name: String) throws(JSException) -> Greeter + @JSFunction func greet() throws(JSException) -> String +} +``` + +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 **If you chose injection:** Implement the class in JavaScript and pass it in `getImports()`. @@ -47,7 +69,7 @@ const { exports } = await init({ }); ``` -**If you chose global:** Do not pass the class in `getImports()`; the runtime will resolve it from `globalThis`. +**If you chose global or module-backed lookup:** Do not pass the class in `getImports()`; the runtime resolves it from `globalThis` or the copied module. ## Macro options 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 64475acfa..301b9d5ac 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 @@ -17,6 +17,20 @@ import JavaScriptKit To bind a function that lives on the JavaScript global object (e.g. `parseInt`, `setTimeout`), add `from: .global`. Use `jsName` when the Swift name differs from the JavaScript name - see the ``JSFunction(jsName:from:)`` API reference for options. +To ship the function with the Swift target, put it in a `.js` or `.mjs` ECMAScript module and use a target-rooted path: + +```javascript +// JavaScript/math.js +export function add(a, b) { return a + b; } +``` + +```swift +@JSFunction(from: .module("/JavaScript/math.js")) +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 embedded and imported only once. `jsName` selects a differently named export, otherwise BridgeJS uses the normalized Swift name. + ### 2. Provide the implementation at initialization Return the corresponding function(s) in the object passed to `getImports()` when initializing the WebAssembly module. @@ -35,7 +49,7 @@ const { exports } = await init({ }); ``` -If you used `from: .global`, do not pass the function in `getImports()`; the runtime resolves it from `globalThis`. +If you used `from: .global` or `.module`, do not pass the function in `getImports()`. Module-only bindings do not require `getImports()` at all. ### 3. Handle errors 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 044ebbe52..7c721259b 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 @@ -17,6 +17,20 @@ import JavaScriptKit To bind a variable that is not on `globalThis`, omit `from: .global` and supply the value in `getImports()` in the next step. Use `jsName` when the Swift name differs from the JavaScript property name - see the ``JSGetter(jsName:from:)`` API reference. +A top-level getter can also read a named export from a target-rooted module: + +```javascript +// JavaScript/config.js +export const environment = "production"; +``` + +```swift +@JSGetter(jsName: "environment", from: .module("/JavaScript/config.js")) +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. + ### 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 83213aca1..238bc8687 100644 --- a/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md +++ b/Sources/JavaScriptKit/Documentation.docc/Articles/BridgeJS/Unsupported-Features.md @@ -6,6 +6,14 @@ 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 + +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. + +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. + +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. + ## Type usage crossing module boundary ### Exporting Swift: extending types from another Swift module diff --git a/Sources/JavaScriptKit/Macros.swift b/Sources/JavaScriptKit/Macros.swift index 191cf15d6..7a1bb4091 100644 --- a/Sources/JavaScriptKit/Macros.swift +++ b/Sources/JavaScriptKit/Macros.swift @@ -9,8 +9,11 @@ public enum JSEnumStyle: String { /// Controls where BridgeJS reads imported JS values from. /// /// - `global`: Read from `globalThis`. -public enum JSImportFrom: String { +/// - `module`: Read a named export from an ECMAScript module file rooted at the Swift target. +public enum JSImportFrom { case global + /// Read from an ECMAScript module file using a `/`-prefixed path rooted at the Swift target directory. + case module(String) } /// A macro that exposes Swift functions, classes, and methods to JavaScript. @@ -141,6 +144,7 @@ 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. @attached(accessor) public macro JSGetter(jsName: String? = nil, from: JSImportFrom? = nil) = #externalMacro(module: "BridgeJSMacros", type: "JSGetterMacro") @@ -180,6 +184,7 @@ 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. @attached(body) public macro JSFunction(jsName: String? = nil, from: JSImportFrom? = nil) = #externalMacro(module: "BridgeJSMacros", type: "JSFunctionMacro") @@ -204,6 +209,7 @@ 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. @attached(member, names: named(jsObject), named(init(unsafelyWrapping:))) @attached(extension, conformances: _JSBridgedClass) public macro JSClass(jsName: String? = nil, from: JSImportFrom? = nil) = diff --git a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift index a3104e685..47a8971aa 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift +++ b/Tests/BridgeJSRuntimeTests/Generated/BridgeJS.swift @@ -8,6 +8,8 @@ @_spi(BridgeJS) import JavaScriptKit +// BridgeJS JavaScript module fingerprint: 76f49b4593a4f170 + #if arch(wasm32) @_extern(wasm, module: "bjs", name: "invoke_js_callback_BridgeJSRuntimeTests_20BridgeJSRuntimeTests10HttpStatusO_Si") fileprivate func invoke_js_callback_BridgeJSRuntimeTests_20BridgeJSRuntimeTests10HttpStatusO_Si_extern(_ callback: Int32, _ param0: Int32) -> Int32 @@ -16918,6 +16920,173 @@ func _$JSClassSupportImports_makeJSClassWithArrayMembers(_ numbers: [Int], _ lab return JSClassWithArrayMembers.bridgeJSLiftReturn(ret) } +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_moduleVersion_get") +fileprivate func bjs_moduleVersion_get_extern() -> Int32 +#else +fileprivate func bjs_moduleVersion_get_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_moduleVersion_get() -> Int32 { + return bjs_moduleVersion_get_extern() +} + +func _$moduleVersion_get() throws(JSException) -> String { + let ret = bjs_moduleVersion_get() + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_moduleAdd") +fileprivate func bjs_moduleAdd_extern(_ lhs: Int32, _ rhs: Int32) -> Int32 +#else +fileprivate func bjs_moduleAdd_extern(_ lhs: Int32, _ rhs: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_moduleAdd(_ lhs: Int32, _ rhs: Int32) -> Int32 { + return bjs_moduleAdd_extern(lhs, rhs) +} + +func _$moduleAdd(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int { + let lhsValue = lhs.bridgeJSLowerParameter() + let rhsValue = rhs.bridgeJSLowerParameter() + let ret = bjs_moduleAdd(lhsValue, rhsValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_moduleRenamed") +fileprivate func bjs_moduleRenamed_extern() -> Int32 +#else +fileprivate func bjs_moduleRenamed_extern() -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_moduleRenamed() -> Int32 { + return bjs_moduleRenamed_extern() +} + +func _$moduleRenamed() throws(JSException) -> String { + let ret = bjs_moduleRenamed() + if let error = _swift_js_take_exception() { + throw error + } + return String.bridgeJSLiftReturn(ret) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ModuleCounter_init") +fileprivate func bjs_ModuleCounter_init_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_init_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_init(_ value: Int32) -> Int32 { + return bjs_ModuleCounter_init_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ModuleCounter_create_static") +fileprivate func bjs_ModuleCounter_create_static_extern(_ value: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_create_static_extern(_ value: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_create_static(_ value: Int32) -> Int32 { + return bjs_ModuleCounter_create_static_extern(value) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ModuleCounter_value_get") +fileprivate func bjs_ModuleCounter_value_get_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_value_get_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_value_get(_ self: Int32) -> Int32 { + return bjs_ModuleCounter_value_get_extern(self) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ModuleCounter_value_set") +fileprivate func bjs_ModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void +#else +fileprivate func bjs_ModuleCounter_value_set_extern(_ self: Int32, _ newValue: Int32) -> Void { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_value_set(_ self: Int32, _ newValue: Int32) -> Void { + return bjs_ModuleCounter_value_set_extern(self, newValue) +} + +#if arch(wasm32) +@_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_ModuleCounter_increment") +fileprivate func bjs_ModuleCounter_increment_extern(_ self: Int32) -> Int32 +#else +fileprivate func bjs_ModuleCounter_increment_extern(_ self: Int32) -> Int32 { + fatalError("Only available on WebAssembly") +} +#endif +@inline(never) fileprivate func bjs_ModuleCounter_increment(_ self: Int32) -> Int32 { + return bjs_ModuleCounter_increment_extern(self) +} + +func _$ModuleCounter_init(_ value: Int) throws(JSException) -> JSObject { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_init(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return JSObject.bridgeJSLiftReturn(ret) +} + +func _$ModuleCounter_create(_ value: Int) throws(JSException) -> ModuleCounter { + let valueValue = value.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_create_static(valueValue) + if let error = _swift_js_take_exception() { + throw error + } + return ModuleCounter.bridgeJSLiftReturn(ret) +} + +func _$ModuleCounter_value_get(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_value_get(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + +func _$ModuleCounter_value_set(_ self: JSObject, _ newValue: Int) throws(JSException) -> Void { + let selfValue = self.bridgeJSLowerParameter() + let newValueValue = newValue.bridgeJSLowerParameter() + bjs_ModuleCounter_value_set(selfValue, newValueValue) + if let error = _swift_js_take_exception() { + throw error + } +} + +func _$ModuleCounter_increment(_ self: JSObject) throws(JSException) -> Int { + let selfValue = self.bridgeJSLowerParameter() + let ret = bjs_ModuleCounter_increment(selfValue) + if let error = _swift_js_take_exception() { + throw error + } + return Int.bridgeJSLiftReturn(ret) +} + #if arch(wasm32) @_extern(wasm, module: "BridgeJSRuntimeTests", name: "bjs_JSTypedArrayImports_jsCreateUint8Array_static") fileprivate func bjs_JSTypedArrayImports_jsCreateUint8Array_static_extern() -> Int32 diff --git a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json index 451a3213d..fdc18ed25 100644 --- a/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json +++ b/Tests/BridgeJSRuntimeTests/Generated/JavaScript/BridgeJS.json @@ -23981,6 +23981,187 @@ } ] }, + { + "functions" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : "\/Modules\/JSImportModule.mjs", + "name" : "moduleAdd", + "parameters" : [ + { + "name" : "lhs", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + }, + { + "name" : "rhs", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + }, + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "from" : "\/Modules\/JSImportModule.mjs", + "jsName" : "renamedFunction", + "name" : "moduleRenamed", + "parameters" : [ + + ], + "returnType" : { + "string" : { + + } + } + } + ], + "globalGetters" : [ + { + "accessLevel" : "internal", + "from" : "\/Modules\/JSImportModule.mjs", + "jsName" : "version", + "name" : "moduleVersion", + "type" : { + "string" : { + + } + } + } + ], + "types" : [ + { + "accessLevel" : "internal", + "constructor" : { + "accessLevel" : "internal", + "parameters" : [ + { + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ] + }, + "from" : "\/Modules\/ModuleCounter.mjs", + "getters" : [ + { + "accessLevel" : "internal", + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "methods" : [ + { + "accessLevel" : "internal", + "effects" : { + "isAsync" : false, + "isStatic" : false, + "isThrows" : true + }, + "name" : "increment", + "parameters" : [ + + ], + "returnType" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "name" : "ModuleCounter", + "setters" : [ + { + "accessLevel" : "internal", + "functionName" : "value_set", + "name" : "value", + "type" : { + "integer" : { + "_0" : { + "isSigned" : true, + "width" : "word" + } + } + } + } + ], + "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" : "ModuleCounter" + } + } + } + ] + } + ] + }, { "functions" : [ @@ -24864,6 +25045,16 @@ } ] } + ], + "modules" : [ + { + "path" : "\/Modules\/JSImportModule.mjs", + "source" : "export function moduleAdd(lhs, rhs) {\n return lhs + rhs;\n}\n\nexport function renamedFunction() {\n return \"loaded from a module\";\n}\n\nexport const version = \"module-v1\";\n" + }, + { + "path" : "\/Modules\/ModuleCounter.mjs", + "source" : "export class ModuleCounter {\n constructor(value) {\n this.value = value;\n }\n\n static create(value) {\n return new ModuleCounter(value);\n }\n\n increment() {\n return ++this.value;\n }\n\n setValue(value) {\n this.value = value;\n }\n}\n" + } ] }, "moduleName" : "BridgeJSRuntimeTests", diff --git a/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift b/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift new file mode 100644 index 000000000..61a61215a --- /dev/null +++ b/Tests/BridgeJSRuntimeTests/JSImportModuleTests.swift @@ -0,0 +1,38 @@ +import JavaScriptKit +import XCTest + +@JSFunction(from: .module("/Modules/JSImportModule.mjs")) +func moduleAdd(_ lhs: Int, _ rhs: Int) throws(JSException) -> Int + +@JSFunction(jsName: "renamedFunction", from: .module("/Modules/JSImportModule.mjs")) +func moduleRenamed() throws(JSException) -> String + +@JSGetter(jsName: "version", from: .module("/Modules/JSImportModule.mjs")) +var moduleVersion: String + +@JSClass(from: .module("/Modules/ModuleCounter.mjs")) +struct ModuleCounter { + @JSFunction init(_ value: Int) throws(JSException) + @JSFunction static func create(_ value: Int) throws(JSException) -> ModuleCounter + @JSFunction func increment() throws(JSException) -> Int + @JSGetter var value: Int + @JSSetter func setValue(_ value: Int) throws(JSException) +} + +final class JSImportModuleTests: XCTestCase { + func testModuleFunctionAndGetter() throws { + XCTAssertEqual(try moduleAdd(20, 22), 42) + XCTAssertEqual(try moduleRenamed(), "loaded from a module") + XCTAssertEqual(try moduleVersion, "module-v1") + } + + func testModuleClassStaticAndInstanceAPIs() throws { + let constructed = try ModuleCounter(3) + XCTAssertEqual(try constructed.increment(), 4) + try constructed.setValue(9) + XCTAssertEqual(try constructed.value, 9) + + let created = try ModuleCounter.create(40) + XCTAssertEqual(try created.increment(), 41) + } +} diff --git a/Tests/BridgeJSRuntimeTests/Modules/JSImportModule.mjs b/Tests/BridgeJSRuntimeTests/Modules/JSImportModule.mjs new file mode 100644 index 000000000..6aa166d88 --- /dev/null +++ b/Tests/BridgeJSRuntimeTests/Modules/JSImportModule.mjs @@ -0,0 +1,9 @@ +export function moduleAdd(lhs, rhs) { + return lhs + rhs; +} + +export function renamedFunction() { + return "loaded from a module"; +} + +export const version = "module-v1"; diff --git a/Tests/BridgeJSRuntimeTests/Modules/ModuleCounter.mjs b/Tests/BridgeJSRuntimeTests/Modules/ModuleCounter.mjs new file mode 100644 index 000000000..5a33b7ffa --- /dev/null +++ b/Tests/BridgeJSRuntimeTests/Modules/ModuleCounter.mjs @@ -0,0 +1,17 @@ +export class ModuleCounter { + constructor(value) { + this.value = value; + } + + static create(value) { + return new ModuleCounter(value); + } + + increment() { + return ++this.value; + } + + setValue(value) { + this.value = value; + } +}