diff --git a/.gitignore b/.gitignore index 5102946ea..1d3cb87be 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ node_modules xcuserdata/ .swiftpm .vscode +Examples/*/Bundle +Examples/*/package-lock.json 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..826e90f81 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 --use-cdn diff --git a/Examples/Basic/index.html b/Examples/Basic/index.html index d94796a09..a674baca1 100644 --- a/Examples/Basic/index.html +++ b/Examples/Basic/index.html @@ -6,7 +6,10 @@ - + 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/_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 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..a674baca1 100644 --- a/Examples/Embedded/index.html +++ b/Examples/Embedded/index.html @@ -6,7 +6,10 @@ - + 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(); diff --git a/Examples/Multithreading/Sources/JavaScript/index.js b/Examples/Multithreading/Sources/JavaScript/index.js deleted file mode 100644 index 3cfc01a43..000000000 --- a/Examples/Multithreading/Sources/JavaScript/index.js +++ /dev/null @@ -1,74 +0,0 @@ -import { instantiate } from "./instantiate.js" -import * as WasmImportsParser from 'https://esm.run/wasm-imports-parser/polyfill.js'; - -// TODO: Remove this polyfill once the browser supports the WebAssembly Type Reflection JS API -// https://chromestatus.com/feature/5725002447978496 -globalThis.WebAssembly = WasmImportsParser.polyfill(globalThis.WebAssembly); - -class ThreadRegistry { - workers = new Map(); - nextTid = 1; - - constructor({ configuration }) { - this.configuration = configuration; - } - - spawnThread(worker, module, memory, startArg) { - const tid = this.nextTid++; - this.workers.set(tid, worker); - worker.postMessage({ module, memory, tid, startArg, configuration: this.configuration }); - return tid; - } - - listenMessageFromWorkerThread(tid, listener) { - const worker = this.workers.get(tid); - worker.onmessage = (event) => { - listener(event.data); - }; - } - - postMessageToWorkerThread(tid, data, transfer) { - const worker = this.workers.get(tid); - worker.postMessage(data, transfer); - } - - terminateWorkerThread(tid) { - const worker = this.workers.get(tid); - worker.terminate(); - this.workers.delete(tid); - } -} - -async function start(configuration = "release") { - const response = await fetch(`./.build/${configuration}/MyApp.wasm`); - const module = await WebAssembly.compileStreaming(response); - const memoryImport = WebAssembly.Module.imports(module).find(i => i.module === "env" && i.name === "memory"); - if (!memoryImport) { - throw new Error("Memory import not found"); - } - if (!memoryImport.type) { - throw new Error("Memory import type not found"); - } - const memoryType = memoryImport.type; - const memory = new WebAssembly.Memory({ initial: memoryType.minimum, maximum: memoryType.maximum, shared: true }); - const threads = new ThreadRegistry({ configuration }); - const { instance, swiftRuntime, wasi } = await instantiate({ - module, - threadChannel: threads, - addToImports(importObject) { - importObject["env"] = { memory } - importObject["wasi"] = { - "thread-spawn": (startArg) => { - const worker = new Worker("Sources/JavaScript/worker.js", { type: "module" }); - return threads.spawnThread(worker, module, memory, startArg); - } - }; - }, - configuration - }); - wasi.initialize(instance); - - swiftRuntime.main(); -} - -start(); diff --git a/Examples/Multithreading/Sources/JavaScript/instantiate.js b/Examples/Multithreading/Sources/JavaScript/instantiate.js deleted file mode 100644 index e7b60504c..000000000 --- a/Examples/Multithreading/Sources/JavaScript/instantiate.js +++ /dev/null @@ -1,29 +0,0 @@ -import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from 'https://esm.run/@bjorn3/browser_wasi_shim@0.3.0'; - -export async function instantiate({ module, addToImports, threadChannel, configuration }) { - const args = ["main.wasm"] - const env = [] - const fds = [ - new OpenFile(new File([])), // stdin - ConsoleStdout.lineBuffered((stdout) => { - console.log(stdout); - }), - ConsoleStdout.lineBuffered((stderr) => { - console.error(stderr); - }), - new PreopenDirectory("/", new Map()), - ]; - const wasi = new WASI(args, env, fds); - - const { SwiftRuntime } = await import(`/.build/${configuration}/JavaScriptKit_JavaScriptKit.resources/Runtime/index.mjs`); - const swiftRuntime = new SwiftRuntime({ sharedMemory: true, threadChannel }); - const importObject = { - wasi_snapshot_preview1: wasi.wasiImport, - javascript_kit: swiftRuntime.wasmImports, - }; - addToImports(importObject); - const instance = await WebAssembly.instantiate(module, importObject); - - swiftRuntime.setInstance(instance); - return { swiftRuntime, wasi, instance }; -} diff --git a/Examples/Multithreading/Sources/JavaScript/worker.js b/Examples/Multithreading/Sources/JavaScript/worker.js deleted file mode 100644 index 703df4407..000000000 --- a/Examples/Multithreading/Sources/JavaScript/worker.js +++ /dev/null @@ -1,28 +0,0 @@ -import { instantiate } from "./instantiate.js" - -self.onmessage = async (event) => { - const { module, memory, tid, startArg, configuration } = event.data; - const { instance, wasi, swiftRuntime } = await instantiate({ - module, - threadChannel: { - postMessageToMainThread: (message, transfer) => { - // Send the job to the main thread - postMessage(message, transfer); - }, - listenMessageFromMainThread: (listener) => { - self.onmessage = (event) => listener(event.data); - } - }, - addToImports(importObject) { - importObject["env"] = { memory } - importObject["wasi"] = { - "thread-spawn": () => { throw new Error("Cannot spawn a new thread from a worker thread"); } - }; - }, - configuration - }); - - swiftRuntime.setInstance(instance); - wasi.inst = instance; - swiftRuntime.startThread(tid, startArg); -} diff --git a/Examples/Multithreading/build.sh b/Examples/Multithreading/build.sh index 0f8670db1..c82a10c32 100755 --- a/Examples/Multithreading/build.sh +++ b/Examples/Multithreading/build.sh @@ -1 +1,3 @@ -swift build --swift-sdk "${SWIFT_SDK_ID:-wasm32-unknown-wasip1-threads}" -Xswiftc -Xclang-linker -Xswiftc -mexec-model=reactor -Xlinker --export=__main_argc_argv -c release -Xswiftc -g +swift package --swift-sdk "${SWIFT_SDK_ID:-wasm32-unknown-wasip1-threads}" -c release \ + plugin --allow-writing-to-package-directory \ + js --use-cdn --output ./Bundle diff --git a/Examples/Multithreading/index.html b/Examples/Multithreading/index.html index 6ed31039d..74ba8cfed 100644 --- a/Examples/Multithreading/index.html +++ b/Examples/Multithreading/index.html @@ -27,25 +27,28 @@ - +

Threading Example

-

- - -
-
- - -
-
- - - -
+
+ + +
+
+ + +
+
+ + + +

-

🧵
+
🧵

diff --git a/Examples/OffscrenCanvas/build.sh b/Examples/OffscrenCanvas/build.sh index 0f8670db1..c82a10c32 100755 --- a/Examples/OffscrenCanvas/build.sh +++ b/Examples/OffscrenCanvas/build.sh @@ -1 +1,3 @@ -swift build --swift-sdk "${SWIFT_SDK_ID:-wasm32-unknown-wasip1-threads}" -Xswiftc -Xclang-linker -Xswiftc -mexec-model=reactor -Xlinker --export=__main_argc_argv -c release -Xswiftc -g +swift package --swift-sdk "${SWIFT_SDK_ID:-wasm32-unknown-wasip1-threads}" -c release \ + plugin --allow-writing-to-package-directory \ + js --use-cdn --output ./Bundle diff --git a/Examples/OffscrenCanvas/index.html b/Examples/OffscrenCanvas/index.html index 5887c66cc..1202807a0 100644 --- a/Examples/OffscrenCanvas/index.html +++ b/Examples/OffscrenCanvas/index.html @@ -68,7 +68,10 @@ - +

OffscreenCanvas Example

diff --git a/Examples/OffscrenCanvas/serve.json b/Examples/OffscrenCanvas/serve.json deleted file mode 120000 index 326719cd4..000000000 --- a/Examples/OffscrenCanvas/serve.json +++ /dev/null @@ -1 +0,0 @@ -../Multithreading/serve.json \ No newline at end of file diff --git a/Examples/OffscrenCanvas/serve.json b/Examples/OffscrenCanvas/serve.json new file mode 100644 index 000000000..537a16904 --- /dev/null +++ b/Examples/OffscrenCanvas/serve.json @@ -0,0 +1,14 @@ +{ + "headers": [{ + "source": "**/*", + "headers": [ + { + "key": "Cross-Origin-Embedder-Policy", + "value": "require-corp" + }, { + "key": "Cross-Origin-Opener-Policy", + "value": "same-origin" + } + ] + }] +} 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..2e997652f --- /dev/null +++ b/Examples/Testing/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 6.0 +// 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..4421c1223 --- /dev/null +++ b/Examples/Testing/Tests/CounterTests/CounterTests.swift @@ -0,0 +1,36 @@ +@testable import Counter + +#if canImport(Testing) +import Testing + +@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) +} + +#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/Makefile b/Makefile index 88f4e0795..ed0727ce8 100644 --- a/Makefile +++ b/Makefile @@ -21,18 +21,10 @@ test: CONFIGURATION=release SWIFT_BUILD_FLAGS="$(SWIFT_BUILD_FLAGS)" $(MAKE) test && \ CONFIGURATION=release SWIFT_BUILD_FLAGS="$(SWIFT_BUILD_FLAGS) -Xswiftc -DJAVASCRIPTKIT_WITHOUT_WEAKREFS" $(MAKE) test -TEST_RUNNER := node --experimental-wasi-unstable-preview1 scripts/test-harness.mjs .PHONY: unittest unittest: @echo Running unit tests - swift build --build-tests -Xswiftc -Xclang-linker -Xswiftc -mexec-model=reactor -Xlinker --export-if-defined=main -Xlinker --export-if-defined=__main_argc_argv --static-swift-stdlib -Xswiftc -static-stdlib $(SWIFT_BUILD_FLAGS) -# Swift 6.1 and later uses .xctest for XCTest bundle but earliers used .wasm -# See https://github.com/swiftlang/swift-package-manager/pull/8254 - if [ -f .build/debug/JavaScriptKitPackageTests.xctest ]; then \ - $(TEST_RUNNER) .build/debug/JavaScriptKitPackageTests.xctest; \ - else \ - $(TEST_RUNNER) .build/debug/JavaScriptKitPackageTests.wasm; \ - fi + swift package --swift-sdk "$(SWIFT_SDK_ID)" js test --prelude ./Tests/prelude.mjs .PHONY: benchmark_setup benchmark_setup: diff --git a/Package.swift b/Package.swift index 4d4634b88..7c49f0e33 100644 --- a/Package.swift +++ b/Package.swift @@ -4,6 +4,7 @@ import PackageDescription // NOTE: needed for embedded customizations, ideally this will not be necessary at all in the future, or can be replaced with traits let shouldBuildForEmbedded = Context.environment["JAVASCRIPTKIT_EXPERIMENTAL_EMBEDDED_WASM"].flatMap(Bool.init) ?? false +let useLegacyResourceBundling = shouldBuildForEmbedded || (Context.environment["JAVASCRIPTKIT_USE_LEGACY_RESOURCE_BUNDLING"].flatMap(Bool.init) ?? false) let package = Package( name: "JavaScriptKit", @@ -12,12 +13,14 @@ 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( name: "JavaScriptKit", dependencies: ["_CJavaScriptKit"], - resources: shouldBuildForEmbedded ? [] : [.copy("Runtime")], + exclude: useLegacyResourceBundling ? ["Runtime"] : [], + resources: useLegacyResourceBundling ? [] : [.copy("Runtime")], cSettings: shouldBuildForEmbedded ? [ .unsafeFlags(["-fdeclspec"]) ] : nil, @@ -71,5 +74,12 @@ let package = Package( "JavaScriptEventLoopTestSupport" ] ), + .plugin( + name: "PackageToJS", + capability: .command( + intent: .custom(verb: "js", description: "Convert a Swift package to a JavaScript package") + ), + sources: ["Sources"] + ), ] ) diff --git a/Plugins/PackageToJS/Package.swift b/Plugins/PackageToJS/Package.swift new file mode 100644 index 000000000..1cc9318bd --- /dev/null +++ b/Plugins/PackageToJS/Package.swift @@ -0,0 +1,12 @@ +// swift-tools-version: 6.0 + +import PackageDescription + +let package = Package( + name: "PackageToJS", + platforms: [.macOS(.v13)], + targets: [ + .target(name: "PackageToJS"), + .testTarget(name: "PackageToJSTests", dependencies: ["PackageToJS"]), + ] +) diff --git a/Plugins/PackageToJS/Sources/MiniMake.swift b/Plugins/PackageToJS/Sources/MiniMake.swift new file mode 100644 index 000000000..04e781690 --- /dev/null +++ b/Plugins/PackageToJS/Sources/MiniMake.swift @@ -0,0 +1,251 @@ +import Foundation + +/// A minimal build system +/// +/// This build system is a traditional mtime-based incremental build system. +struct MiniMake { + /// Attributes of a task + 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 + } + + /// Information about a task enough to capture build + /// graph changes + struct TaskInfo: Codable { + /// Input tasks not yet built + 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] + /// Salt for the task, used to differentiate between otherwise identical tasks + var salt: Data? + } + + /// 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: Codable, 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 } + } + + /// 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 + /// Prints progress of the build + private var printProgress: ProgressPrinter.PrintProgress + + init( + explain: Bool = false, + printProgress: @escaping ProgressPrinter.PrintProgress + ) { + self.tasks = [:] + self.shouldExplain = explain + self.buildCwd = FileManager.default.currentDirectoryPath + self.printProgress = printProgress + } + + /// Adds a task to the build system + mutating func addTask( + inputFiles: [String] = [], inputTasks: [TaskKey] = [], output: String, + attributes: [TaskAttribute] = [], salt: (any Encodable)? = nil, + 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 saltData = try! salt.map { + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + return try encoder.encode($0) + } + let info = TaskInfo( + wants: inputTasks, inputs: inputFiles, output: output, attributes: attributes, + salt: saltData + ) + 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()) + } + } + + private func violated(_ message: @autoclosure () -> String) { + print(message()) + } + + /// Prints progress of the build + struct ProgressPrinter { + typealias PrintProgress = (_ subject: Task, _ total: Int, _ built: Int, _ message: String) -> Void + + /// Total number of tasks to build + let total: Int + /// Number of tasks built so far + var built: Int + /// Prints progress of the build + var printProgress: PrintProgress + + init(total: Int, printProgress: @escaping PrintProgress) { + self.total = total + self.built = 0 + self.printProgress = printProgress + } + + 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, "\(Self.green)building\(Self.reset)") + } + + mutating func skipped(_ task: Task) { + self.print(task, "\(Self.yellow)skipped\(Self.reset)") + } + + private mutating func print(_ task: Task, _ message: @autoclosure () -> String) { + guard !task.attributes.contains(.silent) else { return } + self.printProgress(task, self.total, self.built, message()) + self.built += 1 + } + } + + /// 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 } + visited.insert(task.key) + var total = task.attributes.contains(.silent) ? 0 : 1 + for want in task.wants { + total += visit(task: self.tasks[want]!) + } + return total + } + 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 + 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.computeTotalTasksForDisplay(task: self.tasks[output]!), + printProgress: self.printProgress + ) + // Make a copy of the tasks so we can mutate the state + var tasks = self.tasks + + func runTask(taskKey: TaskKey) throws { + guard var task = tasks[taskKey] else { + violated("Task \(taskKey) not found") + return + } + guard !task.isDone else { return } + + // Build dependencies first + for want in task.wants.sorted() { + try runTask(taskKey: want) + } + + if shouldBuild(task: task) { + progressPrinter.started(task) + try task.build(task) + } else { + progressPrinter.skipped(task) + } + task.isDone = true + tasks[taskKey] = task + } + try runTask(taskKey: output) + } +} diff --git a/Plugins/PackageToJS/Sources/PackageToJS.swift b/Plugins/PackageToJS/Sources/PackageToJS.swift new file mode 100644 index 000000000..a575980d2 --- /dev/null +++ b/Plugins/PackageToJS/Sources/PackageToJS.swift @@ -0,0 +1,417 @@ +import Foundation + +struct PackageToJS { + struct PackageOptions { + /// 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 + var explain: Bool = false + /// Whether to use CDN for dependency packages + var useCDN: Bool + } + + 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 + /// Whether to apply wasm-opt optimizations in release mode (default: true) + var noOptimize: Bool + /// The options for packaging + var packageOptions: PackageOptions + } + + struct TestOptions { + /// Whether to only build tests, don't run them + var buildOnly: Bool + /// Lists all tests + var listTests: Bool + /// The filter to apply to the tests + var filter: [String] + /// The prelude script to use for the tests + var prelude: String? + /// The environment to use for the tests + var environment: String? + /// Whether to run tests in the browser with inspector enabled + var inspect: Bool + /// The options for packaging + var packageOptions: PackageOptions + } +} + +struct PackageToJSError: Swift.Error, CustomStringConvertible { + let description: String + + init(_ message: String) { + self.description = "Error: " + message + } +} + +/// Plans the build for packaging. +struct PackagingPlanner { + /// The options for packaging + let options: PackageToJS.PackageOptions + /// The package ID of the package that this plugin is running on + let packageId: String + /// The directory of the package that contains this plugin + let selfPackageDir: URL + /// The path of this file itself, used to capture changes of planner code + let selfPath: String + /// The directory for the final output + let outputDir: URL + /// The directory for intermediate files + let intermediatesDir: URL + /// The filename of the .wasm file + let wasmFilename = "main.wasm" + /// The path to the .wasm product artifact + let wasmProductArtifact: URL + + init( + options: PackageToJS.PackageOptions, + packageId: String, + pluginWorkDirectoryURL: URL, + selfPackageDir: URL, + outputDir: URL, + wasmProductArtifact: URL + ) { + self.options = options + self.packageId = packageId + self.selfPackageDir = selfPackageDir + self.outputDir = outputDir + self.intermediatesDir = pluginWorkDirectoryURL.appending(path: outputDir.lastPathComponent + ".tmp") + self.selfPath = String(#filePath) + self.wasmProductArtifact = wasmProductArtifact + } + + // 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) + } + try FileManager.default.copyItem(atPath: from, toPath: to) + try FileManager.default.setAttributes( + [.modificationDate: Date()], ofItemAtPath: 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 + ) + } + + 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)") + } + } + + // MARK: - Build plans + + /// Construct the build plan and return the root task key + func planBuild( + make: inout MiniMake, + buildOptions: PackageToJS.BuildOptions + ) throws -> MiniMake.TaskKey { + let (allTasks, _, _) = try planBuildInternal( + make: &make, splitDebug: buildOptions.splitDebug, noOptimize: buildOptions.noOptimize + ) + return make.addTask( + inputTasks: allTasks, output: "all", attributes: [.phony, .silent] + ) { _ in } + } + + func deriveBuildConfiguration() -> (configuration: String, triple: String) { + // e.g. path/to/.build/wasm32-unknown-wasi/debug/Basic.wasm -> ("debug", "wasm32-unknown-wasi") + + // First, resolve symlink to get the actual path as SwiftPM 6.0 and earlier returns unresolved + // symlink path for product artifact. + let wasmProductArtifact = self.wasmProductArtifact.resolvingSymlinksInPath() + let buildConfiguration = wasmProductArtifact.deletingLastPathComponent().lastPathComponent + let triple = wasmProductArtifact.deletingLastPathComponent().deletingLastPathComponent().lastPathComponent + return (buildConfiguration, triple) + } + + private func planBuildInternal( + make: inout MiniMake, + splitDebug: Bool, noOptimize: Bool + ) throws -> ( + allTasks: [MiniMake.TaskKey], + outputDirTask: MiniMake.TaskKey, + packageJsonTask: 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] = [] + + // Guess the build configuration from the parent directory name of .wasm file + let (buildConfiguration, _) = deriveBuildConfiguration() + let wasm: MiniMake.TaskKey + + let shouldOptimize: Bool + let wasmOptPath = try? which("wasm-opt") + if buildConfiguration == "debug" { + shouldOptimize = false + } else { + if wasmOptPath != nil { + shouldOptimize = !noOptimize + } else { + print("Warning: wasm-opt not found in PATH, skipping optimizations") + shouldOptimize = false + } + } + + let intermediatesDirTask = make.addTask( + inputFiles: [selfPath], output: intermediatesDir.path, attributes: [.silent] + ) { + try Self.createDirectory(atPath: $0.output) + } + + let finalWasmPath = outputDir.appending(path: wasmFilename).path + + if let wasmOptPath = wasmOptPath, shouldOptimize { + // Optimize the wasm in release mode + // 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 : intermediatesDir).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, intermediatesDirTask], + output: stripWasmPath + ) { + print("Stripping DWARF debug info...") + 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: finalWasmPath + ) { + print("Optimizing the wasm file...") + try Self.runCommand(wasmOptPath, [stripWasmPath, "-Os", "-o", $0.output]) + } + } else { + // Copy the wasm product artifact + wasm = make.addTask( + inputFiles: [selfPath, wasmProductArtifact.path], inputTasks: [outputDirTask], + output: finalWasmPath + ) { + try Self.syncFile(from: wasmProductArtifact.path, to: $0.output) + } + } + packageInputs.append(wasm) + + let wasmImportsPath = intermediatesDir.appending(path: "wasm-imports.json") + let wasmImportsTask = make.addTask( + inputFiles: [selfPath, finalWasmPath], inputTasks: [outputDirTask, intermediatesDirTask, wasm], + output: wasmImportsPath.path + ) { + let metadata = try parseImports(moduleBytes: Array(try Data(contentsOf: URL(fileURLWithPath: finalWasmPath)))) + let jsonEncoder = JSONEncoder() + jsonEncoder.outputFormatting = .prettyPrinted + let jsonData = try jsonEncoder.encode(metadata) + try jsonData.write(to: URL(fileURLWithPath: $0.output)) + } + + packageInputs.append(wasmImportsTask) + + let platformsDir = outputDir.appending(path: "platforms") + let platformsDirTask = make.addTask( + inputFiles: [selfPath], output: platformsDir.path, attributes: [.silent] + ) { + try Self.createDirectory(atPath: $0.output) + } + + let packageJsonTask = planCopyTemplateFile( + make: &make, file: "Plugins/PackageToJS/Templates/package.json", output: "package.json", outputDirTask: outputDirTask, + inputFiles: [], inputTasks: [] + ) + + // 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"), + ("Plugins/PackageToJS/Templates/platforms/browser.js", "platforms/browser.js"), + ("Plugins/PackageToJS/Templates/platforms/browser.d.ts", "platforms/browser.d.ts"), + ("Plugins/PackageToJS/Templates/platforms/browser.worker.js", "platforms/browser.worker.js"), + ("Plugins/PackageToJS/Templates/platforms/node.js", "platforms/node.js"), + ("Plugins/PackageToJS/Templates/platforms/node.d.ts", "platforms/node.d.ts"), + ("Sources/JavaScriptKit/Runtime/index.mjs", "runtime.js"), + ] { + packageInputs.append(planCopyTemplateFile( + make: &make, file: file, output: output, outputDirTask: outputDirTask, + inputFiles: [wasmImportsPath.path], inputTasks: [platformsDirTask, wasmImportsTask], + wasmImportsPath: wasmImportsPath.path + )) + } + return (packageInputs, outputDirTask, packageJsonTask) + } + + /// Construct the test build plan and return the root task key + func planTestBuild( + make: inout MiniMake + ) throws -> (rootTask: MiniMake.TaskKey, binDir: URL) { + var (allTasks, outputDirTask, packageJsonTask) = try planBuildInternal( + make: &make, splitDebug: false, noOptimize: false + ) + + // Install npm dependencies used in the test harness + let npm = try which("npm") + allTasks.append(make.addTask( + inputFiles: [ + selfPath, + outputDir.appending(path: "package.json").path, + ], inputTasks: [outputDirTask, packageJsonTask], + output: intermediatesDir.appending(path: "npm-install.stamp").path + ) { + try Self.runCommand(npm, ["-C", outputDir.path, "install"]) + _ = FileManager.default.createFile(atPath: $0.output, contents: Data(), attributes: nil) + }) + + 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/test.browser.html", "test.browser.html"), + ("Plugins/PackageToJS/Templates/bin/test.js", "bin/test.js"), + ] { + allTasks.append(planCopyTemplateFile( + make: &make, file: file, output: output, outputDirTask: outputDirTask, + inputFiles: [], inputTasks: [binDirTask] + )) + } + let rootTask = make.addTask( + inputTasks: allTasks, output: "all", attributes: [.phony, .silent] + ) { _ in } + return (rootTask, binDir) + } + + private func planCopyTemplateFile( + make: inout MiniMake, + file: String, + output: String, + outputDirTask: MiniMake.TaskKey, + inputFiles: [String], + inputTasks: [MiniMake.TaskKey], + wasmImportsPath: String? = nil + ) -> MiniMake.TaskKey { + + struct Salt: Encodable { + let conditions: [String: Bool] + let substitutions: [String: String] + } + + let inputPath = selfPackageDir.appending(path: file) + let (_, triple) = deriveBuildConfiguration() + let conditions = [ + "USE_SHARED_MEMORY": triple == "wasm32-unknown-wasip1-threads", + "IS_WASI": triple.hasPrefix("wasm32-unknown-wasi"), + "USE_WASI_CDN": options.useCDN, + ] + let constantSubstitutions = [ + "PACKAGE_TO_JS_MODULE_PATH": wasmFilename, + "PACKAGE_TO_JS_PACKAGE_NAME": options.packageName ?? packageId.lowercased(), + ] + let salt = Salt(conditions: conditions, substitutions: constantSubstitutions) + + return make.addTask( + inputFiles: [selfPath, inputPath.path] + inputFiles, inputTasks: [outputDirTask] + inputTasks, + output: outputDir.appending(path: output).path, salt: salt + ) { + var substitutions = constantSubstitutions + + if let wasmImportsPath = wasmImportsPath { + let importEntries = try JSONDecoder().decode([ImportEntry].self, from: Data(contentsOf: URL(fileURLWithPath: wasmImportsPath))) + let memoryImport = importEntries.first { $0.module == "env" && $0.name == "memory" } + if case .memory(let type) = memoryImport?.kind { + substitutions["PACKAGE_TO_JS_MEMORY_INITIAL"] = "\(type.minimum)" + substitutions["PACKAGE_TO_JS_MEMORY_MAXIMUM"] = "\(type.maximum ?? type.minimum)" + substitutions["PACKAGE_TO_JS_MEMORY_SHARED"] = "\(type.shared)" + } + } + + var content = try String(contentsOf: inputPath, encoding: .utf8) + let options = PreprocessOptions(conditions: conditions, substitutions: substitutions) + content = try preprocess(source: content, file: file, options: options) + try content.write(toFile: $0.output, atomically: true, encoding: .utf8) + } + } +} + +// MARK: - Utilities + +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") +} + +func logCommandExecution(_ command: String, _ arguments: [String]) { + var fullArguments = [command] + fullArguments.append(contentsOf: arguments) + print("$ \(fullArguments.map { "\"\($0)\"" }.joined(separator: " "))") +} + +extension Foundation.Process { + // Monitor termination/interrruption signals to forward them to child process + func setSignalForwarding(_ signalNo: Int32) -> DispatchSourceSignal { + let signalSource = DispatchSource.makeSignalSource(signal: signalNo) + signalSource.setEventHandler { [self] in + signalSource.cancel() + kill(processIdentifier, signalNo) + } + signalSource.resume() + return signalSource + } + + func forwardTerminationSignals(_ body: () throws -> Void) rethrows { + let sources = [ + setSignalForwarding(SIGINT), + setSignalForwarding(SIGTERM), + ] + defer { + for source in sources { + source.cancel() + } + } + try body() + } +} diff --git a/Plugins/PackageToJS/Sources/PackageToJSPlugin.swift b/Plugins/PackageToJS/Sources/PackageToJSPlugin.swift new file mode 100644 index 000000000..7e12eb94f --- /dev/null +++ b/Plugins/PackageToJS/Sources/PackageToJSPlugin.swift @@ -0,0 +1,471 @@ +#if canImport(PackagePlugin) +// Import minimal Foundation APIs to speed up overload resolution +@preconcurrency import struct Foundation.URL +@preconcurrency import struct Foundation.Data +@preconcurrency import class Foundation.Process +@preconcurrency import class Foundation.ProcessInfo +@preconcurrency import class Foundation.FileManager +@preconcurrency import func Foundation.fputs +@preconcurrency import func Foundation.exit +@preconcurrency import var Foundation.stderr +import PackagePlugin + +/// The main entry point for the PackageToJS plugin. +@main +struct PackageToJSPlugin: CommandPlugin { + static let friendlyBuildDiagnostics: + [@Sendable (_ 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 + """ + }), + ] + 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.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 { + if arguments.contains(where: { ["-h", "--help"].contains($0) }) { + printStderr(PackageToJS.BuildOptions.help()) + return + } + + var extractor = ArgumentExtractor(arguments) + let buildOptions = PackageToJS.BuildOptions.parse(from: &extractor) + + if extractor.remainingArguments.count > 0 { + printStderr( + "Unexpected arguments: \(extractor.remainingArguments.joined(separator: " "))") + printStderr(PackageToJS.BuildOptions.help()) + exit(1) + } + + // Build products + let productName = try buildOptions.product ?? deriveDefaultProduct(package: context.package) + let build = try buildWasm( + productName: productName, context: context) + guard build.succeeded else { + reportBuildFailure(build, arguments) + exit(1) + } + let productArtifact = try build.findWasmArtifact(for: productName) + let outputDir = + if let outputPath = buildOptions.packageOptions.outputPath { + URL(fileURLWithPath: outputPath) + } else { + context.pluginWorkDirectoryURL.appending(path: "Package") + } + 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: buildOptions.packageOptions.explain, + printProgress: self.printProgress + ) + let planner = PackagingPlanner( + options: buildOptions.packageOptions, context: context, selfPackage: selfPackage, + outputDir: outputDir, wasmProductArtifact: productArtifact) + let rootTask = try planner.planBuild( + make: &make, buildOptions: buildOptions) + cleanIfBuildGraphChanged(root: rootTask, make: make, context: context) + print("Packaging...") + try make.build(output: rootTask) + print("Packaging finished") + } + + func performTestCommand(context: PluginContext, arguments: [String]) throws { + if arguments.contains(where: { ["-h", "--help"].contains($0) }) { + printStderr(PackageToJS.TestOptions.help()) + return + } + + var extractor = ArgumentExtractor(arguments) + let testOptions = PackageToJS.TestOptions.parse(from: &extractor) + + if extractor.remainingArguments.count > 0 { + printStderr( + "Unexpected arguments: \(extractor.remainingArguments.joined(separator: " "))") + printStderr(PackageToJS.TestOptions.help()) + exit(1) + } + + let productName = "\(context.package.displayName)PackageTests" + let build = try buildWasm( + productName: productName, context: context) + guard build.succeeded else { + 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.packageOptions.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.packageOptions.explain, + printProgress: self.printProgress + ) + let planner = PackagingPlanner( + options: testOptions.packageOptions, context: context, selfPackage: selfPackage, + outputDir: outputDir, wasmProductArtifact: productArtifact) + let (rootTask, binDir) = try planner.planTestBuild( + make: &make) + 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 testJsArguments: [String] = [] + var testFrameworkArguments: [String] = [] + if testOptions.listTests { + testFrameworkArguments += ["--list-tests"] + } + if let prelude = testOptions.prelude { + let preludeURL = URL(fileURLWithPath: prelude, relativeTo: URL(fileURLWithPath: FileManager.default.currentDirectoryPath)) + testJsArguments += ["--prelude", preludeURL.path] + } + if let environment = testOptions.environment { + testJsArguments += ["--environment", environment] + } + if testOptions.inspect { + testJsArguments += ["--inspect"] + } + try runTest( + testRunner: testRunner, context: context, + extraArguments: testJsArguments + ["--"] + testFrameworkArguments + testOptions.filter + ) + try runTest( + testRunner: testRunner, context: context, + extraArguments: testJsArguments + ["--", "--testing-library", "swift-testing"] + testFrameworkArguments + + 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...") + logCommandExecution(node.path, arguments) + + let task = Process() + task.executableURL = node + task.arguments = arguments + task.currentDirectoryURL = context.pluginWorkDirectoryURL + try task.forwardTerminationSignals { + try task.run() + task.waitUntilExit() + } + // 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 + -> PackageManager.BuildResult + { + 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" + ] + } + return try self.packageManager.build(.product(productName), parameters: parameters) + } + + /// 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) + } + + private func printProgress(task: MiniMake.Task, total: Int, built: Int, message: String) { + printStderr("[\(built + 1)/\(total)] \(task.displayName): \(message)") + } +} + +private func printStderr(_ message: String) { + fputs(message + "\n", stderr) +} + +// MARK: - Options parsing + +extension PackageToJS.PackageOptions { + static func parse(from extractor: inout ArgumentExtractor) -> PackageToJS.PackageOptions { + let outputPath = extractor.extractOption(named: "output").last + let packageName = extractor.extractOption(named: "package-name").last + let explain = extractor.extractFlag(named: "explain") + let useCDN = extractor.extractFlag(named: "use-cdn") + return PackageToJS.PackageOptions( + outputPath: outputPath, packageName: packageName, explain: explain != 0, useCDN: useCDN != 0 + ) + } +} + +extension PackageToJS.BuildOptions { + static func parse(from extractor: inout ArgumentExtractor) -> PackageToJS.BuildOptions { + let product = extractor.extractOption(named: "product").last + let splitDebug = extractor.extractFlag(named: "split-debug") + let noOptimize = extractor.extractFlag(named: "no-optimize") + let packageOptions = PackageToJS.PackageOptions.parse(from: &extractor) + return PackageToJS.BuildOptions(product: product, splitDebug: splitDebug != 0, noOptimize: noOptimize != 0, packageOptions: packageOptions) + } + + static func help() -> String { + return """ + OVERVIEW: Builds a JavaScript module from a Swift package. + + 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 + --split-debug Whether to split debug information into a separate .wasm.debug file (default: false) + --no-optimize Whether to disable wasm-opt optimization (default: false) + + 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 + """ + } +} + +extension PackageToJS.TestOptions { + static func parse(from extractor: inout ArgumentExtractor) -> PackageToJS.TestOptions { + let buildOnly = extractor.extractFlag(named: "build-only") + let listTests = extractor.extractFlag(named: "list-tests") + let filter = extractor.extractOption(named: "filter") + let prelude = extractor.extractOption(named: "prelude").last + let environment = extractor.extractOption(named: "environment").last + let inspect = extractor.extractFlag(named: "inspect") + let packageOptions = PackageToJS.PackageOptions.parse(from: &extractor) + var options = PackageToJS.TestOptions( + buildOnly: buildOnly != 0, listTests: listTests != 0, + filter: filter, prelude: prelude, environment: environment, inspect: inspect != 0, packageOptions: packageOptions + ) + + if !options.buildOnly, !options.packageOptions.useCDN { + options.packageOptions.useCDN = true + } + + return 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) + --prelude Path to the prelude script + --environment The environment to use for the tests + --inspect Whether to run tests in the browser with inspector enabled + + EXAMPLES: + $ swift package --swift-sdk wasm32-unknown-wasi plugin js test + $ swift package --swift-sdk wasm32-unknown-wasi plugin js test --environment browser + # 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 + """ + } +} + +// MARK: - PackagePlugin helpers + +/// 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 +} + +extension PackageManager.BuildResult { + /// Find `.wasm` executable artifact + internal func findWasmArtifact(for product: String) throws -> URL { + let executables = self.builtArtifacts.filter { + ($0.kind == .executable) && ($0.url.lastPathComponent == "\(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(\.url.path).joined(separator: ", "))" + ) + } + return executable.url + } +} + +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) + if package.id == id { return package } + for dependency in package.dependencies { + if let found = visit(package: dependency.package) { + return found + } + } + return nil + } + return visit(package: package) +} + +extension PackagingPlanner { + init( + options: PackageToJS.PackageOptions, + context: PluginContext, + selfPackage: Package, + outputDir: URL, + wasmProductArtifact: URL + ) { + self.init( + options: options, + packageId: context.package.id, + pluginWorkDirectoryURL: context.pluginWorkDirectoryURL, + selfPackageDir: selfPackage.directoryURL, + outputDir: outputDir, + wasmProductArtifact: wasmProductArtifact + ) + } +} + +#endif diff --git a/Plugins/PackageToJS/Sources/ParseWasm.swift b/Plugins/PackageToJS/Sources/ParseWasm.swift new file mode 100644 index 000000000..1cec9e43f --- /dev/null +++ b/Plugins/PackageToJS/Sources/ParseWasm.swift @@ -0,0 +1,312 @@ +/// Represents the type of value in WebAssembly +enum ValueType: String, Codable { + case i32 + case i64 + case f32 + case f64 + case funcref + case externref + case v128 +} + +/// Represents a function type in WebAssembly +struct FunctionType: Codable { + let parameters: [ValueType] + let results: [ValueType] +} + +/// Represents a table type in WebAssembly +struct TableType: Codable { + let element: ElementType + let minimum: UInt32 + let maximum: UInt32? + + enum ElementType: String, Codable { + case funcref + case externref + } +} + +/// Represents a memory type in WebAssembly +struct MemoryType: Codable { + let minimum: UInt32 + let maximum: UInt32? + let shared: Bool + let index: IndexType + + enum IndexType: String, Codable { + case i32 + case i64 + } +} + +/// Represents a global type in WebAssembly +struct GlobalType: Codable { + let value: ValueType + let mutable: Bool +} + +/// Represents an import entry in WebAssembly +struct ImportEntry: Codable { + let module: String + let name: String + let kind: ImportKind + + enum ImportKind: Codable { + case function(type: FunctionType) + case table(type: TableType) + case memory(type: MemoryType) + case global(type: GlobalType) + } +} + +/// Parse state for WebAssembly parsing +private class ParseState { + private let moduleBytes: [UInt8] + private var offset: Int + + init(moduleBytes: [UInt8]) { + self.moduleBytes = moduleBytes + self.offset = 0 + } + + func hasMoreBytes() -> Bool { + return offset < moduleBytes.count + } + + func readByte() throws -> UInt8 { + guard offset < moduleBytes.count else { + throw ParseError.unexpectedEndOfData + } + let byte = moduleBytes[offset] + offset += 1 + return byte + } + + func skipBytes(_ count: Int) throws { + guard offset + count <= moduleBytes.count else { + throw ParseError.unexpectedEndOfData + } + offset += count + } + + /// Read an unsigned LEB128 integer + func readUnsignedLEB128() throws -> UInt32 { + var result: UInt32 = 0 + var shift: UInt32 = 0 + var byte: UInt8 + + repeat { + byte = try readByte() + result |= UInt32(byte & 0x7F) << shift + shift += 7 + if shift > 32 { + throw ParseError.integerOverflow + } + } while (byte & 0x80) != 0 + + return result + } + + func readName() throws -> String { + let nameLength = try readUnsignedLEB128() + guard offset + Int(nameLength) <= moduleBytes.count else { + throw ParseError.unexpectedEndOfData + } + + let nameBytes = moduleBytes[offset..<(offset + Int(nameLength))] + guard let name = String(bytes: nameBytes, encoding: .utf8) else { + throw ParseError.invalidUTF8 + } + + offset += Int(nameLength) + return name + } + + func assertBytes(_ expected: [UInt8]) throws { + let baseOffset = offset + let expectedLength = expected.count + + guard baseOffset + expectedLength <= moduleBytes.count else { + throw ParseError.unexpectedEndOfData + } + + for i in 0.. [ImportEntry] { + let parseState = ParseState(moduleBytes: moduleBytes) + try parseMagicNumber(parseState) + try parseVersion(parseState) + + var types: [FunctionType] = [] + var imports: [ImportEntry] = [] + + while parseState.hasMoreBytes() { + let sectionId = try parseState.readByte() + let sectionSize = try parseState.readUnsignedLEB128() + + switch sectionId { + case 1: // Type section + let typeCount = try parseState.readUnsignedLEB128() + for _ in 0.. TableType { + let elementType = try parseState.readByte() + + let element: TableType.ElementType + switch elementType { + case 0x70: + element = .funcref + case 0x6F: + element = .externref + default: + throw ParseError.unknownTableElementType(elementType) + } + + let limits = try parseLimits(parseState) + return TableType(element: element, minimum: limits.minimum, maximum: limits.maximum) +} + +private func parseLimits(_ parseState: ParseState) throws -> MemoryType { + let flags = try parseState.readByte() + let minimum = try parseState.readUnsignedLEB128() + let hasMaximum = (flags & 1) != 0 + let shared = (flags & 2) != 0 + let isMemory64 = (flags & 4) != 0 + let index: MemoryType.IndexType = isMemory64 ? .i64 : .i32 + + if hasMaximum { + let maximum = try parseState.readUnsignedLEB128() + return MemoryType(minimum: minimum, maximum: maximum, shared: shared, index: index) + } else { + return MemoryType(minimum: minimum, maximum: nil, shared: shared, index: index) + } +} + +private func parseGlobalType(_ parseState: ParseState) throws -> GlobalType { + let value = try parseValueType(parseState) + let mutable = try parseState.readByte() == 1 + return GlobalType(value: value, mutable: mutable) +} + +private func parseValueType(_ parseState: ParseState) throws -> ValueType { + let type = try parseState.readByte() + switch type { + case 0x7F: + return .i32 + case 0x7E: + return .i64 + case 0x7D: + return .f32 + case 0x7C: + return .f64 + case 0x70: + return .funcref + case 0x6F: + return .externref + case 0x7B: + return .v128 + default: + throw ParseError.unknownValueType(type) + } +} + +private func parseFunctionType(_ parseState: ParseState) throws -> FunctionType { + let form = try parseState.readByte() + if form != 0x60 { + throw ParseError.invalidFunctionTypeForm(form) + } + + var parameters: [ValueType] = [] + let parameterCount = try parseState.readUnsignedLEB128() + for _ in 0.. */` +/// - `/* #else */` +/// - `/* #endif */` +/// - `@@` +/// - `import.meta.` +/// +/// The condition is a boolean expression that can use the variables +/// defined in the `options`. Variable names must be `[a-zA-Z0-9_]+`. +/// Contents between `if-else-endif` blocks will be included or excluded +/// based on the condition like C's `#if` directive. +/// +/// `@@` and `import.meta.` will be substituted with +/// the value of the variable. +/// +/// The preprocessor will return the preprocessed source code. +func preprocess(source: String, file: String? = nil, options: PreprocessOptions) throws -> String { + let preprocessor = Preprocessor(source: source, file: file, options: options) + let tokens = try preprocessor.tokenize() + let parsed = try preprocessor.parse(tokens: tokens) + return try preprocessor.preprocess(parsed: parsed) +} + +struct PreprocessOptions { + /// The conditions to evaluate in the source code + var conditions: [String: Bool] = [:] + /// The variables to substitute in the source code + var substitutions: [String: String] = [:] +} + +private struct Preprocessor { + enum Token: Equatable { + case `if`(condition: String) + case `else` + case `endif` + case block(String) + } + + struct TokenInfo { + let token: Token + let position: String.Index + } + + struct PreprocessorError: Error, CustomStringConvertible { + let file: String? + let message: String + let source: String + let line: Int + let column: Int + + init(file: String?, message: String, source: String, line: Int, column: Int) { + self.file = file + self.message = message + self.source = source + self.line = line + self.column = column + } + + init(file: String?, message: String, source: String, index: String.Index) { + let (line, column) = Self.computeLineAndColumn(from: index, in: source) + self.init(file: file, message: message, source: source, line: line, column: column) + } + + /// Get the 1-indexed line and column + private static func computeLineAndColumn(from index: String.Index, in source: String) -> (line: Int, column: Int) { + var line = 1 + var column = 1 + for char in source[.. 0 { + description += formatLine(number: line - 1, content: lines[lineIndex - 1], width: lineNumberWidth) + } + description += formatLine(number: line, content: lines[lineIndex], width: lineNumberWidth) + description += formatPointer(column: column, width: lineNumberWidth) + if lineIndex + 1 < lines.count { + description += formatLine(number: line + 1, content: lines[lineIndex + 1], width: lineNumberWidth) + } + + return description + } + + private func formatLine(number: Int, content: String.SubSequence, width: Int) -> String { + return "\(number)".padding(toLength: width, withPad: " ", startingAt: 0) + " | \(content)\n" + } + + private func formatPointer(column: Int, width: Int) -> String { + let padding = String(repeating: " ", count: width) + " | " + String(repeating: " ", count: column - 1) + return padding + "^\n" + } + } + + let source: String + let file: String? + let options: PreprocessOptions + + init(source: String, file: String?, options: PreprocessOptions) { + self.source = source + self.file = file + self.options = options + } + + func unexpectedTokenError(expected: Token?, token: Token, at index: String.Index) -> PreprocessorError { + let message = expected.map { "Expected \($0) but got \(token)" } ?? "Unexpected token \(token)" + return PreprocessorError( + file: file, + message: message, source: source, index: index) + } + + func unexpectedCharacterError(expected: CustomStringConvertible, character: Character, at index: String.Index) -> PreprocessorError { + return PreprocessorError( + file: file, + message: "Expected \(expected) but got \(character)", source: source, index: index) + } + + func unexpectedDirectiveError(at index: String.Index) -> PreprocessorError { + return PreprocessorError( + file: file, + message: "Unexpected directive", source: source, index: index) + } + + func eofError(at index: String.Index) -> PreprocessorError { + return PreprocessorError( + file: file, + message: "Unexpected end of input", source: source, index: index) + } + + func undefinedVariableError(name: String, at index: String.Index) -> PreprocessorError { + return PreprocessorError( + file: file, + message: "Undefined variable \(name)", source: source, index: index) + } + + func tokenize() throws -> [TokenInfo] { + var cursor = source.startIndex + var tokens: [TokenInfo] = [] + + var bufferStart = cursor + + func consume(_ count: Int = 1) { + cursor = source.index(cursor, offsetBy: count) + } + + func takeIdentifier() throws -> String { + var identifier = "" + var char = try peek() + while ["a"..."z", "A"..."Z", "0"..."9"].contains(where: { $0.contains(char) }) + || char == "_" + { + identifier.append(char) + consume() + char = try peek() + } + return identifier + } + + func expect(_ expected: Character) throws { + guard try peek() == expected else { + throw unexpectedCharacterError(expected: expected, character: try peek(), at: cursor) + } + consume() + } + + func expect(_ expected: String) throws { + guard + let endIndex = source.index( + cursor, offsetBy: expected.count, limitedBy: source.endIndex) + else { + throw eofError(at: cursor) + } + guard source[cursor.. Character { + guard cursor < source.endIndex else { + throw eofError(at: cursor) + } + return source[cursor] + } + + func peek2() throws -> (Character, Character) { + guard cursor < source.endIndex, source.index(after: cursor) < source.endIndex else { + throw eofError(at: cursor) + } + let char1 = source[cursor] + let char2 = source[source.index(after: cursor)] + return (char1, char2) + } + + func addToken(_ token: Token, at position: String.Index) { + tokens.append(.init(token: token, position: position)) + } + + func flushBufferToken() { + guard bufferStart < cursor else { return } + addToken(.block(String(source[bufferStart.. Token] = [ + "if": { + try expect(" ") + let condition = try takeIdentifier() + return .if(condition: condition) + }, + "else": { + return .else + }, + "endif": { + return .endif + }, + ] + var token: Token? + for (keyword, factory) in directives { + guard directiveSource.hasPrefix(keyword) else { + continue + } + consume(keyword.count) + token = try factory() + try expect(" */") + break + } + guard let token = token else { + throw unexpectedDirectiveError(at: directiveStart) + } + // Skip a trailing newline + if (try? peek()) == "\n" { + consume() + } + addToken(token, at: directiveStart) + bufferStart = cursor + } + flushBufferToken() + return tokens + } + + enum ParseResult { + case block(String) + indirect case `if`( + condition: String, then: [ParseResult], else: [ParseResult], position: String.Index) + } + + func parse(tokens: [TokenInfo]) throws -> [ParseResult] { + var cursor = tokens.startIndex + + func consume() { + cursor = tokens.index(after: cursor) + } + + func parse() throws -> ParseResult { + switch tokens[cursor].token { + case .block(let content): + consume() + return .block(content) + case .if(let condition): + let ifPosition = tokens[cursor].position + consume() + var then: [ParseResult] = [] + var `else`: [ParseResult] = [] + while cursor < tokens.endIndex && tokens[cursor].token != .else + && tokens[cursor].token != .endif + { + then.append(try parse()) + } + if case .else = tokens[cursor].token { + consume() + while cursor < tokens.endIndex && tokens[cursor].token != .endif { + `else`.append(try parse()) + } + } + guard case .endif = tokens[cursor].token else { + throw unexpectedTokenError( + expected: .endif, token: tokens[cursor].token, at: tokens[cursor].position) + } + consume() + return .if(condition: condition, then: then, else: `else`, position: ifPosition) + case .else, .endif: + throw unexpectedTokenError( + expected: nil, token: tokens[cursor].token, at: tokens[cursor].position) + } + } + var results: [ParseResult] = [] + while cursor < tokens.endIndex { + results.append(try parse()) + } + return results + } + + func preprocess(parsed: [ParseResult]) throws -> String { + var result = "" + + func appendBlock(content: String) { + // Apply substitutions + var substitutedContent = content + for (key, value) in options.substitutions { + substitutedContent = substitutedContent.replacingOccurrences( + of: "@" + key + "@", with: value) + substitutedContent = substitutedContent.replacingOccurrences( + of: "import.meta." + key, with: value) + } + result.append(substitutedContent) + } + + func evaluate(parsed: ParseResult) throws { + switch parsed { + case .block(let content): + appendBlock(content: content) + case .if(let condition, let then, let `else`, let position): + guard let condition = options.conditions[condition] else { + throw undefinedVariableError(name: condition, at: position) + } + let blocks = condition ? then : `else` + for block in blocks { + try evaluate(parsed: block) + } + } + } + for parsed in parsed { + try evaluate(parsed: parsed) + } + return result + } +} diff --git a/Plugins/PackageToJS/Templates/bin/test.js b/Plugins/PackageToJS/Templates/bin/test.js new file mode 100644 index 000000000..5fed17359 --- /dev/null +++ b/Plugins/PackageToJS/Templates/bin/test.js @@ -0,0 +1,75 @@ +import * as nodePlatform from "../platforms/node.js" +import { instantiate } from "../instantiate.js" +import { testBrowser } from "../test.js" +import { parseArgs } from "node:util" +import path from "node:path" + +function splitArgs(args) { + // Split arguments into two parts by "--" + const part1 = [] + const part2 = [] + let index = 0 + while (index < args.length) { + if (args[index] === "--") { + index++ + break + } + part1.push(args[index]) + index++ + } + while (index < args.length) { + part2.push(args[index]) + index++ + } + return [part1, part2] +} + +const [testJsArgs, testFrameworkArgs] = splitArgs(process.argv.slice(2)) +const args = parseArgs({ + args: testJsArgs, + options: { + prelude: { type: "string" }, + environment: { type: "string" }, + inspect: { type: "boolean" }, + }, +}) + +const harnesses = { + node: async ({ preludeScript }) => { + let options = await nodePlatform.defaultNodeSetup({ + args: testFrameworkArgs, + /* #if USE_SHARED_MEMORY */ + spawnWorker: nodePlatform.createDefaultWorkerFactory(preludeScript) + /* #endif */ + }) + if (preludeScript) { + const prelude = await import(preludeScript) + if (prelude.setupOptions) { + options = prelude.setupOptions(options, { isMainThread: true }) + } + } + try { + await instantiate(options) + } catch (e) { + if (e instanceof WebAssembly.CompileError) { + } + throw e + } + }, + browser: async ({ preludeScript }) => { + process.exit(await testBrowser({ preludeScript, inspect: args.values.inspect, args: testFrameworkArgs })); + } +} + +const harness = harnesses[args.values.environment ?? "node"] +if (!harness) { + console.error(`Invalid environment: ${args.values.environment}`) + process.exit(1) +} + +const options = {} +if (args.values.prelude) { + options.preludeScript = path.resolve(process.cwd(), args.values.prelude) +} + +await harness(options) diff --git a/Plugins/PackageToJS/Templates/index.d.ts b/Plugins/PackageToJS/Templates/index.d.ts new file mode 100644 index 000000000..4a1074c14 --- /dev/null +++ b/Plugins/PackageToJS/Templates/index.d.ts @@ -0,0 +1,29 @@ +import type { Import, Export } from './instantiate.js' + +export type Options = { + /** + * The CLI arguments to pass to the WebAssembly module + */ + args?: string[] +/* #if USE_SHARED_MEMORY */ + /** + * The WebAssembly memory to use (must be 'shared') + */ + memory: WebAssembly.Memory +/* #endif */ +} + +/** + * 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 init( + moduleSource: WebAssembly.Module | ArrayBufferView | ArrayBuffer | Response | PromiseLike +): 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..d0d28569f --- /dev/null +++ b/Plugins/PackageToJS/Templates/index.js @@ -0,0 +1,14 @@ +// @ts-check +import { instantiate } from './instantiate.js'; +import { defaultBrowserSetup /* #if USE_SHARED_MEMORY */, createDefaultWorkerFactory /* #endif */} from './platforms/browser.js'; + +/** @type {import('./index.d').init} */ +export async function init(moduleSource) { + const options = await defaultBrowserSetup({ + module: moduleSource, +/* #if USE_SHARED_MEMORY */ + spawnWorker: createDefaultWorkerFactory() +/* #endif */ + }) + return await instantiate(options); +} diff --git a/Plugins/PackageToJS/Templates/instantiate.d.ts b/Plugins/PackageToJS/Templates/instantiate.d.ts new file mode 100644 index 000000000..f813b5489 --- /dev/null +++ b/Plugins/PackageToJS/Templates/instantiate.d.ts @@ -0,0 +1,103 @@ +/* #if USE_SHARED_MEMORY */ +import type { SwiftRuntimeThreadChannel, SwiftRuntime } from "./runtime.js"; +/* #endif */ + +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; + +/* #if USE_SHARED_MEMORY */ +/** + * The type of the WebAssembly memory imported by the module + */ +export declare const MEMORY_TYPE: { + initial: number, + maximum: number, + shared: boolean +} +/* #endif */ +export interface WASI { + /** + * The WASI Preview 1 import object + */ + wasiImport: WebAssembly.ModuleImports + /** + * Initialize the WASI reactor instance + * + * @param instance - The instance of the WebAssembly module + */ + initialize(instance: WebAssembly.Instance): void + /** + * Set a new instance of the WebAssembly module to the WASI context + * Typically used when instantiating a WebAssembly module for a thread + * + * @param instance - The instance of the WebAssembly module + */ + setInstance(instance: WebAssembly.Instance): void +} + +export type ModuleSource = WebAssembly.Module | ArrayBufferView | ArrayBuffer | Response | PromiseLike + +/** + * The options for instantiating a WebAssembly module + */ +export type InstantiateOptions = { + /** + * The WebAssembly module to instantiate + */ + module: ModuleSource, + /** + * The imports provided by the embedder + */ + imports: Import, +/* #if IS_WASI */ + /** + * The WASI implementation to use + */ + wasi: WASI, +/* #endif */ +/* #if USE_SHARED_MEMORY */ + /** + * The WebAssembly memory to use (must be 'shared') + */ + memory: WebAssembly.Memory + /** + * The thread channel is a set of functions that are used to communicate + * between the main thread and the worker thread. + */ + threadChannel: SwiftRuntimeThreadChannel & { + spawnThread: (module: WebAssembly.Module, memory: WebAssembly.Memory, startArg: any) => number; + } +/* #endif */ + /** + * Add imports to the WebAssembly import object + * @param imports - The imports to add + */ + addToCoreImports?: (imports: WebAssembly.Imports) => void +} + +/** + * Instantiate the given WebAssembly module + */ +export declare function instantiate(options: InstantiateOptions): Promise<{ + instance: WebAssembly.Instance, + swift: SwiftRuntime, + exports: Export +}> + +/** + * Instantiate the given WebAssembly module for a thread + */ +export declare function instantiateForThread(tid: number, startArg: number, options: InstantiateOptions): Promise<{ + instance: WebAssembly.Instance, + swift: SwiftRuntime, + exports: Export +}> diff --git a/Plugins/PackageToJS/Templates/instantiate.js b/Plugins/PackageToJS/Templates/instantiate.js new file mode 100644 index 000000000..d786c31ef --- /dev/null +++ b/Plugins/PackageToJS/Templates/instantiate.js @@ -0,0 +1,118 @@ +// @ts-check +// @ts-ignore +import { SwiftRuntime } from "./runtime.js" + +export const MODULE_PATH = "@PACKAGE_TO_JS_MODULE_PATH@"; +/* #if USE_SHARED_MEMORY */ +export const MEMORY_TYPE = { + // @ts-ignore + initial: import.meta.PACKAGE_TO_JS_MEMORY_INITIAL, + // @ts-ignore + maximum: import.meta.PACKAGE_TO_JS_MEMORY_MAXIMUM, + // @ts-ignore + shared: import.meta.PACKAGE_TO_JS_MEMORY_SHARED, +} +/* #endif */ + +/** + * @param {import('./instantiate.d').InstantiateOptions} options + */ +async function createInstantiator(options) { + return { + /** @param {WebAssembly.Imports} importObject */ + addImports: (importObject) => {}, + /** @param {WebAssembly.Instance} instance */ + createExports: (instance) => { + return {}; + }, + } +} +/** @type {import('./instantiate.d').instantiate} */ +export async function instantiate( + options +) { + const result = await _instantiate(options); +/* #if IS_WASI */ + options.wasi.initialize(result.instance); +/* #endif */ + result.swift.main(); + return result; +} + +/** @type {import('./instantiate.d').instantiateForThread} */ +export async function instantiateForThread( + tid, startArg, options +) { + const result = await _instantiate(options); +/* #if IS_WASI */ + options.wasi.setInstance(result.instance); +/* #endif */ + result.swift.startThread(tid, startArg) + return result; +} + +/** @type {import('./instantiate.d').instantiate} */ +async function _instantiate( + options +) { + const moduleSource = options.module; +/* #if IS_WASI */ + const { wasi } = options; +/* #endif */ + const instantiator = await createInstantiator(options); + const swift = new SwiftRuntime({ +/* #if USE_SHARED_MEMORY */ + sharedMemory: true, + threadChannel: options.threadChannel, +/* #endif */ + }); + + /** @type {WebAssembly.Imports} */ + const importObject = { + javascript_kit: swift.wasmImports, +/* #if IS_WASI */ + wasi_snapshot_preview1: wasi.wasiImport, +/* #if USE_SHARED_MEMORY */ + env: { + memory: options.memory, + }, + wasi: { + "thread-spawn": (startArg) => { + return options.threadChannel.spawnThread(module, options.memory, startArg); + } + } +/* #endif */ +/* #endif */ + }; + instantiator.addImports(importObject); + options.addToCoreImports?.(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); + + return { + instance, + swift, + exports: instantiator.createExports(instance), + } +} diff --git a/Plugins/PackageToJS/Templates/package.json b/Plugins/PackageToJS/Templates/package.json new file mode 100644 index 000000000..79562784a --- /dev/null +++ b/Plugins/PackageToJS/Templates/package.json @@ -0,0 +1,16 @@ +{ + "name": "@PACKAGE_TO_JS_PACKAGE_NAME@", + "version": "0.0.0", + "type": "module", + "private": true, + "exports": { + ".": "./index.js", + "./wasm": "./@PACKAGE_TO_JS_MODULE_PATH@" + }, + "dependencies": { + "@bjorn3/browser_wasi_shim": "0.3.0" + }, + "devDependencies": { + "playwright": "^1.51.0" + } +} diff --git a/Plugins/PackageToJS/Templates/platforms/browser.d.ts b/Plugins/PackageToJS/Templates/platforms/browser.d.ts new file mode 100644 index 000000000..5b27cc903 --- /dev/null +++ b/Plugins/PackageToJS/Templates/platforms/browser.d.ts @@ -0,0 +1,15 @@ +import type { InstantiateOptions, ModuleSource } from "../instantiate.js" + +export async function defaultBrowserSetup(options: { + module: ModuleSource, +/* #if IS_WASI */ + args?: string[], + onStdoutLine?: (line: string) => void, + onStderrLine?: (line: string) => void, +/* #endif */ +/* #if USE_SHARED_MEMORY */ + spawnWorker: (module: WebAssembly.Module, memory: WebAssembly.Memory, startArg: any) => Worker, +/* #endif */ +}): Promise + +export function createDefaultWorkerFactory(preludeScript?: string): (module: WebAssembly.Module, memory: WebAssembly.Memory, startArg: any) => Worker diff --git a/Plugins/PackageToJS/Templates/platforms/browser.js b/Plugins/PackageToJS/Templates/platforms/browser.js new file mode 100644 index 000000000..672c274db --- /dev/null +++ b/Plugins/PackageToJS/Templates/platforms/browser.js @@ -0,0 +1,136 @@ +// @ts-check +import { MODULE_PATH /* #if USE_SHARED_MEMORY */, MEMORY_TYPE /* #endif */} from "../instantiate.js" +/* #if IS_WASI */ +/* #if USE_WASI_CDN */ +import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from 'https://cdn.jsdelivr.net/npm/@bjorn3/browser_wasi_shim@0.4.1/+esm'; +/* #else */ +import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from '@bjorn3/browser_wasi_shim'; +/* #endif */ +/* #endif */ + +/* #if USE_SHARED_MEMORY */ +export async function defaultBrowserThreadSetup() { + const threadChannel = { + spawnThread: () => { + throw new Error("Cannot spawn a new thread from a worker thread") + }, + postMessageToMainThread: (message, transfer) => { + // @ts-ignore + self.postMessage(message, transfer); + }, + listenMessageFromMainThread: (listener) => { + // @ts-ignore + self.onmessage = (event) => listener(event.data); + } + } + +/* #if IS_WASI */ + 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()), + ], { debug: false }) +/* #endif */ + return { +/* #if IS_WASI */ + wasi: Object.assign(wasi, { + setInstance(instance) { + wasi.inst = instance; + } + }), +/* #endif */ + threadChannel, + } +} + +/** @type {import('./browser.d.ts').createDefaultWorkerFactory} */ +export function createDefaultWorkerFactory(preludeScript) { + return (tid, startArg, module, memory) => { + const worker = new Worker(new URL("./browser.worker.js", import.meta.url), { + type: "module", + }); + worker.addEventListener("messageerror", (error) => { + console.error(`Worker thread ${tid} error:`, error); + throw error; + }); + worker.postMessage({ module, memory, tid, startArg, preludeScript }); + return worker; + } +} + +class DefaultBrowserThreadRegistry { + workers = new Map(); + nextTid = 1; + + constructor(createWorker) { + this.createWorker = createWorker; + } + + spawnThread(module, memory, startArg) { + const tid = this.nextTid++; + this.workers.set(tid, this.createWorker(tid, startArg, module, memory)); + return tid; + } + + listenMessageFromWorkerThread(tid, listener) { + const worker = this.workers.get(tid); + worker?.addEventListener("message", (event) => { + listener(event.data); + }); + } + + postMessageToWorkerThread(tid, message, transfer) { + const worker = this.workers.get(tid); + worker?.postMessage(message, transfer); + } + + terminateWorkerThread(tid) { + const worker = this.workers.get(tid); + worker.terminate(); + this.workers.delete(tid); + } +} +/* #endif */ + +/** @type {import('./browser.d.ts').defaultBrowserSetup} */ +export async function defaultBrowserSetup(options) { +/* #if IS_WASI */ + const args = options.args ?? [] + const onStdoutLine = options.onStdoutLine ?? ((line) => console.log(line)) + const onStderrLine = options.onStderrLine ?? ((line) => console.error(line)) + const wasi = new WASI(/* args */[MODULE_PATH, ...args], /* env */[], /* fd */[ + new OpenFile(new File([])), // stdin + ConsoleStdout.lineBuffered((stdout) => { + onStdoutLine(stdout); + }), + ConsoleStdout.lineBuffered((stderr) => { + onStderrLine(stderr); + }), + new PreopenDirectory("/", new Map()), + ], { debug: false }) +/* #endif */ +/* #if USE_SHARED_MEMORY */ + const memory = new WebAssembly.Memory(MEMORY_TYPE); + const threadChannel = new DefaultBrowserThreadRegistry(options.spawnWorker) +/* #endif */ + + return { + module: options.module, + imports: {}, +/* #if IS_WASI */ + wasi: Object.assign(wasi, { + setInstance(instance) { + wasi.inst = instance; + } + }), +/* #endif */ +/* #if USE_SHARED_MEMORY */ + memory, threadChannel, +/* #endif */ + } +} diff --git a/Plugins/PackageToJS/Templates/platforms/browser.worker.js b/Plugins/PackageToJS/Templates/platforms/browser.worker.js new file mode 100644 index 000000000..42fe6a2fa --- /dev/null +++ b/Plugins/PackageToJS/Templates/platforms/browser.worker.js @@ -0,0 +1,18 @@ +import { instantiateForThread } from "../instantiate.js" +import { defaultBrowserThreadSetup } from "./browser.js" + +self.onmessage = async (event) => { + const { module, memory, tid, startArg, preludeScript } = event.data; + let options = await defaultBrowserThreadSetup(); + if (preludeScript) { + const prelude = await import(preludeScript); + if (prelude.setupOptions) { + options = prelude.setupOptions(options, { isMainThread: false }) + } + } + await instantiateForThread(tid, startArg, { + ...options, + module, memory, + imports: {}, + }) +} diff --git a/Plugins/PackageToJS/Templates/platforms/node.d.ts b/Plugins/PackageToJS/Templates/platforms/node.d.ts new file mode 100644 index 000000000..433f97ad6 --- /dev/null +++ b/Plugins/PackageToJS/Templates/platforms/node.d.ts @@ -0,0 +1,13 @@ +import type { InstantiateOptions } from "../instantiate.js" +import type { Worker } from "node:worker_threads" + +export async function defaultNodeSetup(options: { +/* #if IS_WASI */ + args?: string[], +/* #endif */ +/* #if USE_SHARED_MEMORY */ + spawnWorker: (module: WebAssembly.Module, memory: WebAssembly.Memory, startArg: any) => Worker, +/* #endif */ +}): Promise + +export function createDefaultWorkerFactory(preludeScript: string): (module: WebAssembly.Module, memory: WebAssembly.Memory, startArg: any) => Worker diff --git a/Plugins/PackageToJS/Templates/platforms/node.js b/Plugins/PackageToJS/Templates/platforms/node.js new file mode 100644 index 000000000..a8bb638bc --- /dev/null +++ b/Plugins/PackageToJS/Templates/platforms/node.js @@ -0,0 +1,158 @@ +// @ts-check +import { fileURLToPath } from "node:url"; +import { Worker, parentPort } from "node:worker_threads"; +import { MODULE_PATH /* #if USE_SHARED_MEMORY */, MEMORY_TYPE /* #endif */} from "../instantiate.js" +/* #if IS_WASI */ +import { WASI, File, OpenFile, ConsoleStdout, PreopenDirectory } from '@bjorn3/browser_wasi_shim'; +/* #endif */ + +/* #if USE_SHARED_MEMORY */ +export async function defaultNodeThreadSetup() { + const threadChannel = { + spawnThread: () => { + throw new Error("Cannot spawn a new thread from a worker thread") + }, + postMessageToMainThread: (message, transfer) => { + // @ts-ignore + parentPort.postMessage(message, transfer); + }, + listenMessageFromMainThread: (listener) => { + // @ts-ignore + parentPort.on("message", listener) + } + } + + 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()), + ], { debug: false }) + + return { + wasi: Object.assign(wasi, { + setInstance(instance) { + wasi.inst = instance; + } + }), + threadChannel, + } +} + +export function createDefaultWorkerFactory(preludeScript) { + return (tid, startArg, module, memory) => { + const selfFilePath = new URL(import.meta.url).pathname; + const instantiatePath = fileURLToPath(new URL("../instantiate.js", import.meta.url)); + const worker = new Worker(` + const { parentPort } = require('node:worker_threads'); + + Error.stackTraceLimit = 100; + parentPort.once("message", async (event) => { + const { instantiatePath, selfFilePath, module, memory, tid, startArg, preludeScript } = event; + const { defaultNodeThreadSetup } = await import(selfFilePath); + const { instantiateForThread } = await import(instantiatePath); + let options = await defaultNodeThreadSetup(); + if (preludeScript) { + const prelude = await import(preludeScript); + if (prelude.setupOptions) { + options = prelude.setupOptions(options, { isMainThread: false }) + } + } + await instantiateForThread(tid, startArg, { + ...options, + module, memory, + imports: {}, + }) + }) + `, + { eval: true } + ) + worker.on("error", (error) => { + console.error(`Worker thread ${tid} error:`, error); + throw error; + }); + worker.postMessage({ instantiatePath, selfFilePath, module, memory, tid, startArg, preludeScript }); + return worker; + } +} + +class DefaultNodeThreadRegistry { + workers = new Map(); + nextTid = 1; + + constructor(createWorker) { + this.createWorker = createWorker; + } + + spawnThread(module, memory, startArg) { + const tid = this.nextTid++; + this.workers.set(tid, this.createWorker(tid, startArg, module, memory)); + return tid; + } + + listenMessageFromWorkerThread(tid, listener) { + const worker = this.workers.get(tid); + worker.on("message", listener); + } + + postMessageToWorkerThread(tid, message, transfer) { + const worker = this.workers.get(tid); + worker.postMessage(message, transfer); + } + + terminateWorkerThread(tid) { + const worker = this.workers.get(tid); + worker.terminate(); + this.workers.delete(tid); + } +} +/* #endif */ + +/** @type {import('./node.d.ts').defaultNodeSetup} */ +export async function defaultNodeSetup(options) { + const path = await import("node:path"); + const { fileURLToPath } = await import("node:url"); + const { readFile } = await import("node:fs/promises") + + const args = options.args ?? process.argv.slice(2) + const wasi = new WASI(/* args */[MODULE_PATH, ...args], /* env */[], /* fd */[ + new OpenFile(new File([])), // stdin + ConsoleStdout.lineBuffered((stdout) => { + console.log(stdout); + }), + ConsoleStdout.lineBuffered((stderr) => { + console.error(stderr); + }), + new PreopenDirectory("/", new Map()), + ], { debug: false }) + const pkgDir = path.dirname(path.dirname(fileURLToPath(import.meta.url))) + const module = await WebAssembly.compile(await readFile(path.join(pkgDir, MODULE_PATH))) +/* #if USE_SHARED_MEMORY */ + const memory = new WebAssembly.Memory(MEMORY_TYPE); + const threadChannel = new DefaultNodeThreadRegistry(options.spawnWorker) +/* #endif */ + + return { + module, + imports: {}, +/* #if IS_WASI */ + wasi: Object.assign(wasi, { + setInstance(instance) { + wasi.inst = instance; + } + }), + addToCoreImports(importObject) { + importObject["wasi_snapshot_preview1"]["proc_exit"] = (code) => { + process.exit(code); + } + }, +/* #endif */ +/* #if USE_SHARED_MEMORY */ + memory, threadChannel, +/* #endif */ + } +} diff --git a/Plugins/PackageToJS/Templates/test.browser.html b/Plugins/PackageToJS/Templates/test.browser.html new file mode 100644 index 000000000..27bfd25fc --- /dev/null +++ b/Plugins/PackageToJS/Templates/test.browser.html @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/Plugins/PackageToJS/Templates/test.d.ts b/Plugins/PackageToJS/Templates/test.d.ts new file mode 100644 index 000000000..b3bbe54dd --- /dev/null +++ b/Plugins/PackageToJS/Templates/test.d.ts @@ -0,0 +1,12 @@ +import type { InstantiateOptions, instantiate } from "./instantiate"; + +export async function testBrowser( + options: { + preludeScript?: string, + args?: string[], + } +): Promise + +export async function testBrowserInPage( + options: InstantiateOptions +): ReturnType diff --git a/Plugins/PackageToJS/Templates/test.js b/Plugins/PackageToJS/Templates/test.js new file mode 100644 index 000000000..8c4432492 --- /dev/null +++ b/Plugins/PackageToJS/Templates/test.js @@ -0,0 +1,188 @@ +/** @type {import('./test.d.ts').testBrowser} */ +export async function testBrowser( + options = {}, +) { + const { fileURLToPath } = await import("node:url"); + const path = await import("node:path"); + const fs = await import("node:fs/promises"); + const os = await import("node:os"); + const { existsSync } = await import("node:fs"); + const selfUrl = fileURLToPath(import.meta.url); + const webRoot = path.dirname(selfUrl); + + const http = await import("node:http"); + const defaultContentTypes = { + ".html": "text/html", + ".js": "text/javascript", + ".mjs": "text/javascript", + ".wasm": "application/wasm", + }; + const preludeScriptPath = "/prelude.js" + const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const pathname = url.pathname; + const filePath = path.join(webRoot, pathname); + + res.setHeader("Cross-Origin-Embedder-Policy", "require-corp"); + res.setHeader("Cross-Origin-Opener-Policy", "same-origin"); + + if (existsSync(filePath) && (await fs.stat(filePath)).isFile()) { + const data = await fs.readFile(filePath); + const ext = pathname.slice(pathname.lastIndexOf(".")); + const contentType = options.contentTypes?.(pathname) || defaultContentTypes[ext] || "text/plain"; + res.writeHead(200, { "Content-Type": contentType }); + res.end(data); + } else if (pathname === "/process-info.json") { + res.writeHead(200, { "Content-Type": "application/json" }); + const info = { + env: process.env, + args: options.args, + }; + if (options.preludeScript) { + info.preludeScript = preludeScriptPath; + } + res.end(JSON.stringify(info)); + } else if (pathname === preludeScriptPath) { + res.writeHead(200, { "Content-Type": "text/javascript" }); + res.end(await fs.readFile(options.preludeScript, "utf-8")); + } else { + res.writeHead(404); + res.end(); + } + }); + + async function tryListen(port) { + try { + await new Promise((resolve) => { + server.listen({ host: "localhost", port }, () => resolve()); + server.once("error", (error) => { + if (error.code === "EADDRINUSE") { + resolve(null); + } else { + throw error; + } + }); + }); + return server.address(); + } catch { + return null; + } + } + + // Try to listen on port 3000, if it's already in use, try a random available port + let address = await tryListen(3000); + if (!address) { + address = await tryListen(0); + } + + if (options.inspect) { + console.log("Serving test page at http://localhost:" + address.port + "/test.browser.html"); + console.log("Inspect mode: Press Ctrl+C to exit"); + await new Promise((resolve) => process.on("SIGINT", resolve)); + process.exit(128 + os.constants.signals.SIGINT); + } + + const playwright = await (async () => { + try { + // @ts-ignore + return await import("playwright") + } catch { + // Playwright is not available in the current environment + console.error(`Playwright is not available in the current environment. +Please run the following command to install it: + + $ npm install playwright && npx playwright install chromium + `); + process.exit(1); + } + })(); + const browser = await playwright.chromium.launch(); + const context = await browser.newContext(); + const page = await context.newPage(); + + + // Forward console messages in the page to the Node.js console + page.on("console", (message) => { + console.log(message.text()); + }); + + const onExit = new Promise((resolve) => { + page.exposeFunction("exitTest", resolve); + }); + await page.goto(`http://localhost:${address.port}/test.browser.html`); + const exitCode = await onExit; + await browser.close(); + return exitCode; +} + +/** @type {import('./test.d.ts').testBrowserInPage} */ +export async function testBrowserInPage(options, processInfo) { + const exitTest = (code) => { + const fn = window.exitTest; + if (fn) { fn(code); } + } + + const handleError = (error) => { + console.error(error); + exitTest(1); + }; + + // There are 6 cases to exit test + // 1. Successfully finished XCTest with `exit(0)` synchronously + // 2. Unsuccessfully finished XCTest with `exit(non - zero)` synchronously + // 3. Successfully finished XCTest with `exit(0)` asynchronously + // 4. Unsuccessfully finished XCTest with `exit(non - zero)` asynchronously + // 5. Crash by throwing JS exception synchronously + // 6. Crash by throwing JS exception asynchronously + + class ExitError extends Error { + constructor(code) { + super(`Process exited with code ${code}`); + this.code = code; + } + } + const handleExitOrError = (error) => { + if (error instanceof ExitError) { + exitTest(error.code); + } else { + handleError(error) // something wrong happens during test + } + } + + // Handle asynchronous exits (case 3, 4, 6) + window.addEventListener("unhandledrejection", event => { + event.preventDefault(); + const error = event.reason; + handleExitOrError(error); + }); + + const { instantiate } = await import("./instantiate.js"); + let setupOptions = (options, _) => { return options }; + if (processInfo.preludeScript) { + const prelude = await import(processInfo.preludeScript); + if (prelude.setupOptions) { + setupOptions = prelude.setupOptions; + } + } + + options = await setupOptions(options, { isMainThread: true }); + + try { + // Instantiate the WebAssembly file + return await instantiate({ + ...options, + addToCoreImports: (imports) => { + options.addToCoreImports?.(imports); + imports["wasi_snapshot_preview1"]["proc_exit"] = (code) => { + exitTest(code); + throw new ExitError(code); + }; + }, + }); + // When JavaScriptEventLoop executor is still running, + // reachable here without catch (case 3, 4, 6) + } catch (error) { + // Handle synchronous exits (case 1, 2, 5) + handleExitOrError(error); + } +} diff --git a/Plugins/PackageToJS/Tests/ExampleProjectTests.swift b/Plugins/PackageToJS/Tests/ExampleProjectTests.swift new file mode 100644 index 000000000..1bcc25d48 --- /dev/null +++ b/Plugins/PackageToJS/Tests/ExampleProjectTests.swift @@ -0,0 +1,6 @@ +import Testing + +@Suite struct ExampleProjectTests { + @Test func example() throws { + } +} diff --git a/Plugins/PackageToJS/Tests/MiniMakeTests.swift b/Plugins/PackageToJS/Tests/MiniMakeTests.swift new file mode 100644 index 000000000..bb097115c --- /dev/null +++ b/Plugins/PackageToJS/Tests/MiniMakeTests.swift @@ -0,0 +1,203 @@ +import Foundation +import Testing + +@testable import PackageToJS + +@Suite struct MiniMakeTests { + // Test basic task management functionality + @Test func basicTaskManagement() throws { + try withTemporaryDirectory { tempDir in + var make = MiniMake(printProgress: { _, _, _, _ in }) + let outputPath = tempDir.appendingPathComponent("output.txt").path + + let task = make.addTask(output: outputPath) { task in + try "Hello".write(toFile: task.output, atomically: true, encoding: .utf8) + } + + try make.build(output: task) + let content = try String(contentsOfFile: outputPath, encoding: .utf8) + #expect(content == "Hello") + } + } + + // Test that task dependencies are handled correctly + @Test func taskDependencies() throws { + try withTemporaryDirectory { tempDir in + var make = MiniMake(printProgress: { _, _, _, _ in }) + let input = tempDir.appendingPathComponent("input.txt").path + let intermediate = tempDir.appendingPathComponent("intermediate.txt").path + let output = tempDir.appendingPathComponent("output.txt").path + + try "Input".write(toFile: input, atomically: true, encoding: .utf8) + + let intermediateTask = make.addTask(inputFiles: [input], output: intermediate) { task in + let content = try String(contentsOfFile: task.inputs[0], encoding: .utf8) + try (content + " processed").write( + toFile: task.output, atomically: true, encoding: .utf8) + } + + let finalTask = make.addTask( + inputFiles: [intermediate], inputTasks: [intermediateTask], output: output + ) { task in + let content = try String(contentsOfFile: task.inputs[0], encoding: .utf8) + try (content + " final").write( + toFile: task.output, atomically: true, encoding: .utf8) + } + + try make.build(output: finalTask) + let content = try String(contentsOfFile: output, encoding: .utf8) + #expect(content == "Input processed final") + } + } + + // Test that phony tasks are always rebuilt + @Test func phonyTask() throws { + try withTemporaryDirectory { tempDir in + var make = MiniMake(printProgress: { _, _, _, _ in }) + let outputPath = tempDir.appendingPathComponent("phony.txt").path + try "Hello".write(toFile: outputPath, atomically: true, encoding: .utf8) + var buildCount = 0 + + let task = make.addTask(output: outputPath, attributes: [.phony]) { task in + buildCount += 1 + try String(buildCount).write(toFile: task.output, atomically: true, encoding: .utf8) + } + + try make.build(output: task) + try make.build(output: task) + + #expect(buildCount == 2, "Phony task should always rebuild") + } + } + + // Test that the same build graph produces stable fingerprints + @Test func fingerprintStability() throws { + var make1 = MiniMake(printProgress: { _, _, _, _ in }) + var make2 = MiniMake(printProgress: { _, _, _, _ in }) + + let output1 = "output1.txt" + + let task1 = make1.addTask(output: output1) { _ in } + let task2 = make2.addTask(output: output1) { _ in } + + let fingerprint1 = try make1.computeFingerprint(root: task1) + let fingerprint2 = try make2.computeFingerprint(root: task2) + + #expect(fingerprint1 == fingerprint2, "Same build graph should have same fingerprint") + } + + // Test that rebuilds are controlled by timestamps + @Test func timestampBasedRebuild() throws { + try withTemporaryDirectory { tempDir in + var make = MiniMake(printProgress: { _, _, _, _ in }) + let input = tempDir.appendingPathComponent("input.txt").path + let output = tempDir.appendingPathComponent("output.txt").path + var buildCount = 0 + + try "Initial".write(toFile: input, atomically: true, encoding: .utf8) + + let task = make.addTask(inputFiles: [input], output: output) { task in + buildCount += 1 + let content = try String(contentsOfFile: task.inputs[0], encoding: .utf8) + try content.write(toFile: task.output, atomically: true, encoding: .utf8) + } + + // First build + try make.build(output: task) + #expect(buildCount == 1, "First build should occur") + + // Second build without changes + try make.build(output: task) + #expect(buildCount == 1, "No rebuild should occur if input is not modified") + + // Modify input and rebuild + try "Modified".write(toFile: input, atomically: true, encoding: .utf8) + try make.build(output: task) + #expect(buildCount == 2, "Should rebuild when input is modified") + } + } + + // Test that silent tasks execute without output + @Test func silentTask() throws { + try withTemporaryDirectory { tempDir in + var messages: [(String, Int, Int, String)] = [] + var make = MiniMake( + printProgress: { task, total, built, message in + messages.append((URL(fileURLWithPath: task.output).lastPathComponent, total, built, message)) + } + ) + let silentOutputPath = tempDir.appendingPathComponent("silent.txt").path + let silentTask = make.addTask(output: silentOutputPath, attributes: [.silent]) { task in + try "Silent".write(toFile: task.output, atomically: true, encoding: .utf8) + } + let finalOutputPath = tempDir.appendingPathComponent("output.txt").path + let task = make.addTask( + inputTasks: [silentTask], output: finalOutputPath + ) { task in + try "Hello".write(toFile: task.output, atomically: true, encoding: .utf8) + } + + try make.build(output: task) + #expect(FileManager.default.fileExists(atPath: silentOutputPath), "Silent task should still create output file") + #expect(FileManager.default.fileExists(atPath: finalOutputPath), "Final task should create output file") + try #require(messages.count == 1, "Should print progress for the final task") + #expect(messages[0] == ("output.txt", 1, 0, "\u{1B}[32mbuilding\u{1B}[0m")) + } + } + + // Test that error cases are handled appropriately + @Test func errorWhileBuilding() throws { + struct BuildError: Error {} + try withTemporaryDirectory { tempDir in + var make = MiniMake(printProgress: { _, _, _, _ in }) + let output = tempDir.appendingPathComponent("error.txt").path + + let task = make.addTask(output: output) { task in + throw BuildError() + } + + #expect(throws: BuildError.self) { + try make.build(output: task) + } + } + } + + // Test that cleanup functionality works correctly + @Test func cleanup() throws { + try withTemporaryDirectory { tempDir in + var make = MiniMake(printProgress: { _, _, _, _ in }) + let outputs = [ + tempDir.appendingPathComponent("clean1.txt").path, + tempDir.appendingPathComponent("clean2.txt").path, + ] + + // Create tasks and build them + let tasks = outputs.map { output in + make.addTask(output: output) { task in + try "Content".write(toFile: task.output, atomically: true, encoding: .utf8) + } + } + + for task in tasks { + try make.build(output: task) + } + + // Verify files exist + for output in outputs { + #expect( + FileManager.default.fileExists(atPath: output), + "Output file should exist before cleanup") + } + + // Clean everything + make.cleanEverything() + + // Verify files are removed + for output in outputs { + #expect( + !FileManager.default.fileExists(atPath: output), + "Output file should not exist after cleanup") + } + } + } +} diff --git a/Plugins/PackageToJS/Tests/PreprocessTests.swift b/Plugins/PackageToJS/Tests/PreprocessTests.swift new file mode 100644 index 000000000..9ebb7a161 --- /dev/null +++ b/Plugins/PackageToJS/Tests/PreprocessTests.swift @@ -0,0 +1,137 @@ +import Testing +@testable import PackageToJS + +@Suite struct PreprocessTests { + @Test func thenBlock() throws { + let source = """ + /* #if FOO */ + console.log("FOO"); + /* #else */ + console.log("BAR"); + /* #endif */ + """ + let options = PreprocessOptions(conditions: ["FOO": true]) + let result = try preprocess(source: source, options: options) + #expect(result == "console.log(\"FOO\");\n") + } + + @Test func elseBlock() throws { + let source = """ + /* #if FOO */ + console.log("FOO"); + /* #else */ + console.log("BAR"); + /* #endif */ + """ + let options = PreprocessOptions(conditions: ["FOO": false]) + let result = try preprocess(source: source, options: options) + #expect(result == "console.log(\"BAR\");\n") + } + + @Test func onelineIf() throws { + let source = """ + /* #if FOO */console.log("FOO");/* #endif */ + """ + let options = PreprocessOptions(conditions: ["FOO": true]) + let result = try preprocess(source: source, options: options) + #expect(result == "console.log(\"FOO\");") + } + + @Test func undefinedVariable() throws { + let source = """ + /* #if FOO */ + /* #endif */ + """ + let options = PreprocessOptions(conditions: [:]) + #expect(throws: Error.self) { + try preprocess(source: source, options: options) + } + } + + @Test func substitution() throws { + let source = "@FOO@" + let options = PreprocessOptions(substitutions: ["FOO": "BAR"]) + let result = try preprocess(source: source, options: options) + #expect(result == "BAR") + } + + @Test func missingEndOfDirective() throws { + let source = """ + /* #if FOO + """ + #expect(throws: Error.self) { + try preprocess(source: source, options: PreprocessOptions()) + } + } + + @Test(arguments: [ + (foo: true, bar: true, expected: "console.log(\"FOO\");\nconsole.log(\"FOO & BAR\");\n"), + (foo: true, bar: false, expected: "console.log(\"FOO\");\nconsole.log(\"FOO & !BAR\");\n"), + (foo: false, bar: true, expected: "console.log(\"!FOO\");\n"), + (foo: false, bar: false, expected: "console.log(\"!FOO\");\n"), + ]) + func nestedIfDirectives(foo: Bool, bar: Bool, expected: String) throws { + let source = """ + /* #if FOO */ + console.log("FOO"); + /* #if BAR */ + console.log("FOO & BAR"); + /* #else */ + console.log("FOO & !BAR"); + /* #endif */ + /* #else */ + console.log("!FOO"); + /* #endif */ + """ + let options = PreprocessOptions(conditions: ["FOO": foo, "BAR": bar]) + let result = try preprocess(source: source, options: options) + #expect(result == expected) + } + + @Test func multipleSubstitutions() throws { + let source = """ + const name = "@NAME@"; + const version = "@VERSION@"; + """ + let options = PreprocessOptions(substitutions: [ + "NAME": "MyApp", + "VERSION": "1.0.0" + ]) + let result = try preprocess(source: source, options: options) + #expect(result == """ + const name = "MyApp"; + const version = "1.0.0"; + """) + } + + @Test func invalidVariableName() throws { + let source = """ + /* #if invalid-name */ + console.log("error"); + /* #endif */ + """ + #expect(throws: Error.self) { + try preprocess(source: source, options: PreprocessOptions()) + } + } + + @Test func emptyBlocks() throws { + let source = """ + /* #if FOO */ + /* #else */ + /* #endif */ + """ + let options = PreprocessOptions(conditions: ["FOO": true]) + let result = try preprocess(source: source, options: options) + #expect(result == "") + } + + @Test func ignoreNonDirectiveComments() throws { + let source = """ + /* Normal comment */ + /** Doc comment */ + """ + let result = try preprocess(source: source, options: PreprocessOptions()) + #expect(result == source) + } +} diff --git a/Plugins/PackageToJS/Tests/TemporaryDirectory.swift b/Plugins/PackageToJS/Tests/TemporaryDirectory.swift new file mode 100644 index 000000000..4aa543bbf --- /dev/null +++ b/Plugins/PackageToJS/Tests/TemporaryDirectory.swift @@ -0,0 +1,24 @@ +import Foundation + +struct MakeTemporaryDirectoryError: Error { + let error: CInt +} + +internal func withTemporaryDirectory(body: (URL) throws -> T) throws -> T { + // Create a temporary directory using mkdtemp + var template = FileManager.default.temporaryDirectory.appendingPathComponent("PackageToJSTests.XXXXXX").path + return try template.withUTF8 { template in + let copy = UnsafeMutableBufferPointer.allocate(capacity: template.count + 1) + template.copyBytes(to: copy) + copy[template.count] = 0 + + guard let result = mkdtemp(copy.baseAddress!) else { + throw MakeTemporaryDirectoryError(error: errno) + } + let tempDir = URL(fileURLWithPath: String(cString: result)) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + return try body(tempDir) + } +} \ No newline at end of file diff --git a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift index 16cfd6374..0dfdac25f 100644 --- a/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift +++ b/Tests/JavaScriptEventLoopTests/WebWorkerTaskExecutorTests.swift @@ -7,6 +7,21 @@ import _CJavaScriptKit // For swjs_get_worker_thread_id @_extern(wasm, module: "JavaScriptEventLoopTestSupportTests", name: "isMainThread") func isMainThread() -> Bool +#if canImport(wasi_pthread) +import wasi_pthread +/// Trick to avoid blocking the main thread. pthread_mutex_lock function is used by +/// the Swift concurrency runtime. +@_cdecl("pthread_mutex_lock") +func pthread_mutex_lock(_ mutex: UnsafeMutablePointer) -> Int32 { + // DO NOT BLOCK MAIN THREAD + var ret: Int32 + repeat { + ret = pthread_mutex_trylock(mutex) + } while ret == EBUSY + return ret +} +#endif + final class WebWorkerTaskExecutorTests: XCTestCase { override func setUp() async { WebWorkerTaskExecutor.installGlobalExecutor() diff --git a/Tests/prelude.mjs b/Tests/prelude.mjs new file mode 100644 index 000000000..53073a850 --- /dev/null +++ b/Tests/prelude.mjs @@ -0,0 +1,12 @@ +/** @type {import('./../.build/plugins/PackageToJS/outputs/PackageTests/test.d.ts').Prelude["setupOptions"]} */ +export function setupOptions(options, context) { + return { + ...options, + addToCoreImports(importObject) { + options.addToCoreImports?.(importObject); + importObject["JavaScriptEventLoopTestSupportTests"] = { + "isMainThread": () => context.isMainThread, + } + } + } +} diff --git a/scripts/test-harness.mjs b/scripts/test-harness.mjs deleted file mode 100644 index 065d6d7da..000000000 --- a/scripts/test-harness.mjs +++ /dev/null @@ -1,17 +0,0 @@ -Error.stackTraceLimit = Infinity; - -import { startWasiTask } from "../IntegrationTests/lib.js"; - -if (process.env["JAVASCRIPTKIT_WASI_BACKEND"] === "MicroWASI") { - console.log("Skipping XCTest tests for MicroWASI because it is not supported yet."); - process.exit(0); -} - -const handleExitOrError = (error) => { - console.log(error); - process.exit(1); -} - -Error.stackTraceLimit = Infinity; - -startWasiTask(process.argv[2]).catch(handleExitOrError);