Skip to content

JavaScript runtime portability #477

Closed

Description

@sliemeobn

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.ts and is considered API(ish)

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

Activity

  1. kateinoigakukun commented on Dec 15, 2025

    @kateinoigakukun
    Member

    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.

  2. sliemeobn commented on Dec 15, 2025

    @sliemeobn
    ContributorAuthor

    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.

  3. sliemeobn commented on Dec 15, 2025

    @sliemeobn
    ContributorAuthor

    @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.

  4. sliemeobn commented on Dec 30, 2025

    @sliemeobn
    ContributorAuthor

    @MaxDesiatov @kateinoigakukun trying to bring the topic back here ; )

    for posterity, if I may summarize from the PR discussion:


    Also, 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?

  5. kateinoigakukun commented on Dec 30, 2025

    @kateinoigakukun
    Member

    Do we have a clear picture how bridge-js will be wired up if used in multiple packages?

    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.

  6. sliemeobn commented on Jan 8, 2026

    @sliemeobn
    ContributorAuthor

    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:

    • ✅ Swift -> JS/TS bridging
    • ✅ JS/TS -> Swift bridging
    • ✅ Swift implementations
    • ❌ TS/JS implementations

    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.

  7. kateinoigakukun commented on Jan 8, 2026

    @kateinoigakukun
    Member

    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.

    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.

  8. sliemeobn commented on Jul 24, 2026

    @sliemeobn
    ContributorAuthor

    With BridgeJS in place and an amended Vite plugin that supports bundling through swift package js directly, 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.)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions