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 cf5431b..6fa41f1 100644 --- a/.github/workflows/swift.yml +++ b/.github/workflows/swift.yml @@ -59,3 +59,27 @@ 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, + # compile the package for WebAssembly, and run the (wasm-eligible) unit tests in Node. + 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 + - 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/Docs/context/platform-support.md b/Docs/context/platform-support.md index 439c684..bc3257c 100644 --- a/Docs/context/platform-support.md +++ b/Docs/context/platform-support.md @@ -1,6 +1,6 @@ # Platform Support -`BSWFoundation` is Apple-first and has an explicit Skip/Android path for the subset that can run without Apple-only frameworks. +`BSWFoundation` is Apple-first and has explicit Android and browser WebAssembly paths for the subset that can run without Apple-only frameworks. ## Apple Platforms @@ -26,8 +26,34 @@ When `SKIP_ENABLED` is present, the package adds Skip dependencies and Android-s - `FoundationInternationalization` - Android logging and platform APIs where needed. -The README states that all features except `AuthStorage` and `LocationFetcher` are intended to be available on Android. `KeychainBacked` uses `SkipKeychain` on Android when Skip dependencies are enabled. The JWT helpers currently live under the `AuthStorage.swift` Darwin guard, so their intended Android availability should be clarified before relying on them from Skip code. +The README states that all features except `AuthStorage` and `LocationFetcher` are intended to be available on Android. Some Android storage APIs require Skip-only modules: `KeychainBacked` and `CodableKeychainBacked` require `SkipKeychain`; `UserDefaultsBacked` and `CodableUserDefaultsBacked` require the Skip user defaults bridge. Build with `SKIP_ENABLED=1` so SwiftPM includes these dependencies. Without those modules, the wrappers remain present but unavailable with a targeted compiler diagnostic. + +The JWT helpers currently live under the `AuthStorage.swift` Darwin guard, so their intended Android availability should be clarified before relying on them from Skip code. + +## Browser WebAssembly Via SwiftWasm + +The package compiles for WASI browser-hosted SwiftWasm. WASM dependencies are conditioned in `Package.swift`: + +- `JavaScriptKit` +- `JavaScriptEventLoop` +- `JavaScriptFoundationCompat` +- `swift-log` + +Browser networking is handled by the WASI-only fetch implementation. `APIClient` defaults to the browser fetch-backed network fetcher on WASM and keeps the URLSession-backed default on platforms where URLSession is available. Browser fetch follows browser security rules for TLS, CORS and mixed content; `Environment.shouldAllowInsecureConnections` is not emulated in the browser. + +WASM consumers must install the JavaScriptKit event-loop executor once during host startup by calling `BSWBrowserRuntime.installJavaScriptEventLoop()` before creating an `APIClient` or starting async work that depends on JavaScript callbacks. + +Persistence support on WASM is intentionally narrow: + +- `UserDefaultsBacked` uses an internal browser `localStorage` adapter and supports only the value types documented in README. Use `CodableUserDefaultsBacked` for codable values. + +Current WASM exclusions and limitations: + +- `AuthStorage` and `LocationFetcher` are unavailable. +- `KeychainBacked` and `CodableKeychainBacked` are unavailable because browsers do not expose Keychain-equivalent secure storage to SwiftWasm. +- URL file uploads are unavailable; browser upload support must use the WASM browser upload body API. +- APIs that require Apple-only frameworks or URLSession delegates remain guarded out of WASI. ## Compatibility Rule -New shared APIs should compile on all declared Apple platforms unless intentionally guarded. New APIs meant to be available on Android should avoid Darwin-only dependencies or provide Android-specific branches. +New shared APIs should compile on all declared Apple platforms unless intentionally guarded. New APIs meant to be available on Android or WASM should avoid Darwin-only dependencies or provide platform-specific branches. Public APIs that are intentionally unavailable on Android or WASM should fail clearly at compile time or with a targeted runtime diagnostic. diff --git a/Docs/features/persistence.md b/Docs/features/persistence.md index f40a74a..0e3da12 100644 --- a/Docs/features/persistence.md +++ b/Docs/features/persistence.md @@ -29,13 +29,13 @@ The same source file includes JWT decoding helpers derived from Auth0's JWT deco ## KeychainBacked -`KeychainBacked` stores optional strings in Keychain. It supports Darwin through `KeychainAccess` and Android through `SkipKeychain`; Linux is excluded. +`KeychainBacked` stores optional strings in Keychain. It supports Darwin through `KeychainAccess` and Android through `SkipKeychain`; Linux and WebAssembly are excluded. Android consumers must build with `SKIP_ENABLED=1` so SwiftPM includes `SkipKeychain`. -`CodableKeychainBacked` stores optional `Codable` values by encoding them before persistence and decoding them on read. +`CodableKeychainBacked` stores optional `Codable` values by encoding them before persistence and decoding them on read. It is also unavailable on WebAssembly because browsers do not expose Keychain-equivalent secure storage to SwiftWasm. ## UserDefaultsBacked -`UserDefaultsBacked` stores optional primitive values in user defaults. Darwin can use standard defaults or an app group suite. The Android path currently supports a narrower set of value types. +`UserDefaultsBacked` stores optional primitive values in user defaults. Darwin can use standard defaults or an app group suite. The Android path requires Skip's user defaults bridge, which is included when building with `SKIP_ENABLED=1`, and currently supports a narrower set of value types. `CodableUserDefaultsBacked` stores optional `Codable` values by encoding them into user defaults. diff --git a/Package.resolved b/Package.resolved index d684186..5a9db5a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "853b5e05ad3a6efd70b566d40499cfe0ffe0bf6b722d3a464a158a209c5edcb0", + "originHash" : "a0588d2d225b95fd6407c40311d363ba7ecd9b60e74db69c8c03e5e288155693", "pins" : [ + { + "identity" : "javascriptkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftwasm/JavaScriptKit.git", + "state" : { + "revision" : "22905075f8b61834810babe5fb9a2f613f22f398", + "version" : "0.56.1" + } + }, { "identity" : "keychainaccess", "kind" : "remoteSourceControl", @@ -10,6 +19,15 @@ "version" : "4.2.2" } }, + { + "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", @@ -36,6 +54,33 @@ "revision" : "db774a277f60063a32d854f2980299caf06da041", "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", + "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..85468ac 100644 --- a/Package.swift +++ b/Package.swift @@ -17,10 +17,31 @@ let applePlatforms = TargetDependencyCondition.when( ] ) +let androidPlatforms = TargetDependencyCondition.when(platforms: [.android]) + +// 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"), + .package(url: "https://source.skip.tools/swift-android-native.git", from: "1.4.1"), ] if skipIsEnabled { @@ -33,11 +54,13 @@ if skipIsEnabled { var targetDependencies: [Target.Dependency] = [ .product(name: "Crypto", package: "swift-crypto"), .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"), + .product(name: "HTTPTypesFoundation", package: "swift-http-types", condition: foundationNetworkingPlatforms), + .product(name: "JavaScriptKit", package: "JavaScriptKit", condition: .when(platforms: [.wasi])), + .product(name: "JavaScriptEventLoop", package: "JavaScriptKit", condition: .when(platforms: [.wasi])), + .product(name: "JavaScriptFoundationCompat", package: "JavaScriptKit", condition: .when(platforms: [.wasi])), + .product(name: "Logging", package: "swift-log", condition: .when(platforms: [.wasi])), + .product(name: "AndroidLogging", package: "swift-android-native", condition: androidPlatforms), ] if skipIsEnabled { @@ -70,7 +93,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/README.md b/README.md index e5255f0..15b58fa 100644 --- a/README.md +++ b/README.md @@ -17,4 +17,150 @@ 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. +Some Android storage APIs require Skip-only dependencies. Build with `SKIP_ENABLED=1` so SwiftPM includes those packages before using `KeychainBacked`, `CodableKeychainBacked`, `UserDefaultsBacked`, or `CodableUserDefaultsBacked` on Android. + 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 `UserDefaultsBacked` uses browser `localStorage` for non-sensitive values. As on Android, `AuthStorage` and `LocationFetcher` are excluded. + +> ⚠️ `KeychainBacked` and `CodableKeychainBacked` are unavailable on WebAssembly. Browsers do not expose Keychain-equivalent secure storage to SwiftWasm; use host-managed auth instead. + +### 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 through BSWFoundation **once, before creating `APIClient` or spawning async work**. In a browser app hosted by JavaScript, do this from the Swift bootstrap function that your JS entrypoint calls before invoking any other Swift API: + +```swift +import BSWFoundation + +@_cdecl("bsw_bootstrap") +public func bsw_bootstrap() { + BSWBrowserRuntime.installJavaScriptEventLoop() +} +``` + +```js +wasm.instance.exports.bsw_bootstrap(); +// Now call exported Swift APIs that create APIClient, use storage, or spawn async work. +``` + +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 ` + + 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" + } +}