From a732a0c45fe8dd6a7f5a1503926cac439f6f1015 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Thu, 6 Mar 2025 17:58:13 +0900 Subject: [PATCH 01/21] Concurrency: Relax WebWorkerTaskExecutor.installGlobalExecutor() isolation requirement Avoid breaking existing code as much as possible just for the sake of trivial "safety". --- Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift b/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift index ac4769a82..14b13eee9 100644 --- a/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift +++ b/Sources/JavaScriptEventLoop/WebWorkerTaskExecutor.swift @@ -434,8 +434,14 @@ public final class WebWorkerTaskExecutor: TaskExecutor { /// Install a global executor that forwards jobs from Web Worker threads to the main thread. /// /// This function must be called once before using the Web Worker task executor. - @MainActor public static func installGlobalExecutor() { + MainActor.assumeIsolated { + installGlobalExecutorIsolated() + } + } + + @MainActor + static func installGlobalExecutorIsolated() { #if canImport(wasi_pthread) && compiler(>=6.1) && _runtime(_multithreaded) // Ensure this function is called only once. guard _mainThread == nil else { return } From 61e93a9046d724723ada8c1841bb4edb55745405 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Fri, 28 Feb 2025 12:58:06 +0000 Subject: [PATCH 02/21] Add initial packager plugin This is very much a work in progress. It's just a proof of concept at this point and just works for very simple examples. The plugin invocation is as follows: ``` swift package --swift-sdk wasm32-unknown-wasi js ``` --- Package.swift | 7 + Plugins/PackageToJS/MiniMake.swift | 168 +++++++++++++++ Plugins/PackageToJS/PackageToJS.swift | 247 +++++++++++++++++++++++ Plugins/PackageToJS/Templates/index.d.ts | 59 ++++++ Plugins/PackageToJS/Templates/index.js | 66 ++++++ 5 files changed, 547 insertions(+) create mode 100644 Plugins/PackageToJS/MiniMake.swift create mode 100644 Plugins/PackageToJS/PackageToJS.swift create mode 100644 Plugins/PackageToJS/Templates/index.d.ts create mode 100644 Plugins/PackageToJS/Templates/index.js diff --git a/Package.swift b/Package.swift index 4d4634b88..b8a6b878b 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,7 @@ let package = Package( .library(name: "JavaScriptEventLoop", targets: ["JavaScriptEventLoop"]), .library(name: "JavaScriptBigIntSupport", targets: ["JavaScriptBigIntSupport"]), .library(name: "JavaScriptEventLoopTestSupport", targets: ["JavaScriptEventLoopTestSupport"]), + .plugin(name: "PackageToJS", targets: ["PackageToJS"]), ], targets: [ .target( @@ -71,5 +72,11 @@ let package = Package( "JavaScriptEventLoopTestSupport" ] ), + .plugin( + name: "PackageToJS", + capability: .command( + intent: .custom(verb: "js", description: "Convert a Swift package to a JavaScript package") + ) + ), ] ) diff --git a/Plugins/PackageToJS/MiniMake.swift b/Plugins/PackageToJS/MiniMake.swift new file mode 100644 index 000000000..839de22e8 --- /dev/null +++ b/Plugins/PackageToJS/MiniMake.swift @@ -0,0 +1,168 @@ +import Foundation + +/// A simple build system +struct MiniMake { + /// Attributes of a task + enum TaskAttribute { + /// Task is phony, meaning it must be built even if its inputs are up to date + case phony + } + /// A task to build + struct Task { + /// Key of the task + let key: TaskKey + /// Display name of the task + let displayName: String + /// Input tasks not yet built + var wants: Set + /// Set of files that must be built before this task + let inputs: [String] + /// Output task name + let output: String + /// Attributes of the task + let attributes: Set + /// Build operation + let build: (Task) throws -> Void + /// Whether the task is done + var isDone: Bool + } + + /// A task key + struct TaskKey: Hashable, Comparable, CustomStringConvertible { + let id: String + var description: String { self.id } + + fileprivate init(id: String) { + self.id = id + } + + static func < (lhs: TaskKey, rhs: TaskKey) -> Bool { lhs.id < rhs.id } + } + + private var tasks: [TaskKey: Task] + private var shouldExplain: Bool + /// Current working directory at the time the build started + private let buildCwd: String + + init(explain: Bool = false) { + self.tasks = [:] + self.shouldExplain = explain + self.buildCwd = FileManager.default.currentDirectoryPath + } + + mutating func addTask(inputFiles: [String] = [], inputTasks: [TaskKey] = [], output: String, attributes: Set = [], build: @escaping (Task) throws -> Void) -> TaskKey { + let displayName = output.hasPrefix(self.buildCwd) ? String(output.dropFirst(self.buildCwd.count + 1)) : output + let taskKey = TaskKey(id: output) + self.tasks[taskKey] = Task(key: taskKey, displayName: displayName, wants: Set(inputTasks), inputs: inputFiles, output: output, attributes: attributes, build: build, isDone: false) + return taskKey + } + + private func explain(_ message: @autoclosure () -> String) { + if self.shouldExplain { + print(message()) + } + } + + private func violated(_ message: @autoclosure () -> String) { + print(message()) + } + + /// Prints progress of the build + struct ProgressPrinter { + /// Total number of tasks to build + let total: Int + /// Number of tasks built so far + var built: Int + + init(total: Int) { + self.total = total + self.built = 0 + } + + private static var green: String { "\u{001B}[32m" } + private static var yellow: String { "\u{001B}[33m" } + private static var reset: String { "\u{001B}[0m" } + + mutating func started(_ task: Task) { + self.print(task.displayName, "\(Self.green)building\(Self.reset)") + } + + mutating func skipped(_ task: Task) { + self.print(task.displayName, "\(Self.yellow)skipped\(Self.reset)") + } + + private mutating func print(_ subjectPath: String, _ message: @autoclosure () -> String) { + Swift.print("[\(self.built + 1)/\(self.total)] \(subjectPath): \(message())") + self.built += 1 + } + } + + private func computeTotalTasks(task: Task) -> Int { + var visited = Set() + func visit(task: Task) -> Int { + guard !visited.contains(task.key) else { return 0 } + visited.insert(task.key) + var total = 1 + for want in task.wants { + total += visit(task: self.tasks[want]!) + } + return total + } + return visit(task: task) + } + + mutating func build(output: TaskKey) throws { + /// Returns true if any of the task's inputs have a modification date later than the task's output + func shouldBuild(task: Task) -> Bool { + if task.attributes.contains(.phony) { + return true + } + let outputURL = URL(fileURLWithPath: task.output) + if !FileManager.default.fileExists(atPath: task.output) { + explain("Task \(task.output) should be built because it doesn't exist") + return true + } + let outputMtime = try? outputURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + return task.inputs.contains { input in + let inputURL = URL(fileURLWithPath: input) + // Ignore directory modification times + var isDirectory: ObjCBool = false + let fileExists = FileManager.default.fileExists(atPath: input, isDirectory: &isDirectory) + if fileExists && isDirectory.boolValue { + return false + } + + let inputMtime = try? inputURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + let shouldBuild = outputMtime == nil || inputMtime == nil || outputMtime! < inputMtime! + if shouldBuild { + explain("Task \(task.output) should be re-built because \(input) is newer: \(outputMtime?.timeIntervalSince1970 ?? 0) < \(inputMtime?.timeIntervalSince1970 ?? 0)") + } + return shouldBuild + } + } + var progressPrinter = ProgressPrinter(total: self.computeTotalTasks(task: self.tasks[output]!)) + + func runTask(taskKey: TaskKey) throws { + guard var task = self.tasks[taskKey] else { + violated("Task \(taskKey) not found") + return + } + guard !task.isDone else { return } + + // Build dependencies first + for want in task.wants { + try runTask(taskKey: want) + } + + if shouldBuild(task: task) { + progressPrinter.started(task) + try task.build(task) + } else { + progressPrinter.skipped(task) + } + task.isDone = true + self.tasks[taskKey] = task + } + try runTask(taskKey: output) + } +} diff --git a/Plugins/PackageToJS/PackageToJS.swift b/Plugins/PackageToJS/PackageToJS.swift new file mode 100644 index 000000000..9311c0691 --- /dev/null +++ b/Plugins/PackageToJS/PackageToJS.swift @@ -0,0 +1,247 @@ +import PackagePlugin +import Foundation + +struct PackageToJSError: Swift.Error, CustomStringConvertible { + let description: String + + init(_ message: String) { + self.description = "Error: " + message + } +} + +@main +struct PackageToJS: CommandPlugin { + struct Options { + var product: String? + var packageName: String? + var explain: Bool = false + + static func parse(from extractor: inout ArgumentExtractor) -> Options { + let product = extractor.extractOption(named: "product").last + let packageName = extractor.extractOption(named: "package-name").last + let explain = extractor.extractFlag(named: "explain") + return Options(product: product, packageName: packageName, explain: explain != 0) + } + } + + func performCommand(context: PluginContext, arguments: [String]) throws { + var extractor = ArgumentExtractor(arguments) + let options = Options.parse(from: &extractor) + + let productName = try options.product ?? deriveDefaultProduct(package: context.package) + // Build products + var parameters = PackageManager.BuildParameters( + configuration: .inherit, + logging: .concise + ) + parameters.echoLogs = true + let buildingForEmbedded = ProcessInfo.processInfo.environment["JAVASCRIPTKIT_EXPERIMENTAL_EMBEDDED_WASM"].flatMap(Bool.init) ?? false + if !buildingForEmbedded { + // NOTE: We only support static linking for now, and the new SwiftDriver + // does not infer `-static-stdlib` for WebAssembly targets intentionally + // for future dynamic linking support. + parameters.otherSwiftcFlags = ["-static-stdlib", "-Xclang-linker", "-mexec-model=reactor"] + parameters.otherLinkerFlags = ["--export-if-defined=__main_argc_argv"] + } + + let build = try self.packageManager.build(.product(productName), parameters: parameters) + + guard build.succeeded else { + print(build.logText) + exit(1) + } + + guard let product = try context.package.products(named: [productName]).first else { + throw PackageToJSError("Failed to find product named \"\(productName)\"") + } + guard let executableProduct = product as? ExecutableProduct else { + throw PackageToJSError("Product type of \"\(productName)\" is not supported. Only executable products are supported.") + } + + let productArtifact = try build.findWasmArtifact(for: productName) + let resourcesPaths = deriveResourcesPaths( + productArtifactPath: productArtifact.path, + sourceTargets: executableProduct.targets, + package: context.package + ) + + let outputDir = context.pluginWorkDirectory.appending(subpath: "Package") + guard let selfPackage = findPackageInDependencies(package: context.package, id: "javascriptkit") else { + throw PackageToJSError("Failed to find JavaScriptKit in dependencies!?") + } + var make = MiniMake(explain: options.explain) + let allTask = constructBuild(make: &make, options: options, context: context, wasmProductArtifact: productArtifact, selfPackage: selfPackage, outputDir: outputDir) + try make.build(output: allTask) + print("Build finished") + } + + /// Construct the build plan and return the root task key + private func constructBuild( + make: inout MiniMake, + options: Options, + context: PluginContext, + wasmProductArtifact: PackageManager.BuildResult.BuiltArtifact, + selfPackage: Package, + outputDir: Path + ) -> MiniMake.TaskKey { + let selfPackageURL = selfPackage.directory + let selfPath = String(#filePath) + let outputDirTask = make.addTask(inputFiles: [selfPath], output: outputDir.string) { + guard !FileManager.default.fileExists(atPath: $0.output) else { return } + try FileManager.default.createDirectory(atPath: $0.output, withIntermediateDirectories: true, attributes: nil) + } + + var packageInputs: [MiniMake.TaskKey] = [] + + func syncFile(from: String, to: String) throws { + if FileManager.default.fileExists(atPath: to) { + try FileManager.default.removeItem(atPath: to) + } + try FileManager.default.copyItem(atPath: from, toPath: to) + } + + let wasmFilename = "main.wasm" + let wasm = make.addTask( + inputFiles: [selfPath, wasmProductArtifact.path.string], inputTasks: [outputDirTask], + output: outputDir.appending(subpath: wasmFilename).string, + // FIXME: This is a hack to ensure that the wasm file is always copied + // even when release/debug configuration is changed. + attributes: [.phony] + ) { + try syncFile(from: wasmProductArtifact.path.string, to: $0.output) + } + packageInputs.append(wasm) + + let packageJSON = make.addTask( + inputFiles: [selfPath], inputTasks: [outputDirTask], + output: outputDir.appending(subpath: "package.json").string + ) { + // Write package.json + let packageJSON = """ + { + "name": "\(options.packageName ?? context.package.id.lowercased())", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./index.js", + "./wasm": "./\(wasmFilename)" + }, + "dependencies": { + "@bjorn3/browser_wasi_shim": "^0.4.1" + } + } + """ + try packageJSON.write(toFile: $0.output, atomically: true, encoding: .utf8) + } + packageInputs.append(packageJSON) + + let substitutions = [ + "@PACKAGE_TO_JS_MODULE_PATH@": wasmFilename, + ] + for (file, output) in [ + ("Plugins/PackageToJS/Templates/index.js", "index.js"), + ("Plugins/PackageToJS/Templates/index.d.ts", "index.d.ts"), + ("Sources/JavaScriptKit/Runtime/index.mjs", "runtime.js"), + ] { + let inputPath = selfPackageURL.appending(subpath: file).string + let copied = make.addTask( + inputFiles: [selfPath, inputPath], inputTasks: [outputDirTask], + output: outputDir.appending(subpath: output).string + ) { + var content = try String(contentsOfFile: inputPath) + for (key, value) in substitutions { + content = content.replacingOccurrences(of: key, with: value) + } + try content.write(toFile: $0.output, atomically: true, encoding: .utf8) + } + packageInputs.append(copied) + } + return make.addTask(inputTasks: packageInputs, output: "all", attributes: [.phony]) { _ in } + } +} + +/// Derive default product from the package +internal func deriveDefaultProduct(package: Package) throws -> String { + let executableProducts = package.products(ofType: ExecutableProduct.self) + guard !executableProducts.isEmpty else { + throw PackageToJSError( + "Make sure there's at least one executable product in your Package.swift") + } + guard executableProducts.count == 1 else { + throw PackageToJSError( + "Failed to disambiguate the product. Pass one of \(executableProducts.map(\.name).joined(separator: ", ")) to the --product option" + ) + + } + return executableProducts[0].name +} + +/// Returns the list of resource bundle paths for the given targets +internal func deriveResourcesPaths( + productArtifactPath: Path, + sourceTargets: [any PackagePlugin.Target], + package: Package +) -> [Path] { + return deriveResourcesPaths( + buildDirectory: productArtifactPath.removingLastComponent(), + sourceTargets: sourceTargets, package: package + ) +} + +internal func deriveResourcesPaths( + buildDirectory: Path, + sourceTargets: [any PackagePlugin.Target], + package: Package +) -> [Path] { + sourceTargets.compactMap { target -> Path? in + // NOTE: The resource bundle file name is constructed from `displayName` instead of `id` for some reason + // https://github.com/apple/swift-package-manager/blob/swift-5.9.2-RELEASE/Sources/PackageLoading/PackageBuilder.swift#L908 + let bundleName = package.displayName + "_" + target.name + ".resources" + let resourcesPath = buildDirectory.appending(subpath: bundleName) + guard FileManager.default.fileExists(atPath: resourcesPath.string) else { return nil } + return resourcesPath + } +} + + +extension PackageManager.BuildResult { + /// Find `.wasm` executable artifact + internal func findWasmArtifact(for product: String) throws + -> PackageManager.BuildResult.BuiltArtifact + { + let executables = self.builtArtifacts.filter { + $0.kind == .executable && $0.path.lastComponent == "\(product).wasm" + } + guard !executables.isEmpty else { + throw PackageToJSError( + "Failed to find '\(product).wasm' from executable artifacts of product '\(product)'") + } + guard executables.count == 1, let executable = executables.first else { + throw PackageToJSError( + "Failed to disambiguate executable product artifacts from \(executables.map(\.path.string).joined(separator: ", "))" + ) + } + return executable + } +} + +private func findPackageInDependencies(package: Package, id: Package.ID) -> Package? { + var visited: Set = [] + func visit(package: Package) -> Package? { + if visited.contains(package.id) { return nil } + visited.insert(package.id) + for dependency in package.dependencies { + let dependencyPackage = dependency.package + if dependencyPackage.id == id { + return dependencyPackage + } + } + for dependency in package.dependencies { + if let found = visit(package: dependency.package) { + return found + } + } + return nil + } + return visit(package: package) +} diff --git a/Plugins/PackageToJS/Templates/index.d.ts b/Plugins/PackageToJS/Templates/index.d.ts new file mode 100644 index 000000000..768ac9c03 --- /dev/null +++ b/Plugins/PackageToJS/Templates/index.d.ts @@ -0,0 +1,59 @@ +/* export */ type Import = { + // TODO: Generate type from imported .d.ts files +} +/* export */ type Export = { + // TODO: Generate type from .swift files +} + +/** + * The path to the WebAssembly module relative to the root of the package + */ +export declare const MODULE_PATH: string; + +/** + * Low-level interface to create an instance of a WebAssembly module + * + * This is used to have full control over the instantiation process + * and to add custom imports. + */ +/* export */ interface Instantiator { + /** + * Add imports to the WebAssembly module + * @param imports - The imports to add + */ + addImports(imports: WebAssembly.Imports): void + + /** + * Create an interface to access exposed functionalities + * @param instance - The instance of the WebAssembly module + * @returns The interface to access the exposed functionalities + */ + createExports(instance: WebAssembly.Instance): Export +} + +/** + * Create an instantiator for the given imports + * @param imports - The imports to add + * @param options - The options + */ +/* export */ function createInstantiator( + imports: Import, + options: {} | undefined +): Promise + +/** + * Instantiate the given WebAssembly module + * + * This is a convenience function that creates an instantiator and instantiates the module. + * @param moduleSource - The WebAssembly module to instantiate + * @param imports - The imports to add + * @param options - The options + */ +export function instantiate( + moduleSource: WebAssembly.Module | Response | PromiseLike, + imports: Import, + options: {} | undefined +): Promise<{ + instance: WebAssembly.Instance, + exports: Export +}> diff --git a/Plugins/PackageToJS/Templates/index.js b/Plugins/PackageToJS/Templates/index.js new file mode 100644 index 000000000..cc1665b0b --- /dev/null +++ b/Plugins/PackageToJS/Templates/index.js @@ -0,0 +1,66 @@ +// @ts-check +import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from '@bjorn3/browser_wasi_shim'; +// @ts-ignore +import { SwiftRuntime } from "./runtime.js" +export const MODULE_PATH = "@PACKAGE_TO_JS_MODULE_PATH@"; + +/** @type {import('./index.d').createInstantiator} */ +/* export */ async function createInstantiator( + imports, + options = {} +) { + return { + addImports: () => {}, + createExports: () => { + return {}; + }, + } +} + +/** @type {import('./index.d').instantiate} */ +export async function instantiate( + moduleSource, + imports, + options +) { + const instantiator = await createInstantiator(imports, options); + const wasi = new WASI(/* args */[MODULE_PATH], /* env */[], /* fd */[ + new OpenFile(new File([])), // stdin + ConsoleStdout.lineBuffered((stdout) => { + console.log(stdout); + }), + ConsoleStdout.lineBuffered((stderr) => { + console.error(stderr); + }), + new PreopenDirectory("/", new Map()), + ]) + const swift = new SwiftRuntime(); + + /** @type {WebAssembly.Imports} */ + const importObject = { + wasi_snapshot_preview1: wasi.wasiImport, + javascript_kit: swift.wasmImports, + }; + instantiator.addImports(importObject); + + let module; + let instance; + if (moduleSource instanceof WebAssembly.Module) { + module = moduleSource; + instance = await WebAssembly.instantiate(module, importObject); + } else { + const result = await WebAssembly.instantiateStreaming(moduleSource, importObject); + module = result.module; + instance = result.instance; + } + + swift.setInstance(instance); + // @ts-ignore: "exports" of the instance is not typed + wasi.initialize(instance); + swift.main(); + + return { + instance, + exports: instantiator.createExports(instance), + } +} From 1003cc2eee73a43f62f04d9e49da0382e15d9234 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Mon, 3 Mar 2025 22:46:46 +0000 Subject: [PATCH 03/21] Update examples --- Examples/Basic/Package.swift | 2 +- Examples/Basic/build.sh | 3 ++- Examples/Basic/index.html | 12 +++++++++++- Examples/Basic/index.js | 33 --------------------------------- Examples/Embedded/build.sh | 4 ++-- Examples/Embedded/index.html | 12 +++++++++++- Examples/Embedded/index.js | 33 --------------------------------- 7 files changed, 27 insertions(+), 72 deletions(-) delete mode 100644 Examples/Basic/index.js delete mode 100644 Examples/Embedded/index.js diff --git a/Examples/Basic/Package.swift b/Examples/Basic/Package.swift index ea70e6b20..f1a80aaaa 100644 --- a/Examples/Basic/Package.swift +++ b/Examples/Basic/Package.swift @@ -17,5 +17,5 @@ let package = Package( ] ) ], - swiftLanguageVersions: [.v5] + swiftLanguageModes: [.v5] ) diff --git a/Examples/Basic/build.sh b/Examples/Basic/build.sh index 0e5761ecf..ac17e291f 100755 --- a/Examples/Basic/build.sh +++ b/Examples/Basic/build.sh @@ -1,2 +1,3 @@ #!/bin/bash -swift build --swift-sdk "${SWIFT_SDK_ID:-wasm32-unknown-wasi}" -Xswiftc -Xclang-linker -Xswiftc -mexec-model=reactor -Xlinker --export=__main_argc_argv +set -ex +swift package --swift-sdk "${SWIFT_SDK_ID:-wasm32-unknown-wasi}" -c "${1:-debug}" js diff --git a/Examples/Basic/index.html b/Examples/Basic/index.html index d94796a09..7146f8e1b 100644 --- a/Examples/Basic/index.html +++ b/Examples/Basic/index.html @@ -3,10 +3,20 @@ Getting Started + - + diff --git a/Examples/Basic/index.js b/Examples/Basic/index.js deleted file mode 100644 index e90769aa5..000000000 --- a/Examples/Basic/index.js +++ /dev/null @@ -1,33 +0,0 @@ -import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from 'https://esm.run/@bjorn3/browser_wasi_shim@0.3.0'; - -async function main(configuration = "debug") { - // Fetch our Wasm File - const response = await fetch(`./.build/${configuration}/Basic.wasm`); - // Create a new WASI system instance - const wasi = new WASI(/* args */["main.wasm"], /* env */[], /* fd */[ - new OpenFile(new File([])), // stdin - ConsoleStdout.lineBuffered((stdout) => { - console.log(stdout); - }), - ConsoleStdout.lineBuffered((stderr) => { - console.error(stderr); - }), - new PreopenDirectory("/", new Map()), - ]) - const { SwiftRuntime } = await import(`./.build/${configuration}/JavaScriptKit_JavaScriptKit.resources/Runtime/index.mjs`); - // Create a new Swift Runtime instance to interact with JS and Swift - const swift = new SwiftRuntime(); - // Instantiate the WebAssembly file - const { instance } = await WebAssembly.instantiateStreaming(response, { - wasi_snapshot_preview1: wasi.wasiImport, - javascript_kit: swift.wasmImports, - }); - // Set the WebAssembly instance to the Swift Runtime - swift.setInstance(instance); - // Start the WebAssembly WASI reactor instance - wasi.initialize(instance); - // Start Swift main function - swift.main() -}; - -main(); diff --git a/Examples/Embedded/build.sh b/Examples/Embedded/build.sh index 1fde1fe91..f807cdbf5 100755 --- a/Examples/Embedded/build.sh +++ b/Examples/Embedded/build.sh @@ -1,5 +1,5 @@ #!/bin/bash package_dir="$(cd "$(dirname "$0")" && pwd)" JAVASCRIPTKIT_EXPERIMENTAL_EMBEDDED_WASM=true \ - swift build --package-path "$package_dir" --product EmbeddedApp \ - -c release --triple wasm32-unknown-none-wasm + swift package --package-path "$package_dir" \ + -c release --triple wasm32-unknown-none-wasm js diff --git a/Examples/Embedded/index.html b/Examples/Embedded/index.html index d94796a09..7146f8e1b 100644 --- a/Examples/Embedded/index.html +++ b/Examples/Embedded/index.html @@ -3,10 +3,20 @@ Getting Started + - + diff --git a/Examples/Embedded/index.js b/Examples/Embedded/index.js deleted file mode 100644 index b95576135..000000000 --- a/Examples/Embedded/index.js +++ /dev/null @@ -1,33 +0,0 @@ -import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from 'https://esm.run/@bjorn3/browser_wasi_shim@0.3.0'; - -async function main(configuration = "release") { - // Fetch our Wasm File - const response = await fetch(`./.build/${configuration}/EmbeddedApp.wasm`); - // Create a new WASI system instance - const wasi = new WASI(/* args */["main.wasm"], /* env */[], /* fd */[ - new OpenFile(new File([])), // stdin - ConsoleStdout.lineBuffered((stdout) => { - console.log(stdout); - }), - ConsoleStdout.lineBuffered((stderr) => { - console.error(stderr); - }), - new PreopenDirectory("/", new Map()), - ]) - const { SwiftRuntime } = await import(`./_Runtime/index.mjs`); - // Create a new Swift Runtime instance to interact with JS and Swift - const swift = new SwiftRuntime(); - // Instantiate the WebAssembly file - const { instance } = await WebAssembly.instantiateStreaming(response, { - //wasi_snapshot_preview1: wasi.wasiImport, - javascript_kit: swift.wasmImports, - }); - // Set the WebAssembly instance to the Swift Runtime - swift.setInstance(instance); - // Start the WebAssembly WASI reactor instance - wasi.initialize(instance); - // Start Swift main function - swift.main() -}; - -main(); From c0671139415c9127dcad40d71054589222668b1a Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Tue, 4 Mar 2025 00:44:43 +0000 Subject: [PATCH 04/21] Provide better help message and friendly build error diagnostics --- Plugins/PackageToJS/PackageToJS.swift | 266 +++++++++++++++----------- 1 file changed, 154 insertions(+), 112 deletions(-) diff --git a/Plugins/PackageToJS/PackageToJS.swift b/Plugins/PackageToJS/PackageToJS.swift index 9311c0691..706336588 100644 --- a/Plugins/PackageToJS/PackageToJS.swift +++ b/Plugins/PackageToJS/PackageToJS.swift @@ -1,19 +1,14 @@ -import PackagePlugin import Foundation - -struct PackageToJSError: Swift.Error, CustomStringConvertible { - let description: String - - init(_ message: String) { - self.description = "Error: " + message - } -} +import PackagePlugin @main struct PackageToJS: CommandPlugin { struct Options { + /// Product to build (default: executable target if there's only one) var product: String? + /// Name of the package (default: lowercased Package.swift name) var packageName: String? + /// Whether to explain the build plan var explain: Bool = false static func parse(from extractor: inout ArgumentExtractor) -> Options { @@ -22,61 +17,124 @@ struct PackageToJS: CommandPlugin { let explain = extractor.extractFlag(named: "explain") return Options(product: product, packageName: packageName, explain: explain != 0) } + + static func help() -> String { + return """ + Usage: swift package --swift-sdk plugin run PackageToJS [options] + + Options: + --product Product to build (default: executable target if there's only one) + --package-name Name of the package (default: lowercased Package.swift name) + --explain Whether to explain the build plan + """ + } } + static let friendlyBuildDiagnostics: + [(_ build: PackageManager.BuildResult, _ arguments: [String]) -> String?] = [ + ( + // In case user misses the `--swift-sdk` option + { build, arguments in + guard + build.logText.contains( + "ld.gold: --export-if-defined=__main_argc_argv: unknown option") + else { return nil } + let didYouMean = + [ + "swift", "package", "--swift-sdk", "wasm32-unknown-wasi", "js", + ] + arguments + return """ + Please pass the `--swift-sdk` option to the "swift package" command. + + Did you mean: + \(didYouMean.joined(separator: " ")) + """ + }), + ( + // In case selected Swift SDK version is not compatible with the Swift compiler version + { build, arguments in + let regex = + #/module compiled with Swift (?\d+\.\d+(?:\.\d+)?) cannot be imported by the Swift (?\d+\.\d+(?:\.\d+)?) compiler/# + guard let match = build.logText.firstMatch(of: regex) else { return nil } + let swiftSDKVersion = match.swiftSDKVersion + let compilerVersion = match.compilerVersion + return """ + Swift versions mismatch: + - Swift SDK version: \(swiftSDKVersion) + - Swift compiler version: \(compilerVersion) + + Please ensure you are using matching versions of the Swift SDK and Swift compiler. + + 1. Use 'swift --version' to check your Swift compiler version + 2. Use 'swift sdk list' to check available Swift SDKs + 3. Select a matching SDK version with --swift-sdk option + """ + }), + ] + func performCommand(context: PluginContext, arguments: [String]) throws { + if arguments.contains(where: { ["-h", "--help"].contains($0) }) { + print(Options.help()) + return + } + var extractor = ArgumentExtractor(arguments) let options = Options.parse(from: &extractor) - let productName = try options.product ?? deriveDefaultProduct(package: context.package) // Build products + let (build, productName) = try buildWasm(options: options, context: context) + guard build.succeeded else { + for diagnostic in Self.friendlyBuildDiagnostics { + if let message = diagnostic(build, arguments) { + fputs("\n" + message + "\n", stderr) + } + } + exit(1) + } + + let productArtifact = try build.findWasmArtifact(for: productName) + let outputDir = context.pluginWorkDirectory.appending(subpath: "Package") + guard + let selfPackage = findPackageInDependencies( + package: context.package, id: "javascriptkit") + else { + throw PackageToJSError("Failed to find JavaScriptKit in dependencies!?") + } + var make = MiniMake(explain: options.explain) + let allTask = constructPackagingPlan( + make: &make, options: options, context: context, wasmProductArtifact: productArtifact, + selfPackage: selfPackage, outputDir: outputDir) + try make.build(output: allTask) + print("Packaging finished") + } + + private func buildWasm(options: Options, context: PluginContext) throws -> ( + build: PackageManager.BuildResult, productName: String + ) { var parameters = PackageManager.BuildParameters( configuration: .inherit, logging: .concise ) parameters.echoLogs = true - let buildingForEmbedded = ProcessInfo.processInfo.environment["JAVASCRIPTKIT_EXPERIMENTAL_EMBEDDED_WASM"].flatMap(Bool.init) ?? false + let buildingForEmbedded = + ProcessInfo.processInfo.environment["JAVASCRIPTKIT_EXPERIMENTAL_EMBEDDED_WASM"].flatMap( + Bool.init) ?? false if !buildingForEmbedded { // NOTE: We only support static linking for now, and the new SwiftDriver // does not infer `-static-stdlib` for WebAssembly targets intentionally // for future dynamic linking support. - parameters.otherSwiftcFlags = ["-static-stdlib", "-Xclang-linker", "-mexec-model=reactor"] + parameters.otherSwiftcFlags = [ + "-static-stdlib", "-Xclang-linker", "-mexec-model=reactor", + ] parameters.otherLinkerFlags = ["--export-if-defined=__main_argc_argv"] } - + let productName = try options.product ?? deriveDefaultProduct(package: context.package) let build = try self.packageManager.build(.product(productName), parameters: parameters) - - guard build.succeeded else { - print(build.logText) - exit(1) - } - - guard let product = try context.package.products(named: [productName]).first else { - throw PackageToJSError("Failed to find product named \"\(productName)\"") - } - guard let executableProduct = product as? ExecutableProduct else { - throw PackageToJSError("Product type of \"\(productName)\" is not supported. Only executable products are supported.") - } - - let productArtifact = try build.findWasmArtifact(for: productName) - let resourcesPaths = deriveResourcesPaths( - productArtifactPath: productArtifact.path, - sourceTargets: executableProduct.targets, - package: context.package - ) - - let outputDir = context.pluginWorkDirectory.appending(subpath: "Package") - guard let selfPackage = findPackageInDependencies(package: context.package, id: "javascriptkit") else { - throw PackageToJSError("Failed to find JavaScriptKit in dependencies!?") - } - var make = MiniMake(explain: options.explain) - let allTask = constructBuild(make: &make, options: options, context: context, wasmProductArtifact: productArtifact, selfPackage: selfPackage, outputDir: outputDir) - try make.build(output: allTask) - print("Build finished") + return (build, productName) } /// Construct the build plan and return the root task key - private func constructBuild( + private func constructPackagingPlan( make: inout MiniMake, options: Options, context: PluginContext, @@ -88,7 +146,8 @@ struct PackageToJS: CommandPlugin { let selfPath = String(#filePath) let outputDirTask = make.addTask(inputFiles: [selfPath], output: outputDir.string) { guard !FileManager.default.fileExists(atPath: $0.output) else { return } - try FileManager.default.createDirectory(atPath: $0.output, withIntermediateDirectories: true, attributes: nil) + try FileManager.default.createDirectory( + atPath: $0.output, withIntermediateDirectories: true, attributes: nil) } var packageInputs: [MiniMake.TaskKey] = [] @@ -118,25 +177,25 @@ struct PackageToJS: CommandPlugin { ) { // Write package.json let packageJSON = """ - { - "name": "\(options.packageName ?? context.package.id.lowercased())", - "version": "0.0.0", - "type": "module", - "exports": { - ".": "./index.js", - "./wasm": "./\(wasmFilename)" - }, - "dependencies": { - "@bjorn3/browser_wasi_shim": "^0.4.1" + { + "name": "\(options.packageName ?? context.package.id.lowercased())", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./index.js", + "./wasm": "./\(wasmFilename)" + }, + "dependencies": { + "@bjorn3/browser_wasi_shim": "^0.4.1" + } } - } - """ + """ try packageJSON.write(toFile: $0.output, atomically: true, encoding: .utf8) } packageInputs.append(packageJSON) let substitutions = [ - "@PACKAGE_TO_JS_MODULE_PATH@": wasmFilename, + "@PACKAGE_TO_JS_MODULE_PATH@": wasmFilename ] for (file, output) in [ ("Plugins/PackageToJS/Templates/index.js", "index.js"), @@ -161,68 +220,43 @@ struct PackageToJS: CommandPlugin { } /// Derive default product from the package +/// - Returns: The name of the product to build +/// - Throws: `PackageToJSError` if there's no executable product or if there's more than one internal func deriveDefaultProduct(package: Package) throws -> String { - let executableProducts = package.products(ofType: ExecutableProduct.self) - guard !executableProducts.isEmpty else { - throw PackageToJSError( - "Make sure there's at least one executable product in your Package.swift") - } - guard executableProducts.count == 1 else { - throw PackageToJSError( - "Failed to disambiguate the product. Pass one of \(executableProducts.map(\.name).joined(separator: ", ")) to the --product option" - ) - - } - return executableProducts[0].name -} - -/// Returns the list of resource bundle paths for the given targets -internal func deriveResourcesPaths( - productArtifactPath: Path, - sourceTargets: [any PackagePlugin.Target], - package: Package -) -> [Path] { - return deriveResourcesPaths( - buildDirectory: productArtifactPath.removingLastComponent(), - sourceTargets: sourceTargets, package: package - ) -} + let executableProducts = package.products(ofType: ExecutableProduct.self) + guard !executableProducts.isEmpty else { + throw PackageToJSError( + "Make sure there's at least one executable product in your Package.swift") + } + guard executableProducts.count == 1 else { + throw PackageToJSError( + "Failed to disambiguate the product. Pass one of \(executableProducts.map(\.name).joined(separator: ", ")) to the --product option" + ) -internal func deriveResourcesPaths( - buildDirectory: Path, - sourceTargets: [any PackagePlugin.Target], - package: Package -) -> [Path] { - sourceTargets.compactMap { target -> Path? in - // NOTE: The resource bundle file name is constructed from `displayName` instead of `id` for some reason - // https://github.com/apple/swift-package-manager/blob/swift-5.9.2-RELEASE/Sources/PackageLoading/PackageBuilder.swift#L908 - let bundleName = package.displayName + "_" + target.name + ".resources" - let resourcesPath = buildDirectory.appending(subpath: bundleName) - guard FileManager.default.fileExists(atPath: resourcesPath.string) else { return nil } - return resourcesPath - } + } + return executableProducts[0].name } - extension PackageManager.BuildResult { - /// Find `.wasm` executable artifact - internal func findWasmArtifact(for product: String) throws - -> PackageManager.BuildResult.BuiltArtifact - { - let executables = self.builtArtifacts.filter { - $0.kind == .executable && $0.path.lastComponent == "\(product).wasm" - } - guard !executables.isEmpty else { - throw PackageToJSError( - "Failed to find '\(product).wasm' from executable artifacts of product '\(product)'") - } - guard executables.count == 1, let executable = executables.first else { - throw PackageToJSError( - "Failed to disambiguate executable product artifacts from \(executables.map(\.path.string).joined(separator: ", "))" - ) + /// Find `.wasm` executable artifact + internal func findWasmArtifact(for product: String) throws + -> PackageManager.BuildResult.BuiltArtifact + { + let executables = self.builtArtifacts.filter { + $0.kind == .executable && $0.path.lastComponent == "\(product).wasm" + } + guard !executables.isEmpty else { + throw PackageToJSError( + "Failed to find '\(product).wasm' from executable artifacts of product '\(product)'" + ) + } + guard executables.count == 1, let executable = executables.first else { + throw PackageToJSError( + "Failed to disambiguate executable product artifacts from \(executables.map(\.path.string).joined(separator: ", "))" + ) + } + return executable } - return executable - } } private func findPackageInDependencies(package: Package, id: Package.ID) -> Package? { @@ -245,3 +279,11 @@ private func findPackageInDependencies(package: Package, id: Package.ID) -> Pack } return visit(package: package) } + +private struct PackageToJSError: Swift.Error, CustomStringConvertible { + let description: String + + init(_ message: String) { + self.description = "Error: " + message + } +} From b4da184deb0cd5011ee35078409b339174720e48 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Wed, 5 Mar 2025 09:17:00 +0000 Subject: [PATCH 05/21] Fix Swift 6 build --- Plugins/PackageToJS/PackageToJS.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/PackageToJS/PackageToJS.swift b/Plugins/PackageToJS/PackageToJS.swift index 706336588..146b7c56b 100644 --- a/Plugins/PackageToJS/PackageToJS.swift +++ b/Plugins/PackageToJS/PackageToJS.swift @@ -1,4 +1,4 @@ -import Foundation +@preconcurrency import Foundation // For "stderr" import PackagePlugin @main @@ -31,7 +31,7 @@ struct PackageToJS: CommandPlugin { } static let friendlyBuildDiagnostics: - [(_ build: PackageManager.BuildResult, _ arguments: [String]) -> String?] = [ + [@Sendable (_ build: PackageManager.BuildResult, _ arguments: [String]) -> String?] = [ ( // In case user misses the `--swift-sdk` option { build, arguments in From 3df9a039ba33ba28432069562cd0aa4a3211ae6b Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Wed, 5 Mar 2025 09:34:54 +0000 Subject: [PATCH 06/21] Silence `[7/7] all: building` --- Plugins/PackageToJS/MiniMake.swift | 13 ++++++++----- Plugins/PackageToJS/PackageToJS.swift | 3 ++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/Plugins/PackageToJS/MiniMake.swift b/Plugins/PackageToJS/MiniMake.swift index 839de22e8..e787c24f9 100644 --- a/Plugins/PackageToJS/MiniMake.swift +++ b/Plugins/PackageToJS/MiniMake.swift @@ -6,6 +6,8 @@ struct MiniMake { enum TaskAttribute { /// Task is phony, meaning it must be built even if its inputs are up to date case phony + /// Don't print anything when building this task + case silent } /// A task to build struct Task { @@ -84,15 +86,16 @@ struct MiniMake { private static var reset: String { "\u{001B}[0m" } mutating func started(_ task: Task) { - self.print(task.displayName, "\(Self.green)building\(Self.reset)") + self.print(task, "\(Self.green)building\(Self.reset)") } mutating func skipped(_ task: Task) { - self.print(task.displayName, "\(Self.yellow)skipped\(Self.reset)") + self.print(task, "\(Self.yellow)skipped\(Self.reset)") } - private mutating func print(_ subjectPath: String, _ message: @autoclosure () -> String) { - Swift.print("[\(self.built + 1)/\(self.total)] \(subjectPath): \(message())") + private mutating func print(_ task: Task, _ message: @autoclosure () -> String) { + guard !task.attributes.contains(.silent) else { return } + Swift.print("[\(self.built + 1)/\(self.total)] \(task.displayName): \(message())") self.built += 1 } } @@ -102,7 +105,7 @@ struct MiniMake { func visit(task: Task) -> Int { guard !visited.contains(task.key) else { return 0 } visited.insert(task.key) - var total = 1 + var total = task.attributes.contains(.silent) ? 0 : 1 for want in task.wants { total += visit(task: self.tasks[want]!) } diff --git a/Plugins/PackageToJS/PackageToJS.swift b/Plugins/PackageToJS/PackageToJS.swift index 146b7c56b..f2055e73a 100644 --- a/Plugins/PackageToJS/PackageToJS.swift +++ b/Plugins/PackageToJS/PackageToJS.swift @@ -104,6 +104,7 @@ struct PackageToJS: CommandPlugin { let allTask = constructPackagingPlan( make: &make, options: options, context: context, wasmProductArtifact: productArtifact, selfPackage: selfPackage, outputDir: outputDir) + print("Packaging...") try make.build(output: allTask) print("Packaging finished") } @@ -215,7 +216,7 @@ struct PackageToJS: CommandPlugin { } packageInputs.append(copied) } - return make.addTask(inputTasks: packageInputs, output: "all", attributes: [.phony]) { _ in } + return make.addTask(inputTasks: packageInputs, output: "all", attributes: [.phony, .silent]) { _ in } } } From 91c6e5e0400a7ac0ec66112960d939eec8fc1a8b Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Wed, 5 Mar 2025 09:36:04 +0000 Subject: [PATCH 07/21] Make the build order deterministic --- Plugins/PackageToJS/MiniMake.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plugins/PackageToJS/MiniMake.swift b/Plugins/PackageToJS/MiniMake.swift index e787c24f9..08b5d4f1f 100644 --- a/Plugins/PackageToJS/MiniMake.swift +++ b/Plugins/PackageToJS/MiniMake.swift @@ -153,7 +153,7 @@ struct MiniMake { guard !task.isDone else { return } // Build dependencies first - for want in task.wants { + for want in task.wants.sorted() { try runTask(taskKey: want) } From a2f5f0792e602f4d50626a9de4e03bb765db875a Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Wed, 5 Mar 2025 10:13:36 +0000 Subject: [PATCH 08/21] Capture build graph changes --- Plugins/PackageToJS/MiniMake.swift | 64 +++++++++++++++++++++------ Plugins/PackageToJS/PackageToJS.swift | 41 +++++++++++++---- 2 files changed, 84 insertions(+), 21 deletions(-) diff --git a/Plugins/PackageToJS/MiniMake.swift b/Plugins/PackageToJS/MiniMake.swift index 08b5d4f1f..a5368ced4 100644 --- a/Plugins/PackageToJS/MiniMake.swift +++ b/Plugins/PackageToJS/MiniMake.swift @@ -3,34 +3,48 @@ import Foundation /// A simple build system struct MiniMake { /// Attributes of a task - enum TaskAttribute { + enum TaskAttribute: String, Codable { /// Task is phony, meaning it must be built even if its inputs are up to date case phony /// Don't print anything when building this task case silent } - /// A task to build - struct Task { - /// Key of the task - let key: TaskKey - /// Display name of the task - let displayName: String + + /// Information about a task enough to capture build + /// graph changes + struct TaskInfo: Codable { /// Input tasks not yet built - var wants: Set + let wants: [TaskKey] /// Set of files that must be built before this task let inputs: [String] /// Output task name let output: String /// Attributes of the task + let attributes: [TaskAttribute] + } + + /// A task to build + struct Task { + let info: TaskInfo + /// Input tasks not yet built + let wants: Set + /// Attributes of the task let attributes: Set + /// Display name of the task + let displayName: String + /// Key of the task + let key: TaskKey /// Build operation let build: (Task) throws -> Void /// Whether the task is done var isDone: Bool + + var inputs: [String] { self.info.inputs } + var output: String { self.info.output } } /// A task key - struct TaskKey: Hashable, Comparable, CustomStringConvertible { + struct TaskKey: Codable, Hashable, Comparable, CustomStringConvertible { let id: String var description: String { self.id } @@ -41,7 +55,9 @@ struct MiniMake { static func < (lhs: TaskKey, rhs: TaskKey) -> Bool { lhs.id < rhs.id } } + /// All tasks in the build system private var tasks: [TaskKey: Task] + /// Whether to explain why tasks are built private var shouldExplain: Bool /// Current working directory at the time the build started private let buildCwd: String @@ -52,13 +68,26 @@ struct MiniMake { self.buildCwd = FileManager.default.currentDirectoryPath } - mutating func addTask(inputFiles: [String] = [], inputTasks: [TaskKey] = [], output: String, attributes: Set = [], build: @escaping (Task) throws -> Void) -> TaskKey { + /// Adds a task to the build system + mutating func addTask(inputFiles: [String] = [], inputTasks: [TaskKey] = [], output: String, attributes: [TaskAttribute] = [], build: @escaping (Task) throws -> Void) -> TaskKey { let displayName = output.hasPrefix(self.buildCwd) ? String(output.dropFirst(self.buildCwd.count + 1)) : output let taskKey = TaskKey(id: output) - self.tasks[taskKey] = Task(key: taskKey, displayName: displayName, wants: Set(inputTasks), inputs: inputFiles, output: output, attributes: attributes, build: build, isDone: false) + let info = TaskInfo(wants: inputTasks, inputs: inputFiles, output: output, attributes: attributes) + self.tasks[taskKey] = Task(info: info, wants: Set(inputTasks), attributes: Set(attributes), displayName: displayName, key: taskKey, build: build, isDone: false) return taskKey } + /// Computes a stable fingerprint of the build graph + /// + /// This fingerprint must be stable across builds and must change + /// if the build graph changes in any way. + func computeFingerprint(root: TaskKey) throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let tasks = self.tasks.sorted { $0.key < $1.key }.map { $0.value.info } + return try encoder.encode(tasks) + } + private func explain(_ message: @autoclosure () -> String) { if self.shouldExplain { print(message()) @@ -100,7 +129,8 @@ struct MiniMake { } } - private func computeTotalTasks(task: Task) -> Int { + /// Computes the total number of tasks to build used for progress display + private func computeTotalTasksForDisplay(task: Task) -> Int { var visited = Set() func visit(task: Task) -> Int { guard !visited.contains(task.key) else { return 0 } @@ -114,6 +144,14 @@ struct MiniMake { return visit(task: task) } + /// Cleans all outputs of all tasks + func cleanEverything() { + for task in self.tasks.values { + try? FileManager.default.removeItem(atPath: task.output) + } + } + + /// Starts building mutating func build(output: TaskKey) throws { /// Returns true if any of the task's inputs have a modification date later than the task's output func shouldBuild(task: Task) -> Bool { @@ -143,7 +181,7 @@ struct MiniMake { return shouldBuild } } - var progressPrinter = ProgressPrinter(total: self.computeTotalTasks(task: self.tasks[output]!)) + var progressPrinter = ProgressPrinter(total: self.computeTotalTasksForDisplay(task: self.tasks[output]!)) func runTask(taskKey: TaskKey) throws { guard var task = self.tasks[taskKey] else { diff --git a/Plugins/PackageToJS/PackageToJS.swift b/Plugins/PackageToJS/PackageToJS.swift index f2055e73a..4b1a72117 100644 --- a/Plugins/PackageToJS/PackageToJS.swift +++ b/Plugins/PackageToJS/PackageToJS.swift @@ -1,4 +1,4 @@ -@preconcurrency import Foundation // For "stderr" +@preconcurrency import Foundation // For "stderr" import PackagePlugin @main @@ -104,6 +104,7 @@ struct PackageToJS: CommandPlugin { let allTask = constructPackagingPlan( make: &make, options: options, context: context, wasmProductArtifact: productArtifact, selfPackage: selfPackage, outputDir: outputDir) + cleanIfBuildGraphChanged(root: allTask, make: make, context: context) print("Packaging...") try make.build(output: allTask) print("Packaging finished") @@ -145,7 +146,11 @@ struct PackageToJS: CommandPlugin { ) -> MiniMake.TaskKey { let selfPackageURL = selfPackage.directory let selfPath = String(#filePath) - let outputDirTask = make.addTask(inputFiles: [selfPath], output: outputDir.string) { + + // Prepare output directory + let outputDirTask = make.addTask( + inputFiles: [selfPath], output: outputDir.string, attributes: [.silent] + ) { guard !FileManager.default.fileExists(atPath: $0.output) else { return } try FileManager.default.createDirectory( atPath: $0.output, withIntermediateDirectories: true, attributes: nil) @@ -160,23 +165,21 @@ struct PackageToJS: CommandPlugin { try FileManager.default.copyItem(atPath: from, toPath: to) } + // Copy the wasm product artifact let wasmFilename = "main.wasm" let wasm = make.addTask( inputFiles: [selfPath, wasmProductArtifact.path.string], inputTasks: [outputDirTask], - output: outputDir.appending(subpath: wasmFilename).string, - // FIXME: This is a hack to ensure that the wasm file is always copied - // even when release/debug configuration is changed. - attributes: [.phony] + output: outputDir.appending(subpath: wasmFilename).string ) { try syncFile(from: wasmProductArtifact.path.string, to: $0.output) } packageInputs.append(wasm) + // Write package.json let packageJSON = make.addTask( inputFiles: [selfPath], inputTasks: [outputDirTask], output: outputDir.appending(subpath: "package.json").string ) { - // Write package.json let packageJSON = """ { "name": "\(options.packageName ?? context.package.id.lowercased())", @@ -195,6 +198,7 @@ struct PackageToJS: CommandPlugin { } packageInputs.append(packageJSON) + // Copy the template files let substitutions = [ "@PACKAGE_TO_JS_MODULE_PATH@": wasmFilename ] @@ -216,7 +220,28 @@ struct PackageToJS: CommandPlugin { } packageInputs.append(copied) } - return make.addTask(inputTasks: packageInputs, output: "all", attributes: [.phony, .silent]) { _ in } + return make.addTask( + inputTasks: packageInputs, output: "all", attributes: [.phony, .silent] + ) { _ in } + } + + /// Clean if the build graph of the packaging process has changed + /// + /// This is especially important to detect user changes debug/release + /// configurations, which leads to placing the .wasm file in a different + /// path. + private func cleanIfBuildGraphChanged( + root: MiniMake.TaskKey, + make: MiniMake, context: PluginContext + ) { + let buildFingerprint = context.pluginWorkDirectoryURL.appending(path: "minimake.json") + let lastBuildFingerprint = try? Data(contentsOf: buildFingerprint) + let currentBuildFingerprint = try? make.computeFingerprint(root: root) + if lastBuildFingerprint != currentBuildFingerprint { + print("Build graph changed, cleaning...") + make.cleanEverything() + } + try? currentBuildFingerprint?.write(to: buildFingerprint) } } From 068754406a89b633255342be8d1458d24ff91d46 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Wed, 5 Mar 2025 14:32:59 +0000 Subject: [PATCH 09/21] Fix the declaration of the instantiate function --- Plugins/PackageToJS/Templates/index.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Plugins/PackageToJS/Templates/index.d.ts b/Plugins/PackageToJS/Templates/index.d.ts index 768ac9c03..a0e3ce87a 100644 --- a/Plugins/PackageToJS/Templates/index.d.ts +++ b/Plugins/PackageToJS/Templates/index.d.ts @@ -36,7 +36,7 @@ export declare const MODULE_PATH: string; * @param imports - The imports to add * @param options - The options */ -/* export */ function createInstantiator( +/* export */ declare function createInstantiator( imports: Import, options: {} | undefined ): Promise @@ -49,7 +49,7 @@ export declare const MODULE_PATH: string; * @param imports - The imports to add * @param options - The options */ -export function instantiate( +export declare function instantiate( moduleSource: WebAssembly.Module | Response | PromiseLike, imports: Import, options: {} | undefined From 0f5714129d5802884c2244f8c446a06f387c8caf Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Wed, 5 Mar 2025 15:53:36 +0000 Subject: [PATCH 10/21] Add `--output` option to PackageToJS plugin --- Plugins/PackageToJS/PackageToJS.swift | 66 +++++++++++++++++++-------- 1 file changed, 48 insertions(+), 18 deletions(-) diff --git a/Plugins/PackageToJS/PackageToJS.swift b/Plugins/PackageToJS/PackageToJS.swift index 4b1a72117..ef367ced5 100644 --- a/Plugins/PackageToJS/PackageToJS.swift +++ b/Plugins/PackageToJS/PackageToJS.swift @@ -6,6 +6,8 @@ struct PackageToJS: CommandPlugin { struct Options { /// Product to build (default: executable target if there's only one) var product: String? + /// Path to the output directory + var outputPath: String? /// Name of the package (default: lowercased Package.swift name) var packageName: String? /// Whether to explain the build plan @@ -13,19 +15,29 @@ struct PackageToJS: CommandPlugin { static func parse(from extractor: inout ArgumentExtractor) -> Options { let product = extractor.extractOption(named: "product").last + let outputPath = extractor.extractOption(named: "output").last let packageName = extractor.extractOption(named: "package-name").last let explain = extractor.extractFlag(named: "explain") - return Options(product: product, packageName: packageName, explain: explain != 0) + return Options( + product: product, outputPath: outputPath, packageName: packageName, + explain: explain != 0 + ) } static func help() -> String { return """ - Usage: swift package --swift-sdk plugin run PackageToJS [options] + Usage: swift package --swift-sdk [swift-package options] plugin run PackageToJS [options] Options: - --product Product to build (default: executable target if there's only one) - --package-name Name of the package (default: lowercased Package.swift name) - --explain Whether to explain the build plan + --product Product to build (default: executable target if there's only one) + --output Path to the output directory (default: .build/plugins/PackageToJS/outputs/Package) + --package-name Name of the package (default: lowercased Package.swift name) + --explain Whether to explain the build plan + + Examples: + $ swift package --swift-sdk wasm32-unknown-wasi plugin js + $ swift package --swift-sdk wasm32-unknown-wasi plugin js --product Example + $ swift package --swift-sdk wasm32-unknown-wasi -c release plugin js """ } } @@ -74,26 +86,38 @@ struct PackageToJS: CommandPlugin { func performCommand(context: PluginContext, arguments: [String]) throws { if arguments.contains(where: { ["-h", "--help"].contains($0) }) { - print(Options.help()) + printStderr(Options.help()) return } var extractor = ArgumentExtractor(arguments) let options = Options.parse(from: &extractor) + if extractor.remainingArguments.count > 0 { + printStderr( + "Unexpected arguments: \(extractor.remainingArguments.joined(separator: " "))") + printStderr(Options.help()) + exit(1) + } + // Build products let (build, productName) = try buildWasm(options: options, context: context) guard build.succeeded else { for diagnostic in Self.friendlyBuildDiagnostics { if let message = diagnostic(build, arguments) { - fputs("\n" + message + "\n", stderr) + printStderr("\n" + message) } } exit(1) } let productArtifact = try build.findWasmArtifact(for: productName) - let outputDir = context.pluginWorkDirectory.appending(subpath: "Package") + let outputDir = + if let outputPath = options.outputPath { + URL(fileURLWithPath: outputPath) + } else { + context.pluginWorkDirectoryURL.appending(path: "Package") + } guard let selfPackage = findPackageInDependencies( package: context.package, id: "javascriptkit") @@ -128,7 +152,9 @@ struct PackageToJS: CommandPlugin { parameters.otherSwiftcFlags = [ "-static-stdlib", "-Xclang-linker", "-mexec-model=reactor", ] - parameters.otherLinkerFlags = ["--export-if-defined=__main_argc_argv"] + parameters.otherLinkerFlags = [ + "--export-if-defined=__main_argc_argv" + ] } let productName = try options.product ?? deriveDefaultProduct(package: context.package) let build = try self.packageManager.build(.product(productName), parameters: parameters) @@ -142,14 +168,14 @@ struct PackageToJS: CommandPlugin { context: PluginContext, wasmProductArtifact: PackageManager.BuildResult.BuiltArtifact, selfPackage: Package, - outputDir: Path + outputDir: URL ) -> MiniMake.TaskKey { - let selfPackageURL = selfPackage.directory + let selfPackageURL = selfPackage.directoryURL let selfPath = String(#filePath) // Prepare output directory let outputDirTask = make.addTask( - inputFiles: [selfPath], output: outputDir.string, attributes: [.silent] + inputFiles: [selfPath], output: outputDir.path, attributes: [.silent] ) { guard !FileManager.default.fileExists(atPath: $0.output) else { return } try FileManager.default.createDirectory( @@ -169,7 +195,7 @@ struct PackageToJS: CommandPlugin { let wasmFilename = "main.wasm" let wasm = make.addTask( inputFiles: [selfPath, wasmProductArtifact.path.string], inputTasks: [outputDirTask], - output: outputDir.appending(subpath: wasmFilename).string + output: outputDir.appending(path: wasmFilename).path ) { try syncFile(from: wasmProductArtifact.path.string, to: $0.output) } @@ -178,7 +204,7 @@ struct PackageToJS: CommandPlugin { // Write package.json let packageJSON = make.addTask( inputFiles: [selfPath], inputTasks: [outputDirTask], - output: outputDir.appending(subpath: "package.json").string + output: outputDir.appending(path: "package.json").path ) { let packageJSON = """ { @@ -207,12 +233,12 @@ struct PackageToJS: CommandPlugin { ("Plugins/PackageToJS/Templates/index.d.ts", "index.d.ts"), ("Sources/JavaScriptKit/Runtime/index.mjs", "runtime.js"), ] { - let inputPath = selfPackageURL.appending(subpath: file).string + let inputPath = selfPackageURL.appending(path: file) let copied = make.addTask( - inputFiles: [selfPath, inputPath], inputTasks: [outputDirTask], - output: outputDir.appending(subpath: output).string + inputFiles: [selfPath, inputPath.path], inputTasks: [outputDirTask], + output: outputDir.appending(path: output).path ) { - var content = try String(contentsOfFile: inputPath) + var content = try String(contentsOf: inputPath) for (key, value) in substitutions { content = content.replacingOccurrences(of: key, with: value) } @@ -306,6 +332,10 @@ private func findPackageInDependencies(package: Package, id: Package.ID) -> Pack return visit(package: package) } +private func printStderr(_ message: String) { + fputs(message + "\n", stderr) +} + private struct PackageToJSError: Swift.Error, CustomStringConvertible { let description: String From 84221b9f185f46e22135d5b17fddf18e25b74de3 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Wed, 5 Mar 2025 15:53:57 +0000 Subject: [PATCH 11/21] Accept ArrayBufferView and ArrayBuffer as moduleSource in instantiate --- Plugins/PackageToJS/Templates/index.d.ts | 2 +- Plugins/PackageToJS/Templates/index.js | 16 +++++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/Plugins/PackageToJS/Templates/index.d.ts b/Plugins/PackageToJS/Templates/index.d.ts index a0e3ce87a..6befac24c 100644 --- a/Plugins/PackageToJS/Templates/index.d.ts +++ b/Plugins/PackageToJS/Templates/index.d.ts @@ -50,7 +50,7 @@ export declare const MODULE_PATH: string; * @param options - The options */ export declare function instantiate( - moduleSource: WebAssembly.Module | Response | PromiseLike, + moduleSource: WebAssembly.Module | ArrayBufferView | ArrayBuffer | Response | PromiseLike, imports: Import, options: {} | undefined ): Promise<{ diff --git a/Plugins/PackageToJS/Templates/index.js b/Plugins/PackageToJS/Templates/index.js index cc1665b0b..a97945aa2 100644 --- a/Plugins/PackageToJS/Templates/index.js +++ b/Plugins/PackageToJS/Templates/index.js @@ -48,10 +48,20 @@ export async function instantiate( if (moduleSource instanceof WebAssembly.Module) { module = moduleSource; instance = await WebAssembly.instantiate(module, importObject); + } else if (typeof Response === "function" && (moduleSource instanceof Response || moduleSource instanceof Promise)) { + if (typeof WebAssembly.instantiateStreaming === "function") { + const result = await WebAssembly.instantiateStreaming(moduleSource, importObject); + module = result.module; + instance = result.instance; + } else { + const moduleBytes = await (await moduleSource).arrayBuffer(); + module = await WebAssembly.compile(moduleBytes); + instance = await WebAssembly.instantiate(module, importObject); + } } else { - const result = await WebAssembly.instantiateStreaming(moduleSource, importObject); - module = result.module; - instance = result.instance; + // @ts-expect-error: Type 'Response' is not assignable to type 'BufferSource' + module = await WebAssembly.compile(moduleSource); + instance = await WebAssembly.instantiate(module, importObject); } swift.setInstance(instance); From b2f41626e5d73078c3d934e8d9c1b80e87f6b3ee Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Wed, 5 Mar 2025 16:13:43 +0000 Subject: [PATCH 12/21] Migrate PackagePlugin.Path to Foundation.URL --- Plugins/PackageToJS/MiniMake.swift | 35 +++++++++++++++++++-------- Plugins/PackageToJS/PackageToJS.swift | 10 ++++---- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/Plugins/PackageToJS/MiniMake.swift b/Plugins/PackageToJS/MiniMake.swift index a5368ced4..566bf1b88 100644 --- a/Plugins/PackageToJS/MiniMake.swift +++ b/Plugins/PackageToJS/MiniMake.swift @@ -69,11 +69,19 @@ struct MiniMake { } /// Adds a task to the build system - mutating func addTask(inputFiles: [String] = [], inputTasks: [TaskKey] = [], output: String, attributes: [TaskAttribute] = [], build: @escaping (Task) throws -> Void) -> TaskKey { - let displayName = output.hasPrefix(self.buildCwd) ? String(output.dropFirst(self.buildCwd.count + 1)) : output + mutating func addTask( + inputFiles: [String] = [], inputTasks: [TaskKey] = [], output: String, + attributes: [TaskAttribute] = [], build: @escaping (Task) throws -> Void + ) -> TaskKey { + let displayName = + output.hasPrefix(self.buildCwd) + ? String(output.dropFirst(self.buildCwd.count + 1)) : output let taskKey = TaskKey(id: output) - let info = TaskInfo(wants: inputTasks, inputs: inputFiles, output: output, attributes: attributes) - self.tasks[taskKey] = Task(info: info, wants: Set(inputTasks), attributes: Set(attributes), displayName: displayName, key: taskKey, build: build, isDone: false) + let info = TaskInfo( + wants: inputTasks, inputs: inputFiles, output: output, attributes: attributes) + self.tasks[taskKey] = Task( + info: info, wants: Set(inputTasks), attributes: Set(attributes), + displayName: displayName, key: taskKey, build: build, isDone: false) return taskKey } @@ -163,25 +171,32 @@ struct MiniMake { explain("Task \(task.output) should be built because it doesn't exist") return true } - let outputMtime = try? outputURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate + let outputMtime = try? outputURL.resourceValues(forKeys: [.contentModificationDateKey]) + .contentModificationDate return task.inputs.contains { input in let inputURL = URL(fileURLWithPath: input) // Ignore directory modification times var isDirectory: ObjCBool = false - let fileExists = FileManager.default.fileExists(atPath: input, isDirectory: &isDirectory) + let fileExists = FileManager.default.fileExists( + atPath: input, isDirectory: &isDirectory) if fileExists && isDirectory.boolValue { return false } - let inputMtime = try? inputURL.resourceValues(forKeys: [.contentModificationDateKey]).contentModificationDate - let shouldBuild = outputMtime == nil || inputMtime == nil || outputMtime! < inputMtime! + let inputMtime = try? inputURL.resourceValues(forKeys: [.contentModificationDateKey] + ).contentModificationDate + let shouldBuild = + outputMtime == nil || inputMtime == nil || outputMtime! < inputMtime! if shouldBuild { - explain("Task \(task.output) should be re-built because \(input) is newer: \(outputMtime?.timeIntervalSince1970 ?? 0) < \(inputMtime?.timeIntervalSince1970 ?? 0)") + explain( + "Task \(task.output) should be re-built because \(input) is newer: \(outputMtime?.timeIntervalSince1970 ?? 0) < \(inputMtime?.timeIntervalSince1970 ?? 0)" + ) } return shouldBuild } } - var progressPrinter = ProgressPrinter(total: self.computeTotalTasksForDisplay(task: self.tasks[output]!)) + var progressPrinter = ProgressPrinter( + total: self.computeTotalTasksForDisplay(task: self.tasks[output]!)) func runTask(taskKey: TaskKey) throws { guard var task = self.tasks[taskKey] else { diff --git a/Plugins/PackageToJS/PackageToJS.swift b/Plugins/PackageToJS/PackageToJS.swift index ef367ced5..c792499c6 100644 --- a/Plugins/PackageToJS/PackageToJS.swift +++ b/Plugins/PackageToJS/PackageToJS.swift @@ -194,10 +194,10 @@ struct PackageToJS: CommandPlugin { // Copy the wasm product artifact let wasmFilename = "main.wasm" let wasm = make.addTask( - inputFiles: [selfPath, wasmProductArtifact.path.string], inputTasks: [outputDirTask], + inputFiles: [selfPath, wasmProductArtifact.url.path], inputTasks: [outputDirTask], output: outputDir.appending(path: wasmFilename).path ) { - try syncFile(from: wasmProductArtifact.path.string, to: $0.output) + try syncFile(from: wasmProductArtifact.url.path, to: $0.output) } packageInputs.append(wasm) @@ -238,7 +238,7 @@ struct PackageToJS: CommandPlugin { inputFiles: [selfPath, inputPath.path], inputTasks: [outputDirTask], output: outputDir.appending(path: output).path ) { - var content = try String(contentsOf: inputPath) + var content = try String(contentsOf: inputPath, encoding: .utf8) for (key, value) in substitutions { content = content.replacingOccurrences(of: key, with: value) } @@ -295,7 +295,7 @@ extension PackageManager.BuildResult { -> PackageManager.BuildResult.BuiltArtifact { let executables = self.builtArtifacts.filter { - $0.kind == .executable && $0.path.lastComponent == "\(product).wasm" + ($0.kind == .executable) && ($0.url.lastPathComponent == "\(product).wasm") } guard !executables.isEmpty else { throw PackageToJSError( @@ -304,7 +304,7 @@ extension PackageManager.BuildResult { } guard executables.count == 1, let executable = executables.first else { throw PackageToJSError( - "Failed to disambiguate executable product artifacts from \(executables.map(\.path.string).joined(separator: ", "))" + "Failed to disambiguate executable product artifacts from \(executables.map(\.url.path).joined(separator: ", "))" ) } return executable From eac6e6e030134c46379a3f436307fc1138bc9389 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Thu, 6 Mar 2025 04:30:45 +0000 Subject: [PATCH 13/21] Add test example --- Examples/Testing/.gitignore | 8 ++ Examples/Testing/Package.swift | 28 +++++++ .../Testing/Sources/Counter/Counter.swift | 7 ++ .../Tests/CounterTests/CounterTests.swift | 15 ++++ Examples/Testing/test.node.mjs | 14 ++++ Plugins/PackageToJS/PackageToJS.swift | 41 +++++++--- Plugins/PackageToJS/Templates/index.d.ts | 49 +++-------- Plugins/PackageToJS/Templates/index.js | 82 ++++++------------- .../PackageToJS/Templates/instantiate.d.ts | 62 ++++++++++++++ Plugins/PackageToJS/Templates/instantiate.js | 64 +++++++++++++++ 10 files changed, 265 insertions(+), 105 deletions(-) create mode 100644 Examples/Testing/.gitignore create mode 100644 Examples/Testing/Package.swift create mode 100644 Examples/Testing/Sources/Counter/Counter.swift create mode 100644 Examples/Testing/Tests/CounterTests/CounterTests.swift create mode 100644 Examples/Testing/test.node.mjs create mode 100644 Plugins/PackageToJS/Templates/instantiate.d.ts create mode 100644 Plugins/PackageToJS/Templates/instantiate.js diff --git a/Examples/Testing/.gitignore b/Examples/Testing/.gitignore new file mode 100644 index 000000000..0023a5340 --- /dev/null +++ b/Examples/Testing/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Examples/Testing/Package.swift b/Examples/Testing/Package.swift new file mode 100644 index 000000000..f83fdc863 --- /dev/null +++ b/Examples/Testing/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "Counter", + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "Counter", + targets: ["Counter"]), + ], + dependencies: [.package(name: "JavaScriptKit", path: "../../")], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "Counter", + dependencies: [ + .product(name: "JavaScriptKit", package: "JavaScriptKit") + ]), + .testTarget( + name: "CounterTests", + dependencies: ["Counter"] + ), + ] +) diff --git a/Examples/Testing/Sources/Counter/Counter.swift b/Examples/Testing/Sources/Counter/Counter.swift new file mode 100644 index 000000000..61e0a7a3b --- /dev/null +++ b/Examples/Testing/Sources/Counter/Counter.swift @@ -0,0 +1,7 @@ +public struct Counter { + public private(set) var count = 0 + + public mutating func increment() { + count += 1 + } +} diff --git a/Examples/Testing/Tests/CounterTests/CounterTests.swift b/Examples/Testing/Tests/CounterTests/CounterTests.swift new file mode 100644 index 000000000..5754843e8 --- /dev/null +++ b/Examples/Testing/Tests/CounterTests/CounterTests.swift @@ -0,0 +1,15 @@ +import Testing +@testable import Counter + +@Test func increment() async throws { + var counter = Counter() + counter.increment() + #expect(counter.count == 1) +} + +@Test func incrementTwice() async throws { + var counter = Counter() + counter.increment() + counter.increment() + #expect(counter.count == 2) +} diff --git a/Examples/Testing/test.node.mjs b/Examples/Testing/test.node.mjs new file mode 100644 index 000000000..6fa90273b --- /dev/null +++ b/Examples/Testing/test.node.mjs @@ -0,0 +1,14 @@ +import { WASI } from "wasi" +import { instantiate } from "./.build/plugins/PackageToJS/outputs/Package/instantiate.js" +import { readFile } from "fs/promises" + +const wasi = new WASI({ + version: "preview1", + args: ["--testing-library", "swift-testing"], + returnOnExit: false, +}) +const { swift } = await instantiate( + await readFile("./.build/plugins/PackageToJS/outputs/Package/main.wasm"), + {}, { wasi } +) +swift.main() diff --git a/Plugins/PackageToJS/PackageToJS.swift b/Plugins/PackageToJS/PackageToJS.swift index c792499c6..6eaa209e6 100644 --- a/Plugins/PackageToJS/PackageToJS.swift +++ b/Plugins/PackageToJS/PackageToJS.swift @@ -101,8 +101,8 @@ struct PackageToJS: CommandPlugin { } // Build products - let (build, productName) = try buildWasm(options: options, context: context) - guard build.succeeded else { + let (productArtifact, build) = try buildWasm(options: options, context: context) + guard let productArtifact = productArtifact else { for diagnostic in Self.friendlyBuildDiagnostics { if let message = diagnostic(build, arguments) { printStderr("\n" + message) @@ -110,8 +110,6 @@ struct PackageToJS: CommandPlugin { } exit(1) } - - let productArtifact = try build.findWasmArtifact(for: productName) let outputDir = if let outputPath = options.outputPath { URL(fileURLWithPath: outputPath) @@ -135,7 +133,7 @@ struct PackageToJS: CommandPlugin { } private func buildWasm(options: Options, context: PluginContext) throws -> ( - build: PackageManager.BuildResult, productName: String + productArtifact: URL?, build: PackageManager.BuildResult ) { var parameters = PackageManager.BuildParameters( configuration: .inherit, @@ -158,7 +156,24 @@ struct PackageToJS: CommandPlugin { } let productName = try options.product ?? deriveDefaultProduct(package: context.package) let build = try self.packageManager.build(.product(productName), parameters: parameters) - return (build, productName) + + var productArtifact: URL? + if build.succeeded { + let testProductName = "\(context.package.displayName)PackageTests" + if productName == testProductName { + for fileExtension in ["wasm", "xctest"] { + let path = ".build/debug/\(testProductName).\(fileExtension)" + if FileManager.default.fileExists(atPath: path) { + productArtifact = URL(fileURLWithPath: path) + break + } + } + } else { + productArtifact = try build.findWasmArtifact(for: productName) + } + } + + return (productArtifact, build) } /// Construct the build plan and return the root task key @@ -166,7 +181,7 @@ struct PackageToJS: CommandPlugin { make: inout MiniMake, options: Options, context: PluginContext, - wasmProductArtifact: PackageManager.BuildResult.BuiltArtifact, + wasmProductArtifact: URL, selfPackage: Package, outputDir: URL ) -> MiniMake.TaskKey { @@ -194,10 +209,10 @@ struct PackageToJS: CommandPlugin { // Copy the wasm product artifact let wasmFilename = "main.wasm" let wasm = make.addTask( - inputFiles: [selfPath, wasmProductArtifact.url.path], inputTasks: [outputDirTask], + inputFiles: [selfPath, wasmProductArtifact.path], inputTasks: [outputDirTask], output: outputDir.appending(path: wasmFilename).path ) { - try syncFile(from: wasmProductArtifact.url.path, to: $0.output) + try syncFile(from: wasmProductArtifact.path, to: $0.output) } packageInputs.append(wasm) @@ -231,6 +246,8 @@ struct PackageToJS: CommandPlugin { for (file, output) in [ ("Plugins/PackageToJS/Templates/index.js", "index.js"), ("Plugins/PackageToJS/Templates/index.d.ts", "index.d.ts"), + ("Plugins/PackageToJS/Templates/instantiate.js", "instantiate.js"), + ("Plugins/PackageToJS/Templates/instantiate.d.ts", "instantiate.d.ts"), ("Sources/JavaScriptKit/Runtime/index.mjs", "runtime.js"), ] { let inputPath = selfPackageURL.appending(path: file) @@ -291,9 +308,7 @@ internal func deriveDefaultProduct(package: Package) throws -> String { extension PackageManager.BuildResult { /// Find `.wasm` executable artifact - internal func findWasmArtifact(for product: String) throws - -> PackageManager.BuildResult.BuiltArtifact - { + internal func findWasmArtifact(for product: String) throws -> URL { let executables = self.builtArtifacts.filter { ($0.kind == .executable) && ($0.url.lastPathComponent == "\(product).wasm") } @@ -307,7 +322,7 @@ extension PackageManager.BuildResult { "Failed to disambiguate executable product artifacts from \(executables.map(\.url.path).joined(separator: ", "))" ) } - return executable + return executable.url } } diff --git a/Plugins/PackageToJS/Templates/index.d.ts b/Plugins/PackageToJS/Templates/index.d.ts index 6befac24c..506779757 100644 --- a/Plugins/PackageToJS/Templates/index.d.ts +++ b/Plugins/PackageToJS/Templates/index.d.ts @@ -1,59 +1,34 @@ -/* export */ type Import = { - // TODO: Generate type from imported .d.ts files -} -/* export */ type Export = { - // TODO: Generate type from .swift files -} - /** * The path to the WebAssembly module relative to the root of the package */ export declare const MODULE_PATH: string; -/** - * Low-level interface to create an instance of a WebAssembly module - * - * This is used to have full control over the instantiation process - * and to add custom imports. - */ -/* export */ interface Instantiator { - /** - * Add imports to the WebAssembly module - * @param imports - The imports to add - */ - addImports(imports: WebAssembly.Imports): void - +export type Options = { /** - * Create an interface to access exposed functionalities - * @param instance - The instance of the WebAssembly module - * @returns The interface to access the exposed functionalities + * The CLI arguments to pass to the WebAssembly module */ - createExports(instance: WebAssembly.Instance): Export + args?: string[] } /** - * Create an instantiator for the given imports - * @param imports - The imports to add - * @param options - The options - */ -/* export */ declare function createInstantiator( - imports: Import, - options: {} | undefined -): Promise - -/** - * Instantiate the given WebAssembly module + * Initialize the given WebAssembly module * * This is a convenience function that creates an instantiator and instantiates the module. * @param moduleSource - The WebAssembly module to instantiate * @param imports - The imports to add * @param options - The options */ -export declare function instantiate( +export declare function init( moduleSource: WebAssembly.Module | ArrayBufferView | ArrayBuffer | Response | PromiseLike, imports: Import, - options: {} | undefined + options: Options | undefined ): Promise<{ instance: WebAssembly.Instance, exports: Export }> + +export declare function runTest( + moduleSource: WebAssembly.Module | ArrayBufferView | ArrayBuffer | Response | PromiseLike, + imports: Import, + options: Options | undefined +): Promise<{ exitCode: number }> diff --git a/Plugins/PackageToJS/Templates/index.js b/Plugins/PackageToJS/Templates/index.js index a97945aa2..702ed5bab 100644 --- a/Plugins/PackageToJS/Templates/index.js +++ b/Plugins/PackageToJS/Templates/index.js @@ -1,30 +1,15 @@ // @ts-check -import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from '@bjorn3/browser_wasi_shim'; -// @ts-ignore -import { SwiftRuntime } from "./runtime.js" +import { WASI, WASIProcExit, File, OpenFile, ConsoleStdout, PreopenDirectory } from '@bjorn3/browser_wasi_shim'; +import { instantiate } from './instantiate.js'; export const MODULE_PATH = "@PACKAGE_TO_JS_MODULE_PATH@"; -/** @type {import('./index.d').createInstantiator} */ -/* export */ async function createInstantiator( - imports, - options = {} -) { - return { - addImports: () => {}, - createExports: () => { - return {}; - }, - } -} - -/** @type {import('./index.d').instantiate} */ -export async function instantiate( +/** @type {import('./index.d').init} */ +export async function init( moduleSource, imports, options ) { - const instantiator = await createInstantiator(imports, options); - const wasi = new WASI(/* args */[MODULE_PATH], /* env */[], /* fd */[ + const wasi = new WASI(/* args */[MODULE_PATH, ...(options?.args ?? [])], /* env */[], /* fd */[ new OpenFile(new File([])), // stdin ConsoleStdout.lineBuffered((stdout) => { console.log(stdout); @@ -33,44 +18,31 @@ export async function instantiate( console.error(stderr); }), new PreopenDirectory("/", new Map()), - ]) - const swift = new SwiftRuntime(); - - /** @type {WebAssembly.Imports} */ - const importObject = { - wasi_snapshot_preview1: wasi.wasiImport, - javascript_kit: swift.wasmImports, - }; - instantiator.addImports(importObject); - - let module; - let instance; - if (moduleSource instanceof WebAssembly.Module) { - module = moduleSource; - instance = await WebAssembly.instantiate(module, importObject); - } else if (typeof Response === "function" && (moduleSource instanceof Response || moduleSource instanceof Promise)) { - if (typeof WebAssembly.instantiateStreaming === "function") { - const result = await WebAssembly.instantiateStreaming(moduleSource, importObject); - module = result.module; - instance = result.instance; - } else { - const moduleBytes = await (await moduleSource).arrayBuffer(); - module = await WebAssembly.compile(moduleBytes); - instance = await WebAssembly.instantiate(module, importObject); - } - } else { - // @ts-expect-error: Type 'Response' is not assignable to type 'BufferSource' - module = await WebAssembly.compile(moduleSource); - instance = await WebAssembly.instantiate(module, importObject); - } - - swift.setInstance(instance); - // @ts-ignore: "exports" of the instance is not typed - wasi.initialize(instance); + ], { debug: false }) + const { instance, exports, swift } = await instantiate(moduleSource, imports, { + wasi: wasi + }); swift.main(); return { instance, - exports: instantiator.createExports(instance), + exports, + } +} + +/** @type {import('./index.d').runTest} */ +export async function runTest( + moduleSource, + imports, + options +) { + try { + const { instance, exports } = await init(moduleSource, imports, options); + return { exitCode: 0 }; + } catch (error) { + if (error instanceof WASIProcExit) { + return { exitCode: error.code }; + } + throw error; } } diff --git a/Plugins/PackageToJS/Templates/instantiate.d.ts b/Plugins/PackageToJS/Templates/instantiate.d.ts new file mode 100644 index 000000000..16d8dbd81 --- /dev/null +++ b/Plugins/PackageToJS/Templates/instantiate.d.ts @@ -0,0 +1,62 @@ +export type Import = { + // TODO: Generate type from imported .d.ts files +} +export type Export = { + // TODO: Generate type from .swift files +} + +/** + * Low-level interface to create an instance of a WebAssembly module + * + * This is used to have full control over the instantiation process + * and to add custom imports. + */ +export interface Instantiator { + /** + * Add imports to the WebAssembly module + * @param imports - The imports to add + */ + addImports(imports: WebAssembly.Imports): void + + /** + * Create an interface to access exposed functionalities + * @param instance - The instance of the WebAssembly module + * @returns The interface to access the exposed functionalities + */ + createExports(instance: WebAssembly.Instance): Export +} + +/** + * Create an instantiator for the given imports + * @param imports - The imports to add + * @param options - The options + */ +export declare function createInstantiator( + imports: Import, + options: Options | undefined +): Promise + +export interface WASI { + wasiImport: WebAssembly.ModuleImports + initialize(instance: WebAssembly.Instance): void +} + +/** + * Instantiate the given WebAssembly module + * + * This is a convenience function that creates an instantiator and instantiates the module. + * @param moduleSource - The WebAssembly module to instantiate + * @param imports - The imports to add + * @param options - The options + */ +export declare function instantiate( + moduleSource: WebAssembly.Module | ArrayBufferView | ArrayBuffer | Response | PromiseLike, + imports: Import, + options: { + wasi: WASI + } +): Promise<{ + instance: WebAssembly.Instance, + swift: any, + exports: Export +}> diff --git a/Plugins/PackageToJS/Templates/instantiate.js b/Plugins/PackageToJS/Templates/instantiate.js new file mode 100644 index 000000000..98acaf013 --- /dev/null +++ b/Plugins/PackageToJS/Templates/instantiate.js @@ -0,0 +1,64 @@ +// @ts-check +// @ts-ignore +import { SwiftRuntime } from "./runtime.js" + +/** @type {import('./instantiate.d').createInstantiator} */ +export async function createInstantiator( + imports, + options = {} +) { + return { + addImports: () => {}, + createExports: () => { + return {}; + }, + } +} + +/** @type {import('./instantiate.d').instantiate} */ +export async function instantiate( + moduleSource, + imports, + options +) { + const { wasi } = options; + const instantiator = await createInstantiator(imports, options); + const swift = new SwiftRuntime(); + + /** @type {WebAssembly.Imports} */ + const importObject = { + javascript_kit: swift.wasmImports, + wasi_snapshot_preview1: wasi.wasiImport, + }; + instantiator.addImports(importObject); + + let module; + let instance; + if (moduleSource instanceof WebAssembly.Module) { + module = moduleSource; + instance = await WebAssembly.instantiate(module, importObject); + } else if (typeof Response === "function" && (moduleSource instanceof Response || moduleSource instanceof Promise)) { + if (typeof WebAssembly.instantiateStreaming === "function") { + const result = await WebAssembly.instantiateStreaming(moduleSource, importObject); + module = result.module; + instance = result.instance; + } else { + const moduleBytes = await (await moduleSource).arrayBuffer(); + module = await WebAssembly.compile(moduleBytes); + instance = await WebAssembly.instantiate(module, importObject); + } + } else { + // @ts-expect-error: Type 'Response' is not assignable to type 'BufferSource' + module = await WebAssembly.compile(moduleSource); + instance = await WebAssembly.instantiate(module, importObject); + } + + swift.setInstance(instance); + wasi.initialize(instance); + + return { + instance, + swift, + exports: instantiator.createExports(instance), + } +} From 33bf22818e2715eb10376e3766ec6c279dc92fe7 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Thu, 6 Mar 2025 05:06:46 +0000 Subject: [PATCH 14/21] Add test subcommand to PackageToJS plugin --- Examples/Testing/run-tests.mjs | 3 + Examples/Testing/test.node.mjs | 14 - Plugins/PackageToJS/PackageToJS.swift | 288 +++++++++++---------- Plugins/PackageToJS/PackagingPlanner.swift | 163 ++++++++++++ Plugins/PackageToJS/Templates/bin/test.js | 8 + Plugins/PackageToJS/Templates/test.d.ts | 0 Plugins/PackageToJS/Templates/test.js | 32 +++ 7 files changed, 353 insertions(+), 155 deletions(-) create mode 100644 Examples/Testing/run-tests.mjs delete mode 100644 Examples/Testing/test.node.mjs create mode 100644 Plugins/PackageToJS/PackagingPlanner.swift create mode 100644 Plugins/PackageToJS/Templates/bin/test.js create mode 100644 Plugins/PackageToJS/Templates/test.d.ts create mode 100644 Plugins/PackageToJS/Templates/test.js diff --git a/Examples/Testing/run-tests.mjs b/Examples/Testing/run-tests.mjs new file mode 100644 index 000000000..1f90013a5 --- /dev/null +++ b/Examples/Testing/run-tests.mjs @@ -0,0 +1,3 @@ +import * as t from "./.build/plugins/PackageToJS/outputs/PackageTests/test.js" + +console.log(t) diff --git a/Examples/Testing/test.node.mjs b/Examples/Testing/test.node.mjs deleted file mode 100644 index 6fa90273b..000000000 --- a/Examples/Testing/test.node.mjs +++ /dev/null @@ -1,14 +0,0 @@ -import { WASI } from "wasi" -import { instantiate } from "./.build/plugins/PackageToJS/outputs/Package/instantiate.js" -import { readFile } from "fs/promises" - -const wasi = new WASI({ - version: "preview1", - args: ["--testing-library", "swift-testing"], - returnOnExit: false, -}) -const { swift } = await instantiate( - await readFile("./.build/plugins/PackageToJS/outputs/Package/main.wasm"), - {}, { wasi } -) -swift.main() diff --git a/Plugins/PackageToJS/PackageToJS.swift b/Plugins/PackageToJS/PackageToJS.swift index 6eaa209e6..f5f0ad69c 100644 --- a/Plugins/PackageToJS/PackageToJS.swift +++ b/Plugins/PackageToJS/PackageToJS.swift @@ -4,8 +4,6 @@ import PackagePlugin @main struct PackageToJS: CommandPlugin { struct Options { - /// Product to build (default: executable target if there's only one) - var product: String? /// Path to the output directory var outputPath: String? /// Name of the package (default: lowercased Package.swift name) @@ -14,34 +12,82 @@ struct PackageToJS: CommandPlugin { var explain: Bool = false static func parse(from extractor: inout ArgumentExtractor) -> Options { - let product = extractor.extractOption(named: "product").last let outputPath = extractor.extractOption(named: "output").last let packageName = extractor.extractOption(named: "package-name").last let explain = extractor.extractFlag(named: "explain") return Options( - product: product, outputPath: outputPath, packageName: packageName, - explain: explain != 0 + outputPath: outputPath, packageName: packageName, explain: explain != 0 ) } + } + + struct BuildOptions { + /// Product to build (default: executable target if there's only one) + var product: String? + var options: Options + + static func parse(from extractor: inout ArgumentExtractor) -> BuildOptions { + let product = extractor.extractOption(named: "product").last + let options = Options.parse(from: &extractor) + return BuildOptions(product: product, options: options) + } static func help() -> String { return """ - Usage: swift package --swift-sdk [swift-package options] plugin run PackageToJS [options] + OVERVIEW: Builds a JavaScript module from a Swift package. - Options: + USAGE: swift package --swift-sdk [SwiftPM options] PackageToJS [options] [subcommand] + + OPTIONS: --product Product to build (default: executable target if there's only one) --output Path to the output directory (default: .build/plugins/PackageToJS/outputs/Package) --package-name Name of the package (default: lowercased Package.swift name) --explain Whether to explain the build plan - Examples: + SUBCOMMANDS: + test Builds and runs tests + + EXAMPLES: $ swift package --swift-sdk wasm32-unknown-wasi plugin js + # Build a specific product $ swift package --swift-sdk wasm32-unknown-wasi plugin js --product Example + # Build in release configuration $ swift package --swift-sdk wasm32-unknown-wasi -c release plugin js + + # Run tests + $ swift package --swift-sdk wasm32-unknown-wasi plugin js test """ } } + struct TestOptions { + /// Whether to only build tests, don't run them + var buildOnly: Bool = false + var options: Options + + static func parse(from extractor: inout ArgumentExtractor) -> TestOptions { + let buildOnly = extractor.extractFlag(named: "build-only") + let options = Options.parse(from: &extractor) + return TestOptions(buildOnly: buildOnly != 0, options: options) + } + + static func help() -> String { + return """ + OVERVIEW: Builds and runs tests + + USAGE: swift package --swift-sdk [SwiftPM options] PackageToJS test [options] + + OPTIONS: + --build-only Whether to build only (default: false) + + EXAMPLES: + $ swift package --swift-sdk wasm32-unknown-wasi plugin js test + # Just build tests, don't run them + $ swift package --swift-sdk wasm32-unknown-wasi plugin js test --build-only + """ + } + } + static let friendlyBuildDiagnostics: [@Sendable (_ build: PackageManager.BuildResult, _ arguments: [String]) -> String?] = [ ( @@ -83,58 +129,129 @@ struct PackageToJS: CommandPlugin { """ }), ] + static private func reportBuildFailure(_ build: PackageManager.BuildResult, _ arguments: [String]) { + for diagnostic in Self.friendlyBuildDiagnostics { + if let message = diagnostic(build, arguments) { + printStderr("\n" + message) + } + } + } func performCommand(context: PluginContext, arguments: [String]) throws { if arguments.contains(where: { ["-h", "--help"].contains($0) }) { - printStderr(Options.help()) + printStderr(BuildOptions.help()) return } + if arguments.first == "test" { + return try performTestCommand(context: context, arguments: Array(arguments.dropFirst())) + } + + return try performBuildCommand(context: context, arguments: arguments) + } + + static let JAVASCRIPTKIT_PACKAGE_ID: Package.ID = "javascriptkit" + + func performBuildCommand(context: PluginContext, arguments: [String]) throws { var extractor = ArgumentExtractor(arguments) - let options = Options.parse(from: &extractor) + let buildOptions = BuildOptions.parse(from: &extractor) if extractor.remainingArguments.count > 0 { printStderr( "Unexpected arguments: \(extractor.remainingArguments.joined(separator: " "))") - printStderr(Options.help()) + printStderr(BuildOptions.help()) exit(1) } // Build products - let (productArtifact, build) = try buildWasm(options: options, context: context) - guard let productArtifact = productArtifact else { - for diagnostic in Self.friendlyBuildDiagnostics { - if let message = diagnostic(build, arguments) { - printStderr("\n" + message) - } - } + let productName = try buildOptions.product ?? deriveDefaultProduct(package: context.package) + let build = try buildWasm(productName: productName, context: context) + guard build.succeeded else { + Self.reportBuildFailure(build, arguments) exit(1) } + let productArtifact = try build.findWasmArtifact(for: productName) let outputDir = - if let outputPath = options.outputPath { + if let outputPath = buildOptions.options.outputPath { URL(fileURLWithPath: outputPath) } else { context.pluginWorkDirectoryURL.appending(path: "Package") } guard let selfPackage = findPackageInDependencies( - package: context.package, id: "javascriptkit") + package: context.package, id: Self.JAVASCRIPTKIT_PACKAGE_ID) else { throw PackageToJSError("Failed to find JavaScriptKit in dependencies!?") } - var make = MiniMake(explain: options.explain) - let allTask = constructPackagingPlan( - make: &make, options: options, context: context, wasmProductArtifact: productArtifact, - selfPackage: selfPackage, outputDir: outputDir) - cleanIfBuildGraphChanged(root: allTask, make: make, context: context) + var make = MiniMake(explain: buildOptions.options.explain) + let planner = PackagingPlanner( + options: buildOptions.options, context: context, selfPackage: selfPackage, outputDir: outputDir) + let rootTask = planner.planBuild( + make: &make, wasmProductArtifact: productArtifact) + cleanIfBuildGraphChanged(root: rootTask, make: make, context: context) print("Packaging...") - try make.build(output: allTask) + try make.build(output: rootTask) print("Packaging finished") } - private func buildWasm(options: Options, context: PluginContext) throws -> ( - productArtifact: URL?, build: PackageManager.BuildResult - ) { + func performTestCommand(context: PluginContext, arguments: [String]) throws { + var extractor = ArgumentExtractor(arguments) + let testOptions = TestOptions.parse(from: &extractor) + + if extractor.remainingArguments.count > 0 { + printStderr("Unexpected arguments: \(extractor.remainingArguments.joined(separator: " "))") + printStderr(TestOptions.help()) + exit(1) + } + + let productName = "\(context.package.displayName)PackageTests" + let build = try buildWasm(productName: productName, context: context) + guard build.succeeded else { + Self.reportBuildFailure(build, arguments) + exit(1) + } + + // NOTE: Find the product artifact from the default build directory + // because PackageManager.BuildResult doesn't include the + // product artifact for tests. + // This doesn't work when `--scratch-path` is used but + // we don't have a way to guess the correct path. (we can find + // the path by building a dummy executable product but it's + // not worth the overhead) + var productArtifact: URL? + for fileExtension in ["wasm", "xctest"] { + let path = ".build/debug/\(productName).\(fileExtension)" + if FileManager.default.fileExists(atPath: path) { + productArtifact = URL(fileURLWithPath: path) + break + } + } + guard let productArtifact = productArtifact else { + throw PackageToJSError("Failed to find '\(productName).wasm' or '\(productName).xctest'") + } + let outputDir = if let outputPath = testOptions.options.outputPath { + URL(fileURLWithPath: outputPath) + } else { + context.pluginWorkDirectoryURL.appending(path: "PackageTests") + } + guard + let selfPackage = findPackageInDependencies( + package: context.package, id: Self.JAVASCRIPTKIT_PACKAGE_ID) + else { + throw PackageToJSError("Failed to find JavaScriptKit in dependencies!?") + } + var make = MiniMake(explain: testOptions.options.explain) + let planner = PackagingPlanner( + options: testOptions.options, context: context, selfPackage: selfPackage, outputDir: outputDir) + let rootTask = planner.planTestBuild( + make: &make, wasmProductArtifact: productArtifact) + cleanIfBuildGraphChanged(root: rootTask, make: make, context: context) + print("Packaging tests...") + try make.build(output: rootTask) + print("Packaging tests finished") + } + + private func buildWasm(productName: String, context: PluginContext) throws -> PackageManager.BuildResult { var parameters = PackageManager.BuildParameters( configuration: .inherit, logging: .concise @@ -154,118 +271,7 @@ struct PackageToJS: CommandPlugin { "--export-if-defined=__main_argc_argv" ] } - let productName = try options.product ?? deriveDefaultProduct(package: context.package) - let build = try self.packageManager.build(.product(productName), parameters: parameters) - - var productArtifact: URL? - if build.succeeded { - let testProductName = "\(context.package.displayName)PackageTests" - if productName == testProductName { - for fileExtension in ["wasm", "xctest"] { - let path = ".build/debug/\(testProductName).\(fileExtension)" - if FileManager.default.fileExists(atPath: path) { - productArtifact = URL(fileURLWithPath: path) - break - } - } - } else { - productArtifact = try build.findWasmArtifact(for: productName) - } - } - - return (productArtifact, build) - } - - /// Construct the build plan and return the root task key - private func constructPackagingPlan( - make: inout MiniMake, - options: Options, - context: PluginContext, - wasmProductArtifact: URL, - selfPackage: Package, - outputDir: URL - ) -> MiniMake.TaskKey { - let selfPackageURL = selfPackage.directoryURL - let selfPath = String(#filePath) - - // Prepare output directory - let outputDirTask = make.addTask( - inputFiles: [selfPath], output: outputDir.path, attributes: [.silent] - ) { - guard !FileManager.default.fileExists(atPath: $0.output) else { return } - try FileManager.default.createDirectory( - atPath: $0.output, withIntermediateDirectories: true, attributes: nil) - } - - var packageInputs: [MiniMake.TaskKey] = [] - - func syncFile(from: String, to: String) throws { - if FileManager.default.fileExists(atPath: to) { - try FileManager.default.removeItem(atPath: to) - } - try FileManager.default.copyItem(atPath: from, toPath: to) - } - - // Copy the wasm product artifact - let wasmFilename = "main.wasm" - let wasm = make.addTask( - inputFiles: [selfPath, wasmProductArtifact.path], inputTasks: [outputDirTask], - output: outputDir.appending(path: wasmFilename).path - ) { - try syncFile(from: wasmProductArtifact.path, to: $0.output) - } - packageInputs.append(wasm) - - // Write package.json - let packageJSON = make.addTask( - inputFiles: [selfPath], inputTasks: [outputDirTask], - output: outputDir.appending(path: "package.json").path - ) { - let packageJSON = """ - { - "name": "\(options.packageName ?? context.package.id.lowercased())", - "version": "0.0.0", - "type": "module", - "exports": { - ".": "./index.js", - "./wasm": "./\(wasmFilename)" - }, - "dependencies": { - "@bjorn3/browser_wasi_shim": "^0.4.1" - } - } - """ - try packageJSON.write(toFile: $0.output, atomically: true, encoding: .utf8) - } - packageInputs.append(packageJSON) - - // Copy the template files - let substitutions = [ - "@PACKAGE_TO_JS_MODULE_PATH@": wasmFilename - ] - for (file, output) in [ - ("Plugins/PackageToJS/Templates/index.js", "index.js"), - ("Plugins/PackageToJS/Templates/index.d.ts", "index.d.ts"), - ("Plugins/PackageToJS/Templates/instantiate.js", "instantiate.js"), - ("Plugins/PackageToJS/Templates/instantiate.d.ts", "instantiate.d.ts"), - ("Sources/JavaScriptKit/Runtime/index.mjs", "runtime.js"), - ] { - let inputPath = selfPackageURL.appending(path: file) - let copied = make.addTask( - inputFiles: [selfPath, inputPath.path], inputTasks: [outputDirTask], - output: outputDir.appending(path: output).path - ) { - var content = try String(contentsOf: inputPath, encoding: .utf8) - for (key, value) in substitutions { - content = content.replacingOccurrences(of: key, with: value) - } - try content.write(toFile: $0.output, atomically: true, encoding: .utf8) - } - packageInputs.append(copied) - } - return make.addTask( - inputTasks: packageInputs, output: "all", attributes: [.phony, .silent] - ) { _ in } + return try self.packageManager.build(.product(productName), parameters: parameters) } /// Clean if the build graph of the packaging process has changed diff --git a/Plugins/PackageToJS/PackagingPlanner.swift b/Plugins/PackageToJS/PackagingPlanner.swift new file mode 100644 index 000000000..35b65d80f --- /dev/null +++ b/Plugins/PackageToJS/PackagingPlanner.swift @@ -0,0 +1,163 @@ +import Foundation +import PackagePlugin + +struct PackagingPlanner { + let options: PackageToJS.Options + let context: PluginContext + let selfPackage: Package + let selfPath: String + let outputDir: URL + let wasmFilename = "main.wasm" + + init( + options: PackageToJS.Options, context: PluginContext, selfPackage: Package, + outputDir: URL + ) { + self.options = options + self.context = context + self.selfPackage = selfPackage + self.outputDir = outputDir + self.selfPath = String(#filePath) + } + + private static func syncFile(from: String, to: String) throws { + if FileManager.default.fileExists(atPath: to) { + try FileManager.default.removeItem(atPath: to) + } + try FileManager.default.copyItem(atPath: from, toPath: to) + } + + private static func createDirectory(atPath: String) throws { + guard !FileManager.default.fileExists(atPath: atPath) else { return } + try FileManager.default.createDirectory( + atPath: atPath, withIntermediateDirectories: true, attributes: nil + ) + } + + /// Construct the build plan and return the root task key + func planBuild( + make: inout MiniMake, + wasmProductArtifact: URL, + ) -> MiniMake.TaskKey { + let (allTasks, _) = planBuildInternal(make: &make, wasmProductArtifact: wasmProductArtifact) + return make.addTask( + inputTasks: allTasks, output: "all", attributes: [.phony, .silent] + ) { _ in } + } + + private func planBuildInternal( + make: inout MiniMake, + wasmProductArtifact: URL, + ) -> (allTasks: [MiniMake.TaskKey], outputDirTask: MiniMake.TaskKey) { + // Prepare output directory + let outputDirTask = make.addTask( + inputFiles: [selfPath], output: outputDir.path, attributes: [.silent] + ) { + try Self.createDirectory(atPath: $0.output) + } + + var packageInputs: [MiniMake.TaskKey] = [] + + // Copy the wasm product artifact + let wasm = make.addTask( + inputFiles: [selfPath, wasmProductArtifact.path], inputTasks: [outputDirTask], + output: outputDir.appending(path: wasmFilename).path + ) { + try Self.syncFile(from: wasmProductArtifact.path, to: $0.output) + } + packageInputs.append(wasm) + + // Write package.json + let packageJSON = make.addTask( + inputFiles: [selfPath], inputTasks: [outputDirTask], + output: outputDir.appending(path: "package.json").path + ) { + let packageJSON = """ + { + "name": "\(options.packageName ?? context.package.id.lowercased())", + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./index.js", + "./wasm": "./\(wasmFilename)" + }, + "dependencies": { + "@bjorn3/browser_wasi_shim": "^0.4.1" + } + } + """ + try packageJSON.write(toFile: $0.output, atomically: true, encoding: .utf8) + } + packageInputs.append(packageJSON) + + // Copy the template files + for (file, output) in [ + ("Plugins/PackageToJS/Templates/index.js", "index.js"), + ("Plugins/PackageToJS/Templates/index.d.ts", "index.d.ts"), + ("Plugins/PackageToJS/Templates/instantiate.js", "instantiate.js"), + ("Plugins/PackageToJS/Templates/instantiate.d.ts", "instantiate.d.ts"), + ("Sources/JavaScriptKit/Runtime/index.mjs", "runtime.js"), + ] { + packageInputs.append(planCopyTemplateFile( + make: &make, file: file, output: output, outputDirTask: outputDirTask, + inputs: [] + )) + } + return (packageInputs, outputDirTask) + } + + /// Construct the test build plan and return the root task key + func planTestBuild( + make: inout MiniMake, + wasmProductArtifact: URL, + ) -> MiniMake.TaskKey { + var (allTasks, outputDirTask) = planBuildInternal(make: &make, wasmProductArtifact: wasmProductArtifact) + + let binDir = outputDir.appending(path: "bin") + let binDirTask = make.addTask( + inputFiles: [selfPath], inputTasks: [outputDirTask], + output: binDir.path + ) { + try Self.createDirectory(atPath: $0.output) + } + allTasks.append(binDirTask) + + // Copy the template files + for (file, output) in [ + ("Plugins/PackageToJS/Templates/test.js", "test.js"), + ("Plugins/PackageToJS/Templates/test.d.ts", "test.d.ts"), + ("Plugins/PackageToJS/Templates/bin/test.js", "bin/test.js"), + ] { + allTasks.append(planCopyTemplateFile( + make: &make, file: file, output: output, outputDirTask: outputDirTask, + inputs: [binDirTask] + )) + } + return make.addTask( + inputTasks: allTasks, output: "all", attributes: [.phony, .silent] + ) { _ in } + } + + private func planCopyTemplateFile( + make: inout MiniMake, + file: String, + output: String, + outputDirTask: MiniMake.TaskKey, + inputs: [MiniMake.TaskKey] + ) -> MiniMake.TaskKey { + let inputPath = selfPackage.directoryURL.appending(path: file) + let substitutions = [ + "@PACKAGE_TO_JS_MODULE_PATH@": wasmFilename + ] + return make.addTask( + inputFiles: [selfPath, inputPath.path], inputTasks: [outputDirTask] + inputs, + output: outputDir.appending(path: output).path + ) { + var content = try String(contentsOf: inputPath, encoding: .utf8) + for (key, value) in substitutions { + content = content.replacingOccurrences(of: key, with: value) + } + try content.write(toFile: $0.output, atomically: true, encoding: .utf8) + } + } +} diff --git a/Plugins/PackageToJS/Templates/bin/test.js b/Plugins/PackageToJS/Templates/bin/test.js new file mode 100644 index 000000000..6f1e9fa73 --- /dev/null +++ b/Plugins/PackageToJS/Templates/bin/test.js @@ -0,0 +1,8 @@ +import { NodeRunner, BrowserRunner } from "../test.js" + +const runners = { + "node": NodeRunner, + "browser": BrowserRunner, +} +const runner = new runners[process.env.TEST_RUNNER || "node"]() +await runner.run() diff --git a/Plugins/PackageToJS/Templates/test.d.ts b/Plugins/PackageToJS/Templates/test.d.ts new file mode 100644 index 000000000..e69de29bb diff --git a/Plugins/PackageToJS/Templates/test.js b/Plugins/PackageToJS/Templates/test.js new file mode 100644 index 000000000..b60a20277 --- /dev/null +++ b/Plugins/PackageToJS/Templates/test.js @@ -0,0 +1,32 @@ +// @ts-check +import { WASI } from "wasi" +import { instantiate } from "./instantiate.js" +import { MODULE_PATH } from "./index.js" +import { readFile } from "fs/promises" + +export class NodeRunner { + constructor() { } + + async run() { + const wasi = new WASI({ + version: "preview1", + args: ["--testing-library", "swift-testing"], + returnOnExit: false, + }) + const path = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const dirname = path.dirname(fileURLToPath(import.meta.url)) + const { swift } = await instantiate( + await readFile(path.join(dirname, MODULE_PATH)), + {}, { wasi } + ) + swift.main() + } +} + +export class BrowserRunner { + constructor() { } + + async run() { + } +} From 0128cc19975a993abb70abe9d3d0b747c489916d Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Thu, 6 Mar 2025 06:55:18 +0000 Subject: [PATCH 15/21] Add Node.js harness --- Plugins/PackageToJS/PackageToJS.swift | 103 +++++++++++++++++---- Plugins/PackageToJS/PackagingPlanner.swift | 5 +- Plugins/PackageToJS/Templates/test.d.ts | 9 ++ Plugins/PackageToJS/Templates/test.js | 32 ++++++- 4 files changed, 123 insertions(+), 26 deletions(-) diff --git a/Plugins/PackageToJS/PackageToJS.swift b/Plugins/PackageToJS/PackageToJS.swift index f5f0ad69c..7900fed6f 100644 --- a/Plugins/PackageToJS/PackageToJS.swift +++ b/Plugins/PackageToJS/PackageToJS.swift @@ -62,13 +62,20 @@ struct PackageToJS: CommandPlugin { struct TestOptions { /// Whether to only build tests, don't run them - var buildOnly: Bool = false + var buildOnly: Bool + var listTests: Bool + var testLibrary: String? + var filter: [String] + var options: Options static func parse(from extractor: inout ArgumentExtractor) -> TestOptions { let buildOnly = extractor.extractFlag(named: "build-only") + let listTests = extractor.extractFlag(named: "list-tests") + let testLibrary = extractor.extractOption(named: "test-library").last + let filter = extractor.extractOption(named: "filter") let options = Options.parse(from: &extractor) - return TestOptions(buildOnly: buildOnly != 0, options: options) + return TestOptions(buildOnly: buildOnly != 0, listTests: listTests != 0, testLibrary: testLibrary, filter: filter, options: options) } static func help() -> String { @@ -84,7 +91,7 @@ struct PackageToJS: CommandPlugin { $ swift package --swift-sdk wasm32-unknown-wasi plugin js test # Just build tests, don't run them $ swift package --swift-sdk wasm32-unknown-wasi plugin js test --build-only - """ + """ } } @@ -129,7 +136,9 @@ struct PackageToJS: CommandPlugin { """ }), ] - static private func reportBuildFailure(_ build: PackageManager.BuildResult, _ arguments: [String]) { + static private func reportBuildFailure( + _ build: PackageManager.BuildResult, _ arguments: [String] + ) { for diagnostic in Self.friendlyBuildDiagnostics { if let message = diagnostic(build, arguments) { printStderr("\n" + message) @@ -138,11 +147,6 @@ struct PackageToJS: CommandPlugin { } func performCommand(context: PluginContext, arguments: [String]) throws { - if arguments.contains(where: { ["-h", "--help"].contains($0) }) { - printStderr(BuildOptions.help()) - return - } - if arguments.first == "test" { return try performTestCommand(context: context, arguments: Array(arguments.dropFirst())) } @@ -153,6 +157,11 @@ struct PackageToJS: CommandPlugin { static let JAVASCRIPTKIT_PACKAGE_ID: Package.ID = "javascriptkit" func performBuildCommand(context: PluginContext, arguments: [String]) throws { + if arguments.contains(where: { ["-h", "--help"].contains($0) }) { + printStderr(BuildOptions.help()) + return + } + var extractor = ArgumentExtractor(arguments) let buildOptions = BuildOptions.parse(from: &extractor) @@ -185,7 +194,8 @@ struct PackageToJS: CommandPlugin { } var make = MiniMake(explain: buildOptions.options.explain) let planner = PackagingPlanner( - options: buildOptions.options, context: context, selfPackage: selfPackage, outputDir: outputDir) + options: buildOptions.options, context: context, selfPackage: selfPackage, + outputDir: outputDir) let rootTask = planner.planBuild( make: &make, wasmProductArtifact: productArtifact) cleanIfBuildGraphChanged(root: rootTask, make: make, context: context) @@ -195,6 +205,11 @@ struct PackageToJS: CommandPlugin { } func performTestCommand(context: PluginContext, arguments: [String]) throws { + if arguments.contains(where: { ["-h", "--help"].contains($0) }) { + printStderr(TestOptions.help()) + return + } + var extractor = ArgumentExtractor(arguments) let testOptions = TestOptions.parse(from: &extractor) @@ -227,13 +242,15 @@ struct PackageToJS: CommandPlugin { } } guard let productArtifact = productArtifact else { - throw PackageToJSError("Failed to find '\(productName).wasm' or '\(productName).xctest'") - } - let outputDir = if let outputPath = testOptions.options.outputPath { - URL(fileURLWithPath: outputPath) - } else { - context.pluginWorkDirectoryURL.appending(path: "PackageTests") + throw PackageToJSError( + "Failed to find '\(productName).wasm' or '\(productName).xctest'") } + let outputDir = + if let outputPath = testOptions.options.outputPath { + URL(fileURLWithPath: outputPath) + } else { + context.pluginWorkDirectoryURL.appending(path: "PackageTests") + } guard let selfPackage = findPackageInDependencies( package: context.package, id: Self.JAVASCRIPTKIT_PACKAGE_ID) @@ -242,16 +259,47 @@ struct PackageToJS: CommandPlugin { } var make = MiniMake(explain: testOptions.options.explain) let planner = PackagingPlanner( - options: testOptions.options, context: context, selfPackage: selfPackage, outputDir: outputDir) - let rootTask = planner.planTestBuild( + options: testOptions.options, context: context, selfPackage: selfPackage, + outputDir: outputDir) + let (rootTask, binDir) = planner.planTestBuild( make: &make, wasmProductArtifact: productArtifact) cleanIfBuildGraphChanged(root: rootTask, make: make, context: context) print("Packaging tests...") try make.build(output: rootTask) print("Packaging tests finished") + + let testRunner = binDir.appending(path: "test.js") + if !testOptions.buildOnly { + var extraArguments: [String] = [] + if testOptions.listTests { + extraArguments += ["--list-tests"] + } + try runTest(testRunner: testRunner, context: context, extraArguments: extraArguments + testOptions.filter) + try runTest(testRunner: testRunner, context: context, + extraArguments: ["--testing-library", "swift-testing"] + extraArguments + testOptions.filter.flatMap { ["--filter", $0] }) + } + } + + private func runTest(testRunner: URL, context: PluginContext, extraArguments: [String]) throws { + let node = try which("node") + let arguments = ["--experimental-wasi-unstable-preview1", testRunner.path] + extraArguments + print("Running test...") + print("$ \(([node.path] + arguments).map { "\"\($0)\"" }.joined(separator: " "))") + + let task = Process() + task.executableURL = node + task.arguments = arguments + task.currentDirectoryURL = context.pluginWorkDirectoryURL + try task.run() + task.waitUntilExit() + guard task.terminationStatus == 0 else { + throw PackageToJSError("Test failed with status \(task.terminationStatus)") + } } - private func buildWasm(productName: String, context: PluginContext) throws -> PackageManager.BuildResult { + private func buildWasm(productName: String, context: PluginContext) throws + -> PackageManager.BuildResult + { var parameters = PackageManager.BuildParameters( configuration: .inherit, logging: .concise @@ -357,6 +405,23 @@ private func printStderr(_ message: String) { fputs(message + "\n", stderr) } +private func which(_ executable: String) throws -> URL { + let pathSeparator: Character + #if os(Windows) + pathSeparator = ";" + #else + pathSeparator = ":" + #endif + let paths = ProcessInfo.processInfo.environment["PATH"]!.split(separator: pathSeparator) + for path in paths { + let url = URL(fileURLWithPath: String(path)).appendingPathComponent(executable) + if FileManager.default.isExecutableFile(atPath: url.path) { + return url + } + } + throw PackageToJSError("Executable \(executable) not found in PATH") +} + private struct PackageToJSError: Swift.Error, CustomStringConvertible { let description: String diff --git a/Plugins/PackageToJS/PackagingPlanner.swift b/Plugins/PackageToJS/PackagingPlanner.swift index 35b65d80f..748aadbab 100644 --- a/Plugins/PackageToJS/PackagingPlanner.swift +++ b/Plugins/PackageToJS/PackagingPlanner.swift @@ -110,7 +110,7 @@ struct PackagingPlanner { func planTestBuild( make: inout MiniMake, wasmProductArtifact: URL, - ) -> MiniMake.TaskKey { + ) -> (rootTask: MiniMake.TaskKey, binDir: URL) { var (allTasks, outputDirTask) = planBuildInternal(make: &make, wasmProductArtifact: wasmProductArtifact) let binDir = outputDir.appending(path: "bin") @@ -133,9 +133,10 @@ struct PackagingPlanner { inputs: [binDirTask] )) } - return make.addTask( + let rootTask = make.addTask( inputTasks: allTasks, output: "all", attributes: [.phony, .silent] ) { _ in } + return (rootTask, binDir) } private func planCopyTemplateFile( diff --git a/Plugins/PackageToJS/Templates/test.d.ts b/Plugins/PackageToJS/Templates/test.d.ts index e69de29bb..2e43a3d4f 100644 --- a/Plugins/PackageToJS/Templates/test.d.ts +++ b/Plugins/PackageToJS/Templates/test.d.ts @@ -0,0 +1,9 @@ +export declare class NodeRunner { + constructor() + run(): Promise +} + +export declare class BrowserRunner { + constructor() + run(): Promise +} diff --git a/Plugins/PackageToJS/Templates/test.js b/Plugins/PackageToJS/Templates/test.js index b60a20277..b5485d7a2 100644 --- a/Plugins/PackageToJS/Templates/test.js +++ b/Plugins/PackageToJS/Templates/test.js @@ -1,20 +1,42 @@ // @ts-check -import { WASI } from "wasi" import { instantiate } from "./instantiate.js" import { MODULE_PATH } from "./index.js" -import { readFile } from "fs/promises" export class NodeRunner { constructor() { } async run() { + try { + await this._run() + } catch (error) { + // Print hint for the user + if (error instanceof WebAssembly.CompileError) { + // Old Node.js doesn't support some wasm features. + // Our minimum supported version is v18.x + throw new Error(`${error.message} + +Hint: Some WebAssembly features might not be supported in your Node.js version. +Please ensure you are using Node.js v18.x or newer. + `) + } + throw error + } + } + + async _run() { + const { WASI } = await import("wasi") + const path = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const { readFile } = await import("node:fs/promises") + const wasi = new WASI({ version: "preview1", - args: ["--testing-library", "swift-testing"], + args: [MODULE_PATH, ...process.argv.slice(2)], + preopens: { + "./": process.cwd(), + }, returnOnExit: false, }) - const path = await import("node:path"); - const { fileURLToPath } = await import("node:url"); const dirname = path.dirname(fileURLToPath(import.meta.url)) const { swift } = await instantiate( await readFile(path.join(dirname, MODULE_PATH)), From 3fb7879544fb853842bc01678e530f415c0df505 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Thu, 6 Mar 2025 07:17:35 +0000 Subject: [PATCH 16/21] Ignore "no tests found" exit status in PackageToJS --- Plugins/PackageToJS/PackageToJS.swift | 43 ++++++++++++++++----------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/Plugins/PackageToJS/PackageToJS.swift b/Plugins/PackageToJS/PackageToJS.swift index 7900fed6f..cd6ea4037 100644 --- a/Plugins/PackageToJS/PackageToJS.swift +++ b/Plugins/PackageToJS/PackageToJS.swift @@ -40,7 +40,7 @@ struct PackageToJS: CommandPlugin { OPTIONS: --product Product to build (default: executable target if there's only one) - --output Path to the output directory (default: .build/plugins/PackageToJS/outputs/Package) + --output Path to the output directory (default: .build/plugins/PackageToJS/outputs/Package) --package-name Name of the package (default: lowercased Package.swift name) --explain Whether to explain the build plan @@ -75,7 +75,10 @@ struct PackageToJS: CommandPlugin { let testLibrary = extractor.extractOption(named: "test-library").last let filter = extractor.extractOption(named: "filter") let options = Options.parse(from: &extractor) - return TestOptions(buildOnly: buildOnly != 0, listTests: listTests != 0, testLibrary: testLibrary, filter: filter, options: options) + return TestOptions( + buildOnly: buildOnly != 0, listTests: listTests != 0, testLibrary: testLibrary, + filter: filter, options: options + ) } static func help() -> String { @@ -83,7 +86,7 @@ struct PackageToJS: CommandPlugin { OVERVIEW: Builds and runs tests USAGE: swift package --swift-sdk [SwiftPM options] PackageToJS test [options] - + OPTIONS: --build-only Whether to build only (default: false) @@ -91,6 +94,7 @@ struct PackageToJS: CommandPlugin { $ swift package --swift-sdk wasm32-unknown-wasi plugin js test # Just build tests, don't run them $ swift package --swift-sdk wasm32-unknown-wasi plugin js test --build-only + $ node .build/plugins/PackageToJS/outputs/PackageTests/bin/test.js """ } } @@ -174,7 +178,8 @@ struct PackageToJS: CommandPlugin { // Build products let productName = try buildOptions.product ?? deriveDefaultProduct(package: context.package) - let build = try buildWasm(productName: productName, context: context) + let build = try buildWasm( + productName: productName, context: context, options: buildOptions.options) guard build.succeeded else { Self.reportBuildFailure(build, arguments) exit(1) @@ -214,13 +219,15 @@ struct PackageToJS: CommandPlugin { let testOptions = TestOptions.parse(from: &extractor) if extractor.remainingArguments.count > 0 { - printStderr("Unexpected arguments: \(extractor.remainingArguments.joined(separator: " "))") + printStderr( + "Unexpected arguments: \(extractor.remainingArguments.joined(separator: " "))") printStderr(TestOptions.help()) exit(1) } let productName = "\(context.package.displayName)PackageTests" - let build = try buildWasm(productName: productName, context: context) + let build = try buildWasm( + productName: productName, context: context, options: testOptions.options) guard build.succeeded else { Self.reportBuildFailure(build, arguments) exit(1) @@ -274,9 +281,15 @@ struct PackageToJS: CommandPlugin { if testOptions.listTests { extraArguments += ["--list-tests"] } - try runTest(testRunner: testRunner, context: context, extraArguments: extraArguments + testOptions.filter) - try runTest(testRunner: testRunner, context: context, - extraArguments: ["--testing-library", "swift-testing"] + extraArguments + testOptions.filter.flatMap { ["--filter", $0] }) + try runTest( + testRunner: testRunner, context: context, + extraArguments: extraArguments + testOptions.filter + ) + try runTest( + testRunner: testRunner, context: context, + extraArguments: ["--testing-library", "swift-testing"] + extraArguments + + testOptions.filter.flatMap { ["--filter", $0] } + ) } } @@ -292,12 +305,13 @@ struct PackageToJS: CommandPlugin { task.currentDirectoryURL = context.pluginWorkDirectoryURL try task.run() task.waitUntilExit() - guard task.terminationStatus == 0 else { + // swift-testing returns EX_UNAVAILABLE (which is 69 in wasi-libc) for "no tests found" + guard task.terminationStatus == 0 || task.terminationStatus == 69 else { throw PackageToJSError("Test failed with status \(task.terminationStatus)") } } - private func buildWasm(productName: String, context: PluginContext) throws + private func buildWasm(productName: String, context: PluginContext, options: Options) throws -> PackageManager.BuildResult { var parameters = PackageManager.BuildParameters( @@ -385,12 +399,7 @@ private func findPackageInDependencies(package: Package, id: Package.ID) -> Pack func visit(package: Package) -> Package? { if visited.contains(package.id) { return nil } visited.insert(package.id) - for dependency in package.dependencies { - let dependencyPackage = dependency.package - if dependencyPackage.id == id { - return dependencyPackage - } - } + if package.id == id { return package } for dependency in package.dependencies { if let found = visit(package: dependency.package) { return found From 2b6aa1c42ae5aa0b1b4f89677e3a3f8a2544047b Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Thu, 6 Mar 2025 07:22:40 +0000 Subject: [PATCH 17/21] Fix build for 6.0 --- Examples/Testing/Package.swift | 2 +- .../Tests/CounterTests/CounterTests.swift | 23 ++++++++++++++++++- Examples/Testing/run-tests.mjs | 3 --- Plugins/PackageToJS/PackagingPlanner.swift | 6 ++--- 4 files changed, 26 insertions(+), 8 deletions(-) delete mode 100644 Examples/Testing/run-tests.mjs diff --git a/Examples/Testing/Package.swift b/Examples/Testing/Package.swift index f83fdc863..2e997652f 100644 --- a/Examples/Testing/Package.swift +++ b/Examples/Testing/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version: 6.2 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription diff --git a/Examples/Testing/Tests/CounterTests/CounterTests.swift b/Examples/Testing/Tests/CounterTests/CounterTests.swift index 5754843e8..4421c1223 100644 --- a/Examples/Testing/Tests/CounterTests/CounterTests.swift +++ b/Examples/Testing/Tests/CounterTests/CounterTests.swift @@ -1,6 +1,8 @@ -import Testing @testable import Counter +#if canImport(Testing) +import Testing + @Test func increment() async throws { var counter = Counter() counter.increment() @@ -13,3 +15,22 @@ import Testing counter.increment() #expect(counter.count == 2) } + +#endif + +import XCTest + +class CounterTests: XCTestCase { + func testIncrement() async { + var counter = Counter() + counter.increment() + XCTAssertEqual(counter.count, 1) + } + + func testIncrementTwice() async { + var counter = Counter() + counter.increment() + counter.increment() + XCTAssertEqual(counter.count, 2) + } +} diff --git a/Examples/Testing/run-tests.mjs b/Examples/Testing/run-tests.mjs deleted file mode 100644 index 1f90013a5..000000000 --- a/Examples/Testing/run-tests.mjs +++ /dev/null @@ -1,3 +0,0 @@ -import * as t from "./.build/plugins/PackageToJS/outputs/PackageTests/test.js" - -console.log(t) diff --git a/Plugins/PackageToJS/PackagingPlanner.swift b/Plugins/PackageToJS/PackagingPlanner.swift index 748aadbab..570cb8418 100644 --- a/Plugins/PackageToJS/PackagingPlanner.swift +++ b/Plugins/PackageToJS/PackagingPlanner.swift @@ -37,7 +37,7 @@ struct PackagingPlanner { /// Construct the build plan and return the root task key func planBuild( make: inout MiniMake, - wasmProductArtifact: URL, + wasmProductArtifact: URL ) -> MiniMake.TaskKey { let (allTasks, _) = planBuildInternal(make: &make, wasmProductArtifact: wasmProductArtifact) return make.addTask( @@ -47,7 +47,7 @@ struct PackagingPlanner { private func planBuildInternal( make: inout MiniMake, - wasmProductArtifact: URL, + wasmProductArtifact: URL ) -> (allTasks: [MiniMake.TaskKey], outputDirTask: MiniMake.TaskKey) { // Prepare output directory let outputDirTask = make.addTask( @@ -109,7 +109,7 @@ struct PackagingPlanner { /// Construct the test build plan and return the root task key func planTestBuild( make: inout MiniMake, - wasmProductArtifact: URL, + wasmProductArtifact: URL ) -> (rootTask: MiniMake.TaskKey, binDir: URL) { var (allTasks, outputDirTask) = planBuildInternal(make: &make, wasmProductArtifact: wasmProductArtifact) From cd9716ffd01b82b93e535d077adcbd82b9d167b8 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Thu, 6 Mar 2025 07:40:44 +0000 Subject: [PATCH 18/21] Apply wasm-opt for release --- Plugins/PackageToJS/PackageToJS.swift | 8 +-- Plugins/PackageToJS/PackagingPlanner.swift | 79 +++++++++++++++++++--- 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/Plugins/PackageToJS/PackageToJS.swift b/Plugins/PackageToJS/PackageToJS.swift index cd6ea4037..0b4ccdf96 100644 --- a/Plugins/PackageToJS/PackageToJS.swift +++ b/Plugins/PackageToJS/PackageToJS.swift @@ -201,7 +201,7 @@ struct PackageToJS: CommandPlugin { let planner = PackagingPlanner( options: buildOptions.options, context: context, selfPackage: selfPackage, outputDir: outputDir) - let rootTask = planner.planBuild( + let rootTask = try planner.planBuild( make: &make, wasmProductArtifact: productArtifact) cleanIfBuildGraphChanged(root: rootTask, make: make, context: context) print("Packaging...") @@ -268,7 +268,7 @@ struct PackageToJS: CommandPlugin { let planner = PackagingPlanner( options: testOptions.options, context: context, selfPackage: selfPackage, outputDir: outputDir) - let (rootTask, binDir) = planner.planTestBuild( + let (rootTask, binDir) = try planner.planTestBuild( make: &make, wasmProductArtifact: productArtifact) cleanIfBuildGraphChanged(root: rootTask, make: make, context: context) print("Packaging tests...") @@ -414,7 +414,7 @@ private func printStderr(_ message: String) { fputs(message + "\n", stderr) } -private func which(_ executable: String) throws -> URL { +func which(_ executable: String) throws -> URL { let pathSeparator: Character #if os(Windows) pathSeparator = ";" @@ -431,7 +431,7 @@ private func which(_ executable: String) throws -> URL { throw PackageToJSError("Executable \(executable) not found in PATH") } -private struct PackageToJSError: Swift.Error, CustomStringConvertible { +struct PackageToJSError: Swift.Error, CustomStringConvertible { let description: String init(_ message: String) { diff --git a/Plugins/PackageToJS/PackagingPlanner.swift b/Plugins/PackageToJS/PackagingPlanner.swift index 570cb8418..6aaca8075 100644 --- a/Plugins/PackageToJS/PackagingPlanner.swift +++ b/Plugins/PackageToJS/PackagingPlanner.swift @@ -34,12 +34,24 @@ struct PackagingPlanner { ) } + private static func runCommand(_ command: URL, _ arguments: [String]) throws { + let task = Process() + task.executableURL = command + task.arguments = arguments + task.currentDirectoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + try task.run() + task.waitUntilExit() + guard task.terminationStatus == 0 else { + throw PackageToJSError("Command failed with status \(task.terminationStatus)") + } + } + /// Construct the build plan and return the root task key func planBuild( make: inout MiniMake, wasmProductArtifact: URL - ) -> MiniMake.TaskKey { - let (allTasks, _) = planBuildInternal(make: &make, wasmProductArtifact: wasmProductArtifact) + ) throws -> MiniMake.TaskKey { + let (allTasks, _) = try planBuildInternal(make: &make, wasmProductArtifact: wasmProductArtifact) return make.addTask( inputTasks: allTasks, output: "all", attributes: [.phony, .silent] ) { _ in } @@ -48,7 +60,7 @@ struct PackagingPlanner { private func planBuildInternal( make: inout MiniMake, wasmProductArtifact: URL - ) -> (allTasks: [MiniMake.TaskKey], outputDirTask: MiniMake.TaskKey) { + ) throws -> (allTasks: [MiniMake.TaskKey], outputDirTask: MiniMake.TaskKey) { // Prepare output directory let outputDirTask = make.addTask( inputFiles: [selfPath], output: outputDir.path, attributes: [.silent] @@ -58,12 +70,57 @@ struct PackagingPlanner { var packageInputs: [MiniMake.TaskKey] = [] - // Copy the wasm product artifact - let wasm = make.addTask( - inputFiles: [selfPath, wasmProductArtifact.path], inputTasks: [outputDirTask], - output: outputDir.appending(path: wasmFilename).path - ) { - try Self.syncFile(from: wasmProductArtifact.path, to: $0.output) + // Guess the build configuration from the parent directory name of .wasm file + let buildConfiguration = wasmProductArtifact.deletingLastPathComponent().lastPathComponent + let wasm: MiniMake.TaskKey + + let shouldOptimize: Bool + let wasmOptPath = try? which("wasm-opt") + if buildConfiguration == "debug" { + shouldOptimize = false + } else { + if wasmOptPath != nil { + shouldOptimize = true + } else { + print("Warning: wasm-opt not found in PATH, skipping optimizations") + shouldOptimize = false + } + } + + if let wasmOptPath = wasmOptPath, shouldOptimize { + // Optimize the wasm in release mode + let tmpDir = outputDir.deletingLastPathComponent().appending(path: "\(outputDir.lastPathComponent).tmp") + let tmpDirTask = make.addTask( + inputFiles: [selfPath], output: tmpDir.path, attributes: [.silent] + ) { + try Self.createDirectory(atPath: $0.output) + } + let stripWasmPath = tmpDir.appending(path: wasmFilename + ".strip").path + + // First, strip DWARF sections as their existence enables DWARF preserving mode in wasm-opt + let stripWasm = make.addTask( + inputFiles: [selfPath, wasmProductArtifact.path], inputTasks: [outputDirTask, tmpDirTask], + output: stripWasmPath + ) { + print("Stripping debug information...") + try Self.runCommand(wasmOptPath, [wasmProductArtifact.path, "--strip-dwarf", "--debuginfo", "-o", $0.output]) + } + // Then, run wasm-opt with all optimizations + wasm = make.addTask( + inputFiles: [selfPath], inputTasks: [outputDirTask, stripWasm], + output: outputDir.appending(path: wasmFilename).path + ) { + print("Optimizing the wasm file...") + try Self.runCommand(wasmOptPath, [stripWasmPath, "--debuginfo", "-Os", "-o", $0.output]) + } + } else { + // Copy the wasm product artifact + wasm = make.addTask( + inputFiles: [selfPath, wasmProductArtifact.path], inputTasks: [outputDirTask], + output: outputDir.appending(path: wasmFilename).path + ) { + try Self.syncFile(from: wasmProductArtifact.path, to: $0.output) + } } packageInputs.append(wasm) @@ -110,8 +167,8 @@ struct PackagingPlanner { func planTestBuild( make: inout MiniMake, wasmProductArtifact: URL - ) -> (rootTask: MiniMake.TaskKey, binDir: URL) { - var (allTasks, outputDirTask) = planBuildInternal(make: &make, wasmProductArtifact: wasmProductArtifact) + ) throws -> (rootTask: MiniMake.TaskKey, binDir: URL) { + var (allTasks, outputDirTask) = try planBuildInternal(make: &make, wasmProductArtifact: wasmProductArtifact) let binDir = outputDir.appending(path: "bin") let binDirTask = make.addTask( From 92403820c906b05c9ce0554526167d25871676e3 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Thu, 6 Mar 2025 08:29:14 +0000 Subject: [PATCH 19/21] Add --split-debug option to PackageToJS plugin --- Plugins/PackageToJS/PackageToJS.swift | 8 ++++++-- Plugins/PackageToJS/PackagingPlanner.swift | 23 +++++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Plugins/PackageToJS/PackageToJS.swift b/Plugins/PackageToJS/PackageToJS.swift index 0b4ccdf96..0866a90fa 100644 --- a/Plugins/PackageToJS/PackageToJS.swift +++ b/Plugins/PackageToJS/PackageToJS.swift @@ -24,12 +24,15 @@ struct PackageToJS: CommandPlugin { struct BuildOptions { /// Product to build (default: executable target if there's only one) var product: String? + /// Whether to split debug information into a separate file (default: false) + var splitDebug: Bool var options: Options static func parse(from extractor: inout ArgumentExtractor) -> BuildOptions { let product = extractor.extractOption(named: "product").last + let splitDebug = extractor.extractFlag(named: "split-debug") let options = Options.parse(from: &extractor) - return BuildOptions(product: product, options: options) + return BuildOptions(product: product, splitDebug: splitDebug != 0, options: options) } static func help() -> String { @@ -43,6 +46,7 @@ struct PackageToJS: CommandPlugin { --output Path to the output directory (default: .build/plugins/PackageToJS/outputs/Package) --package-name Name of the package (default: lowercased Package.swift name) --explain Whether to explain the build plan + --split-debug Whether to split debug information into a separate .wasm.debug file (default: false) SUBCOMMANDS: test Builds and runs tests @@ -202,7 +206,7 @@ struct PackageToJS: CommandPlugin { options: buildOptions.options, context: context, selfPackage: selfPackage, outputDir: outputDir) let rootTask = try planner.planBuild( - make: &make, wasmProductArtifact: productArtifact) + make: &make, splitDebug: buildOptions.splitDebug, wasmProductArtifact: productArtifact) cleanIfBuildGraphChanged(root: rootTask, make: make, context: context) print("Packaging...") try make.build(output: rootTask) diff --git a/Plugins/PackageToJS/PackagingPlanner.swift b/Plugins/PackageToJS/PackagingPlanner.swift index 6aaca8075..113fc1101 100644 --- a/Plugins/PackageToJS/PackagingPlanner.swift +++ b/Plugins/PackageToJS/PackagingPlanner.swift @@ -1,6 +1,7 @@ import Foundation import PackagePlugin +/// Plans the build for packaging. struct PackagingPlanner { let options: PackageToJS.Options let context: PluginContext @@ -20,6 +21,8 @@ struct PackagingPlanner { self.selfPath = String(#filePath) } + // MARK: - Primitive build operations + private static func syncFile(from: String, to: String) throws { if FileManager.default.fileExists(atPath: to) { try FileManager.default.removeItem(atPath: to) @@ -46,12 +49,17 @@ struct PackagingPlanner { } } + // MARK: - Build plans + /// Construct the build plan and return the root task key func planBuild( make: inout MiniMake, + splitDebug: Bool, wasmProductArtifact: URL ) throws -> MiniMake.TaskKey { - let (allTasks, _) = try planBuildInternal(make: &make, wasmProductArtifact: wasmProductArtifact) + let (allTasks, _) = try planBuildInternal( + make: &make, splitDebug: splitDebug, wasmProductArtifact: wasmProductArtifact + ) return make.addTask( inputTasks: allTasks, output: "all", attributes: [.phony, .silent] ) { _ in } @@ -59,6 +67,7 @@ struct PackagingPlanner { private func planBuildInternal( make: inout MiniMake, + splitDebug: Bool, wasmProductArtifact: URL ) throws -> (allTasks: [MiniMake.TaskKey], outputDirTask: MiniMake.TaskKey) { // Prepare output directory @@ -95,14 +104,16 @@ struct PackagingPlanner { ) { try Self.createDirectory(atPath: $0.output) } - let stripWasmPath = tmpDir.appending(path: wasmFilename + ".strip").path + // If splitDebug is true, we need to place the DWARF-stripped wasm file (but "name" section remains) + // in the output directory. + let stripWasmPath = (splitDebug ? outputDir : tmpDir).appending(path: wasmFilename + ".debug").path // First, strip DWARF sections as their existence enables DWARF preserving mode in wasm-opt let stripWasm = make.addTask( inputFiles: [selfPath, wasmProductArtifact.path], inputTasks: [outputDirTask, tmpDirTask], output: stripWasmPath ) { - print("Stripping debug information...") + print("Stripping DWARF debug info...") try Self.runCommand(wasmOptPath, [wasmProductArtifact.path, "--strip-dwarf", "--debuginfo", "-o", $0.output]) } // Then, run wasm-opt with all optimizations @@ -111,7 +122,7 @@ struct PackagingPlanner { output: outputDir.appending(path: wasmFilename).path ) { print("Optimizing the wasm file...") - try Self.runCommand(wasmOptPath, [stripWasmPath, "--debuginfo", "-Os", "-o", $0.output]) + try Self.runCommand(wasmOptPath, [stripWasmPath, "-Os", "-o", $0.output]) } } else { // Copy the wasm product artifact @@ -168,7 +179,9 @@ struct PackagingPlanner { make: inout MiniMake, wasmProductArtifact: URL ) throws -> (rootTask: MiniMake.TaskKey, binDir: URL) { - var (allTasks, outputDirTask) = try planBuildInternal(make: &make, wasmProductArtifact: wasmProductArtifact) + var (allTasks, outputDirTask) = try planBuildInternal( + make: &make, splitDebug: false, wasmProductArtifact: wasmProductArtifact + ) let binDir = outputDir.appending(path: "bin") let binDirTask = make.addTask( From d11a2a298e43af4e434c0091f5fd824b5defec7d Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Thu, 6 Mar 2025 17:49:40 +0900 Subject: [PATCH 20/21] Update examples --- Examples/Basic/index.html | 4 ++-- Examples/Embedded/index.html | 4 ++-- Plugins/PackageToJS/Templates/bin/test.js | 8 ++------ Plugins/PackageToJS/Templates/index.d.ts | 11 +--------- Plugins/PackageToJS/Templates/index.js | 20 +------------------ .../PackageToJS/Templates/instantiate.d.ts | 5 +++++ Plugins/PackageToJS/Templates/instantiate.js | 2 ++ Plugins/PackageToJS/Templates/test.d.ts | 5 ----- Plugins/PackageToJS/Templates/test.js | 7 ------- 9 files changed, 15 insertions(+), 51 deletions(-) diff --git a/Examples/Basic/index.html b/Examples/Basic/index.html index 7146f8e1b..f337dfb17 100644 --- a/Examples/Basic/index.html +++ b/Examples/Basic/index.html @@ -14,8 +14,8 @@ diff --git a/Examples/Embedded/index.html b/Examples/Embedded/index.html index 7146f8e1b..f337dfb17 100644 --- a/Examples/Embedded/index.html +++ b/Examples/Embedded/index.html @@ -14,8 +14,8 @@ diff --git a/Plugins/PackageToJS/Templates/bin/test.js b/Plugins/PackageToJS/Templates/bin/test.js index 6f1e9fa73..cd108eeef 100644 --- a/Plugins/PackageToJS/Templates/bin/test.js +++ b/Plugins/PackageToJS/Templates/bin/test.js @@ -1,8 +1,4 @@ -import { NodeRunner, BrowserRunner } from "../test.js" +import { NodeRunner } from "../test.js" -const runners = { - "node": NodeRunner, - "browser": BrowserRunner, -} -const runner = new runners[process.env.TEST_RUNNER || "node"]() +const runner = new NodeRunner() await runner.run() diff --git a/Plugins/PackageToJS/Templates/index.d.ts b/Plugins/PackageToJS/Templates/index.d.ts index 506779757..d0d3a2fcd 100644 --- a/Plugins/PackageToJS/Templates/index.d.ts +++ b/Plugins/PackageToJS/Templates/index.d.ts @@ -1,7 +1,4 @@ -/** - * The path to the WebAssembly module relative to the root of the package - */ -export declare const MODULE_PATH: string; +import type { Import, Export } from './instantiate.js' export type Options = { /** @@ -26,9 +23,3 @@ export declare function init( instance: WebAssembly.Instance, exports: Export }> - -export declare function runTest( - moduleSource: WebAssembly.Module | ArrayBufferView | ArrayBuffer | Response | PromiseLike, - imports: Import, - options: Options | undefined -): Promise<{ exitCode: number }> diff --git a/Plugins/PackageToJS/Templates/index.js b/Plugins/PackageToJS/Templates/index.js index 702ed5bab..16c5dcf7d 100644 --- a/Plugins/PackageToJS/Templates/index.js +++ b/Plugins/PackageToJS/Templates/index.js @@ -1,7 +1,6 @@ // @ts-check import { WASI, WASIProcExit, File, OpenFile, ConsoleStdout, PreopenDirectory } from '@bjorn3/browser_wasi_shim'; -import { instantiate } from './instantiate.js'; -export const MODULE_PATH = "@PACKAGE_TO_JS_MODULE_PATH@"; +import { instantiate, MODULE_PATH } from './instantiate.js'; /** @type {import('./index.d').init} */ export async function init( @@ -29,20 +28,3 @@ export async function init( exports, } } - -/** @type {import('./index.d').runTest} */ -export async function runTest( - moduleSource, - imports, - options -) { - try { - const { instance, exports } = await init(moduleSource, imports, options); - return { exitCode: 0 }; - } catch (error) { - if (error instanceof WASIProcExit) { - return { exitCode: error.code }; - } - throw error; - } -} diff --git a/Plugins/PackageToJS/Templates/instantiate.d.ts b/Plugins/PackageToJS/Templates/instantiate.d.ts index 16d8dbd81..608c7e532 100644 --- a/Plugins/PackageToJS/Templates/instantiate.d.ts +++ b/Plugins/PackageToJS/Templates/instantiate.d.ts @@ -5,6 +5,11 @@ export type Export = { // TODO: Generate type from .swift files } +/** + * The path to the WebAssembly module relative to the root of the package + */ +export declare const MODULE_PATH: string; + /** * Low-level interface to create an instance of a WebAssembly module * diff --git a/Plugins/PackageToJS/Templates/instantiate.js b/Plugins/PackageToJS/Templates/instantiate.js index 98acaf013..475eea00a 100644 --- a/Plugins/PackageToJS/Templates/instantiate.js +++ b/Plugins/PackageToJS/Templates/instantiate.js @@ -2,6 +2,8 @@ // @ts-ignore import { SwiftRuntime } from "./runtime.js" +export const MODULE_PATH = "@PACKAGE_TO_JS_MODULE_PATH@"; + /** @type {import('./instantiate.d').createInstantiator} */ export async function createInstantiator( imports, diff --git a/Plugins/PackageToJS/Templates/test.d.ts b/Plugins/PackageToJS/Templates/test.d.ts index 2e43a3d4f..c9698fb18 100644 --- a/Plugins/PackageToJS/Templates/test.d.ts +++ b/Plugins/PackageToJS/Templates/test.d.ts @@ -2,8 +2,3 @@ export declare class NodeRunner { constructor() run(): Promise } - -export declare class BrowserRunner { - constructor() - run(): Promise -} diff --git a/Plugins/PackageToJS/Templates/test.js b/Plugins/PackageToJS/Templates/test.js index b5485d7a2..699ceca1d 100644 --- a/Plugins/PackageToJS/Templates/test.js +++ b/Plugins/PackageToJS/Templates/test.js @@ -45,10 +45,3 @@ Please ensure you are using Node.js v18.x or newer. swift.main() } } - -export class BrowserRunner { - constructor() { } - - async run() { - } -} From a7a77727f002f4e56330dfe756396209024d0db8 Mon Sep 17 00:00:00 2001 From: Yuta Saito Date: Thu, 6 Mar 2025 17:57:45 +0900 Subject: [PATCH 21/21] Remove no longer necessary manual symlink to _Runtime --- Examples/Embedded/_Runtime | 1 - 1 file changed, 1 deletion(-) delete mode 120000 Examples/Embedded/_Runtime diff --git a/Examples/Embedded/_Runtime b/Examples/Embedded/_Runtime deleted file mode 120000 index af934baa2..000000000 --- a/Examples/Embedded/_Runtime +++ /dev/null @@ -1 +0,0 @@ -../../Sources/JavaScriptKit/Runtime \ No newline at end of file