JavaScriptKit icon indicating copy to clipboard operation
JavaScriptKit copied to clipboard

JavaScript runtime portability

Open sliemeobn opened this issue 2 months ago • 3 comments

I am working on a smooth vite-based "hot-reload" + build experience for an ElementaryUI starter template. I have it working well now, but the topic of how to bootstrap the javascript parts is still messy. Currently, the only way to get to the required JavaScriptKit runtime imports reliably is by building through the js plugin. I think the js command plugin is a great choice for many cases, but it has issues.

For my "web app in the browser" use case:

  • debug rebuild on file-change takes a lot longer (on my machine ~2 s vs ~0.9 s "raw" swift build - most of it probably just SwiftPM waking up)
  • the js plugin simply checks a of things that I do not need every time
  • the wasi-shim dependency is in a weird place (cdn is no good for "real" stuff, and depending on a local package that does not exist until after build is also weird)
  • I don't love that I need a matching top-level JavaScriptKit dependency (especially since we are still in 0.x.x versions)

For all these reasons I decided to base my "swift wasm" vite plugin on "raw" SwiftPM build commands. With the SwiftWasm SDKs the build is really nothing I need a plugin for (only the linker bits for exec-model=reactor and main export are a bit rough, but very doable).

I currently see two and a half issues:

  • How do I get the javascript runtime reliably?
  • How do I deal with the bridge-js imports reliably?
  • How to best deal with the WASI-shim setup (not a blocker really)

Here is my annotated WIP-version of the index.ts that would bootstrap an ElementaryUI app:

// index.ts
import { WASI, OpenFile, File, ConsoleStdout } from "@bjorn3/browser_wasi_shim";

// PROBLEM: typing bug makes this non-compatible with esbuild/rolldown
// - "export { SwiftRuntimeThreadChannel };",
// + "export type { SwiftRuntimeThreadChannel };"
import { SwiftRuntime } from "./.build/checkouts/JavaScriptKit/Runtime/src/index.ts";

// PROBLEM: these bjs imports live nowhere, only in the js plugin templates 
import { bjs } from "./javascriptkit.ts";

import appInit from "./Package.swift"; // this is the cool part that the vite plugin does

// JavaScriptKit Swift runtime
const swiftRuntime = new SwiftRuntime();

// HALF-PROLEM: do we ever need to customize this, or should we just vendor a pre-configured version of this?
// Basic WASI setup
const wasi = new WASI(
  [],
  [],
  [
    new OpenFile(new File([])),
    ConsoleStdout.lineBuffered(console.log),
    ConsoleStdout.lineBuffered(console.error),
  ],
  { debug: false }
);

// Vite wasm init import (https://vite.dev/guide/features#webassembly)
const instance = await appInit({
  javascript_kit: swiftRuntime.wasmImports,
  wasi_snapshot_preview1: wasi.wasiImport,
  bjs,
});

swiftRuntime.setInstance(instance);
wasi.initialize(instance);

// Run Swift's main entry point
swiftRuntime.main();
// javascriptkit.ts
const unexpectedBjsCall = () => {
  throw new Error("Unexpected call to BridgeJS function");
};

export const bjs = {
  swift_js_return_string: unexpectedBjsCall,
  swift_js_init_memory: unexpectedBjsCall,
  swift_js_make_js_string: unexpectedBjsCall,
  swift_js_init_memory_with_result: unexpectedBjsCall,
  swift_js_throw: unexpectedBjsCall,
  swift_js_retain: unexpectedBjsCall,
  swift_js_release: unexpectedBjsCall,
  swift_js_push_tag: unexpectedBjsCall,
  swift_js_push_int: unexpectedBjsCall,
  swift_js_push_f32: unexpectedBjsCall,
  swift_js_push_f64: unexpectedBjsCall,
  swift_js_push_string: unexpectedBjsCall,
  swift_js_pop_param_int32: unexpectedBjsCall,
  swift_js_pop_param_f32: unexpectedBjsCall,
  swift_js_pop_param_f64: unexpectedBjsCall,
  swift_js_return_optional_bool: unexpectedBjsCall,
  swift_js_return_optional_int: unexpectedBjsCall,
  swift_js_return_optional_string: unexpectedBjsCall,
  swift_js_return_optional_double: unexpectedBjsCall,
  swift_js_return_optional_float: unexpectedBjsCall,
  swift_js_return_optional_heap_object: unexpectedBjsCall,
  swift_js_return_optional_object: unexpectedBjsCall,
  swift_js_get_optional_int_presence: unexpectedBjsCall,
  swift_js_get_optional_int_value: unexpectedBjsCall,
  swift_js_get_optional_string: unexpectedBjsCall,
  swift_js_get_optional_float_presence: unexpectedBjsCall,
  swift_js_get_optional_float_value: unexpectedBjsCall,
  swift_js_get_optional_double_presence: unexpectedBjsCall,
  swift_js_get_optional_double_value: unexpectedBjsCall,
  swift_js_get_optional_heap_object_pointer: unexpectedBjsCall,
};

I hope this illustrates my point of view.

One option could be to add a command plugin to generate the javascript bits, but not entangle build + generate + wasm-opt + all-the-things in one command. However, this plugin would add maintenance burden for things that any web-bundling tool already does better and faster. So I am not convinced...

One "fix" for now would be to

  • fix the typing issues in the ts runtime code
  • add a "makeEmptyBJSExport" function or similar to the runtime so it can be used outside of the JS plugin
  • make a "promise" that the runtime stays under Runtime/src/index.ts and is considered API(ish)

I am happy to provide a PR for this, but I wanted to discuss this more broadly first.

sliemeobn avatar Dec 15 '25 08:12 sliemeobn

I still want to try to solve the root problem first before branching the universe.

debug rebuild on file-change takes a lot longer (on my machine ~2 s vs ~0.9 s "raw" swift build - most of it probably just SwiftPM waking up)

How about profiling where the extra 1s come from? Is this the overhead from PackageToJS code execution, wasm-opt, or anything else? I believe swift build and swift package js should have the same launch overhead for most of the parts, so curious how it could be slow 1s.

kateinoigakukun avatar Dec 15 '25 08:12 kateinoigakukun

My gut-feel is that SwiftPM's check if the plugin is built and up-to-date before calling into it does not help. Also, my observation is that simply having a plugin present in a top-level dependency slows things down.

I understand the desire to funnel everything to one controlled plugin that handles the building/bundling exclusively. However, I believe there should be a way to get the runtime reliably somehow in addition. No branching of universes necessary.

sliemeobn avatar Dec 15 '25 08:12 sliemeobn

@kateinoigakukun

How about profiling where the extra 1s come from?

Here are a few basic measurements on a simple one-file project. I changed on character in a string between each run.

swift package --swift-sdk swift-6.2.1-RELEASE_wasm js --product Basic

Plugin: 1.31s
Time: 0.94s user 0.30s system 52% cpu 2.350 total

Plugin: 1.31s
Time: 0.94s user 0.31s system 54% cpu 2.272 total

Plugin: 1.20s
Time: 0.94s user 0.30s system 57% cpu 2.163 total

Plugin: 1.19s
Time: 0.92s user 0.29s system 59% cpu 2.019 total

swift build --swift-sdk swift-6.2.1-RELEASE_wasm --product Basic --toolset wasm-reactor-toolset.json

Build of product 'Basic' complete! (1.09s)
Time: 0.67s user 0.23s system 59% cpu 1.509 total

Build of product 'Basic' complete! (1.08s)
Time: 0.67s user 0.22s system 61% cpu 1.469 total

Build of product 'Basic' complete! (1.06s)
Time: 0.67s user 0.22s system 60% cpu 1.446 total

About the 1s difference from the numbers above: I see this when doing no-change rebuilds, but that is not really the use case.

Build of product 'Basic' complete! (0.78s)
...
Time in plugin: 0.79 s
Time: 0.60s user 0.18s system 44% cpu 1.759 total

My interpretation:

  • SwiftPM adds ~400ms to whatever "Build of product" reports as time (raw build)
  • SwiftPM adds ~900ms to the plugin execution time (as measured in the plugin)
  • The plugin adds around 100-200ms to "raw build" time

So, in this case, going through the plugin "costs" around 700ms on each re-build (which is around 50% of the total raw-build time). Not horrible, but really not great either.

I hope you understand my desire to get as much off the hot-reload-path as possible.

sliemeobn avatar Dec 15 '25 09:12 sliemeobn