From e462ddc8a17613a0dbcb4677bc6cd8247e367080 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Sun, 5 Jul 2026 09:32:00 +0200 Subject: [PATCH 01/17] Compile BSWFoundation for WebAssembly (browser / JavaScriptKit) Second step of the WASM effort (after the swift-http-types adoption). The whole package now builds for wasm32 (swift build --swift-sdk ...wasm) and gains a browser network fetcher. - Package.swift: JavaScriptKit (+ JavaScriptEventLoop, JavaScriptFoundationCompat) and swift-log linked WASI-only; HTTPTypesFoundation restricted to the platforms where URLSession exists (its URLSession bridge doesn't compile for wasm). - APIClient+FetchFetcher.swift: FetchNetworkFetcher, an APIClientNetworkFetcher backed by the browser fetch API via JavaScriptKit, wired as the default fetcher on wasm (replacing the fatalError stub). - Logging: OSLog is Apple-only; on wasm a small Logger/OSLogType shim backed by swift-log keeps the existing OSLog-shaped call sites compiling unchanged. - Deferred off wasm for now (follow-up PR): KeychainBacked/UserDefaultsBacked (need localStorage backing) and Observable.stream(for:) (needs a DispatchQueue-free, concurrency-correct reschedule). Platform detection uses `#if os(WASI)` (per swift.org's WASM guide). Router and JSONParser needed no changes; this SDK's Foundation provides JSONSerialization/CharacterSet/date formatters. Apple/Android builds unchanged (guards are additive; Apple build + RouterTests verified green). Compile-verified only; browser runtime validation (a real fetch) is a follow-up via a JavaScriptKit harness. Co-Authored-By: Claude Opus 4.8 --- Package.resolved | 29 ++++++- Package.swift | 34 ++++++++- .../APIClient/APIClient+FetchFetcher.swift | 76 +++++++++++++++++++ .../APIClient/APIClient+Logging.swift | 2 +- .../APIClient/APIClient+URLSession.swift | 8 +- .../Extensions/Logger+WASM.swift | 35 +++++++++ .../Extensions/Observation+Ext.swift | 21 +++-- .../Parse/FailableCodableArray.swift | 2 +- Sources/BSWFoundation/Parse/JSONParser.swift | 2 +- .../PropertyWrappers/KeychainBacked.swift | 7 +- .../PropertyWrappers/UserDefaultsBacked.swift | 3 +- 11 files changed, 198 insertions(+), 21 deletions(-) create mode 100644 Sources/BSWFoundation/APIClient/APIClient+FetchFetcher.swift create mode 100644 Sources/BSWFoundation/Extensions/Logger+WASM.swift diff --git a/Package.resolved b/Package.resolved index d684186..6e9ff80 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "853b5e05ad3a6efd70b566d40499cfe0ffe0bf6b722d3a464a158a209c5edcb0", + "originHash" : "3f97b665206f0b87706561889f040ee4130a6b65a988922e3fd6df8ec8238ab4", "pins" : [ + { + "identity" : "javascriptkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftwasm/JavaScriptKit.git", + "state" : { + "revision" : "22905075f8b61834810babe5fb9a2f613f22f398", + "version" : "0.56.1" + } + }, { "identity" : "keychainaccess", "kind" : "remoteSourceControl", @@ -36,6 +45,24 @@ "revision" : "db774a277f60063a32d854f2980299caf06da041", "version" : "1.6.0" } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a", + "version" : "1.14.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" + } } ], "version" : 3 diff --git a/Package.swift b/Package.swift index 5b8d30b..96ab88b 100644 --- a/Package.swift +++ b/Package.swift @@ -17,10 +17,28 @@ let applePlatforms = TargetDependencyCondition.when( ] ) +// Platforms where URLSession / FoundationNetworking exist. Excludes WASM (WASI), where +// HTTPTypesFoundation's URLSession bridge does not compile. +let foundationNetworkingPlatforms = TargetDependencyCondition.when( + platforms: [ + .iOS, + .macOS, + .macCatalyst, + .tvOS, + .watchOS, + .visionOS, + .linux, + .android, + .windows + ] +) + var packageDependencies: [Package.Dependency] = [ .package(url: "https://github.com/kishikawakatsumi/KeychainAccess.git", from: "4.2.2"), .package(url: "https://github.com/apple/swift-crypto.git", from: "3.12.3"), .package(url: "https://github.com/apple/swift-http-types.git", from: "1.6.0"), + .package(url: "https://github.com/swiftwasm/JavaScriptKit.git", from: "0.56.1"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.5.0"), ] if skipIsEnabled { @@ -35,9 +53,19 @@ var targetDependencies: [Target.Dependency] = [ .product(name: "KeychainAccess", package: "KeychainAccess", condition: applePlatforms), // `HTTPTypes` is pure Swift (no Foundation) and links on every platform, including WASM. .product(name: "HTTPTypes", package: "swift-http-types"), - // `HTTPTypesFoundation` bridges to URLSession/URLRequest. Its API compiles to nothing on - // WASI, so linking it everywhere is harmless; we only `import` it from the URLSession fetcher. - .product(name: "HTTPTypesFoundation", package: "swift-http-types"), + // `HTTPTypesFoundation` bridges to URLSession/URLRequest. Its URLSession extensions do NOT + // compile for wasm, and we only `import` it from the (Darwin/FoundationNetworking-guarded) + // URLSession fetcher, so link it only on platforms where URLSession exists. + .product(name: "HTTPTypesFoundation", package: "swift-http-types", condition: foundationNetworkingPlatforms), + // In the browser, network I/O goes through the JS `fetch` API via JavaScriptKit. These + // products only link on WASM (WASI); every other platform ignores them. + .product(name: "JavaScriptKit", package: "JavaScriptKit", condition: .when(platforms: [.wasi])), + .product(name: "JavaScriptEventLoop", package: "JavaScriptKit", condition: .when(platforms: [.wasi])), + // Data <-> Uint8Array bridging for the fetch fetcher's request/response bodies. + .product(name: "JavaScriptFoundationCompat", package: "JavaScriptKit", condition: .when(platforms: [.wasi])), + // swift-log backs the wasm logging shim (OSLog is Apple-only, AndroidLogging is Android-only). + // WASM-scoped so Apple/Android logging is unchanged. + .product(name: "Logging", package: "swift-log", condition: .when(platforms: [.wasi])), ] if skipIsEnabled { diff --git a/Sources/BSWFoundation/APIClient/APIClient+FetchFetcher.swift b/Sources/BSWFoundation/APIClient/APIClient+FetchFetcher.swift new file mode 100644 index 0000000..de15bdf --- /dev/null +++ b/Sources/BSWFoundation/APIClient/APIClient+FetchFetcher.swift @@ -0,0 +1,76 @@ +// +// Browser networking for BSWFoundation on WebAssembly. +// + +#if os(WASI) +import Foundation +import HTTPTypes +import JavaScriptKit +import JavaScriptEventLoop +import JavaScriptFoundationCompat + +/// An ``APIClientNetworkFetcher`` backed by the browser's `fetch` API via JavaScriptKit. +/// +/// This is the default fetcher on WebAssembly, where `URLSession` is unavailable. +/// +/// - Important: The host application must install the JavaScriptKit event-loop executor once at +/// startup — `JavaScriptEventLoop.installGlobalExecutor()` — otherwise `async`/`await` (and +/// therefore this fetcher) will not run. +public struct FetchNetworkFetcher: APIClientNetworkFetcher { + + public init() {} + + public func perform(_ request: APIClient.OutboundRequest) async throws -> APIClient.Response { + // Uploading a file by path is meaningless in the browser sandbox. + guard request.fileToUpload == nil else { + throw APIClient.Error.encodingRequestFailed + } + guard let url = request.httpRequest.url else { + throw APIClient.Error.malformedURL + } + guard let fetch = JSObject.global.fetch.object else { + throw APIClient.Error.malformedResponse + } + + // Build the `fetch` options object: { method, headers, body }. + let options = JSObject() + options["method"] = .string(request.httpRequest.method.rawValue) + + let headers = JSObject() + for field in request.httpRequest.headerFields { + headers[field.name.rawName] = .string(field.value) + } + options["headers"] = headers.jsValue + + if let body = request.body { + options["body"] = body.jsValue // Data -> Uint8Array (JavaScriptFoundationCompat) + } + + // fetch(url, options) -> Promise + let responseValue = try await awaitPromise(fetch(url.absoluteString, options)) + guard let response = responseValue.object else { + throw APIClient.Error.malformedResponse + } + + let statusCode = Int(response.status.number ?? 0) + + // response.arrayBuffer() -> Promise; wrap in Uint8Array and copy to Data. + let arrayBuffer = try await awaitPromise(response.arrayBuffer!()) + let bytes = JSObject.global.Uint8Array.function!.new(arrayBuffer) + let data = Data.construct(from: bytes.jsValue) ?? Data() + + // NOTE: response header fields are not yet surfaced (see follow-up); the status code is + // what `APIClient` validates and is sufficient for the request pipeline. + let httpResponse = HTTPResponse(status: .init(code: statusCode)) + return APIClient.Response(data: data, httpResponse: httpResponse) + } + + /// Awaits a `JSValue` that is expected to wrap a JavaScript `Promise`. + private func awaitPromise(_ value: JSValue) async throws -> JSValue { + guard let object = value.object else { + throw APIClient.Error.malformedResponse + } + return try await JSPromise(unsafelyWrapping: object).value + } +} +#endif diff --git a/Sources/BSWFoundation/APIClient/APIClient+Logging.swift b/Sources/BSWFoundation/APIClient/APIClient+Logging.swift index 0cb24ae..8c4bcce 100644 --- a/Sources/BSWFoundation/APIClient/APIClient+Logging.swift +++ b/Sources/BSWFoundation/APIClient/APIClient+Logging.swift @@ -2,7 +2,7 @@ import Foundation import HTTPTypes #if os(Android) import AndroidLogging -#else +#elseif !os(WASI) import OSLog #endif diff --git a/Sources/BSWFoundation/APIClient/APIClient+URLSession.swift b/Sources/BSWFoundation/APIClient/APIClient+URLSession.swift index 80608e0..4ba261f 100644 --- a/Sources/BSWFoundation/APIClient/APIClient+URLSession.swift +++ b/Sources/BSWFoundation/APIClient/APIClient+URLSession.swift @@ -139,10 +139,14 @@ private extension APIClient { extension APIClient { - /// No `URLSession` is available on this platform, so an `APIClientNetworkFetcher` must be - /// supplied explicitly to `APIClient(environment:networkFetcher:)`. static func makeDefaultNetworkFetcher(environment: Environment) -> APIClientNetworkFetcher { + #if os(WASI) + // In the browser, network I/O goes through the JS `fetch` API. + return FetchNetworkFetcher() + #else + // No `URLSession` and no known browser: require an explicit fetcher. fatalError("BSWFoundation: no default APIClientNetworkFetcher is available on this platform. Pass one explicitly to APIClient(environment:networkFetcher:).") + #endif } } diff --git a/Sources/BSWFoundation/Extensions/Logger+WASM.swift b/Sources/BSWFoundation/Extensions/Logger+WASM.swift new file mode 100644 index 0000000..af1588a --- /dev/null +++ b/Sources/BSWFoundation/Extensions/Logger+WASM.swift @@ -0,0 +1,35 @@ +#if os(WASI) +import Logging + +/// Minimal `OSLog`-compatible logging shim for WebAssembly, where `OSLog` is unavailable. +/// It keeps the OSLog-shaped call sites (`Logger(subsystem:category:)`, `OSLogType`) compiling +/// unchanged while routing output through swift-log, so a custom `LogHandler` (e.g. one that +/// forwards to `console.debug/warn/error` via JavaScriptKit) can be installed by the host app. +struct Logger { + private let backing: Logging.Logger + + init(subsystem: String, category: String) { + self.backing = Logging.Logger(label: "\(subsystem).\(category)") + } + + func debug(_ message: String) { backing.debug("\(message)") } + func info(_ message: String) { backing.info("\(message)") } + func warning(_ message: String) { backing.warning("\(message)") } + func error(_ message: String) { backing.error("\(message)") } + func log(level: OSLogType, _ message: String) { backing.log(level: level.swiftLogLevel, "\(message)") } +} + +/// `OSLogType` stand-in so the shared logging code compiles on wasm. +enum OSLogType { + case debug, info, error, `default` + + var swiftLogLevel: Logging.Logger.Level { + switch self { + case .debug: return .debug + case .info: return .info + case .error: return .error + case .default: return .notice + } + } +} +#endif diff --git a/Sources/BSWFoundation/Extensions/Observation+Ext.swift b/Sources/BSWFoundation/Extensions/Observation+Ext.swift index b22a7da..e34fc6b 100644 --- a/Sources/BSWFoundation/Extensions/Observation+Ext.swift +++ b/Sources/BSWFoundation/Extensions/Observation+Ext.swift @@ -1,14 +1,18 @@ import Foundation import Observation +#if !os(WASI) +// `stream(for:)` re-arms observation tracking via `DispatchQueue.main.async`, which is +// unavailable on wasm (single-threaded). A concurrency-correct wasm reschedule (via the +// JavaScriptKit event loop) is a follow-up; the helper is excluded on wasm for now. extension Observable where Self: AnyObject & Sendable { - + public func stream( for keyPath: KeyPath ) -> AsyncStream { let box = ObservationBox() nonisolated(unsafe) let keyPath = keyPath - + return AsyncStream(Value.self) { continuation in @Sendable func track(object: Self) { Observation.withObservationTracking { [weak object] in @@ -22,16 +26,21 @@ extension Observable where Self: AnyObject & Sendable { } } } - + continuation.onTermination = { _ in box.isCancelled = true } - + track(object: self) } } } +private final class ObservationBox: @unchecked Sendable { + var isCancelled = false +} +#endif + extension AsyncStream where Element: Equatable { public func until(_ e: Element) async { var iterator = self.makeAsyncIterator() @@ -42,7 +51,3 @@ extension AsyncStream where Element: Equatable { } } } - -private final class ObservationBox: @unchecked Sendable { - var isCancelled = false -} diff --git a/Sources/BSWFoundation/Parse/FailableCodableArray.swift b/Sources/BSWFoundation/Parse/FailableCodableArray.swift index deaa5c7..3e20b19 100644 --- a/Sources/BSWFoundation/Parse/FailableCodableArray.swift +++ b/Sources/BSWFoundation/Parse/FailableCodableArray.swift @@ -5,7 +5,7 @@ import Foundation #if os(Android) import AndroidLogging -#else +#elseif !os(WASI) import OSLog #endif diff --git a/Sources/BSWFoundation/Parse/JSONParser.swift b/Sources/BSWFoundation/Parse/JSONParser.swift index 08641db..dc452f4 100644 --- a/Sources/BSWFoundation/Parse/JSONParser.swift +++ b/Sources/BSWFoundation/Parse/JSONParser.swift @@ -6,7 +6,7 @@ import Foundation #if os(Android) import AndroidLogging -#else +#elseif !os(WASI) import OSLog #endif diff --git a/Sources/BSWFoundation/PropertyWrappers/KeychainBacked.swift b/Sources/BSWFoundation/PropertyWrappers/KeychainBacked.swift index 0cd41cf..6efd26a 100644 --- a/Sources/BSWFoundation/PropertyWrappers/KeychainBacked.swift +++ b/Sources/BSWFoundation/PropertyWrappers/KeychainBacked.swift @@ -5,12 +5,13 @@ import Foundation #if os(Android) import SkipKeychain -#else +#elseif !os(WASI) import KeychainAccess #endif -/// This is supported anywhere but Linux -#if !os(Linux) +/// This is supported anywhere but Linux. +/// WASM support (localStorage-backed) is added in a follow-up; excluded here for now. +#if !os(Linux) && !os(WASI) /// Stores a String on the Keychain @propertyWrapper diff --git a/Sources/BSWFoundation/PropertyWrappers/UserDefaultsBacked.swift b/Sources/BSWFoundation/PropertyWrappers/UserDefaultsBacked.swift index 6927e75..2f0577d 100644 --- a/Sources/BSWFoundation/PropertyWrappers/UserDefaultsBacked.swift +++ b/Sources/BSWFoundation/PropertyWrappers/UserDefaultsBacked.swift @@ -9,7 +9,8 @@ import SkipFuse import SkipAndroidBridge #endif -#if !os(Linux) +// WASM support (localStorage-backed) is added in a follow-up; excluded here for now. +#if !os(Linux) && !os(WASI) /// Stores the given `T` type on User Defaults. /// /// The value parameter can be only property list objects: `NSData`, `NSString`, `NSNumber`, `NSDate`, `NSArray`, or `NSDictionary`. From e39675a1871c08985b8ff9407b59703ee1843308 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Sun, 5 Jul 2026 10:04:52 +0200 Subject: [PATCH 02/17] Unify logging into a public BSWLogger facade Replace the per-file `#if os(Android) import AndroidLogging #elseif !os(WASI) import OSLog #endif` dance (which relied on every platform happening to vend a `Logger`/`OSLogType` symbol) with a single, owned, public `BSWLogger`. It forwards to each platform's native backend behind one set of guards: - Apple: OSLog (Console.app / unified logging) - Android: AndroidLogging (logcat) - WebAssembly: swift-log (browser console via a LogHandler) - other: print `APIClient+Logging`, `JSONParser` and `FailableCodableArray` now use `BSWLogger` and no longer import logging modules directly, and apps built on BSWFoundation get one consistent logging surface. Apple + wasm builds and RouterTests are green; the Android branch uses only the AndroidLogging API the code already exercised. Co-Authored-By: Claude Opus 4.8 --- .../APIClient/APIClient+Logging.swift | 13 +-- .../BSWFoundation/Extensions/BSWLogger.swift | 96 +++++++++++++++++++ .../Extensions/Logger+WASM.swift | 35 ------- .../Parse/FailableCodableArray.swift | 8 +- Sources/BSWFoundation/Parse/JSONParser.swift | 7 +- 5 files changed, 102 insertions(+), 57 deletions(-) create mode 100644 Sources/BSWFoundation/Extensions/BSWLogger.swift delete mode 100644 Sources/BSWFoundation/Extensions/Logger+WASM.swift diff --git a/Sources/BSWFoundation/APIClient/APIClient+Logging.swift b/Sources/BSWFoundation/APIClient/APIClient+Logging.swift index 8c4bcce..632afdf 100644 --- a/Sources/BSWFoundation/APIClient/APIClient+Logging.swift +++ b/Sources/BSWFoundation/APIClient/APIClient+Logging.swift @@ -1,10 +1,5 @@ import Foundation import HTTPTypes -#if os(Android) -import AndroidLogging -#elseif !os(WASI) -import OSLog -#endif //MARK: Logging @@ -14,7 +9,7 @@ extension APIClient { guard loggingConfiguration.requestBehaviour == .all else { return } - let logger = Logger(subsystem: submoduleName("APIClient"), category: "APIClient.Request") + let logger = BSWLogger(subsystem: submoduleName("APIClient"), category: "APIClient.Request") let httpMethod = request.httpRequest.method.rawValue let path = request.httpRequest.path ?? "" logger.debug("Sending Request → \(httpMethod) \(path)") @@ -24,7 +19,7 @@ extension APIClient { } func logResponse(_ response: Response, forPath path: String) { - let logger = Logger(subsystem: submoduleName("APIClient"), category: "APIClient.Response") + let logger = BSWLogger(subsystem: submoduleName("APIClient"), category: "APIClient.Response") let statusCode = response.httpResponse.status.code let isError = !(200..<300).contains(statusCode) let shouldLogThis: Bool = { @@ -38,7 +33,7 @@ extension APIClient { } }() guard shouldLogThis else { return } - let logType: OSLogType = isError ? .error : .debug + let logType: BSWLogger.Level = isError ? .error : .debug logger.log(level: logType, "Receiving Response → Path: \(path) HTTPStatusCode: \(statusCode) ") if isError, let errorString = String(data: response.data, encoding: .utf8), !errorString.isEmpty { logger.log(level: logType, "Error Message: \(errorString)") @@ -49,7 +44,7 @@ extension APIClient { guard loggingConfiguration.responseBehaviour != .none else { return } - let logger = Logger(subsystem: submoduleName("APIClient"), category: "APIClient.Network") + let logger = BSWLogger(subsystem: submoduleName("APIClient"), category: "APIClient.Network") let httpMethod = request.httpRequest.method.rawValue let path = request.httpRequest.path ?? "" logger.error("Error Received for Request → \(httpMethod) \(path). Error: \(networkError)") diff --git a/Sources/BSWFoundation/Extensions/BSWLogger.swift b/Sources/BSWFoundation/Extensions/BSWLogger.swift new file mode 100644 index 0000000..e8114d0 --- /dev/null +++ b/Sources/BSWFoundation/Extensions/BSWLogger.swift @@ -0,0 +1,96 @@ +// +// A small, unified logging facade for BSWFoundation and its consumers. +// + +#if canImport(OSLog) +import OSLog +#elseif os(Android) +import AndroidLogging +#elseif os(WASI) +import Logging +#endif + +/// A lightweight, cross-platform logging facade used throughout BSWFoundation. +/// +/// It forwards to the platform's native logging backend, so output lands where you expect: +/// - **Apple** platforms: `OSLog` (the unified logging system / Console.app) +/// - **Android**: `AndroidLogging` (logcat) +/// - **WebAssembly**: `swift-log` (its installed `LogHandler`, e.g. the browser console) +/// - **Other** platforms: `print` +/// +/// The API intentionally mirrors `OSLog.Logger` (`init(subsystem:category:)`), so it is a drop-in +/// across the ecosystem and gives every app built on BSWFoundation a single, consistent logger. +public struct BSWLogger: Sendable { + + /// The severity of a log message. + public enum Level: Sendable { + case debug, info, warning, error + } + + #if canImport(OSLog) || os(Android) || os(WASI) + private let backing: Logger + #else + private let label: String + #endif + + /// Creates a logger for the given subsystem and category. + public init(subsystem: String, category: String) { + #if canImport(OSLog) || os(Android) + self.backing = Logger(subsystem: subsystem, category: category) + #elseif os(WASI) + self.backing = Logger(label: "\(subsystem).\(category)") + #else + self.label = "\(subsystem).\(category)" + #endif + } + + public func debug(_ message: @autoclosure () -> String) { log(level: .debug, message()) } + public func info(_ message: @autoclosure () -> String) { log(level: .info, message()) } + public func warning(_ message: @autoclosure () -> String) { log(level: .warning, message()) } + public func error(_ message: @autoclosure () -> String) { log(level: .error, message()) } + + public func log(level: Level, _ message: @autoclosure () -> String) { + let text = message() + #if canImport(OSLog) || os(Android) + backing.log(level: level.osLogType, "\(text)") + #elseif os(WASI) + backing.log(level: level.loggingLevel, "\(text)") + #else + print("[\(label)] [\(level)] \(text)") + #endif + } +} + +#if canImport(OSLog) +private extension BSWLogger.Level { + var osLogType: OSLogType { + switch self { + case .debug: return .debug + case .info: return .info + case .warning: return .default + case .error: return .error + } + } +} +#elseif os(Android) +private extension BSWLogger.Level { + // `AndroidLogging` mirrors `OSLog`; map onto the OSLogType cases the codebase already exercises. + var osLogType: OSLogType { + switch self { + case .debug, .info: return .debug + case .warning, .error: return .error + } + } +} +#elseif os(WASI) +private extension BSWLogger.Level { + var loggingLevel: Logging.Logger.Level { + switch self { + case .debug: return .debug + case .info: return .info + case .warning: return .warning + case .error: return .error + } + } +} +#endif diff --git a/Sources/BSWFoundation/Extensions/Logger+WASM.swift b/Sources/BSWFoundation/Extensions/Logger+WASM.swift deleted file mode 100644 index af1588a..0000000 --- a/Sources/BSWFoundation/Extensions/Logger+WASM.swift +++ /dev/null @@ -1,35 +0,0 @@ -#if os(WASI) -import Logging - -/// Minimal `OSLog`-compatible logging shim for WebAssembly, where `OSLog` is unavailable. -/// It keeps the OSLog-shaped call sites (`Logger(subsystem:category:)`, `OSLogType`) compiling -/// unchanged while routing output through swift-log, so a custom `LogHandler` (e.g. one that -/// forwards to `console.debug/warn/error` via JavaScriptKit) can be installed by the host app. -struct Logger { - private let backing: Logging.Logger - - init(subsystem: String, category: String) { - self.backing = Logging.Logger(label: "\(subsystem).\(category)") - } - - func debug(_ message: String) { backing.debug("\(message)") } - func info(_ message: String) { backing.info("\(message)") } - func warning(_ message: String) { backing.warning("\(message)") } - func error(_ message: String) { backing.error("\(message)") } - func log(level: OSLogType, _ message: String) { backing.log(level: level.swiftLogLevel, "\(message)") } -} - -/// `OSLogType` stand-in so the shared logging code compiles on wasm. -enum OSLogType { - case debug, info, error, `default` - - var swiftLogLevel: Logging.Logger.Level { - switch self { - case .debug: return .debug - case .info: return .info - case .error: return .error - case .default: return .notice - } - } -} -#endif diff --git a/Sources/BSWFoundation/Parse/FailableCodableArray.swift b/Sources/BSWFoundation/Parse/FailableCodableArray.swift index 3e20b19..7caad9a 100644 --- a/Sources/BSWFoundation/Parse/FailableCodableArray.swift +++ b/Sources/BSWFoundation/Parse/FailableCodableArray.swift @@ -3,12 +3,6 @@ import FoundationInternationalization #endif import Foundation -#if os(Android) -import AndroidLogging -#elseif !os(WASI) -import OSLog -#endif - public struct FailableCodableArray : Decodable { public let elements: [Element] @@ -38,7 +32,7 @@ private struct FailableDecodable : Decodable { do { return try container.decode(Base.self) } catch let error { - let logger = Logger(subsystem: submoduleName("FailableDecodable"), category: "error") + let logger = BSWLogger(subsystem: submoduleName("FailableDecodable"), category: "error") logger.warning("Error decoding \(Base.self): \(error)") return nil } diff --git a/Sources/BSWFoundation/Parse/JSONParser.swift b/Sources/BSWFoundation/Parse/JSONParser.swift index dc452f4..f9e34e5 100644 --- a/Sources/BSWFoundation/Parse/JSONParser.swift +++ b/Sources/BSWFoundation/Parse/JSONParser.swift @@ -4,11 +4,6 @@ // import Foundation -#if os(Android) -import AndroidLogging -#elseif !os(WASI) -import OSLog -#endif public enum JSONParser { @@ -80,7 +75,7 @@ public enum JSONParser { do { return try jsonDecoder.decode(T.self, from: data) } catch let decodingError as DecodingError { - let logger = Logger(subsystem: submoduleName("JSONParser"), category: "parseData<\(T.self)>") + let logger = BSWLogger(subsystem: submoduleName("JSONParser"), category: "parseData<\(T.self)>") switch decodingError { case .keyNotFound(let missingKey, let context): logger.warning("Decoding error: key \(String(describing: missingKey)) is missing, Context: \(context.debugDescription)") From 732cbb4b26f9dc0af3e8c7c6373aff5ac8504757 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Sun, 5 Jul 2026 10:16:27 +0200 Subject: [PATCH 03/17] Add a GitHub-hosted WebAssembly CI compile gate The self-hosted `mobile` runners don't have the WebAssembly Swift SDK, so the new wasm job runs on ubuntu-latest using the official `swift:6.3.3` image as the toolchain, installs the matching wasm Swift SDK, and compiles the package with `swift build --swift-sdk swift-6.3.3-RELEASE_wasm`. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/swift.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index cf5431b..bc259b6 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -59,3 +59,19 @@ jobs: fi - name: Run tests on Android run: SKIP_ENABLED=1 skip android test + + wasm-build: + # GitHub-hosted: the self-hosted `mobile` runners don't have the WebAssembly Swift SDK. + # The official Swift image provides the toolchain; we then install the matching wasm SDK + # and compile the package for WebAssembly (browser / JavaScriptKit target). + runs-on: ubuntu-latest + container: swift:6.3.3 + steps: + - uses: actions/checkout@v4 + - name: Install Swift SDK for WebAssembly + run: | + swift sdk install \ + https://download.swift.org/swift-6.3.3-release/wasm-sdk/swift-6.3.3-RELEASE/swift-6.3.3-RELEASE_wasm.artifactbundle.tar.gz \ + --checksum cabfa08b73bb8ac783927ecd15fa386e99d0c139c5f232445067bcf58379cae7 + - name: Build for WebAssembly + run: swift build --swift-sdk swift-6.3.3-RELEASE_wasm From a681317d3a0485de4e5495271fd54e95911f2389 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Sun, 5 Jul 2026 10:30:09 +0200 Subject: [PATCH 04/17] Add localStorage-backed storage for the property wrappers on wasm KeychainBacked / CodableKeychainBacked / UserDefaultsBacked / CodableUserDefaultsBacked now work on WebAssembly (previously excluded), backed by a new WASMKeyValueStore that wraps window.localStorage via JavaScriptKit. - New WASMKeyValueStore (#if os(WASI)): string/data get + set (nil removes), no-ops gracefully if localStorage is unavailable. - The property wrappers gain #if os(WASI) branches and are now included on wasm (guard relaxed from `!os(Linux) && !os(WASI)` to `!os(Linux)`); the KeychainAccess import is tightened to `canImport(Darwin)`. Documented warning: localStorage is NOT secure, so KeychainBacked values are not encrypted at rest on wasm. AuthStorage stays Apple-only for now (it isn't cross-platform even on Android); porting it is a separate follow-up. Verified green on Apple (build + UserDefaultsBackedTests), wasm (swift build --swift-sdk), and Android (SKIP_ENABLED=1 skip android build). Co-Authored-By: Claude Opus 4.8 --- Package.resolved | 92 ++++++++++++++++++- .../PropertyWrappers/KeychainBacked.swift | 44 +++++++-- .../PropertyWrappers/UserDefaultsBacked.swift | 51 ++++++++-- .../PropertyWrappers/WASMKeyValueStore.swift | 53 +++++++++++ 4 files changed, 222 insertions(+), 18 deletions(-) create mode 100644 Sources/BSWFoundation/PropertyWrappers/WASMKeyValueStore.swift diff --git a/Package.resolved b/Package.resolved index 6e9ff80..80d48d8 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "3f97b665206f0b87706561889f040ee4130a6b65a988922e3fd6df8ec8238ab4", + "originHash" : "e4910ec516df70c8fd1f29b2973d750155d642d0fad11d9dd3d0c2e2b9059c61", "pins" : [ { "identity" : "javascriptkit", @@ -19,6 +19,87 @@ "version" : "4.2.2" } }, + { + "identity" : "skip", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip.git", + "state" : { + "revision" : "0a57ed3d92ae6025b59ba4a25a86570c4f6094ce", + "version" : "1.9.4" + } + }, + { + "identity" : "skip-android-bridge", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-android-bridge.git", + "state" : { + "revision" : "e9c30eb0f0278e8407435ef6fed50c362a3279c2", + "version" : "0.6.3" + } + }, + { + "identity" : "skip-bridge", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-bridge.git", + "state" : { + "revision" : "72b7b1d4734332cfdc4b519539b5beec0fb3ac00", + "version" : "0.17.2" + } + }, + { + "identity" : "skip-foundation", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-foundation.git", + "state" : { + "revision" : "1854caad39cc2c9ce18e4d6765c79f026059e67e", + "version" : "1.4.1" + } + }, + { + "identity" : "skip-fuse", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-fuse.git", + "state" : { + "revision" : "8f3295094ad29075730284c5197c7f1d94c0f2d9", + "version" : "1.0.2" + } + }, + { + "identity" : "skip-keychain", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-keychain.git", + "state" : { + "revision" : "def9e33d25a5a6c92a4e6ae029d7a2ed3ecdd9d2", + "version" : "0.3.2" + } + }, + { + "identity" : "skip-lib", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-lib.git", + "state" : { + "revision" : "76e7da8a870b5b66ea0c3264f648b58b73bcdc0d", + "version" : "1.4.0" + } + }, + { + "identity" : "skip-unit", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/skip-unit.git", + "state" : { + "revision" : "c89af47fd645e04db863e938ade39f91e1bb62b8", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-android-native", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/swift-android-native.git", + "state" : { + "revision" : "7e6e833e6f163a2b75340f75c70b1d96ea6b8135", + "version" : "1.5.1" + } + }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", @@ -46,6 +127,15 @@ "version" : "1.6.0" } }, + { + "identity" : "swift-jni", + "kind" : "remoteSourceControl", + "location" : "https://source.skip.tools/swift-jni.git", + "state" : { + "revision" : "fe76ac21aca639976833b5ea3e875dc072519ac4", + "version" : "0.5.0" + } + }, { "identity" : "swift-log", "kind" : "remoteSourceControl", diff --git a/Sources/BSWFoundation/PropertyWrappers/KeychainBacked.swift b/Sources/BSWFoundation/PropertyWrappers/KeychainBacked.swift index 6efd26a..e520751 100644 --- a/Sources/BSWFoundation/PropertyWrappers/KeychainBacked.swift +++ b/Sources/BSWFoundation/PropertyWrappers/KeychainBacked.swift @@ -5,19 +5,23 @@ import Foundation #if os(Android) import SkipKeychain -#elseif !os(WASI) +#elseif canImport(Darwin) import KeychainAccess #endif -/// This is supported anywhere but Linux. -/// WASM support (localStorage-backed) is added in a follow-up; excluded here for now. -#if !os(Linux) && !os(WASI) +/// Supported everywhere except Linux. On WebAssembly it is backed by `localStorage` +/// via `WASMKeyValueStore` — which is **not** secure storage. +#if !os(Linux) -/// Stores a String on the Keychain +/// Stores a String on the Keychain (or `localStorage` on wasm). @propertyWrapper public class KeychainBacked { private let key: String + #if os(WASI) + private let store = WASMKeyValueStore.shared + #else private let keychain: Keychain + #endif public init(key: String, appGroupID: String? = nil) { self.key = key @@ -29,11 +33,11 @@ public class KeychainBacked { return Keychain(service: Bundle.main.bundleIdentifier!) } }() - #else + #elseif os(Android) self.keychain = Keychain.shared #endif } - + #if canImport(Darwin) public var wrappedValue: String? { get { @@ -42,6 +46,14 @@ public class KeychainBacked { keychain[key] = newValue } } + #elseif os(WASI) + public var wrappedValue: String? { + get { + return store.string(forKey: key) + } set { + store.set(newValue, forKey: key) + } + } #else public var wrappedValue: String? { get { @@ -63,21 +75,25 @@ public extension KeychainBacked { } } -/// Stores the given `T` type on the Keychain (as long as it's `Codable`) +/// Stores the given `T` type on the Keychain (or `localStorage` on wasm), as long as it's `Codable`. @propertyWrapper public class CodableKeychainBacked { private let key: String + #if os(WASI) + private let store = WASMKeyValueStore.shared + #else private let keychain: Keychain + #endif public init(key: String) { self.key = key #if canImport(Darwin) self.keychain = Keychain(service: Bundle.main.bundleIdentifier!) - #else + #elseif os(Android) self.keychain = Keychain.shared #endif } - + #if canImport(Darwin) public var wrappedValue: T? { get { @@ -86,6 +102,14 @@ public class CodableKeychainBacked { keychain[key] = newValue.encodedAsString() } } + #elseif os(WASI) + public var wrappedValue: T? { + get { + return store.string(forKey: key)?.decoded() + } set { + store.set(newValue.encodedAsString(), forKey: key) + } + } #else public var wrappedValue: T? { get { diff --git a/Sources/BSWFoundation/PropertyWrappers/UserDefaultsBacked.swift b/Sources/BSWFoundation/PropertyWrappers/UserDefaultsBacked.swift index 2f0577d..ca7cf20 100644 --- a/Sources/BSWFoundation/PropertyWrappers/UserDefaultsBacked.swift +++ b/Sources/BSWFoundation/PropertyWrappers/UserDefaultsBacked.swift @@ -9,8 +9,9 @@ import SkipFuse import SkipAndroidBridge #endif -// WASM support (localStorage-backed) is added in a follow-up; excluded here for now. -#if !os(Linux) && !os(WASI) +/// Supported everywhere except Linux. On WebAssembly it is backed by `localStorage` +/// via `WASMKeyValueStore`. +#if !os(Linux) /// Stores the given `T` type on User Defaults. /// /// The value parameter can be only property list objects: `NSData`, `NSString`, `NSNumber`, `NSDate`, `NSArray`, or `NSDictionary`. @@ -18,8 +19,12 @@ import SkipAndroidBridge public final class UserDefaultsBacked: Sendable { private let key: String private let defaultValue: T? + #if os(WASI) + private let store = WASMKeyValueStore.shared + #else private nonisolated(unsafe) let store: UserDefaults - + #endif + public init(key: String, defaultValue: T? = nil, appGroupID: String? = nil) { self.key = key self.defaultValue = defaultValue @@ -31,11 +36,11 @@ public final class UserDefaultsBacked: Sendable { return UserDefaults.standard } }() - #else + #elseif os(Android) self.store = SkipAndroidBridge.AndroidUserDefaults.standard #endif } - + public var wrappedValue: T? { get { #if canImport(Darwin) @@ -43,6 +48,14 @@ public final class UserDefaultsBacked: Sendable { return defaultValue } return value + #elseif os(WASI) + if T.self == Bool.self { + return (store.string(forKey: key).map { $0 == "true" } as? T) ?? defaultValue + } else if T.self == String.self { + return (store.string(forKey: key) as? T) ?? defaultValue + } else { + fatalError("Type not yet supported on WebAssembly") + } #else if T.self == Bool.self { return self.store.bool(forKey: key) as? T @@ -53,12 +66,26 @@ public final class UserDefaultsBacked: Sendable { } #endif } set { + #if os(WASI) + if let newValue { + if let bool = newValue as? Bool { + store.set(bool ? "true" : "false", forKey: key) + } else if let string = newValue as? String { + store.set(string, forKey: key) + } else { + fatalError("Type not yet supported on WebAssembly") + } + } else { + store.removeObject(forKey: key) + } + #else if newValue != nil { self.store.set(newValue, forKey: key) } else { self.store.removeObject(forKey: key) } _ = self.store.synchronize() + #endif } } } @@ -75,7 +102,11 @@ public extension UserDefaultsBacked { public final class CodableUserDefaultsBacked: Sendable { private let key: String private let defaultValue: T? + #if os(WASI) + private let store = WASMKeyValueStore.shared + #else private nonisolated(unsafe) let store: UserDefaults + #endif public init(key: String, defaultValue: T? = nil, appGroupID: String? = nil) { self.key = key @@ -88,11 +119,11 @@ public final class CodableUserDefaultsBacked: Sendable { return UserDefaults.standard } }() - #else + #elseif os(Android) self.store = SkipAndroidBridge.AndroidUserDefaults.standard #endif } - + public var wrappedValue: T? { get { guard let data = store.data(forKey: key) else { @@ -103,9 +134,15 @@ public final class CodableUserDefaultsBacked: Sendable { if let newValue, let data = try? JSONEncoder().encode(newValue) { store.set(data, forKey: key) } else { + #if os(WASI) + store.set(Data?.none, forKey: key) + #else store.set(nil, forKey: key) + #endif } + #if !os(WASI) _ = store.synchronize() + #endif } } } diff --git a/Sources/BSWFoundation/PropertyWrappers/WASMKeyValueStore.swift b/Sources/BSWFoundation/PropertyWrappers/WASMKeyValueStore.swift new file mode 100644 index 0000000..d1f71ea --- /dev/null +++ b/Sources/BSWFoundation/PropertyWrappers/WASMKeyValueStore.swift @@ -0,0 +1,53 @@ +// +// localStorage-backed key-value storage for WebAssembly. +// + +#if os(WASI) +import Foundation +import JavaScriptKit + +/// A `window.localStorage`-backed key-value store, used to implement the storage property +/// wrappers on WebAssembly, where there is neither a Keychain nor `UserDefaults`. +/// +/// - Warning: `localStorage` is plain-text, origin-scoped browser storage — it is **not** secure. +/// Values written through `KeychainBacked` are therefore not encrypted at rest on wasm. +public final class WASMKeyValueStore: @unchecked Sendable { + + public static let shared = WASMKeyValueStore() + + /// `nil` when `localStorage` is unavailable (e.g. a Worker context); the store then no-ops. + private let localStorage: JSObject? + + private init() { + localStorage = JSObject.global.localStorage.object + } + + public func string(forKey key: String) -> String? { + guard let localStorage else { return nil } + return localStorage.getItem!(key).string + } + + public func data(forKey key: String) -> Data? { + string(forKey: key)?.data(using: .utf8) + } + + /// Sets the string value, or removes the key when `value` is `nil`. + public func set(_ value: String?, forKey key: String) { + guard let localStorage else { return } + if let value { + _ = localStorage.setItem!(key, value) + } else { + _ = localStorage.removeItem!(key) + } + } + + /// Sets the data value (stored as its UTF-8 string), or removes the key when `value` is `nil`. + public func set(_ value: Data?, forKey key: String) { + set(value.flatMap { String(data: $0, encoding: .utf8) }, forKey: key) + } + + public func removeObject(forKey key: String) { + set(String?.none, forKey: key) + } +} +#endif From e34cc3b60696305324f314d6eace318f44014760 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Sun, 5 Jul 2026 10:52:11 +0200 Subject: [PATCH 05/17] Finish wasm PR 2: response headers + Observable.stream(for:) on wasm MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FetchNetworkFetcher now surfaces the response's HTTP header fields, read from the JS Headers object (Array.from → [[name, value], …]) into the returned APIClient.Response's HTTPResponse. - Observable.stream(for:) is no longer excluded on wasm. Reworked into a coordinator whose reschedule captures a Sendable `self` instead of a recursive local closure (which Swift 6 region-based isolation rejected as a `sending` data-race risk). It reschedules with a Task on wasm (the JavaScriptKit event loop) and DispatchQueue.main elsewhere. Verified green: Apple (build + ObservationTests), wasm (swift build --swift-sdk), and Android (SKIP_ENABLED=1 skip android build). Co-Authored-By: Claude Opus 4.8 --- .../APIClient/APIClient+FetchFetcher.swift | 26 ++++++- .../Extensions/Observation+Ext.swift | 78 ++++++++++++------- 2 files changed, 72 insertions(+), 32 deletions(-) diff --git a/Sources/BSWFoundation/APIClient/APIClient+FetchFetcher.swift b/Sources/BSWFoundation/APIClient/APIClient+FetchFetcher.swift index de15bdf..20e7384 100644 --- a/Sources/BSWFoundation/APIClient/APIClient+FetchFetcher.swift +++ b/Sources/BSWFoundation/APIClient/APIClient+FetchFetcher.swift @@ -59,9 +59,10 @@ public struct FetchNetworkFetcher: APIClientNetworkFetcher { let bytes = JSObject.global.Uint8Array.function!.new(arrayBuffer) let data = Data.construct(from: bytes.jsValue) ?? Data() - // NOTE: response header fields are not yet surfaced (see follow-up); the status code is - // what `APIClient` validates and is sufficient for the request pipeline. - let httpResponse = HTTPResponse(status: .init(code: statusCode)) + let httpResponse = HTTPResponse( + status: .init(code: statusCode), + headerFields: Self.headerFields(from: response) + ) return APIClient.Response(data: data, httpResponse: httpResponse) } @@ -72,5 +73,24 @@ public struct FetchNetworkFetcher: APIClientNetworkFetcher { } return try await JSPromise(unsafelyWrapping: object).value } + + /// Reads a JS `Response.headers` (`Headers` object) into `HTTPFields`. + /// `Array.from(headers)` yields `[[name, value], …]`, which we walk by index. + private static func headerFields(from response: JSObject) -> HTTPFields { + var fields = HTTPFields() + guard let arrayConstructor = JSObject.global.Array.object, + let entries = arrayConstructor.from!(response.headers).object else { + return fields + } + let count = Int(entries.length.number ?? 0) + for index in 0..( for keyPath: KeyPath ) -> AsyncStream { - let box = ObservationBox() - nonisolated(unsafe) let keyPath = keyPath - - return AsyncStream(Value.self) { continuation in - @Sendable func track(object: Self) { - Observation.withObservationTracking { [weak object] in - guard let self = object, !box.isCancelled else { return } - let value = self[keyPath: keyPath] - continuation.yield(value) - } onChange: { [weak object] in - DispatchQueue.main.async { - guard let self = object, !box.isCancelled else { return } - track(object: self) - } - } - } - - continuation.onTermination = { _ in - box.isCancelled = true - } - - track(object: self) + AsyncStream(Value.self) { continuation in + let coordinator = ObservationStreamCoordinator(object: self, keyPath: keyPath, continuation: continuation) + continuation.onTermination = { _ in coordinator.cancel() } + coordinator.start() } } } -private final class ObservationBox: @unchecked Sendable { - var isCancelled = false +/// Drives `withObservationTracking` for ``stream(for:)``, re-arming after each change *off the +/// current call stack* to avoid re-entrant registration. `DispatchQueue` is unavailable on wasm +/// (single-threaded), so there we reschedule with a `Task` (the JavaScriptKit event loop). +/// +/// It is a class so the reschedule closure captures a `Sendable` `self` rather than a recursive +/// local function (which Swift 6's region-based isolation rejects as a `sending` data-race risk). +private final class ObservationStreamCoordinator: @unchecked Sendable { + private weak var object: Root? + private let keyPath: KeyPath + private let continuation: AsyncStream.Continuation + private var isCancelled = false + + init(object: Root, keyPath: KeyPath, continuation: AsyncStream.Continuation) { + self.object = object + self.keyPath = keyPath + self.continuation = continuation + } + + func start() { + track() + } + + func cancel() { + isCancelled = true + } + + private func track() { + withObservationTracking { + guard let object, !isCancelled else { return } + continuation.yield(object[keyPath: keyPath]) + } onChange: { [weak self] in + self?.reschedule() + } + } + + private func reschedule() { + guard !isCancelled else { return } + #if os(WASI) + Task { [weak self] in self?.track() } + #else + DispatchQueue.main.async { [weak self] in self?.track() } + #endif + } } -#endif extension AsyncStream where Element: Equatable { public func until(_ e: Element) async { From dc0655cf965668d88115c731bf0d393adee674ee Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Sun, 5 Jul 2026 11:10:14 +0200 Subject: [PATCH 06/17] Add a runtime WASM harness (node + browser) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A small executable package that proves BSWFoundation's WebAssembly paths work at runtime, not just at compile time: - a real `fetch` GET through FetchNetworkFetcher, decoded by JSONParser, and - a `localStorage` round-trip through KeychainBacked → WASMKeyValueStore. Bundled with JavaScriptKit's PackageToJS (`swift package … js`). Runs in Node (main.mjs shims localStorage, which node lacks; fetch is built in) and in a browser (index.html; both APIs native there). The built bundle (.wasm) and node_modules are gitignored. Verified locally in Node against swift-6.3.2-RELEASE_wasm: ✅ fetch GET https://httpbingo.org/ip → origin = … ✅ KeychainBacked localStorage round-trip → 'hello-from-wasm' Co-Authored-By: Claude Opus 4.8 --- WASMHarness/.gitignore | 3 + WASMHarness/Package.resolved | 69 ++++++++++++++++++++++ WASMHarness/Package.swift | 29 +++++++++ WASMHarness/README.md | 46 +++++++++++++++ WASMHarness/Sources/WASMHarness/main.swift | 61 +++++++++++++++++++ WASMHarness/index.html | 17 ++++++ WASMHarness/main.mjs | 29 +++++++++ WASMHarness/package.json | 5 ++ 8 files changed, 259 insertions(+) create mode 100644 WASMHarness/.gitignore create mode 100644 WASMHarness/Package.resolved create mode 100644 WASMHarness/Package.swift create mode 100644 WASMHarness/README.md create mode 100644 WASMHarness/Sources/WASMHarness/main.swift create mode 100644 WASMHarness/index.html create mode 100644 WASMHarness/main.mjs create mode 100644 WASMHarness/package.json diff --git a/WASMHarness/.gitignore b/WASMHarness/.gitignore new file mode 100644 index 0000000..6deb4f6 --- /dev/null +++ b/WASMHarness/.gitignore @@ -0,0 +1,3 @@ +.build/ +node_modules/ +package-lock.json diff --git a/WASMHarness/Package.resolved b/WASMHarness/Package.resolved new file mode 100644 index 0000000..b7e5546 --- /dev/null +++ b/WASMHarness/Package.resolved @@ -0,0 +1,69 @@ +{ + "originHash" : "0ffaaced017efc045757e24601281659815e9a80a0f18d078e3040a97cca3dc0", + "pins" : [ + { + "identity" : "javascriptkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftwasm/JavaScriptKit.git", + "state" : { + "revision" : "22905075f8b61834810babe5fb9a2f613f22f398", + "version" : "0.56.1" + } + }, + { + "identity" : "keychainaccess", + "kind" : "remoteSourceControl", + "location" : "https://github.com/kishikawakatsumi/KeychainAccess.git", + "state" : { + "revision" : "84e546727d66f1adc5439debad16270d0fdd04e7", + "version" : "4.2.2" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "db774a277f60063a32d854f2980299caf06da041", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a", + "version" : "1.14.0" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "79e4b74a295b6eb74a8b585e3a39d29e70c1dbd1", + "version" : "603.0.2" + } + } + ], + "version" : 3 +} diff --git a/WASMHarness/Package.swift b/WASMHarness/Package.swift new file mode 100644 index 0000000..366e0b3 --- /dev/null +++ b/WASMHarness/Package.swift @@ -0,0 +1,29 @@ +// swift-tools-version: 6.2 +import PackageDescription + +// A small executable that exercises BSWFoundation's WebAssembly paths at runtime: +// a real `fetch` GET through FetchNetworkFetcher and a localStorage round-trip through +// KeychainBacked. Build + run with: +// +// swift package --swift-sdk swift-6.3.x-RELEASE_wasm js +// node main.mjs +// +let package = Package( + name: "WASMHarness", + platforms: [.macOS(.v15)], + dependencies: [ + .package(name: "BSWFoundation", path: ".."), + .package(url: "https://github.com/swiftwasm/JavaScriptKit.git", from: "0.56.1"), + ], + targets: [ + .executableTarget( + name: "WASMHarness", + dependencies: [ + .product(name: "BSWFoundation", package: "BSWFoundation"), + .product(name: "JavaScriptKit", package: "JavaScriptKit"), + .product(name: "JavaScriptEventLoop", package: "JavaScriptKit"), + ] + ) + ], + swiftLanguageModes: [.v6] +) diff --git a/WASMHarness/README.md b/WASMHarness/README.md new file mode 100644 index 0000000..a109e18 --- /dev/null +++ b/WASMHarness/README.md @@ -0,0 +1,46 @@ +# BSWFoundation WebAssembly harness + +A tiny executable that proves BSWFoundation's WebAssembly paths work at **runtime** +(not just at compile time) in a real JavaScript host: + +- a real `fetch` GET through `FetchNetworkFetcher`, decoded by `JSONParser`, and +- a `localStorage` round-trip through `KeychainBacked` → `WASMKeyValueStore`. + +## Build + +```sh +swift package --package-path WASMHarness \ + --swift-sdk swift-6.3.x-RELEASE_wasm \ + --disable-sandbox js +``` + +This bundles to `WASMHarness/.build/plugins/PackageToJS/outputs/Package/` +(both a Node and a browser entrypoint are generated). + +## Run in Node + +Node has `fetch` built in but no `localStorage`, so `main.mjs` shims it in memory. + +```sh +npm install --prefix WASMHarness # installs @bjorn3/browser_wasi_shim (see package.json) +node WASMHarness/main.mjs +``` + +Expected output: + +``` +— BSWFoundation WebAssembly runtime harness — +✅ fetch GET https://httpbingo.org/ip → origin = … +✅ KeychainBacked localStorage round-trip → 'hello-from-wasm' +— harness complete — +``` + +## Run in a browser + +In a browser both `fetch` and `localStorage` are native — no shims. Serve this +folder over HTTP (wasm can't load from `file://`) and open `index.html`: + +```sh +npx serve WASMHarness # or any static file server +# open the printed URL + /index.html and check the devtools console +``` diff --git a/WASMHarness/Sources/WASMHarness/main.swift b/WASMHarness/Sources/WASMHarness/main.swift new file mode 100644 index 0000000..e384d34 --- /dev/null +++ b/WASMHarness/Sources/WASMHarness/main.swift @@ -0,0 +1,61 @@ +import Foundation +import JavaScriptKit +import JavaScriptEventLoop +import BSWFoundation + +@main +struct WASMHarness { + static func main() { + JavaScriptEventLoop.installGlobalExecutor() + Task { + await runHarness() + // Tell the node runner (main.mjs) the async work is finished. + _ = JSObject.global.__harnessDone.function?() + } + } +} + +private func log(_ message: String) { + if let console = JSObject.global.console.object { + _ = console.log!(message) + } +} + +private struct HarnessEnvironment: Environment { + var baseURL: URL { URL(string: "https://httpbingo.org")! } +} + +private enum HarnessAPI: Endpoint { + case ip + var path: String { "/ip" } +} + +private struct IPResponse: Decodable { + let origin: String +} + +private func runHarness() async { + log("— BSWFoundation WebAssembly runtime harness —") + + // 1. A real network GET through the default fetcher (FetchNetworkFetcher → JS fetch). + let client = APIClient(environment: HarnessEnvironment()) + do { + let request = APIClient.Request(endpoint: HarnessAPI.ip) + let ip = try await client.perform(request) + log("✅ fetch GET https://httpbingo.org/ip → origin = \(ip.origin)") + } catch { + log("❌ fetch GET failed: \(error)") + } + + // 2. A localStorage round-trip through KeychainBacked (→ WASMKeyValueStore → localStorage). + let token = KeychainBacked(key: "harness.token") + token.wrappedValue = "hello-from-wasm" + let readBack = token.wrappedValue + if readBack == "hello-from-wasm" { + log("✅ KeychainBacked localStorage round-trip → '\(readBack ?? "")'") + } else { + log("❌ KeychainBacked round-trip failed → '\(readBack ?? "nil")'") + } + + log("— harness complete —") +} diff --git a/WASMHarness/index.html b/WASMHarness/index.html new file mode 100644 index 0000000..aa6cded --- /dev/null +++ b/WASMHarness/index.html @@ -0,0 +1,17 @@ + + + + + BSWFoundation WebAssembly harness + + +

Running the BSWFoundation wasm harness — open the developer console to see results.

+ + + diff --git a/WASMHarness/main.mjs b/WASMHarness/main.mjs new file mode 100644 index 0000000..cdc1901 --- /dev/null +++ b/WASMHarness/main.mjs @@ -0,0 +1,29 @@ +// Node.js runner for the BSWFoundation WebAssembly harness. +// +// swift package --swift-sdk swift-6.3.x-RELEASE_wasm js +// node main.mjs +// +// node provides `fetch`, but not `localStorage` (a browser API), so we shim it in memory. + +import { instantiate } from "./.build/plugins/PackageToJS/outputs/Package/instantiate.js" +import { defaultNodeSetup } from "./.build/plugins/PackageToJS/outputs/Package/platforms/node.js" + +// Minimal in-memory localStorage shim (node has no localStorage). +const _store = new Map() +globalThis.localStorage = { + getItem: (key) => (_store.has(key) ? _store.get(key) : null), + setItem: (key, value) => { _store.set(key, String(value)) }, + removeItem: (key) => { _store.delete(key) }, +} + +async function main() { + let resolveDone + const done = new Promise((resolve) => { resolveDone = resolve }) + globalThis.__harnessDone = () => resolveDone() + + const options = await defaultNodeSetup() + await instantiate(options) // runs WASMHarness.main(), which starts the async Task + await done // wait until the Swift harness signals completion +} + +main() diff --git a/WASMHarness/package.json b/WASMHarness/package.json new file mode 100644 index 0000000..63a1ead --- /dev/null +++ b/WASMHarness/package.json @@ -0,0 +1,5 @@ +{ + "dependencies": { + "@bjorn3/browser_wasi_shim": "^0.3.0" + } +} From 36f0d2436dcebaa158b847e560a565ef331427b4 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Sun, 5 Jul 2026 11:13:51 +0200 Subject: [PATCH 07/17] Document WebAssembly support + the test harness in the README Add a "WebAssembly / Browser Support" section to the top-level README (parallel to Android Support) with a from-scratch harness runbook: install the wasm Swift SDK, build via the JavaScriptKit `js` plugin, and run in Node or a browser, incl. expected output and the localStorage-not-secure caveat. Add a matching prerequisite pointer in WASMHarness/README. Co-Authored-By: Claude Opus 4.8 --- README.md | 46 +++++++++++++++++++++++++++++++++++++++++++ WASMHarness/README.md | 7 +++++++ 2 files changed, 53 insertions(+) diff --git a/README.md b/README.md index e5255f0..c5ff592 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,49 @@ Please checkout [the documentation](https://swiftpackageindex.com/theleftbit/BSW Android support is in an ongoing effort, and it's' built on top of the [Skip Native toolchain](https://skip.tools/docs/native/). All features of this package except for `AuthStorage` and `LocationFetcher` are available and ready to use. If you find any issue, please report it using GitHub. + +## WebAssembly / Browser Support + +BSWFoundation compiles for WebAssembly and runs in the browser via [SwiftWasm](https://swiftwasm.org) and [JavaScriptKit](https://github.com/swiftwasm/JavaScriptKit). Networking goes through the browser's `fetch` API (`FetchNetworkFetcher`, used automatically as the default fetcher on wasm) and key-value storage (`KeychainBacked`, `UserDefaultsBacked`) through `localStorage`. As on Android, `AuthStorage` and `LocationFetcher` are excluded. + +> ⚠️ On WebAssembly, `KeychainBacked` is backed by `localStorage`, which is **not** secure storage — values are not encrypted at rest. + +### Running the test harness + +[`WASMHarness/`](WASMHarness) is a small executable that exercises the wasm paths at runtime — a real `fetch` GET decoded by `JSONParser`, plus a `localStorage` round-trip through `KeychainBacked`. + +1. **Install the WebAssembly Swift SDK** (once). The version must match your Swift toolchain — check `swift --version` and see [swift.org's WebAssembly guide](https://www.swift.org/documentation/articles/wasm-getting-started.html) for the current URL/checksum: + + ```sh + swift sdk install \ + https://download.swift.org/swift-6.3.3-release/wasm-sdk/swift-6.3.3-RELEASE/swift-6.3.3-RELEASE_wasm.artifactbundle.tar.gz \ + --checksum cabfa08b73bb8ac783927ecd15fa386e99d0c139c5f232445067bcf58379cae7 + ``` + +2. **Build** the harness bundle (use the SDK id printed by `swift sdk list`): + + ```sh + swift package --package-path WASMHarness --swift-sdk swift-6.3.3-RELEASE_wasm --disable-sandbox js + ``` + +3. **Run in Node** — Node provides `fetch`; `main.mjs` shims `localStorage`, which Node lacks: + + ```sh + npm install --prefix WASMHarness # installs @bjorn3/browser_wasi_shim + node WASMHarness/main.mjs + ``` + + Expected output: + + ``` + ✅ fetch GET https://httpbingo.org/ip → origin = … + ✅ KeychainBacked localStorage round-trip → 'hello-from-wasm' + ``` + +4. **Run in a browser** — `fetch` and `localStorage` are both native there. Serve the folder over HTTP (wasm can't load from `file://`) and open `index.html`: + + ```sh + npx serve WASMHarness # then open the printed URL + /index.html and check the devtools console + ``` + +The whole package also has a wasm compile gate in CI, and builds are verified on Apple, Android, and WebAssembly. diff --git a/WASMHarness/README.md b/WASMHarness/README.md index a109e18..89c9f53 100644 --- a/WASMHarness/README.md +++ b/WASMHarness/README.md @@ -6,6 +6,13 @@ A tiny executable that proves BSWFoundation's WebAssembly paths work at **runtim - a real `fetch` GET through `FetchNetworkFetcher`, decoded by `JSONParser`, and - a `localStorage` round-trip through `KeychainBacked` → `WASMKeyValueStore`. +## Prerequisite + +Install the WebAssembly Swift SDK matching your Swift toolchain — see the +top-level README's "WebAssembly / Browser Support" section, or +[swift.org's WASM guide](https://www.swift.org/documentation/articles/wasm-getting-started.html). +Confirm the installed SDK id with `swift sdk list`. + ## Build ```sh From 8b21d0802d1594fb2c5b570cce592f4f3d24bfab Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Sun, 5 Jul 2026 11:31:21 +0200 Subject: [PATCH 08/17] Run the unit tests on WebAssembly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test target now builds and runs on wasm — in Node via JavaScriptKit's `js test` — with 44 tests across 12 suites passing. - Link JavaScriptEventLoopTestSupport (wasi-only) so async tests (Task.sleep, observation streams) get the JS event-loop executor instead of hitting an unsupported WASI async-io syscall. - Guard the network / URLSession / file tests in APIClientTests off wasm; the deterministic mock-backed tests run there. Extend APIClientErrorTests' localization expectations to os(WASI) (plain "could not", as on Android/Linux). - Add an `isWASI` test flag and disable the Apple-coupled UserDefaultsBackedTests on wasm (it asserts against UserDefaults.standard; the wasm storage path is covered by WASMHarness). - .github/wasm-prelude.js shims `localStorage` for Node. - CI: the wasm-build job now installs Node + the WASI shim and runs `js test` after the compile gate. Apple/Android builds are unchanged (the test dependency is wasi-conditioned; Apple `swift build --build-tests` verified green). Co-Authored-By: Claude Opus 4.8 --- .github/wasm-prelude.js | 11 +++ .github/workflows/swift.yml | 12 ++- Package.resolved | 92 +------------------ Package.swift | 8 +- .../APIClient/APIClientErrorTests.swift | 6 +- .../APIClient/APIClientTests.swift | 12 +++ .../Extensions/Android.swift | 10 ++ .../Extensions/UserDefaultsBackedTests.swift | 2 +- 8 files changed, 55 insertions(+), 98 deletions(-) create mode 100644 .github/wasm-prelude.js diff --git a/.github/wasm-prelude.js b/.github/wasm-prelude.js new file mode 100644 index 0000000..513fc87 --- /dev/null +++ b/.github/wasm-prelude.js @@ -0,0 +1,11 @@ +// Prelude for running BSWFoundation's test suite on WebAssembly under Node +// (`swift package --swift-sdk … js test --prelude .github/wasm-prelude.js`). +// +// Node provides `fetch` but not `localStorage` (a browser API), so we shim it in memory +// for the storage tests (KeychainBacked / UserDefaultsBacked → WASMKeyValueStore). +const store = new Map() +globalThis.localStorage = { + getItem: (key) => (store.has(key) ? store.get(key) : null), + setItem: (key, value) => { store.set(key, String(value)) }, + removeItem: (key) => { store.delete(key) }, +} diff --git a/.github/workflows/swift.yml b/.github/workflows/swift.yml index bc259b6..6fa41f1 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -62,8 +62,8 @@ jobs: wasm-build: # GitHub-hosted: the self-hosted `mobile` runners don't have the WebAssembly Swift SDK. - # The official Swift image provides the toolchain; we then install the matching wasm SDK - # and compile the package for WebAssembly (browser / JavaScriptKit target). + # The official Swift image provides the toolchain; we then install the matching wasm SDK, + # compile the package for WebAssembly, and run the (wasm-eligible) unit tests in Node. runs-on: ubuntu-latest container: swift:6.3.3 steps: @@ -75,3 +75,11 @@ jobs: --checksum cabfa08b73bb8ac783927ecd15fa386e99d0c139c5f232445067bcf58379cae7 - name: Build for WebAssembly run: swift build --swift-sdk swift-6.3.3-RELEASE_wasm + - name: Install Node.js (for the wasm test runner) + run: apt-get update && apt-get install -y nodejs npm + - name: Install the WASI shim + run: npm install @bjorn3/browser_wasi_shim@0.3.0 + - name: Run unit tests on WebAssembly (Node) + run: | + swift package --swift-sdk swift-6.3.3-RELEASE_wasm --disable-sandbox \ + js test --prelude .github/wasm-prelude.js diff --git a/Package.resolved b/Package.resolved index 80d48d8..cf306b6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "e4910ec516df70c8fd1f29b2973d750155d642d0fad11d9dd3d0c2e2b9059c61", + "originHash" : "9f04813349b5405881495944b998a016ba8b756b2c99f5d8f925578c96e12c04", "pins" : [ { "identity" : "javascriptkit", @@ -19,87 +19,6 @@ "version" : "4.2.2" } }, - { - "identity" : "skip", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip.git", - "state" : { - "revision" : "0a57ed3d92ae6025b59ba4a25a86570c4f6094ce", - "version" : "1.9.4" - } - }, - { - "identity" : "skip-android-bridge", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-android-bridge.git", - "state" : { - "revision" : "e9c30eb0f0278e8407435ef6fed50c362a3279c2", - "version" : "0.6.3" - } - }, - { - "identity" : "skip-bridge", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-bridge.git", - "state" : { - "revision" : "72b7b1d4734332cfdc4b519539b5beec0fb3ac00", - "version" : "0.17.2" - } - }, - { - "identity" : "skip-foundation", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-foundation.git", - "state" : { - "revision" : "1854caad39cc2c9ce18e4d6765c79f026059e67e", - "version" : "1.4.1" - } - }, - { - "identity" : "skip-fuse", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-fuse.git", - "state" : { - "revision" : "8f3295094ad29075730284c5197c7f1d94c0f2d9", - "version" : "1.0.2" - } - }, - { - "identity" : "skip-keychain", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-keychain.git", - "state" : { - "revision" : "def9e33d25a5a6c92a4e6ae029d7a2ed3ecdd9d2", - "version" : "0.3.2" - } - }, - { - "identity" : "skip-lib", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-lib.git", - "state" : { - "revision" : "76e7da8a870b5b66ea0c3264f648b58b73bcdc0d", - "version" : "1.4.0" - } - }, - { - "identity" : "skip-unit", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-unit.git", - "state" : { - "revision" : "c89af47fd645e04db863e938ade39f91e1bb62b8", - "version" : "1.7.0" - } - }, - { - "identity" : "swift-android-native", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/swift-android-native.git", - "state" : { - "revision" : "7e6e833e6f163a2b75340f75c70b1d96ea6b8135", - "version" : "1.5.1" - } - }, { "identity" : "swift-asn1", "kind" : "remoteSourceControl", @@ -127,15 +46,6 @@ "version" : "1.6.0" } }, - { - "identity" : "swift-jni", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/swift-jni.git", - "state" : { - "revision" : "fe76ac21aca639976833b5ea3e875dc072519ac4", - "version" : "0.5.0" - } - }, { "identity" : "swift-log", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 96ab88b..b06405e 100644 --- a/Package.swift +++ b/Package.swift @@ -98,7 +98,13 @@ let package = Package( ), .testTarget( name: "BSWFoundationTests", - dependencies: ["BSWFoundation"] + dependencies: [ + "BSWFoundation", + // On wasm, linking this activates the JavaScriptKit event-loop executor for the + // test bundle, so async tests (Task.sleep, etc.) run instead of hitting an + // unsupported WASI async-io syscall. + .product(name: "JavaScriptEventLoopTestSupport", package: "JavaScriptKit", condition: .when(platforms: [.wasi])), + ] ), ], swiftLanguageModes: [.v6], diff --git a/Tests/BSWFoundationTests/APIClient/APIClientErrorTests.swift b/Tests/BSWFoundationTests/APIClient/APIClientErrorTests.swift index 4589c40..d984347 100644 --- a/Tests/BSWFoundationTests/APIClient/APIClientErrorTests.swift +++ b/Tests/BSWFoundationTests/APIClient/APIClientErrorTests.swift @@ -7,7 +7,7 @@ struct APIClientErrorTests { @Test func errorPrinting_encodingRequestFailed() { let localizedDescription = APIClient.Error.encodingRequestFailed.localizedDescription - #if os(Android) + #if os(Android) || os(WASI) #expect(localizedDescription == "The operation could not be completed. (BSWFoundation.APIClient.Error.encodingRequestFailed)") #else #expect(localizedDescription == "The operation couldn’t be completed. (BSWFoundation.APIClient.Error.encodingRequestFailed)") @@ -20,7 +20,7 @@ struct APIClientErrorTests { ["Please try again"] """.data(using: .utf8) let localizedDescription = APIClient.Error.failureStatusCode(400, errorMessageData).localizedDescription - #if os(Android) + #if os(Android) || os(WASI) #expect(localizedDescription == "The operation could not be completed. (BSWFoundation.APIClient.Error.FailureStatusCode: 400, Message: [\"Please try again\"])") #else #expect(localizedDescription == "The operation couldn’t be completed. (BSWFoundation.APIClient.Error.FailureStatusCode: 400, Message: [\"Please try again\"])") @@ -33,7 +33,7 @@ struct APIClientErrorTests { "Please try again" """.data(using: .utf8) let localizedDescription = APIClient.Error.failureStatusCode(400, errorMessageData).localizedDescription - #if os(Android) + #if os(Android) || os(WASI) #expect(localizedDescription == "The operation could not be completed. (BSWFoundation.APIClient.Error.FailureStatusCode: 400, Message: \"Please try again\")") #else #expect(localizedDescription == "The operation couldn’t be completed. (BSWFoundation.APIClient.Error.FailureStatusCode: 400, Message: \"Please try again\")") diff --git a/Tests/BSWFoundationTests/APIClient/APIClientTests.swift b/Tests/BSWFoundationTests/APIClient/APIClientTests.swift index 2e23a25..f08594c 100644 --- a/Tests/BSWFoundationTests/APIClient/APIClientTests.swift +++ b/Tests/BSWFoundationTests/APIClient/APIClientTests.swift @@ -10,6 +10,10 @@ import HTTPTypes import FoundationNetworking #endif +// On WebAssembly the network / URLSession / file / Dispatch based tests are excluded (`#if +// !os(WASI)`); the deterministic, mock-backed tests below run there. Real networking is covered +// by the Apple/Android jobs and by the runtime harness in `WASMHarness/`. + actor APIClientTests { var sut: APIClient @@ -18,6 +22,7 @@ actor APIClientTests { sut = APIClient(environment: HTTPBin.Hosts.production) } + #if !os(WASI) @Test func GET() async throws { let ipRequest = BSWFoundation.APIClient.Request( @@ -99,6 +104,7 @@ actor APIClientTests { } try FileManager.default.removeItem(at: file) } + #endif @Test func unauthorizedCallsRightMethod() async throws { @@ -115,6 +121,7 @@ actor APIClientTests { #expect(failedPath != nil) } + #if !os(WASI) @Test func unauthorizedRetriesAfterGeneratingNewCredentials() async throws { @@ -156,6 +163,7 @@ actor APIClientTests { ) let _ = try await sut.perform(ipRequest) } + #endif @Test func customizeRequests() async throws { @@ -200,6 +208,7 @@ actor APIClientTests { #expect(capturedRequest.httpRequest.headerFields[.init("Signature")!] == "hello") } + #if !os(WASI) static func generateRandomFile() throws -> URL { let length = 2048 let bytes = [UInt32](repeating: 0, count: length).map { _ in arc4random() } @@ -211,13 +220,16 @@ actor APIClientTests { return url } + #endif } @MainActor private class MockAPIClientDelegate: NSObject, APIClientDelegate { func apiClientDidReceiveUnauthorized(forRequest atPath: String, apiClientID: APIClient.ID) async throws -> Bool { failedPath = atPath + #if !os(WASI) dispatchPrecondition(condition: .onQueue(.main)) + #endif return false } var failedPath: String? diff --git a/Tests/BSWFoundationTests/Extensions/Android.swift b/Tests/BSWFoundationTests/Extensions/Android.swift index 8ca3b89..ed1a946 100644 --- a/Tests/BSWFoundationTests/Extensions/Android.swift +++ b/Tests/BSWFoundationTests/Extensions/Android.swift @@ -8,3 +8,13 @@ var isAndroid: Bool { return false #endif } + +/// On WebAssembly, storage is localStorage-backed and `UserDefaults.standard` semantics differ, +/// so some Apple-coupled tests are turned off there (their wasm paths are covered by `WASMHarness`). +var isWASI: Bool { + #if os(WASI) + return true + #else + return false + #endif +} diff --git a/Tests/BSWFoundationTests/Extensions/UserDefaultsBackedTests.swift b/Tests/BSWFoundationTests/Extensions/UserDefaultsBackedTests.swift index b841a86..e48ab35 100644 --- a/Tests/BSWFoundationTests/Extensions/UserDefaultsBackedTests.swift +++ b/Tests/BSWFoundationTests/Extensions/UserDefaultsBackedTests.swift @@ -3,7 +3,7 @@ import Foundation import BSWFoundation import Testing -@Suite(.serialized, .disabled(if: isAndroid)) +@Suite(.serialized, .disabled(if: isAndroid || isWASI)) actor UserDefaultsBackedTests { @Test From e73200196e49ec55ef47224cd35efc0e5b1405a3 Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Sun, 5 Jul 2026 12:33:32 +0200 Subject: [PATCH 09/17] Document wasm production builds & binary size in the README MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Explain that the ~76 MB figure is a debug artifact: for deployment, build in release with Binaryen's wasm-opt on PATH (so PackageToJS runs its size pass) and serve the wasm brotli/gzip-compressed. Include reference sizes for the WASMHarness bundle (release + wasm-opt ≈ 45 MB raw / ~12 MB brotli) and note the reflection-metadata stripping option and the Foundation baseline. Co-Authored-By: Claude Opus 4.8 --- README.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c5ff592..5d03639 100644 --- a/README.md +++ b/README.md @@ -63,4 +63,29 @@ BSWFoundation compiles for WebAssembly and runs in the browser via [SwiftWasm](h npx serve WASMHarness # then open the printed URL + /index.html and check the devtools console ``` -The whole package also has a wasm compile gate in CI, and builds are verified on Apple, Android, and WebAssembly. +### Production builds & binary size + +A **debug** wasm build is very large (~76 MB) — never ship it. For deployment, build in +**release** *and* make sure [Binaryen](https://github.com/WebAssembly/binaryen)'s `wasm-opt` is on +`PATH` before building, so the PackageToJS plugin runs its size-optimization pass. What the browser +actually downloads is the compressed (`Content-Encoding: br`/`gzip`) file, which is far smaller. + +Reference sizes for the `WASMHarness` bundle (which links all of BSWFoundation + Foundation): + +| Build | raw | gzip | brotli (served) | +|---|---|---|---| +| debug | ~76 MB | — | — | +| release, no `wasm-opt` | ~71 MB | ~23 MB | — | +| **release + `wasm-opt`** | **~45 MB** | ~18 MB | **~12 MB** | + +```sh +brew install binaryen # or your platform's package providing `wasm-opt` +swift package --package-path WASMHarness --swift-sdk swift-6.3.3-RELEASE_wasm --disable-sandbox js -c release +``` + +Then serve the `.wasm` with brotli or gzip enabled (browsers stream-compile it). Most of the +remaining size is the Swift runtime + Foundation — an inherent baseline for Swift-with-Foundation +in the browser. To go further, a production build can also strip reflection metadata +(`-Xswiftc -disable-reflection-metadata`), at the cost of `Mirror`/runtime reflection. + +The whole package also has a wasm compile gate in CI (plus the unit tests run on wasm in Node), and builds are verified on Apple, Android, and WebAssembly. From 743eec881a413cba2f181f8651ee7bb95ef5217f Mon Sep 17 00:00:00 2001 From: Pierluigi Cifani Date: Sun, 5 Jul 2026 15:48:40 +0200 Subject: [PATCH 10/17] Document building browser apps and reusing a ViewModel from React Adds two consumer-facing subsections to the WebAssembly docs: - Building a browser app on BSWFoundation: the JavaScriptKit event-loop dependencies, installGlobalExecutor(), the PackageToJS `js` plugin, serving, and the UserDefaultsBacked -> CodableUserDefaultsBacked caveat. - Reusing a Swift @Observable ViewModel from a JS framework such as React: the globalThis bridge contract plus the Vite /public loading gotcha. Co-Authored-By: Claude Opus 4.8 --- README.md | 71 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/README.md b/README.md index 5d03639..4142480 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,77 @@ BSWFoundation compiles for WebAssembly and runs in the browser via [SwiftWasm](h > ⚠️ On WebAssembly, `KeychainBacked` is backed by `localStorage`, which is **not** secure storage — values are not encrypted at rest. +### Building a browser app + +To build a browser app on top of BSWFoundation, add JavaScriptKit's event-loop products to your executable target — wasi-conditioned, so your Apple/Android builds are unaffected: + +```swift +dependencies: [ + .package(url: "https://github.com/theleftbit/BSWFoundation.git", from: "..."), + .package(url: "https://github.com/swiftwasm/JavaScriptKit.git", from: "0.56.1"), +], +targets: [ + .executableTarget( + name: "MyWebApp", + dependencies: [ + .product(name: "BSWFoundation", package: "BSWFoundation"), + .product(name: "JavaScriptKit", package: "JavaScriptKit", condition: .when(platforms: [.wasi])), + .product(name: "JavaScriptEventLoop", package: "JavaScriptKit", condition: .when(platforms: [.wasi])), + ] + ) +] +``` + +Install the JavaScriptKit global executor **once, before any async work** — without it, `Task` / `async`-`await` (and therefore `APIClient`) won't run: + +```swift +import JavaScriptEventLoop + +@main +struct MyWebApp { + static func main() { + JavaScriptEventLoop.installGlobalExecutor() + Task { /* your app — APIClient, storage, etc. all work from here */ } + } +} +``` + +Bundle it with the [PackageToJS](https://github.com/swiftwasm/JavaScriptKit) plugin JavaScriptKit ships, then serve the output folder over HTTP: + +```sh +swift package --swift-sdk swift-6.3.3-RELEASE_wasm --disable-sandbox \ + js --use-cdn -c release --product MyWebApp --output Public +npx serve Public +``` + +(`--use-cdn` resolves the `@bjorn3/browser_wasi_shim` runtime dependency from a CDN; drop it and `npm install` instead if you bundle with a package manager. See [Production builds & binary size](#production-builds--binary-size) below for shrinking the `.wasm`.) + +> ⚠️ `UserDefaultsBacked` on wasm only supports `Bool` and `String`. For any other type — `Int`, `Date`, your own `Codable` — use **`CodableUserDefaultsBacked`**, which JSON-encodes the value and behaves identically on every platform. + +### Reusing a Swift `ViewModel` from React (or any JS framework) + +You don't have to render the DOM from Swift. A common pattern is to keep your `@Observable` model and business logic in Swift and let a JS framework own the view, via a thin "bridge" executable target that: + +1. builds the `ViewModel` (which uses `APIClient`, storage, etc.), +2. pushes its state to JavaScript through `globalThis` callbacks whenever it changes (drive updates with `Observable.stream(for:)`), +3. exposes its actions back on `globalThis` (e.g. a `bump()` method). + +The JS side registers the callbacks, renders from the pushed state, and calls the exposed actions — it never re-implements any logic: + +```swift +// The front-end sets these on globalThis before the module boots: +// __swiftDemoUpdate(state) — called with a plain JS object on every change +// __onSwiftDemoReady(api) — called once when ready; `api.bump()` drives the model +guard let update = JSObject.global.__swiftDemoUpdate.function else { return } +let state = JSObject.global.Object.function!.new() +state.counter = .number(Double(viewModel.counter)) +_ = update(state.jsValue) +``` + +**Bundler note (Vite):** the generated `.wasm` + loader are static assets. Put the bundle in `public/` and boot it from a tiny `