RFC: A generated shell-side Core
This RFC is proposed. It builds on the
per-operation types RFC and is implemented in the
same pull request as this text, on top of that RFC's implementation stack, so
that reviewers can read real code alongside the proposal. If accepted, it would
ship in the same crux_core release as per-operation types. The
type generation chapter describes the
emitted API, and the shell chapters in Part II show the migrated examples.
This RFC proposes that type generation emit the whole shell-side core loop —
serialise an event, cross the FFI, deserialise the requests, hand each to the
effect handler, resolve with what comes back, and repeat — as a Core class in
Swift, Kotlin, TypeScript and C#, talking to the Rust core through a small,
generated, byte-level CoreBridge protocol. When the codegen is told where
BoltFFI put its bindings, type generation implements that protocol too, and
the shell constructs Core from nothing but its effect handler; otherwise the
shell satisfies it with a handful of lines around the bindings.
Summary
The per-operation types RFC gave type generation two facts it did not have
before: how many times each effect is resolved, and with what type. From those
it emits an EffectHandler interface and an EffectDispatcher, so the shell
no longer calls resolve itself. That removed the part of the shell glue that
was easy to get wrong. What is left is a loop that is the same in every shell,
in every language, in every Crux app:
- serialise the
Eventwith bincode and pass the bytes to the core'supdate; - deserialise the
Requeststhat come back; - for each request, if it is
Render, re-readview()and publish it; otherwise hand it to the dispatcher; - when the dispatcher resolves a request, pass the bytes to the core's
resolve, and go back to step 2 with what it returns.
Type generation knows every type in that loop. It knows the Event and the
ViewModel because register_app registers them; it knows Request and
Requests because the effect macro registers them; it knows which variant is
Render because the operation type is crux_core::render::RenderOperation.
So it can emit the loop, and this RFC has it do so.
Why?
The residue after the handler API
Take the weather example's Swift shell as it stood after adopting
EffectHandler. Its glue was four files: core.swift (72 lines), which held
the loop, the dispatcher and the render arm; bridge.swift (26), a
hand-written CoreBridge protocol plus a FakeBridge for previews;
handler.swift (23), a render implementation whose body was "re-read the
view"; and LiveBridge.swift (50), the bincode plumbing around CoreFfi. Of
those 171 lines, roughly 140 say nothing about the weather app. They would be
the same, character for character, in any other Crux app's Swift shell, and
they were: the counter and counter-http examples carried their own copies.
The Kotlin shell had one 171-line Core.kt doing all of the above, of which
the app-specific part was eleven one-line delegations. Every TypeScript shell
in the repository — six of them — carried a byte-identical deserializeRequests
that hand-rolled the length-prefixed loop the generated Requests class
already implements.
Hand-rolled glue gets things wrong quietly
LiveBridge.swift guarded its deserialisation with if bytes.count < 8 { return [] }. A bincode Requests is never shorter than its eight-byte length
prefix, so the guard never fired, but nothing said so, and a reader had to work
out whether it was load-bearing. The old counter-http Swift shell resolved an
SSE stream in a 42-line block that was duplicated, verbatim, for the terminal
event. None of this is hard; all of it is the sort of thing that is written
once, copied, and not looked at again — which is exactly the category of code a
generator should own.
render points the wrong way
With the handler API, render is the one handler method that needs the core:
it has to call view() and publish the result. So the app's handler ends up
holding a reference back to whatever owns the bridge, or the handler is that
object, as it was in weather. Either way the handler, which is supposed to be
the shell's answer to the core's requests, has to know about the core's view.
The generated Core is the natural owner of the view, and once it owns Render
the handler is purely outbound again.
It sets up the next step
Crux is
exploring sending view model
diffs over the wire rather than whole view models. Applying a diff needs
something on the shell side that holds the current view and can patch it. If
every app hand-writes the loop, every app has to own that mirror and wire up the
patching when diffs arrive. If a generated Core holds the view, the diff
plugin changes the inside of one generated method and the app-facing surface
does not move.
Goals
- Generate the loop once, in the type generation plugins that already emit the handler API, for all four languages.
- Keep type generation independent of the FFI binding generator by default. The generated code must not discover BoltFFI's output, whose package, module and class names differ per language and per project; it may be told where that output is, and then bridge to it.
- Keep the shell's remaining code purely about the platform: an
EffectHandlerfor the platform work, and nothing else when the codegen knows where the FFI bindings are — a few lines adapting them to a byte-level protocol when it does not. - Leave the door open for previews and tests that never load the Rust library.
- Hold the current
ViewModelinside the generated code, so diff-based updates can land later without changing how apps use it.
Non-goals
- Generating the Rust side of the FFI (
shared/src/ffi.rs). It is also boilerplate, but BoltFFI 0.29's binding generator reads source without expanding macros, so acrux_coremacro emitting#[boltffi::export]would be invisible to it. BoltFFI 0.30 still discovers exports this way, and exports declared in a dependency reach the bindings only for path dependencies, socrux_corecannot host the FFI class either. Worth revisiting when BoltFFI's per-invocation metadata capture (boltffi/boltffi#665) ships. - Rust shells. Leptos, Yew and Dioxus call the typed
Core<App>directly and match on theEffectenum; there is no serialisation and nothing to generate. - UI frameworks. The generated
Corepublishes the view through each language's standard observability primitive —Observationin Swift,StateFlowin Kotlin,INotifyPropertyChangedin C#, a callback in TypeScript — and stops there. Binding that to SwiftUI, Compose, MAUI or React is the shell's business. - Stream termination, which the per-operation types RFC leaves open. The
generated
Coreneither helps nor hinders it.
Design
The bridge is a protocol over bytes
The generated code needs three things from the Rust core: update(bytes) -> bytes, resolve(id, bytes) -> bytes and view() -> bytes. Type generation
emits exactly that as a protocol (CoreBridge in Swift, Kotlin and
TypeScript, ICoreBridge in C#). Something implements it around whatever
BoltFFI generated — in Swift that is three one-line methods converting Data
to [UInt8] and back; in Kotlin and TypeScript the bytes pass straight
through — and hands the instance to Core.
The protocol is the seam for two other things. A preview or a test can
implement it with canned bytes and never load the Rust library, which is what
the weather example's FakeBridge did by hand. And a shell whose Rust side
pushes effects out of band, as the middleware examples do through a
CruxShell.process_effects(bytes) callback, can feed those bytes to the same
Core through a public process(bytes).
Three closures would have done the same job with slightly less ceremony. A named protocol was chosen because the fake is a real use, and because a protocol gives the adapter a name the documentation can point at.
The adapter is generated when the codegen is told where BoltFFI is
Type generation cannot find BoltFFI's output: the Swift module, the Kotlin
package, the npm package and the C# namespace are all settings in
boltffi.toml, and the exported class name is a Rust identifier in ffi.rs.
But the shape of that output is fixed by BoltFFI, so once the codegen is given
the names — CodeGenerator::boltffi(BoltFfi::new().swift("Shared").kotlin() .typescript("shared", ..).csharp()), each language opted in on its own — the
adapter is a template with the names filled in. Type generation emits it as
FfiBridge, and gives Core a constructor that takes only the handler:
Core(handler:) in Swift, Core(handler, scope) in Kotlin,
Core.create(handler, onView) in TypeScript and Core(handler) in C#. That
is the surface a shell engineer sees: a class that wants an effect handler,
with nothing about Rust in view.
The consequences differ per language, and are the reason this is a
configuration rather than a default. In Swift, FfiBridge imports BoltFFI's
module, so the generated package declares a dependency on BoltFFI's package
and must carry a platforms: floor no lower than that package's; the floor is
set on the type generation Config, because only the app knows its
deployment target. FfiBridge is written as a companion file in the module,
so the module's main file does not import the FFI at all. In TypeScript the
wasm module initialises asynchronously and BoltFFI's initialized promise is
not in its type declarations, so Core.create is an async factory that
awaits it through a cast, once, in generated code, rather than in every shell.
The generated package.json depends on the wasm package, which means BoltFFI
has to run before type generation. In Kotlin and C# the bindings usually share
a package or namespace with the generated types and nothing more is needed.
The middleware examples are excluded on purpose: their CoreFfi::new takes a
CruxShell callback, a different constructor shape, and they keep writing
their adapter against CoreBridge. So does a fake. The protocol and the
two-argument constructor are emitted whether or not boltffi(..) is set.
Core owns the loop
Core is constructed with a bridge and an EffectHandler. On construction it
reads the initial view. update(event) serialises the event, calls the bridge,
and processes the requests. Processing walks the requests in order and hands
each to an EffectDispatcher it built with a resolve callback that calls the
bridge's resolve and processes what comes back — the recursion that a
Command with several .await points depends on, and that every hand-written
shell had to remember.
process(requests) and process(bytes) are public. The first exists so that
the shell can drive the loop from requests it obtained itself; the second is
for the middleware callback shape mentioned above, and it tolerates an empty
byte array, which is what those callbacks and the middleware update/resolve
hand over when there is nothing to do.
Core owns Render and holds the view
Type generation records, per effect variant, whether the operation type is
crux_core::render::RenderOperation. When it is, Core intercepts that
variant before dispatching: it re-reads the view from the bridge, stores it,
and notifies. The handler never sees Render, and the generated
EffectHandler gives render a default that does nothing — a protocol
extension in Swift, a default body in Kotlin and C#, an optional member in
TypeScript — so an app handler simply does not mention it.
The view is held, not merely forwarded, for the reason given above under
"It sets up the next step": whoever applies a diff needs the previous value.
Today the held value is replaced wholesale on every Render. When a diff
plugin exists it will patch the held value instead, and update, view and
the notification stay as they are.
Publication uses the observability primitive each language's UI layer
already understands, without depending on the UI framework itself. In Swift
Core is @Observable and view is its one observed property, so a SwiftUI
view that reads core.view is invalidated on every render. In Kotlin view
is a StateFlow<ViewModel>; a coroutine scope is already needed for the
suspend dispatcher, and StateFlow is what every Android consumer would
wrap a callback in anyway. In C# Core implements INotifyPropertyChanged
and raises PropertyChanged for View. TypeScript keeps an onView
callback, because a React state setter is exactly that shape. None of these
fires for the initial view; the caller reads view if it wants it.
An effect enum with no RenderOperation variant gets no Core. There is no
view loop to own in that case, and the handler and dispatcher are still
emitted for such an app to drive itself.
Concurrency, per language
- Swift:
Coreis@MainActorandfinal. The dispatcher runs request handlers inTasks and resolves from wherever they finish, so the resolve callback hops back withTask { @MainActor in ... }before touching the bridge or the view. This serialises calls into the Rust bridge on the main actor, which is what the hand-written shells did, and is cheap: the Rust side does no I/O.CoreBridgeisSendable; a wrapper around BoltFFI's non-SendableCoreFficlass declares itself@unchecked Sendable, which is sound because the RustBridgeguards its state with mutexes.Coreis@Observable, so it alone carries@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *); the protocol and dispatcher keep the lower bar the generated package has always had, so a shell with an older deployment target can still drive them itself. - Kotlin:
Coretakes aCoroutineScope. Each request is dispatched in its own coroutine, so a slow handler never blocks the requests behind it, and each resolution is processed in its own coroutine too.updateitself is synchronous. TheMutableStateFlowbehindviewis atomic and the Rust bridge is internally synchronised, so no further locking is needed. - TypeScript: single-threaded;
updateis synchronous, request handlers return promises, and the dispatcher resolves in their continuations. - C#: as TypeScript, with
Task.PropertyChangedmay be raised on a thread-pool thread after an asynchronous request completes, so a UI shell marshals to its dispatcher inside the handler.
What type generation needs to know
Two additions to the metadata the plugins already read:
EffectVariantMetagainsrender: bool, set by comparing the operation'sTypeIdwithRenderOperation's. The#[effect(facet_typegen)]macro already records every variant, so nothing changes for users.TypeRegistry::register_apprecords anAppMetawith the registry names ofApp::EventandApp::ViewModel, which it previously registered without remembering which was which. The firstregister_appwins, matching the existing rule that the handler API is emitted for the first registered effect.CodeGenerator::app()exposes it.
The plugin itself (type_generation::facet::plugins::core) is a sibling of the
handler plugin and follows it exactly: it acts on after_type for the primary
effect, so CoreBridge and Core land in the same module as Effect, after
the handler API. Writing them as a separate file was considered and rejected,
because facet-generate skips plugin runtime files when the serde runtime comes
from an external package, which would make Core silently disappear for some
configurations.
Names and escape hatches
Core, CoreBridge, ICoreBridge and FfiBridge join the reserved names;
TypeRegistry::build reports an error if a shared type claims one. A shell
that already had its own Core type — as every example did — deletes it or
refers to the generated one by module.
CodeGenerator::without_core() turns off CoreBridge and Core and leaves the
handler API; without_effect_handlers() turns off both, since Core depends on
the dispatcher. A boltffi(..) configuration with nothing to bridge — no
Core, because of either of those, no registered app, or no Render
variant — is an error at generation time, not a silent no-op.
Drawbacks
A defaulted render is a silent default. A shell that drives
EffectDispatcher itself and forgets render now compiles and never repaints,
where before it failed to compile. The default is documented as existing for
Core's benefit, and the recommended path is Core, but it is a real loss of
a compile-time check for the other path.
The Swift Core needs iOS 17 / macOS 14. @Observable arrived with those
releases. The rest of the generated module keeps its iOS 13 / macOS 10.15 bar,
so a shell below iOS 17 loses only the generated loop, not the handler API.
The Swift Core serialises all bridge calls on the main actor. For the
Rust core this is fine; a shell that wanted to call the bridge from a
background actor would write its own loop, which remains possible.
One more fixed-name surface. Three more reserved names and a shape that apps build against, which the difficient work will need to preserve.
The generated Kotlin module depends on kotlinx-coroutines-core. It
already emitted suspend functions, which need only the standard library;
StateFlow and launch need the library, so type generation now adds it to
the generated build.gradle.kts.
With boltffi(..), the generated package depends on BoltFFI's. In Swift
that is a package dependency and a platforms: floor the app has to state; in
TypeScript it is a package.json dependency that makes boltffi pack a
prerequisite of type generation. Both are the price of the shell not writing
the adapter, and both are absent when the option is not set.
Migration
A shell that has adopted the handler API is three steps away:
- Tell the codegen where BoltFFI's output is, with
boltffi(..), and give the SwiftConfigaplatforms:floor; or, for another binding generator, write the bridge adapter yourself: a type conforming toCoreBridgewhose three methods call the bindings. - Move the
EffectHandlermethods and their state off the hand-written core object into a plain handler type, and deleterender. - Delete the hand-written loop and construct the generated
Corewith the handler (and the adapter, if you wrote one). Observecore.viewthe way the platform does:@Environment(Core.self)in SwiftUI,collectAsState()in Compose,PropertyChangedin C#; in TypeScript, pass a state setter asonView.
The migration guide walks through
this for each language, and the notes and weather examples show the result.
Shells that have not adopted the handler API can still do so first; everything
here is additive, and a shell that matches on Effect by hand keeps working.
Alternatives considered
Generate direct calls into the BoltFFI bindings, always. Rejected as the only shape: the generated types package would have to import the bindings package, whose name is a project decision in every language, and the middleware examples have a different FFI shape altogether. Adopted as an opt-in: when the codegen is told the names, it emits the adapter and the one-argument constructor, and the protocol remains the seam for everything else. The first version of this RFC stopped at the protocol; the bridged constructor was added after review, when the question "why is the bridging by hand?" turned out to have configuration, not a limitation, as its answer.
Read boltffi.toml to find the names. Rejected: it would tie type
generation to BoltFFI's configuration schema, which changes between releases,
and the exported class name is not in the file anyway. Repeating three or four
names in codegen.rs is cheap and explicit.
Emit the adapter into the app target instead of the generated package.
This avoids the Swift package dependency, but puts a generated file in the
app's own source tree, and the whole point is that the shell sees only a
Core. Rejected in favour of the dependency and a platforms: floor.
Leave render to the app. A Core that treats Render as an ordinary
notification is simpler to specify, but every app then owns the view, the
handler needs a way back to the core, and the diff-based future lands in app
code rather than generated code. Rejected for the reasons in "Why?".
Emit Core as a runtime file. Cleaner in the generated tree, but skipped by
facet-generate's installers when the serde runtime is an external package.
Rejected in favour of emitting into the effect's module.
Generate ffi.rs too. Not possible with the BoltFFI the examples use, as
noted under non-goals.
Open questions
- Observable view models.
Corenow publishesviewthrough each language's standard primitive, but it replaces the value wholesale, so a SwiftUI or Compose view that reads any part of it is invalidated on every render. Finer-grained invalidation would need the view model types to be observable — generated observable classes, or per-field flows — and only pays off once diffs arrive over the wire. That belongs to the diff-based updates work below rather than to this RFC. - Diff-based updates. When the difficient plugin exists, does the
notification carry the whole view, the diff, or both? The held view means
any of the three is possible without moving
updateorview. - The middleware callback.
process(bytes)accepts whatCruxShell.process_effectshands over, but the shell still writes theCruxShellconformance, theArc<dyn CruxShell>constructor and itsCoreBridgeadapter by hand, becauseCoreFfi::new(shell)is not the constructorFfiBridgecalls. ShouldBoltFfigrow a variant for that shape, withCoresupplying the callback itself? - Stream termination is unchanged from the per-operation types RFC's open question 5.
Next steps
- Land this with the notes and weather shells migrated, so the book's Part II
shows the generated
Corein Swift, Kotlin and TypeScript. - Move the remaining examples' shells across in the breaking release, when their capabilities move to per-operation types.
- Prototype the difficient plugin against the held view.