Crosswake. Bridge
(crosswake v0.2.4)
View Source
The typed control-contract seam every future native-controls pack rides on.
A LiveView that has attached the bridge (attach/1, or on_mount: Crosswake.Bridge)
can call push/3 for a route-declared capability family and receive a correlated,
typed Crosswake.Bridge.Reply in its own handle_info/2 — never stringly-typed wire
JSON. There is no configuration in which push/3 resolves to silence: no shell, an
unwired hook, and a shell refusal each deliver exactly one typed reply, collapsed onto
the single Crosswake.Shell.Denial shape at status, distinguished only at reason
(CTRL-01, CTRL-02).
Ship no availability predicate
This module deliberately does NOT ship available?/2 or connected?/1. A pre-check
invites if available?, do: push, else: fallback — a three-way branch by the back
door that reintroduces exactly the branching CTRL-02 exists to collapse. Expo shipped
exactly this footgun: its isAvailableAsync returns true on browsers where the
feature does not actually work. push/3 is the only entry point; it always resolves
to a typed reply.
Attaching
attach/1 requires :crosswake_manifest (a compiled Crosswake.Manifest.Types.Root.t())
and :crosswake_route_id to already be assigned on the socket — Crosswake never
guesses a route id. Assign both, then call attach/1 (or use on_mount: Crosswake.Bridge
after another on_mount hook has assigned them):
def mount(_params, _session, socket) do
socket =
socket
|> assign(crosswake_manifest: MyApp.manifest(), crosswake_route_id: "my-route")
|> Crosswake.Bridge.attach()
{:ok, socket}
endCalling push/3 on a socket that never attached raises Crosswake.Bridge.NotMountedError.
Correlation, exactly-once delivery, and reconnects
push/3 mints an internal correlation_id that embeds a per-mount epoch, minted fresh
every time attach/1 runs. A reply is delivered at most once: a three-layer
compare-and-delete (the server in-flight map in socket.private, the correlation id
itself, and the epoch) drops anything that fails a layer — before adopter code ever
runs — and emits a [:crosswake, :bridge, :dropped] telemetry event naming the reason
(:duplicate or :foreign_epoch) (D-23).
An in-flight ask does NOT survive a LiveView reconnect. A fresh attach/1 call mints a
new epoch and a fresh in-flight table, so a reply minted under the previous epoch is
dropped as :foreign_epoch — resurrecting it would replay a user's answer into a
LiveView that never asked for it, which is precisely the "silently wrong" failure this
project exists to prevent (D-24). The recovery path is the fallback UI rebuilt from
assigns (Phase 155's generated fallback components), not resurrecting the stale ask.
When two independent answer sources race for the same ask — a native reply and an
on-page fallback click — call Crosswake.Bridge.resolve/2 from the fallback handler.
It is an atomic compare-and-delete (safe because a LiveView is one serialized process);
whichever answer arrives first wins and the other finds nothing to resolve (D-25). Do
NOT route both answer sources into the same event name to "deduplicate" them — that
guarantees the same mutation runs twice.
Payload ceiling (forward-compatibility note, D-29)
Both native shells type the bridge payload as a string-to-string map ([String: String]
on iOS, Map<String, String> on Android). A future control whose payload does not fit
that shape (e.g. a structured list, as Phase 156's menu actions: will need) requires a
wire-only Crosswake.Bridge.Contract @version bump — not an adopter-API break, because
the capability handshake already routes an old native to the :unavailable_capability
denial, and that future phase is native-rebuild-required regardless. This module does not
solve that here; it is recorded so a future maintainer is not surprised by it.
Summary
Functions
Attaches the bridge to a mounted LiveView socket.
Returns the wire envelope push/3 actually built for ref, as a plain map — the
same map that was pushed to the hook — or nil when no ask carrying that ref is
in flight.
on_mount callback delegating to attach/1, for live_session ..., on_mount: Crosswake.Bridge.
Dispatches a bounded capability to the native shell and arms the correlation +
wiring-deadline machinery. Returns the socket immediately (chainable, mirroring
Phoenix.LiveView.stream_insert/3's "a chainable socket-to-socket function may
raise" precedent) — the reply always arrives later via handle_info/2 as
{:crosswake_bridge, ref, %Crosswake.Bridge.Reply{}}.
Atomically clears the in-flight ask matching ref, returning the socket.
Functions
@spec attach(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t()
Attaches the bridge to a mounted LiveView socket.
Requires :crosswake_manifest and :crosswake_route_id to already be assigned.
Registers the reserved-event interceptor (handle_event) and the server-armed
wiring-deadline interceptor (handle_info) — both halt on Crosswake's own reserved
messages and {:cont, socket} on everything else, so unrelated events and messages
reach the LiveView's own callbacks unchanged.
Safe to call more than once on the same socket (e.g. a fresh mount/3 after a
reconnect): any previously attached bridge hooks are detached first, then a fresh
epoch and in-flight table are minted (D-24) — a reply minted under the previous epoch
is dropped as :foreign_epoch rather than delivered into the newly attached state.
@spec dispatched(Phoenix.LiveView.Socket.t(), term()) :: map() | nil
Returns the wire envelope push/3 actually built for ref, as a plain map — the
same map that was pushed to the hook — or nil when no ask carrying that ref is
in flight.
Read it immediately after push/3 when you need to render evidence of what was
dispatched (the showcase's AdminPilot approval panel does exactly this):
socket = Crosswake.Bridge.push(socket, "haptics", ref: :tap, payload: %{"style" => "light"})
assign(socket, dispatch: Crosswake.Bridge.dispatched(socket, :tap))This exists so an adopter never hand-assembles a second copy of the envelope to display. A hand-copied summary is free to drift from the manifest the seam actually resolved against — which is the exact failure the typed seam exists to remove — so the envelope is read back from the seam rather than re-declared.
This is NOT an availability predicate (D-09). It reports what THIS LiveView asked
for and has not yet resolved; it says nothing about whether a shell is present,
and branching on it cannot skip the reply, because push/3 always resolves to
exactly one typed reply regardless of what this returns.
@spec on_mount(atom(), map(), map(), Phoenix.LiveView.Socket.t()) :: {:cont, Phoenix.LiveView.Socket.t()}
on_mount callback delegating to attach/1, for live_session ..., on_mount: Crosswake.Bridge.
Must run AFTER whatever on_mount hook assigns :crosswake_manifest and
:crosswake_route_id — on_mount hooks in a live_session run in declared order.
@spec push(Phoenix.LiveView.Socket.t(), String.t(), keyword()) :: Phoenix.LiveView.Socket.t()
Dispatches a bounded capability to the native shell and arms the correlation +
wiring-deadline machinery. Returns the socket immediately (chainable, mirroring
Phoenix.LiveView.stream_insert/3's "a chainable socket-to-socket function may
raise" precedent) — the reply always arrives later via handle_info/2 as
{:crosswake_bridge, ref, %Crosswake.Bridge.Reply{}}.
Options
:ref— an opaque routing handle echoed back on delivery. Omit it for a fire-and-forget push (e.g. haptics) — no reply is delivered tohandle_info/2in that case, matching the "no ref, no reply clause needed" shape.:payload— the command payload map. Defaults to%{}.:timeout— milliseconds before the server-side reply backstop delivers a:shell_unreachabledenial (details.failing_moment: :reply_timeout) if no reply has arrived. Defaults to10_000. Pass:infinityto opt a human-in-the-loop control out of the backstop entirely (D-22). The client-side hook timer is primary and dies with the client; this server timer is armed attimeout + 2_000ms so it never races ahead of a healthy client-side timeout.
Raises Crosswake.Bridge.NotMountedError if the socket never attached,
Crosswake.Bridge.UndeclaredCapabilityError if the route never declared
capability_family, and Crosswake.Bridge.UnknownCapabilityFamilyError if
capability_family is not in the bridge's known vocabulary at all (D-51) — all three
unconditionally, in every environment.
@spec resolve(Phoenix.LiveView.Socket.t(), term()) :: Phoenix.LiveView.Socket.t()
Atomically clears the in-flight ask matching ref, returning the socket.
Safe to call from an on-page fallback UI's click handler because a LiveView is one
serialized process — this IS the compare-and-delete (D-25). Call it once: the native
reply path is then deduped automatically, because by the time (if ever) it arrives the
ask is already gone. A second call for the same ref is a no-op — it finds nothing and
returns the socket unchanged; it never raises.
Do NOT route the native reply and the fallback click into the same event name to
"deduplicate" them — that guarantees the same mutation runs twice (D-25's rejected
API-DESIGN.md alternative). resolve/2 is the only mechanism.
Phase 155's generated fallback components ship with this call already wired in.
A socket that never called attach/1 has nothing in flight, so resolve/2 returns it
unchanged here too (D-50) — this is what makes the "it never raises" promise above
literally true rather than aspirational. Phase 155's generated fallback ships inside
adopter code that may run before any capability motivates calling attach/1, so the
first fallback click must never 500 the LiveView process.