Repository navigation
JavaScript runtime portability #477
Description
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 lot 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.tsand is considered API(ish)
I am happy to provide a PR for this, but I wanted to discuss this more broadly first.
Activity
kateinoigakukun commentedon Dec 15, 2025
I still want to try to solve the root problem first before branching the universe.
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 buildandswift package jsshould have the same launch overhead for most of the parts, so curious how it could be slow 1s.sliemeobn commentedon Dec 15, 2025
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 commentedon Dec 15, 2025
@kateinoigakukun
Here are a few basic measurements on a simple one-file project.
I changed on character in a string between each run.
About the 1s difference from the numbers above:
I see this when doing no-change rebuilds, but that is not really the use case.
My interpretation:
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 commentedon Dec 30, 2025
@MaxDesiatov @kateinoigakukun trying to bring the topic back here ; )
for posterity, if I may summarize from the PR discussion:
swift-buildAlso, to me at least, the build time issue is only part of the puzzle (as described above).
In addition to the points above, I would like to add a question:
Do we have a clear picture how bridge-js will be wired up if used in multiple packages?
IE: If JavaScriptKit has imports/exports, and maybe ElementaryUI has a few, and maybe an third package (say, an ElementaryRouter module), and the final app as well... Is there a plan how to make sure all the right bits end up in the right place?
kateinoigakukun commentedon Dec 30, 2025
Yes, sure. BridgeJSLink is responsible for wiring up imports/exports from all modules. You can find the data flow overview here. To wire up with all dependencies properly, we need to inspect build/source directories of dependency packages to find (pre-)generated skeleton files. Especially for inspecting dependency sources, we need to use package manifest's evaluation result, so SwiftPM Plugin's API is essential here.
sliemeobn commentedon Jan 8, 2026
I have to admit my thinking is not yet 100% clear, but my question was aimed at something slightly different - and I think I can now explain what.
from a high-level perspective, iiuc, what is currently "handled" for package dependencies is this:
ie: if your module wants to ship swift code + js code, all you get is swift and the interface - you have to "deliver" the js code yourself somehow (please correct me if I am wrong). and in the top-level executable you somehow need to know where to get all the JS from.
I think often (if not mostly, or always?) if you develop a Swift library that defines "bridged" APIs to JS, that is because you want to call into JS code - and you know exactly what the code should be, you write it as part of your module.
the JavaScriptKit runtime is special-cased by the JS plugin, but I think we should un-special-case it and have a system for delivering not just the interfaces but the js implementations from any module as well.
kateinoigakukun commentedon Jan 8, 2026
This idea itself makes a lot of sense to me, we should have a way to allow library authors to collocate JS implementations along with Swift code.
fix: use `export type` for SwiftRuntimeThreadChannel re-export
sliemeobn commentedon Jul 24, 2026
With BridgeJS in place and an amended Vite plugin that supports bundling through
swift package jsdirectly, I think this issue is no longer relevant.also the topic of "shipping JS snippets" is potentially addressed with #792.
I think it's worth pursuing a future where the "BridgeJS link + js codegen" is
a) better at stripping out unused interop decls (in both the final wasm and the glue code)
b) more portable and less entangled in SwiftPM command plugin mumbo-jumbo (eg: so it can be repackaged or called from another command plugin in a dependent)
these topics are better served in their own issues I think.
(@kateinoigakukun @MaxDesiatov let me know if you disagree and want to reopen this.)