EP 292 · Cross Platform Swift · Aug 26, 2024 ·Members

Video #292: Cross-Platform Swift: Networking

smart_display

Loading stream…

Video #292: Cross-Platform Swift: Networking

Episode: Video #292 Date: Aug 26, 2024 Access: Members Only 🔒 URL: https://www.pointfree.co/episodes/ep292-cross-platform-swift-networking

Episode thumbnail

Description

Let’s dial up the complexity of our Wasm application! We’ll introduce some async logic in the form of a network request. We’ll take steps to not only control this dependency, but we’ll do so across both Apple and Wasm platforms, and we’ll isolate its interface from its live implementation to speed up our builds and reduce our app’s size.

Video

Cloudflare Stream video ID: cca8eeedd84775a2c031d11b3abc97e3 Local file: video_292_cross-platform-swift-networking.mp4 *(download with --video 292)*

References

Transcript

0:05

I don’t know about you, but I think this pretty incredible. We have a 100% pure Swift code base that is powering a web app. And even better, we are using the same model that we used over in UIKit and SwiftUI. This is showing a possible vision for cross-platform development in Swift. One model powering features on two vastly different platforms: iOS and the web. And it’s all thanks to the cross-platform observe function that comes with our Swift Navigation library, as well as Swift’s amazing Observation framework. Brandon

0:33

But we are still missing some of the most important behavior in the feature, which is the network request that loads a fact from the server and presents it to the user. This is going to be a little more difficult than everything we have accomplished so far because it involves new APIs for making network requests and it involves asynchronous work.

0:53

Let’s dig in. Making network requests

0:55

As we saw earlier, Foundation’s URLSession is not available to us in a Wasm context, and we have all of the networking code commented out in our CounterModel .

1:08

The way one performs network requests in JavaScript is through the global fetch function, which we can explore in the browser console. We can await a fetch to the numberapi.com endpoint and assign it to a response: let response = await fetch("http://www.numberapi.com/42")

1:33

And then we can await that response to get a textual description from it: await response.text()

2:03

…and we will find that: Note 42 is the answer to the Ultimate Question of Life, the Universe, and Everything.

2:08

That’s the basics of performing a fetch, and we just need to figure out how to do this from our Swift code.

2:16

First of all, how do we get access to this fetch function from our CounterFeature where the CounterModel lives? This module is supposed to be platform agnostic so that we can use it on iOS and Wasm, and maybe even Windows or Linux someday in the future.

2:42

If we start plunking down JavaScript code in this module we will make it so that it can only compile for Wasm, and that’s definitely not what we want to do. However, for right now we just want to concentrate on getting this to work for Wasm and we will worry about making it compatible with our other platforms in a moment.

2:59

So we are going to take the drastic, but temporary, measure of adding JavaScriptKit to our CounterFeature module: .target( name: "CounterFeature", dependencies: [ .product( name: "JavaScriptEventLoop", package: "JavaScriptKit" ), .product(name: "JavaScriptKit", package: "JavaScriptKit"), … ] )

3:28

And now we can import JavaScriptKit and JavaScriptEventLoop: import JavaScriptEventLoop import JavaScriptKit

3:39

And we can invoke fetch by going through the JSObject.global variable: JSObject.global.fetch I want to mention again that it is definitely not right for us to be importing JavaScriptKit into our core domain model. But let’s keep our momentum going forward, and we will address this problem later.

3:49

Next we can invoke this function by passing a URL string: JSObject.global.fetch("http://numberapi.com/\(count)")

4:03

…but the fetch dynamic member look up returns an optional closure that can be invoked. For some reason there is a mismatch between JSValue ’s dynamic member lookup, which returns non-optional JSValue s, and JSObject ‘s, which return optional JSValue s. We’re not going to get in the weeds about this, though.

4:41

We know fetch exists, and so we will force unwrap it: JSObject.global.fetch!("http://numberapi.com/\(count)")

4:49

This now eagerly makes the request and we want to await the response to get the data back from the server.

4:58

To do this we get the underlying object for the fetch: JSObject.global.fetch!("http://numberapi.com/\(count)") .object!

5:09

…which is actually what is known as a “promise”, and promises are the things that can actually be awaited in JavaScript. So we will turn the nebulous JS object into a JSPromise : JSPromise( JSObject.global.fetch!("http://numberapi.com/\(count)") .object! )

5:21

But this initializer is failable because you may be trying to cast a non-promise object into a promise. But we feel confident we are doing things correctly here and so we will force unwrap: JSPromise( JSObject.global.fetch!("http://numberapi.com/\(count)") .object! )!

5:30

We are getting close, but now we need to await this promise to exact the value from it. There’s a value property for that, but we need to do more force unwrapping: JSPromise( JSObject.global.fetch!("http://numberapi.com/\(count)") .object! )!.value

5:37

And then this is the thing we can await: let response = try await JSPromise( JSObject.global.fetch!("http://numberapi.com/\(count)") .object! )!.value

5:44

So this bit of Swift code here corresponds to the JavaScript code we wrote in the browser a moment ago: let response = await fetch("http://www.numberapi.com/42")

6:06

Anytime you see an await in JavaScript it translates to wrapping some code in a JSPromise and then await ing its value in Swift.

6:18

However, we do have an error right now: Non-sendable type ‘JSPromise’ exiting main actor-isolated context in call to non-isolated property ‘value’ cannot cross actor boundary

6:20

This is happening because we are in an isolated context since CounterModel is marked as @MainActor , and we are accessing an async property on a non-sendable type, which is JSPromise . This is essentially equivalent to passing a non-sendable value from an isolated context to a non-isolated async context, which is not a legitimate thing to do.

6:41

This does bring up an interesting topic when it comes to building applications for the web. JavaScript is a single-threaded environment in browsers. It sounds bizarre, but it’s true. JavaScript is still able to accomplish concurrency thanks to an event loop, and it even has a nice async / await syntax. But, if you ever perform any kind of blocking work in JavaScript, you are effectively blocking all code from running in the browser.

7:05

And in such an an environment, the concepts of sendability and @MainActor s are less important. If there is only one thread, then all types are thread safe. And so for now we will just perform a @preconcurrency import of JavaScriptKit: @preconcurrency import JavaScriptKit …to silence the warning.

7:19

Now things are compiling in Xcode and in terminal, and we now have a response from the API, and we need to turn this response into a string.

7:39

As we saw a moment ago, the JavaScript to turn the response into a string was: await response.text()

7:59

And so in Swift we need to wrap this in a JSPromise and await its value : try await JSPromise(response.text().object!)!.value.string!

8:28

And then we can wrap it all up in a Fact value: self.fact = Fact( value: try await JSPromise(response.text().object!)!.value .string! )

8:51

We are now populating the fact state from a network request, but we aren’t currently showing it on the screen.

9:01

The easiest way to do this is just to add another div to the DOM: var factLabel = document.createElement("div") _ = document.body.appendChild(factLabel)

9:40

And then we can check if the fact value is available, and if so display the fact, or we can just say “Loading fact…”: if let fact = model.fact?.value { factLabel.innerText = .string(fact) } else if model.factIsLoading { factLabel.innerText = "Loading fact…" } else { factLabel.innerText = "" }

10:31

And the final step is to actually invoke the factButtonTapped method on the model when the button is tapped. We can do that just like we did for the “Increment” and “Decrement” buttons, but we do have to spin up an unstructured Task in order to invoke the method: var factButton = document.createElement("button") factButton.innerText = "Get fact" factButton.onclick = .object( JSClosure { _ in Task { await model.factButtonTapped() } return .undefined } ) _ = document.body.appendChild(factButton)

11:24

This now compiles and we can run the app in the browser to see that it even works! We can count up to a number, click “Fact”, and a “Loading…” message will appear followed by a fact about the number. Isn’t that absolutely incredible?

11:46

And if we count up again the fact will be cleared out because we have that special logic in our model: func incrementButtonTapped() { count += 1 fact = nil } func decrementButtonTapped() { count -= 1 fact = nil }

12:10

We now have implemented even more of our app for the web, which was previously built in SwiftUI, UIKit and Epoxy. Cross platform affordances

12:29

I hope all of our viewers are as excited about what we have done here as we are. We are running a full blown Swift feature in the browser. Since all of the logic and behavior of the feature was extracted to a little observable model, we just have to invoke the methods from the DOM and observe changes to the model to update the UI. This is now some serious behavior we have implemented in our web-based application. Stephen

12:53

There is one big bummer about what we have done so far though. We made some pretty big changes to our CounterModel in order to make it play nicely with Wasm. We had to start depending on heavyweight dependencies like JavaScriptKit in order to make network requests, and our model will no longer work on other platforms such as iOS.

13:09

This of course seems to go against our goal to be able to write cross platform code, but it serves as an important lesson in the types of challenges you are going to encounter writing cross platform code. And not only are we going to fix these problems, but we are going to see that in fixing them we are actually going to improve the versatility of our model. So it’s a win-win!

13:27

Let’s give it a shot.

13:30

Right now we are performing the network request in the model in a very Wasm-oriented manner: do { try await Task.sleep(for: .seconds(1)) let response = try await JSPromise( JSObject.global.fetch!("http://numberapi.com/\(count)") .object! )!.value self.fact = Fact( value: try await JSPromise(response.text().object!)!.value .string! ) } catch { // TODO: error handling }

13:43

With just a little bit of work we can make it so that we use JavaScriptKit to perform network requests when building for Wasm, and otherwise we use Apple’s Foundation APIs.

13:52

There is a compiler directive that allows us to distinguish the Wasm platform from other platforms: #if os(WASI) let response = try await JSPromise( JSObject.global.fetch!("http://numberapi.com/\(count)") .object! )!.value self.fact = Fact( value: try await JSPromise(response.text().object!)!.value .string! ) #else // Non-Wasm platform #endif

14:08

And so we can put the Apple platform-specific code in the #else of this branch: #if os(WASI) let response = try await JSPromise( JSObject.global.fetch!("http://numberapi.com/\(count)") .object! )!.value self.fact = Fact( value: try await JSPromise(response.text().object!)!.value .string! ) #else let loadedFact = try await String( decoding: URLSession.shared .data( from: URL(string: "http://www.numberapi.com/\(count)")! ).0, as: UTF8.self ) self.fact = Fact(value: loadedFact) #endif

14:14

And lastly we will also need to make sure to import the JavaScript libraries when on Wasm: #if os(WASI) import JavaScriptEventLoop @preconcurrency import JavaScriptKit #endif

14:24

And technically this does fix all of the problems. We now have code that compiles for both Apple platforms and Wasm, and it will perform a platform-specific network request inside the factButtonTapped method.

14:41

However, there is a lot about this crude technique that we can improve. First of all, we are still unconditionally depending on JavaScriptKit and JavaScriptEventLoop in CounterFeature, which means we are compiling all of that code on Apple platforms even though we don’t need any of it.

14:56

It turns out that SPM has the ability to conditionally depend on products based on the platform that we are building for. So we can decide to include JavaScriptEventLoop and JavaScriptKit only when building for Wasm: .product( name: "JavaScriptEventLoop", package: "JavaScriptKit", condition: .when(platforms: [.wasi]) ), .product( name: "JavaScriptKit", package: "JavaScriptKit", condition: .when(platforms: [.wasi]) ),

15:15

And now that we are completely omitting these dependencies when building for Apple platforms we have an even better way to conditionally include code. We can just check if we can import these packages.

15:26

For example, when importing: #if canImport(JavaScriptEventLoop) import JavaScriptEventLoop #endif #if canImport(JavaScriptKit) @preconcurrency import JavaScriptKit #endif

15:39

…and when performing the network request: #if canImport(JavaScriptKit) && canImport(JavaScriptEventLoop) … #else … #endif

15:53

So that is already a big improvement. We are now compiling these JavaScript libraries only when necessary, and we have a clear way to determine if we should call out to JavaScript for the network request, or use Apple’s Foundation APIs.

16:04

But we can still improve things quite a bit. It is quite messy to litter our code with these #if checks all over the place. Not only does it make the code look messy, but each time you see #if you should think of it as a block of code that is not necessarily compiling right now, which means we aren’t being notified when there are problems. For each #if you have a potentially whole new build configuration you have to eventually test to make sure that everything is copacetic.

16:27

A small thing we could do to improve the situation is define a dedicated function for handling just the logic of performing the network request: private func fetchFact(number: Int) async throws -> String { #if canImport(JavaScriptKit) && canImport(JavaScriptEventLoop) let response = try await JSPromise( JSObject.global.fetch!("http://numberapi.com/\(number)") .object! )!.value return try await JSPromise(response.text().object!)!.value .string! #else return try await String( decoding: URLSession.shared .data( from: URL( string: "http://www.numberapi.com/\(number)" )! ).0, as: UTF8.self ) #endif }

17:19

And then our model logic gets a lot simpler: do { try await Task.sleep(for: .seconds(1)) fact = Fact(value: try await fetchFact(number: count)) } catch { // TODO: error handling }

17:32

OK, now this is compiling, and we have quarantined our platform-specific logic into just one little function. That is certainly a lot better.

17:43

But there is still more improvements we can make to this code. The true problem with this code is that we are haphazardly sprinkling an uncontrolled dependency into our feature code. That makes it difficult for us to run this feature in previews and in tests because we are susceptible to the vagaries of the outside world in order to execute the feature’s logic.

18:06

Our internet may be slow or we may not even have internet right now. Or the API server we are reaching out to could be down. Or we could just want to test a flow where a very specific response comes back from the server. This isn’t possible because we are just making the request right in the feature code.

18:22

This is why we like to take control over our dependencies rather than letting them control us. It takes only a little bit of upfront work, but that work pays dividends over and over as you build your features.

18:33

And lucky for us, we even maintain a powerful dependency library , and the library is even cross platform. It works on Linux, Windows, and Wasm. So, let’s add it to our package: .package( url: "https://github.com/pointfreeco/swift-dependencies", from: "1.0.0" ),

18:50

…and add it as a dependency to our CounterFeature: .product(name: "Dependencies", package: "swift-dependencies"),

18:58

There’s also a handy, but optional, macros library that we want to depend on: .product( name: "DependenciesMacros", package: "swift-dependencies" ),

19:05

This gives us a nice tool for creating dependency clients in a few lines of code. It’s completely optional, so you don’t have to use it, but we will in this demo.

19:13

Next let’s create a new file to hold our dependency that will be responsible for making network requests.

19:22

And we can import our Dependencies library: import Dependencies

19:27

And we will design our dependency as a simple struct that holds onto a single closure. The closure takes an integer, representing the number we want to get a fact for, and it will be async and throwing, and ultimately will return a string of the fact: struct FactClient { var fetch: (Int) async throws -> String }

19:47

If you prefer to model your dependencies as protocols that is totally fine. You can do that too. You will just need to adapt the code we are about to write slightly.

19:54

Next we will apply the @DependencyClient macro to this type, and we will see a few interesting things happen: import DependenciesMacros @DependencyClient struct FactClient { var fetch: (Int) async throws -> String }

20:13

Expanding the macro we will see that it generated an initializer for us that includes all the endpoints in the client: init( fetch: @escaping (Int) async throws -> String ) { self.fetch = fetch }

20:31

Of course currently there is only one endpoint, but in the future there could be more, and they will be added to this initializer automatically. This makes it easy to construct instances of this client in a pinch, such as in tests, previews or other contexts.

20:43

It also interestingly generated an empty initializer: init() { }

20:45

How is this even possible? Our fetch closure doesn’t have a default.

20:50

Well, this is possible thanks to code that the @DependencyEndpoint macro generates: @DependencyEndpoint var fetch: (Int) async throws -> String { @storageRestrictions(initializes: _fetch) init(initialValue) { _fetch = initialValue } get { _fetch } set { _fetch = newValue } } private var _fetch: (Int) async throws -> String = { _ in IssueReporting.reportIssue( "Unimplemented: '\(Self.self).fetch'" ) throw DependenciesMacros.Unimplemented("fetch") } The @DependencyEndpoint macro swaps out the fetch property for a _fetch stored property and a non-underscored fetch computed property. This allows us to provide a default implementation for the closure, and by default it reports an issue with our IssueReporting library, and then throws an error.

21:13

The reason we like this as a default is that it allows us to construct an implementation of this client that will cause test failures when executed. This forces one to override their dependencies in tests, and helps to make sure that you do not accidentally invoke live dependencies because that can be quite dangerous. You would never want to execute network requests or track analytics events in tests.

21:32

The way this all works is by first conforming our dependency client to the TestDependencyKey protocol: extension FactClient: TestDependencyKey { static let testValue = FactClient() }

21:57

This doesn’t compile because TestDependencyKey requires that the dependency be sendable: Type ‘FactClient’ does not conform to the ‘Sendable’ protocol

22:03

We can fix this by marking the fetch endpoint as @Sendable : var fetch: @Sendable (Int) async throws -> String

22:14

This has now registered our dependency with the Dependencies library by allowing us to describing which implementation of the client is used when running tests. And because we are using the empty initializer, it means that if fetch is ever invoked in a test it will cause a test failure, unless we override the dependency.

22:31

But we of course do not want to use this dependency when running our app in the browser or on an iOS device. We want to use a version of the dependency that actually performs a real network request. To do this we need to further conform to the DependencyKey protocol and provide a liveValue : extension FactClient: DependencyKey { static let liveValue = FactClient { number in } } This is the version of the dependency that will be used in real life situations.

22:58

And it’s in this live implementation that is is most appropriate to perform that platform-specific networking logic we had over in the model: extension FactClient: DependencyKey { static let liveValue = FactClient { number in #if canImport(JavaScriptKit) && canImport(JavaScriptEventLoop) let response = try await JSPromise( JSObject.global.fetch!("http://numberapi.com/\(number)") .object! )!.value return try await JSPromise(response.text().object!)!.value .string! #else return try await String( decoding: URLSession.shared .data( from: URL( string: "http://www.numberapi.com/\(number)" )! ).0, as: UTF8.self ) #endif } } And we will need to import JavaScriptKit or Foundation depending on the platform we are compiling for: import Foundation #if canImport(JavaScriptEventLoop) import JavaScriptEventLoop #endif #if canImport(JavaScriptKit) @preconcurrency import JavaScriptKit #endif

23:25

This dependency is now compiling, and ready to use in our model.

23:30

We will start by importing Dependencies into the CounterModel.swift file: import Dependencies

23:34

And then we will add the dependency to our model: class CounterModel { @Dependency(FactClient.self) var factClient … }

23:48

However, because @Dependency is a property wrapper, and property wrappers don’t play nicely with the @Perceptible macro, we need to ignore it: class CounterModel { @PerceptionIgnored @Dependency(FactClient.self) var factClient … }

23:58

It’s not really a big deal to ignore observation of this field since it doesn’t contain any observable state anyway.

24:04

Next we can greatly simplify our factButtonTapped function by invoking our dependency, rather than trying to figure out how we want to perform the network request: func factButtonTapped() async { self.fact = nil self.factIsLoading = true defer { self.factIsLoading = false } do { fact = try await Fact(value: factClient.fetch(count)) } catch { // TODO: error handling } } Sending ‘self.count’ risks causing data races

24:17

We do get a cryptic error at the moment in the Xcode 16 beta, but this is just a Swift bug that has already been fixed on a more recent snapshot that we can’t use quite yet. Luckily there’s a workaround, which is to assign count to a local variable first: var count = count

24:40

This has greatly cleaned up our domain’s code. We no longer have to worry about platform specific concerns, like how we are going to perform a network request. We can just ask the dependency for a fact, and be completely oblivious to how the dependency does that work.

25:02

That is all it takes and our Wasm application is compiling and it works exactly as it did before. We can count to a number and request a fact. Separating interface from implementation

25:14

This is looking pretty great. We have now pushed the platform-specific code to the dependency that makes a network request, and that allows our feature code to be as simple and straightforward as possible. It can just ask for a fact for a number without worrying about any other details. And this means it will also be easy to write unit tests for this feature and those tests can be run on both Apple platforms and Wasm. Brandon

25:35

Before moving onto the next feature to recreate in our web app let’s take a moment to go above and beyond with our dependencies. Right now our Counter module needs to depend on JavaScriptKit and JavaScriptEventLoop in order to compile for Wasm. That means while working on this feature for Wasm we are needing to build these JavaScript libraries even when not running the app in the browser.

25:57

There is a better way to handle this. We can not only put the dependency in its own module, so that it can be built in isolation and used by many parts of a modularized app, but we can further separate the interface and implementation of the dependency so that the only time we need to build JavaScriptKit and other Wasm-specific libraries is when actually running the web app.

26:25

Let’s show how this works.

26:29

If we look at the Package.swift file we will see that our Counter module is directly depending on JavaScriptKit: .target( name: "CounterFeature", dependencies: [ .product( name: "Dependencies", package: "swift-dependencies" ), .product( name: "DependenciesMacros", package: "swift-dependencies" ), .product( name: "JavaScriptEventLoop", package: "JavaScriptKit", condition: .when(platforms: [.wasi]) ), .product( name: "JavaScriptKit", package: "JavaScriptKit", condition: .when(platforms: [.wasi]) ), .product(name: "Perception", package: "swift-perception"), .product( name: "SwiftNavigation", package: "swiftui-navigation" ) ] )

26:40

And the only reason for this is because the FactClient dependency is directly in this module.

26:45

Let’s start by extracting it out to its own module. We can create a new library: .library(name: "FactClient", targets: ["FactClient"]),

26:55

…and then a new target in the package file for the FactClient , and it just needs to depend on the Dependencies libraries and the Wasm libraries: .target( name: "FactClient", dependencies: [ .product( name: "Dependencies", package: "swift-dependencies" ), .product( name: "DependenciesMacros", package: "swift-dependencies" ), .product( name: "JavaScriptEventLoop", package: "JavaScriptKit", condition: .when(platforms: [.wasi]) ), .product( name: "JavaScriptKit", package: "JavaScriptKit", condition: .when(platforms: [.wasi]) ), ] )

27:15

And now the Counter target can depend just on the FactClient instead of directly depending on those other modules: .target( name: "CounterFeature", dependencies: [ "FactClient", .product(name: "Perception", package: "swift-perception"), .product( name: "SwiftNavigation", package: "swiftui-navigation" ) ] ),

27:25

And any other module that wants access to the FactClient can depend on this module just like we are doing here.

27:32

Next we will create a directory called FactClient …

27:38

…and we will drag-and-drop the FactClient.swift file in that directory.

27:45

With that done the FactClient module is compiling, but we will need to make everything public so that it is accessible from other modules.

28:14

And next to get the Counter module compiling we need to import the FactClient module: import FactClient

28:26

And now this module is compiling. And in fact the entire Wasm app is compiling, and it would work exactly as it did before. But we have now moved the dependency to its own module so that other modules can make use of it if they want.

28:44

However, besides that one small improvement, for the most part we have just moved code around. The Counter module is still depending on the Wasm libraries, it’s just not indirectly through the FactClient module.

28:59

To further disentangle this dependency we need to separate the interface of the FactClient from its implementation. If we look at how the FactClient is used in the CounterModel we will see that we first declare our dependence on the client using the @Dependency property wrapper: @Dependency(FactClient.self) var factClient

29:19

And then we use the client to fetch a fact about the count: let fact = try await factClient.fetch(count)

29:25

And that is all.

29:26

None of this requires knowledge of the implementation of the FactClient . All we need to know in the Counter module is that there is a type called FactClient , that it is registered with the dependency system, and that it has an endpoint called fetch that can be invoked to get a fact.

29:46

But all of the details of performing network requests via URLSession or JSPromise are not important at all in this file. This means we can separate the FactClient module into 2 modules: one that only holds the stuff that is publicly needed in the Counter module, and then another that holds all of the implementation details of the client.

30:06

We will let the existing FactClient module be the interface, and we will create a new module that holds the live implementation, called FactClientLive, and this target will depend on the FactClient interface module, and this is where it will also be appropriate to depend on the JavaScriptKit libraries: .target( name: "FactClientLive", dependencies: [ "FactClient", .product( name: "JavaScriptEventLoop", package: "JavaScriptKit", condition: .when(platforms: [.wasi]) ), .product( name: "JavaScriptKit", package: "JavaScriptKit", condition: .when(platforms: [.wasi]) ), ] )

30:43

And then back over in the FactClient target we can remove those JavaScriptKit libraries, and only depend on our Dependencies library: .target( name: "FactClient", dependencies: [ .product( name: "Dependencies", package: "swift-dependencies" ), .product( name: "DependenciesMacros", package: "swift-dependencies" ), ] ),

30:53

Next we will create a directory called FactClientLive in the package.

31:00

And we will cut-and-paste the implementation details of the FactClient over to this file: import Dependencies import FactClient #if canImport(JavaScriptEventLoop) import JavaScriptEventLoop #endif #if canImport(JavaScriptKit) @preconcurrency import JavaScriptKit #endif extension FactClient: DependencyKey { public static let liveValue = FactClient { number in #if canImport(JavaScriptKit) && canImport(JavaScriptEventLoop) let response = try await JSPromise( JSObject.global.fetch!("http://numberapi.com/\(number)") .object! )!.value return try await JSPromise(response.text().object!)!.value .string! #else return try await String( decoding: URLSession.shared .data( from: URL( string: "http://www.numberapi.com/\(number)" )! ).0, as: UTF8.self ) #endif } }

31:30

So now all this module is responsible for is conforming to the DependencyKey protocol, which is what will provide the main app with the implementation that can make an actual network request.

31:49

We do have a strange warning though: Extension declares a conformance of imported type ‘FactClient’ to imported protocol ‘DependencyKey’; this will not behave correctly if the owners of ‘FactClient’ introduce this conformance in the future

31:58

This is actually just a bug in the Swift compiler and will be fixed soon.

32:02

In case you didn’t already know, in Swift 6 it will be an error to conform 3rd party types to 3rd party protocols. We don’t have to go into all the reasons for this, but it is a good change, and if you want to force such a conformance you must explicitly annotate the conformance with @retroactive .

32:29

However, while the protocol is “3rd party” in the sense that it is coming from an external package, the type is not 3rd party. It is in the same package as this module, but just in a different module. That situation is specifically allowed for retroactive conformances, but is currently buggy in Swift. So, we will ignore this warning since it will go away in a near future Swift.

32:58

The FactClientLive module is now compiling, which means we can massively slim down the FactClient module to hold only the basics of the interface of the dependency, as well as its conformance to the TestDependencyKey protocol: import Dependencies import DependenciesMacros @DependencyClient public struct FactClient: Sendable { public var fetch: @Sendable (Int) async throws -> String } extension FactClient: TestDependencyKey { public static let testValue = FactClient() }

34:13

That completes the separation of the interface from the implementation for our FactClient , and now when we work on the Counter module in isolation we will not need to compile the JavaScript libraries at all.

34:45

However, if we run the app in the browser we will find that no fact is loaded. But luckily for us there is a very helpful error message in the console: CounterFeature/CounterModel.swift:11 – @Dependency(FactClient.self) has no live implementation, but was accessed from a live context. Location: CounterFeature/CounterModel.swift:11 Dependency: FactClient To fix you can do one of two things: • Conform ‘FactClient’ to the ‘DependencyKey’ protocol by providing a live implementation of your dependency, and make sure that the conformance is linked with this current application. • Override the implementation of ‘FactClient’ using ‘withDependencies’. This is typically done at the entry point of your application, but can be done later too.

35:46

And the first bullet point is exactly what the problem is: Note Conform ‘FactClient’ to the ‘DependencyKey’ protocol by providing a live implementation of your dependency, and make sure that the conformance is linked with this current application.

36:00

This is the exact same kind of message that appears as a purple runtime warning in Xcode when you run into this problem.

36:12

We are getting this message because we are accessing a test dependency in a live context. In particular, the testValue has been used when loading the fact, and that is why a fact did not appear, even though we are running in a non-testing context. The live implementation, meaning the one given by DependencyKey , is in the FactClientLive module, but we haven’t linked that module to our executable target. This means our dependency management library can’t find the live implementation, which is a big problem, and hence the error.

36:24

A stack trace is even provided with this error message, but unfortunately the it is just a list of mangled symbols and so it’s a bit hard to read. There is some experimental support for debug symbols in Chrome, but we aren’t going to worry about any of that right now.

36:52

The fix is to just link the FactClientLive module with the executable: .executableTarget( name: "WasmApp", dependencies: [ "CounterFeature", "FactClientLive", .product( name: "JavaScriptEventLoop", package: "JavaScriptKit" ), .product(name: "JavaScriptKit", package: "JavaScriptKit"), .product( name: "SwiftNavigation", package: "swift-navigation" ), ] )

37:42

That’s all it takes, and now when we fetch a fact it works as we expect, and no messages in the console.

37:57

So, that’s great, but before moving on we want to show that it’s even possible to customize how issues are reported in this situation. If you didn’t know already, we recently open sourced a library called Issue Reporting that unifies all the ways one can report issues in an app, such as with logging, preconditions, runtime warnings, breakpoints, and more. And all of our libraries use this library to report their issues, and so when using our libraries you will have a very consistent way to see issues being reported.

38:32

You can create a custom conformance to the IssueReporter protocol, and do whatever you want with the issue. You could send to an os_log , or send it to your backend server, or anything really.

38:44

For example, if we thought the red error in the console was a bit too intense, and would rather show a yellow warning, we could make our own custom issue reporter that uses the console.warn JavaScript function: struct JavaScriptConsoleWarning: IssueReporter { func reportIssue( _ message: @autoclosure () -> String?, fileID: StaticString, filePath: StaticString, line: UInt, column: UInt ) { _ = JSObject.global.console.warn( """ \(fileID):\(line) – \(message() ?? "") """ ) } } Note that the reportIssue method even gets access to the file and line of where the issue was reported, which can be helpful in tracking it down.

39:47

However, it may not be the best idea to send file names and lines to the browser, so we could also omit this information in release builds: #if DEBUG _ = JSObject.global.console.warn( """ \(fileID):\(line) – \(message() ?? "") """ ) #endif

40:03

Further, at the top of the entry point we can override the issue reporters used in the app: IssueReporters.current = [JavaScriptConsoleWarning()]

40:26

Now if we go back to not linking the FactClientLive module…

40:33

…when an issue is reported in the console we get something a little nicer: CounterFeature/CounterModel.swift:11 – @Dependency(FactClient.self) has no live implementation, but was accessed from a live context. Location: CounterFeature/CounterModel.swift:11 Dependency: FactClient To fix you can do one of two things: • Conform ‘FactClient’ to the ‘DependencyKey’ protocol by providing a live implementation of your dependency, and make sure that the conformance is linked with this current application. • Override the implementation of ‘FactClient’ using ‘withDependencies’. This is typically done at the entry point of your application, but can be done later too.

40:44

It’s pretty incredible to see how all of our Swift tooling works for the web, and we can even build web apps using the same patterns and techniques that we like to use for our iOS apps. It’s all thanks to the work we have put in to make sure that our libraries mostly only use pure Swift and build for all of Swift’s platforms. Next time: Navigation

41:19

So this is pretty incredible. The mere fact that we wanted our feature’s model to be cross platform friendly led us to improving the overall code the model. Rather than performing network requests directly in the model we have properly extracted that behavior out to a dependency that can be injected into the model. Stephen

41:50

And we now have a truly cross platform model that powers both the logic and behavior of our feature, and we are using it in a SwiftUI app, a UIKit app, and a Wasm app.

42:00

But let’s push things even further. So far we haven’t performed any navigation in the web app, but previously on iOS we presented the number fact in an alert and sheet in order to demonstrate state-driven navigation.

42:10

Let’s see what that looks like in Wasm…next time! References Swift for WebAssembly - How To Use Swift In Your Web App Steven Van Impe A talk from SwiftCraft 2024: WebAssembly is a rapidly growing technology that provides great opportunities for Swift developers. This talk will introduce Swift developers to WebAssembly, and demonstrate how they can run Swift in the browser, call JavaScript from Swift to access the DOM, add Swift modules to web apps, and so much more. A live demo will show how a single Swift codebase can power not just an iOS app, but also a web app, and a back-end. https://www.youtube.com/watch?v=q0OdHVfz7r0 Batteries Not Included: Beyond Xcode Kabir Oberai A talk from Swift TO 2023: A confrontation of the notion that Xcode+macOS are the only way to develop apps for Apple platforms. We’ll discover what it takes to build apps in other IDEs like Visual Studio Code, as well as on non-Apple platforms, unveiling the secrets of cross-compilation to build, sign, and deploy iOS apps on Windows and Linux. https://www.youtube.com/watch?v=fQ9uU9RHnRM Swift WebAssembly + GoodNotes, a cross-platform story! Pedro Gómez • Nov 19, 2022 A talk from NSSpain 2022 discussing how Goodnotes uses Swift Wasm in their application: When a company implements 100% of the codebase in a language like Swift, there is a chance that in the future you may need to implement something that is not an iOS app with the same code, something like a web page maybe? This talk relates how we’ve reused most of the already implemented code in GoodNotes iOS app to create a web project we can reuse in different platforms, the technical approach we fo llowed, challenges and solutions applied. https://vimeo.com/751290710 Downloads Sample code 0292-cross-platform-pt3 Point-Free A hub for advanced Swift programming. Brought to you by Brandon Williams and Stephen Celis . Content Become a member The Point-Free Way Beta previews Gifts Videos Collections Free clips Blog More About Us Community Slack Mastodon Twitter BlueSky GitHub Contact Us Privacy Policy © 2026 Point-Free, Inc. All rights are reserved for the videos and transcripts on this site. All other content is licensed under CC BY-NC-SA 4.0 , and the underlying source code to run this site is licensed under the MIT License .