arizona_socket (arizona v0.4.0)

View Source

Bridges a WebSocket frame stream with the live process.

The server adapter's WebSocket handler creates a socket via init/4, then forwards inbound text frames to handle_in/2 and inbox messages to handle_info/2. Each call returns a result tuple that the transport translates into WebSocket frames or close codes.

Wire protocol

Inbound text frames are JSON arrays:

[~"cached_fps", FpList]                           %% client tells us which fingerprints it has
[~"navigate", #{~"path" := Path, ~"qs" := Qs}]    %% SPA navigation request
[ViewId, Event, Payload]                          %% UI event
~"0"                                              %% ping (replied with ~"1")

Outbound text frames are JSON maps with keys ~"o" (ops), ~"e" (effects), and/or ~"a" (newly proven az-* attribute names -- the connect frame carries the compile-time walk of the route's handler and layouts, any later frame only the per-socket delta; see arizona_event_attrs). ~"o" and ~"e" are arrays produced by arizona_diff and arizona_js respectively.

Exit handling

The live process is linked. Exits map to WebSocket close codes:

  • normal, shutdown, {shutdown, _other_} -- graceful close 1000. Client does NOT auto-reconnect (treats it as a deliberate end of session).
  • {shutdown, drain} -- graceful close 1001 ("going away"). Client auto-reconnects via the form-state-preserving path; new live process mounts against the new server version. A frame whose call into the live process finds it gone (a drain landing while the frame is in flight) is dropped, and the close comes from the queued exit, after any push the process sent before exiting -- a drain's effects among them.
  • Anything else (including raises in handle_in/2 and handle_info/2, among them a frame that cannot be encoded) -- crash close 4500: the view crashed after it mounted. Client remounts it in place by reconnecting.

In dev mode (arizona_reloader:enabled/0) the socket also joins the reloader topic and closes 1001 on {arizona_reloader, reload}, whatever the client: funs held in the view's state (a stream's key fun, a fun prop) point into code a second reload purges, so the client reconnects and remounts on fresh code.

A failure to mount the view closes 4501 instead, whatever the exit: the connect's mount (init/4), a reconnect's deferred resync mount, and a navigate -- each up to the frame that delivers the view. The page itself may be stale (a deploy changed the route or the assets), so the client reloads it after a jittered delay.

Summary

Functions

Tells the socket's live process its listener is draining, as {arizona_drain, Deadline} (Deadline an erlang:monotonic_time(millisecond) value): the view's handle_drain/2 runs and, by default, the socket closes 1001, which clients reconnect from. A socket built without a live process (a test fixture) has nothing to tell.

Handles an inbound text frame.

Handles inbox messages forwarded by the transport.

Creates a socket for Handler with the given Bindings and Req, and starts its live process.

Returns the live process pid backing this socket, or undefined if the socket was constructed without one (test fixtures).

Types

result()

-nominal result() ::
             {ok, socket()} |
             {reply, iodata(), socket()} |
             {reply_many, [iodata()], socket()} |
             {close, pos_integer(), binary(), socket()}.

socket()

-opaque socket()

Functions

drain(Socket, Deadline)

-spec drain(Socket, Deadline) -> ok when Socket :: socket(), Deadline :: integer().

Tells the socket's live process its listener is draining, as {arizona_drain, Deadline} (Deadline an erlang:monotonic_time(millisecond) value): the view's handle_drain/2 runs and, by default, the socket closes 1001, which clients reconnect from. A socket built without a live process (a test fixture) has nothing to tell.

handle_in(Frame, Socket)

-spec handle_in(Frame, Socket) -> result() when Frame :: binary(), Socket :: socket().

Handles an inbound text frame.

While a deferred reconnect resync is pending (fps_follow, see init/4), the FIRST frame settles it: the promised [~"cached_fps", FpList] seeds the fingerprints and the resync replies deduped; any other frame flushes the resync undeduped first, its own reply following in the same result (reply_many), so no frame ever reaches the still-unmounted live process.

Recognized payloads:

  • ~"0" -- ping, replied with ~"1"
  • [~"cached_fps", FpList] -- seeds fingerprints into the live process
  • [~"navigate", #{~"path" := Path, ~"qs" := Qs}] -- SPA navigation (replace)
  • [~"patch", #{~"path" := Path, ~"qs" := Qs}] -- in-place SPA navigation
  • [ViewId, Event, Payload] -- UI event dispatch (Event a binary, Payload a map)

Unrecognized payloads, and frames whose body is not valid JSON, are silently dropped (a single bad frame must not crash the socket). An event frame whose Payload is not a map is dropped the same way, so a crafted non-map payload can't reach a #{...}-matching handler and crash it.

handle_info(Info, Socket)

-spec handle_info(Info, Socket) -> result() when Info :: term(), Socket :: socket().

Handles inbox messages forwarded by the transport.

Routes {arizona_push, ViewId, Ops, Effects} from the live process into a reply frame, its ops tagged with the view id the push names. A push the old page emitted before a navigate this socket drives never gets here: its ops are folded into the navigate's frame ahead of the replace and its effects dropped (drain_pending_pushes/1). Handles 'EXIT' from the linked live process per the mapping in this module's "Exit handling" section.

init(Handler, Bindings, Req, Opts)

-spec init(Handler, Bindings, Req, Opts) -> result()
              when Handler :: module(), Bindings :: map(), Req :: az:request(), Opts :: map().

Creates a socket for Handler with the given Bindings and Req, and starts its live process.

Opts may include:

  • reconnect -- if true, renders the page and replies with an ?OP_REPLACE op (used when the client is reconnecting after a network drop)
  • fps_follow -- if true (with reconnect), the client promised its cached_fps frame as the first frame after open, so the resync render is DEFERRED until that frame arrives and its payload dedups statics against the announced fingerprints -- the difference between re-shipping a full page's statics to every client of a deploy-drain reconnect storm and shipping roughly the dynamics. A backstop timer (?RESYNC_TIMEOUT_MS) flushes the resync undeduped for a flagged client that never sends the frame; a client without the flag (a native client, any non-announcing one) keeps the immediate resync -- zero penalty.
  • on_mount -- list of arizona_live:on_mount/0 hooks
  • layouts -- the route's arizona_render:layout/0 list, i.e. what wrapped this page at SSR. A later navigate/patch to a route whose layouts differ cannot be served over the wire and degrades to a full-page navigation

The route adapter (used by SPA navigate to resolve new routes) is recovered from Req itself via arizona_req:adapter/1.

live_pid/1

-spec live_pid(socket()) -> pid() | undefined.

Returns the live process pid backing this socket, or undefined if the socket was constructed without one (test fixtures).