feature: Enable Electron desktop apps via Uni RPC over IPC#610
Conversation
Adds a Scala.js Electron IPC transport so a renderer (Chromium) can call
services in the main process (Node.js) using Uni's existing RPC stack,
tunneled over ipcRenderer.invoke/ipcMain.handle instead of a network socket.
Why: Uni's RPC is transport-agnostic (RPCRouter/RPCClient exchange a JSON
envelope over a pluggable HttpChannel), so a desktop app shouldn't need an
embedded HTTP server. Reusing the same wire format keeps service traits and
clients unchanged across HTTP and IPC.
Library (wvlet.uni.electron, JS-only):
- ElectronRendererChannel + ElectronChannelFactory + ElectronRenderer.install:
an HttpAsyncChannel that marshals requests through a preload bridge
(window.uniRPC.request). Renderer is async-only (no sync IPC in a sandbox).
- ElectronRPCServer.serve(ipcMain, routers*): registers ipcMain.handle and
bridges Rx[Response] to a js.Promise. Electron objects are handed in as
values (no require("electron")) to avoid Scala.js linker coupling.
- ElectronIPC: structured-clone-safe Request/Response <-> payload marshaling.
Refactor:
- Extract RPCDispatcher (shared, cross-platform) from netty's RPCHandler so
HTTP and IPC share one dispatch path; RPCHandler now delegates to it.
Fixes uncovered by the first end-to-end RPC test:
- RPCClient scalar params (String/Int/...) never serialized: JSON.parse of a
bare value fails; use JSON.parseAny.
- scalajs-java-securerandom was % Test, so downstream JS apps couldn't link
uni's main code (ULID -> SecureRandom via the Weaver chain). Now compile.
Example + docs:
- examples/electron-app: full reference (sbt Scala.js api/main/renderer,
electron-vite + vite-plugin-scalajs, preload bridge, electron-builder).
- docs/http/electron.md guide + adr/2026-06-24-electron-ipc-transport.md.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces Electron IPC transport support for Uni RPC, allowing Scala.js desktop applications to tunnel RPC calls over Electron's IPC instead of a network socket. It extracts a transport-neutral RPCDispatcher from netty's RPCHandler and adds the JS-only wvlet.uni.electron package containing the IPC wire contract, renderer channel, and main-process server. A complete example application and documentation are also provided. The review feedback highlights several robustness and correctness improvements, including handling potential crashes and hanging promises in the main-process dispatcher, preserving multi-value headers during serialization, preventing synchronous type errors on null payloads, and ensuring synchronous exceptions in the renderer channel do not bypass reactive stream error handling.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| private def dispatch(dispatcher: RPCDispatcher, payload: js.Dynamic): js.Promise[js.Object] = | ||
| val request = ElectronIPC.toRequest(payload) | ||
| new js.Promise[js.Object]((resolve, _reject) => | ||
| RxRunner.run(dispatcher.dispatch(request)) { | ||
| case OnNext(v) => | ||
| resolve(ElectronIPC.fromResponse(v.asInstanceOf[Response])) | ||
| case OnError(e) => | ||
| val errorResponse = RPCStatus.INTERNAL_ERROR_I0.newException(e.getMessage, e).toResponse | ||
| resolve(ElectronIPC.fromResponse(errorResponse)) | ||
| case OnCompletion => | ||
| // No value emitted: nothing to resolve with (should not happen for RPC). | ||
| } | ||
| ) |
There was a problem hiding this comment.
Uncaught exceptions in toRequest or RxRunner.run can crash the Node.js main process. Additionally, if the dispatcher returns an empty stream (e.g., if a service method returns Rx.empty), the promise will hang indefinitely because OnCompletion does not resolve or reject. Wrap the execution in a try-catch block inside the Promise constructor, and track whether a value was emitted to resolve with an error response if the stream completes empty.
private def dispatch(dispatcher: RPCDispatcher, payload: js.Dynamic): js.Promise[js.Object] =
new js.Promise[js.Object]((resolve, _reject) =>
try
val request = ElectronIPC.toRequest(payload)
var emitted = false
RxRunner.run(dispatcher.dispatch(request)) {
case OnNext(v) =>
emitted = true
resolve(ElectronIPC.fromResponse(v.asInstanceOf[Response]))
case OnError(e) =>
val errorResponse = RPCStatus.INTERNAL_ERROR_I0.newException(e.getMessage, e).toResponse
resolve(ElectronIPC.fromResponse(errorResponse))
case OnCompletion =>
if !emitted then
val errorResponse = RPCStatus.INTERNAL_ERROR_I0.newException("No response emitted by RPC dispatcher").toResponse
resolve(ElectronIPC.fromResponse(errorResponse))
}
catch
case e: Throwable =>
val errorResponse = RPCStatus.INTERNAL_ERROR_I0.newException(e.getMessage, e).toResponse
resolve(ElectronIPC.fromResponse(errorResponse))
)References
- To prevent uncaught exceptions from crashing the process in asynchronous environments (like Node.js), wrap the entire body of response-writing methods in a try-catch block so that failed writes are caught centrally.
- When implementing cross-platform background tasks, catch all Throwable exceptions (including fatal ones) and record them as a failure result instead of rethrowing them to ensure single-threaded platforms like Scala.js do not throw immediately.
| private def headersToJS(headers: HttpMultiMap): js.Object = | ||
| val dict = js.Dictionary.empty[String] | ||
| headers | ||
| .entries | ||
| .foreach { case (k, v) => | ||
| dict(k) = v | ||
| } | ||
| dict.asInstanceOf[js.Object] |
There was a problem hiding this comment.
Multi-value headers are currently overwritten and lost during serialization because headersToJS maps each header key to a single string value in a js.Dictionary. To preserve all values for multi-value headers, serialize them as arrays of strings.
private def headersToJS(headers: HttpMultiMap): js.Object =
val dict = js.Dictionary.empty[js.Array[String]]
headers.entries.foreach { case (k, v) =>
if !dict.contains(k) then
dict(k) = js.Array()
dict(k).push(v)
}
dict.asInstanceOf[js.Object]| private def jsToHeaders(headers: js.Any): HttpMultiMap = | ||
| if js.isUndefined(headers) || headers == null then | ||
| HttpMultiMap.empty | ||
| else | ||
| val builder = HttpMultiMap.newBuilder | ||
| val dict = headers.asInstanceOf[js.Dictionary[Any]] | ||
| dict.foreach { case (k, v) => | ||
| builder.add(k, String.valueOf(v)) | ||
| } | ||
| builder.result() |
There was a problem hiding this comment.
To support deserializing multi-value headers that are serialized as arrays of strings, update jsToHeaders to check if the header value is an array and add each item individually.
private def jsToHeaders(headers: js.Any): HttpMultiMap =
if js.isUndefined(headers) || headers == null then
HttpMultiMap.empty
else
val builder = HttpMultiMap.newBuilder
val dict = headers.asInstanceOf[js.Dictionary[js.Any]]
dict.foreach { case (k, v) =>
if js.Array.isArray(v) then
v.asInstanceOf[js.Array[js.Any]].foreach { item =>
builder.add(k, String.valueOf(item))
}
else
builder.add(k, String.valueOf(v))
}
builder.result()| def toRequest(payload: js.Dynamic): Request = | ||
| val method = HttpMethod.of(asString(payload.method)).getOrElse(HttpMethod.POST) | ||
| val uri = asString(payload.uri) | ||
| val body = asString(payload.body) | ||
| val base = Request(method, uri).withHeaders(jsToHeaders(payload.headers)) | ||
| if body.isEmpty then | ||
| base | ||
| else | ||
| base.withJsonContent(body) |
There was a problem hiding this comment.
If payload is null or undefined, calling payload.method or payload.uri will throw a JS TypeError synchronously. Add a null/undefined check to make toRequest robust.
def toRequest(payload: js.Dynamic): Request =
if js.isUndefined(payload) || payload == null then
Request(HttpMethod.POST, "")
else
val method = HttpMethod.of(asString(payload.method)).getOrElse(HttpMethod.POST)
val uri = asString(payload.uri)
val body = asString(payload.body)
val base = Request(method, uri).withHeaders(jsToHeaders(payload.headers))
if body.isEmpty then
base
else
base.withJsonContent(body)| override def send(request: HttpRequest, config: HttpClientConfig): Rx[HttpResponse] = | ||
| val payload = ElectronIPC.toPayload(request) | ||
| val promise = bridge.applyDynamic("request")(payload).asInstanceOf[js.Promise[js.Dynamic]] | ||
| val future = promise.toFuture.map(ElectronIPC.toResponse) | ||
| Rx.future(future) |
There was a problem hiding this comment.
If bridge is null/undefined or calling request throws a synchronous exception, it will bypass the reactive stream's error handling and crash the caller. Wrap the call in a try-catch block and return a failed Rx to ensure idiomatic error propagation.
override def send(request: HttpRequest, config: HttpClientConfig): Rx[HttpResponse] =
try
val payload = ElectronIPC.toPayload(request)
val promise = bridge.applyDynamic("request")(payload).asInstanceOf[js.Promise[js.Dynamic]]
val future = promise.toFuture.map(ElectronIPC.toResponse)
Rx.future(future)
catch
case e: Throwable =>
Rx.future(scala.concurrent.Future.failed(e))References
- When implementing cross-platform background tasks, catch all Throwable exceptions (including fatal ones) and record them as a failure result instead of rethrowing them.
| override def sendStreaming(request: HttpRequest, config: HttpClientConfig): Rx[Array[Byte]] = | ||
| throw NotImplementedError( | ||
| "Streaming responses are not supported over Electron IPC. Use a regular RPC call." | ||
| ) |
There was a problem hiding this comment.
Throwing NotImplementedError synchronously in sendStreaming bypasses the reactive stream's error handling. Return a failed Rx instead to allow callers to handle the error through the stream.
override def sendStreaming(request: HttpRequest, config: HttpClientConfig): Rx[Array[Byte]] =
Rx.future(scala.concurrent.Future.failed(NotImplementedError(
"Streaming responses are not supported over Electron IPC. Use a regular RPC call."
)))…end) Caught by running the reference app in real Electron: - Preload reference pointed at ../preload/index.js, but electron-vite emits an ESM preload as index.mjs under "type": "module". Fixed the path. - ESM preloads require sandbox:false (contextBridge isolation is preserved), matching electron-vite's own template. Without it window.uniRPC is undefined. - Declare pnpm.onlyBuiltDependencies (electron, esbuild) so `pnpm install` downloads the Electron binary instead of silently skipping the build script. - Commit pnpm-lock.yaml for reproducible installs. Verified headlessly against the built bundle: the counter round-trips 0 -> +10 -> 10 -> Reset -> 0, each value a live RPC call over Electron IPC. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds Tailwind CSS (v4 via @tailwindcss/vite) to the renderer and restyles the counter into a card layout: rounded panel, large indigo counter with a pop animation on update, and colored +1 / +10 / Reset buttons with hover/active states on a dark background. Tailwind scans the Scala.js sources for class names via @source directives in style.css (the className strings live in RendererApp.scala). CSP allows inline styles since Vite/Tailwind inject <style> tags. Verified: the built CSS bundle contains the utilities used by the Scala UI (bg-indigo-600, text-7xl, rounded-2xl, hover:*, scale-110, …), and the styled app launches in Electron with no errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A standalone "Setup from Scratch" tutorial under docs/http that builds the counter app from an empty directory: sbt projects, shared RPC service, main process + IPC wiring, preload bridge, renderer, electron-vite build, run, and package. Complements the existing /http/electron API reference (cross-linked both ways) and distills the verified examples/electron-app. Wired into both sidebars; uses __UNI_VERSION__ for the dependency line. Calls out the gotchas the example hit (ESM preload + sandbox:false, securerandom, async-only renderer). pnpm docs:build passes. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replaces the ASCII renderer↔preload↔main diagrams in the Electron reference, the tutorial, and the example README with Mermaid flowcharts. Wires Mermaid into VitePress via vitepress-plugin-mermaid (withMermaid wraps the config). GitHub renders the README's ```mermaid fence natively. pnpm docs:build passes; the graphs are bundled into the page chunks and render client-side. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
## Summary Follow-up to #610. The horizontal Mermaid flowcharts rendered **too small** in VitePress's width-limited content column. The renderer↔preload↔main exchange is really a **request/response sequence**, so this switches the three diagrams to `sequenceDiagram`: - Lays out **vertically** → fits the narrow content column (docs have plenty of vertical space). - Reads top-to-bottom in call order, making the IPC round-trip explicit (request → `ipcRenderer.invoke` → dispatch → response → Promise). No site-wide changes (no column widening, no zoom plugin) — the fix is just the right diagram type for the content. Applied to: - `docs/http/electron.md` (reference) - `docs/http/electron-tutorial.md` (tutorial) - `examples/electron-app/README.md` (GitHub renders it natively) ## Verification `pnpm docs:build` passes; `sequenceDiagram` and the (URL-encoded) message text are bundled into the page chunks for client-side rendering. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Summary
Enables Uni to build Electron desktop apps: a renderer (Chromium) calls services running in the main process (Node.js) using Uni's existing RPC stack, tunneled over Electron IPC (
ipcRenderer.invoke/ipcMain.handle) instead of a network socket. Reusing the RPC wire format means service traits and clients are unchanged across HTTP and IPC.Library —
wvlet.uni.electron(Scala.js only)ElectronRendererChannel+ElectronChannelFactory+ElectronRenderer.install()— renderer-sideHttpAsyncChannelthat marshals each request through a preload bridge (window.uniRPC.request). Async-only (a sandboxed renderer has no synchronous IPC).ElectronRPCServer.serve(ipcMain, routers*)— main-side: registersipcMain.handle("uni-rpc", …), dispatches viaRPCDispatcher, bridgesRx[Response]→js.Promise. Electron objects are handed in as values (norequire("electron")) to avoid Scala.js linker coupling.ElectronIPC— structured-clone-safeRequest/Response↔ payload marshaling.Refactor
RPCDispatcher(shared, cross-platform) from netty'sRPCHandlerso HTTP and IPC share one dispatch path.RPCHandlernow delegates to it.Bug fixes (surfaced by the first end-to-end RPC test)
RPCClientscalar params never serialized —JSON.parseof a bare value ("world",42) fails; switched toJSON.parseAny. No runtime RPC test existed before, so this was latent.scalajs-java-securerandomwas% Test— downstream JS apps couldn't link uni's main code (ULID →SecureRandomvia the Weaver chain). Now acompiledep, so desktop apps link out of the box.Example + docs
examples/electron-app/— full reference: Scala.jsapi/main/renderer,electron-vite+@scala-js/vite-plugin-scalajspipeline, preload bridge,electron-builderpackaging.docs/http/electron.mdguide (wired into both sidebars) +adr/2026-06-24-electron-ipc-transport.md.Verification
ElectronRPCTest: round-trips sync + async RPC over a fake IPC bridge — 5/5.wireMainProcess/mainexports.scalafmtCheckAllclean; JVM/JS/Native compile;pnpm docs:buildsucceeds.🤖 Generated with Claude Code