Web App with Embedded Swift - POC demo

Hello lovely community,

I just pushed an Embedded Swift proof-of-concept Wasm app out the door that uses

It is a simplified wordle clone that clocks in at around 71 kB (compressed wasm).

Check it out here ->> Swiftle - Embedded Demo App <<- (source code)


Things with this setup are a bit rough but almost good enough, and I wanted to share a "mission status report" with a few thoughts I had along the way:

SwiftPM - Emit Empty Object File
I understand that building an embedded binary is not exactly "officially supported" right now, but things are very close to good enough, so I hope I can provide some motivation to close the few remaining gaps eventually.

This post by @rauhul describes it well

but to me the one issue that stands out is -Xfrontend -emit-empty-object-file. All the other things can be solved today by throwing a bunch of flags at swift build, but this one needs to be placed on all swift targets but the main one.

I ended up adding an environment variable-based conditional .unsafeFlags swift setting in all Package.swift files. This a) does not scale, and b) clashes with the SwiftPM rule to not allow unsafe flags from versioned package tags. At the moment all dependencies must reference branches. Not sure if there is a better work-around, but this is the biggest obstacle atm. (Also, this means that package traits would not really help here as well).

SwiftPM - Resources
IIUC SwiftPM generates a bit of Foundation-based bundle access code if you have resources defined in your package. That's nice and all, but a bit annoying if there is no Foundation. It would be nice to have an option to copy the files in a bundle folder, but skip the codegen.

SwiftPM - Conditional Target Dependencies
Maybe this is possible already, but I could not figure out how to specify a platform condition on a target dependency to mean "wasm32-none" or similar.

.executableTarget(
    name: "EmbeddedApp",
    dependencies: [
        .product(name: "ElementaryDOM", package: "ElementaryDOM"),
        .product(name: "JavaScriptKit", package: "JavaScriptKit"),
        .product(
            name: "dlmalloc", // I only need this for runtimeless wasm
            package: "swift-dlmalloc", 
            condition: .when(platforms: [.custom("what do I write here?")]))
    ]
),

Missing @_expose(wasm) symbols
Similar to the issue of cdecls not working in dependencies, I also could not get @_expose(wasm) to work without tricks. I understand that these symbols are "shaken" out if not used (which seems reasonable), so a hope was that @_used would do the trick - but that also does not work.
If I reference the exposed function (not even call it) in the main module, things work (so the "exposedness" is preserved, but it needs to be referenced somehow.)
Not sure how much of this is intended or even defined behavior, so I ask: Should an unreferenced @_used @_expose(...) function be present as a wasm export in the final binary?

Strings - Equality
Swift's String/Unicode situation has been discussed many a time before and I totally understand why things are the way they are. Long story short, I ended up adding a String.utf8Equals in basically every package and replaced all == comparisons with it.
I know, I know, but I can imagine this topic will be one of the first for many to stumble over.
So, I wonder if there is some imaginable opt-in raw UTF8 mode for Embedded to say "I am ok with UTF8-comparisons" or something like this? Not sure about sorting and other string tricks, but equating and hashing would be sweet.

SwiftPM - Linking optional extras
As I understand the unicode support stuff can be opted-in to by linking a provided .a file. Is there an easy way to add this to a SwiftPM setup in a portable way?
I did not try the unicode archive, but I failed with a quick experiment of linking wasi-libc stuff manually.

Variadic generics
Their status is documented as "not yet"... sure would be ever so sweet to have these available ; )

Observation for Embedded?
As other, deeper parts of the stdlib (like concurrency features) are ported for embedded I wonder if there are thoughts on having (at least parts of) Observation available anytime soon?


Whew, sorry for the wall of text. I hope this summary from the Embedded Wasm for Web perspective can be of some value.

20 Likes

Just want to say that this is a really cool demonstration, well done! Also, I’m still amazed at the binary sizes possible with embedded swift!

3 Likes

Impressive and exciting! And it's extremely useful to have this writeup of sharp edges that's based on actual real-world usage.

2 Likes

Very cool. I'm definitely looking for ways to improve SwiftPM for projects like this.

2 Likes

Quick update on my continued adventures into Embedded Wasm apps with Swift:

I managed to set up an Observation like system that works for Embedded Swift and integrate it with the Elementary DOM renderer. Lacking a better name I went with a @Reactive macro.

So you can define a type like this...

@Reactive
class MyData {
    var hello: String
    var there: Int
}

... and the DOM will update as you change it (just as you'd expect like using @Observable in SwiftUI). You can see it in action in the example app in the repo.


I am still (almost) successfully using SwiftPM to build it all, with a couple more road blocks I stubbed my toe on:

CLI Flags for swift build
Initially I thought utilizing CLI flags instead of settings defined in the Package.swift file was a great idea to keep the package manifests clean and compatible with "normal" as well as Embedded builds. However, once macro targets entered the chat this quickly fell apart. SwiftPM really does not like it when it gets "global" flags set for building when macro targets need to run on the host instead of the target system (has been discussed before, and is pretty understandable).

In addition to .unsafeFlags(["-Xfrontend", "-emit-empty-object-file"]) (as mentioned in my initial post) this adds -fdeclspec to the list of unsafeFlags which are blocking the portable use of SwiftPM.

cSettings: shouldBuildForEmbedded ? [.unsafeFlags(["-fdeclspec"])] : nil,

hasFeature(Embedded) for C targets
Especially the JavaScriptKit bits needed a bit of adjustment, including a C target. Originally a __Embedded macro was defined to configure the C code for an Embedded build. However, this was basically just a patch to know that the target is built in an Embedded Swift context (there is no built-in way that I know of). That alone would be quite ok, but...

.define("MY_THING") not working?
...but I was quite surprised to find that using a .define("__Embedded") as a cSetting in the Package.swift file on the C target did not work. It only worked if it was a) defined via an -Xcc -D CLI option, or b) defined on every (swift!) target up the dependency chain, including the entry-point app.
That is, if I added cSettings with the identical __Embedded define to all swift targets, it worked (app -> elementary module -> javascriptkit swift target -> javascriptkit C target). If any one of these targets did not define the same macro, I got a clang build issue as if the macro was not defined.


I have been hesitant to create github issues so far because of the experimental nature of this whole ordeal. If I should create one, please let me know. (especially the C macro topic feels very strange to me)

9 Likes

I didn't understand the why of this one — does String's == not work/exist in embedded? Or is the standard definition problematic in some way?

well, I wouldn't call it "problematic", but String does its thing in a unicode-correct way.
that implies that comparing bytes is not enough, because "equal" texts can be expressed with different sequences of bytes. (there are plenty of resources about this on the forums and all around, a fun rabbit hole ; )

obviously, the "knowledge" of all these unicode rules must be encoded somewhere, and in an embedded context that means you'll need to provide these in the binary. this is documented and absolutely possible. however, if you don't particularly care about unicode-correctness, the price (mainly binary size, also a bit of performance I would assume) is a topic.

so, by explicitly comparing the underlying utf8 sequence instead of Strings you cleanly express what you are after. but it is a bit cumbersome und unergonomic...

is there a way to call into the client’s system unicode library to perform this comparison?

1 Like

I think there is no way to call the client.

1 Like

If you squint at the "Strings" section in the embedded user manual here swift/docs/EmbeddedSwift/UserManual.md at main · swiftlang/swift · GitHub
you might see a way to get that done.

however, I am skeptical there is a way of having all these functions bridged to "the host" (thinking about WASM-land at least) that isn't cost-prohibitive in terms of performance :thinking:

actually, now that I think of it, maybe we could package up a wasm component that includes the swift stdlib unicode stuff and exports all these functions. and, IIUC, we could then use that "shared" wasm component and hook it up in the browser app through wasm imports/exports?

thoughts anyone?

1 Like

I ventured deeper into the dungeon of reactive UIs with Embedded Swift and managed to slay a few more dragons.

Specifically, I got the @State, @Binding, and @Environment machinery working:

import ElementaryDOM

extension EnvironmentValues {
    @Entry var myValue = ""
}

@View
struct ContentView {
    @State var number = 0

    var content: some View {
        div {
            ValueDisplay()
            CountUpButton(number: $number)
        }
        .environment(#Key(\.myValue), "The number is \(number)")
    }
}

@View
struct CountUpButton {
    @Binding var number: Int

    var content: some View {
        button { "Count up" }
            .onClick { _ in number += 1 }
    }
}

@View
struct ValueDisplay {
    @Environment(#Key(\.myValue)) var text

    var content: some View {
        p { "Value: \(text)" }
    }
}

This totally works as expected, with a sub-100K wasm file - no wasi, no Swift runtime. :exploding_head:
I don't know about you, but to me it is mind-boggling that complie-time generics (with a light seasoning of macros) can pull this off.


Things are starting to take shape, and feedback (especially around the SwiftPM issues mentioned upthread) would be greatly appreciated.

SwiftPM idea: --allow-unsafe-flags-in-dependencies
A major hurdle in packaging this up as a reusable library is the necessity of unsafe flags, as SwiftPM does not allow unsafe flags in versioned package dependencies. Ideally, some knight in shining armor will one day figure out "embedded build support" in SwiftPM that just works™️.

In the meantime, could we maybe add an --allow-unsafe-flags-in-dependencies CLI flag? That should be a lot easier to do, and I can't think of any major downsides. (might generally be a nice escape hatch for other, more experimental setups as well).

Embedded: KeyPaths
The limitations around key paths are understandable, but it feels to me that a tiny bit more support for keypaths would add a lot of value.

For example, this snippet from the @Binding type:

@propertyWrapper
@dynamicMemberLookup
public struct Binding<V> {
    /* ... */
    @_unavailableInEmbedded
    public subscript<P>(dynamicMember keypath: WritableKeyPath<V, P>) -> Binding<P> {
        Binding<P>(
            get: { self.wrappedValue[keyPath: keypath] },
            set: { self.wrappedValue[keyPath: keypath] = $0 }
        )
    }
}

I can almost taste it that this could be possible without having an actual runtime value for the key path, by just allowing static, known keypaths that can be optimized out somehow.

3 Likes

The major downside, and one of the reasons these options are unsafe and not allowed for general use, is that certain varieties like -B option in clang allow arbitrary code execution on developer's machine. With no appropriate toolchain sandboxing or virtualization this is not something that should be allowed outside of special use cases.

I understand the concern, but I don't see how is this would be different from things like --disable-sandbox for package plugins.

For --disable-sandbox there's no other alternative, while for unsafeFlags an escape hatch is already available: don't use dependencies constrained by version ranges.

@sliemeobn! Thanks a lot for posting this! Following your build steps allowed me to build my own little proof of concept Embedded Swift WebAssembly module that talks to the DOM via JavaScriptKit.

If anyone's interested, the code is here: GitHub - ole/Swift-WebAssembly-JavaScriptKit-playground. It doesn't add anything new over Simon’s example, but it may be helpful for people who want to start from scratch because it’s an even smaller example.

A few observations:

Binary size

A default release build (-c release) of the Wasm binary is 147 kB. With -Osize and an additional pass through wasm-opt -Os --strip-debug I can get it down to 48 kB.

Calling JavaScript functions with more than 2 arguments

Calling DOM APIs through JavaScriptKit works fine for functions that take at most 2 arguments. For example, these work fine:

let canvas = document.getElementById("canvas")
var ctx: JSValue = canvas.getContext("2d")
_ = ctx.beginPath()
_ = ctx.moveTo(80, 200)

But, as soon I as try to call a function with 3 or more arguments, I get a compiler error "cannot call value of non-function type 'JSValue'" (only when building in Embedded Swift mode):

_ = ctx.fillRect(20, 20, 200, 100)
//          ^ error: cannot call value of non-function type 'JSValue' 

My guess is this has something to with JavaScriptKit expecting arguments as (any ConvertibleToJSValue…) or something like this and missing support for existentials in Embedded Swift, but I'm not sure.

The best fix I came up with is this abomination:

// Ugly, but it works!
_ = ctx.object!.fillRect.function!(
    this: ctx.object!, 
    arguments: [20, 20, 200, 100]
)

Maybe someone who knows JavaScriptKit better can improve on this.

6 Likes